Persistent DOM in ViewTransitions (#7861)
* First pass at transition:persist * Persistent islands * Changeset * Updated compiler * Use official release * Upgrade again * Refactor to allow head content to persist untouched * >= * Specify the types for "astro:persist" * Automatically persist links * Use reference for array * Upgrade to latest compiler version * Explain the feature * Update .changeset/empty-experts-unite.md Co-authored-by: Yan Thomas <61414485+Yan-Thomas@users.noreply.github.com> * Update .changeset/empty-experts-unite.md Co-authored-by: Yan Thomas <61414485+Yan-Thomas@users.noreply.github.com> * Update .changeset/empty-experts-unite.md Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca> * Update .changeset/empty-experts-unite.md Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca> --------- Co-authored-by: Yan Thomas <61414485+Yan-Thomas@users.noreply.github.com> Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
This commit is contained in:
parent
4e651af16f
commit
41afb84057
18 changed files with 243 additions and 21 deletions
27
.changeset/empty-experts-unite.md
Normal file
27
.changeset/empty-experts-unite.md
Normal file
|
@ -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.
|
|
@ -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;
|
||||
const swap = () => {
|
||||
document.documentElement.replaceWith(doc.documentElement);
|
||||
|
||||
// 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 = () => {
|
||||
// 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;
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in a new issue