Support view transitions for client:only components

This commit is contained in:
Martin Trapp 2023-10-04 18:21:27 +02:00
parent aa265d7302
commit daad1def81
5 changed files with 95 additions and 15 deletions

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
Support view transitions for client:only components

View file

@ -3,6 +3,7 @@ import Layout from '../components/Layout.astro';
import Island from '../components/Island'; import Island from '../components/Island';
--- ---
<Layout> <Layout>
<p id="page-one">Page 1</p>
<a id="click-two" href="/client-only-two">go to page 2</a> <a id="click-two" href="/client-only-two">go to page 2</a>
<div transition:persist="island"> <div transition:persist="island">
<Island client:only count={5}>message here</Island> <Island client:only count={5}>message here</Island>

View file

@ -4,6 +4,7 @@ import Island from '../components/Island';
--- ---
<Layout> <Layout>
<p id="page-two">Page 2</p> <p id="page-two">Page 2</p>
<a id="click-one" href="/client-only-one">go to page 1</a>
<div transition:persist="island"> <div transition:persist="island">
<Island client:only count={5}>message here</Island> <Island client:only count={5}>message here</Island>
</div> </div>

View file

@ -649,9 +649,14 @@ test.describe('View Transitions', () => {
}); });
test('client:only styles are retained on transition', async ({ page, astro }) => { test('client:only styles are retained on transition', async ({ page, astro }) => {
const loads = [];
page.addListener('load', async (p) => {
loads.push(p);
});
const totalExpectedStyles = 7; const totalExpectedStyles = 7;
// Go to page 1 // Go to page 1 (normal load)
await page.goto(astro.resolveUrl('/client-only-one')); await page.goto(astro.resolveUrl('/client-only-one'));
let msg = page.locator('.counter-message'); let msg = page.locator('.counter-message');
await expect(msg).toHaveText('message here'); await expect(msg).toHaveText('message here');
@ -659,13 +664,24 @@ test.describe('View Transitions', () => {
let styles = await page.locator('style').all(); let styles = await page.locator('style').all();
expect(styles.length).toEqual(totalExpectedStyles); expect(styles.length).toEqual(totalExpectedStyles);
// Transition to page 2 (will do a full load)
await page.click('#click-two'); await page.click('#click-two');
let pageTwo = page.locator('#page-two'); let pageTwo = page.locator('#page-two');
await expect(pageTwo, 'should have content').toHaveText('Page 2'); await expect(pageTwo, 'should have content').toHaveText('Page 2');
// Transition to page 1 (will do a full load)
await page.click('#click-one');
let pageOne = page.locator('#page-one');
await expect(pageOne, 'should have content').toHaveText('Page 1');
// Transition to page 1 (real transition, no full load)
await page.click('#click-two');
styles = await page.locator('style').all(); styles = await page.locator('style').all();
expect(styles.length).toEqual(totalExpectedStyles, 'style count has not changed'); expect(styles.length).toEqual(totalExpectedStyles, 'style count has not changed');
expect(loads.length, 'There should only be 1 page load').toEqual(3);
}); });
test('Horizontal scroll position restored on back button', async ({ page, astro }) => { test('Horizontal scroll position restored on back button', async ({ page, astro }) => {

View file

@ -10,6 +10,15 @@ type State = {
}; };
type Events = 'astro:page-load' | 'astro:after-swap'; type Events = 'astro:page-load' | 'astro:after-swap';
let viteDevIds: { static: Record<string, string[]>; dynamic: Record<string, string[]> };
if (import.meta.env.DEV) {
// viteDevIds on a page
viteDevIds = JSON.parse(
sessionStorage.getItem('astro:viteDevIds') || '{"static":{},"dynamic":{}}'
);
}
const page = (url: { origin: string; pathname: string }) => url.origin + url.pathname;
// only update history entries that are managed by us // only update history entries that are managed by us
// leave other entries alone and do not accidently add state. // leave other entries alone and do not accidently add state.
const persistState = (state: State) => history.state && history.replaceState(state, ''); const persistState = (state: State) => history.state && history.replaceState(state, '');
@ -44,8 +53,10 @@ const PERSIST_ATTR = 'data-astro-transition-persist';
const parser = new DOMParser(); const parser = new DOMParser();
// explained at its usage // explained at its usage
let noopEl: HTMLDivElement; let noopEl: HTMLDivElement;
let reloadEl: HTMLDivElement;
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
noopEl = document.createElement('div'); noopEl = document.createElement('div');
reloadEl = document.createElement('div');
} }
// The History API does not tell you if navigation is forward or back, so // The History API does not tell you if navigation is forward or back, so
@ -198,20 +209,40 @@ async function updateDOM(
const href = el.getAttribute('href'); const href = el.getAttribute('href');
return newDocument.head.querySelector(`link[rel=stylesheet][href="${href}"]`); return newDocument.head.querySelector(`link[rel=stylesheet][href="${href}"]`);
} }
if (import.meta.env.DEV) {
const viteDevId = el.getAttribute('data-vite-dev-id');
if (!viteDevId) {
return null;
}
const newDevEl = newDocument.head.querySelector(`[data-vite-dev-id="${viteDevId}"]`);
if (newDevEl) {
return newDevEl;
}
// What follows is a fix for an issue (#8472) with missing client:only styles after transition. // What follows is a fix for an issue (#8472) with missing client:only styles after transition.
// That problem exists only in dev mode where styles are injected into the page by Vite. // That problem exists only in dev mode where styles are injected into the page by Vite.
// Returning a noop element ensures that the styles are not removed from the old document. // Returning a noop element ensures that the styles are not removed from the old document.
// Guarding the code below with the dev mode check // Guarding the code below with the dev mode check
// allows tree shaking to remove this code in production. // allows tree shaking to remove this code in production.
if (import.meta.env.DEV) { if (
if (el.tagName === 'STYLE' && el.dataset.viteDevId) { document.querySelector(
const devId = el.dataset.viteDevId; `[${PERSIST_ATTR}] astro-island[client="only"], astro-island[client="only"][${PERSIST_ATTR}]`
// If this same style tag exists, remove it from the new page )
return ( ) {
newDocument.querySelector(`style[data-vite-dev-id="${devId}"]`) || const here = page(toLocation);
// Otherwise, keep it anyways. This is client:only styles. const dynamicViteDevIds = viteDevIds.dynamic[here];
noopEl if (!dynamicViteDevIds) {
); console.info(`
${toLocation.pathname}
Development mode only: This page uses view transitions with persisted client:only Astro islands.
On the first transition to this page, Astro did a full page reload to capture the dynamic effects of the client only code.
`);
location.href = toLocation.href;
return reloadEl;
}
if (dynamicViteDevIds?.includes(viteDevId)) {
return noopEl;
}
} }
} }
return null; return null;
@ -249,12 +280,16 @@ async function updateDOM(
// Swap head // Swap head
for (const el of Array.from(document.head.children)) { for (const el of Array.from(document.head.children)) {
const newEl = persistedHeadElement(el as HTMLElement); const newEl = persistedHeadElement(el as HTMLElement);
if (newEl === reloadEl) {
return;
}
// If the element exists in the document already, remove it // If the element exists in the document already, remove it
// from the new document and leave the current node alone // from the new document and leave the current node alone
if (newEl) { if (newEl) {
newEl.remove(); newEl.remove();
} else { } else {
// Otherwise remove the element in the head. It doesn't exist in the new page. // Otherwise remove the element from the head.
// It doesn't exist in the new page or will be re-inserted after this loop
el.remove(); el.remove();
} }
} }
@ -336,6 +371,20 @@ async function transition(
options: Options, options: Options,
popState?: State popState?: State
) { ) {
if (import.meta.env.DEV) {
const thisPageStaticViteDevIds = viteDevIds.static[page(location)];
if (thisPageStaticViteDevIds) {
const allViteDevIds = new Set<string>();
document.head
.querySelectorAll('[data-vite-dev-id]')
.forEach((el) => allViteDevIds.add(el.getAttribute('data-vite-dev-id')!));
viteDevIds.dynamic[page(location)] = [...allViteDevIds].filter(
(x) => !thisPageStaticViteDevIds.includes(x)
);
sessionStorage.setItem('astro:viteDevIds', JSON.stringify(viteDevIds, null, 2));
}
}
let finished: Promise<void>; let finished: Promise<void>;
const href = toLocation.href; const href = toLocation.href;
const response = await fetchHTML(href); const response = await fetchHTML(href);
@ -360,6 +409,14 @@ async function transition(
location.href = href; location.href = href;
return; return;
} }
if (import.meta.env.DEV) {
const staticViteDevIds = new Set<string>();
newDocument.querySelectorAll('head > [data-vite-dev-id]').forEach((el) => {
staticViteDevIds.add(el.getAttribute('data-vite-dev-id')!);
});
viteDevIds.static[page(toLocation)] = [...staticViteDevIds];
sessionStorage.setItem('astro:viteDevIds', JSON.stringify(viteDevIds, null, 2));
}
if (!popState) { if (!popState) {
// save the current scroll position before we change the DOM and transition to the new page // save the current scroll position before we change the DOM and transition to the new page