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
JavaScript
/*
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);
}