From 728fa095ddd99e04e550fa63f835e9fa96a231b1 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sun, 18 Oct 2026 23:41:21 +0800 Subject: [PATCH] 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) --- packages/react/src/runtime/pages-client.ts | 110 +++++++++++++++--- .../void/test/integration/node-static.test.ts | 79 +++++++++++++ .../pages/router-url-client.server.ts | 5 + .../node-static/pages/router-url-client.tsx | 9 ++ .../pages/router-url/[slug].server.ts | 16 +++ .../node-static/pages/router-url/[slug].tsx | 44 +++++++ 6 files changed, 244 insertions(+), 19 deletions(-) create mode 100644 playground/node-static/pages/router-url-client.server.ts create mode 100644 playground/node-static/pages/router-url-client.tsx create mode 100644 playground/node-static/pages/router-url/[slug].server.ts create mode 100644 playground/node-static/pages/router-url/[slug].tsx diff --git a/packages/react/src/runtime/pages-client.ts b/packages/react/src/runtime/pages-client.ts index 3de396df4..c93792a9b 100644 --- a/packages/react/src/runtime/pages-client.ts +++ b/packages/react/src/runtime/pages-client.ts @@ -8,6 +8,7 @@ import React, { useLayoutEffect, useMemo, useReducer, + useState, useTransition, type ComponentType, } from 'react'; @@ -106,6 +107,9 @@ const committedResources = new Set(); const EMPTY_PARAMS: Record = {}; let activePendingNavigationId: number | null = null; +// The URL the prerendered HTML was rendered with, while it differs from the browser URL. The +// hydration commit clears it before effects run, so effects and later renders read the browser URL. +let prerenderedUrl: string | null = null; let scheduleVisit: | ((url: string, options?: VisitOptions) => Promise) | null = null; @@ -156,6 +160,51 @@ function normalizeUrl(value: string | URL): string { return url.origin === window.location.origin ? url.pathname + url.search + url.hash : url.href; } +/** Page data holds a decoded path. Compare it with browser URLs in their encoded form. */ +function encodedPageUrl(url: string): string { + if (!url) { + return url; + } + const parsed = new URL(url, window.location.origin); + return parsed.pathname + parsed.search; +} + +/** Static hosts serve one page at `/a`, `/a/`, `/a.html` and `/a/index.html`. */ +function staticPagePath(pathname: string): string { + let path = pathname; + if (path.endsWith('/index.html')) { + path = path.slice(0, -'index.html'.length); + } else if (path.endsWith('.html')) { + path = path.slice(0, -'.html'.length); + } + return path.length > 1 && path.endsWith('/') ? path.slice(0, -1) : path; +} + +/** + * Static page data serves every query of a path, so its url has no query. Returns the query of + * fetchUrl when the page data is such data for its path, or null. + */ +function staticVisitQuery(pageUrl: string, fetchUrl: string): string | null { + if (!pageUrl || pageUrl.includes('?')) { + return null; + } + const queryStart = fetchUrl.indexOf('?'); + const fetchPath = queryStart === -1 ? fetchUrl : fetchUrl.slice(0, queryStart); + if (staticPagePath(fetchPath) !== staticPagePath(pageUrl)) { + return null; + } + return queryStart === -1 ? '' : fetchUrl.slice(queryStart); +} + +/** + * The URL prerendered HTML was rendered with, when it lacks the visitor's query, or null. + */ +function prerenderedRouteUrl(pageData: ReactPageData): string | null { + const pageUrl = encodedPageUrl(pageData.url); + const query = staticVisitQuery(pageUrl, window.location.pathname + window.location.search); + return query ? pageUrl + window.location.hash : null; +} + function navigationForResource(resource: PageNavigationResource): NavigationState { const method = resource.method.toUpperCase(); const isSubmitting = method !== 'GET'; @@ -287,21 +336,29 @@ function RouteResource({ const pageData = result.pageData as ReactPageData; const PageComponent = result.component as ReactPageComponent; const routeParams = pageData.params ?? EMPTY_PARAMS; - const routeRouter = useMemo( - () => - createRouterFacade( - outerRouter!, - () => { - const outerUrl = outerRouter!.url; - const outerPath = outerUrl.pathname + outerUrl.search; - return outerPath === pageData.url - ? outerPath + outerUrl.hash - : pageData.url + resource.hash; - }, - () => routeParams, - ), - [outerRouter, pageData.url, resource.hash, routeParams], - ); + const routeRouter = useMemo(() => { + const pageUrl = encodedPageUrl(pageData.url); + return createRouterFacade( + outerRouter!, + () => { + const outerUrl = outerRouter!.url; + const outerPath = outerUrl.pathname + outerUrl.search; + if (outerPath === pageUrl) { + return outerPath + outerUrl.hash; + } + // Keep the query of a visit that loaded static page data. The outer URL keeps the hash + // current after a same-page hash visit; during a reveal it still names the old page. + const query = staticVisitQuery(pageUrl, resource.fetchUrl); + if (query !== null) { + return ( + pageUrl + query + (outerPath === resource.fetchUrl ? outerUrl.hash : resource.hash) + ); + } + return pageUrl + resource.hash; + }, + () => routeParams, + ); + }, [outerRouter, pageData.url, resource.fetchUrl, resource.hash, routeParams]); let element = React.createElement(PageComponent, pageData.props); for (let i = result.layouts.length - 1; i >= 0; i--) { const Layout = result.layouts[i] as ReactLayoutComponent; @@ -356,11 +413,12 @@ function App({ navigationFallback?: ComponentType<{ initialPageData: PageObject }>; }): React.ReactNode { const initialPageData = engine.initialPageData as ReactPageData; + const [hydrated, setHydrated] = useState(prerenderedUrl === null); const initialResource = useMemo( () => ({ id: 0, url: initialPageData.url || currentUrl(), - fetchUrl: initialPageData.url || currentUrl(), + fetchUrl: window.location.pathname + window.location.search, hash: window.location.hash, method: 'GET', options: {}, @@ -374,6 +432,13 @@ function App({ async commit() { engine.commitInitialRender(); document.getElementById('app')!.setAttribute('data-hydrated', 'true'); + if (prerenderedUrl !== null) { + prerenderedUrl = null; + // A transition waits for Suspense boundaries that are still dehydrated, where an urgent + // update would replace their server HTML with the fallback. Those boundaries hydrate + // with the browser URL, so one that renders the query falls back to a client render. + React.startTransition(() => setHydrated(true)); + } }, dispose() {}, }), @@ -598,8 +663,11 @@ function App({ }, [isTransitionPending, state.navigation]); const routerValue = useMemo( - () => createRouterFacade(router, () => state.routeUrl), - [router, state.routeUrl], + () => + createRouterFacade(router, () => + hydrated ? state.routeUrl : (prerenderedUrl ?? state.routeUrl), + ), + [router, state.routeUrl, hydrated], ); return React.createElement( @@ -630,6 +698,7 @@ export async function startReactPages(config: ReactPagesConfig): Promise { } }); + it('keeps the query string in router.url on a prerendered React page', async () => { + const browser = await chromium.launch(); + try { + const page = await browser.newPage(); + page.setDefaultTimeout(10_000); + const errors: Array = []; + page.on('console', (message) => { + const text = message.text(); + // React reports a hydration text mismatch as minified error #418. + if (text.toLowerCase().includes('hydrat') || text.includes('#418')) { + errors.push(text); + } + }); + page.on('pageerror', (error) => errors.push(error.message)); + // Mark the server-rendered lazy note before React hydrates it. + await page.addInitScript(() => { + document.addEventListener('readystatechange', () => { + if (document.readyState === 'interactive') { + const note = document.querySelector('[data-testid="lazy-note"]'); + if (note) { + note.__STAMP = 1; + } + } + }); + }); + const routerUrl = () => page.textContent('[data-testid="router-url"]'); + + // The page HTML and its static page data were rendered for the path, without a query. + const pageData = JSON.parse( + readFileSync(join(DIST, 'client/_void/pages/router-url/plain.json'), 'utf-8'), + ) as { url: string }; + expect(pageData.url).toBe('/router-url/plain'); + await page.goto(`${staticBase}/router-url/plain?foo=bar#baz`); + await page.waitForSelector('#app[data-hydrated="true"]'); + await expect.poll(routerUrl).toBe('/router-url/plain?foo=bar#baz'); + expect(await page.textContent('[data-testid="router-path"]')).toBe( + '/router-url/plain?foo=bar', + ); + // An effect that runs once, on mount, sees the query too. + expect(await page.textContent('[data-testid="router-mount-search"]')).toBe('?foo=bar'); + // The URL switch after hydration keeps the server HTML of a Suspense boundary that has not + // hydrated yet. + expect( + await page.$eval('[data-testid="lazy-note"]', (note: NavElement) => note.__STAMP), + ).toBe(1); + + await page.click('a[href="#details"]'); + await expect.poll(routerUrl).toBe('/router-url/plain?foo=bar#details'); + + await page.click('a[href="/router-url/plain?foo=two"]'); + await page.waitForURL(`${staticBase}/router-url/plain?foo=two`); + await expect.poll(routerUrl).toBe('/router-url/plain?foo=two'); + expect(await page.textContent('[data-testid="router-path"]')).toBe( + '/router-url/plain?foo=two', + ); + + // Page data holds the decoded path of a page served at an encoded path. + await page.goto(`${staticBase}/router-url/a%20b?x=1#a`); + await page.waitForSelector('#app[data-hydrated="true"]'); + await expect.poll(routerUrl).toBe('/router-url/a%20b?x=1#a'); + await page.click('a[href="#details"]'); + await expect.poll(routerUrl).toBe('/router-url/a%20b?x=1#details'); + + // Static hosts also serve the page at its .html path. router.url keeps the page path. + await page.goto(`${staticBase}/router-url/plain.html?foo=bar#baz`); + await page.waitForSelector('#app[data-hydrated="true"]'); + await expect.poll(routerUrl).toBe('/router-url/plain?foo=bar#baz'); + + // A page rendered only in the browser has no prerendered HTML to match. + await page.goto(`${staticBase}/router-url-client?foo=bar`); + await page.waitForSelector('#app[data-hydrated="true"]'); + expect(await page.textContent('[data-testid="router-first-search"]')).toBe('?foo=bar'); + + expect(errors).toEqual([]); + } finally { + await browser.close(); + } + }); + it('collects prerender paths from the built node app endpoint', async () => { const paths = await collectPrerenderPathsNode({ root: join(ROOT, PLAYGROUND), diff --git a/playground/node-static/pages/router-url-client.server.ts b/playground/node-static/pages/router-url-client.server.ts new file mode 100644 index 000000000..67953cc0b --- /dev/null +++ b/playground/node-static/pages/router-url-client.server.ts @@ -0,0 +1,5 @@ +import { defineHandler } from 'void'; + +export const ssr = false; + +export const loader = defineHandler(() => ({})); diff --git a/playground/node-static/pages/router-url-client.tsx b/playground/node-static/pages/router-url-client.tsx new file mode 100644 index 000000000..bb7f5522c --- /dev/null +++ b/playground/node-static/pages/router-url-client.tsx @@ -0,0 +1,9 @@ +import { useRouter } from '@void/react'; +import { useState } from 'react'; + +// Rendered only in the browser, so the first render can use the query. +export default function RouterUrlClientPage() { + const router = useRouter(); + const [firstSearch] = useState(() => router.url.search); + return

{firstSearch}

; +} diff --git a/playground/node-static/pages/router-url/[slug].server.ts b/playground/node-static/pages/router-url/[slug].server.ts new file mode 100644 index 000000000..920c9f507 --- /dev/null +++ b/playground/node-static/pages/router-url/[slug].server.ts @@ -0,0 +1,16 @@ +import { defineHandler } from 'void'; + +export type Props = Readonly<{ + title: string; +}>; + +export const prerender = true; + +// "a b" is served at the encoded path /router-url/a%20b. +export function getPrerenderPaths() { + return [{ slug: 'plain' }, { slug: 'a b' }]; +} + +export const loader = defineHandler(() => { + return { title: 'Router URL' }; +}); diff --git a/playground/node-static/pages/router-url/[slug].tsx b/playground/node-static/pages/router-url/[slug].tsx new file mode 100644 index 000000000..26e7ae7b0 --- /dev/null +++ b/playground/node-static/pages/router-url/[slug].tsx @@ -0,0 +1,44 @@ +import { Link, useRouter } from '@void/react'; +import { lazy, Suspense, useEffect, useRef } from 'react'; +import type { Props } from './[slug].server.ts'; + +function Note() { + return

Lazy note

; +} + +// Loads late in the browser, so its boundary is still dehydrated after the page hydrates. +const LazyNote = lazy(async () => { + if (typeof window !== 'undefined') { + await new Promise((resolve) => setTimeout(resolve, 500)); + } + return { default: Note }; +}); + +export default function RouterUrlPage({ title }: Props) { + const router = useRouter(); + const { pathname, search, hash } = router.url; + const href = pathname + search + hash; + // The server never sees the hash, so write the whole URL only after hydration. + const urlRef = useRef(null); + useEffect(() => { + urlRef.current!.textContent = href; + }, [href]); + const mountRef = useRef(null); + useEffect(() => { + mountRef.current!.textContent = router.url.search; + // oxlint-disable-next-line react-hooks/exhaustive-deps -- reads the router once, on mount + }, []); + + return ( +
+

{title}

+

{pathname + search}

+

+

+ Details | Query two + Loading

}> + + +
+ ); +}