Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 91 additions & 19 deletions packages/react/src/runtime/pages-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import React, {
useLayoutEffect,
useMemo,
useReducer,
useState,
useTransition,
type ComponentType,
} from 'react';
Expand Down Expand Up @@ -106,6 +107,9 @@ const committedResources = new Set<number>();
const EMPTY_PARAMS: Record<string, string> = {};

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<ReactPageData | undefined>)
| null = null;
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<ReactPageResource>(
() => ({
id: 0,
url: initialPageData.url || currentUrl(),
fetchUrl: initialPageData.url || currentUrl(),
fetchUrl: window.location.pathname + window.location.search,
hash: window.location.hash,
method: 'GET',
options: {},
Expand All @@ -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() {},
}),
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -630,6 +698,7 @@ export async function startReactPages(config: ReactPagesConfig): Promise<ReactPa
pendingVisitSettlers.clear();
committedResources.clear();
activePendingNavigationId = null;
prerenderedUrl = null;

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

const isSsr = appEl.getAttribute('data-void-ssr') !== 'false';
if (isSsr) {
prerenderedUrl = prerenderedRouteUrl(engine.initialPageData as ReactPageData);
}
const appRoot = React.createElement(App, {
engine,
router,
viewTransitions: config.viewTransitions,
navigationFallback: config.navigationFallback,
});
const isSsr = appEl.getAttribute('data-void-ssr') !== 'false';
const hasDeferred = Boolean((engine.initialPageData as ReactPageData).deferredKeys);
const app =
isSsr && hasDeferred
Expand Down
79 changes: 79 additions & 0 deletions packages/void/test/integration/node-static.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,85 @@ describe('playground/node-static build (target: node, output: static)', () => {
}
});

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<string> = [];
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<NavElement>('[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),
Expand Down
5 changes: 5 additions & 0 deletions playground/node-static/pages/router-url-client.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { defineHandler } from 'void';

export const ssr = false;

export const loader = defineHandler(() => ({}));
9 changes: 9 additions & 0 deletions playground/node-static/pages/router-url-client.tsx
Original file line number Diff line number Diff line change
@@ -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 <p data-testid="router-first-search">{firstSearch}</p>;
}
16 changes: 16 additions & 0 deletions playground/node-static/pages/router-url/[slug].server.ts
Original file line number Diff line number Diff line change
@@ -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<Props>(() => {
return { title: 'Router URL' };
});
44 changes: 44 additions & 0 deletions playground/node-static/pages/router-url/[slug].tsx
Original file line number Diff line number Diff line change
@@ -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 <p data-testid="lazy-note">Lazy note</p>;
}

// 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<HTMLParagraphElement>(null);
useEffect(() => {
urlRef.current!.textContent = href;
}, [href]);
const mountRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
mountRef.current!.textContent = router.url.search;
// oxlint-disable-next-line react-hooks/exhaustive-deps -- reads the router once, on mount
}, []);

return (
<div>
<h1 data-testid="router-url-title">{title}</h1>
<p data-testid="router-path">{pathname + search}</p>
<p data-testid="router-url" ref={urlRef} />
<p data-testid="router-mount-search" ref={mountRef} />
<Link href="#details">Details</Link> | <Link href={pathname + '?foo=two'}>Query two</Link>
<Suspense fallback={<p data-testid="lazy-fallback">Loading</p>}>
<LazyNote />
</Suspense>
</div>
);
}
Loading