Skip to content

Commit

Permalink
Persistent DOM in ViewTransitions (#7861)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
3 people committed Aug 2, 2023
1 parent 4e651af commit 41afb84
Show file tree
Hide file tree
Showing 18 changed files with 242 additions and 20 deletions.
27 changes: 27 additions & 0 deletions .changeset/empty-experts-unite.md
Original file line number Diff line number Diff line change
@@ -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.
65 changes: 56 additions & 9 deletions packages/astro/components/ViewTransitions.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand All @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions packages/astro/e2e/fixtures/view-transitions/astro.config.mjs
Original file line number Diff line number Diff line change
@@ -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,
},
},
});
5 changes: 4 additions & 1 deletion packages/astro/e2e/fixtures/view-transitions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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>
</>
);
}
Original file line number Diff line number Diff line change
@@ -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>
Original file line number Diff line number Diff line change
@@ -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>
Original file line number Diff line number Diff line change
@@ -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>
Original file line number Diff line number Diff line change
@@ -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>
Original file line number Diff line number Diff line change
@@ -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>
36 changes: 36 additions & 0 deletions packages/astro/e2e/view-transitions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
2 changes: 1 addition & 1 deletion packages/astro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/@types/astro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/core/compile/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions packages/astro/src/runtime/server/hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -166,5 +168,11 @@ export async function generateHydrateScript(
})
);

transitionDirectivesToCopyOnIsland.forEach(name => {
if(props[name]) {
island.props[name] = props[name];
}
});

return island;
}
3 changes: 2 additions & 1 deletion packages/astro/src/runtime/server/transition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading

0 comments on commit 41afb84

Please sign in to comment.