From ec991d36276c07087df9da88d57f6d991c97e68c Mon Sep 17 00:00:00 2001 From: Christoph Nakazawa Date: Mon, 6 Jul 2026 18:04:57 +0900 Subject: [PATCH] fix: `useId` mismatches when deferring props. --- packages/react/src/runtime/deferred-shell.ts | 2 ++ packages/react/src/runtime/pages-client.ts | 16 ++++++++-- packages/react/src/runtime/pages-server.ts | 5 ++- .../integration/react-pages-client.test.ts | 32 +++++++++++++++++-- playground/react-pages/pages/deferred.tsx | 16 ++++++++-- 5 files changed, 62 insertions(+), 9 deletions(-) create mode 100644 packages/react/src/runtime/deferred-shell.ts diff --git a/packages/react/src/runtime/deferred-shell.ts b/packages/react/src/runtime/deferred-shell.ts new file mode 100644 index 000000000..53ff72056 --- /dev/null +++ b/packages/react/src/runtime/deferred-shell.ts @@ -0,0 +1,2 @@ +export const DEFERRED_SHELL_END_ID: string = '__VOID_DEFERRED_SHELL_END__'; +export const DEFERRED_SHELL_END_MARKER: string = ``; diff --git a/packages/react/src/runtime/pages-client.ts b/packages/react/src/runtime/pages-client.ts index 3a5e1a02a..33ed8a389 100644 --- a/packages/react/src/runtime/pages-client.ts +++ b/packages/react/src/runtime/pages-client.ts @@ -35,6 +35,7 @@ import { RouterContext, SharedContext, } from './context.ts'; +import { DEFERRED_SHELL_END_ID } from './deferred-shell.ts'; // oxlint-disable-next-line no-explicit-any -- component props are narrowed by generated route types type ComponentModule = { default: ComponentType }; @@ -625,13 +626,24 @@ export async function startReactPages(config: ReactPagesConfig): Promise { - if (appEl.getAttribute('data-void-ssr') === 'false') { + if (!isSsr) { createRoot(appEl).render(app); } else { hydrateRoot(appEl, app); diff --git a/packages/react/src/runtime/pages-server.ts b/packages/react/src/runtime/pages-server.ts index a89e3d41c..33d559d7f 100644 --- a/packages/react/src/runtime/pages-server.ts +++ b/packages/react/src/runtime/pages-server.ts @@ -11,12 +11,11 @@ import { RouterContext, SharedContext, } from './context.ts'; +import { DEFERRED_SHELL_END_ID, DEFERRED_SHELL_END_MARKER } from './deferred-shell.ts'; // oxlint-disable-next-line no-explicit-any -- component props are narrowed by generated route types type ComponentModule = { default: ComponentType }; const DEFERRED_SHELL_CANCEL_REASON = 'Void deferred shell complete'; -const DEFERRED_SHELL_END_ID = '__VOID_DEFERRED_SHELL_END__'; -const DEFERRED_SHELL_END_MARKER = ``; export type ReactPagesServerConfig = Readonly<{ pageObj: PageObject; @@ -102,7 +101,7 @@ function readDeferredShell(stream: ReadableStream): ReadableStream string) { await page.goto(BASE + '/'); await waitForHydration(page); - // SPA navigate to deferred page - await page.click('a[href="/deferred"]'); + await page.evaluate(() => { + window.__SPA_MARKER = true; + }); + + // Avoid hover prefetch so this exercises the deferred NDJSON navigation path. + await page.evaluate(() => { + document.querySelector('a[href="/deferred"]')?.click(); + }); await page.waitForFunction(() => document.querySelector('[data-testid="deferred-resolved"]')); const text = await page.textContent('[data-testid="deferred-resolved"]'); expect(text).toBe('Count: 42'); + expect(await page.evaluate(() => window.__SPA_MARKER)).toBe(true); expect(page.url()).toBe(BASE + '/deferred'); } finally { await page.context().close(); } }); + it('deferred props: useId hydrates without mismatches', async () => { + const page = await newPage(); + try { + await page.goto(BASE + '/deferred'); + await waitForHydration(page); + await page.waitForFunction(() => document.querySelector('[data-testid="deferred-resolved"]')); + await page.waitForFunction( + () => + document.querySelector('[data-testid="deferred-title"]')?.dataset.clientId, + ); + + const ids = await page.evaluate(() => { + const title = document.querySelector('[data-testid="deferred-title"]')!; + return { server: title.id, client: title.dataset.clientId }; + }); + expect(ids.server).toBe(ids.client); + } finally { + await page.context().close(); + } + }); + it('SPA navigation updates document.title from head()', async () => { const page = await newPage(); try { diff --git a/playground/react-pages/pages/deferred.tsx b/playground/react-pages/pages/deferred.tsx index 6c79774d0..c68593e6c 100644 --- a/playground/react-pages/pages/deferred.tsx +++ b/playground/react-pages/pages/deferred.tsx @@ -1,4 +1,4 @@ -import { Suspense, use } from 'react'; +import { Suspense, use, useId } from 'react'; import type { Props } from './deferred.server.ts'; function Stats({ stats }: Pick) { @@ -8,9 +8,21 @@ function Stats({ stats }: Pick) { } export default function DeferredPage({ title, stats }: Props) { + const titleId = useId(); + return (
-

{title}

+

{ + if (element) { + element.dataset.clientId = titleId; + } + }} + > + {title} +

Loading stats...
}>