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:
parent
9fe4b95969
commit
63bc37f2b6
9 changed files with 586 additions and 434 deletions
17
.changeset/fresh-pots-draw.md
Normal file
17
.changeset/fresh-pots-draw.md
Normal 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);
|
||||
};
|
||||
```
|
7
packages/astro/client.d.ts
vendored
7
packages/astro/client.d.ts
vendored
|
@ -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';
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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', () => {
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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"
|
||||
|
|
437
packages/astro/src/transitions/router.ts
Normal file
437
packages/astro/src/transitions/router.ts
Normal 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();
|
||||
}
|
|
@ -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";
|
||||
`;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue