1/* 2 * Link Preloader 3 * 4 * Speculatively loads internal pages so subsequent navigations feel instant. 5 * 6 * Two modes run cooperatively: 7 * 8 * 1. Hover/focus mode (devices with a real pointer): 9 * - Chromium with Speculation Rules API -> `prerender`: fetches AND renders 10 * the destination page (subresources, scripts, DOMContentLoaded). 11 * - Other browsers -> `<link rel="prefetch">`: warms the HTML cache only. 12 * 13 * 2. Viewport mode (touch-only devices by default; opt-in on desktop): 14 * - Uses IntersectionObserver to watch internal anchors. When a link is 15 * on-screen (with a ~300px lookahead margin) for at least 16 * `viewportMinVisibleMs`, a cancellable `<link rel="prefetch">` is added. 17 * - When the link scrolls off-screen, the prefetch element is removed, 18 * cancelling any in-flight request and freeing our concurrency slot. 19 * (If the response already arrived it stays in the HTTP cache.) 20 * - Refcounted per URL so duplicate links don't double-fetch. 21 * 22 * Per-link opt-out: add `data-no-prefetch`. 23 * Global config: set `window.LinkPreloaderConfig = { ... }` before this script. 24 * 25 * Caveat: prerender executes the destination page's scripts, so pages with 26 * load-time side effects (premature analytics, logout endpoints, etc.) should 27 * opt out via `data-no-prefetch`. 28 */ 29{ 30 if (typeof window !== 'undefined' && typeof document !== 'undefined') { 31 const DEFAULTS = { 32 debounceDelay: 120, 33 maxConcurrentPrefetches: 3, 34 respectSaveData: true, 35 enableViewportPrefetch: true, 36 enableViewportOnDesktop: false, 37 viewportRootMargin: '300px 0px', 38 viewportMinVisibleMs: 400, 39 maxViewportPrefetches: 4, 40 }; 41 42 const config = { ...DEFAULTS, ...(window.LinkPreloaderConfig ?? {}) }; 43 44 const prefetched = new Set(); 45 const timers = new WeakMap(); 46 let inFlight = 0; 47 48 const isTouchOnlyDevice = () => 49 'ontouchstart' in window && !window.matchMedia('(hover: hover)').matches; 50 51 const isSaveDataConstrained = () => { 52 const { connection } = navigator; 53 if (!connection) return false; 54 return connection.saveData === true || /(^|-)2g$/.test(connection.effectiveType ?? ''); 55 }; 56 57 const supportsPrefetch = () => { 58 const { relList } = document.createElement('link'); 59 return relList && typeof relList.supports === 'function' 60 ? relList.supports('prefetch') 61 : 'prefetch' in (relList ?? {}); 62 }; 63 64 const supportsSpeculationRules = () => 65 typeof HTMLScriptElement !== 'undefined' && 66 typeof HTMLScriptElement.supports === 'function' && 67 HTMLScriptElement.supports('speculationrules'); 68 69 const canPrerender = supportsSpeculationRules(); 70 71 const shouldPrefetch = (anchor) => { 72 if (!anchor || anchor.tagName !== 'A') return false; 73 if (anchor.hasAttribute('data-no-prefetch')) return false; 74 if (anchor.target?.toLowerCase() === '_blank') return false; 75 76 const rel = (anchor.getAttribute('rel') ?? '').toLowerCase(); 77 if (rel.includes('external') || rel.includes('nofollow')) return false; 78 79 const rawHref = anchor.getAttribute('href'); 80 if (!rawHref) return false; 81 82 const trimmed = rawHref.trim(); 83 if (!trimmed || trimmed.startsWith('#')) return false; 84 85 const lower = trimmed.toLowerCase(); 86 if ( 87 lower.startsWith('mailto:') || 88 lower.startsWith('tel:') || 89 lower.startsWith('javascript:') || 90 lower.startsWith('sms:') 91 ) { 92 return false; 93 } 94 95 const resolved = anchor.href; 96 if (!resolved || !resolved.startsWith(window.location.origin)) return false; 97 98 const [current] = window.location.href.split('#'); 99 const [target] = resolved.split('#'); 100 if (target === current) return false;
101 102 return !prefetched.has(target); 103 }; 104 105 const addPrerenderRule = (url) => { 106 const script = document.createElement('script'); 107 script.type = 'speculationrules'; 108 script.textContent = JSON.stringify({ 109 prerender: [{ source: 'list', urls: [url] }], 110 }); 111 document.head.appendChild(script); 112 }; 113 114 const addPrefetchLink = (url, asHint) => { 115 const link = document.createElement('link'); 116 link.rel = 'prefetch'; 117 link.href = url; 118 if (asHint) link.as = asHint; 119 120 const release = () => { 121 inFlight = Math.max(0, inFlight - 1); 122 }; 123 124 link.addEventListener('load', release, { once: true }); 125 link.addEventListener('error', release, { once: true }); 126 document.head.appendChild(link); 127 }; 128 129 const prefetch = (url, asHint) => { 130 if (prefetched.has(url)) return; 131 if (inFlight >= config.maxConcurrentPrefetches) return; 132 133 prefetched.add(url); 134 inFlight += 1; 135 136 if (canPrerender) { 137 try { 138 addPrerenderRule(url); 139 } catch { 140 addPrefetchLink(url, asHint); 141 return; 142 } 143 // Prerender doesn't emit a load event we can listen on; release 144 // the slot shortly after so we don't starve other hovers. 145 window.setTimeout(() => { 146 inFlight = Math.max(0, inFlight - 1); 147 }, 1500); 148 return; 149 } 150 151 addPrefetchLink(url, asHint); 152 }; 153 154 const scheduleFromAnchor = (anchor) => { 155 if (!shouldPrefetch(anchor)) return; 156 if (timers.has(anchor)) return; 157 158 const timerId = window.setTimeout(() => { 159 timers.delete(anchor); 160 if (!shouldPrefetch(anchor)) return; 161 const [resolved] = anchor.href.split('#'); 162 const asHint = /\.pdf(\?|$)/i.test(resolved) ? 'document' : 'document'; 163 prefetch(resolved, asHint); 164 }, config.debounceDelay); 165 166 timers.set(anchor, timerId); 167 }; 168 169 const cancelFromAnchor = (anchor) => { 170 const timerId = timers.get(anchor); 171 if (timerId) { 172 window.clearTimeout(timerId); 173 timers.delete(anchor); 174 } 175 }; 176 177 const anchorFromEvent = (event) => event.target?.closest?.('a') ?? null; 178 179 const onPointerOver = (event) => { 180 const anchor = anchorFromEvent(event); 181 if (anchor) scheduleFromAnchor(anchor); 182 }; 183 184 const onPointerOut = (event) => { 185 const anchor = anchorFromEvent(event); 186 if (anchor) cancelFromAnchor(anchor); 187 }; 188 189 const onFocusIn = (event) => { 190 if (event.target?.tagName === 'A') scheduleFromAnchor(event.target); 191 }; 192 193 const onFocusOut = (event) => { 194 if (event.target?.tagName === 'A') cancelFromAnchor(event.target); 195 }; 196 197 // ---- Viewport mode (mobile / opt-in desktop) ---- 198 199 const viewportHandles = new Map(); // url -> { element, refCount } 200 const viewportTimers = new WeakMap(); // anchor -> timerId 201 const viewportUrls = new WeakMap(); // anchor -> url currently held open 202 let viewportInFlight = 0; 203 204 const startViewportPrefetch = (url) => { 205 const existing = viewportHandles.get(url); 206 if (existing) { 207 existing.refCount += 1; 208 return true; 209 } 210 if (viewportInFlight >= config.maxViewportPrefetches) return false; 211 if (prefetched.has(url)) { 212 // Already prefetched via hover/prerender â just track refs so a 213 // later release call balances cleanly without removing anything. 214 viewportHandles.set(url, { element: null, refCount: 1 }); 215 return true; 216 } 217 218 const link = document.createElement('link'); 219 link.rel = 'prefetch'; 220 link.href = url; 221 link.as = 'document'; 222 document.head.appendChild(link); 223 224 viewportInFlight += 1; 225 viewportHandles.set(url, { element: link, refCount: 1 }); 226 return true; 227 }; 228 229 const stopViewportPrefetch = (url) => { 230 const handle = viewportHandles.get(url); 231 if (!handle) return; 232 handle.refCount -= 1; 233 if (handle.refCount > 0) return; 234 235 if (handle.element) { 236 handle.element.remove(); 237 viewportInFlight = Math.max(0, viewportInFlight - 1); 238 } 239 viewportHandles.delete(url); 240 }; 241 242 const onIntersect = (entries) => {
243 for (const entry of entries) { 244 const anchor = entry.target; 245 246 if (entry.isIntersecting) { 247 if (viewportTimers.has(anchor)) continue; 248 if (!shouldPrefetch(anchor)) continue; 249 250 const timerId = window.setTimeout(() => { 251 viewportTimers.delete(anchor); 252 if (!shouldPrefetch(anchor)) return; 253 const [url] = anchor.href.split('#'); 254 if (startViewportPrefetch(url)) { 255 viewportUrls.set(anchor, url); 256 } 257 }, config.viewportMinVisibleMs); 258 259 viewportTimers.set(anchor, timerId); 260 } else { 261 const timerId = viewportTimers.get(anchor); 262 if (timerId) { 263 window.clearTimeout(timerId); 264 viewportTimers.delete(anchor); 265 } 266 const url = viewportUrls.get(anchor); 267 if (url) { 268 stopViewportPrefetch(url); 269 viewportUrls.delete(anchor); 270 } 271 } 272 } 273 }; 274 275 const observedAnchors = new WeakSet(); 276 let intersectionObserver = null; 277 278 const observeAnchor = (anchor) => { 279 if (!intersectionObserver) return; 280 if (!(anchor instanceof HTMLAnchorElement)) return; 281 if (observedAnchors.has(anchor)) return; 282 if (!anchor.hasAttribute('href')) return; 283 observedAnchors.add(anchor); 284 intersectionObserver.observe(anchor); 285 }; 286 287 const scanForAnchors = (root) => { 288 if (root.nodeType !== 1 && root.nodeType !== 9) return; 289 if (root instanceof HTMLAnchorElement) observeAnchor(root); 290 root.querySelectorAll?.('a[href]').forEach(observeAnchor); 291 }; 292 293 const initViewportMode = () => { 294 if (typeof IntersectionObserver === 'undefined') return; 295 if (typeof MutationObserver === 'undefined') return; 296 297 intersectionObserver = new IntersectionObserver(onIntersect, { 298 rootMargin: config.viewportRootMargin, 299 threshold: 0.01, 300 }); 301 302 const start = () => { 303 scanForAnchors(document.body); 304 new MutationObserver((mutations) => { 305 for (const mutation of mutations) { 306 for (const node of mutation.addedNodes) { 307 scanForAnchors(node); 308 } 309 } 310 }).observe(document.body, { childList: true, subtree: true }); 311 }; 312 313 if (document.readyState === 'loading') { 314 document.addEventListener('DOMContentLoaded', start, { once: true }); 315 } else { 316 start(); 317 } 318 }; 319 320 // ---- Bootstrap ---- 321 322 const hasHover = !isTouchOnlyDevice(); 323 const bailForData = config.respectSaveData && isSaveDataConstrained(); 324 const canDoAnyPrefetch = supportsPrefetch() || canPrerender; 325 326 if (!bailForData && canDoAnyPrefetch) { 327 if (hasHover) { 328 document.addEventListener('pointerover', onPointerOver, { passive: true }); 329 document.addEventListener('pointerout', onPointerOut, { passive: true }); 330 document.addEventListener('focusin', onFocusIn, { passive: true }); 331 document.addEventListener('focusout', onFocusOut, { passive: true }); 332 } 333 334 const enableViewport = 335 config.enableViewportPrefetch && 336 (!hasHover || config.enableViewportOnDesktop); 337 338 if (enableViewport) { 339 initViewportMode(); 340 } 341 } 342 } 343}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.