diff --git a/.changeset/lovely-nails-cough.md b/.changeset/lovely-nails-cough.md new file mode 100644 index 000000000000..4aecb48d141d --- /dev/null +++ b/.changeset/lovely-nails-cough.md @@ -0,0 +1,26 @@ +--- +"@astrojs/react": minor +"astro": minor +--- + +Changes the default behavior of `transition:persist` to update the props of persisted islands upon navigation. Also adds a new view transitions option `transition:persist-props` (default: `false`) to prevent props from updating as needed. + +Islands which have the `transition:persist` property to keep their state when using the `` router will now have their props updated upon navigation. This is useful in cases where the component relies on page-specific props, such as the current page title, which should update upon navigation. + +For example, the component below is set to persist across navigation. This component receives a `products` props and might have some internal state, such as which filters are applied: + +```astro + +``` + +Upon navigation, this component persists, but the desired `products` might change, for example if you are visiting a category of products, or you are performing a search. + +Previously the props would not change on navigation, and your island would have to handle updating them externally, such as with API calls. + +With this change the props are now updated, while still preserving state. + +You can override this new default behavior on a per-component basis using `transition:persist-props=true` to persist both props and state during navigation: + +```astro + +``` 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 734e2011b25b..541ca6026e19 100644 --- a/packages/astro/e2e/fixtures/view-transitions/src/components/Island.jsx +++ b/packages/astro/e2e/fixtures/view-transitions/src/components/Island.jsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import './Island.css'; import { indirect} from './css.js'; -export default function Counter({ children, count: initialCount, id }) { +export default function Counter({ children, count: initialCount, id, page }) { const [count, setCount] = useState(initialCount); const add = () => setCount((i) => i + 1); const subtract = () => setCount((i) => i - 1); @@ -10,6 +10,7 @@ export default function Counter({ children, count: initialCount, id }) { return ( <>
+

{page}

{count}
diff --git a/packages/astro/e2e/fixtures/view-transitions/src/pages/island-one.astro b/packages/astro/e2e/fixtures/view-transitions/src/pages/island-one.astro index 89822a01be93..883d567a118f 100644 --- a/packages/astro/e2e/fixtures/view-transitions/src/pages/island-one.astro +++ b/packages/astro/e2e/fixtures/view-transitions/src/pages/island-one.astro @@ -1,9 +1,13 @@ --- import Layout from '../components/Layout.astro'; import Island from '../components/Island.jsx'; +export const prerender = false; + +const persistProps = Astro.url.searchParams.has('persist'); ---

Page 1

go to 2 - +
diff --git a/packages/astro/e2e/fixtures/view-transitions/src/pages/island-two.astro b/packages/astro/e2e/fixtures/view-transitions/src/pages/island-two.astro index 3841ca8970c3..37912591cb57 100644 --- a/packages/astro/e2e/fixtures/view-transitions/src/pages/island-two.astro +++ b/packages/astro/e2e/fixtures/view-transitions/src/pages/island-two.astro @@ -5,5 +5,5 @@ import Island from '../components/Island.jsx';

Page 2

go to 1 - +
diff --git a/packages/astro/e2e/view-transitions.test.js b/packages/astro/e2e/view-transitions.test.js index d90575c52286..b963bf2fd7f4 100644 --- a/packages/astro/e2e/view-transitions.test.js +++ b/packages/astro/e2e/view-transitions.test.js @@ -543,6 +543,38 @@ test.describe('View Transitions', () => { cnt = page.locator('.counter pre'); // Count should remain await expect(cnt).toHaveText('6'); + + // Props should have changed + const pageTitle = page.locator('.page'); + await expect(pageTitle).toHaveText('Island 2'); + }); + + test('transition:persist-props prevents props from changing', async ({ page, astro }) => { + // Go to page 1 + await page.goto(astro.resolveUrl('/island-one?persist')); + + // Navigate to page 2 + await page.click('#click-two'); + const p = page.locator('#island-two'); + await expect(p).toBeVisible(); + + // Props should have changed + const pageTitle = page.locator('.page'); + await expect(pageTitle).toHaveText('Island 1'); + }); + + test('transition:persist-props=false makes props update', async ({ page, astro }) => { + // Go to page 2 + await page.goto(astro.resolveUrl('/island-two')); + + // Navigate to page 1 + await page.click('#click-one'); + const p = page.locator('#island-one'); + await expect(p).toBeVisible(); + + // Props should have changed + const pageTitle = page.locator('.page'); + await expect(pageTitle).toHaveText('Island 1'); }); test('Scripts are only executed once', async ({ page, astro }) => { diff --git a/packages/astro/package.json b/packages/astro/package.json index 42ee8a741531..b1bbf0b2f434 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -114,7 +114,7 @@ "test:node": "astro-scripts test \"test/**/*.test.js\"" }, "dependencies": { - "@astrojs/compiler": "^2.5.3", + "@astrojs/compiler": "^2.7.0", "@astrojs/internal-helpers": "workspace:*", "@astrojs/markdown-remark": "workspace:*", "@astrojs/telemetry": "workspace:*", diff --git a/packages/astro/src/runtime/server/hydration.ts b/packages/astro/src/runtime/server/hydration.ts index 4c8b71ba8843..28b5ff674e19 100644 --- a/packages/astro/src/runtime/server/hydration.ts +++ b/packages/astro/src/runtime/server/hydration.ts @@ -27,6 +27,7 @@ interface ExtractedProps { const transitionDirectivesToCopyOnIsland = Object.freeze([ 'data-astro-transition-scope', 'data-astro-transition-persist', + 'data-astro-transition-persist-props', ]); // Used to extract the directives, aka `client:load` information about a component. @@ -175,7 +176,7 @@ export async function generateHydrateScript( ); transitionDirectivesToCopyOnIsland.forEach((name) => { - if (props[name]) { + if (typeof props[name] !== 'undefined') { island.props[name] = props[name]; } }); diff --git a/packages/astro/src/transitions/router.ts b/packages/astro/src/transitions/router.ts index 02e2d69dfd20..817ada55c211 100644 --- a/packages/astro/src/transitions/router.ts +++ b/packages/astro/src/transitions/router.ts @@ -306,6 +306,11 @@ async function updateDOM( } }; + const shouldCopyProps = (el: HTMLElement): boolean => { + const persistProps = el.dataset.astroTransitionPersistProps; + return persistProps == null || persistProps === 'false'; + } + const defaultSwap = (beforeSwapEvent: TransitionBeforeSwapEvent) => { // swap attributes of the html element // - delete all attributes from the current document @@ -366,6 +371,11 @@ async function updateDOM( // The element exists in the new page, replace it with the element // from the old page so that state is preserved. newEl.replaceWith(el); + // For islands, copy over the props to allow them to re-render + if(newEl.localName === 'astro-island' && shouldCopyProps(el as HTMLElement)) { + el.setAttribute('ssr', ''); + el.setAttribute('props', newEl.getAttribute('props')!); + } } } restoreFocus(savedFocus); diff --git a/packages/integrations/react/client.js b/packages/integrations/react/client.js index 90e4ceb837b5..cfe46a919223 100644 --- a/packages/integrations/react/client.js +++ b/packages/integrations/react/client.js @@ -53,6 +53,17 @@ function getChildren(childString, experimentalReactChildren) { } } +// Keep a map of roots so we can reuse them on re-renders +let rootMap = new WeakMap(); +const getOrCreateRoot = (element, creator) => { + let root = rootMap.get(element); + if(!root) { + root = creator(); + rootMap.set(element, root); + } + return root; +}; + export default (element) => (Component, props, { default: children, ...slotted }, { client }) => { if (!element.hasAttribute('ssr')) return; @@ -75,14 +86,20 @@ export default (element) => } if (client === 'only') { return startTransition(() => { - const root = createRoot(element); + const root = getOrCreateRoot(element, () => { + const r = createRoot(element); + element.addEventListener('astro:unmount', () => r.unmount(), { once: true }); + return r; + }); root.render(componentEl); - element.addEventListener('astro:unmount', () => root.unmount(), { once: true }); }); } startTransition(() => { - const root = hydrateRoot(element, componentEl, renderOptions); + const root = getOrCreateRoot(element, () => { + const r = hydrateRoot(element, componentEl, renderOptions); + element.addEventListener('astro:unmount', () => r.unmount(), { once: true }); + return r; + }); root.render(componentEl); - element.addEventListener('astro:unmount', () => root.unmount(), { once: true }); }); }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8e8d09977b6..130057300a0d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -498,8 +498,8 @@ importers: packages/astro: dependencies: '@astrojs/compiler': - specifier: ^2.5.3 - version: 2.5.3 + specifier: ^2.7.0 + version: 2.7.0 '@astrojs/internal-helpers': specifier: workspace:* version: link:../internal-helpers @@ -5529,8 +5529,8 @@ packages: /@astrojs/compiler@1.8.2: resolution: {integrity: sha512-o/ObKgtMzl8SlpIdzaxFnt7SATKPxu4oIP/1NL+HDJRzxfJcAkOTAb/ZKMRyULbz4q+1t2/DAebs2Z1QairkZw==} - /@astrojs/compiler@2.5.3: - resolution: {integrity: sha512-jzj01BRv/fmo+9Mr2FhocywGzEYiyiP2GVHje1ziGNU6c97kwhYGsnvwMkHrncAy9T9Vi54cjaMK7UE4ClX4vA==} + /@astrojs/compiler@2.7.0: + resolution: {integrity: sha512-XpC8MAaWjD1ff6/IfkRq/5k1EFj6zhCNqXRd5J43SVJEBj/Bsmizkm8N0xOYscGcDFQkRgEw6/eKnI5x/1l6aA==} /@astrojs/language-server@2.5.5(prettier-plugin-astro@0.12.3)(prettier@3.1.1)(typescript@5.2.2): resolution: {integrity: sha512-hk7a8S7bpf//BOA6mMjiYqi/eiYtGPfUfw59eVXdutdRFdwDHtu4jcsLu43ZaId56pAcE8qFjIvJxySvzcxiUA==} @@ -5544,7 +5544,7 @@ packages: prettier-plugin-astro: optional: true dependencies: - '@astrojs/compiler': 2.5.3 + '@astrojs/compiler': 2.7.0 '@jridgewell/sourcemap-codec': 1.4.15 '@volar/kit': 1.10.10(typescript@5.2.2) '@volar/language-core': 1.10.10 @@ -5580,7 +5580,7 @@ packages: prettier-plugin-astro: optional: true dependencies: - '@astrojs/compiler': 2.5.3 + '@astrojs/compiler': 2.7.0 '@jridgewell/sourcemap-codec': 1.4.15 '@volar/kit': 2.0.4(typescript@5.3.2) '@volar/language-core': 2.0.4