diff --git a/.changeset/purple-dots-refuse.md b/.changeset/purple-dots-refuse.md new file mode 100644 index 000000000000..74be758b57f9 --- /dev/null +++ b/.changeset/purple-dots-refuse.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes styles of `client:only` components not persisting during view transitions in dev mode diff --git a/packages/astro/e2e/fixtures/view-transitions/astro.config.mjs b/packages/astro/e2e/fixtures/view-transitions/astro.config.mjs index 2b22ff9cf3f3..f4450f67285d 100644 --- a/packages/astro/e2e/fixtures/view-transitions/astro.config.mjs +++ b/packages/astro/e2e/fixtures/view-transitions/astro.config.mjs @@ -1,12 +1,14 @@ import { defineConfig } from 'astro/config'; import react from '@astrojs/react'; +import vue from '@astrojs/vue'; +import svelte from '@astrojs/svelte'; import nodejs from '@astrojs/node'; // https://astro.build/config export default defineConfig({ output: 'hybrid', adapter: nodejs({ mode: 'standalone' }), - integrations: [react()], + integrations: [react(),vue(),svelte()], redirects: { '/redirect-two': '/two', '/redirect-external': 'http://example.com/', diff --git a/packages/astro/e2e/fixtures/view-transitions/package.json b/packages/astro/e2e/fixtures/view-transitions/package.json index f4ba9b17b053..b53b5fcad4a6 100644 --- a/packages/astro/e2e/fixtures/view-transitions/package.json +++ b/packages/astro/e2e/fixtures/view-transitions/package.json @@ -6,6 +6,10 @@ "astro": "workspace:*", "@astrojs/node": "workspace:*", "@astrojs/react": "workspace:*", + "@astrojs/vue": "workspace:*", + "@astrojs/svelte": "workspace:*", + "svelte": "^4.2.0", + "vue": "^3.3.4", "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 index fb21044d78cc..28c5642a9897 100644 --- a/packages/astro/e2e/fixtures/view-transitions/src/components/Island.css +++ b/packages/astro/e2e/fixtures/view-transitions/src/components/Island.css @@ -8,4 +8,6 @@ .counter-message { text-align: center; + background-color: lightskyblue; + color:black } diff --git a/packages/astro/e2e/fixtures/view-transitions/src/components/Island.jsx b/packages/astro/e2e/fixtures/view-transitions/src/components/Island.jsx index cde38498028b..734e2011b25b 100644 --- a/packages/astro/e2e/fixtures/view-transitions/src/components/Island.jsx +++ b/packages/astro/e2e/fixtures/view-transitions/src/components/Island.jsx @@ -1,5 +1,6 @@ import React, { useState } from 'react'; import './Island.css'; +import { indirect} from './css.js'; export default function Counter({ children, count: initialCount, id }) { const [count, setCount] = useState(initialCount); diff --git a/packages/astro/e2e/fixtures/view-transitions/src/components/SvelteCounter.svelte b/packages/astro/e2e/fixtures/view-transitions/src/components/SvelteCounter.svelte new file mode 100644 index 000000000000..6647a19ce7d0 --- /dev/null +++ b/packages/astro/e2e/fixtures/view-transitions/src/components/SvelteCounter.svelte @@ -0,0 +1,36 @@ + + +
+ +
{count}
+ +
+
+ +
+ + diff --git a/packages/astro/e2e/fixtures/view-transitions/src/components/VueCounter.vue b/packages/astro/e2e/fixtures/view-transitions/src/components/VueCounter.vue new file mode 100644 index 000000000000..e75620aff455 --- /dev/null +++ b/packages/astro/e2e/fixtures/view-transitions/src/components/VueCounter.vue @@ -0,0 +1,43 @@ + + + + + diff --git a/packages/astro/e2e/fixtures/view-transitions/src/components/css.js b/packages/astro/e2e/fixtures/view-transitions/src/components/css.js new file mode 100644 index 000000000000..b2bf4b9679c4 --- /dev/null +++ b/packages/astro/e2e/fixtures/view-transitions/src/components/css.js @@ -0,0 +1,3 @@ +import "./other.postcss"; +export const indirect = ""; + diff --git a/packages/astro/e2e/fixtures/view-transitions/src/components/other.postcss b/packages/astro/e2e/fixtures/view-transitions/src/components/other.postcss new file mode 100644 index 000000000000..55b21b9202f2 --- /dev/null +++ b/packages/astro/e2e/fixtures/view-transitions/src/components/other.postcss @@ -0,0 +1 @@ +/* not much to see */ diff --git a/packages/astro/e2e/fixtures/view-transitions/src/pages/client-only-four.astro b/packages/astro/e2e/fixtures/view-transitions/src/pages/client-only-four.astro new file mode 100644 index 000000000000..9ebfa65f04e8 --- /dev/null +++ b/packages/astro/e2e/fixtures/view-transitions/src/pages/client-only-four.astro @@ -0,0 +1,11 @@ +--- +import Layout from '../components/Layout.astro'; +import Island from '../components/Island'; +import VueCounter from '../components/VueCounter.vue'; +import SvelteCounter from '../components/SvelteCounter.svelte'; +--- + +

Page 4

+ Vue + Svelte +
diff --git a/packages/astro/e2e/fixtures/view-transitions/src/pages/client-only-one.astro b/packages/astro/e2e/fixtures/view-transitions/src/pages/client-only-one.astro index a8d5e8995ae4..a51ccc299b2a 100644 --- a/packages/astro/e2e/fixtures/view-transitions/src/pages/client-only-one.astro +++ b/packages/astro/e2e/fixtures/view-transitions/src/pages/client-only-one.astro @@ -5,6 +5,6 @@ import Island from '../components/Island'; go to page 2
- message here + message here
diff --git a/packages/astro/e2e/fixtures/view-transitions/src/pages/client-only-three.astro b/packages/astro/e2e/fixtures/view-transitions/src/pages/client-only-three.astro new file mode 100644 index 000000000000..34fa6992699b --- /dev/null +++ b/packages/astro/e2e/fixtures/view-transitions/src/pages/client-only-three.astro @@ -0,0 +1,16 @@ +--- +import Layout from '../components/Layout.astro'; +import Island from '../components/Island'; +import VueCounter from '../components/VueCounter.vue'; +import SvelteCounter from '../components/SvelteCounter.svelte'; +--- + + go to page 4 +
+ + message here +
+ Vue + Svelte +

client-only-three

+
diff --git a/packages/astro/e2e/fixtures/view-transitions/src/pages/client-only-two.astro b/packages/astro/e2e/fixtures/view-transitions/src/pages/client-only-two.astro index 884ec46833d5..4190d86efb45 100644 --- a/packages/astro/e2e/fixtures/view-transitions/src/pages/client-only-two.astro +++ b/packages/astro/e2e/fixtures/view-transitions/src/pages/client-only-two.astro @@ -5,6 +5,6 @@ import Island from '../components/Island';

Page 2

- message here + message here
diff --git a/packages/astro/e2e/view-transitions.test.js b/packages/astro/e2e/view-transitions.test.js index d2c14aabdabc..a378df7c530c 100644 --- a/packages/astro/e2e/view-transitions.test.js +++ b/packages/astro/e2e/view-transitions.test.js @@ -241,15 +241,15 @@ test.describe('View Transitions', () => { let p = page.locator('#totwo'); await expect(p, 'should have content').toHaveText('Go to listener two'); // on load a CSS transition is started triggered by a class on the html element - expect(transitions).toEqual(1); - + expect(transitions).toBeLessThanOrEqual(1); + const transitionsBefore = transitions; // go to page 2 await page.click('#totwo'); p = page.locator('#toone'); await expect(p, 'should have content').toHaveText('Go to listener one'); // swap() resets that class, the after-swap listener sets it again. // the temporarily missing class must not trigger page rendering - expect(transitions).toEqual(1); + expect(transitions).toEqual(transitionsBefore); }); test('click hash links does not do navigation', async ({ page, astro }) => { @@ -670,10 +670,9 @@ test.describe('View Transitions', () => { expect(loads.length, 'There should be 2 page loads').toEqual(2); }); - test.skip('client:only styles are retained on transition', async ({ page, astro }) => { - const totalExpectedStyles = 7; + test('client:only styles are retained on transition (1/2)', async ({ page, astro }) => { + const totalExpectedStyles = 8; - // Go to page 1 await page.goto(astro.resolveUrl('/client-only-one')); let msg = page.locator('.counter-message'); await expect(msg).toHaveText('message here'); @@ -690,6 +689,27 @@ test.describe('View Transitions', () => { expect(styles.length).toEqual(totalExpectedStyles, 'style count has not changed'); }); + test('client:only styles are retained on transition (2/2)', async ({ page, astro }) => { + const totalExpectedStyles_page_three = 10; + const totalExpectedStyles_page_four = 8; + + await page.goto(astro.resolveUrl('/client-only-three')); + let msg = page.locator('#name'); + await expect(msg).toHaveText('client-only-three'); + await page.waitForTimeout(400); // await hydration + + let styles = await page.locator('style').all(); + expect(styles.length).toEqual(totalExpectedStyles_page_three); + + await page.click('#click-four'); + + let pageTwo = page.locator('#page-four'); + await expect(pageTwo, 'should have content').toHaveText('Page 4'); + + styles = await page.locator('style').all(); + expect(styles.length).toEqual(totalExpectedStyles_page_four, 'style count has not changed'); + }); + test('Horizontal scroll position restored on back button', async ({ page, astro }) => { await page.goto(astro.resolveUrl('/wide-page')); let article = page.locator('#widepage'); diff --git a/packages/astro/src/transitions/router.ts b/packages/astro/src/transitions/router.ts index 869ed87af5fa..1bbbc85a138d 100644 --- a/packages/astro/src/transitions/router.ts +++ b/packages/astro/src/transitions/router.ts @@ -47,6 +47,7 @@ const announce = () => { }; const PERSIST_ATTR = 'data-astro-transition-persist'; +const VITE_ID = 'data-vite-dev-id'; let parser: DOMParser; @@ -202,8 +203,10 @@ async function updateDOM( ) { // Check for a head element that should persist and returns it, // either because it has the data attribute or is a link el. - const persistedHeadElement = (el: HTMLElement): Element | null => { + // Returns null if the element is not part of the new head, undefined if it should be left alone. + const persistedHeadElement = (el: HTMLElement): Element | null | undefined => { const id = el.getAttribute(PERSIST_ATTR); + if (id === '') return undefined; const newEl = id && newDocument.head.querySelector(`[${PERSIST_ATTR}="${id}"]`); if (newEl) { return newEl; @@ -226,7 +229,7 @@ async function updateDOM( // The element that currently has the focus is part of a DOM tree // that will survive the transition to the new document. // Save the element and the cursor position - if (activeElement?.closest('[data-astro-transition-persist]')) { + if (activeElement?.closest(`[${PERSIST_ATTR}]`)) { if ( activeElement instanceof HTMLInputElement || activeElement instanceof HTMLTextAreaElement @@ -290,7 +293,7 @@ async function updateDOM( // from the new document and leave the current node alone if (newEl) { newEl.remove(); - } else { + } else if (newEl === null) { // Otherwise remove the element in the head. It doesn't exist in the new page. el.remove(); } @@ -306,6 +309,7 @@ async function updateDOM( // this will reset scroll Position document.body.replaceWith(newDocument.body); + for (const el of oldBody.querySelectorAll(`[${PERSIST_ATTR}]`)) { const id = el.getAttribute(PERSIST_ATTR); const newEl = document.querySelector(`[${PERSIST_ATTR}="${id}"]`); @@ -315,7 +319,6 @@ async function updateDOM( newEl.replaceWith(el); } } - restoreFocus(savedFocus); if (popState) { @@ -404,6 +407,8 @@ async function transition( return; } + if (import.meta.env.DEV) await prepareForClientOnlyComponents(newDocument, toLocation); + if (!popState) { // save the current scroll position before we change the DOM and transition to the new page history.replaceState({ ...history.state, scrollX, scrollY }, ''); @@ -438,6 +443,7 @@ export function navigate(href: string, options?: Options) { 'The view transtions client API was called during a server side render. This may be unintentional as the navigate() function is expected to be called in response to user interactions. Please make sure that your usage is correct.' ); warning.name = 'Warning'; + // eslint-disable-next-line no-console console.warn(warning); navigateOnServerWarned = true; } @@ -519,3 +525,53 @@ if (inBrowser) { markScriptsExec(); } } + +// Keep all styles that are potentially created by client:only components +// and required on the next page +async function prepareForClientOnlyComponents(newDocument: Document, toLocation: URL) { + // Any client:only component on the next page? + if (newDocument.body.querySelector(`astro-island[client='only']`)) { + // Load the next page with an empty module loader cache + const nextPage = document.createElement('iframe'); + nextPage.setAttribute('src', toLocation.href); + nextPage.style.display = 'none'; + document.body.append(nextPage); + await hydrationDone(nextPage); + + const nextHead = nextPage.contentDocument?.head; + if (nextHead) { + // Clear former persist marks + document.head + .querySelectorAll(`style[${PERSIST_ATTR}=""]`) + .forEach((s) => s.removeAttribute(PERSIST_ATTR)); + + // Collect the vite ids of all styles present in the next head + const viteIds = [...nextHead.querySelectorAll(`style[${VITE_ID}]`)].map((style) => + style.getAttribute(VITE_ID) + ); + // Mark styles of the current head as persistent + // if they come from hydration and not from the newDocument + viteIds.forEach((id) => { + const style = document.head.querySelector(`style[${VITE_ID}="${id}"]`); + if (style && !newDocument.head.querySelector(`style[${VITE_ID}="${id}"]`)) { + style.setAttribute(PERSIST_ATTR, ''); + } + }); + } + + // return a promise that resolves when all astro-islands are hydrated + async function hydrationDone(loadingPage: HTMLIFrameElement) { + await new Promise( + (r) => loadingPage.contentWindow?.addEventListener('load', r, { once: true }) + ); + + return new Promise(async (r) => { + for (let count = 0; count <= 20; ++count) { + if (!loadingPage.contentDocument!.body.querySelector('astro-island[ssr]')) break; + await new Promise((r2) => setTimeout(r2, 50)); + } + r(); + }); + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6116f2c0c661..a19fcf7c8bc3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1493,6 +1493,12 @@ importers: '@astrojs/react': specifier: workspace:* version: link:../../../../integrations/react + '@astrojs/svelte': + specifier: workspace:* + version: link:../../../../integrations/svelte + '@astrojs/vue': + specifier: workspace:* + version: link:../../../../integrations/vue astro: specifier: workspace:* version: link:../../.. @@ -1502,6 +1508,12 @@ importers: react-dom: specifier: ^18.1.0 version: 18.2.0(react@18.2.0) + svelte: + specifier: ^4.2.0 + version: 4.2.0 + vue: + specifier: ^3.3.4 + version: 3.3.4 packages/astro/e2e/fixtures/vue-component: dependencies: