diff options
18 files changed, 242 insertions, 20 deletions
diff --git a/.changeset/empty-experts-unite.md b/.changeset/empty-experts-unite.md new file mode 100644 index 000000000..78d0f2e86 --- /dev/null +++ b/.changeset/empty-experts-unite.md @@ -0,0 +1,27 @@ +--- +'astro': minor +--- + +Persistent DOM and Islands in Experimental View Transitions + +With `viewTransitions: true` enabled in your Astro config's experimental section, pages using the `<ViewTransition />` routing component can now access a new `transition:persist` directive. + +With this directive, you can keep the state of DOM elements and islands on the old page when transitioning to the new page. + +For example, to keep a video playing across page navigation, add `transition:persist` to the element: + +```astro +<video controls="" autoplay="" transition:persist> + <source src="https://ia804502.us.archive.org/33/items/GoldenGa1939_3/GoldenGa1939_3_512kb.mp4" type="video/mp4"> +</video> +``` + +This `<video>` element, with its current state, will be moved over to the next page (if the video also exists on that page). + +Likewise, this feature works with any client-side framework component island. In this example, a counter's state is preserved and moved to the new page: + +```astro +<Counter count={5} client:load transition:persist /> +``` + +See our [View Transitions Guide](https://docs.astro.build/en/guides/view-transitions/#maintaining-state) to learn more on usage. diff --git a/packages/astro/components/ViewTransitions.astro b/packages/astro/components/ViewTransitions.astro index 7197674db..d08cf3466 100644 --- a/packages/astro/components/ViewTransitions.astro +++ b/packages/astro/components/ViewTransitions.astro @@ -34,6 +34,7 @@ const { fallback = 'animate' } = Astro.props as Props; !!document.querySelector('[name="astro-view-transitions-enabled"]'); const triggerEvent = (name: Events) => document.dispatchEvent(new Event(name)); const onload = () => triggerEvent('astro:load'); + const PERSIST_ATTR = 'data-astro-transition-persist'; const throttle = (cb: (...args: any[]) => any, delay: number) => { let wait = false; @@ -86,9 +87,51 @@ const { fallback = 'animate' } = Astro.props as Props; async function updateDOM(dir: Direction, html: string, state?: State, fallback?: Fallback) { const doc = parser.parseFromString(html, 'text/html'); doc.documentElement.dataset.astroTransition = dir; + + // Check for a head element that should persist, either because it has the data + // attribute or is a link el. + const persistedHeadElement = (el: Element): Element | null => { + const id = el.getAttribute(PERSIST_ATTR); + const newEl = id && doc.head.querySelector(`[${PERSIST_ATTR}="${id}"]`); + if(newEl) { + return newEl; + } + if(el.matches('link[rel=stylesheet]')) { + const href = el.getAttribute('href'); + return doc.head.querySelector(`link[rel=stylesheet][href="${href}"]`); + } + return null; + }; + const swap = () => { - document.documentElement.replaceWith(doc.documentElement); + // Swap head + for(const el of Array.from(document.head.children)) { + const newEl = persistedHeadElement(el); + // 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(...doc.head.children); + // Move over persist stuff in the body + const oldBody = document.body; + document.body.replaceWith(doc.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 (state?.scrollY != null) { scrollTo(0, state.scrollY); } @@ -97,17 +140,21 @@ const { fallback = 'animate' } = Astro.props as Props; }; // Wait on links to finish, to prevent FOUC - const links = Array.from(doc.querySelectorAll('head link[rel=stylesheet]')).map( - (link) => - new Promise((resolve) => { - const c = link.cloneNode(); + const links: Promise<any>[] = []; + for(const el of doc.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); - }) - ); - if (links.length) { - await Promise.all(links); + })); + } } + links.length && await Promise.all(links); if (fallback === 'animate') { let isAnimating = false; diff --git a/packages/astro/e2e/fixtures/view-transitions/astro.config.mjs b/packages/astro/e2e/fixtures/view-transitions/astro.config.mjs index 9e89fa72e..c0df0074c 100644 --- a/packages/astro/e2e/fixtures/view-transitions/astro.config.mjs +++ b/packages/astro/e2e/fixtures/view-transitions/astro.config.mjs @@ -1,9 +1,16 @@ import { defineConfig } from 'astro/config'; +import react from '@astrojs/react'; // https://astro.build/config export default defineConfig({ + integrations: [react()], experimental: { viewTransitions: true, assets: true, }, + vite: { + build: { + assetsInlineLimit: 0, + }, + }, }); diff --git a/packages/astro/e2e/fixtures/view-transitions/package.json b/packages/astro/e2e/fixtures/view-transitions/package.json index 50258fd1a..90a07f839 100644 --- a/packages/astro/e2e/fixtures/view-transitions/package.json +++ b/packages/astro/e2e/fixtures/view-transitions/package.json @@ -3,6 +3,9 @@ "version": "0.0.0", "private": true, "dependencies": { - "astro": "workspace:*" + "astro": "workspace:*", + "@astrojs/react": "workspace:*", + "react": "^18.1.0", + "react-dom": "^18.1.0" } } diff --git a/packages/astro/e2e/fixtures/view-transitions/src/components/Island.css b/packages/astro/e2e/fixtures/view-transitions/src/components/Island.css new file mode 100644 index 000000000..fb21044d7 --- /dev/null +++ b/packages/astro/e2e/fixtures/view-transitions/src/components/Island.css @@ -0,0 +1,11 @@ +.counter { + display: grid; + font-size: 2em; + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-top: 2em; + place-items: center; +} + +.counter-message { + text-align: center; +} diff --git a/packages/astro/e2e/fixtures/view-transitions/src/components/Island.jsx b/packages/astro/e2e/fixtures/view-transitions/src/components/Island.jsx new file mode 100644 index 000000000..cde384980 --- /dev/null +++ b/packages/astro/e2e/fixtures/view-transitions/src/components/Island.jsx @@ -0,0 +1,19 @@ +import React, { useState } from 'react'; +import './Island.css'; + +export default function Counter({ children, count: initialCount, id }) { + const [count, setCount] = useState(initialCount); + const add = () => setCount((i) => i + 1); + const subtract = () => setCount((i) => i - 1); + + return ( + <> + <div id={id} className="counter"> + <button className="decrement" onClick={subtract}>-</button> + <pre>{count}</pre> + <button className="increment" onClick={add}>+</button> + </div> + <div className="counter-message">{children}</div> + </> + ); +} diff --git a/packages/astro/e2e/fixtures/view-transitions/src/components/Video.astro b/packages/astro/e2e/fixtures/view-transitions/src/components/Video.astro new file mode 100644 index 000000000..7235266bc --- /dev/null +++ b/packages/astro/e2e/fixtures/view-transitions/src/components/Video.astro @@ -0,0 +1,3 @@ +<video controls="" autoplay="" name="media" transition:persist transition:name="video"> + <source src="https://ia804502.us.archive.org/33/items/GoldenGa1939_3/GoldenGa1939_3_512kb.mp4" type="video/mp4"> +</video> diff --git a/packages/astro/e2e/fixtures/view-transitions/src/pages/island-one.astro b/packages/astro/e2e/fixtures/view-transitions/src/pages/island-one.astro new file mode 100644 index 000000000..89822a01b --- /dev/null +++ b/packages/astro/e2e/fixtures/view-transitions/src/pages/island-one.astro @@ -0,0 +1,9 @@ +--- +import Layout from '../components/Layout.astro'; +import Island from '../components/Island.jsx'; +--- +<Layout> + <p id="island-one">Page 1</p> + <a id="click-two" href="/island-two">go to 2</a> + <Island count={5} client:load transition:persist transition:name="counter" /> +</Layout> diff --git a/packages/astro/e2e/fixtures/view-transitions/src/pages/island-two.astro b/packages/astro/e2e/fixtures/view-transitions/src/pages/island-two.astro new file mode 100644 index 000000000..3841ca897 --- /dev/null +++ b/packages/astro/e2e/fixtures/view-transitions/src/pages/island-two.astro @@ -0,0 +1,9 @@ +--- +import Layout from '../components/Layout.astro'; +import Island from '../components/Island.jsx'; +--- +<Layout> + <p id="island-two">Page 2</p> + <a id="click-one" href="/island-one">go to 1</a> + <Island count={2} client:load transition:persist transition:name="counter" /> +</Layout> diff --git a/packages/astro/e2e/fixtures/view-transitions/src/pages/video-one.astro b/packages/astro/e2e/fixtures/view-transitions/src/pages/video-one.astro new file mode 100644 index 000000000..76f221c63 --- /dev/null +++ b/packages/astro/e2e/fixtures/view-transitions/src/pages/video-one.astro @@ -0,0 +1,17 @@ +--- +import Layout from '../components/Layout.astro'; +import Video from '../components/Video.astro'; +--- +<Layout> + <p id="video-one">Page 1</p> + <a id="click-two" href="/video-two">go to 2</a> + <Video /> + <script> + const vid = document.querySelector('video'); + vid.addEventListener('canplay', () => { + // Jump to the 1 minute mark + vid.currentTime = 60; + vid.dataset.ready = ''; + }, { once: true }); + </script> +</Layout> diff --git a/packages/astro/e2e/fixtures/view-transitions/src/pages/video-two.astro b/packages/astro/e2e/fixtures/view-transitions/src/pages/video-two.astro new file mode 100644 index 000000000..7a947a85d --- /dev/null +++ b/packages/astro/e2e/fixtures/view-transitions/src/pages/video-two.astro @@ -0,0 +1,14 @@ +--- +import Layout from '../components/Layout.astro'; +import Video from '../components/Video.astro'; +--- +<style> + #video-two { + color: blue; + } +</style> +<Layout> + <p id="video-two">Page 2</p> + <a id="click-one" href="/video-one">go to 1</a> + <Video /> +</Layout> diff --git a/packages/astro/e2e/view-transitions.test.js b/packages/astro/e2e/view-transitions.test.js index e71b892ba..2498a5a8a 100644 --- a/packages/astro/e2e/view-transitions.test.js +++ b/packages/astro/e2e/view-transitions.test.js @@ -243,4 +243,40 @@ test.describe('View Transitions', () => { const img = page.locator('img[data-astro-transition-scope]'); await expect(img).toBeVisible('The image tag should have the transition scope attribute.'); }); + + test('<video> can persist using transition:persist', async ({ page, astro }) => { + const getTime = () => document.querySelector('video').currentTime; + + // Go to page 1 + await page.goto(astro.resolveUrl('/video-one')); + const vid = page.locator('video[data-ready]'); + await expect(vid).toBeVisible(); + const firstTime = await page.evaluate(getTime); + + // Navigate to page 2 + await page.click('#click-two'); + const p = page.locator('#video-two'); + await expect(p).toBeVisible(); + const secondTime = await page.evaluate(getTime); + + expect(secondTime).toBeGreaterThanOrEqual(firstTime); + }); + + test('Islands can persist using transition:persist', async ({ page, astro }) => { + // Go to page 1 + await page.goto(astro.resolveUrl('/island-one')); + let cnt = page.locator('.counter pre'); + await expect(cnt).toHaveText('5'); + + await page.click('.increment'); + await expect(cnt).toHaveText('6'); + + // Navigate to page 2 + await page.click('#click-two'); + const p = page.locator('#island-two'); + await expect(p).toBeVisible(); + cnt = page.locator('.counter pre'); + // Count should remain + await expect(cnt).toHaveText('6'); + }); }); diff --git a/packages/astro/package.json b/packages/astro/package.json index e093a4f1d..dfd5badf4 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -115,7 +115,7 @@ "test:e2e:match": "playwright test -g" }, "dependencies": { - "@astrojs/compiler": "^1.6.3", + "@astrojs/compiler": "^1.8.0", "@astrojs/internal-helpers": "^0.1.1", "@astrojs/language-server": "^1.0.0", "@astrojs/markdown-remark": "^2.2.1", diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 59e295eba..1c7152fec 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -92,6 +92,7 @@ export interface AstroBuiltinAttributes { 'is:raw'?: boolean; 'transition:animate'?: 'morph' | 'slide' | 'fade' | TransitionDirectionalAnimations; 'transition:name'?: string; + 'transition:persist'?: boolean | string; } export interface AstroDefineVarsAttribute { diff --git a/packages/astro/src/core/compile/compile.ts b/packages/astro/src/core/compile/compile.ts index c1df9e5f3..33dcec0cc 100644 --- a/packages/astro/src/core/compile/compile.ts +++ b/packages/astro/src/core/compile/compile.ts @@ -46,6 +46,7 @@ export async function compile({ scopedStyleStrategy: astroConfig.scopedStyleStrategy, resultScopedSlot: true, experimentalTransitions: astroConfig.experimental.viewTransitions, + experimentalPersistence: astroConfig.experimental.viewTransitions, transitionsAnimationURL: 'astro/components/viewtransitions.css', preprocessStyle: createStylePreprocessor({ filename, diff --git a/packages/astro/src/runtime/server/hydration.ts b/packages/astro/src/runtime/server/hydration.ts index bb8d64912..598d0a9cf 100644 --- a/packages/astro/src/runtime/server/hydration.ts +++ b/packages/astro/src/runtime/server/hydration.ts @@ -22,6 +22,8 @@ interface ExtractedProps { props: Record<string | number | symbol, any>; } +const transitionDirectivesToCopyOnIsland = Object.freeze(['data-astro-transition-scope', 'data-astro-transition-persist']); + // Used to extract the directives, aka `client:load` information about a component. // Finds these special props and removes them from what gets passed into the component. export function extractDirectives( @@ -166,5 +168,11 @@ export async function generateHydrateScript( }) ); + transitionDirectivesToCopyOnIsland.forEach(name => { + if(props[name]) { + island.props[name] = props[name]; + } + }); + return island; } diff --git a/packages/astro/src/runtime/server/transition.ts b/packages/astro/src/runtime/server/transition.ts index 253a861f9..c348d292d 100644 --- a/packages/astro/src/runtime/server/transition.ts +++ b/packages/astro/src/runtime/server/transition.ts @@ -17,10 +17,11 @@ function incrementTransitionNumber(result: SSRResult) { return num; } -function createTransitionScope(result: SSRResult, hash: string) { +export function createTransitionScope(result: SSRResult, hash: string) { const num = incrementTransitionNumber(result); return `astro-${hash}-${num}`; } + export function renderTransition( result: SSRResult, hash: string, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1372602ec..6c2305f6e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -486,8 +486,8 @@ importers: packages/astro: dependencies: '@astrojs/compiler': - specifier: ^1.6.3 - version: 1.6.3 + specifier: ^1.8.0 + version: 1.8.0 '@astrojs/internal-helpers': specifier: ^0.1.1 version: link:../internal-helpers @@ -1487,9 +1487,18 @@ importers: packages/astro/e2e/fixtures/view-transitions: dependencies: + '@astrojs/react': + specifier: workspace:* + version: link:../../../../integrations/react astro: specifier: workspace:* version: link:../../.. + react: + specifier: ^18.1.0 + version: 18.2.0 + react-dom: + specifier: ^18.1.0 + version: 18.2.0(react@18.2.0) packages/astro/e2e/fixtures/vue-component: dependencies: @@ -5602,8 +5611,8 @@ packages: sisteransi: 1.0.5 dev: false - /@astrojs/compiler@1.6.3: - resolution: {integrity: sha512-n0xTuBznKspc0plk6RHBOlSv/EwQGyMNSxEOPj7HMeiRNnXX4woeSopN9hQsLkqraDds1eRvB4u99buWgVNJig==} + /@astrojs/compiler@1.8.0: + resolution: {integrity: sha512-E0TI/uyO8n+IPSZ4Fvl9Lne8JKEasR6ZMGvE2G096oTWOXSsPAhRs2LomV3z+/VRepo2h+t/SdVo54wox4eJwA==} /@astrojs/internal-helpers@0.1.1: resolution: {integrity: sha512-+LySbvFbjv2nO2m/e78suleQOGEru4Cnx73VsZbrQgB2u7A4ddsQg3P2T0zC0e10jgcT+c6nNlKeLpa6nRhQIg==} @@ -5613,7 +5622,7 @@ packages: resolution: {integrity: sha512-oEw7AwJmzjgy6HC9f5IdrphZ1GVgfV/+7xQuyf52cpTiRWd/tJISK3MsKP0cDkVlfodmNABNFnAaAWuLZEiiiA==} hasBin: true dependencies: - '@astrojs/compiler': 1.6.3 + '@astrojs/compiler': 1.8.0 '@jridgewell/trace-mapping': 0.3.18 '@vscode/emmet-helper': 2.8.8 events: 3.3.0 @@ -15692,7 +15701,7 @@ packages: resolution: {integrity: sha512-dPzop0gKZyVGpTDQmfy+e7FKXC9JT3mlpfYA2diOVz+Ui+QR1U4G/s+OesKl2Hib2JJOtAYJs/l+ovgT0ljlFA==} engines: {node: ^14.15.0 || >=16.0.0, pnpm: '>=7.14.0'} dependencies: - '@astrojs/compiler': 1.6.3 + '@astrojs/compiler': 1.8.0 prettier: 2.8.8 sass-formatter: 0.7.6 dev: true @@ -15701,7 +15710,7 @@ packages: resolution: {integrity: sha512-lJ/mG/Lz/ccSwNtwqpFS126mtMVzFVyYv0ddTF9wqwrEG4seECjKDAyw/oGv915rAcJi8jr89990nqfpmG+qdg==} engines: {node: ^14.15.0 || >=16.0.0, pnpm: '>=7.14.0'} dependencies: - '@astrojs/compiler': 1.6.3 + '@astrojs/compiler': 1.8.0 prettier: 2.8.8 sass-formatter: 0.7.6 synckit: 0.8.5 @@ -18709,7 +18718,7 @@ packages: sharp: optional: true dependencies: - '@astrojs/compiler': 1.6.3 + '@astrojs/compiler': 1.8.0 '@astrojs/internal-helpers': 0.1.1 '@astrojs/language-server': 1.0.0 '@astrojs/markdown-remark': 2.2.1(astro@2.9.7) |