diff options
-rw-r--r-- | .changeset/fresh-pots-draw.md | 17 | ||||
-rw-r--r-- | packages/astro/client.d.ts | 7 | ||||
-rw-r--r-- | packages/astro/components/ViewTransitions.astro | 444 | ||||
-rw-r--r-- | packages/astro/e2e/fixtures/view-transitions/src/pages/six.astro | 20 | ||||
-rw-r--r-- | packages/astro/e2e/fixtures/view-transitions/src/pages/two.astro | 1 | ||||
-rw-r--r-- | packages/astro/e2e/view-transitions.test.js | 81 | ||||
-rw-r--r-- | packages/astro/package.json | 3 | ||||
-rw-r--r-- | packages/astro/src/transitions/router.ts | 437 | ||||
-rw-r--r-- | packages/astro/src/transitions/vite-plugin-transitions.ts | 10 |
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"; + `; + } }, }; } |