From f79b33108b4d962f18b1dd05c0321198fac1821a Mon Sep 17 00:00:00 2001 From: Matt Boon Date: Fri, 3 Jul 2026 16:44:42 +0200 Subject: [PATCH 1/9] fix(router): seed route params on client hydration + preserve catch-all segments Fixes #2741 and #2742. - #2741: the client router object (templates/router.ts) hardcoded `params: {}`, so `useRouter().params` was empty after hydration even though the route match params are in the hydration data. Seed them from the hydration data (joining catch-all arrays). - #2742: the SSR param flatteners (layout-applicator, script-page-handling) used `value[0]`, truncating catch-all `[...slug]` to its first segment. Introduce a shared `flattenRouteParams` helper that joins segments with `/`, matching the client/SPA normalizers so server and client agree. Verified live in `veryfront dev`: /posts/42 -> params.id "42" (was "none"); /docs/guides/intro -> params.slug "guides/intro" on both SSR and client (was "guides" on SSR, "none" on client). Unit test covers the flattener red-green. --- .../templates/router.ts | 16 +++++++++++- src/rendering/layouts/layout-applicator.ts | 9 ++----- src/rendering/script-page-handling.ts | 9 ++----- src/routing/flatten-route-params.test.ts | 25 +++++++++++++++++++ src/routing/flatten-route-params.ts | 20 +++++++++++++++ src/routing/index.ts | 1 + 6 files changed, 65 insertions(+), 15 deletions(-) create mode 100644 src/routing/flatten-route-params.test.ts create mode 100644 src/routing/flatten-route-params.ts diff --git a/src/html/hydration-script-builder/templates/router.ts b/src/html/hydration-script-builder/templates/router.ts index d0f724ee54..b9fd336ea7 100644 --- a/src/html/hydration-script-builder/templates/router.ts +++ b/src/html/hydration-script-builder/templates/router.ts @@ -974,7 +974,21 @@ export const getRouterScript = () => ` }, pathname: window.location.pathname, query: Object.fromEntries(new URLSearchParams(window.location.search)), - params: {}, + // Seed route params from the hydration data (issue #2741). Catch-all + // segments arrive as arrays and are joined so no path info is lost. + params: (function () { + try { + const el = document.getElementById('veryfront-hydration-data'); + const raw = (JSON.parse(el && el.textContent ? el.textContent : '{}') || {}).params || {}; + const out = {}; + for (const key in raw) { + out[key] = Array.isArray(raw[key]) ? raw[key].join('/') : raw[key]; + } + return out; + } catch (_) { + return {}; + } + })(), isPreview: false, isMounted: true, navigate: (path) => navigateSPA(path, true), diff --git a/src/rendering/layouts/layout-applicator.ts b/src/rendering/layouts/layout-applicator.ts index 06075ff35f..3a7af2de77 100644 --- a/src/rendering/layouts/layout-applicator.ts +++ b/src/rendering/layouts/layout-applicator.ts @@ -1,5 +1,6 @@ import { dirname, join } from "#veryfront/compat/path"; import { rendererLogger } from "#veryfront/utils"; +import { flattenRouteParams } from "#veryfront/routing"; import * as BundledReact from "react"; import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; import type { LayoutItem, MdxBundle, MDXComponents } from "#veryfront/types"; @@ -119,13 +120,7 @@ export class LayoutApplicator { const React = await getProjectReact(); const headingsArray = this.headings ?? []; - const flatParams = this.params - ? Object.fromEntries( - Object.entries(this.params) - .map(([key, value]) => [key, Array.isArray(value) ? value[0] : value]) - .filter((entry): entry is [string, string] => entry[1] !== undefined), - ) - : {}; + const flatParams = flattenRouteParams(this.params); const query = this.requestUrl ? Object.fromEntries(this.requestUrl.searchParams) : {}; const pageContext = { slug: pageInfo.entity.slug || "", diff --git a/src/rendering/script-page-handling.ts b/src/rendering/script-page-handling.ts index 59cf864dae..a3e56277dc 100644 --- a/src/rendering/script-page-handling.ts +++ b/src/rendering/script-page-handling.ts @@ -10,6 +10,7 @@ import { rewriteNpmImports } from "#veryfront/transforms/npm-import-rewrites.ts" import { dirname, join } from "#veryfront/compat/path/index.ts"; import { cwd } from "#veryfront/platform/compat/process.ts"; import { RENDER_ERROR } from "#veryfront/errors/error-registry.ts"; +import { flattenRouteParams } from "#veryfront/routing"; import { createError, toError } from "#veryfront/errors/veryfront-error.ts"; import type { ComponentProps, @@ -128,13 +129,7 @@ function buildPageContext( params?: Record, url?: URL, ): PageContext { - const flatParams: Record = params - ? Object.fromEntries( - Object.entries(params) - .map(([k, v]) => [k, Array.isArray(v) ? v[0] : v]) - .filter((entry): entry is [string, string] => entry[1] !== undefined), - ) - : {}; + const flatParams = flattenRouteParams(params); return { params: flatParams, diff --git a/src/routing/flatten-route-params.test.ts b/src/routing/flatten-route-params.test.ts new file mode 100644 index 0000000000..576d998c2a --- /dev/null +++ b/src/routing/flatten-route-params.test.ts @@ -0,0 +1,25 @@ +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { flattenRouteParams } from "./flatten-route-params.ts"; + +describe("routing/flattenRouteParams", () => { + it("keeps a single dynamic segment as-is", () => { + assertEquals(flattenRouteParams({ id: "42" }), { id: "42" }); + }); + + it("joins catch-all array segments instead of dropping them (issue #2742)", () => { + // Regression: the SSR flatteners used `value[0]`, truncating to "guides". + assertEquals(flattenRouteParams({ slug: ["guides", "intro"] }), { slug: "guides/intro" }); + }); + + it("handles mixed params and skips undefined values", () => { + assertEquals( + flattenRouteParams({ id: "7", rest: ["a", "b", "c"], missing: undefined as never }), + { id: "7", rest: "a/b/c" }, + ); + }); + + it("returns an empty object for no params", () => { + assertEquals(flattenRouteParams(undefined), {}); + }); +}); diff --git a/src/routing/flatten-route-params.ts b/src/routing/flatten-route-params.ts new file mode 100644 index 0000000000..7bfcc62f2d --- /dev/null +++ b/src/routing/flatten-route-params.ts @@ -0,0 +1,20 @@ +/** + * Flattens matched route params (which may be arrays for catch-all `[...slug]` + * segments) into the `Record` shape the router value exposes. + * + * Catch-all segments are **joined with `/`** so no path information is lost — + * `/docs/guides/intro` -> `{ slug: "guides/intro" }`, not `{ slug: "guides" }`. + * This matches the client SPA normalizer and the RSC hydration normalizer, so + * server and client agree. + */ +export function flattenRouteParams( + params?: Record, +): Record { + if (!params) return {}; + const flat: Record = {}; + for (const [key, value] of Object.entries(params)) { + if (value === undefined) continue; + flat[key] = Array.isArray(value) ? value.join("/") : value; + } + return flat; +} diff --git a/src/routing/index.ts b/src/routing/index.ts index 8f3856cb8f..3f14d09ab3 100644 --- a/src/routing/index.ts +++ b/src/routing/index.ts @@ -6,6 +6,7 @@ */ export type { Route, RouteMatch } from "./matchers/index.ts"; +export { flattenRouteParams } from "./flatten-route-params.ts"; export { getSpecificityScore, matchRoute, From ec14301377030b1a838e75530d49d5431e78a40d Mon Sep 17 00:00:00 2001 From: Matt Boon Date: Fri, 3 Jul 2026 17:05:21 +0200 Subject: [PATCH 2/9] Release veryfront 0.1.994 --- deno.json | 6 +++--- src/utils/version-constant.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/deno.json b/deno.json index 6af1b42e71..d17f250606 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": { @@ -393,12 +393,12 @@ "build:npm": "deno run -A scripts/build/generate-integrations-module.ts && deno task generate && deno run -A scripts/build/build-npm-dnt.ts", "release": "deno run -A scripts/release.ts", "test": "deno task generate && VF_DISABLE_LRU_INTERVAL=1 SSR_TRANSFORM_PER_PROJECT_LIMIT=0 REVALIDATION_PER_PROJECT_LIMIT=0 NODE_ENV=production LOG_FORMAT=text deno test --preload=src/schemas/_test-setup.ts --no-check --parallel --allow-all '--ignore=tests/e2e,tests/integration/compiled-binary-e2e.test.ts' --unstable-worker-options --unstable-net", - "test:unit": "deno task generate && VF_DISABLE_LRU_INTERVAL=1 SSR_TRANSFORM_PER_PROJECT_LIMIT=0 REVALIDATION_PER_PROJECT_LIMIT=0 NODE_ENV=production LOG_FORMAT=text deno test --preload=src/schemas/_test-setup.ts --no-check --parallel --allow-all --v8-flags=--max-old-space-size=8192 '--ignore=tests,src/workflow/__tests__' --unstable-worker-options --unstable-net $(find src cli -name '*.test.ts*' ! -name '*.integration.test.ts*')", + "test:unit": "deno task generate && VF_DISABLE_LRU_INTERVAL=1 SSR_TRANSFORM_PER_PROJECT_LIMIT=0 REVALIDATION_PER_PROJECT_LIMIT=0 NODE_ENV=production LOG_FORMAT=text deno test --preload=src/schemas/_test-setup.ts --no-check --parallel --allow-all --v8-flags=--max-old-space-size=8192 '--ignore=tests,src/workflow/__tests__' --unstable-worker-options --unstable-net $(find src cli -name '*.test.ts' ! -name '*.integration.test.ts')", "test:integration": "deno task generate && VF_DISABLE_LRU_INTERVAL=1 SSR_TRANSFORM_PER_PROJECT_LIMIT=0 REVALIDATION_PER_PROJECT_LIMIT=0 NODE_ENV=production LOG_FORMAT=text deno test --preload=src/schemas/_test-setup.ts --no-check --parallel --allow-all '--ignore=tests/e2e,tests/integration/compiled-binary-e2e.test.ts' tests --unstable-worker-options --unstable-net", "test:integration:cli": "VF_DISABLE_LRU_INTERVAL=1 SSR_TRANSFORM_PER_PROJECT_LIMIT=0 REVALIDATION_PER_PROJECT_LIMIT=0 NODE_ENV=production LOG_FORMAT=text deno test --no-check --parallel --allow-all --unstable-worker-options --unstable-net $(find cli -name '*.integration.test.ts')", "test:record": "VCR=record deno test --no-check --allow-all $(find cli -name '*.integration.test.ts' -path '*/commands/*')", "test:coverage": "rm -rf coverage && deno task generate && VF_DISABLE_LRU_INTERVAL=1 SSR_TRANSFORM_PER_PROJECT_LIMIT=0 REVALIDATION_PER_PROJECT_LIMIT=0 NODE_ENV=production LOG_FORMAT=text deno test --preload=src/schemas/_test-setup.ts --no-check --parallel --fail-fast --allow-all --v8-flags=--max-old-space-size=8192 --coverage=coverage '--ignore=tests/integration/compiled-binary-e2e.test.ts' --unstable-worker-options --unstable-net || exit 1", - "test:coverage:unit": "rm -rf coverage && deno task generate && VF_DISABLE_LRU_INTERVAL=1 SSR_TRANSFORM_PER_PROJECT_LIMIT=0 REVALIDATION_PER_PROJECT_LIMIT=0 NODE_ENV=production LOG_FORMAT=text deno test --preload=src/schemas/_test-setup.ts --no-check --parallel --fail-fast --allow-all --v8-flags=--max-old-space-size=8192 --coverage=coverage '--ignore=tests,src/workflow/__tests__' --unstable-worker-options --unstable-net $(find src cli -name '*.test.ts*' ! -name '*.integration.test.ts*') || exit 1", + "test:coverage:unit": "rm -rf coverage && deno task generate && VF_DISABLE_LRU_INTERVAL=1 SSR_TRANSFORM_PER_PROJECT_LIMIT=0 REVALIDATION_PER_PROJECT_LIMIT=0 NODE_ENV=production LOG_FORMAT=text deno test --preload=src/schemas/_test-setup.ts --no-check --parallel --fail-fast --allow-all --v8-flags=--max-old-space-size=8192 --coverage=coverage '--ignore=tests,src/workflow/__tests__' --unstable-worker-options --unstable-net $(find src cli -name '*.test.ts' ! -name '*.integration.test.ts') || exit 1", "test:coverage:integration": "rm -rf coverage && deno task generate && VF_DISABLE_LRU_INTERVAL=1 SSR_TRANSFORM_PER_PROJECT_LIMIT=0 REVALIDATION_PER_PROJECT_LIMIT=0 NODE_ENV=production LOG_FORMAT=text deno test --preload=src/schemas/_test-setup.ts --no-check --parallel --fail-fast --allow-all --v8-flags=--max-old-space-size=8192 --coverage=coverage '--ignore=tests/e2e,tests/integration/compiled-binary-e2e.test.ts' tests --unstable-worker-options --unstable-net || exit 1", "coverage:report": "deno coverage coverage --include=src/ --exclude=tests '--exclude=src/**/*_test.ts' '--exclude=src/**/*_test.tsx' '--exclude=src/**/*.test.ts' '--exclude=src/**/*.test.tsx' --lcov > coverage/lcov.info && deno run --allow-read scripts/lint/check-coverage.ts 80", "coverage:gate": "deno coverage coverage --include=src/ --exclude=tests '--exclude=src/**/*_test.ts' '--exclude=src/**/*_test.tsx' '--exclude=src/**/*.test.ts' '--exclude=src/**/*.test.tsx' --lcov > coverage/lcov.info && deno run --allow-read scripts/lint/check-coverage.ts 68", diff --git a/src/utils/version-constant.ts b/src/utils/version-constant.ts index 4b7d87ac10..8ad7058335 100644 --- a/src/utils/version-constant.ts +++ b/src/utils/version-constant.ts @@ -1,4 +1,4 @@ // Keep in sync with deno.json version. // scripts/release.ts updates this constant during releases. /** Shared version value. */ -export const VERSION = "0.1.993"; +export const VERSION = "0.1.994"; From 61b357bd4807e6d364b6d9d5bda4b6444450a8e0 Mon Sep 17 00:00:00 2001 From: Matt Boon Date: Fri, 3 Jul 2026 18:09:09 +0200 Subject: [PATCH 3/9] fix(router): refresh route params on SPA + popstate navigation The long-lived client router seeded params once at hydration but the SPA navigation and popstate paths only updated pathname/query, leaving useRouter().params stale (e.g. { id: '42' }) after navigating to a different or static route. Extract a shared normalizeRouteParams helper and update router.params from the new page data in both paths. --- .../templates/router.test.ts | 18 ++++++++++++++ .../templates/router.ts | 24 +++++++++++++++---- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/src/html/hydration-script-builder/templates/router.test.ts b/src/html/hydration-script-builder/templates/router.test.ts index 68bc18403d..a56b523483 100644 --- a/src/html/hydration-script-builder/templates/router.test.ts +++ b/src/html/hydration-script-builder/templates/router.test.ts @@ -317,6 +317,24 @@ describe("hydration-script-builder/templates/router", () => { assertIncludes(getRouterScript(), "window.__veryfrontRouter = router"); }); + it("should normalize route params joining catch-all segments", () => { + const result = getRouterScript(); + assertIncludes(result, "function normalizeRouteParams(raw)"); + assertIncludes(result, "Array.isArray(value) ? value.join('/') : value"); + }); + + it("should refresh router params during SPA and popstate navigation", () => { + const result = getRouterScript(); + assertIncludes( + result, + "window.__veryfrontRouter.params = normalizeRouteParams(pageData.params);", + ); + assertIncludes( + result, + "window.__veryfrontRouter.params = normalizeRouteParams(e.state.pageData.params);", + ); + }); + it("should handle popstate events for browser back/forward", () => { assertIncludes(getRouterScript(), "addEventListener('popstate'"); }); diff --git a/src/html/hydration-script-builder/templates/router.ts b/src/html/hydration-script-builder/templates/router.ts index b9fd336ea7..459f4cf181 100644 --- a/src/html/hydration-script-builder/templates/router.ts +++ b/src/html/hydration-script-builder/templates/router.ts @@ -611,6 +611,7 @@ export const getRouterScript = () => ` currentPath = targetPath; window.__veryfrontRouter.pathname = targetPath; window.__veryfrontRouter.query = Object.fromEntries(new URLSearchParams(window.location.search)); + window.__veryfrontRouter.params = normalizeRouteParams(pageData.params); if (restoreScroll) { restoreScrollPosition(targetPath); @@ -951,6 +952,22 @@ export const getRouterScript = () => ` }, IDLE_PREFETCH_DELAY_MS); } + // ============================================ + // Route params normalization + // ============================================ + // Catch-all segments arrive as arrays and are joined so no path info is + // lost, matching the server flattenRouteParams + RSC hydration normalizer. + function normalizeRouteParams(raw) { + const out = {}; + if (!raw) return out; + for (const key in raw) { + const value = raw[key]; + if (value === undefined) continue; + out[key] = Array.isArray(value) ? value.join('/') : value; + } + return out; + } + // ============================================ // Router object // ============================================ @@ -980,11 +997,7 @@ export const getRouterScript = () => ` try { const el = document.getElementById('veryfront-hydration-data'); const raw = (JSON.parse(el && el.textContent ? el.textContent : '{}') || {}).params || {}; - const out = {}; - for (const key in raw) { - out[key] = Array.isArray(raw[key]) ? raw[key].join('/') : raw[key]; - } - return out; + return normalizeRouteParams(raw); } catch (_) { return {}; } @@ -1017,6 +1030,7 @@ export const getRouterScript = () => ` currentPath = path; window.__veryfrontRouter.pathname = path; window.__veryfrontRouter.query = Object.fromEntries(new URLSearchParams(window.location.search)); + window.__veryfrontRouter.params = normalizeRouteParams(e.state.pageData.params); restoreScrollPosition(path); hideNavigationProgress(); From d02fcb6ff93591afbf3138bdabdf074ef73a883c Mon Sep 17 00:00:00 2001 From: Matt Boon Date: Fri, 3 Jul 2026 18:33:06 +0200 Subject: [PATCH 4/9] test(router): tighten catch-all coverage per review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address kwakayama review on PR #2743: 1. SSR param tests no longer encode the old first-segment contract. The test-local buildPageContext/buildSSRRouter copies now reuse the shared flattenRouteParams helper (mirroring production), and the stale 'flatten to first element' assertions expect joined catch-all values (a/b), so these paths would catch a #2742-style regression returning. 2. Add executable runtime coverage for the generated router: evaluate the script in a stubbed DOM and assert real behavior — seed params from hydration data (joining catch-all), SPA navigation replaces stale params, static routes clear params, and popstate refreshes from history state. Previously only string-token presence was checked. --- .../templates/router.test.ts | 197 ++++++++++++++++++ .../layouts/layout-applicator.test.ts | 21 +- src/rendering/script-page-handling.test.ts | 15 +- 3 files changed, 212 insertions(+), 21 deletions(-) diff --git a/src/html/hydration-script-builder/templates/router.test.ts b/src/html/hydration-script-builder/templates/router.test.ts index a56b523483..3d726dfe19 100644 --- a/src/html/hydration-script-builder/templates/router.test.ts +++ b/src/html/hydration-script-builder/templates/router.test.ts @@ -394,4 +394,201 @@ describe("hydration-script-builder/templates/router", () => { assertIncludes(getRouterScript(), "document.title = pageData.frontmatter.title"); }); }); + + // Executable coverage: evaluate the generated browser runtime in a stubbed + // DOM so a future edit can't keep the string tokens while breaking the actual + // hydration-data parsing, param normalization, or SPA/popstate param updates. + describe("generated router runtime (executable)", () => { + interface RuntimeLocation { + origin: string; + pathname: string; + search: string; + readonly href: string; + } + interface RuntimeRouter { + params: Record; + pathname: string; + query: Record; + navigate(path: string): Promise; + push(path: string): void; + } + interface RuntimeWindow { + location: RuntimeLocation; + history: { pushState(): void; back(): void; forward(): void }; + addEventListener(type: string, fn: (e: unknown) => void): void; + dispatchEvent(): boolean; + scrollTo(): void; + scrollY: number; + __veryfrontRouter?: RuntimeRouter; + __veryfrontHydrationComplete?: () => void; + } + interface RuntimeHandle { + router: RuntimeRouter; + navigateSPA: (href: string, pushState?: boolean, restoreScroll?: boolean) => Promise; + win: RuntimeWindow; + listeners: Record void>>; + setNextPageData: (data: unknown) => void; + } + + function evaluateRouterRuntime( + opts: { + pathname?: string; + search?: string; + hydrationParams?: Record; + } = {}, + ): RuntimeHandle { + const hydrationJson = JSON.stringify({ params: opts.hydrationParams ?? {} }); + const listeners: Record void>> = {}; + const addEventListener = (type: string, fn: (e: unknown) => void) => { + (listeners[type] ??= []).push(fn); + }; + + const makeEl = () => ({ + style: {} as Record, + id: "", + textContent: "", + setAttribute() {}, + getAttribute() { + return null; + }, + prepend() {}, + remove() {}, + appendChild() {}, + }); + + const rootEl = { __reactRoot: { render() {} } }; + const doc = { + readyState: "complete", + body: { prepend() {}, setAttribute() {}, removeAttribute() {}, appendChild() {} }, + head: { appendChild() {} }, + createElement: () => makeEl(), + querySelector: () => null, + querySelectorAll: () => [] as unknown[], + getElementById: (id: string) => { + if (id === "veryfront-hydration-data") return { textContent: hydrationJson }; + if (id === "root") return rootEl; + return null; + }, + addEventListener, + }; + + const win: RuntimeWindow = { + location: { + origin: "https://veryfront.test", + pathname: opts.pathname ?? "/", + search: opts.search ?? "", + get href() { + return "https://veryfront.test" + this.pathname + this.search; + }, + }, + history: { pushState() {}, back() {}, forward() {} }, + addEventListener, + dispatchEvent() { + return true; + }, + scrollTo() {}, + scrollY: 0, + }; + + let nextPageData: unknown = { pagePath: "page", params: {} }; + const fetchStub = () => + Promise.resolve({ + ok: true, + status: 200, + url: "/_veryfront/page-data/page.json", + headers: { get: () => null }, + json: () => Promise.resolve(nextPageData), + }); + + const React = { createElement: () => ({}) }; + const provider = () => ({}); + const loadComponent = () => Promise.resolve(() => null); + + const factory = new Function( + "window", + "document", + "fetch", + "React", + "RouterProvider", + "PageContextProvider", + "loadComponent", + "setTimeout", + "clearTimeout", + getRouterScript() + "\nreturn { router, navigateSPA };", + ); + + const handle = factory( + win, + doc, + fetchStub, + React, + provider, + provider, + loadComponent, + () => 0, + () => {}, + ) as { router: RuntimeRouter; navigateSPA: RuntimeHandle["navigateSPA"] }; + + return { + router: handle.router, + navigateSPA: handle.navigateSPA, + win, + listeners, + setNextPageData: (data: unknown) => { + nextPageData = data; + }, + }; + } + + it("seeds router params from hydration data, joining catch-all segments", () => { + const { router } = evaluateRouterRuntime({ + pathname: "/docs/guides/intro", + hydrationParams: { slug: ["guides", "intro"], lang: "en" }, + }); + assertEquals(router.params, { slug: "guides/intro", lang: "en" }); + }); + + it("replaces stale params with new page data on SPA navigation", async () => { + const runtime = evaluateRouterRuntime({ + pathname: "/posts/42", + hydrationParams: { id: "42" }, + }); + runtime.win.__veryfrontHydrationComplete?.(); + + runtime.setNextPageData({ pagePath: "page", params: { id: "99" } }); + runtime.win.location.pathname = "/posts/99"; + await runtime.navigateSPA("/posts/99", true); + + assertEquals(runtime.router.params, { id: "99" }); + assertEquals(runtime.router.pathname, "/posts/99"); + }); + + it("clears params when navigating to a static route", async () => { + const runtime = evaluateRouterRuntime({ + pathname: "/posts/42", + hydrationParams: { id: "42" }, + }); + runtime.win.__veryfrontHydrationComplete?.(); + + runtime.setNextPageData({ pagePath: "page", params: {} }); + await runtime.navigateSPA("/about", true); + + assertEquals(runtime.router.params, {}); + }); + + it("refreshes params from history state on popstate navigation", async () => { + const runtime = evaluateRouterRuntime({ + pathname: "/posts/42", + hydrationParams: { id: "42" }, + }); + runtime.win.__veryfrontHydrationComplete?.(); + + runtime.win.location.pathname = "/posts/7"; + const popstate = runtime.listeners.popstate?.[0]; + if (!popstate) throw new Error("popstate handler was not registered"); + await popstate({ state: { pageData: { pagePath: "page", params: { id: "7" } } } }); + + assertEquals(runtime.router.params, { id: "7" }); + }); + }); }); diff --git a/src/rendering/layouts/layout-applicator.test.ts b/src/rendering/layouts/layout-applicator.test.ts index 33dd8fc419..064c265a27 100644 --- a/src/rendering/layouts/layout-applicator.test.ts +++ b/src/rendering/layouts/layout-applicator.test.ts @@ -1,6 +1,7 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; +import { flattenRouteParams } from "#veryfront/routing"; import type { LayoutApplicationOptions } from "./layout-applicator.ts"; function isDotPath(pageFilePath: string): boolean { @@ -27,13 +28,9 @@ function buildSSRRouter( replace: () => void; reload: () => void; } { - const flatParams = params - ? Object.fromEntries( - Object.entries(params) - .map(([key, value]) => [key, Array.isArray(value) ? value[0] : value]) - .filter((entry): entry is [string, string] => entry[1] !== undefined), - ) - : {}; + // Mirror production: reuse the shared helper so this test can't drift back + // to the old first-segment-only contract (issue #2742). + const flatParams = flattenRouteParams(params); return { domain: requestUrl?.origin ?? "", @@ -123,12 +120,12 @@ describe("LayoutApplicator helpers", () => { assertEquals(router.query, {}); }); - it("should flatten params into string values", () => { - const url = new URL("https://example.com/blog/123"); - const router = buildSSRRouter(url, "/pages/blog/[id].tsx", "blog/123", { - id: ["123", "ignored"], + it("should join catch-all params instead of dropping segments", () => { + const url = new URL("https://example.com/blog/123/extra"); + const router = buildSSRRouter(url, "/pages/blog/[...id].tsx", "blog/123/extra", { + id: ["123", "extra"], }); - assertEquals(router.params, { id: "123" }); + assertEquals(router.params, { id: "123/extra" }); }); it("should handle URL with multiple search params", () => { diff --git a/src/rendering/script-page-handling.test.ts b/src/rendering/script-page-handling.test.ts index c24f3a64a6..909b67f12d 100644 --- a/src/rendering/script-page-handling.test.ts +++ b/src/rendering/script-page-handling.test.ts @@ -2,6 +2,7 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { handleScriptPage } from "./script-page-handling.ts"; +import { flattenRouteParams } from "#veryfront/routing"; import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; type ScriptModuleOutput = @@ -47,13 +48,9 @@ function buildPageContext( params?: Record, url?: URL, ): PageContext { - const flatParams: Record = params - ? Object.fromEntries( - Object.entries(params) - .map(([k, v]) => [k, Array.isArray(v) ? v[0] : v]) - .filter((entry): entry is [string, string] => entry[1] !== undefined), - ) - : {}; + // Mirror production: reuse the shared helper so this test can't drift back + // to the old first-segment-only contract (issue #2742). + const flatParams = flattenRouteParams(params); return { params: flatParams, @@ -165,9 +162,9 @@ describe("script-page-handling helpers", () => { assertEquals(ctx.frontmatter, { title: "About" }); }); - it("should flatten array params to first element", () => { + it("should join catch-all array params instead of dropping segments", () => { const ctx = buildPageContext(mockPageInfo, "blog", { tags: ["a", "b"] }); - assertEquals(ctx.params, { tags: "a" }); + assertEquals(ctx.params, { tags: "a/b" }); }); it("should handle empty params", () => { From c5f18b6d908b87318efcd68b50f65a1d4c209407 Mon Sep 17 00:00:00 2001 From: Matt Boon Date: Fri, 3 Jul 2026 18:48:43 +0200 Subject: [PATCH 5/9] fix(router): update params before render + restore .tsx unit tests Address kwakayama's superseding review (58/100) on PR #2743: 1. High - SPA/popstate rendered with stale params. RouterProvider reads router.params during render, but navigateSPA and the popstate handler mutated window.__veryfrontRouter.params AFTER renderPageFromData, so the first render of the new page used the previous route's params. Move the router snapshot update (pathname/query/params) before renderPageFromData in both paths. The executable runtime test now captures router.params at the moment the generated code builds the RouterProvider element and asserts it matches the new route (fails if the update is reordered after render). 2. High - restore .test.tsx unit coverage. The rebased release commit had reverted the test glob from '*.test.ts*' to '*.test.ts', dropping all 9 .test.tsx files (including router-provider.test.tsx and hydration-router.test.tsx) from test:unit/test:coverage:unit. Restore '*.test.ts*' with the matching '*.integration.test.ts*' exclusion. --- deno.json | 4 +-- .../templates/router.test.ts | 30 ++++++++++++++++--- .../templates/router.ts | 17 +++++++---- 3 files changed, 40 insertions(+), 11 deletions(-) diff --git a/deno.json b/deno.json index d17f250606..8abeaae28d 100644 --- a/deno.json +++ b/deno.json @@ -393,12 +393,12 @@ "build:npm": "deno run -A scripts/build/generate-integrations-module.ts && deno task generate && deno run -A scripts/build/build-npm-dnt.ts", "release": "deno run -A scripts/release.ts", "test": "deno task generate && VF_DISABLE_LRU_INTERVAL=1 SSR_TRANSFORM_PER_PROJECT_LIMIT=0 REVALIDATION_PER_PROJECT_LIMIT=0 NODE_ENV=production LOG_FORMAT=text deno test --preload=src/schemas/_test-setup.ts --no-check --parallel --allow-all '--ignore=tests/e2e,tests/integration/compiled-binary-e2e.test.ts' --unstable-worker-options --unstable-net", - "test:unit": "deno task generate && VF_DISABLE_LRU_INTERVAL=1 SSR_TRANSFORM_PER_PROJECT_LIMIT=0 REVALIDATION_PER_PROJECT_LIMIT=0 NODE_ENV=production LOG_FORMAT=text deno test --preload=src/schemas/_test-setup.ts --no-check --parallel --allow-all --v8-flags=--max-old-space-size=8192 '--ignore=tests,src/workflow/__tests__' --unstable-worker-options --unstable-net $(find src cli -name '*.test.ts' ! -name '*.integration.test.ts')", + "test:unit": "deno task generate && VF_DISABLE_LRU_INTERVAL=1 SSR_TRANSFORM_PER_PROJECT_LIMIT=0 REVALIDATION_PER_PROJECT_LIMIT=0 NODE_ENV=production LOG_FORMAT=text deno test --preload=src/schemas/_test-setup.ts --no-check --parallel --allow-all --v8-flags=--max-old-space-size=8192 '--ignore=tests,src/workflow/__tests__' --unstable-worker-options --unstable-net $(find src cli -name '*.test.ts*' ! -name '*.integration.test.ts*')", "test:integration": "deno task generate && VF_DISABLE_LRU_INTERVAL=1 SSR_TRANSFORM_PER_PROJECT_LIMIT=0 REVALIDATION_PER_PROJECT_LIMIT=0 NODE_ENV=production LOG_FORMAT=text deno test --preload=src/schemas/_test-setup.ts --no-check --parallel --allow-all '--ignore=tests/e2e,tests/integration/compiled-binary-e2e.test.ts' tests --unstable-worker-options --unstable-net", "test:integration:cli": "VF_DISABLE_LRU_INTERVAL=1 SSR_TRANSFORM_PER_PROJECT_LIMIT=0 REVALIDATION_PER_PROJECT_LIMIT=0 NODE_ENV=production LOG_FORMAT=text deno test --no-check --parallel --allow-all --unstable-worker-options --unstable-net $(find cli -name '*.integration.test.ts')", "test:record": "VCR=record deno test --no-check --allow-all $(find cli -name '*.integration.test.ts' -path '*/commands/*')", "test:coverage": "rm -rf coverage && deno task generate && VF_DISABLE_LRU_INTERVAL=1 SSR_TRANSFORM_PER_PROJECT_LIMIT=0 REVALIDATION_PER_PROJECT_LIMIT=0 NODE_ENV=production LOG_FORMAT=text deno test --preload=src/schemas/_test-setup.ts --no-check --parallel --fail-fast --allow-all --v8-flags=--max-old-space-size=8192 --coverage=coverage '--ignore=tests/integration/compiled-binary-e2e.test.ts' --unstable-worker-options --unstable-net || exit 1", - "test:coverage:unit": "rm -rf coverage && deno task generate && VF_DISABLE_LRU_INTERVAL=1 SSR_TRANSFORM_PER_PROJECT_LIMIT=0 REVALIDATION_PER_PROJECT_LIMIT=0 NODE_ENV=production LOG_FORMAT=text deno test --preload=src/schemas/_test-setup.ts --no-check --parallel --fail-fast --allow-all --v8-flags=--max-old-space-size=8192 --coverage=coverage '--ignore=tests,src/workflow/__tests__' --unstable-worker-options --unstable-net $(find src cli -name '*.test.ts' ! -name '*.integration.test.ts') || exit 1", + "test:coverage:unit": "rm -rf coverage && deno task generate && VF_DISABLE_LRU_INTERVAL=1 SSR_TRANSFORM_PER_PROJECT_LIMIT=0 REVALIDATION_PER_PROJECT_LIMIT=0 NODE_ENV=production LOG_FORMAT=text deno test --preload=src/schemas/_test-setup.ts --no-check --parallel --fail-fast --allow-all --v8-flags=--max-old-space-size=8192 --coverage=coverage '--ignore=tests,src/workflow/__tests__' --unstable-worker-options --unstable-net $(find src cli -name '*.test.ts*' ! -name '*.integration.test.ts*') || exit 1", "test:coverage:integration": "rm -rf coverage && deno task generate && VF_DISABLE_LRU_INTERVAL=1 SSR_TRANSFORM_PER_PROJECT_LIMIT=0 REVALIDATION_PER_PROJECT_LIMIT=0 NODE_ENV=production LOG_FORMAT=text deno test --preload=src/schemas/_test-setup.ts --no-check --parallel --fail-fast --allow-all --v8-flags=--max-old-space-size=8192 --coverage=coverage '--ignore=tests/e2e,tests/integration/compiled-binary-e2e.test.ts' tests --unstable-worker-options --unstable-net || exit 1", "coverage:report": "deno coverage coverage --include=src/ --exclude=tests '--exclude=src/**/*_test.ts' '--exclude=src/**/*_test.tsx' '--exclude=src/**/*.test.ts' '--exclude=src/**/*.test.tsx' --lcov > coverage/lcov.info && deno run --allow-read scripts/lint/check-coverage.ts 80", "coverage:gate": "deno coverage coverage --include=src/ --exclude=tests '--exclude=src/**/*_test.ts' '--exclude=src/**/*_test.tsx' '--exclude=src/**/*.test.ts' '--exclude=src/**/*.test.tsx' --lcov > coverage/lcov.info && deno run --allow-read scripts/lint/check-coverage.ts 68", diff --git a/src/html/hydration-script-builder/templates/router.test.ts b/src/html/hydration-script-builder/templates/router.test.ts index 3d726dfe19..eaa64dee5e 100644 --- a/src/html/hydration-script-builder/templates/router.test.ts +++ b/src/html/hydration-script-builder/templates/router.test.ts @@ -428,6 +428,10 @@ describe("hydration-script-builder/templates/router", () => { win: RuntimeWindow; listeners: Record void>>; setNextPageData: (data: unknown) => void; + // The router.params snapshot captured the moment renderPageFromData built + // the RouterProvider element — i.e. what the new page renders with. This is + // what the ordering bug (mutating params after render) would get wrong. + getRenderedParams: () => Record | null; } function evaluateRouterRuntime( @@ -500,8 +504,20 @@ describe("hydration-script-builder/templates/router", () => { json: () => Promise.resolve(nextPageData), }); - const React = { createElement: () => ({}) }; - const provider = () => ({}); + const RouterProvider = () => ({}); + const PageContextProvider = () => ({}); + // Capture router.params exactly when the generated render builds the + // RouterProvider element, so the test reflects what the new page renders + // with (not the value the router settles on afterwards). + let renderedRouterParams: Record | null = null; + const React = { + createElement: (type: unknown, props?: { router?: RuntimeRouter }) => { + if (type === RouterProvider && props?.router) { + renderedRouterParams = { ...props.router.params }; + } + return {}; + }, + }; const loadComponent = () => Promise.resolve(() => null); const factory = new Function( @@ -522,8 +538,8 @@ describe("hydration-script-builder/templates/router", () => { doc, fetchStub, React, - provider, - provider, + RouterProvider, + PageContextProvider, loadComponent, () => 0, () => {}, @@ -537,6 +553,7 @@ describe("hydration-script-builder/templates/router", () => { setNextPageData: (data: unknown) => { nextPageData = data; }, + getRenderedParams: () => renderedRouterParams, }; } @@ -561,6 +578,9 @@ describe("hydration-script-builder/templates/router", () => { assertEquals(runtime.router.params, { id: "99" }); assertEquals(runtime.router.pathname, "/posts/99"); + // The new page must render with the fresh params — not the previous + // route's — which only holds if params are updated before render. + assertEquals(runtime.getRenderedParams(), { id: "99" }); }); it("clears params when navigating to a static route", async () => { @@ -574,6 +594,7 @@ describe("hydration-script-builder/templates/router", () => { await runtime.navigateSPA("/about", true); assertEquals(runtime.router.params, {}); + assertEquals(runtime.getRenderedParams(), {}); }); it("refreshes params from history state on popstate navigation", async () => { @@ -589,6 +610,7 @@ describe("hydration-script-builder/templates/router", () => { await popstate({ state: { pageData: { pagePath: "page", params: { id: "7" } } } }); assertEquals(runtime.router.params, { id: "7" }); + assertEquals(runtime.getRenderedParams(), { id: "7" }); }); }); }); diff --git a/src/html/hydration-script-builder/templates/router.ts b/src/html/hydration-script-builder/templates/router.ts index 459f4cf181..f3cfded094 100644 --- a/src/html/hydration-script-builder/templates/router.ts +++ b/src/html/hydration-script-builder/templates/router.ts @@ -604,15 +604,19 @@ export const getRouterScript = () => ` window.history.pushState({ pageData, scrollY: 0 }, '', href); } - perfStart('nav:render:' + href); - await renderPageFromData(pageData, targetPath); - perfEnd('nav:render:' + href); - + // Update the shared router snapshot BEFORE rendering. RouterProvider + // reads router.params during render, so mutating after renderPageFromData + // would leave the new page's first render with the previous route's + // params (issue #2741). pathname/query move up for the same reason. currentPath = targetPath; window.__veryfrontRouter.pathname = targetPath; window.__veryfrontRouter.query = Object.fromEntries(new URLSearchParams(window.location.search)); window.__veryfrontRouter.params = normalizeRouteParams(pageData.params); + perfStart('nav:render:' + href); + await renderPageFromData(pageData, targetPath); + perfEnd('nav:render:' + href); + if (restoreScroll) { restoreScrollPosition(targetPath); } else if (hash) { @@ -1026,12 +1030,15 @@ export const getRouterScript = () => ` showNavigationProgress(); try { - await renderPageFromData(e.state.pageData, path); + // Update the router snapshot before rendering so RouterProvider reads + // this route's params, not the previous route's (issue #2741). currentPath = path; window.__veryfrontRouter.pathname = path; window.__veryfrontRouter.query = Object.fromEntries(new URLSearchParams(window.location.search)); window.__veryfrontRouter.params = normalizeRouteParams(e.state.pageData.params); + await renderPageFromData(e.state.pageData, path); + restoreScrollPosition(path); hideNavigationProgress(); } catch (error) { From 06cea831e6ef751cc19c8536648c7656d2025be1 Mon Sep 17 00:00:00 2001 From: Matt Boon Date: Fri, 3 Jul 2026 18:59:55 +0200 Subject: [PATCH 6/9] fix(rendering): flatten catch-all params for MDX generateMetadata Third production SSR path with the #2742 first-segment truncation, missed by the earlier flattenRouteParams conversion: generateMetadata() for MDX pages received catch-all params truncated to their first element (/docs/[...slug] -> { slug: 'guides' } instead of 'guides/intro'). Route it through the shared flattenRouteParams helper like the other consumers. --- src/rendering/page-rendering.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/rendering/page-rendering.ts b/src/rendering/page-rendering.ts index fec73ef161..a45eb9366c 100644 --- a/src/rendering/page-rendering.ts +++ b/src/rendering/page-rendering.ts @@ -6,6 +6,7 @@ import type { EntityInfo, MdxBundle, MDXComponents, MDXModule, PageBundle } from import { mdxRenderer } from "#veryfront/transforms/mdx/index.ts"; import { clearMdxEsmCacheNamespace } from "#veryfront/transforms/mdx/esm-module-loader/index.ts"; import { getProjectReact } from "#veryfront/react"; +import { flattenRouteParams } from "#veryfront/routing"; import { compileContent } from "#veryfront/transforms/mdx/compiler/index.ts"; import { ensureError, getErrorMessage } from "#veryfront/errors/veryfront-error.ts"; import { withSpan } from "#veryfront/observability/tracing/otlp-setup.ts"; @@ -177,13 +178,7 @@ export function handleMDXPage( if (typeof mod.generateMetadata === "function") { try { - const params = options?.params - ? (Object.fromEntries( - Object.entries(options.params) - .map(([k, v]) => [k, Array.isArray(v) ? v[0] : v]) - .filter((entry): entry is [string, string] => entry[1] !== undefined), - ) as Record) - : {}; + const params = flattenRouteParams(options?.params); const query = options?.url ? Object.fromEntries(options.url.searchParams) : {}; const gen = await mod.generateMetadata({ From 6b372b6f219a86723d840cb0164745f73f94c962 Mon Sep 17 00:00:00 2001 From: Matt Boon Date: Fri, 3 Jul 2026 19:12:23 +0200 Subject: [PATCH 7/9] fix(router): normalize catch-all params at client render seams + consolidate flattener MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address remaining findings from Koji's review on PR #2743: - #4 (High, server/client disagreement): renderPageFromData (SPA/popstate) and renderer.ts (initial hydration) passed raw pageData.params — arrays for catch-all routes — into page props and pageContext, while SSR now emits joined strings. usePageContext().params.slug and the page's params prop therefore disagreed between server ('guides/intro') and client (['guides','intro']). Normalize via normalizeRouteParams at both seams. normalizeRouteParams is idempotent on strings, so it's a no-op if page data already carries joined values. Executable test now captures the params handed to the page component and asserts they're joined. - #3 (empty catch-all contract): pin { slug: [] } -> { slug: '' } (key kept, matching the RSC/API/client normalizers) with a flatten-route-params test. - #6 (duplicate flatteners): routing barrel exported two near-identical helpers. context-builder's normalizeParams now delegates to flattenRouteParams (single implementation), removing the divergence risk. --- .../templates/renderer.test.ts | 8 +++-- .../templates/renderer.ts | 8 +++-- .../templates/router.test.ts | 35 ++++++++++++++++--- .../templates/router.ts | 10 ++++-- src/routing/api/context-builder.ts | 13 ++++--- src/routing/flatten-route-params.test.ts | 7 ++++ 6 files changed, 63 insertions(+), 18 deletions(-) diff --git a/src/html/hydration-script-builder/templates/renderer.test.ts b/src/html/hydration-script-builder/templates/renderer.test.ts index f468ca7028..3940d333e8 100644 --- a/src/html/hydration-script-builder/templates/renderer.test.ts +++ b/src/html/hydration-script-builder/templates/renderer.test.ts @@ -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", () => { @@ -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,"); }); diff --git a/src/html/hydration-script-builder/templates/renderer.ts b/src/html/hydration-script-builder/templates/renderer.ts index 03bd596349..4b22a27436 100644 --- a/src/html/hydration-script-builder/templates/renderer.ts +++ b/src/html/hydration-script-builder/templates/renderer.ts @@ -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; @@ -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, diff --git a/src/html/hydration-script-builder/templates/router.test.ts b/src/html/hydration-script-builder/templates/router.test.ts index eaa64dee5e..313d36b0c7 100644 --- a/src/html/hydration-script-builder/templates/router.test.ts +++ b/src/html/hydration-script-builder/templates/router.test.ts @@ -432,6 +432,9 @@ describe("hydration-script-builder/templates/router", () => { // the RouterProvider element — i.e. what the new page renders with. This is // what the ordering bug (mutating params after render) would get wrong. getRenderedParams: () => Record | null; + // The `params` prop handed to the page component during render — must be + // normalized (joined) so it matches the server render. + getRenderedPageParams: () => Record | null; } function evaluateRouterRuntime( @@ -506,14 +509,22 @@ describe("hydration-script-builder/templates/router", () => { const RouterProvider = () => ({}); const PageContextProvider = () => ({}); - // Capture router.params exactly when the generated render builds the - // RouterProvider element, so the test reflects what the new page renders - // with (not the value the router settles on afterwards). + // Capture params exactly when the generated render builds elements, so the + // test reflects what the new page renders with (not the value the router + // settles on afterwards). renderedRouterParams = what RouterProvider sees; + // renderedPageParams = what the page component receives as its `params` + // prop (must be normalized so it matches the server render, issue #2742). let renderedRouterParams: Record | null = null; + let renderedPageParams: Record | null = null; const React = { - createElement: (type: unknown, props?: { router?: RuntimeRouter }) => { + createElement: ( + type: unknown, + props?: { router?: RuntimeRouter; params?: Record }, + ) => { if (type === RouterProvider && props?.router) { renderedRouterParams = { ...props.router.params }; + } else if (props && "params" in props && renderedPageParams === null) { + renderedPageParams = { ...(props.params ?? {}) }; } return {}; }, @@ -554,6 +565,7 @@ describe("hydration-script-builder/templates/router", () => { nextPageData = data; }, getRenderedParams: () => renderedRouterParams, + getRenderedPageParams: () => renderedPageParams, }; } @@ -583,6 +595,21 @@ describe("hydration-script-builder/templates/router", () => { assertEquals(runtime.getRenderedParams(), { id: "99" }); }); + it("normalizes catch-all params for both router and page props on SPA nav", () => { + const runtime = evaluateRouterRuntime({ pathname: "/", hydrationParams: {} }); + runtime.win.__veryfrontHydrationComplete?.(); + + // Page data carries a raw catch-all array, as route matching produces it. + runtime.setNextPageData({ pagePath: "page", params: { slug: ["guides", "intro"] } }); + return runtime.navigateSPA("/docs/guides/intro", true).then(() => { + // Both the router snapshot and the page component's `params` prop must + // be joined strings so client and server render identically (#2742). + assertEquals(runtime.router.params, { slug: "guides/intro" }); + assertEquals(runtime.getRenderedParams(), { slug: "guides/intro" }); + assertEquals(runtime.getRenderedPageParams(), { slug: "guides/intro" }); + }); + }); + it("clears params when navigating to a static route", async () => { const runtime = evaluateRouterRuntime({ pathname: "/posts/42", diff --git a/src/html/hydration-script-builder/templates/router.ts b/src/html/hydration-script-builder/templates/router.ts index f3cfded094..8943092952 100644 --- a/src/html/hydration-script-builder/templates/router.ts +++ b/src/html/hydration-script-builder/templates/router.ts @@ -713,9 +713,15 @@ export const getRouterScript = () => ` } } + // Normalize catch-all params (arrays -> joined strings) so page props and + // page context match the server render exactly. SSR emits joined strings + // via flattenRouteParams; without this the client would hand raw arrays to + // props and usePageContext() after navigation (issue #2742). + const normalizedParams = normalizeRouteParams(pageData.params); + let tree = React.createElement(PageComponent, { ...pageData.props, - params: pageData.params + params: normalizedParams }); if (pageData.layouts?.length) { @@ -738,7 +744,7 @@ export const getRouterScript = () => ` const pageContext = { slug: pageData.slug || '', path: pageData.pagePath || targetPath, - params: pageData.params || {}, + params: normalizedParams, query: Object.fromEntries(new URLSearchParams(window.location.search)), frontmatter: pageData.frontmatter || {}, headings: headingsArray, diff --git a/src/routing/api/context-builder.ts b/src/routing/api/context-builder.ts index 30dc28bb05..02e6285055 100644 --- a/src/routing/api/context-builder.ts +++ b/src/routing/api/context-builder.ts @@ -1,6 +1,7 @@ import type { RouteMatch } from "./api-route-matcher.ts"; import type { FileSystemAdapter } from "#veryfront/platform/adapters/base.ts"; import { parseCookies } from "#veryfront/utils/cookie-utils.ts"; +import { flattenRouteParams } from "../flatten-route-params.ts"; export { parseCookies }; @@ -57,14 +58,12 @@ export function createContext( }; } +/** + * @deprecated Use {@link flattenRouteParams} directly. Kept as a thin alias so + * the routing barrel exposes a single flattener implementation (issue #2742). + */ export function normalizeParams( params: Record, ): Record { - const out: Record = {}; - - for (const [key, value] of Object.entries(params)) { - out[key] = Array.isArray(value) ? value.join("/") : value; - } - - return out; + return flattenRouteParams(params); } diff --git a/src/routing/flatten-route-params.test.ts b/src/routing/flatten-route-params.test.ts index 576d998c2a..bf68d9885f 100644 --- a/src/routing/flatten-route-params.test.ts +++ b/src/routing/flatten-route-params.test.ts @@ -22,4 +22,11 @@ describe("routing/flattenRouteParams", () => { it("returns an empty object for no params", () => { assertEquals(flattenRouteParams(undefined), {}); }); + + it("maps an empty optional catch-all array to an empty string, keeping the key", () => { + // `[[...slug]]` matched at its base yields `{ slug: [] }`. Joining gives + // `""` (key retained) rather than dropping the key, matching the RSC/API/ + // client normalizers so server and client agree on index routes. + assertEquals(flattenRouteParams({ slug: [] }), { slug: "" }); + }); }); From b0992bf9e4e6af1dfb668fc86d2830aba03cd767 Mon Sep 17 00:00:00 2001 From: Matt Boon Date: Fri, 3 Jul 2026 19:18:39 +0200 Subject: [PATCH 8/9] fix(hydration): seed route params into 'use client' full-document payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finding #5 from Koji's review (#2741 on the full-HTML-document client-page path): 'use client' pages that render their own hydrate via the RSC client runtime, which reads hydrationData.params — but html-injection.ts never wrote that field, so these pages hydrated with empty params on dynamic routes. The client type (ClientRuntimeHydrationData) and the client normalizer already expected params; only the server side was missing. Add params to InjectHTMLContentOptions, emit it in the client-page hydration payload (catch-all arrays preserved; the client runtime joins them), and pass context.options.params from the orchestrator. Turned out to be a small change since context.options.params was already in scope at the call site. --- src/html/html-injection.test.ts | 36 ++++++++++++++++++++++++++++++ src/html/html-injection.ts | 8 +++++++ src/rendering/orchestrator/html.ts | 1 + 3 files changed, 45 insertions(+) diff --git a/src/html/html-injection.test.ts b/src/html/html-injection.test.ts index 7727ecb6a4..4cd052942d 100644 --- a/src/html/html-injection.test.ts +++ b/src/html/html-injection.test.ts @@ -108,6 +108,42 @@ 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("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..a15712bbdc 100644 --- a/src/html/html-injection.ts +++ b/src/html/html-injection.ts @@ -26,6 +26,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; /** Whether page is embedded in Studio iframe */ studioEmbed?: boolean; /** Project ID for Studio communication */ @@ -118,6 +125,7 @@ export function injectHTMLContent( 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, diff --git a/src/rendering/orchestrator/html.ts b/src/rendering/orchestrator/html.ts index c72de922dd..f7abca4d4b 100644 --- a/src/rendering/orchestrator/html.ts +++ b/src/rendering/orchestrator/html.ts @@ -275,6 +275,7 @@ export class HTMLGenerator { pagePath, projectDir: this.config.projectDir, isClientPage, + params: context.options?.params, environment: context.options?.environment, isLocalProject: this.config.mode === "development", nonce: context.options?.nonce, From 12887f1142421680b904db679ae35155a7aa0212 Mon Sep 17 00:00:00 2001 From: Matt Boon Date: Fri, 3 Jul 2026 20:56:03 +0200 Subject: [PATCH 9/9] fix(security): escape client-page hydration payload against script breakout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kwakayama review (HIGH): the #2741 fix serialized the 'use client' hydration payload with raw JSON.stringify into , breaking out of the tag — reflected XSS on the full-HTML client-page path. Serialize with jsonForInlineScript (escapes < > & and line separators), matching the main shell hydration path. Covers slug too, which is likewise URL-derived. Regression test injects as a param and asserts no literal breakout plus lossless round-trip; it fails on the old raw JSON.stringify. --- src/html/html-injection.test.ts | 24 ++++++++++++++++++++++++ src/html/html-injection.ts | 7 ++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/html/html-injection.test.ts b/src/html/html-injection.test.ts index 4cd052942d..3fa21138ab 100644 --- a/src/html/html-injection.test.ts +++ b/src/html/html-injection.test.ts @@ -128,6 +128,30 @@ describe("html/html-injection", () => { 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, diff --git a/src/html/html-injection.ts b/src/html/html-injection.ts index a15712bbdc..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, @@ -121,7 +122,11 @@ 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