UNPKG

astro

Version:

Astro is a modern site builder with web best practices, performance, and DX front-of-mind.

332 lines (331 loc) • 11.7 kB
/* NOTE: Be careful about adding dependencies or imports in this file so that it can load quickly in dev. */ import { internalFetchHeaders } from "virtual:astro:adapter-config/client"; const debug = import.meta.env.DEV ? console.debug : undefined; const inBrowser = import.meta.env.SSR === false; // Track prefetched URLs so we don't prefetch twice const prefetchedUrls = new Set(); // Track listened anchors so we don't attach duplicated listeners const listenedAnchors = new WeakSet(); // User-defined config for prefetch. The values are injected by vite-plugin-prefetch // and can be undefined if not configured. But it will be set a fallback value in `init()`. // @ts-expect-error injected global let prefetchAll = __PREFETCH_PREFETCH_ALL__; // @ts-expect-error injected global let defaultStrategy = __PREFETCH_DEFAULT_STRATEGY__; // @ts-expect-error injected global let clientPrerender = __EXPERIMENTAL_CLIENT_PRERENDER__; let inited = false; /** * Initialize the prefetch script, only works once. * * @param defaultOpts Default options for prefetching if not already set by the user config. */ export function init(defaultOpts) { if (!inBrowser) return; // Init only once if (inited) return; inited = true; debug?.(`[astro] Initializing prefetch script`); // Fallback default values if not set by user config prefetchAll ??= defaultOpts?.prefetchAll ?? false; defaultStrategy ??= defaultOpts?.defaultStrategy ?? "hover"; // In the future, perhaps we can enable treeshaking specific unused strategies initTapStrategy(); initHoverStrategy(); initViewportStrategy(); initLoadStrategy(); } /** * Prefetch links with higher priority when the user taps on them */ function initTapStrategy() { for (const event of ["touchstart", "mousedown"]) { document.addEventListener(event, (e) => { const anchor = e.target.closest("a"); if (elMatchesStrategy(anchor, "tap")) { prefetch(anchor.href, { ignoreSlowConnection: true }); } }, { passive: true }); } } /** * Prefetch links with higher priority when the user hovers over them */ function initHoverStrategy() { let timeout; // Handle focus listeners document.body.addEventListener("focusin", (e) => { const anchor = e.target.closest("a"); if (elMatchesStrategy(anchor, "hover")) { handleHoverIn(anchor.href); } }, { passive: true }); document.body.addEventListener("focusout", handleHoverOut, { passive: true }); // Handle hover listeners. Re-run each time on page load. onPageLoad(() => { for (const anchor of document.getElementsByTagName("a")) { // Skip if already listening if (listenedAnchors.has(anchor)) continue; // Add listeners for anchors matching the strategy if (elMatchesStrategy(anchor, "hover")) { listenedAnchors.add(anchor); anchor.addEventListener("mouseenter", (e) => handleHoverIn(e.currentTarget.href), { passive: true }); anchor.addEventListener("mouseleave", handleHoverOut, { passive: true }); } } }); function handleHoverIn(href) { // Debounce hover prefetches by 80ms if (timeout) { clearTimeout(timeout); } timeout = setTimeout(() => { prefetch(href); }, 80); } // Cancel prefetch if the user hovers away function handleHoverOut() { if (timeout) { clearTimeout(timeout); timeout = 0; } } } /** * Prefetch links with lower priority as they enter the viewport */ function initViewportStrategy() { let observer; onPageLoad(() => { for (const anchor of document.getElementsByTagName("a")) { // Skip if already listening if (listenedAnchors.has(anchor)) continue; // Observe for anchors matching the strategy if (elMatchesStrategy(anchor, "viewport")) { listenedAnchors.add(anchor); observer ??= createViewportIntersectionObserver(); observer.observe(anchor); } } }); } function createViewportIntersectionObserver() { const timeouts = new WeakMap(); return new IntersectionObserver((entries, observer) => { for (const entry of entries) { const anchor = entry.target; const timeout = timeouts.get(anchor); // Prefetch if intersecting if (entry.isIntersecting) { // Debounce viewport prefetches by 300ms if (timeout) { clearTimeout(timeout); } timeouts.set(anchor, setTimeout(() => { observer.unobserve(anchor); timeouts.delete(anchor); prefetch(anchor.href); }, 300)); } else { // If exited viewport but haven't prefetched, cancel it if (timeout) { clearTimeout(timeout); timeouts.delete(anchor); } } } }); } /** * Prefetch links with lower priority when page load */ function initLoadStrategy() { onPageLoad(() => { for (const anchor of document.getElementsByTagName("a")) { if (elMatchesStrategy(anchor, "load")) { // Prefetch every link in this page prefetch(anchor.href); } } }); } /** * Prefetch a URL so it's cached when the user navigates to it. * * @param url A full or partial URL string based on the current `location.href`. They are only fetched if: * - The user is online * - The user is not in data saver mode * - The URL is within the same origin * - The URL is not the current page * - The URL has not already been prefetched * @param opts Additional options for prefetching. */ export function prefetch(url, opts) { // Remove url hash to avoid prefetching the same URL multiple times url = url.replace(/#.*/, ""); const ignoreSlowConnection = opts?.ignoreSlowConnection ?? false; if (!canPrefetchUrl(url, ignoreSlowConnection)) return; prefetchedUrls.add(url); // Prefetch with speculationrules if `clientPrerender` is enabled and supported. // Skip dynamic injection when a static document-source speculation rules script is already // in the page (injected at build time for CSP compatibility). // NOTE: This condition is tree-shaken if `clientPrerender` is false as its a static value if (clientPrerender && HTMLScriptElement.supports?.("speculationrules") && !hasStaticSpeculationRules()) { debug?.(`[astro] Prefetching ${url} with <script type="speculationrules">`); appendSpeculationRules(url, opts?.eagerness ?? "immediate"); } else if (document.createElement("link").relList?.supports?.("prefetch")) { debug?.(`[astro] Prefetching ${url} with <link rel="prefetch">`); const link = document.createElement("link"); link.rel = "prefetch"; link.setAttribute("href", url); document.head.append(link); } else { debug?.(`[astro] Prefetching ${url} with fetch`); // Apply adapter-specific headers for internal fetches const headers = new Headers(); for (const [key, value] of Object.entries(internalFetchHeaders)) { headers.set(key, value); } // The `<link rel="prefetch">` branch above is unsupported in WebKit // (Safari), so this fetch fallback is the path there. A prefetch is a // best-effort hint, so swallow network failures — otherwise a flaky // connection surfaces an unhandled `TypeError: Load failed` rejection // to the page's global error handlers (and trips no-floating-promises). fetch(url, { priority: "low", headers }).catch(() => {}); } } function canPrefetchUrl(url, ignoreSlowConnection) { // Skip prefetch if offline if (!navigator.onLine) return false; // Skip prefetch if using data saver mode or slow connection if (!ignoreSlowConnection && isSlowConnection()) return false; // Else check if URL is within the same origin, not the current page, and not already prefetched try { const urlObj = new URL(url, location.href); return location.origin === urlObj.origin && (location.pathname !== urlObj.pathname || location.search !== urlObj.search) && !prefetchedUrls.has(url); } catch {} return false; } function elMatchesStrategy(el, strategy) { // @ts-expect-error access unknown property this way as it's more performant if (el?.tagName !== "A") return false; const attrValue = el.dataset.astroPrefetch; // Out-out if `prefetchAll` is enabled if (attrValue === "false") { return false; } // Fallback to tap strategy if using data saver mode or slow connection if (strategy === "tap" && (attrValue != null || prefetchAll) && isSlowConnection()) { return true; } // If anchor has no dataset but we want to prefetch all, or has dataset but no value, // check against fallback default strategy if (attrValue == null && prefetchAll || attrValue === "") { return strategy === defaultStrategy; } // Else if dataset is explicitly defined, check against it if (attrValue === strategy) { return true; } // Else, no match return false; } function isSlowConnection() { if ("connection" in navigator) { // Untyped Chrome-only feature: https://developer.mozilla.org/en-US/docs/Web/API/Navigator/connection const conn = navigator.connection; return conn.saveData || /2g/.test(conn.effectiveType); } return false; } /** * Listen to page loads and handle Astro's View Transition specific events. * Also observes DOM mutations for dynamically added anchors (e.g. from server islands). */ function onPageLoad(cb) { cb(); // Ignore first call of `astro-page-load` as we already call `cb` above. // We have to call `cb` eagerly as View Transitions may not be enabled. let firstLoad = false; document.addEventListener("astro:page-load", () => { if (!firstLoad) { firstLoad = true; return; } cb(); }); // Watch for dynamically added anchors (e.g. from server islands) new MutationObserver((mutations) => { for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (node instanceof Element && (node.tagName === "A" || node.querySelector?.("a"))) { cb(); return; } } } }).observe(document.body, { childList: true, subtree: true }); } /** Cached result of static speculation rules detection. */ let _hasStaticRules; /** * Returns `true` when a `<script type="speculationrules">` using `"source": "document"` * is already present in the page (injected server-side for CSP compatibility). * The browser handles URL matching via CSS selectors in that case, so per-URL * dynamic injection is unnecessary. */ function hasStaticSpeculationRules() { if (_hasStaticRules === undefined) { _hasStaticRules = Array.from(document.querySelectorAll("script[type=\"speculationrules\"]")).some((el) => { try { const rules = JSON.parse(el.textContent ?? ""); const entries = [...rules.prerender ?? [], ...rules.prefetch ?? []]; return entries.some((entry) => entry.source === "document"); } catch { return false; } }); } return _hasStaticRules; } /** * Appends a `<script type="speculationrules">` tag to the head of the * document that prerenders the `url` passed in. * * Modifying the script and appending a new link does not trigger the prerender. * A new script must be added for each `url`. * * @param url The url of the page to prerender. * * @param eagerness Hint to the browser as to how eagerly it should prefetch/prerender link targets */ function appendSpeculationRules(url, eagerness) { const script = document.createElement("script"); script.type = "speculationrules"; // nosemgrep: javascript.lang.security.audit.unknown-value-with-script-tag.unknown-value-with-script-tag // This writes JSON via textContent, not executable JavaScript source. script.textContent = JSON.stringify({ prerender: [{ source: "list", urls: [url], eagerness }], // Currently, adding `prefetch` is required to fallback if `prerender` fails. // Possibly will be automatic in the future, in which case it can be removed. // https://github.com/WICG/nav-speculation/issues/162#issuecomment-1977818473 prefetch: [{ source: "list", urls: [url], eagerness }] }); document.head.append(script); }