UNPKG

astro

Version:

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

312 lines (311 loc) • 13.4 kB
import { SERVER_ISLAND_START } from "../runtime/server/render/server-islands-shared.js"; const PERSIST_ATTR = "data-astro-transition-persist"; const NON_OVERRIDABLE_ASTRO_ATTRS = ["data-astro-transition", "data-astro-transition-fallback"]; // Vite's CSS HMR runtime keeps references to the style nodes it injects, so preserve // those nodes across ClientRouter head swaps. https://github.com/withastro/astro/pull/17612 const viteStyleState = import.meta.env.DEV ? (() => { const styles = new Map(); let observer; return { styles, observe() { if (observer) return; observer = new MutationObserver((records) => { for (const record of records) { for (const node of record.addedNodes) { if (!(node instanceof HTMLStyleElement)) continue; const viteDevId = node.dataset.viteDevId; if (!viteDevId) continue; const knownStyle = styles.get(viteDevId); if (node === knownStyle) continue; // ClientRouter appends the fetched style before Vite injects the node // registered for HMR. Replace it and track Vite's node instead. knownStyle?.remove(); styles.set(viteDevId, node); } } }); observer.observe(document.head, { childList: true }); } }; })() : undefined; const scriptsAlreadyRan = new Set(); export function detectScriptExecuted(script) { const key = script.src ? new URL(script.src, location.href).href : script.textContent; if (scriptsAlreadyRan.has(key)) return true; scriptsAlreadyRan.add(key); return false; } /* * Mark new scripts that should not execute */ export function deselectScripts(doc) { for (const s2 of doc.scripts) { if (!s2.hasAttribute("data-astro-rerun") && detectScriptExecuted(s2)) { // the old script is in the new document and doesn't have the rerun attribute // we mark it as executed to prevent re-execution s2.dataset.astroExec = ""; } } } /* * swap attributes of the html element * delete all attributes from the current document * insert all attributes from doc * reinsert all original attributes that are referenced in NON_OVERRIDABLE_ASTRO_ATTRS' */ export function swapRootAttributes(newDoc) { const currentRoot = document.documentElement; const nonOverridableAstroAttributes = [...currentRoot.attributes].filter(({ name }) => (currentRoot.removeAttribute(name), NON_OVERRIDABLE_ASTRO_ATTRS.includes(name))); [...newDoc.documentElement.attributes, ...nonOverridableAstroAttributes].forEach(({ name, value }) => currentRoot.setAttribute(name, value)); } /* * make the old head look like the new one */ export function swapHeadElements(doc) { // Elements and special server island comments const relevantNodes = (parent, what = "both") => [...[...parent.childNodes].filter((child) => child.nodeType === 8 && child.textContent === SERVER_ISLAND_START || what === "both" && child.nodeType === 1)]; for (const el of Array.from(document.head.children)) { const newEl = persistedHeadElement(el, doc); // If the element exists in the document already, remove it // from the new document and leave the current node alone if (newEl) { newEl.remove(); } else { if (import.meta.env.DEV && el instanceof HTMLStyleElement) { const viteDevId = el.dataset.viteDevId; viteDevId && viteStyleState?.styles.set(viteDevId, el); } // If the element does not exist in the new document, remove the element from current the head. el.remove(); } } // Remove stale server island markers // that may have been left behind from a previous navigation. relevantNodes(document.head, "commentsOnly").forEach((node) => node.remove()); // Everything left in the new head is new, append it all. if (import.meta.env.DEV) { relevantNodes(doc.head).forEach((child) => { const viteDevId = child instanceof HTMLStyleElement && child.dataset.viteDevId; const knownStyle = viteDevId && viteStyleState?.styles.get(viteDevId); if (knownStyle) { // Generated styles such as UnoCSS can keep the same Vite ID while their CSS changes // between routes, so copy the incoming CSS into the style element Vite uses for HMR. // https://github.com/withastro/astro/pull/16242 // Vue scoped styles are excluded because their content may be transformed in the browser. if (!vueScopedStyleId(knownStyle)) knownStyle.textContent = child.textContent; document.head.append(knownStyle); } else { if (viteDevId) { viteStyleState?.styles.set(viteDevId, child); viteStyleState?.observe(); } document.head.append(child); } }); } else { document.head.append(...relevantNodes(doc.head)); } } export function swapBodyElement(newElement, oldElement) { // Lift persist elements to <html> before the body swap so they stay in the DOM // throughout replaceWith(). This prevents Safari from losing WebGL context on // <canvas> elements due to brief DOM detachment. Uses moveBefore() where available // (Chrome 133+) for zero-detachment atomic moves. const persistPairs = []; const docEl = oldElement.ownerDocument.documentElement; // Media that are live right now. The only way for one of these nodes to end up in // the new body is the persist transfer below (a matched `transition:persist` // element moves with its whole subtree — so this also covers media inside an inner // persist container that has no counterpart on the new page, and the attribute // placed on the media element itself). Such nodes were never inert and must keep // their identity and playback state; see reifyMediaElements(). const liveMedia = new Set(oldElement.querySelectorAll("video, audio")); // moveBefore() is not yet in TypeScript's DOM lib, feature-detect and wrap. const moveBefore = typeof docEl.moveBefore === "function" ? (parent, node, child) => parent.moveBefore(node, child) : null; for (const el of oldElement.querySelectorAll(`[${PERSIST_ATTR}]`)) { const id = el.getAttribute(PERSIST_ATTR); const newEl = newElement.querySelector(`[${PERSIST_ATTR}="${id}"]`); if (!newEl) continue; persistPairs.push({ old: el, newTarget: newEl }); if (moveBefore) { moveBefore(docEl, el, null); } else { docEl.appendChild(el); } } // this will reset scroll Position oldElement.replaceWith(newElement); // Move persist elements into the new body at the position of their targets for (const { old: el, newTarget } of persistPairs) { if (moveBefore) { moveBefore(newTarget.parentNode, el, newTarget); newTarget.remove(); } else { newTarget.replaceWith(el); } // For islands, copy over the props to allow them to re-render if (newTarget.localName === "astro-island" && shouldCopyProps(el) && !isSameProps(el, newTarget)) { el.setAttribute("ssr", ""); el.setAttribute("props", newTarget.getAttribute("props")); } } // This will upgrade any Declarative Shadow DOM in the new body. attachShadowRoots(newElement); // Re-create media elements so the browser initialises their media stack. // DOMParser produces elements in an inert document where <video>/<audio> // never get a media controller; moving them into the live DOM does not // retroactively initialise one, leaving controls disabled. Replacing each // element with a fresh copy created via document.createElement() forces // the browser to set up playback. See https://github.com/withastro/astro/issues/17601 // Media carried over from the previous page were never inert: they are the live // nodes of the old body, and re-creating them would destroy their playback state. reifyMediaElements(newElement, liveMedia); } /** * Replace media elements with fresh copies created in the live document. * Elements parsed by DOMParser originate from an inert document where the browser * never initialises the media stack, leaving controls disabled after a view-transition * swap. Creating a fresh element via `document.createElement()` and copying attributes * and children forces proper initialisation. * Elements in `liveMedia` are skipped: they were already live in the old document and * reached the new body through `transition:persist` — replacing them would reset * `currentTime`/`paused` and drop listeners and framework refs. * @see https://github.com/withastro/astro/issues/17601 */ function reifyMediaElements(root, liveMedia) { for (const media of root.querySelectorAll("video, audio")) { if (liveMedia.has(media)) continue; const fresh = document.createElement(media.localName); for (const attr of media.attributes) { fresh.setAttribute(attr.name, attr.value); } fresh.innerHTML = media.innerHTML; // Chrome only syncs the muted IDL property from the content attribute when the // HTML parser creates the element; setAttribute('muted', ...) on a script-created // element leaves video.muted === false. https://github.com/withastro/astro/issues/18152 if (fresh.hasAttribute("muted")) fresh.muted = true; media.replaceWith(fresh); } } /** * Attach Shadow DOM roots for templates with the declarative `shadowrootmode` attribute. * @see https://github.com/withastro/astro/issues/14340 * @see https://web.dev/articles/declarative-shadow-dom#polyfill * @param root DOM subtree to attach shadow roots within. */ function attachShadowRoots(root) { root.querySelectorAll("template[shadowrootmode]").forEach((template) => { const mode = template.getAttribute("shadowrootmode"); const parent = template.parentNode; if ((mode === "closed" || mode === "open") && parent instanceof HTMLElement) { // Skip if shadow root already exists (e.g., from transition-persisted elements) if (parent.shadowRoot) { template.remove(); return; } const shadowRoot = parent.attachShadow({ mode }); shadowRoot.appendChild(template.content); template.remove(); attachShadowRoots(shadowRoot); } }); } export const saveFocus = () => { const activeElement = document.activeElement; // The element that currently has the focus is part of a DOM tree // that will survive the transition to the new document. // Save the element and the cursor position if (activeElement?.closest(`[${PERSIST_ATTR}]`)) { if (activeElement instanceof HTMLInputElement || activeElement instanceof HTMLTextAreaElement) { const start = activeElement.selectionStart; const end = activeElement.selectionEnd; return () => restoreFocus({ activeElement, start, end }); } return () => restoreFocus({ activeElement }); } else { return () => restoreFocus({ activeElement: null }); } }; export const restoreFocus = ({ activeElement, start, end }) => { if (activeElement) { activeElement.focus(); if (activeElement instanceof HTMLInputElement || activeElement instanceof HTMLTextAreaElement) { if (typeof start === "number") activeElement.selectionStart = start; if (typeof end === "number") activeElement.selectionEnd = end; } } }; export const vueScopedStyleId = (el) => { const viteDevId = el.dataset.viteDevId || ""; const url = new URL(viteDevId, location.href); return url.searchParams.get("vue") !== null && url.searchParams.get("type") === "style" && url.searchParams.has("scoped") ? viteDevId : ""; }; // Check for a head element that should persist and returns it, // either because it has the data attribute or because replacing it would cause avoidable FOUC. const persistedHeadElement = (el, newDoc) => { const id = el.getAttribute(PERSIST_ATTR); const newEl = id && newDoc.head.querySelector(`[${PERSIST_ATTR}="${id}"]`); if (newEl) { return newEl; } if (el.matches("link[rel=stylesheet]")) { const href = el.getAttribute("href"); return newDoc.head.querySelector(`link[rel=stylesheet][href="${href}"]`); } // Vue scoped CSS may be transformed in the browser, so preserve its current contents // across ClientRouter navigations. https://github.com/withastro/astro/pull/16379 if (import.meta.env.DEV && el instanceof HTMLStyleElement) { const viteDevId = vueScopedStyleId(el); if (viteDevId) { return newDoc.head.querySelector(`style[data-vite-dev-id="${viteDevId}"]`); } } // Preserve inline <style> elements with identical content across navigations. // This prevents unnecessary removal and re-insertion of styles (e.g. @font-face // declarations from <Font>), which would cause the browser to re-evaluate them // and trigger a flash of unstyled text (FOUT). if (el.tagName === "STYLE" && el.textContent) { const styles = newDoc.head.querySelectorAll("style"); for (const s of styles) { if (s.textContent === el.textContent) { return s; } } } // Preserve font preload links across navigations to avoid re-fetching cached fonts. if (el.matches("link[rel=preload][as=font]")) { const href = el.getAttribute("href"); return newDoc.head.querySelector(`link[rel=preload][as=font][href="${href}"]`); } return null; }; const shouldCopyProps = (el) => { const persistProps = el.dataset.astroTransitionPersistProps; return persistProps == null || persistProps === "false"; }; const isSameProps = (oldEl, newEl) => { return oldEl.getAttribute("props") === newEl.getAttribute("props"); }; export const swapFunctions = { deselectScripts, swapRootAttributes, swapHeadElements, swapBodyElement, saveFocus }; export const swap = (doc) => { deselectScripts(doc); swapRootAttributes(doc); swapHeadElements(doc); const restoreFocusFunction = saveFocus(); swapBodyElement(doc.body, document.body); restoreFocusFunction(); };