diff --git a/.changeset/empty-experts-unite.md b/.changeset/empty-experts-unite.md
new file mode 100644
index 000000000000..78d0f2e86377
--- /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 ` ` 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
+
+
+
+```
+
+This `` 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
+
+```
+
+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 7197674db419..d08cf3466d2b 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[] = [];
+ 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(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 9e89fa72e62a..c0df0074c658 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 50258fd1a16b..90a07f839755 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 000000000000..fb21044d78cc
--- /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 000000000000..cde38498028b
--- /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 (
+ <>
+
+ {children}
+ >
+ );
+}
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 000000000000..7235266bc67b
--- /dev/null
+++ b/packages/astro/e2e/fixtures/view-transitions/src/components/Video.astro
@@ -0,0 +1,3 @@
+
+
+
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 000000000000..89822a01be93
--- /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';
+---
+
+ Page 1
+ go to 2
+
+
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 000000000000..3841ca8970c3
--- /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';
+---
+
+ Page 2
+ go to 1
+
+
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 000000000000..76f221c630b4
--- /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';
+---
+
+ Page 1
+ go to 2
+
+
+
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 000000000000..7a947a85d78a
--- /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';
+---
+
+
+ Page 2
+ go to 1
+
+
diff --git a/packages/astro/e2e/view-transitions.test.js b/packages/astro/e2e/view-transitions.test.js
index e71b892ba8ad..2498a5a8a49c 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(' 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 e093a4f1d0e8..dfd5badf4ae8 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 59e295ebac66..1c7152fec261 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 c1df9e5f3e62..33dcec0ccfda 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 bb8d64912979..598d0a9cf7b7 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;
}
+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 253a861f9f42..c348d292daf3 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 1372602ecf39..6c2305f6ebc2 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)