diff --git a/deno.json b/deno.json index 6af1b42e71..8abeaae28d 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "veryfront", - "version": "0.1.993", + "version": "0.1.994", "license": "Apache-2.0", "nodeModulesDir": "auto", "minimumDependencyAge": { diff --git a/src/html/html-injection.test.ts b/src/html/html-injection.test.ts index 7727ecb6a4..3fa21138ab 100644 --- a/src/html/html-injection.test.ts +++ b/src/html/html-injection.test.ts @@ -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, + "
content
", + 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 in route params so the hydration payload cannot break out (XSS)", () => { + const payload = ""; + const html = injectHTMLContent( + baseTemplate, + "content
", + 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(""), false); + // Round-trips losslessly: if the payload had broken out of the tag, the + // extractor's non-greedy `` 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, + "content
", + 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, diff --git a/src/html/html-injection.ts b/src/html/html-injection.ts index 2b018e1b12..a59cc60044 100644 --- a/src/html/html-injection.ts +++ b/src/html/html-injection.ts @@ -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, @@ -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