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

}> + + +
+ ); +}