astro
Version:
Astro is a modern site builder with web best practices, performance, and DX front-of-mind.
577 lines (576 loc) • 25.2 kB
JavaScript
import { internalFetchHeaders } from "virtual:astro:adapter-config/client";
import { doPreparation, doSwap, onPageLoad, triggerEvent, updateScrollPosition } from "./events.js";
import { detectScriptExecuted } from "./swap-functions.js";
const inBrowser = import.meta.env.SSR === false;
export const supportsViewTransitions = inBrowser && !!document.startViewTransition;
export const transitionEnabledOnThisPage = () => inBrowser && !!document.querySelector("[name=\"astro-view-transitions-enabled\"]");
const samePage = (thisLocation, otherLocation) => thisLocation.pathname === otherLocation.pathname && thisLocation.search === otherLocation.search;
// The previous navigation that might still be in processing
let mostRecentNavigation;
// The previous transition that might still be in processing
let mostRecentTransition;
// When we traverse the history, the window.location is already set to the new location.
// This variable tells us where we came from
let originalLocation;
const announce = () => {
let div = document.createElement("div");
div.setAttribute("aria-live", "assertive");
div.setAttribute("aria-atomic", "true");
div.className = "astro-route-announcer";
document.body.append(div);
setTimeout(
() => {
let title = document.title || document.querySelector("h1")?.textContent || location.pathname;
div.textContent = title;
},
// Much thought went into this magic number; the gist is that screen readers
// need to see that the element changed and might not do so if it happens
// too quickly.
60
);
};
const PERSIST_ATTR = "data-astro-transition-persist";
const DIRECTION_ATTR = "data-astro-transition";
const OLD_NEW_ATTR = "data-astro-transition-fallback";
const VITE_ID = "data-vite-dev-id";
let parser;
// The History API does not tell you if navigation is forward or back, so
// you can figure it using an index. On pushState the index is incremented so you
// can use that to determine popstate if going forward or back.
let currentHistoryIndex = 0;
if (inBrowser) {
if (history.state) {
// Here we reloaded a page with history state
// (e.g. history navigation from non-transition page or browser reload)
currentHistoryIndex = history.state.index;
scrollTo({
left: history.state.scrollX,
top: history.state.scrollY
});
} else if (transitionEnabledOnThisPage()) {
// This page is loaded from the browser address bar or via a link from extern,
// it needs a state in the history
history.replaceState({
index: currentHistoryIndex,
scrollX,
scrollY
}, "");
history.scrollRestoration = "manual";
}
}
// returns the contents of the page or null if the router can't deal with it.
async function fetchHTML(href, init) {
try {
// Apply adapter-specific headers for internal fetches
const headers = new Headers(init?.headers);
for (const [key, value] of Object.entries(internalFetchHeaders)) {
headers.set(key, value);
}
const res = await fetch(href, {
...init,
headers
});
const contentType = res.headers.get("content-type") ?? "";
// drop potential charset (+ other name/value pairs) as parser needs the mediaType
const mediaType = contentType.split(";", 1)[0].trim();
// the DOMParser can handle two types of HTML
if (mediaType !== "text/html" && mediaType !== "application/xhtml+xml") {
// everything else (e.g. audio/mp3) will be handled by the browser but not by us
return null;
}
const html = await res.text();
return {
html,
redirected: res.redirected ? res.url : undefined,
mediaType
};
} catch {
// can't fetch, let someone else deal with it.
return null;
}
}
export function getFallback() {
const el = document.querySelector("[name=\"astro-view-transitions-fallback\"]");
if (el) {
return el.getAttribute("content");
}
return "animate";
}
function runScripts() {
let wait = Promise.resolve();
let needsWaitForInlineModuleScript = false;
// The original code made the assumption that all inline scripts are directly executed when inserted into the DOM.
// This is not true for inline module scripts, which are deferred but still executed in order.
// inline module scripts cannot be awaited for with onload.
// Thus to be able to wait for the execution of all scripts, we make sure that the last inline module script
// is always followed by an external module script
for (const script of document.getElementsByTagName("script")) {
script.dataset.astroExec === undefined && script.getAttribute("type") === "module" && (needsWaitForInlineModuleScript = script.getAttribute("src") === null);
}
needsWaitForInlineModuleScript && document.body.insertAdjacentHTML("beforeend", `<script type="module" src="data:application/javascript,"/>`);
for (const script of document.getElementsByTagName("script")) {
if (script.dataset.astroExec === "") continue;
const type = script.getAttribute("type");
if (type && type !== "module" && type !== "text/javascript") continue;
const newScript = document.createElement("script");
newScript.innerHTML = script.innerHTML;
for (const attr of script.attributes) {
if (attr.name === "src") {
const p = new Promise((r) => {
newScript.onload = newScript.onerror = r;
});
wait = wait.then(() => p);
}
newScript.setAttribute(attr.name, attr.value);
}
newScript.dataset.astroExec = "";
script.replaceWith(newScript);
}
return wait;
}
// Add a new entry to the browser history. This also sets the new page in the browser address bar.
// Sets the scroll position according to the hash fragment of the new location.
const moveToLocation = (to, from, options, pageTitleForBrowserHistory, historyState) => {
const intraPage = samePage(from, to);
const targetPageTitle = document.title;
document.title = pageTitleForBrowserHistory;
let scrolledToTop = false;
if (to.href !== location.href && !historyState) {
if (options.history === "replace") {
const current = history.state;
history.replaceState({
...options.state,
index: current.index,
scrollX: current.scrollX,
scrollY: current.scrollY
}, "", to.href);
} else {
history.pushState({
...options.state,
index: ++currentHistoryIndex,
scrollX: 0,
scrollY: 0
}, "", to.href);
}
}
document.title = targetPageTitle;
// now we are on the new page for non-history navigation!
// (with history navigation page change happens before popstate is fired)
originalLocation = to;
// freshly loaded pages start from the top
if (!intraPage) {
scrollTo({
left: 0,
top: 0,
behavior: "instant"
});
scrolledToTop = true;
}
if (historyState) {
scrollTo(historyState.scrollX, historyState.scrollY);
} else {
if (to.hash) {
// because we are already on the target page ...
// ... what comes next is an intra-page navigation
// that won't reload the page but instead scroll to the fragment
history.scrollRestoration = "auto";
const savedState = history.state;
location.href = to.href;
if (!history.state) {
history.replaceState(savedState, "");
if (intraPage) {
window.dispatchEvent(new PopStateEvent("popstate"));
}
}
} else {
if (!scrolledToTop) {
scrollTo({
left: 0,
top: 0,
behavior: "instant"
});
}
}
history.scrollRestoration = "manual";
}
};
function preloadStyleLinks(newDocument) {
const links = [];
for (const el of newDocument.querySelectorAll("head link[rel=stylesheet]")) {
// Do not preload links that are already on the page.
if (!document.querySelector(`[${PERSIST_ATTR}="${el.getAttribute(PERSIST_ATTR)}"], link[rel=stylesheet][href="${el.getAttribute("href")}"]`)) {
const c = document.createElement("link");
c.setAttribute("rel", "preload");
c.setAttribute("as", "style");
c.setAttribute("href", el.getAttribute("href"));
links.push(new Promise((resolve) => {
["load", "error"].forEach((evName) => c.addEventListener(evName, resolve));
document.head.append(c);
}));
}
}
return links;
}
// replace head and body of the windows document with contents from newDocument
// if !popstate, update the history entry and scroll position according to toLocation
// if popState is given, this holds the scroll position for history navigation
// if fallback === "animate" then simulate view transitions
async function updateDOM(preparationEvent, options, currentTransition, historyState, fallback) {
async function animate(phase) {
function isInfinite(animation) {
const effect = animation.effect;
if (!effect || !(effect instanceof KeyframeEffect) || !effect.target) return false;
const style = window.getComputedStyle(effect.target, effect.pseudoElement);
return style.animationIterationCount === "infinite";
}
const currentAnimations = document.getAnimations();
// Trigger view transition animations waiting for data-astro-transition-fallback
document.documentElement.setAttribute(OLD_NEW_ATTR, phase);
const nextAnimations = document.getAnimations();
const newAnimations = nextAnimations.filter((a) => !currentAnimations.includes(a) && !isInfinite(a));
// Wait for all new animations to finish (resolved or rejected).
// Do not reject on canceled ones.
return Promise.allSettled(newAnimations.map((a) => a.finished));
}
const animateFallbackOld = async () => {
if (fallback === "animate" && !currentTransition.transitionSkipped && !preparationEvent.signal.aborted) {
try {
await animate("old");
} catch {}
}
};
const pageTitleForBrowserHistory = document.title;
const swapEvent = await doSwap(preparationEvent, currentTransition.viewTransition, animateFallbackOld);
moveToLocation(swapEvent.to, swapEvent.from, options, pageTitleForBrowserHistory, historyState);
triggerEvent("astro:after-swap");
// Resolve the finished promise of the simulation's ViewTransition.
// For 'animate', wait for the new-page animation to complete first.
// For other fallback modes (e.g. 'swap'), resolve immediately — no animation needed.
if (fallback === "animate" && !currentTransition.transitionSkipped && !swapEvent.signal.aborted) {
animate("new").finally(() => currentTransition.viewTransitionFinished());
} else {
currentTransition.viewTransitionFinished?.();
}
}
function abortAndRecreateMostRecentNavigation() {
mostRecentNavigation?.controller.abort();
return mostRecentNavigation = { controller: new AbortController() };
}
async function transition(direction, from, to, options, historyState, hasUAVisualTransition = false) {
// The most recent navigation always has precedence
// Yes, there can be several navigation instances as the user can click links
// while we fetch content or simulate view transitions. Even synchronous creations are possible
// e.g. by calling navigate() from a transition event.
// Invariant: all but the most recent navigation are already aborted.
const currentNavigation = abortAndRecreateMostRecentNavigation();
// not ours
if (!transitionEnabledOnThisPage() || location.origin !== to.origin) {
if (currentNavigation === mostRecentNavigation) mostRecentNavigation = undefined;
location.href = to.href;
return;
}
const navigationType = historyState ? "traverse" : options.history === "replace" ? "replace" : "push";
if (navigationType !== "traverse") {
updateScrollPosition({
scrollX,
scrollY
});
}
if (samePage(from, to) && !options.formData) {
if (direction !== "back" && to.hash || direction === "back" && from.hash) {
moveToLocation(to, from, options, document.title, historyState);
if (currentNavigation === mostRecentNavigation) mostRecentNavigation = undefined;
return;
}
}
const prepEvent = await doPreparation(from, to, direction, navigationType, options.sourceElement, options.info, currentNavigation.controller.signal, options.formData, defaultLoader);
if (prepEvent.defaultPrevented || prepEvent.signal.aborted) {
if (currentNavigation === mostRecentNavigation) mostRecentNavigation = undefined;
if (!prepEvent.signal.aborted) {
// not aborted -> delegate to browser
location.href = to.href;
}
// and / or exit
return;
}
async function defaultLoader(preparationEvent) {
const href = preparationEvent.to.href;
const init = { signal: preparationEvent.signal };
if (preparationEvent.formData) {
init.method = "POST";
const form = preparationEvent.sourceElement instanceof HTMLFormElement ? preparationEvent.sourceElement : preparationEvent.sourceElement instanceof HTMLElement && "form" in preparationEvent.sourceElement ? preparationEvent.sourceElement.form : preparationEvent.sourceElement?.closest("form");
// Form elements without enctype explicitly set default to application/x-www-form-urlencoded.
// In order to maintain compatibility with Astro 4.x, we need to check the value of enctype
// on the attributes property rather than accessing .enctype directly. Astro 5.x may
// introduce defaulting to application/x-www-form-urlencoded as a breaking change, and then
// we can access .enctype directly.
//
// Note: getNamedItem can return null in real life, even if TypeScript doesn't think so, hence
// the ?.
init.body = from !== undefined && Reflect.get(HTMLFormElement.prototype, "attributes", form).getNamedItem("enctype")?.value === "application/x-www-form-urlencoded" ? new URLSearchParams(preparationEvent.formData) : preparationEvent.formData;
}
const response = await fetchHTML(href, init);
// If there is a problem fetching the new page, just do an MPA navigation to it.
if (response === null) {
preparationEvent.preventDefault();
return;
}
// if there was a redirection, show the final URL in the browser's address bar
if (response.redirected) {
const redirectedTo = new URL(response.redirected);
// but do not redirect cross origin
if (redirectedTo.origin !== preparationEvent.to.origin) {
preparationEvent.preventDefault();
return;
}
// preserve fragment
const fragment = preparationEvent.to.hash;
preparationEvent.to = redirectedTo;
preparationEvent.to.hash = fragment;
}
parser ??= new DOMParser();
preparationEvent.newDocument = parser.parseFromString(response.html, response.mediaType);
// The next line might look like a hack,
// but it is actually necessary as noscript elements
// and their contents are returned as markup by the parser,
// see https://developer.mozilla.org/en-US/docs/Web/API/DOMParser/parseFromString
preparationEvent.newDocument.querySelectorAll("noscript").forEach((el) => el.remove());
// If ClientRouter is not enabled on the incoming page, do a full page load to it.
// Unless this was a form submission, in which case we do not want to trigger another mutation.
if (!preparationEvent.newDocument.querySelector("[name=\"astro-view-transitions-enabled\"]") && !preparationEvent.formData) {
preparationEvent.preventDefault();
return;
}
const links = preloadStyleLinks(preparationEvent.newDocument);
links.length && !preparationEvent.signal.aborted && await Promise.all(links);
if (import.meta.env.DEV && !preparationEvent.signal.aborted) await prepareForClientOnlyComponents(preparationEvent.newDocument, preparationEvent.to, preparationEvent.signal);
}
async function abortAndRecreateMostRecentTransition() {
if (mostRecentTransition) {
if (mostRecentTransition.viewTransition) {
try {
mostRecentTransition.viewTransition.skipTransition();
} catch {}
try {
// UpdateCallbackDone might already been settled, i.e. if the previous transition finished updating the DOM.
// Could not take long, we wait for it to avoid parallel updates
// (which are very unlikely as long as swap() is not async).
await mostRecentTransition.viewTransition.updateCallbackDone;
} catch {}
}
}
return mostRecentTransition = { transitionSkipped: false };
}
const currentTransition = await abortAndRecreateMostRecentTransition();
if (prepEvent.signal.aborted) {
if (currentNavigation === mostRecentNavigation) mostRecentNavigation = undefined;
return;
}
document.documentElement.setAttribute(DIRECTION_ATTR, prepEvent.direction);
if (supportsViewTransitions && !hasUAVisualTransition) {
// This automatically cancels any previous transition
// We also already took care that the earlier update callback got through
currentTransition.viewTransition = document.startViewTransition(async () => await updateDOM(prepEvent, options, currentTransition, historyState));
} else {
// Simulation mode requires a bit more manual work.
// Also used when PopStateEvent.hasUAVisualTransition indicates the browser already
// provided a visual transition (e.g. Safari swipe gesture) — in that case, fallback
// is "swap" to skip animations.
const updateDone = (async () => {
// Immediately paused to set up the ViewTransition object for Fallback mode
await Promise.resolve();
await updateDOM(prepEvent, options, currentTransition, historyState, hasUAVisualTransition ? "swap" : getFallback());
return undefined;
})();
// When the updateDone promise is settled,
// we have run and awaited all swap functions and the after-swap event
// This qualifies for "updateCallbackDone".
//
// For the build in ViewTransition, "ready" settles shortly after "updateCallbackDone",
// i.e. after all pseudo elements are created and the animation is about to start.
// In simulation mode the "old" animation starts before swap,
// the "new" animation starts after swap. That is not really comparable.
// Thus we go with "very, very shortly after updateCallbackDone" and make both equal.
//
// "finished" resolves after all animations are done.
currentTransition.viewTransition = {
updateCallbackDone: updateDone,
ready: updateDone,
// Finished promise could have been done better: finished rejects iff updateDone does.
// Our simulation always resolves, never rejects.
finished: new Promise((r) => currentTransition.viewTransitionFinished = r),
skipTransition: () => {
currentTransition.transitionSkipped = true;
// This cancels all animations of the simulation
document.documentElement.removeAttribute(OLD_NEW_ATTR);
},
types: new Set()
};
}
// In earlier versions was then'ed on viewTransition.ready which would not execute
// if the visual part of the transition has errors or was skipped
currentTransition.viewTransition?.updateCallbackDone.finally(async () => {
await runScripts();
onPageLoad();
announce();
});
// finished.ready and finished.finally are the same for the simulation but not
// necessarily for native view transition, where finished rejects when updateCallbackDone does.
currentTransition.viewTransition?.finished.finally(() => {
currentTransition.viewTransition = undefined;
if (currentTransition === mostRecentTransition) mostRecentTransition = undefined;
if (currentNavigation === mostRecentNavigation) mostRecentNavigation = undefined;
document.documentElement.removeAttribute(DIRECTION_ATTR);
document.documentElement.removeAttribute(OLD_NEW_ATTR);
});
try {
// Compatibility:
// In an earlier version we awaited viewTransition.ready, which includes animation setup.
// Scripts that depend on the view transition pseudo elements should hook on viewTransition.ready.
await currentTransition.viewTransition?.updateCallbackDone;
} catch (e) {
// This log doesn't make it worse than before, where we got error messages about uncaught exceptions, which can't be caught when the trigger was a click or history traversal.
// Needs more investigation on root causes if errors still occur sporadically
const err = e;
// biome-ignore lint/suspicious/noConsole: allowed
console.log("[astro]", err.name, err.message, err.stack);
}
}
let navigateOnServerWarned = false;
export async function navigate(href, options) {
if (inBrowser === false) {
if (!navigateOnServerWarned) {
// instantiate an error for the stacktrace to show to user.
const warning = new Error("The view transitions client API was called during a server side render. This may be unintentional as the navigate() function is expected to be called in response to user interactions. Please make sure that your usage is correct.");
warning.name = "Warning";
console.warn(warning);
navigateOnServerWarned = true;
}
return;
}
await transition("forward", originalLocation, new URL(href, location.href), options ?? {});
}
function onPopState(ev) {
if (!transitionEnabledOnThisPage() && ev.state) {
// The current page doesn't have View Transitions enabled
// but the page we navigate to does (because it set the state).
// Do a full page refresh to reload the client-side router from the new page.
location.reload();
return;
}
// History entries without state are created by the browser (e.g. for hash links)
// Our view transition entries always have state.
// Just ignore stateless entries.
// The browser will handle navigation fine without our help
if (ev.state === null) {
return;
}
const state = history.state;
const nextIndex = state.index;
const direction = nextIndex > currentHistoryIndex ? "forward" : "back";
currentHistoryIndex = nextIndex;
transition(direction, originalLocation, new URL(location.href), {}, state, ev.hasUAVisualTransition);
}
const onScrollEnd = () => {
// NOTE: our "popstate" event handler may call `pushState()` or
// `replaceState()` and then `scrollTo()`, which will fire "scroll" and
// "scrollend" events. To avoid redundant work and expensive calls to
// `replaceState()`, we simply check that the values are different before
// updating.
if (history.state && (scrollX !== history.state.scrollX || scrollY !== history.state.scrollY)) {
updateScrollPosition({
scrollX,
scrollY
});
}
};
// initialization
if (inBrowser) {
if (supportsViewTransitions || getFallback() !== "none") {
originalLocation = new URL(location.href);
addEventListener("popstate", onPopState);
addEventListener("load", onPageLoad);
// There's not a good way to record scroll position before a history back
// navigation, so we will record it when the user has stopped scrolling.
if ("onscrollend" in window) addEventListener("scrollend", onScrollEnd);
else {
// Keep track of state between intervals
let intervalId, lastY, lastX, lastIndex;
const scrollInterval = () => {
// Check the index to see if a popstate event was fired
if (lastIndex !== history.state?.index) {
clearInterval(intervalId);
intervalId = undefined;
return;
}
// Check if the user stopped scrolling
if (lastY === scrollY && lastX === scrollX) {
// Cancel the interval and update scroll positions
clearInterval(intervalId);
intervalId = undefined;
onScrollEnd();
return;
} else {
// Update vars with current positions
lastY = scrollY, lastX = scrollX;
}
};
// We can't know when or how often scroll events fire, so we'll just use them to start intervals
addEventListener("scroll", () => {
if (intervalId !== undefined) return;
lastIndex = history.state?.index, lastY = scrollY, lastX = scrollX;
intervalId = window.setInterval(scrollInterval, 50);
}, { passive: true });
}
}
for (const script of document.getElementsByTagName("script")) {
detectScriptExecuted(script);
script.dataset.astroExec = "";
}
}
// Keep all styles that are potentially created by client:only components
// and required on the next page
async function prepareForClientOnlyComponents(newDocument, toLocation, signal) {
// Any client:only component on the next page?
if (newDocument.body.querySelector(`astro-island[client='only']`)) {
// Load the next page with an empty module loader cache
const nextPage = document.createElement("iframe");
// with srcdoc resolving imports does not work on webkit browsers
nextPage.src = toLocation.href;
nextPage.style.display = "none";
document.body.append(nextPage);
// silence the iframe's console
// @ts-ignore
nextPage.contentWindow.console = Object.keys(console).reduce((acc, key) => {
acc[key] = () => {};
return acc;
}, {});
await hydrationDone(nextPage);
const nextHead = nextPage.contentDocument?.head;
if (nextHead) {
// Collect the vite ids of all styles present in the next head
const viteIds = [...nextHead.querySelectorAll(`style[${VITE_ID}]`)].map((style) => style.getAttribute(VITE_ID));
// Copy required styles to the new document if they are from hydration.
viteIds.forEach((id) => {
const style = nextHead.querySelector(`style[${VITE_ID}="${id}"]`);
if (style && !newDocument.head.querySelector(`style[${VITE_ID}="${id}"]`)) {
newDocument.head.appendChild(style.cloneNode(true));
}
});
}
// return a promise that resolves when all astro-islands are hydrated
async function hydrationDone(loadingPage) {
if (!signal.aborted) {
await new Promise((r) => loadingPage.contentWindow?.addEventListener("load", r, { once: true }));
}
return new Promise(async (r) => {
for (let count = 0; count <= 20; ++count) {
if (signal.aborted) break;
if (!loadingPage.contentDocument.body.querySelector("astro-island[ssr]")) break;
await new Promise((r2) => setTimeout(r2, 50));
}
r();
});
}
}
}