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
This commit is contained in:
Martin Trapp 2023-09-27 15:30:13 +02:00 committed by GitHub
parent 9fe4b95969
commit 63bc37f2b6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 586 additions and 434 deletions

View file

@ -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);
};
```

View file

@ -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';
}

View file

@ -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>

View file

@ -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>

View file

@ -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', () => {

View file

@ -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');
});
});

View file

@ -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"

View file

@ -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();
}

View file

@ -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";
`;
}
},
};
}