summaryrefslogtreecommitdiff
path: root/packages/astro/src/prefetch/index.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/astro/src/prefetch/index.ts')
-rw-r--r--packages/astro/src/prefetch/index.ts349
1 files changed, 349 insertions, 0 deletions
diff --git a/packages/astro/src/prefetch/index.ts b/packages/astro/src/prefetch/index.ts
new file mode 100644
index 000000000..70f7052d3
--- /dev/null
+++ b/packages/astro/src/prefetch/index.ts
@@ -0,0 +1,349 @@
+/*
+ NOTE: Do not add any dependencies or imports in this file so that it can load quickly in dev.
+*/
+
+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<string>();
+// Track listened anchors so we don't attach duplicated listeners
+const listenedAnchors = new WeakSet<HTMLAnchorElement>();
+
+// 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: boolean = __PREFETCH_PREFETCH_ALL__;
+// @ts-expect-error injected global
+let defaultStrategy: string = __PREFETCH_DEFAULT_STRATEGY__;
+// @ts-expect-error injected global
+let clientPrerender: boolean = __EXPERIMENTAL_CLIENT_PRERENDER__;
+
+interface InitOptions {
+ defaultStrategy?: string;
+ prefetchAll?: boolean;
+}
+
+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?: InitOptions) {
+ 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.body.addEventListener(
+ event,
+ (e) => {
+ if (elMatchesStrategy(e.target, 'tap')) {
+ prefetch(e.target.href, { ignoreSlowConnection: true });
+ }
+ },
+ { passive: true },
+ );
+ }
+}
+
+/**
+ * Prefetch links with higher priority when the user hovers over them
+ */
+function initHoverStrategy() {
+ let timeout: number;
+
+ // Handle focus listeners
+ document.body.addEventListener(
+ 'focusin',
+ (e) => {
+ if (elMatchesStrategy(e.target, 'hover')) {
+ handleHoverIn(e);
+ }
+ },
+ { 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', handleHoverIn, { passive: true });
+ anchor.addEventListener('mouseleave', handleHoverOut, { passive: true });
+ }
+ }
+ });
+
+ function handleHoverIn(e: Event) {
+ const href = (e.target as HTMLAnchorElement).href;
+
+ // Debounce hover prefetches by 80ms
+ if (timeout) {
+ clearTimeout(timeout);
+ }
+ timeout = setTimeout(() => {
+ prefetch(href);
+ }, 80) as unknown as number;
+ }
+
+ // 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: IntersectionObserver;
+
+ 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<HTMLAnchorElement, number>();
+
+ return new IntersectionObserver((entries, observer) => {
+ for (const entry of entries) {
+ const anchor = entry.target as HTMLAnchorElement;
+ 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) as unknown as number,
+ );
+ } 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);
+ }
+ }
+ });
+}
+
+export interface PrefetchOptions {
+ /**
+ * How the prefetch should prioritize the URL. (default `'link'`)
+ * - `'link'`: use `<link rel="prefetch">`.
+ * - `'fetch'`: use `fetch()`.
+ *
+ * @deprecated It is recommended to not use this option, and let prefetch use `'link'` whenever it's supported,
+ * or otherwise fall back to `'fetch'`. `'link'` works better if the URL doesn't set an appropriate cache header,
+ * as the browser will continue to cache it as long as it's used subsequently.
+ */
+ with?: 'link' | 'fetch';
+ /**
+ * Should prefetch even on data saver mode or slow connection. (default `false`)
+ */
+ ignoreSlowConnection?: boolean;
+}
+
+/**
+ * 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: string, opts?: PrefetchOptions) {
+ // 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
+ // NOTE: This condition is tree-shaken if `clientPrerender` is false as its a static value
+ if (clientPrerender && HTMLScriptElement.supports?.('speculationrules')) {
+ debug?.(`[astro] Prefetching ${url} with <script type="speculationrules">`);
+ appendSpeculationRules(url);
+ }
+ // Prefetch with link if supported
+ else if (
+ document.createElement('link').relList?.supports?.('prefetch') &&
+ opts?.with !== 'fetch'
+ ) {
+ debug?.(`[astro] Prefetching ${url} with <link rel="prefetch">`);
+ const link = document.createElement('link');
+ link.rel = 'prefetch';
+ link.setAttribute('href', url);
+ document.head.append(link);
+ }
+ // Otherwise, fallback prefetch with fetch
+ else {
+ debug?.(`[astro] Prefetching ${url} with fetch`);
+ fetch(url, { priority: 'low' });
+ }
+}
+
+function canPrefetchUrl(url: string, ignoreSlowConnection: boolean) {
+ // 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: EventTarget | null, strategy: string): el is HTMLAnchorElement {
+ // @ts-expect-error access unknown property this way as it's more performant
+ if (el?.tagName !== 'A') return false;
+ const attrValue = (el as HTMLElement).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 as any;
+ return conn.saveData || /2g/.test(conn.effectiveType);
+ }
+ return false;
+}
+
+/**
+ * Listen to page loads and handle Astro's View Transition specific events
+ */
+function onPageLoad(cb: () => void) {
+ 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();
+ });
+}
+
+/**
+ * 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.
+ */
+function appendSpeculationRules(url: string) {
+ const script = document.createElement('script');
+ script.type = 'speculationrules';
+ script.textContent = JSON.stringify({
+ prerender: [
+ {
+ source: 'list',
+ urls: [url],
+ },
+ ],
+ // 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],
+ },
+ ],
+ });
+ document.head.append(script);
+}