Skip to content

Commit 728fa09

Browse files
Brooooooklynclaude
andcommitted
fix(react): keep the visitor's query in useRouter().url on static pages
With `output: 'static'`, the prerendered HTML and its page data hold the path without a query, and serve every query of that path. The React page router used the page data's URL whenever it differed from the browser URL, so `useRouter().url` dropped the query on load and after `Link` navigation, and kept a stale hash after a same-page hash link. When the page data has no query and belongs to the same page path, `router.url` now keeps the query of the visit. `/a`, `/a/`, `/a.html` and `/a/index.html` count as one page path, and paths are compared in encoded form. Hydration still renders with the prerendered URL, so the HTML matches. The commit then switches to the browser URL before effects run and re-renders in a transition, which keeps the server HTML of Suspense boundaries that have not hydrated yet. Fixes #449 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 9ed64ba commit 728fa09

6 files changed

Lines changed: 244 additions & 19 deletions

File tree

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

Lines changed: 91 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import React, {
88
useLayoutEffect,
99
useMemo,
1010
useReducer,
11+
useState,
1112
useTransition,
1213
type ComponentType,
1314
} from 'react';
@@ -106,6 +107,9 @@ const committedResources = new Set<number>();
106107
const EMPTY_PARAMS: Record<string, string> = {};
107108

108109
let activePendingNavigationId: number | null = null;
110+
// The URL the prerendered HTML was rendered with, while it differs from the browser URL. The
111+
// hydration commit clears it before effects run, so effects and later renders read the browser URL.
112+
let prerenderedUrl: string | null = null;
109113
let scheduleVisit:
110114
| ((url: string, options?: VisitOptions) => Promise<ReactPageData | undefined>)
111115
| null = null;
@@ -156,6 +160,51 @@ function normalizeUrl(value: string | URL): string {
156160
return url.origin === window.location.origin ? url.pathname + url.search + url.hash : url.href;
157161
}
158162

163+
/** Page data holds a decoded path. Compare it with browser URLs in their encoded form. */
164+
function encodedPageUrl(url: string): string {
165+
if (!url) {
166+
return url;
167+
}
168+
const parsed = new URL(url, window.location.origin);
169+
return parsed.pathname + parsed.search;
170+
}
171+
172+
/** Static hosts serve one page at `/a`, `/a/`, `/a.html` and `/a/index.html`. */
173+
function staticPagePath(pathname: string): string {
174+
let path = pathname;
175+
if (path.endsWith('/index.html')) {
176+
path = path.slice(0, -'index.html'.length);
177+
} else if (path.endsWith('.html')) {
178+
path = path.slice(0, -'.html'.length);
179+
}
180+
return path.length > 1 && path.endsWith('/') ? path.slice(0, -1) : path;
181+
}
182+
183+
/**
184+
* Static page data serves every query of a path, so its url has no query. Returns the query of
185+
* fetchUrl when the page data is such data for its path, or null.
186+
*/
187+
function staticVisitQuery(pageUrl: string, fetchUrl: string): string | null {
188+
if (!pageUrl || pageUrl.includes('?')) {
189+
return null;
190+
}
191+
const queryStart = fetchUrl.indexOf('?');
192+
const fetchPath = queryStart === -1 ? fetchUrl : fetchUrl.slice(0, queryStart);
193+
if (staticPagePath(fetchPath) !== staticPagePath(pageUrl)) {
194+
return null;
195+
}
196+
return queryStart === -1 ? '' : fetchUrl.slice(queryStart);
197+
}
198+
199+
/**
200+
* The URL prerendered HTML was rendered with, when it lacks the visitor's query, or null.
201+
*/
202+
function prerenderedRouteUrl(pageData: ReactPageData): string | null {
203+
const pageUrl = encodedPageUrl(pageData.url);
204+
const query = staticVisitQuery(pageUrl, window.location.pathname + window.location.search);
205+
return query ? pageUrl + window.location.hash : null;
206+
}
207+
159208
function navigationForResource(resource: PageNavigationResource): NavigationState {
160209
const method = resource.method.toUpperCase();
161210
const isSubmitting = method !== 'GET';
@@ -287,21 +336,29 @@ function RouteResource({
287336
const pageData = result.pageData as ReactPageData;
288337
const PageComponent = result.component as ReactPageComponent;
289338
const routeParams = pageData.params ?? EMPTY_PARAMS;
290-
const routeRouter = useMemo(
291-
() =>
292-
createRouterFacade(
293-
outerRouter!,
294-
() => {
295-
const outerUrl = outerRouter!.url;
296-
const outerPath = outerUrl.pathname + outerUrl.search;
297-
return outerPath === pageData.url
298-
? outerPath + outerUrl.hash
299-
: pageData.url + resource.hash;
300-
},
301-
() => routeParams,
302-
),
303-
[outerRouter, pageData.url, resource.hash, routeParams],
304-
);
339+
const routeRouter = useMemo(() => {
340+
const pageUrl = encodedPageUrl(pageData.url);
341+
return createRouterFacade(
342+
outerRouter!,
343+
() => {
344+
const outerUrl = outerRouter!.url;
345+
const outerPath = outerUrl.pathname + outerUrl.search;
346+
if (outerPath === pageUrl) {
347+
return outerPath + outerUrl.hash;
348+
}
349+
// Keep the query of a visit that loaded static page data. The outer URL keeps the hash
350+
// current after a same-page hash visit; during a reveal it still names the old page.
351+
const query = staticVisitQuery(pageUrl, resource.fetchUrl);
352+
if (query !== null) {
353+
return (
354+
pageUrl + query + (outerPath === resource.fetchUrl ? outerUrl.hash : resource.hash)
355+
);
356+
}
357+
return pageUrl + resource.hash;
358+
},
359+
() => routeParams,
360+
);
361+
}, [outerRouter, pageData.url, resource.fetchUrl, resource.hash, routeParams]);
305362
let element = React.createElement(PageComponent, pageData.props);
306363
for (let i = result.layouts.length - 1; i >= 0; i--) {
307364
const Layout = result.layouts[i] as ReactLayoutComponent;
@@ -356,11 +413,12 @@ function App({
356413
navigationFallback?: ComponentType<{ initialPageData: PageObject }>;
357414
}): React.ReactNode {
358415
const initialPageData = engine.initialPageData as ReactPageData;
416+
const [hydrated, setHydrated] = useState(prerenderedUrl === null);
359417
const initialResource = useMemo<ReactPageResource>(
360418
() => ({
361419
id: 0,
362420
url: initialPageData.url || currentUrl(),
363-
fetchUrl: initialPageData.url || currentUrl(),
421+
fetchUrl: window.location.pathname + window.location.search,
364422
hash: window.location.hash,
365423
method: 'GET',
366424
options: {},
@@ -374,6 +432,13 @@ function App({
374432
async commit() {
375433
engine.commitInitialRender();
376434
document.getElementById('app')!.setAttribute('data-hydrated', 'true');
435+
if (prerenderedUrl !== null) {
436+
prerenderedUrl = null;
437+
// A transition waits for Suspense boundaries that are still dehydrated, where an urgent
438+
// update would replace their server HTML with the fallback. Those boundaries hydrate
439+
// with the browser URL, so one that renders the query falls back to a client render.
440+
React.startTransition(() => setHydrated(true));
441+
}
377442
},
378443
dispose() {},
379444
}),
@@ -598,8 +663,11 @@ function App({
598663
}, [isTransitionPending, state.navigation]);
599664

600665
const routerValue = useMemo(
601-
() => createRouterFacade(router, () => state.routeUrl),
602-
[router, state.routeUrl],
666+
() =>
667+
createRouterFacade(router, () =>
668+
hydrated ? state.routeUrl : (prerenderedUrl ?? state.routeUrl),
669+
),
670+
[router, state.routeUrl, hydrated],
603671
);
604672

605673
return React.createElement(
@@ -630,6 +698,7 @@ export async function startReactPages(config: ReactPagesConfig): Promise<ReactPa
630698
pendingVisitSettlers.clear();
631699
committedResources.clear();
632700
activePendingNavigationId = null;
701+
prerenderedUrl = null;
633702

634703
const engine = await createPageNavigationEngine({
635704
adapter: {
@@ -657,13 +726,16 @@ export async function startReactPages(config: ReactPagesConfig): Promise<ReactPa
657726
throw new Error('pages: Missing #app element for Void React pages hydration.');
658727
}
659728

729+
const isSsr = appEl.getAttribute('data-void-ssr') !== 'false';
730+
if (isSsr) {
731+
prerenderedUrl = prerenderedRouteUrl(engine.initialPageData as ReactPageData);
732+
}
660733
const appRoot = React.createElement(App, {
661734
engine,
662735
router,
663736
viewTransitions: config.viewTransitions,
664737
navigationFallback: config.navigationFallback,
665738
});
666-
const isSsr = appEl.getAttribute('data-void-ssr') !== 'false';
667739
const hasDeferred = Boolean((engine.initialPageData as ReactPageData).deferredKeys);
668740
const app =
669741
isSsr && hasDeferred

‎packages/void/test/integration/node-static.test.ts‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,85 @@ describe('playground/node-static build (target: node, output: static)', () => {
258258
}
259259
});
260260

261+
it('keeps the query string in router.url on a prerendered React page', async () => {
262+
const browser = await chromium.launch();
263+
try {
264+
const page = await browser.newPage();
265+
page.setDefaultTimeout(10_000);
266+
const errors: Array<string> = [];
267+
page.on('console', (message) => {
268+
const text = message.text();
269+
// React reports a hydration text mismatch as minified error #418.
270+
if (text.toLowerCase().includes('hydrat') || text.includes('#418')) {
271+
errors.push(text);
272+
}
273+
});
274+
page.on('pageerror', (error) => errors.push(error.message));
275+
// Mark the server-rendered lazy note before React hydrates it.
276+
await page.addInitScript(() => {
277+
document.addEventListener('readystatechange', () => {
278+
if (document.readyState === 'interactive') {
279+
const note = document.querySelector<NavElement>('[data-testid="lazy-note"]');
280+
if (note) {
281+
note.__STAMP = 1;
282+
}
283+
}
284+
});
285+
});
286+
const routerUrl = () => page.textContent('[data-testid="router-url"]');
287+
288+
// The page HTML and its static page data were rendered for the path, without a query.
289+
const pageData = JSON.parse(
290+
readFileSync(join(DIST, 'client/_void/pages/router-url/plain.json'), 'utf-8'),
291+
) as { url: string };
292+
expect(pageData.url).toBe('/router-url/plain');
293+
await page.goto(`${staticBase}/router-url/plain?foo=bar#baz`);
294+
await page.waitForSelector('#app[data-hydrated="true"]');
295+
await expect.poll(routerUrl).toBe('/router-url/plain?foo=bar#baz');
296+
expect(await page.textContent('[data-testid="router-path"]')).toBe(
297+
'/router-url/plain?foo=bar',
298+
);
299+
// An effect that runs once, on mount, sees the query too.
300+
expect(await page.textContent('[data-testid="router-mount-search"]')).toBe('?foo=bar');
301+
// The URL switch after hydration keeps the server HTML of a Suspense boundary that has not
302+
// hydrated yet.
303+
expect(
304+
await page.$eval('[data-testid="lazy-note"]', (note: NavElement) => note.__STAMP),
305+
).toBe(1);
306+
307+
await page.click('a[href="#details"]');
308+
await expect.poll(routerUrl).toBe('/router-url/plain?foo=bar#details');
309+
310+
await page.click('a[href="/router-url/plain?foo=two"]');
311+
await page.waitForURL(`${staticBase}/router-url/plain?foo=two`);
312+
await expect.poll(routerUrl).toBe('/router-url/plain?foo=two');
313+
expect(await page.textContent('[data-testid="router-path"]')).toBe(
314+
'/router-url/plain?foo=two',
315+
);
316+
317+
// Page data holds the decoded path of a page served at an encoded path.
318+
await page.goto(`${staticBase}/router-url/a%20b?x=1#a`);
319+
await page.waitForSelector('#app[data-hydrated="true"]');
320+
await expect.poll(routerUrl).toBe('/router-url/a%20b?x=1#a');
321+
await page.click('a[href="#details"]');
322+
await expect.poll(routerUrl).toBe('/router-url/a%20b?x=1#details');
323+
324+
// Static hosts also serve the page at its .html path. router.url keeps the page path.
325+
await page.goto(`${staticBase}/router-url/plain.html?foo=bar#baz`);
326+
await page.waitForSelector('#app[data-hydrated="true"]');
327+
await expect.poll(routerUrl).toBe('/router-url/plain?foo=bar#baz');
328+
329+
// A page rendered only in the browser has no prerendered HTML to match.
330+
await page.goto(`${staticBase}/router-url-client?foo=bar`);
331+
await page.waitForSelector('#app[data-hydrated="true"]');
332+
expect(await page.textContent('[data-testid="router-first-search"]')).toBe('?foo=bar');
333+
334+
expect(errors).toEqual([]);
335+
} finally {
336+
await browser.close();
337+
}
338+
});
339+
261340
it('collects prerender paths from the built node app endpoint', async () => {
262341
const paths = await collectPrerenderPathsNode({
263342
root: join(ROOT, PLAYGROUND),
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { defineHandler } from 'void';
2+
3+
export const ssr = false;
4+
5+
export const loader = defineHandler(() => ({}));
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { useRouter } from '@void/react';
2+
import { useState } from 'react';
3+
4+
// Rendered only in the browser, so the first render can use the query.
5+
export default function RouterUrlClientPage() {
6+
const router = useRouter();
7+
const [firstSearch] = useState(() => router.url.search);
8+
return <p data-testid="router-first-search">{firstSearch}</p>;
9+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { defineHandler } from 'void';
2+
3+
export type Props = Readonly<{
4+
title: string;
5+
}>;
6+
7+
export const prerender = true;
8+
9+
// "a b" is served at the encoded path /router-url/a%20b.
10+
export function getPrerenderPaths() {
11+
return [{ slug: 'plain' }, { slug: 'a b' }];
12+
}
13+
14+
export const loader = defineHandler<Props>(() => {
15+
return { title: 'Router URL' };
16+
});
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { Link, useRouter } from '@void/react';
2+
import { lazy, Suspense, useEffect, useRef } from 'react';
3+
import type { Props } from './[slug].server.ts';
4+
5+
function Note() {
6+
return <p data-testid="lazy-note">Lazy note</p>;
7+
}
8+
9+
// Loads late in the browser, so its boundary is still dehydrated after the page hydrates.
10+
const LazyNote = lazy(async () => {
11+
if (typeof window !== 'undefined') {
12+
await new Promise((resolve) => setTimeout(resolve, 500));
13+
}
14+
return { default: Note };
15+
});
16+
17+
export default function RouterUrlPage({ title }: Props) {
18+
const router = useRouter();
19+
const { pathname, search, hash } = router.url;
20+
const href = pathname + search + hash;
21+
// The server never sees the hash, so write the whole URL only after hydration.
22+
const urlRef = useRef<HTMLParagraphElement>(null);
23+
useEffect(() => {
24+
urlRef.current!.textContent = href;
25+
}, [href]);
26+
const mountRef = useRef<HTMLParagraphElement>(null);
27+
useEffect(() => {
28+
mountRef.current!.textContent = router.url.search;
29+
// oxlint-disable-next-line react-hooks/exhaustive-deps -- reads the router once, on mount
30+
}, []);
31+
32+
return (
33+
<div>
34+
<h1 data-testid="router-url-title">{title}</h1>
35+
<p data-testid="router-path">{pathname + search}</p>
36+
<p data-testid="router-url" ref={urlRef} />
37+
<p data-testid="router-mount-search" ref={mountRef} />
38+
<Link href="#details">Details</Link> | <Link href={pathname + '?foo=two'}>Query two</Link>
39+
<Suspense fallback={<p data-testid="lazy-fallback">Loading</p>}>
40+
<LazyNote />
41+
</Suspense>
42+
</div>
43+
);
44+
}

0 commit comments

Comments
 (0)