summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGravatar Martin Trapp <94928215+martrapp@users.noreply.github.com> 2023-09-27 15:30:13 +0200
committerGravatar GitHub <noreply@github.com> 2023-09-27 15:30:13 +0200
commit63bc37f2b60cf5b093018ae30a2ae3c51da7d22d (patch)
treea3a818f7aa30eea855252179c71f36f80476e462
parent9fe4b9596988dc8b498825eae266805daf4b435b (diff)
downloadastro-63bc37f2b60cf5b093018ae30a2ae3c51da7d22d.tar.gz
astro-63bc37f2b60cf5b093018ae30a2ae3c51da7d22d.tar.zst
astro-63bc37f2b60cf5b093018ae30a2ae3c51da7d22d.zip
API for clientside router (#8571)
* refactored CSR into goto() function * first refectoring for router API * added test * added comments to fixture * rename + preliminary changeset * changeset: now 'minor' and featuring Mathew's example from the docs * moved for simpler diff * update after #8617 * fixed ts-errors * more comprehensible handling of intra-page state * sync with main * synch from next_tm
-rw-r--r--.changeset/fresh-pots-draw.md17
-rw-r--r--packages/astro/client.d.ts7
-rw-r--r--packages/astro/components/ViewTransitions.astro444
-rw-r--r--packages/astro/e2e/fixtures/view-transitions/src/pages/six.astro20
-rw-r--r--packages/astro/e2e/fixtures/view-transitions/src/pages/two.astro1
-rw-r--r--packages/astro/e2e/view-transitions.test.js81
-rw-r--r--packages/astro/package.json3
-rw-r--r--packages/astro/src/transitions/router.ts437
-rw-r--r--packages/astro/src/transitions/vite-plugin-transitions.ts10
9 files changed, 586 insertions, 434 deletions
diff --git a/.changeset/fresh-pots-draw.md b/.changeset/fresh-pots-draw.md
new file mode 100644
index 000000000..dde314c42
--- /dev/null
+++ b/.changeset/fresh-pots-draw.md
@@ -0,0 +1,17 @@
+---
+'astro': minor
+---
+
+View transitions can now be triggered from JavaScript!
+
+Import the client-side router from "astro:transitions/client" and enjoy your new remote control for navigation:
+
+```js
+import { navigate } from 'astro:transitions/client';
+
+// Navigate to the selected option automatically.
+document.querySelector('select').onchange = (ev) => {
+ let href = ev.target.value;
+ navigate(href);
+};
+```
diff --git a/packages/astro/client.d.ts b/packages/astro/client.d.ts
index 5f396b522..c75ae7971 100644
--- a/packages/astro/client.d.ts
+++ b/packages/astro/client.d.ts
@@ -120,6 +120,13 @@ declare module 'astro:transitions' {
export const ViewTransitions: ViewTransitionsModule['default'];
}
+declare module 'astro:transitions/client' {
+ type TransitionRouterModule = typeof import('./dist/transitions/router.js');
+ export const supportsViewTransitions: TransitionRouterModule['supportsViewTransitions'];
+ export const transitionEnabledOnThisPage: TransitionRouterModule['transitionEnabledOnThisPage'];
+ export const navigate: TransitionRouterModule['navigate'];
+}
+
declare module 'astro:middleware' {
export * from 'astro/middleware/namespace';
}
diff --git a/packages/astro/components/ViewTransitions.astro b/packages/astro/components/ViewTransitions.astro
index 230b2f302..65a7af8a4 100644
--- a/packages/astro/components/ViewTransitions.astro
+++ b/packages/astro/components/ViewTransitions.astro
@@ -11,91 +11,12 @@ const { fallback = 'animate' } = Astro.props as Props;
<meta name="astro-view-transitions-enabled" content="true" />
<meta name="astro-view-transitions-fallback" content={fallback} />
<script>
- type Fallback = 'none' | 'animate' | 'swap';
- type Direction = 'forward' | 'back';
- type State = {
- index: number;
- scrollX: number;
- scrollY: number;
- intraPage?: boolean;
- };
- type Events = 'astro:page-load' | 'astro:after-swap';
-
- // only update history entries that are managed by us
- // leave other entries alone and do not accidently add state.
- const persistState = (state: State) => history.state && history.replaceState(state, '');
- // @ts-expect-error: startViewTransition might exist
- const supportsViewTransitions = !!document.startViewTransition;
- const transitionEnabledOnThisPage = () =>
- !!document.querySelector('[name="astro-view-transitions-enabled"]');
- const triggerEvent = (name: Events) => document.dispatchEvent(new Event(name));
- const onPageLoad = () => triggerEvent('astro:page-load');
- const PERSIST_ATTR = 'data-astro-transition-persist';
- const parser = new DOMParser();
- // explained at its usage
- let noopEl: HTMLDivElement;
- if (import.meta.env.DEV) {
- noopEl = document.createElement('div');
- }
-
- // 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 (history.state) {
- // 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()) {
- history.replaceState({ index: currentHistoryIndex, scrollX, scrollY, intraPage: false }, '');
- }
- const throttle = (cb: (...args: any[]) => any, delay: number) => {
- let wait = false;
- // During the waiting time additional events are lost.
- // So repeat the callback at the end if we have swallowed events.
- let onceMore = false;
- return (...args: any[]) => {
- if (wait) {
- onceMore = true;
- return;
- }
- cb(...args);
- wait = true;
- setTimeout(() => {
- if (onceMore) {
- onceMore = false;
- cb(...args);
- }
- wait = false;
- }, delay);
- };
- };
-
- // returns the contents of the page or null if the router can't deal with it.
- async function fetchHTML(
- href: string
- ): Promise<null | { html: string; redirected?: string; mediaType: DOMParserSupportedType }> {
- try {
- const res = await fetch(href);
- // drop potential charset (+ other name/value pairs) as parser needs the mediaType
- const mediaType = res.headers.get('content-type')?.replace(/;.*$/, '');
- // 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 (err) {
- // can't fetch, let someone else deal with it.
- return null;
- }
- }
+ import {
+ supportsViewTransitions,
+ transitionEnabledOnThisPage,
+ navigate,
+ } from 'astro:transitions/client';
+ export type Fallback = 'none' | 'animate' | 'swap';
function getFallback(): Fallback {
const el = document.querySelector('[name="astro-view-transitions-fallback"]');
@@ -105,264 +26,7 @@ const { fallback = 'animate' } = Astro.props as Props;
return 'animate';
}
- function markScriptsExec() {
- for (const script of document.scripts) {
- script.dataset.astroExec = '';
- }
- }
-
- function runScripts() {
- let wait = Promise.resolve();
- for (const script of Array.from(document.scripts)) {
- if (script.dataset.astroExec === '') 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 = r;
- });
- wait = wait.then(() => p as any);
- }
- newScript.setAttribute(attr.name, attr.value);
- }
- newScript.dataset.astroExec = '';
- script.replaceWith(newScript);
- }
- return wait;
- }
-
- function isInfinite(animation: 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 updateHistoryAndScrollPosition = (toLocation) => {
- if (toLocation.href !== location.href) {
- history.pushState(
- { index: ++currentHistoryIndex, scrollX: 0, scrollY: 0 },
- '',
- toLocation.href
- );
- // now we are on the new page for non-history navigations!
- // (with history navigation page change happens before popstate is fired)
- }
- // freshly loaded pages start from the top
- scrollTo({ left: 0, top: 0, behavior: 'instant' });
-
- if (toLocation.hash) {
- // because we are already on the target page ...
- // ... what comes next is a intra-page navigation
- // that won't reload the page but instead scroll to the fragment
- location.href = toLocation.href;
- }
- };
-
- // 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(
- newDocument: Document,
- toLocation: URL,
- popState?: State,
- fallback?: Fallback
- ) {
- // Check for a head element that should persist and returns it,
- // either because it has the data attribute or is a link el.
- const persistedHeadElement = (el: HTMLElement): Element | null => {
- const id = el.getAttribute(PERSIST_ATTR);
- const newEl = id && newDocument.head.querySelector(`[${PERSIST_ATTR}="${id}"]`);
- if (newEl) {
- return newEl;
- }
- if (el.matches('link[rel=stylesheet]')) {
- const href = el.getAttribute('href');
- return newDocument.head.querySelector(`link[rel=stylesheet][href="${href}"]`);
- }
- // What follows is a fix for an issue (#8472) with missing client:only styles after transition.
- // That problem exists only in dev mode where styles are injected into the page by Vite.
- // Returning a noop element ensures that the styles are not removed from the old document.
- // Guarding the code below with the dev mode check
- // allows tree shaking to remove this code in production.
- if (import.meta.env.DEV) {
- if (el.tagName === 'STYLE' && el.dataset.viteDevId) {
- const devId = el.dataset.viteDevId;
- // If this same style tag exists, remove it from the new page
- return (
- newDocument.querySelector(`style[data-astro-dev-id="${devId}"]`) ||
- // Otherwise, keep it anyways. This is client:only styles.
- noopEl
- );
- }
- }
- return null;
- };
-
- const swap = () => {
- // swap attributes of the html element
- // - delete all attributes from the current document
- // - insert all attributes from doc
- // - reinsert all original attributes that are named 'data-astro-*'
- const html = document.documentElement;
- const astro = [...html.attributes].filter(
- ({ name }) => (html.removeAttribute(name), name.startsWith('data-astro-'))
- );
- [...newDocument.documentElement.attributes, ...astro].forEach(({ name, value }) =>
- html.setAttribute(name, value)
- );
-
- // Replace scripts in both the head and body.
- for (const s1 of document.scripts) {
- for (const s2 of newDocument.scripts) {
- if (
- // Inline
- (!s1.src && s1.textContent === s2.textContent) ||
- // External
- (s1.src && s1.type === s2.type && s1.src === s2.src)
- ) {
- // the old script is in the new document: we mark it as executed to prevent re-execution
- s2.dataset.astroExec = '';
- break;
- }
- }
- }
-
- // Swap head
- for (const el of Array.from(document.head.children)) {
- const newEl = persistedHeadElement(el as HTMLElement);
- // 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 {
- // Otherwise remove the element in the head. It doesn't exist in the new page.
- el.remove();
- }
- }
-
- // Everything left in the new head is new, append it all.
- document.head.append(...newDocument.head.children);
-
- // Persist elements in the existing body
- const oldBody = document.body;
-
- // this will reset scroll Position
- document.body.replaceWith(newDocument.body);
- for (const el of oldBody.querySelectorAll(`[${PERSIST_ATTR}]`)) {
- const id = el.getAttribute(PERSIST_ATTR);
- const newEl = document.querySelector(`[${PERSIST_ATTR}="${id}"]`);
- if (newEl) {
- // The element exists in the new page, replace it with the element
- // from the old page so that state is preserved.
- newEl.replaceWith(el);
- }
- }
-
- if (popState) {
- scrollTo(popState.scrollX, popState.scrollY); // usings 'auto' scrollBehavior
- } else {
- updateHistoryAndScrollPosition(toLocation);
- }
-
- triggerEvent('astro:after-swap');
- };
-
- // Wait on links to finish, to prevent FOUC
- const links: Promise<any>[] = [];
- 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]`
- )
- ) {
- const c = document.createElement('link');
- c.setAttribute('rel', 'preload');
- c.setAttribute('as', 'style');
- c.setAttribute('href', el.getAttribute('href')!);
- links.push(
- new Promise<any>((resolve) => {
- ['load', 'error'].forEach((evName) => c.addEventListener(evName, resolve));
- document.head.append(c);
- })
- );
- }
- }
- links.length && (await Promise.all(links));
-
- if (fallback === 'animate') {
- // Trigger the animations
- const currentAnimations = document.getAnimations();
- document.documentElement.dataset.astroTransitionFallback = 'old';
- const newAnimations = document
- .getAnimations()
- .filter((a) => !currentAnimations.includes(a) && !isInfinite(a));
- const finished = Promise.all(newAnimations.map((a) => a.finished));
- const fallbackSwap = () => {
- swap();
- document.documentElement.dataset.astroTransitionFallback = 'new';
- };
- await finished;
- fallbackSwap();
- } else {
- swap();
- }
- }
-
- async function transition(direction: Direction, toLocation: URL, popState?: State) {
- let finished: Promise<void>;
- const href = toLocation.href;
- const response = await fetchHTML(href);
- // If there is a problem fetching the new page, just do an MPA navigation to it.
- if (response === null) {
- location.href = href;
- return;
- }
- // if there was a redirection, show the final URL in the browser's address bar
- if (response.redirected) {
- toLocation = new URL(response.redirected);
- }
-
- const 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
- newDocument.querySelectorAll('noscript').forEach((el) => el.remove());
-
- if (!newDocument.querySelector('[name="astro-view-transitions-enabled"]')) {
- location.href = href;
- return;
- }
-
- if (!popState) {
- // save the current scroll position before we change the DOM and transition to the new page
- history.replaceState({ ...history.state, scrollX, scrollY }, '');
- }
- document.documentElement.dataset.astroTransition = direction;
- if (supportsViewTransitions) {
- // @ts-expect-error: startViewTransition exist
- finished = document.startViewTransition(() =>
- updateDOM(newDocument, toLocation, popState)
- ).finished;
- } else {
- finished = updateDOM(newDocument, toLocation, popState, getFallback());
- }
- try {
- await finished;
- } finally {
- // skip this for the moment as it tends to stop fallback animations
- // document.documentElement.removeAttribute('data-astro-transition');
- await runScripts();
- markScriptsExec();
- onPageLoad();
- }
- }
-
- // Prefetching
+ // Prefetching
function maybePrefetch(pathname: string) {
if (document.querySelector(`link[rel=prefetch][href="${pathname}"]`)) return;
// @ts-expect-error: connection might exist
@@ -406,86 +70,12 @@ const { fallback = 'animate' } = Astro.props as Props;
return;
}
ev.preventDefault();
- navigate(link.href);
+ navigate(link.href, {
+ history: link.dataset.astroHistory === 'replace' ? 'replace' : 'auto',
+ });
});
- function navigate(href) {
- // not ours
- if (!transitionEnabledOnThisPage()) {
- location.href = href;
- return;
- }
- const toLocation = new URL(href, location.href);
- // We do not have page transitions on navigations to the same page (intra-page navigation)
- // but we want to handle prevent reload on navigation to the same page
- // Same page means same origin, path and query params (but maybe different hash)
- if (
- location.origin === toLocation.origin &&
- location.pathname === toLocation.pathname &&
- location.search === toLocation.search
- ) {
- // mark current position as non transition intra-page scrolling
- if (location.href !== toLocation.href) {
- history.replaceState({ ...history.state, intraPage: true }, '');
- history.pushState(
- { index: ++currentHistoryIndex, scrollX: 0, scrollY: 0 },
- '',
- toLocation.href
- );
- }
- if (toLocation.hash) {
- location.href = toLocation.href;
- } else {
- scrollTo({ left: 0, top: 0, behavior: 'instant' });
- }
- } else {
- transition('forward', toLocation);
- }
- }
-
- addEventListener('popstate', (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.
- // Scroll restauration will then happen during the reload when the router's code is re-executed
- if (history.scrollRestoration) {
- history.scrollRestoration = 'manual';
- }
- 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) {
- if (history.scrollRestoration) {
- history.scrollRestoration = 'auto';
- }
- return;
- }
-
- // With the default "auto", the browser will jump to the old scroll position
- // before the ViewTransition is complete.
- if (history.scrollRestoration) {
- history.scrollRestoration = 'manual';
- }
-
- const state: State = history.state;
- if (state.intraPage) {
- // this is non transition intra-page scrolling
- scrollTo(state.scrollX, state.scrollY);
- } else {
- const nextIndex = state.index;
- const direction: Direction = nextIndex > currentHistoryIndex ? 'forward' : 'back';
- currentHistoryIndex = nextIndex;
- transition(direction, new URL(location.href), state);
- }
- });
-
- ['mouseenter', 'touchstart', 'focus'].forEach((evName) => {
+ ['mouseenter', 'touchstart', 'focus'].forEach((evName) => {
document.addEventListener(
evName,
(ev) => {
@@ -503,17 +93,5 @@ const { fallback = 'animate' } = Astro.props as Props;
{ passive: true, capture: true }
);
});
-
- addEventListener('load', onPageLoad);
- // There's not a good way to record scroll position before a back button.
- // So the way we do it is by listening to scrollend if supported, and if not continuously record the scroll position.
- const updateState = () => {
- persistState({ ...history.state, scrollX, scrollY });
- };
-
- if ('onscrollend' in window) addEventListener('scrollend', updateState);
- else addEventListener('scroll', throttle(updateState, 300));
-
- markScriptsExec();
}
</script>
diff --git a/packages/astro/e2e/fixtures/view-transitions/src/pages/six.astro b/packages/astro/e2e/fixtures/view-transitions/src/pages/six.astro
new file mode 100644
index 000000000..720378206
--- /dev/null
+++ b/packages/astro/e2e/fixtures/view-transitions/src/pages/six.astro
@@ -0,0 +1,20 @@
+---
+import Layout from '../components/Layout.astro';
+---
+<Layout>
+ <p id="six">Page 6</p>
+ <a id="click-one" href="/one">test</a>
+ <div id="test">test content</div>
+ <script>
+ import { navigate } from "astro:transitions/client";
+
+ // this is to simulate client side use, will be triggered from test
+ window.addEventListener('jumpToTwo', ()=>navigate('/two'));
+
+ // this is a holder to pick up the router in additional tests
+ window.clientSideRouterForTestsParkedHere = navigate
+
+ // this is the direct use of the router in this page, redirecting to page one
+ navigate('/one');
+ </script>
+</Layout>
diff --git a/packages/astro/e2e/fixtures/view-transitions/src/pages/two.astro b/packages/astro/e2e/fixtures/view-transitions/src/pages/two.astro
index af67edbd5..b77679d7f 100644
--- a/packages/astro/e2e/fixtures/view-transitions/src/pages/two.astro
+++ b/packages/astro/e2e/fixtures/view-transitions/src/pages/two.astro
@@ -4,6 +4,7 @@ import Layout from '../components/Layout.astro';
<Layout link="/two.css">
<p id="two">Page 2</p>
<article id="twoarticle"></article>
+ <a id="click-longpage" data-astro-history="replace" href="/long-page">go to long page</a>
</Layout>
<script>
document.addEventListener('astro:page-load', () => {
diff --git a/packages/astro/e2e/view-transitions.test.js b/packages/astro/e2e/view-transitions.test.js
index b06d5a988..05a8f8ad0 100644
--- a/packages/astro/e2e/view-transitions.test.js
+++ b/packages/astro/e2e/view-transitions.test.js
@@ -680,6 +680,39 @@ test.describe('View Transitions', () => {
await expect(locator).not.toBeInViewport();
});
+ test('Use the client side router', async ({ page, astro }) => {
+ await page.goto(astro.resolveUrl('/six'));
+ // page six loads the router and automatically uses the router to navigate to page 1
+ let p = page.locator('#one');
+ await expect(p, 'should have content').toHaveText('Page 1');
+
+ // nudge to jump to page 2
+ await page.evaluate(() => {
+ window.dispatchEvent(new Event('jumpToTwo'));
+ });
+ p = page.locator('#two');
+ await expect(p, 'should have content').toHaveText('Page 2');
+
+ // jump to page 3
+ await page.evaluate(() => {
+ // get the router from its fixture park position
+ const navigate = window.clientSideRouterForTestsParkedHere;
+ navigate('/three');
+ });
+ p = page.locator('#three');
+ await expect(p, 'should have content').toHaveText('Page 3');
+
+ // go back
+ await page.goBack();
+ p = page.locator('#two');
+ await expect(p, 'should have content').toHaveText('Page 2');
+
+ // no bad things happen when we revisit redirecting to page 6
+ await page.goto(astro.resolveUrl('/six'));
+ p = page.locator('#one');
+ await expect(p, 'should have content').toHaveText('Page 1');
+ });
+
test('body inline scripts do not re-execute on navigation', async ({ page, astro }) => {
const errors = [];
page.addListener('pageerror', (err) => {
@@ -697,4 +730,52 @@ test.describe('View Transitions', () => {
expect(errors).toHaveLength(0);
});
+
+ test('replace history', async ({ page, astro }) => {
+ await page.goto(astro.resolveUrl('/one'));
+ // page six loads the router and automatically uses the router to navigate to page 1
+ let p = page.locator('#one');
+ await expect(p, 'should have content').toHaveText('Page 1');
+
+ // go to page 2
+ await page.click('#click-two');
+ p = page.locator('#two');
+ await expect(p, 'should have content').toHaveText('Page 2');
+
+ // replace with long page
+ await page.click('#click-longpage');
+ let article = page.locator('#longpage');
+ await expect(article, 'should have script content').toBeVisible('exists');
+
+ // one step back == #1
+ await page.goBack();
+ p = page.locator('#one');
+ await expect(p, 'should have content').toHaveText('Page 1');
+ });
+
+ test('CSR replace history', async ({ page, astro }) => {
+ await page.goto(astro.resolveUrl('/six'));
+ // page six loads the router and automatically uses the router to navigate to page 1
+ let p = page.locator('#one');
+ await expect(p, 'should have content').toHaveText('Page 1');
+
+ // goto #2
+ await page.evaluate(() => {
+ window.clientSideRouterForTestsParkedHere('/two', { history: 'auto' });
+ });
+ p = page.locator('#two');
+ await expect(p, 'should have content').toHaveText('Page 2');
+
+ // replace with long page
+ await page.evaluate(() => {
+ window.clientSideRouterForTestsParkedHere('/long-page', { history: 'replace' });
+ });
+ let article = page.locator('#longpage');
+ await expect(article, 'should have script content').toBeVisible('exists');
+
+ // one step back == #1
+ await page.goBack();
+ p = page.locator('#one');
+ await expect(p, 'should have content').toHaveText('Page 1');
+ });
});
diff --git a/packages/astro/package.json b/packages/astro/package.json
index 530453f17..8eed917a3 100644
--- a/packages/astro/package.json
+++ b/packages/astro/package.json
@@ -77,7 +77,8 @@
"types": "./dist/core/middleware/namespace.d.ts",
"default": "./dist/core/middleware/namespace.js"
},
- "./transitions": "./dist/transitions/index.js"
+ "./transitions": "./dist/transitions/index.js",
+ "./transitions/router": "./dist/transitions/router.js"
},
"imports": {
"#astro/*": "./dist/*.js"
diff --git a/packages/astro/src/transitions/router.ts b/packages/astro/src/transitions/router.ts
new file mode 100644
index 000000000..990414c6a
--- /dev/null
+++ b/packages/astro/src/transitions/router.ts
@@ -0,0 +1,437 @@
+export type Fallback = 'none' | 'animate' | 'swap';
+export type Direction = 'forward' | 'back';
+export type Options = { history?: 'auto' | 'push' | 'replace' };
+
+type State = {
+ index: number;
+ scrollX: number;
+ scrollY: number;
+ intraPage?: boolean;
+};
+type Events = 'astro:page-load' | 'astro:after-swap';
+
+// only update history entries that are managed by us
+// leave other entries alone and do not accidently add state.
+const persistState = (state: State) => history.state && history.replaceState(state, '');
+export const supportsViewTransitions = !!document.startViewTransition;
+export const transitionEnabledOnThisPage = () =>
+ !!document.querySelector('[name="astro-view-transitions-enabled"]');
+const samePage = (otherLocation: URL) =>
+ location.pathname === otherLocation.pathname && location.search === otherLocation.search;
+const triggerEvent = (name: Events) => document.dispatchEvent(new Event(name));
+const onPageLoad = () => triggerEvent('astro:page-load');
+const PERSIST_ATTR = 'data-astro-transition-persist';
+const parser = new DOMParser();
+// explained at its usage
+let noopEl: HTMLDivElement;
+if (import.meta.env.DEV) {
+ noopEl = document.createElement('div');
+}
+
+// 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 (history.state) {
+ // 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()) {
+ history.replaceState({ index: currentHistoryIndex, scrollX, scrollY, intraPage: false }, '');
+}
+const throttle = (cb: (...args: any[]) => any, delay: number) => {
+ let wait = false;
+ // During the waiting time additional events are lost.
+ // So repeat the callback at the end if we have swallowed events.
+ let onceMore = false;
+ return (...args: any[]) => {
+ if (wait) {
+ onceMore = true;
+ return;
+ }
+ cb(...args);
+ wait = true;
+ setTimeout(() => {
+ if (onceMore) {
+ onceMore = false;
+ cb(...args);
+ }
+ wait = false;
+ }, delay);
+ };
+};
+
+// returns the contents of the page or null if the router can't deal with it.
+async function fetchHTML(
+ href: string
+): Promise<null | { html: string; redirected?: string; mediaType: DOMParserSupportedType }> {
+ try {
+ const res = await fetch(href);
+ // drop potential charset (+ other name/value pairs) as parser needs the mediaType
+ const mediaType = res.headers.get('content-type')?.replace(/;.*$/, '');
+ // 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 (err) {
+ // can't fetch, let someone else deal with it.
+ return null;
+ }
+}
+
+function getFallback(): Fallback {
+ const el = document.querySelector('[name="astro-view-transitions-fallback"]');
+ if (el) {
+ return el.getAttribute('content') as Fallback;
+ }
+ return 'animate';
+}
+
+function markScriptsExec() {
+ for (const script of document.scripts) {
+ script.dataset.astroExec = '';
+ }
+}
+
+function runScripts() {
+ let wait = Promise.resolve();
+ for (const script of Array.from(document.scripts)) {
+ if (script.dataset.astroExec === '') 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 = r;
+ });
+ wait = wait.then(() => p as any);
+ }
+ newScript.setAttribute(attr.name, attr.value);
+ }
+ newScript.dataset.astroExec = '';
+ script.replaceWith(newScript);
+ }
+ return wait;
+}
+
+function isInfinite(animation: 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 updateHistoryAndScrollPosition = (toLocation: URL, replace: boolean, intraPage: boolean) => {
+ const fresh = !samePage(toLocation);
+ if (toLocation.href !== location.href) {
+ if (replace) {
+ history.replaceState({ ...history.state }, '', toLocation.href);
+ } else {
+ history.replaceState({ ...history.state, intraPage }, '');
+ history.pushState({ index: ++currentHistoryIndex, scrollX, scrollY }, '', toLocation.href);
+ }
+ // now we are on the new page for non-history navigations!
+ // (with history navigation page change happens before popstate is fired)
+ // freshly loaded pages start from the top
+ if (fresh) {
+ scrollTo({ left: 0, top: 0, behavior: 'instant' });
+ }
+ }
+ if (toLocation.hash) {
+ // because we are already on the target page ...
+ // ... what comes next is a intra-page navigation
+ // that won't reload the page but instead scroll to the fragment
+ location.href = toLocation.href;
+ } else {
+ scrollTo({ left: 0, top: 0, behavior: 'instant' });
+ }
+};
+
+// 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(
+ newDocument: Document,
+ toLocation: URL,
+ options: Options,
+ popState?: State,
+ fallback?: Fallback
+) {
+ // Check for a head element that should persist and returns it,
+ // either because it has the data attribute or is a link el.
+ const persistedHeadElement = (el: HTMLElement): Element | null => {
+ const id = el.getAttribute(PERSIST_ATTR);
+ const newEl = id && newDocument.head.querySelector(`[${PERSIST_ATTR}="${id}"]`);
+ if (newEl) {
+ return newEl;
+ }
+ if (el.matches('link[rel=stylesheet]')) {
+ const href = el.getAttribute('href');
+ return newDocument.head.querySelector(`link[rel=stylesheet][href="${href}"]`);
+ }
+ // What follows is a fix for an issue (#8472) with missing client:only styles after transition.
+ // That problem exists only in dev mode where styles are injected into the page by Vite.
+ // Returning a noop element ensures that the styles are not removed from the old document.
+ // Guarding the code below with the dev mode check
+ // allows tree shaking to remove this code in production.
+ if (import.meta.env.DEV) {
+ if (el.tagName === 'STYLE' && el.dataset.viteDevId) {
+ const devId = el.dataset.viteDevId;
+ // If this same style tag exists, remove it from the new page
+ return (
+ newDocument.querySelector(`style[data-astro-dev-id="${devId}"]`) ||
+ // Otherwise, keep it anyways. This is client:only styles.
+ noopEl
+ );
+ }
+ }
+ return null;
+ };
+
+ const swap = () => {
+ // swap attributes of the html element
+ // - delete all attributes from the current document
+ // - insert all attributes from doc
+ // - reinsert all original attributes that are named 'data-astro-*'
+ const html = document.documentElement;
+ const astro = [...html.attributes].filter(
+ ({ name }) => (html.removeAttribute(name), name.startsWith('data-astro-'))
+ );
+ [...newDocument.documentElement.attributes, ...astro].forEach(({ name, value }) =>
+ html.setAttribute(name, value)
+ );
+
+ // Replace scripts in both the head and body.
+ for (const s1 of document.scripts) {
+ for (const s2 of newDocument.scripts) {
+ if (
+ // Inline
+ (!s1.src && s1.textContent === s2.textContent) ||
+ // External
+ (s1.src && s1.type === s2.type && s1.src === s2.src)
+ ) {
+ // the old script is in the new document: we mark it as executed to prevent re-execution
+ s2.dataset.astroExec = '';
+ break;
+ }
+ }
+ }
+
+ // Swap head
+ for (const el of Array.from(document.head.children)) {
+ const newEl = persistedHeadElement(el as HTMLElement);
+ // 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 {
+ // Otherwise remove the element in the head. It doesn't exist in the new page.
+ el.remove();
+ }
+ }
+
+ // Everything left in the new head is new, append it all.
+ document.head.append(...newDocument.head.children);
+
+ // Persist elements in the existing body
+ const oldBody = document.body;
+
+ // this will reset scroll Position
+ document.body.replaceWith(newDocument.body);
+ for (const el of oldBody.querySelectorAll(`[${PERSIST_ATTR}]`)) {
+ const id = el.getAttribute(PERSIST_ATTR);
+ const newEl = document.querySelector(`[${PERSIST_ATTR}="${id}"]`);
+ if (newEl) {
+ // The element exists in the new page, replace it with the element
+ // from the old page so that state is preserved.
+ newEl.replaceWith(el);
+ }
+ }
+
+ if (popState) {
+ scrollTo(popState.scrollX, popState.scrollY); // usings 'auto' scrollBehavior
+ } else {
+ updateHistoryAndScrollPosition(toLocation, options.history === 'replace', false);
+ }
+
+ triggerEvent('astro:after-swap');
+ };
+
+ // Wait on links to finish, to prevent FOUC
+ const links: Promise<any>[] = [];
+ 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]`
+ )
+ ) {
+ const c = document.createElement('link');
+ c.setAttribute('rel', 'preload');
+ c.setAttribute('as', 'style');
+ c.setAttribute('href', el.getAttribute('href')!);
+ links.push(
+ new Promise<any>((resolve) => {
+ ['load', 'error'].forEach((evName) => c.addEventListener(evName, resolve));
+ document.head.append(c);
+ })
+ );
+ }
+ }
+ links.length && (await Promise.all(links));
+
+ if (fallback === 'animate') {
+ // Trigger the animations
+ const currentAnimations = document.getAnimations();
+ document.documentElement.dataset.astroTransitionFallback = 'old';
+ const newAnimations = document
+ .getAnimations()
+ .filter((a) => !currentAnimations.includes(a) && !isInfinite(a));
+ const finished = Promise.all(newAnimations.map((a) => a.finished));
+ const fallbackSwap = () => {
+ swap();
+ document.documentElement.dataset.astroTransitionFallback = 'new';
+ };
+ await finished;
+ fallbackSwap();
+ } else {
+ swap();
+ }
+}
+
+async function transition(
+ direction: Direction,
+ toLocation: URL,
+ options: Options,
+ popState?: State
+) {
+ let finished: Promise<void>;
+ const href = toLocation.href;
+ const response = await fetchHTML(href);
+ // If there is a problem fetching the new page, just do an MPA navigation to it.
+ if (response === null) {
+ location.href = href;
+ return;
+ }
+ // if there was a redirection, show the final URL in the browser's address bar
+ if (response.redirected) {
+ toLocation = new URL(response.redirected);
+ }
+
+ const 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
+ newDocument.querySelectorAll('noscript').forEach((el) => el.remove());
+
+ if (!newDocument.querySelector('[name="astro-view-transitions-enabled"]')) {
+ location.href = href;
+ return;
+ }
+
+ if (!popState) {
+ // save the current scroll position before we change the DOM and transition to the new page
+ history.replaceState({ ...history.state, scrollX, scrollY }, '');
+ }
+ document.documentElement.dataset.astroTransition = direction;
+ if (supportsViewTransitions) {
+ finished = document.startViewTransition(() =>
+ updateDOM(newDocument, toLocation, options, popState)
+ ).finished;
+ } else {
+ finished = updateDOM(newDocument, toLocation, options, popState, getFallback());
+ }
+ try {
+ await finished;
+ } finally {
+ // skip this for the moment as it tends to stop fallback animations
+ // document.documentElement.removeAttribute('data-astro-transition');
+ await runScripts();
+ markScriptsExec();
+ onPageLoad();
+ }
+}
+
+export function navigate(href: string, options?: Options) {
+ // not ours
+ if (!transitionEnabledOnThisPage()) {
+ location.href = href;
+ return;
+ }
+ const toLocation = new URL(href, location.href);
+ // We do not have page transitions on navigations to the same page (intra-page navigation)
+ // but we want to handle prevent reload on navigation to the same page
+ // Same page means same origin, path and query params (but maybe different hash)
+ if (location.origin === toLocation.origin && samePage(toLocation)) {
+ updateHistoryAndScrollPosition(toLocation, options?.history === 'replace', true);
+ } else {
+ // different origin will be detected by fetch
+ transition('forward', toLocation, options ?? {});
+ }
+}
+
+if (supportsViewTransitions || getFallback() !== 'none') {
+ addEventListener('popstate', (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.
+ // Scroll restauration will then happen during the reload when the router's code is re-executed
+ if (history.scrollRestoration) {
+ history.scrollRestoration = 'manual';
+ }
+ 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) {
+ if (history.scrollRestoration) {
+ history.scrollRestoration = 'auto';
+ }
+ return;
+ }
+
+ // With the default "auto", the browser will jump to the old scroll position
+ // before the ViewTransition is complete.
+ if (history.scrollRestoration) {
+ history.scrollRestoration = 'manual';
+ }
+
+ const state: State = history.state;
+ if (state.intraPage) {
+ // this is non transition intra-page scrolling
+ scrollTo(state.scrollX, state.scrollY);
+ } else {
+ const nextIndex = state.index;
+ const direction: Direction = nextIndex > currentHistoryIndex ? 'forward' : 'back';
+ currentHistoryIndex = nextIndex;
+ transition(direction, new URL(location.href), {}, state);
+ }
+ });
+
+ addEventListener('load', onPageLoad);
+ // There's not a good way to record scroll position before a back button.
+ // So the way we do it is by listening to scrollend if supported, and if not continuously record the scroll position.
+ const updateState = () => {
+ persistState({ ...history.state, scrollX, scrollY });
+ };
+
+ if ('onscrollend' in window) addEventListener('scrollend', updateState);
+ else addEventListener('scroll', throttle(updateState, 300));
+
+ markScriptsExec();
+}
diff --git a/packages/astro/src/transitions/vite-plugin-transitions.ts b/packages/astro/src/transitions/vite-plugin-transitions.ts
index 188ebbc61..e530fc1e2 100644
--- a/packages/astro/src/transitions/vite-plugin-transitions.ts
+++ b/packages/astro/src/transitions/vite-plugin-transitions.ts
@@ -2,6 +2,8 @@ import * as vite from 'vite';
const virtualModuleId = 'astro:transitions';
const resolvedVirtualModuleId = '\0' + virtualModuleId;
+const virtualClientModuleId = 'astro:transitions/client';
+const resolvedVirtualClientModuleId = '\0' + virtualClientModuleId;
// The virtual module for the astro:transitions namespace
export default function astroTransitions(): vite.Plugin {
@@ -11,6 +13,9 @@ export default function astroTransitions(): vite.Plugin {
if (id === virtualModuleId) {
return resolvedVirtualModuleId;
}
+ if (id === virtualClientModuleId) {
+ return resolvedVirtualClientModuleId;
+ }
},
load(id) {
if (id === resolvedVirtualModuleId) {
@@ -19,6 +24,11 @@ export default function astroTransitions(): vite.Plugin {
export { default as ViewTransitions } from "astro/components/ViewTransitions.astro";
`;
}
+ if (id === resolvedVirtualClientModuleId) {
+ return `
+ export * from "astro/transitions/router";
+ `;
+ }
},
};
}