Skip to content

Commit 504af69

Browse files
authored
fix: useId mismatches when deferring props. (#258)
Fixes void-sdk/void#76
1 parent f74426a commit 504af69

5 files changed

Lines changed: 62 additions & 9 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const DEFERRED_SHELL_END_ID: string = '__VOID_DEFERRED_SHELL_END__';
2+
export const DEFERRED_SHELL_END_MARKER: string = `<template id="${DEFERRED_SHELL_END_ID}"></template>`;

‎packages/react/src/runtime/pages-client.ts‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
RouterContext,
3636
SharedContext,
3737
} from './context.ts';
38+
import { DEFERRED_SHELL_END_ID } from './deferred-shell.ts';
3839

3940
// oxlint-disable-next-line no-explicit-any -- component props are narrowed by generated route types
4041
type ComponentModule = { default: ComponentType<any> };
@@ -625,13 +626,24 @@ export async function startReactPages(config: ReactPagesConfig): Promise<ReactPa
625626
throw new Error('pages: Missing #app element for Void React pages hydration.');
626627
}
627628

628-
const app = React.createElement(App, {
629+
const appRoot = React.createElement(App, {
629630
engine,
630631
router,
631632
viewTransitions: config.viewTransitions,
632633
});
634+
const isSsr = appEl.getAttribute('data-void-ssr') !== 'false';
635+
const hasDeferred = Boolean((engine.initialPageData as ReactPageData).deferredKeys);
636+
const app =
637+
isSsr && hasDeferred
638+
? React.createElement(
639+
React.Fragment,
640+
null,
641+
appRoot,
642+
React.createElement('template', { id: DEFERRED_SHELL_END_ID }),
643+
)
644+
: appRoot;
633645
startTransition(() => {
634-
if (appEl.getAttribute('data-void-ssr') === 'false') {
646+
if (!isSsr) {
635647
createRoot(appEl).render(app);
636648
} else {
637649
hydrateRoot(appEl, app);

‎packages/react/src/runtime/pages-server.ts‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,11 @@ import {
1111
RouterContext,
1212
SharedContext,
1313
} from './context.ts';
14+
import { DEFERRED_SHELL_END_ID, DEFERRED_SHELL_END_MARKER } from './deferred-shell.ts';
1415

1516
// oxlint-disable-next-line no-explicit-any -- component props are narrowed by generated route types
1617
type ComponentModule = { default: ComponentType<any> };
1718
const DEFERRED_SHELL_CANCEL_REASON = 'Void deferred shell complete';
18-
const DEFERRED_SHELL_END_ID = '__VOID_DEFERRED_SHELL_END__';
19-
const DEFERRED_SHELL_END_MARKER = `<template id="${DEFERRED_SHELL_END_ID}"></template>`;
2019

2120
export type ReactPagesServerConfig = Readonly<{
2221
pageObj: PageObject;
@@ -102,7 +101,7 @@ function readDeferredShell(stream: ReadableStream<Uint8Array>): ReadableStream<U
102101
buffer += decoder.decode(value, { stream: true });
103102
const markerIndex = buffer.indexOf(DEFERRED_SHELL_END_MARKER);
104103
if (markerIndex !== -1) {
105-
flush(buffer.slice(0, markerIndex));
104+
flush(buffer.slice(0, markerIndex + DEFERRED_SHELL_END_MARKER.length));
106105
await reader.cancel(DEFERRED_SHELL_CANCEL_REASON);
107106
break;
108107
}

‎packages/react/test/integration/react-pages-client.test.ts‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -571,18 +571,46 @@ function defineClientTests(getBase: () => string) {
571571
await page.goto(BASE + '/');
572572
await waitForHydration(page);
573573

574-
// SPA navigate to deferred page
575-
await page.click('a[href="/deferred"]');
574+
await page.evaluate(() => {
575+
window.__SPA_MARKER = true;
576+
});
577+
578+
// Avoid hover prefetch so this exercises the deferred NDJSON navigation path.
579+
await page.evaluate(() => {
580+
document.querySelector<HTMLAnchorElement>('a[href="/deferred"]')?.click();
581+
});
576582
await page.waitForFunction(() => document.querySelector('[data-testid="deferred-resolved"]'));
577583

578584
const text = await page.textContent('[data-testid="deferred-resolved"]');
579585
expect(text).toBe('Count: 42');
586+
expect(await page.evaluate(() => window.__SPA_MARKER)).toBe(true);
580587
expect(page.url()).toBe(BASE + '/deferred');
581588
} finally {
582589
await page.context().close();
583590
}
584591
});
585592

593+
it('deferred props: useId hydrates without mismatches', async () => {
594+
const page = await newPage();
595+
try {
596+
await page.goto(BASE + '/deferred');
597+
await waitForHydration(page);
598+
await page.waitForFunction(() => document.querySelector('[data-testid="deferred-resolved"]'));
599+
await page.waitForFunction(
600+
() =>
601+
document.querySelector<HTMLElement>('[data-testid="deferred-title"]')?.dataset.clientId,
602+
);
603+
604+
const ids = await page.evaluate(() => {
605+
const title = document.querySelector<HTMLElement>('[data-testid="deferred-title"]')!;
606+
return { server: title.id, client: title.dataset.clientId };
607+
});
608+
expect(ids.server).toBe(ids.client);
609+
} finally {
610+
await page.context().close();
611+
}
612+
});
613+
586614
it('SPA navigation updates document.title from head()', async () => {
587615
const page = await newPage();
588616
try {

‎playground/react-pages/pages/deferred.tsx‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Suspense, use } from 'react';
1+
import { Suspense, use, useId } from 'react';
22
import type { Props } from './deferred.server.ts';
33

44
function Stats({ stats }: Pick<Props, 'stats'>) {
@@ -8,9 +8,21 @@ function Stats({ stats }: Pick<Props, 'stats'>) {
88
}
99

1010
export default function DeferredPage({ title, stats }: Props) {
11+
const titleId = useId();
12+
1113
return (
1214
<div>
13-
<h1>{title}</h1>
15+
<h1
16+
id={titleId}
17+
data-testid="deferred-title"
18+
ref={(element) => {
19+
if (element) {
20+
element.dataset.clientId = titleId;
21+
}
22+
}}
23+
>
24+
{title}
25+
</h1>
1426
<Suspense fallback={<div data-testid="deferred-loading">Loading stats...</div>}>
1527
<Stats stats={stats} />
1628
</Suspense>

0 commit comments

Comments
 (0)