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:
Matthew Phillips 2023-08-02 14:42:01 -04:00 committed by GitHub
parent 4e651af16f
commit 41afb84057
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 243 additions and 21 deletions

View 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.

View file

@ -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;

View file

@ -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,
},
},
});

View file

@ -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"
}
}

View file

@ -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;
}

View file

@ -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>
</>
);
}

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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');
});
});

View file

@ -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",

View file

@ -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 {

View file

@ -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,

View file

@ -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;
}

View file

@ -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,

View file

@ -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)