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
2 changes: 1 addition & 1 deletion deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "veryfront",
"version": "0.1.993",
"version": "0.1.994",
"license": "Apache-2.0",
"nodeModulesDir": "auto",
"minimumDependencyAge": {
Expand Down
60 changes: 60 additions & 0 deletions src/html/html-injection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,66 @@ describe("html/html-injection", () => {
assertEquals(hydrationData.clientModuleStrategy, "rsc-module");
});

it("seeds route params into client-page hydration data (issue #2741)", () => {
const html = injectHTMLContent(
baseTemplate,
"<p>content</p>",
minMeta,
{
mode: "production",
slug: "docs/guides/intro",
pagePath: "/app/page.tsx",
isClientPage: true,
params: { slug: ["guides", "intro"] },
},
);

const hydrationData = extractHydrationData(html);
// Catch-all arrays are preserved in the payload; the client runtime joins
// them when seeding the router (issue #2742).
assertEquals(hydrationData.params, { slug: ["guides", "intro"] });
});

it("escapes </script> in route params so the hydration payload cannot break out (XSS)", () => {
const payload = "</script><script>globalThis.pwned=1</script>";
const html = injectHTMLContent(
baseTemplate,
"<p>content</p>",
minMeta,
{
mode: "production",
slug: "test",
pagePath: "/app/page.tsx",
isClientPage: true,
params: { slug: [payload] },
},
);

// The literal breakout sequence must not appear anywhere in the output;
// jsonForInlineScript encodes `<` as \\u003c inside the JSON value.
assertEquals(html.includes("<script>globalThis.pwned=1</script>"), false);
// Round-trips losslessly: if the payload had broken out of the tag, the
// extractor's non-greedy `</script>` match would truncate the JSON and
// JSON.parse would throw here.
assertEquals(extractHydrationData(html).params, { slug: [payload] });
});

it("defaults client-page hydration params to an empty object when unset", () => {
const html = injectHTMLContent(
baseTemplate,
"<p>content</p>",
minMeta,
{
mode: "production",
slug: "test",
pagePath: "/app/page.tsx",
isClientPage: true,
},
);

assertEquals(extractHydrationData(html).params, {});
});

it("keeps production client-page injection on the RSC client boot script", () => {
const html = injectHTMLContent(
baseTemplate,
Expand Down
15 changes: 14 additions & 1 deletion src/html/html-injection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
generateStyleTags,
} from "./tag-generators.ts";
import { buildNonceAttribute } from "./html-escape.ts";
import { jsonForInlineScript } from "#veryfront/security/client/html-sanitizer.ts";
import {
getDevScripts,
getDevStyles,
Expand All @@ -26,6 +27,13 @@ export interface InjectHTMLContentOptions {
projectDir?: string;
/** Whether the page has 'use client' directive */
isClientPage?: boolean;
/**
* Route params from the initial match, seeded into the 'use client' hydration
* payload so full-HTML-document client pages hydrate with their params
* instead of an empty object (issue #2741). Catch-all arrays are preserved;
* the client runtime joins them (issue #2742).
*/
params?: Record<string, string | string[]>;
/** Whether page is embedded in Studio iframe */
studioEmbed?: boolean;
/** Project ID for Studio communication */
Expand Down Expand Up @@ -114,10 +122,15 @@ export function injectHTMLContent(

// Inject hydration data for 'use client' pages (before scripts, so client.js can find it)
if (options.pagePath && options.isClientPage && hasBodyClose) {
const hydrationData = JSON.stringify({
// Serialize with jsonForInlineScript, not raw JSON.stringify: route params
// (and slug) are URL-derived and decoded, so a segment like `%3C/script%3E`
// would otherwise break out of the <script> tag (reflected XSS). This escapes
// `<`, `>`, `&`, and line separators, matching the main shell hydration path.
const hydrationData = jsonForInlineScript({
pagePath: toProjectRelativePath(options.pagePath, options.projectDir),
slug: options.slug,
isClientPage: true,
params: options.params ?? {},
clientModuleStrategy: determineClientModuleStrategy({
isLocalProject: options.isLocalProject ?? options.mode === "development",
environment: options.environment,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,10 @@ describe("hydration-script-builder/templates/renderer", () => {
assertIncludes(getRendererScript(), "pageModule.default || pageModule");
});

it("should merge props with params", () => {
assertIncludes(getRendererScript(), "...(data.props || {}), params: data.params || {}");
it("should merge props with normalized params", () => {
const result = getRendererScript();
assertIncludes(result, "const normalizedParams = normalizeRouteParams(data.params)");
assertIncludes(result, "...(data.props || {}), params: normalizedParams");
});

it("should wrap with layouts from innermost to outermost", () => {
Expand Down Expand Up @@ -108,7 +110,7 @@ describe("hydration-script-builder/templates/renderer", () => {
const result = getRendererScript();
assertIncludes(result, "slug: data.slug");
assertIncludes(result, "path: data.pagePath");
assertIncludes(result, "params: data.params");
assertIncludes(result, "params: normalizedParams");
assertIncludes(result, "frontmatter: data.frontmatter");
assertIncludes(result, "headings,");
});
Expand Down
8 changes: 6 additions & 2 deletions src/html/hydration-script-builder/templates/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,11 @@ export const getRendererScript = () => `
return;
}

const pageProps = { ...(data.props || {}), params: data.params || {} };
// Normalize catch-all params (arrays -> joined strings) so the hydrated
// props and page context match the server render. normalizeRouteParams
// is defined in router.ts, which loads first (issue #2742).
const normalizedParams = normalizeRouteParams(data.params);
const pageProps = { ...(data.props || {}), params: normalizedParams };
let tree = React.createElement(PageComponent, pageProps);

const layouts = data.layouts;
Expand Down Expand Up @@ -143,7 +147,7 @@ export const getRendererScript = () => `
const pageContext = {
slug: data.slug || '',
path: data.pagePath || resolvedPathname,
params: data.params || {},
params: normalizedParams,
query: Object.fromEntries(new URLSearchParams(window.location.search)),
frontmatter: data.frontmatter || {},
headings,
Expand Down
Loading