Commit Graph

1132 Commits

Author SHA1 Message Date
James Anderson b463b8eb30 fix(script): translate React DOM prop names to HTML attrs in hoisted beforeInteractive scripts (#1629)
Inline `<Script strategy="beforeInteractive">` content is captured via
BeforeInteractiveContext and spliced directly into <head> as raw HTML by
the SSR pipeline. The attribute collector preserved React's camelCase prop
names verbatim, so `className="foo"` round-tripped as `classname="foo"`
(HTML attribute names are case-insensitive), making CSS selectors on `.foo`
fail to match. Same hazard for `htmlFor`, `httpEquiv`, `acceptCharset`.

Translate those four prop names to their HTML attribute equivalents at the
collection boundary, mirroring Next.js's set-attributes-from-props.ts.

Fixes #1518
2026-05-28 14:03:18 +01:00
James Anderson a1a448a49f fix(route-handler): default Path=/ on returned Set-Cookie when merging mutable cookies (#1484) (#1627)
When a route handler called `cookies().set()` alongside a returned
`Response` that carried raw `Set-Cookie` headers without a `Path`
attribute, vinext appended the raw line verbatim. The resulting comma-
joined `set-cookie` view then read like `bar=bar2, baz=baz2` with no
per-cookie `Path=/`, diverging from Next.js whose `appendMutableCookies`
re-runs every returned cookie through `ResponseCookies.set` (which
normalises a missing `path` to `/`).

`applyMutableCookieFallbacks` now ensures each returned cookie carries
an explicit `Path=` attribute, matching Next.js's behaviour in
`packages/next/src/server/web/spec-extension/adapters/request-cookies.ts`.

Ported the Next.js coverage from
`test/e2e/app-dir/actions/app-action.test.ts` ("should support setting
cookies in route handlers with the correct overrides") as
`nextjs-compat/set-cookies.test.ts`.

Fixes #1484
2026-05-28 14:02:33 +01:00
James Anderson f7ea402cd7 fix(link): forward href/onClick to child in legacyBehavior (#1623)
* fix(link): forward href/onClick to child in legacyBehavior (#1469)

When `<Link legacyBehavior>` is set, the shim previously rendered its own
wrapping `<a>` around the user's child anchor, producing nested anchors and
duplicated content (e.g. a hidden `<a id="custom-button"></a>` alongside the
user's anchor) and breaking onClick propagation.

Mirror Next.js: when `legacyBehavior` is true, forward `href`/`onClick`/
`onMouseEnter`/`onTouchStart` to the single child via `React.cloneElement`
instead of wrapping. `href` is only forwarded when `passHref` is set or the
child is an `<a>` without its own `href`. String/number children are wrapped
in a plain `<a>` first so they have an element to clone, matching Next.js.

Ported from .nextjs-ref/packages/next/src/client/link.tsx (legacyBehavior
branch) and covered by .nextjs-ref/test/e2e/legacy-link-behavior-pages.

Fixes #1469

* fix(link): address bigbonk legacyBehavior parity gaps (#1469)

- Skip <Link>'s own onClick prop when invoked via legacyBehavior. The
  child's onClick is the canonical handler; calling Link's onClick too
  caused a double-fire. Added a dev console.warn matching Next.js when
  onClick/onMouseEnter are passed to <Link legacyBehavior>.
- Use the child element's own ref in legacyBehavior (matches Next.js)
  instead of routing through Link's forwardedRef. Merged with the
  internal intersection-observer ref so prefetching still works.
- Use `'href' in child.props` (matches Next.js) instead of `!== undefined`
  so an explicit `href={undefined}` on the child <a> is treated as the
  child owning its href and is not overwritten.
- Clone the child in the dangerous-scheme branch when legacyBehavior is
  set, so blocked javascript:/data:/vbscript: URLs do not produce nested
  anchors. The dangerous href is intentionally not forwarded to the child.

Added 3 unit tests covering the new behaviors.
2026-05-28 13:56:10 +01:00
James Anderson 30e374cd68 test(link): cover javascript: URL console.error for App + Pages Router Link (#1622)
Fixes #1576. PR #1575 hoisted the dangerous-scheme guard out of the async
`performNavigation` body for Pages Router `router.push`/`router.replace`, which
covers two of the six Next.js `test/e2e/app-dir/javascript-urls/javascript-urls.test.ts`
scenarios. This adds regression coverage for the remaining four Link-flavoured
scenarios — App Router and Pages Router, both `href` and `as` — by toggling
`appNavigation` in `renderIsolatedLink` and swapping `href` <-> `as`. Each test
clicks the rendered anchor and asserts:

- `console.error` is emitted with a message containing "has blocked a
  javascript: URL as a security precaution." (the substring the Next.js E2E
  matcher asserts on).
- `navigate` / `fetch` are never invoked, so navigation does not proceed.
- A user-provided `onClick` still fires, so analytics or local UI state is
  preserved.

The Link shim's existing `handleDangerousClick` path (`shims/link.tsx`) and the
`assertSafeNavigationUrl` guard inside the app router push/replace already
emit the matching message; this PR locks that contract behind a test matrix
so a future Link refactor cannot regress it silently.

The two `router.push` / `router.replace` scenarios from the issue are already
covered by `tests/router-javascript-urls.test.ts:96-128` (App Router) and
`tests/router-javascript-urls.test.ts:180-302` (Pages Router, from #1575).

Ported from Next.js: test/e2e/app-dir/javascript-urls/javascript-urls.test.ts
2026-05-28 13:52:36 +01:00
Nathan Nguyen bdbd1f833d fix(app-router): preserve source identity for intercepted renders (#1589)
* fix(app-router): preserve source identity for intercepted renders

Intercepted App Router renders used the visible target pathname as the payload route identity. That let a modal navigation such as /feed to /photos/42 replace the retained source tree with target-scoped route and page ids, so refreshes, server actions, chained modals, and history traversal could lose source page state.

The violated invariant is that an intercepted render has separate display and matched-route identities. This commits a fenced render identity helper, keeps source route and page ids stable for retained source-route payloads, leaves the target pathname in interception proof, and includes source context in RSC cache variants.

Regression coverage asserts source payload keys, cache variants, modal refresh/action/history flows, and source client/search state preservation.

* fix(app-router): normalize intercepted rsc cache source

* fix(app-router): harden intercepted cache regeneration

* refactor(app-router): share intercepted path normalization

* fixup! fix(app-router): preserve source identity for intercepted renders

fixup! fix(app-router): preserve source identity for intercepted renders
2026-05-28 12:42:01 +01:00
Nathan Nguyen 8e3438d267 fix(app-router): complete default autoscroll page target parity (#1601)
* fix(app-router): complete default autoscroll page target parity

App Router page navigations can commit loading shells, final payloads, and superseded payloads out of order. A pending scroll intent that is only keyed by navigation start can be consumed by the wrong committed tree, which makes default page target scrolling and focus timing diverge from Next.js when loading boundaries or rapid navigations are involved.

Bind scroll intents to approved render commits, consume only the matching intent from the committed scroll target, and clear hard-navigation failures only for their own intent. Extend the Next.js compat fixture coverage for skipped first children, loading boundary timing, same-page search param changes, focus behavior, horizontal scroll preservation, and stale intent races.

* test(app-router): update link navigation scroll intent assertion

* fix(app-router): address review — consume scroll intent on no-commit, tighten test

- Consume scroll intent before returning no-commit in renderNavigationPayload,
  preventing the navigateClientSide fallback from scrolling for an abandoned
  navigation.
- Replace waitForTimeout(700) with expect.poll for robust race-condition
  stability assertion (CI-flake-safe, stronger proof of stability).
- Update wrapperless targeting comment to clarify outer function / inner
  class split.
- Add three unit tests covering no-commit cleanup, stale intent rejection,
  and claim-without-commitId consumption paths.
2026-05-28 12:33:05 +01:00
Divanshu Chauhan (divkix) b6ac0c9abd test(pages-router): regression test for literal form action=action:foo (#1476) (#1613)
* test(pages-router): regression test for literal form action=action:foo (#1476)

Adds a Pages Router fixture page with a literal action:foo string in a form
action attribute, plus an integration test asserting the attribute survives
SSR serialization intact. This guards against future regressions where React
19 or the RSC plugin might strip the literal string because it resembles a
server action ID.

Ported from Next.js test/e2e/app-dir/action-in-pages-router.

* test: clarify action:foo regression test comment

Update the comment to accurately describe that this test verifies React 19
SSR preserves literal string action attributes, not server action invocation.

This addresses review feedback on the PR adding the regression test for #1476.
2026-05-28 09:43:16 +01:00
Divanshu Chauhan (divkix) fdcc850638 fix(routing): serve pages/_foo.tsx when app/ also exists (#1610) 2026-05-27 22:24:02 +01:00
Divanshu Chauhan (divkix) 8b4210f38f fix(app-router): dev-only favicon.ico short-circuit to avoid expensive 404 render (#1612) 2026-05-27 19:56:38 +01:00
Divanshu Chauhan (divkix) aae4b50fe8 fix(build): implement compiler.removeConsole option (#1611)
* fix(build): implement compiler.removeConsole option

Implements Next.js parity for compiler.removeConsole, which strips
console.* calls from the client bundle during production builds.

Behavior:
- compiler.removeConsole: true removes all console.<method>() calls
- compiler.removeConsole: { exclude: ['error'] } preserves excluded
  methods (case-insensitive)
- Shadowed console bindings (local vars, params, destructured) are
  preserved per Next.js SWC behavior
- Computed access console[prop]() is preserved
- Removed calls in expression statements become ; for clean output
- Removed calls in other contexts (JSX, arguments, returns) become void 0

Files:
- packages/vinext/src/plugins/remove-console.ts (new transform)
- packages/vinext/src/config/next-config.ts (types + resolution)
- packages/vinext/src/index.ts (Vite plugin registration)
- tests/remove-console.test.ts (24 unit tests)
- tests/next-config.test.ts (update default resolved config)

Fixes #1497

* fix(knip): un-export RemoveConsoleConfig type

Remove the declare -x ABBR_TIPS_PROMPT="\\n💡 \\e[1m{{ .abbr }}\\e[0m => {{ .cmd }}"
declare -x ABBR_TIPS_REGEXES="(^(\\w+\\s+)+(-{1,2})\\w+)(\\s\\S+) (^(\\s?(\\w-?)+){3}).* (^(\\s?(\\w-?)+){2}).* (^(\\s?(\\w-?)+){1}).*"
declare -x BUN_PTY_LIB="/\$bunfs/root/librust_pty_arm64-b7hv765h.dylib"
declare -x COLORTERM="truecolor"
declare -x COMMAND_MODE="unix2003"
declare -x CPPFLAGS="-I/opt/homebrew/opt/openssl@3/include"
declare -x DEBIAN_FRONTEND="noninteractive"
declare -x DIVMEMORY_API_KEY="3b3f7180985460433de23604da244b8e4c1917f81f992199ebeb0ebc75e7a51e"
declare -x DIVMEMORY_WORKER_URL="https://divmemory.divkix.workers.dev"
declare -x DOTNET_CLI_TELEMETRY_OPTOUT="= 1"
declare -x DO_NOT_TRACK="= 1"
declare -x EDITOR="nano"
declare -x FORCE_COLOR="0"
declare -x FUNCTIONS_CORE_TOOLS_TELEMETRY_OPTOUT="= 1"
declare -x GATSBY_TELEMETRY_DISABLED="= 1"
declare -x GCM_INTERACTIVE="never"
declare -x GIT_ASKPASS=""
declare -x GIT_EDITOR="true"
declare -x GIT_SEQUENCE_EDITOR="true"
declare -x GIT_SSH_COMMAND="ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new"
declare -x GIT_TERMINAL_PROMPT="0"
declare -x GPG_TTY="/dev/ttys005"
declare -x HOME="/Users/divkix"
declare -x HOMEBREW_CELLAR="/opt/homebrew/Cellar"
declare -x HOMEBREW_NO_ANALYTICS="= 1"
declare -x HOMEBREW_PREFIX="/opt/homebrew"
declare -x HOMEBREW_REPOSITORY="/opt/homebrew"
declare -x INFOPATH="/opt/homebrew/share/info"
declare -x LANG="en_US.UTF-8"
declare -x LDFLAGS="-L/opt/homebrew/opt/openssl@3/lib"
declare -x LOGNAME="divkix"
declare -x LaunchInstanceID="EB1B14BF-88FC-4DF6-A808-CFA3E9E33401"
declare -x MORPH_API_KEY="sk-sKIOR_4BfuniAInrzxgMoovjMKqlBxa2Ae6EC2oVOe44hOl-"
declare -x NEXT_TELEMETRY_DISABLED="= 1"
declare -x NODE_EXTRA_CA_CERTS="/Users/divkix/.factory/cache/certs/factory-cli-certs.pem"
declare -x OLDPWD="/Users/divkix/GitHub/vinext.fix-issue-1497"
declare -x OPENCODE_ENABLE_EXA="true"
declare -x OSLogRateLimit="64"
declare -x PATH="/Users/divkix/.factory/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/Users/divkix/.antigravity-ide/antigravity-ide/bin:/Users/divkix/.antigravity/antigravity/bin:/opt/homebrew/opt/llvm/bin:/opt/homebrew/opt/imagemagick-full/bin:/opt/homebrew/opt/ffmpeg-full/bin:/opt/homebrew/opt/ruby/bin:/Users/divkix/.bun/bin:/bin:/Library/Java/JavaVirtualMachines/temurin-25.jdk/Contents/Home/bin:/opt/homebrew/opt/python/libexec/bin:/opt/homebrew/opt/python/bin:/opt/homebrew/opt/curl/bin:/opt/homebrew/opt/libpq/bin:/Users/divkix/go/bin:/Users/divkix/.local/bin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/pkg/env/global/bin:/Library/Apple/usr/bin:/usr/local/MacGPG2/bin:/Users/divkix/.lmstudio/bin:/Users/divkix/.orbstack/bin:/Applications/Warp.app/Contents/Resources/bin"
declare -x PKG_CONFIG_PATH="/opt/homebrew/opt/openssl@3/lib/pkgconfig"
declare -x POWERSHELL_TELEMETRY_OPTOUT="= 1"
declare -x PWD="/Users/divkix/GitHub/vinext.fix-issue-1497"
declare -x PYTHONUNBUFFERED="1"
declare -x SECURITYSESSIONID="186b0"
declare -x SHELL="/opt/homebrew/bin/fish"
declare -x SHLVL="2"
declare -x SSH_ASKPASS=""
declare -x SSH_AUTH_SOCK="/var/run/com.apple.launchd.VER64w7Euk/Listeners"
declare -x SSH_SOCKET_DIR="~/.ssh"
declare -x STARSHIP_CONFIG="/Users/divkix/.config/starship.toml"
declare -x STARSHIP_SESSION_KEY="5594177922001814"
declare -x STARSHIP_SHELL="fish"
declare -x SUDO_ASKPASS=""
declare -x TERM="dumb"
declare -x TERM_PROGRAM="WarpTerminal"
declare -x TERM_PROGRAM_VERSION="v0.2026.05.20.09.21.stable_03"
declare -x TMPDIR="/var/folders/l6/72dpyfhj03x4z9lvndxkdbxr0000gn/T/"
declare -x USER="divkix"
declare -x VSCODE_TELEMETRY_LEVEL="= off"
declare -x WARP_CLIENT_VERSION="v0.2026.05.20.09.21.stable_03"
declare -x WARP_CLI_AGENT_PROTOCOL_VERSION="1"
declare -x WARP_HONOR_PS1="1"
declare -x WARP_IS_LOCAL_SHELL_SESSION="1"
declare -x WARP_USE_SSH_WRAPPER="1"
declare -x XPC_FLAGS="0x0"
declare -x XPC_SERVICE_NAME="0"
declare -x __ABBR_TIPS_KEYS="l la ll lla g gcl gph gphf gpl gplr gst gcm gcam gco gca gcan gitweb gwp gwl reload c df less www untar bu oc fastdl a__cat a__ls a__rmd"
declare -x __ABBR_TIPS_VALUES="ls ls -a ls -l ls -la git git clone git push git push -f git pull git pull --rebase git status git commit -m git commit -am git commit git commit -a --amend git commit --amend -a --no-edit open (git remote get-url origin) git worktree prune -v git worktree list exec fish \"clear && printf '\\\\e[3J'\" df -h more python3 -m http.server 8000 tar -zxvf fisher update && brew update && brew upgrade opencode aria2c -x16 -s16 -k1M bat lsd --icon-theme=unicode trash"
declare -x __CFBundleIdentifier="dev.warp.Warp-Stable"
declare -x __CF_USER_TEXT_ENCODING="0x1F5:0:0" keyword from  — it is only
used internally within  and knip flagged it as an
unused export.

* ci: trigger CI re-run

Previous E2E failures (font-google, error-interactive, hooks) appear
to be flaky/unrelated to the removeConsole changes.

* fix(remove-console): address bonk review feedback

- Document client-only scoping as a known parity gap from Next.js
- Correct the node_modules skip comment (Next.js does apply to deps)
- Hoist SCOPE_ENTERING_TYPES to a module-level Set for O(1) lookup
- Push a scope for CatchClause params so `catch (console)` shadows
- Document the var-hoisting limitation in BlockStatement scope handling
- Add resolution tests covering all removeConsole config shapes

* fix(remove-console): gate to build + cover remaining shadow patterns

- Set `apply: "build"` on the plugin so console stripping no longer fires
  in `next dev`. Matches Next.js, which documents removeConsole as a
  production-only feature.
- Extend `checkBinding` to recognize AssignmentPattern, ArrayPattern, and
  RestElement so destructured defaults, array destructuring, and rest
  elements correctly shadow `console`.
- Mark FunctionDeclaration / ClassDeclaration `id` in the enclosing scope
  before pushing the function's own scope (function and class declarations
  hoist their name into the surrounding scope).
- Mark named FunctionExpression `id` in the function's own scope (named
  function expressions bind their name only inside their body).
- Add 10 tests covering all newly-supported shadow patterns plus a
  CatchClause regression test.

---------

Co-authored-by: James <james@eli.cx>
2026-05-27 19:16:34 +01:00
James Anderson 217aecd84e fix(ci): grant contents:read to publish workflow's CI gate (#1620)
The publish workflow declares `permissions: {}` at the top level, which
revokes all permissions. When it invokes the reusable ci.yml workflow,
the called workflow inherits this restriction and cannot request
`contents: read` for actions/checkout, failing with:

    The workflow is requesting 'contents: read', but is only allowed
    'contents: none'.

Grant the permission explicitly at the ci job level so the reusable
workflow can use it.
v0.0.53
2026-05-27 15:32:01 +00:00
James Anderson a0f4e26655 fix(security): honor pages API bodyParser config (F-PROD-4) (#1617)
* fix(security): honor pages API bodyParser config (F-PROD-4)

Pages Router API routes export configuration like:

    export const config = { api: { bodyParser: false } };
    export const config = { api: { bodyParser: { sizeLimit: '4mb' } } };

vinext silently ignored this, hard-coding a 1 MB limit and always
parsing the body. That created a security downgrade for webhook
handlers (Stripe, GitHub, Slack, etc.) that depend on
'bodyParser: false' to read the raw request stream for HMAC
signature verification. By the time the user handler ran, the body
had already been consumed and parsed into 'req.body', so HMAC
verification would either fail closed (no signature match) or fail
open (handlers that fall back to an empty body for unsupported
content types).

Legitimate large uploads were also rejected with 413 even when the
route declared 'sizeLimit: "4mb"' or higher.

This change introduces a shared helper that resolves the route
module's 'config.api.bodyParser' option and patches both code paths:

  - Node/dev path:   packages/vinext/src/server/api-handler.ts
  - Workers/prod:    packages/vinext/src/server/pages-api-route.ts
                     (+ packages/vinext/src/server/pages-node-compat.ts)

Behaviour now matches Next.js
(packages/next/src/server/api-utils/node/api-resolver.ts):

  - bodyParser: false               -> skip parsing entirely. Node
                                       handlers receive the raw
                                       IncomingMessage stream;
                                       Workers handlers receive
                                       request.body as a
                                       ReadableStream<Uint8Array>
                                       on 'req.body'.
  - bodyParser: { sizeLimit: '4mb' } -> parse with the user-supplied
                                        limit instead of the
                                        hard-coded 1 MB.
  - bodyParser: true / undefined     -> existing 1 MB default behaviour.

The sizeLimit parser accepts the same suffix grammar Next.js does
('500b', '100kb', '4mb', '1gb'), implemented inline rather than
pulling the 'bytes' package.

Tests cover both Node and Workers paths plus parser-level unit
tests, including:

  - bodyParser: false leaves the stream intact for HMAC verification
  - sizeLimit '4mb' accepts a 2 MB POST that used to 413
  - sizeLimit '4mb' rejects a 5 MB POST with 413
  - sizeLimit '100kb' rejects a 200 KB POST with 413
  - No config preserves the 1 MB default

config.api.responseLimit is not in scope for this PR and remains a
follow-up.

Reference: Finding F-PROD-4 in SECURITY-AUDIT-2026-05.md

* chore(security): drop unused ResolvedBodyParserConfig export to satisfy knip
2026-05-27 16:25:32 +01:00
James Anderson 1f5fb6b42c fix(security): gate x-forwarded-proto in edge API runtime on trustProxy (F-PROD-7) (#1618)
The dev edge API bridge (`createEdgeApiRequest` in `api-handler.ts`) was
reading `X-Forwarded-Proto` without the `trustProxy` gate that the rest of
the prod server uses. A client that can reach the dev server directly
could send `X-Forwarded-Proto: https` and trick edge handlers that gate
Secure-cookie issuance on `request.url.startsWith("https")` (or any other
`request.url.protocol` check) into believing the request was
TLS-terminated.

Same issue applied to `X-Forwarded-Host` in the same function: the raw
header value was used to build the request URL, opening a host-header
poisoning vector identical to the one `prod-server.resolveHost` already
guards against.

This commit:

* Extracts `resolveRequestProtocol`, `resolveRequestHost`, `trustProxy`,
  and `trustedHosts` into a new shared module
  `packages/vinext/src/server/proxy-trust.ts`. The helpers accept both
  Node `IncomingMessage` and Web `Headers` so the same trust policy
  applies in every server flavor.
* Updates `prod-server.ts` to delegate to the shared module
  (re-exporting `resolveHost`, `trustedHosts`, and `trustProxy` to keep
  the existing public surface and the tests that mutate `trustedHosts`
  working).
* Updates `createEdgeApiRequest` to use the new helpers so dev edge API
  routes honour `X-Forwarded-Proto` / `X-Forwarded-Host` only when
  `VINEXT_TRUST_PROXY=1` / `VINEXT_TRUSTED_HOSTS` is configured.

Tests:

* New `tests/api-handler-trust-proxy.test.ts` covers the default
  (untrusted) behaviour, `VINEXT_TRUST_PROXY=1`, and the
  `VINEXT_TRUSTED_HOSTS` allow-list (including the implicit
  `trustProxy` enablement, case-insensitive matching, and
  comma-separated values).
* Updated the existing "uses the first x-forwarded-proto value" test to
  reflect the new (correct) default of ignoring forged proxy headers.

Reference: Finding F-PROD-7 in SECURITY-AUDIT-2026-05.md.
2026-05-27 16:17:16 +01:00
James Anderson 03e658f221 fix(security): bound x-vinext-mounted-slots cache key cardinality (F-PROD-1) (#1619)
* fix(security): bound x-vinext-mounted-slots cache-key cardinality (F-PROD-1)

Vinext invents two internal headers (X-Vinext-Mounted-Slots,
X-Vinext-Interception-Context) that are legitimately set by the browser's
RSC client and flow into the ISR RSC cache key (appIsrRscKey via
fnv1a64) and optimistic-routing template/source keys.

Because these headers were accepted verbatim from external requests, an
attacker who controls the browser could fan out unbounded distinct values:

  for i in $(seq 1 100000); do
    curl -s "https://target.example/blog.rsc?_rsc=0" \\
      -H "X-Vinext-Mounted-Slots: attacker-$i" \\
      -H "RSC: 1" > /dev/null &
  done

Each unique header value mapped to a unique cache-key hash, producing one
KV write per request (Cloudflare KV bills per write), cache fragmentation,
and content-split cache pollution.

We cannot strip the headers wholesale because the legitimate vinext
navigation client does set them on the user's browser-to-server RSC
fetch. Instead, this commit validates the input shape and bounds
cardinality at the boundary, before the value reaches the cache key.

Approach: input-shape validation + bounds. Specifically:

x-vinext-mounted-slots (normalizeMountedSlotsHeader):
  - Raw header value capped at 4096 bytes (oversized → null).
  - Each token must match the legitimate slot:<name>:<treePath> wire
    format produced by encodeSlotId — name is non-empty and contains no
    ':', treePath starts with '/'. Anything else (e.g. arbitrary
    'attacker-N' bytes) is dropped silently.
  - Each token capped at 256 bytes.
  - Token count capped at 16 after dedup + sort.

x-vinext-interception-context (new normalizeInterceptionContextHeader):
  - Null bytes stripped (header-injection defense, preserved from prior
    behavior).
  - Value capped at 1024 bytes.
  - Must start with '/' (legitimate values are same-origin pathnames
    like '/feed', '/photos/42' — see resolveManifestNavigationInterceptionContext).
  - Raw whitespace is rejected.

Anything that fails validation is treated as if the header were absent,
so the request is still served (just without interception or with the
no-slots cache key).

Why not strip the headers entirely (F-PROD-1 audit option (a))? The
RSC fetch IS the user's browser-to-server request — there is no
internal hop where these headers are added on the server side. Stripping
would break legitimate parallel-route and intercepting-route navigation.

Why not hash the input before using it (audit option (b))? The value
already passes through fnv1a64 in appIsrRscKey. Hashing does not fix
unbounded cardinality — it just relocates it.

Follow-up: the audit hint also suggests validating slot IDs against the
route's declared parallel-route slots and rejecting unknown slot IDs
(matching Next.js's Next-Router-State-Tree approach). That requires
route-manifest access at the cache-key construction point and is a
larger structural change tracked separately.

Tests added in tests/isr-cache.test.ts and
tests/app-rsc-request-normalization.test.ts cover:
  - 1000 distinct attacker-supplied header values collapse to a single
    cache key.
  - Tokens that do not match slot:<name>:<treePath> are rejected.
  - Token count cap (16) is enforced.
  - Raw header length cap (4096 bytes) is enforced.
  - Interception-context values that do not start with '/' are rejected.
  - Legitimate browser-emitted values continue to round-trip.

Identified in internal security audit — see SECURITY-AUDIT-2026-05.md
finding F-PROD-1.

* fix(security): keep slot-id shape validation inside app-elements codec boundary

The architectural invariant test (`app-elements.test.ts > keeps raw
AppElements wire-key construction inside the codec boundary`) failed
because the new mounted-slots header validator in
`app-mounted-slots-header.ts` reimplemented the slot-id wire-format
check (`token.startsWith("slot:")` + body parsing) outside the
codec module.

Delegate shape validation to `AppElementsWire.isSlotId` (already
exported from `app-elements-wire.ts`) so the wire-format definition
lives in exactly one place. The header normalizer retains its
security-relevant bounds (token byte cap, total header byte cap, token
count cap, sort + dedup) — only the shape check moves.

Security property is preserved: slot-id cardinality is still bounded
by MAX_SLOT_TOKENS, MAX_TOKEN_LENGTH, and MAX_RAW_HEADER_LENGTH, and
the same legitimate-shape filter (`slot:<name>:<treePath>` with an
absolute tree path) still gates which tokens contribute to the
normalized header value used in the ISR RSC cache key.
2026-05-27 15:58:54 +01:00
Nathan Nguyen 314fbea508 fix(app-router): align RSC navigation scroll targeting with Next.js (#1599)
* fix(app-router): align RSC navigation scroll targeting with Next.js

App Router RSC navigations scrolled the document after appNavigate resolved, which lost Next.js segment targeting and focus semantics. That left persistent layout offsets and horizontal scroll behaviour coarser than Next.js during normal navigation.

Next.js treats scroll as a navigation intent consumed by the newly navigated segment after commit. This adds a small intent state, wraps the page slot in a client scroll target, and keeps the existing hash-only and popstate paths separate.

Adds focused Playwright coverage ported from Next.js autoscroll and navigation focus tests.

* test(form): stop asserting app router scroll detail

* fix(app-router): prevent focus from changing scroll

* fix(app-router): share scroll intent across Vite module instances

The App Router scroll intent was stored in plain module-level variables in
app-router-scroll-state.ts. navigateClientSide stages the intent and the
committed AppRouterScrollTarget consumes it, but next/navigation and the
scroll-target module can resolve to separate Vite module instances. If the
writer and consumer hold different copies, the staged intent is invisible to
the consumer and the post-commit scroll/focus silently no-ops.

This is the same multi-instance hazard the rest of the navigation state already
guards against with Symbol.for globals (see _CLIENT_NAV_STATE_KEY in
navigation.ts and AGENTS.md "RSC and SSR Are Separate Vite Environments").

Move the single pending intent and the id counter onto a Symbol.for global so
every module instance shares one slot, matching the established navigation
state pattern. Function signatures are unchanged, so callers need no edits.

Also document why AppRouterScrollTarget must remain a class component:
findDOMNode needs a mounted class instance to locate the first rendered DOM
node without a wrapper element, mirroring Next's default scroll handler.
2026-05-27 12:25:54 +01:00
Divanshu Chauhan (divkix) 6a806ecee1 feat(instrumentation): support nextConfig.instrumentationClientInject (#1416)
* feat(instrumentation): support nextConfig.instrumentationClientInject (#1326)

Add support for the Next.js instrumentationClientInject config option that
lets users inject client bootstrap modules for side effects and an optional
onRouterTransitionStart hook ahead of the user's instrumentation-client file.

Changes:
- New generateInstrumentationClientInjectModule() generates a virtual ESM
  module with side-effect imports in array order + composed hook fan-out.
- Accept instrumentationClientInject in NextConfig / ResolvedNextConfig.
- New vinext:instrumentation-client-inject Vite plugin with resolveId/load
  hooks; virtual module string is precomputed once in config() for efficiency.
- Unit tests cover empty injects, single/multi specifiers, hook ordering,
  and empty-module fallback when no user file exists.

* test(instrumentation): fix oxfmt formatting and assertion indentation

The test expected 2-space indent for the generated code, but the
implementation produces 4-space indent within if blocks. Also fixes
oxfmt quote style and line wrapping.

* fix(ci): lint warnings and missing field in test helper

- Replace unnecessary escaped quotes inside template literals to fix
  eslint(no-useless-escape) warnings in instrumentation-client-inject.ts
- Add missing instrumentationClientInject field to makeResolved() test
  helper in next-config.test.ts to fix TS2322 type errors

* Address review feedback for instrumentationClientInject.

Make clientInjectModule self-clearing on config re-eval, filter non-string inject specifiers, document dual resolution paths, and add escaping coverage.

Signed-off-by: Divanshu Chauhan <divkix@divkix.me>

* test(instrumentation): add plugin pipeline integration tests and fix inject resolution

Add client-environment tests for instrumentationClientInject virtual module
wiring, omit the alias when injects are active so resolveId/load run, reload
next.config when root changes, and pre-resolve relative inject paths. Share
empty-module path via INSTRUMENTATION_CLIENT_EMPTY_MODULE and simplify tests.

* fix(instrumentation): resolve empty-module.js in production builds

CI E2E builds failed because INSTRUMENTATION_CLIENT_EMPTY_MODULE pointed
at empty-module.ts, which is not shipped in dist. Prefer .js when present.

* test(instrumentation): address PR review comments for client inject

- Revert nextConfigRoot guard changes in config loading to avoid buildId divergence risk
- Add cache-busting to dynamic ESM module imports in tests and document the reason
- Add integration test case for empty-inject and missing user-file fallback path

---------

Signed-off-by: Divanshu Chauhan <divkix@divkix.me>
2026-05-27 11:13:53 +01:00
Nathan Nguyen 0046e0691a fix(app-router): preserve encoded prerender params (#1597)
* fix(app-router): preserve encoded prerender params

App Router prerendered pages with encoded dynamic params could be bypassed in production and rendered again with decoded route params. This diverged from Next.js for generateStaticParams values like "sticks & stones".

The root cause was split across build and startup. The prerender render path only had the encoded URL, so normal route matching decoded the page params. After preserving encoded params for the render, production startup still seeded the ISR cache under the encoded artifact path while live requests lookup cache entries by the runtime-normalized pathname.

Carry trusted prerender params through the prerender-only request path, keep decoded params for dynamicParams validation, and seed prerendered App Router artifacts under the normalized runtime cache key while reading files from their encoded output path.

* chore(app-router): clean up prerender header internals

Reuse the shared record guard for prerender route param parsing and remove the local duplicate.

Keep Next.js INTERNAL_HEADERS as the exact upstream list, with vinext-only internal headers tracked separately but stripped by the same request pipeline. This fixes the CI unit failure while preserving the prerender header trust boundary.

Factor repeated Node-header conversion and forwarded-protocol handling in the production server to avoid local assertions and duplicate loops.

* fix(app-router): scope prerender params to route pattern

Bind the internal prerender route-param header to the route pattern that produced it so encoded render params cannot cross a rewrite boundary into a different dynamic route shape.

The App Router handler now only applies encoded prerender params when the payload routePattern matches the final matched route. Otherwise it falls back to the decoded params from normal route matching.

Tighten the header parser to validate the payload shape with Object.entries/Object.keys and add a regression for a prerendered source route rewritten to a dynamic target route with a different param name.

* fix(app-router): verify prerender params match route

Require trusted prerender route params to prove both the final route pattern and the final decoded matched params before using their encoded render values.

This prevents same-pattern rewrites from carrying stale prerender params into a different concrete route match, while keeping valid prerender renders encoded and static validation decoded.

Add handler regression coverage for same-pattern rewrites and helper coverage for catch-all array comparison.

* docs(app-router): clarify prerender route-param trust boundary

The prerender route-param readers and serializer carried implicit
coupling that read as security verification but actually delegated it.
A reader new to readTrustedPrerenderRouteParams could assume it verifies
the prerender secret, when verification happens once upstream at
prod-server's nodeToWebRequest boundary and every downstream read trusts
that. The double read-then-reattach in createAppRscHandler also depends
on the secret header surviving filterInternalHeaders, with no note of
that coupling.

Document the trust contract: secret is verified at the outer boundary,
downstream reads operate on already-trusted requests gated by
VINEXT_PRERENDER=1, and the secret header must stay out of the internal
strip list for the second read to succeed. Also document the
empty-params to null serialization contract.

No behavior change; comments only.
2026-05-27 10:55:26 +01:00
Jim Geurts 2af6298a11 fix(shims/script): guard HTMLElement and prefer contextual nonce in SSR (#1608)
Some SSR/edge runtimes polyfill `document` but not the `HTMLElement`
constructor. The previous nonce resolver hit `instanceof HTMLElement`
unguarded and crashed the render with
"ReferenceError: HTMLElement is not defined".

The resolver also fell through to DOM auto-detection whenever `window`
was defined, even when a contextual server nonce was available — DOM
auto-detection is a browser convenience and shouldn't be reached on the
server.

This change:

- Guards `HTMLElement` before `instanceof`, falling back to
  `getAttribute("nonce")` when the constructor is unavailable.
- Prefers explicit nonce, then contextual server nonce, then DOM
  fallback (only when `window` is defined).
- Adds regression coverage for the crash and the resolver ordering,
  including the exact minimal repro shape from the upstream report
  (window + document defined, HTMLElement absent).

Refs cloudflare/vinext#1607
2026-05-27 09:42:41 +01:00
Divanshu Chauhan (divkix) 2b9ff64019 fix(routing): preserve dynamic route params key order (#1609)
App Router dynamic-route params were being returned in reversed key
order (deepest segment first) because the recursive trie matcher
mutated result.params on the way back up the call stack.

Fix by collecting param entries into a shared stack during forward
traversal, then materializing the params object from that stack at
the terminal node. This preserves declaration order for Object.keys().

- Shared buildParams helper extracted to routing/utils.ts to unify
code between route-trie.ts and app-optimistic-routing.ts.
- Added tests verifying key order for [category]/[id], [a]/[b]/[c],
mixed static+dynamic, and backtracking-to-catch-all scenarios.

Fixes #1537
2026-05-27 09:41:37 +01:00
Nathan Nguyen 5b633b0284 fix(pages): render masked basePath error routes (#1441)
* fix(pages): render masked basePath error routes

Pages Router route misses under basePath returned generic HTML and masked client navigations to /404 or /_error could fetch the visible as-path instead of the error route. This diverged from Next.js when applications used next/router to preserve a friendly URL while rendering the error component.

The implementation assumed the browser URL and the component route could always collapse to one fetch target. Preserve the masked history URL, fetch the Pages error route as the HTML target, allow its 404 response to hydrate, and render pages/404 for production route misses with the correct status and client module metadata.

* test(pages): keep error navigation coverage out of vitest browser

Vitest integration CI does not install Playwright browsers, so launching Chromium from tests/pages-router.test.ts made the PR fail deterministically even though the upstream deploy harness passed.

Keep the browser-level verification in the upstream deploy-suite run and cover vinext locally at the lower boundaries: production 404 rendering through HTTP and Pages Router masked error-route navigation through the router shim. The generated Pages entry now also shares one SSR manifest module lookup helper between asset tag collection and client module URL resolution.

* fix(prerender): keep custom 404 out of generic page loop

* fix(pages): fall back to _error on route misses

* fix(pages): document the catch-all guard on matchRoute("/404")

The pattern check prevents a dynamic catch-all route (e.g. [...slug]) from
being used as the 404 fallback. Without it, matchRoute can return a
catch-all match and silently hijack the error rendering path.

* fix(pages): preserve error navigation rewrite order

* fix(pages): limit fallback rewrites to route misses

* fix(deploy): skip deferred error rendering for data requests

* fix(pages): preserve ISR response status

* fix(pages): keep module metadata in ISR regeneration
2026-05-27 09:36:13 +01:00
Nathan Nguyen 5fe9789bbd fix(app-router): preserve history index on external state writes (#1600)
* fix(app-router): preserve history index on external state writes

External history.pushState and history.replaceState calls preserved vinext interception metadata but dropped the traversal index. That left userland or library shallow history entries without the metadata App Router traversal uses to classify and anchor back/forward state.

createExternalHistoryStatePreservingMetadata now preserves vinext navigation metadata as one unit from the current history entry. The shim regression covers object, null, undefined, and index-only external state writes.

* test(app-router): seed stale history context through unpatched state

Public History API calls now sanitize vinext metadata before delegating. The refresh regression test was seeding stale intercepted context through that public wrapper, so it tested the wrapper boundary instead of the stale internal-state condition.

Use the saved original replaceState handle to create the same framework-owned stale state the test is meant to refresh away.
2026-05-26 15:49:58 +01:00
한만욱 a66004c71e fix: export static App Router metadata files (#1590)
* fix: export static App Router metadata files

* fix: address static metadata export review

---------

Co-authored-by: James Anderson <james@eli.cx>
2026-05-26 15:18:04 +01:00
Nathan Nguyen a0b8ae068a fix(draft-mode): keep draft secret out of client defines (#1592)
* fix(draft-mode): keep draft secret out of client defines

The draft-mode bypass token was emitted through Vite define, so Vite's client env payload and client chunks could contain the server-only cookie secret when app code referenced that env key.

The violated boundary was treating a server runtime credential like a shared compile-time define. Generate one plugin-scoped secret and initialize server entries with it, while next/headers reads only internal server state.

Adds regression coverage for dev env exposure, production client bundle leakage, and the built server draft cookie round trip.

* test(draft-mode): initialize secret after module reset

* fix(draft-mode): scope draft secret to request context

Draft mode validation briefly lived in a package-global singleton after removing the client-visible define. That made multiple app instances in the same process able to overwrite each other's validator.

Thread the generated app secret through the App Router request and dispatch boundaries, store it on HeadersContext, and make cache bypass validation a pure request-plus-secret check. Add a regression that proves two contexts keep independent draft validators.

* fix(draft-mode): guard stale draft controls
2026-05-26 15:00:44 +01:00
Nathan Nguyen 7142809f1e fix(font): preserve local font binding family (#1593)
* fix(font): preserve local font binding family

Local next/font/local calls currently rewrite font asset paths but drop the caller binding name that Next.js passes to its local font loader. App Router layouts still receive a generated font class and preload the font file, but computed font-family resolves to __local_font_N instead of the binding name observed by Next.js.

The root cause was that vinext's local font transform only handled asset URLs while the runtime shim always invented a private family name. Thread a private _vinext.font.family payload from const-bound localFont calls into the shim, validate it at the boundary, and fall back to the generated family for dynamic or invalid input.

Regression coverage ports the mdx-font-preload behavior at the transform and runtime boundaries so the layout class resolves to myFont while existing preload/path rewriting stays covered.

* fix(font): separate local font injection identity

Review found that the transform-provided local font family can repeat across modules when different files use the same binding name, such as const myFont = localFont(...).

Keep that binding name as the visible CSS font-family, but key @font-face injection by the generated class name so distinct localFont calls cannot suppress each other's source rules. Add a focused regression for repeated transformed binding names.

* test(font): cover invalid local family payloads

Address review feedback on the local font transform follow-up.

Simplify the balanced call guard, pin the MDX provenance links to the Next.js v16.2.6 source used for this port, and add coverage proving invalid internal family payloads fall back to the generated __local_font_N family without leaking CSS injection text.

* fix(font): harden internal family payloads

Address the latest review pass on local font family handling.

Reject non-string private family payloads before regex validation, cover the stateful toString case, and make binding-family injection tolerant of same-line block formatting so it stays aligned with the existing path rewrite behavior.
2026-05-26 14:24:38 +01:00
Nathan Nguyen f8e79b7ff6 fix(build): clean stale vinext build output before rebuilds (#1586)
Repeated vinext builds currently leave stale hashed server chunks in dist/server. Workers deployments with no_bundle upload every matched JS module, so stale chunks count against Worker size limits.

The CLI assumed Vite's per-environment emptyOutDir would remove old output, but the multi-environment build and hybrid Pages Router pass preserve dist/server artifacts.

Clean the vinext dist output once immediately before the Vite build starts, while preserving build.emptyOutDir: false as the escape hatch. Add focused tests for default cleaning, explicit preservation, and outside-root safety.
2026-05-26 12:12:54 +01:00
Nathan Nguyen 4b6b438b94 fix(app-router): stream generated metadata for non-html bots (#1585)
* fix(app-router): stream generated metadata for non-html bots

Dynamic App Router metadata was always folded into the route head. That diverged from Next.js for normal browser-like requests, where generated metadata is sent through a body outlet while html-limited bots still receive blocking head metadata.

Track whether the matched route uses generateMetadata, thread next.config htmlLimitedBots into the generated RSC entry, and choose head versus body placement at page element construction.

* test(app-router): cover streaming metadata bot edge cases

Generated metadata must keep the default html-limited bot list when serialized config contains a falsy regex source. Empty config strings previously produced an empty regex, which treated every user agent as blocking and moved generated metadata back into the head.

Add regression coverage for the default Twitterbot path and the streaming body serializer, then match Next.js by falling back to the default bot regex for any falsy htmlLimitedBots value.

* fix(app-router): validate html-limited bot regex config

Invalid serialized htmlLimitedBots values could reach request handling and throw while building the bot matcher. That puts a config error on the request path and recompiles the same matcher for repeated requests.

Validate serialized regex sources while resolving next.config and reuse compiled bot matchers in the streaming metadata helper. The focused tests cover invalid config, falsy fallback, and matcher reuse.

* refactor(config): narrow next config option reads

Config resolution had several local assertions around experimental options, Turbopack aliases, output mode, and Sass options. Those assertions made the boundary wider than necessary in the code adjacent to htmlLimitedBots handling.

Read optional records, strings, arrays, and body-size inputs through typed narrowing helpers so the resolved config path keeps the same semantics without spreading unchecked asserted values.

* fix(config): keep bot regex helper out of server graph

Config resolution imported the streaming metadata server module to validate htmlLimitedBots. In RSC-backed App Router integration tests, that cross-layer import can pull server runtime modules through config resolution and surface rsc reference-validation errors.

Move the shared html-limited bot matcher cache to a neutral utils module and import it from config and the server streaming metadata helper. This keeps config validation independent from the server runtime graph while preserving the cached matcher behavior.
2026-05-26 12:07:07 +01:00
Jerry Zhao c4eab2e861 chore: add .gitattributes to normalize line endings to LF (#1581) 2026-05-26 11:46:04 +01:00
Nathan Nguyen bca641535b fix: resolve relative server action redirects (#1579)
* fix: resolve relative server action redirects

Server action redirects could carry dot-relative locations from the action response. Treating those locations as raw browser redirects resolved them from the current document URL and forced a reload, so nested App Router actions could land on the wrong route or lose client state.

Resolve server action redirect locations against the action initiation pathname using Next.js directory-style semantics, keep same-origin redirects on the App Router navigation path, and hard-navigate only for external destinations or parse fallback.

Adds browser-level coverage ported from the Next.js relative server action redirect test plus focused helper assertions for the URL resolution contract.

* chore: rerun ci

* chore: rerun ci again
2026-05-26 11:45:53 +01:00
Nathan Nguyen 4b4dc8d2ef fix: handle Pages Router middleware redirects (#1439) 2026-05-26 11:35:58 +01:00
一纸忘忧 5188768b58 fix: normalize static file cache paths on Windows (#1578) 2026-05-26 10:33:49 +00:00
James Anderson fd086c4475 fix(app-router): apply x-edge-runtime header on all App Router response paths (#1603)
Extends #1565: the merged change gated the marker on the resolved segment
config, but only covered the two `buildAppPage*Response` builders. Five
other response construction paths bypassed those helpers and skipped the
header for edge-runtime routes:

- `app-page-cache.ts` — ISR HIT/STALE responses
- `app-server-action-execution.ts` — server action RSC responses
  (redirect, no-revalidate, re-render)
- `app-page-boundary.ts` — boundary RSC responses
- `app-page-execution.ts` — 200 + flight-payload redirect responses
- `app-page-stream.ts` — HTML stream responses used by the boundary path

Centralizes the gating in a single `applyEdgeRuntimeHeader` helper so
every site shares one source of truth for header name + condition, and
threads `isEdgeRuntime` through the fallback renderer + dispatch options
so each construction site receives it.

Adds positive + negative coverage for each newly-covered path.
2026-05-26 11:28:33 +01:00
James Anderson 0e64147176 fix(pages-router): pass revalidateReason to getStaticProps; default gSSP Cache-Control (#1573)
* fix(pages-router): pass revalidateReason to getStaticProps context

Pages Router `getStaticProps` now receives `context.revalidateReason`
matching Next.js: `'build'` during build-time prerendering,
`'on-demand'` when the dispatch is signalled as an explicit revalidation,
and `'stale'` for stale-while-revalidate background regeneration and
runtime cache-miss requests.

Closes #1462

* fix(pages-router): default gSSP Cache-Control in prod + JSON data paths

Mirrors the dev-server branch added earlier in this PR. Production HTML
gSSP responses (renderPagesPageResponse) and the _next/data JSON envelope
(pages-server-entry isDataReq) now default to
"private, no-cache, no-store, max-age=0, must-revalidate" when the user
did not set Cache-Control via res.setHeader, matching Next.js's
pages-handler.ts (revalidate: 0 → getCacheControlHeader). Closes the
dev/prod parity gap flagged by bigbonk.

Also fixes the broken `gsspRes.headers` access in the isDataReq path
(should be `getHeaders()`) so user-set headers actually propagate, and
notes that isOnDemandRevalidate is forward-looking plumbing (no
caller sets it yet — res.revalidate() isn't implemented).

* test(pages-router): fix misaligned continuation comment

Drop the extra leading space on the JSDoc continuation lines flagged by
bigbonk so the block aligns with the surrounding tests.
2026-05-26 11:25:25 +01:00
James Anderson 7a6a91ef19 fix(404): align default not-found copy with Next.js (#1567)
* fix(404): align default not-found copy with Next.js

The Pages Router previously emitted `<h1>404 - Page not found</h1>` and the
App Router emitted plain-text `This page could not be found` (no period) when
no user-defined 404 boundary was present. Next.js renders
`This page could not be found.` (with trailing period) for both routers.

Closes #1454.

* fix(404): address bot review — charset attribute and h1 line-height

- Use HTML attribute name 'charset' (not the JSX 'charSet') in the raw HTML template.
- Drop stray 'line-height:49px' on the Pages Router h1 — Next.js's _error.tsx inherits 48px from the parent div.
2026-05-26 09:33:01 +00:00
James Anderson d0839903e8 fix(app-router): emit vary marker on edge RSC route response (#1565)
* fix(app-router): emit x-edge-runtime marker on edge RSC route response

Closes #1531

The Next.js edge-runtime app template (edge-ssr-app.ts) sets
`x-edge-runtime: 1` on every App Router response. vinext serves App
Router responses from a Cloudflare Worker (an edge-equivalent runtime)
but was not emitting this marker, so Next.js' upstream test suite
(`test/e2e/app-dir/app/index.test.ts`) saw `null` for the header.

Add the header to both the RSC and HTML response builders so the
assertion passes for any App Router page, regardless of which payload
shape the client requests.

* fix(app-router): gate x-edge-runtime marker on configured runtime

Only emit `x-edge-runtime: 1` when the route's resolved segment config
opts into the edge runtime (`export const runtime = "edge"` or
`"experimental-edge"`). Plumb the resolved runtime from the segment
config through dispatch and the render lifecycle so both RSC and HTML
response builders see it.

Without the gate, nodejs-runtime routes also advertise the marker, which
hides the configured runtime from downstream consumers.
2026-05-26 10:29:59 +01:00
James Anderson 61cf2e78f5 fix(image): emit /_next/image URLs (#1562)
* fix(image): emit /_next/image URLs to match Next.js

Closes #1513

The default image loader and optimization endpoint switched from the
vinext-specific /_vinext/image path to Next.js's canonical /_next/image.
This unblocks the deploy suite tests that import Next.js's expected
URL shape (/_next/image?url=...&w=...&q=...).

* refactor(image): use IMAGE_OPTIMIZATION_PATH constant at remaining call sites

Replace hardcoded "/_next/image" strings in index.ts, app-rsc-handler.ts,
and the generated worker entry templates in deploy.ts with the
IMAGE_OPTIMIZATION_PATH constant from server/image-optimization, matching
the pattern already used in prod-server.ts. Prevents future drift if the
path ever changes again.

* feat(image): accept both /_next/image and /_vinext/image at the optimizer

Add a VINEXT_IMAGE_OPTIMIZATION_PATH constant and an
isImageOptimizationPath() helper, then route through every match site
(prod-server, dev server passthrough, app RSC handler, generated worker
templates, and shipped example workers). Apps that wire image URLs to
either prefix now hit the same handler; new URLs are still emitted via
IMAGE_OPTIMIZATION_PATH.
2026-05-26 10:28:22 +01:00
James Anderson e5d9c4ca33 fix(pages-router): await Promise-shaped gssp props (#1568)
* fix(pages-router): await Promise-shaped gssp props

Next.js explicitly supports a Promise value for `getServerSideProps`
`props`. Pages returning `{ props: Promise.resolve(...) }` previously
rendered empty pageProps because vinext passed the unresolved Promise
straight through to the serializer. Match Next.js' `render.tsx` by
detecting a thenable `props` and awaiting it before continuing.

The fix is applied symmetrically to the dev server gssp dispatch
(`dev-server.ts`) and the prod page-data dispatch (`pages-page-data.ts`)
so the two stay in sync per AGENTS.md. Other return shapes (`notFound`,
`redirect`, `revalidate`, fallback) are untouched.

Closes #1459

* refactor: simplify Promise-shaped props handling

await Promise.resolve(x) already handles both thenables and plain
values, so the .then duck-typing check is unnecessary.
2026-05-26 09:21:38 +00:00
James Anderson dc77f8480d fix(pages-router): throw on dangerous URI schemes synchronously (#1575)
Hoist the `javascript:` / `data:` / `vbscript:` block from inside the
async `performNavigation` body up into `Router.push` / `Router.replace`
themselves so the throw is fully synchronous. Mirrors Next.js's Pages
Router `push`/`replace` at
packages/next/src/shared/lib/router/router.ts:1025-1033,1057-1065.

Why: `performNavigation` is an `async` function, so throwing from inside
it wraps the error in a rejected Promise. `<button onClick={() =>
router.push("javascript:...")}>` does not await that Promise, so React's
event-handler error reporter never observes the throw and the matching
`console.error` is not surfaced through page logs. That is the failure
shape asserted by Next.js's
`test/e2e/app-dir/javascript-urls/javascript-urls.test.ts:341,376`.

Behaviour preserved: navigation is still blocked, and the matching
`console.error("Next.js has blocked a javascript: URL as a security
precaution.")` is still emitted by `assertSafeNavigationUrl`. The inner
guard inside `performNavigation` is kept as defence-in-depth.

Refs #1349
2026-05-26 10:20:43 +01:00
James Anderson adb2e12827 fix(app-router): preserve 307 status on document loads in prerender (#1571)
The App Router prerender harness forwarded requests through `fetch` with
fetch's default `redirect: "follow"` behavior. When a page called
`redirect()` the prod server emitted a 307, fetch silently followed the
Location header to the destination page, and the harness wrote the
destination's HTML under the redirecting route's filename. The seeded
cache then served that body with status 200 for every document request,
breaking Next.js parity (test/e2e/app-dir/rsc-redirect/rsc-redirect.test.ts
expects 307).

Set `redirect: "manual"` on the App Router prerender `rscHandler` so the
original 3xx surfaces back. `htmlRender.ok` is then false, the route is
marked skipped, and at runtime the document request hits the live
render path that already builds a proper 307 + Location response via
`buildAppPageSpecialErrorResponse`. RSC flight-payload handling (#1347)
is untouched.

The pages-prerender `renderPage` helper already used `redirect: "manual"`,
so this brings the App Router phase in line.

Closes #1530
2026-05-26 10:20:11 +01:00
James Anderson 37e077dd06 fix(pages-router): preserve query string in Link and router.push (#1572)
`parseQueryString` and the parseQuery template embedded in the Pages
Router worker entry both used `url.split("?")[1]`, which silently drops
everything after the second `?`. URLs like `/linker?href=/about?hello=world`
have query `href=/about?hello=world` per RFC 3986, so the previous
implementation truncated `query.href` to `/about` and `<Link>` rendered
without the embedded query string. Switching to an `indexOf`-based slice
keeps the full query value intact.

Closes #1471
2026-05-26 10:19:14 +01:00
James Anderson 7ffa2b7f3c fix(pages-router): default Cache-Control on gssp responses (#1574)
getServerSideProps responses now ship the Next.js default
`private, no-cache, no-store, max-age=0, must-revalidate` Cache-Control
header in both dev and prod, and forward `res.setHeader('Cache-Control', ...)`
overrides set inside gssp through to the final response.

Mirrors next/src/server/route-modules/pages/pages-handler.ts where a
gssp page resolves to `revalidate: 0` and getCacheControlHeader emits
the same string, gated by `if (!res.getHeader('Cache-Control'))` to
preserve user overrides.

Closes #1461
2026-05-26 10:17:55 +01:00
James Anderson e3b1cde53d fix(pages-router): document push/replace Promise<boolean> contract (#1566)
Pages Router `window.next.router.push`/`replace` already resolved to
`true` on a successful navigation (and `false` on a hard-navigation
fallback) via `performNavigation`, but the public `window.next.router`
surface typed both as `(...args: unknown[]) => unknown`, leaving
consumers (and the Next.js deploy test suite that `awaits` the result)
without a typed boolean to assert on.

- Narrow `PagesRouterPublicInstance.push`/`replace` to
  `(...args: unknown[]) => Promise<boolean>` to match Next.js's
  `Router.change` contract (router.ts:1206 — `Promise<boolean>`).
- Add unit-test regression coverage asserting
  `window.next.router.push`/`replace` resolve to `true` on success.
- Void the `router.push(...)` calls in `tests/e2e/app-router/build-id-navigation.spec.ts`
  to satisfy the tightened union (App Router push is `void`).

Closes #1467
2026-05-26 10:10:41 +01:00
James Anderson 485046ed09 fix(link): warn on repeated forward slashes in href (#1563)
* fix(link): warn on repeated forward slashes in href

Closes #1554

When a `<Link href>` contains repeated forward-slashes (e.g. `/foo//bar`)
or a backslash, Next.js's `resolveHref` emits a `console.error` so the
developer notices the invalid URL during dev/CI. vinext's Link shim did
not surface that warning, which caused the Next.js Deploy Suite test
`test/e2e/repeated-forward-slashes-error` to fail waiting for the
message in the CLI output.

Add a small `warnOnRepeatedSlashesInHref` helper that mirrors the regex
and message used by Next.js, and call it from the Link body after the
href is resolved. The href continues to render and navigate unchanged —
Next.js does not block, it only warns.

Ported from Next.js: packages/next/src/client/resolve-href.ts

* fix(link): normalize repeated slashes and drop dedup for Next.js parity

Address review feedback on #1563:

- Remove the module-level `warnedRepeatedSlashHrefs` Set. Next.js fires
  this warning on every `resolveHref` call, so dedup was an unintended
  divergence and a latent test-ordering hazard.
- Add `normalizeRepeatedSlashes` (port of
  packages/next/src/shared/lib/utils/normalize-repeated-slashes.ts) and
  apply it after warning, so `<Link href="/a//b">` renders `href="/a/b"`
  exactly as Next.js does.
- Preserve protocol-relative URLs (`//example.com/...`) unchanged —
  vinext treats those as external via `isAbsoluteOrProtocolRelativeUrl`,
  so we skip both the warning and the normalization for them to avoid
  regressing the existing "locale does not mangle protocol-relative
  URLs" test.
- Document the `window.location.pathname` fallback (Next.js uses
  `router.pathname`; the difference is cosmetic and not asserted by the
  compat test).
- Add tests covering no-dedup behaviour, href normalization (including
  backslashes, query-string preservation, and protocol-prefixed URLs).
2026-05-26 10:10:08 +01:00
James Anderson 59c672a839 fix(app-router): router.prefetch throws on invalid URL (#1564)
* fix(app-router): router.prefetch throws on invalid URL

Match Next.js's createPrefetchURL behaviour: when the href cannot be
parsed via `new URL(href, window.location.href)`, throw the canonical
message `Cannot prefetch '<href>' because it cannot be converted to a
URL.` so the surrounding error boundary in the App Router can surface
the failure instead of silently swallowing it inside the async prefetch
IIFE.

Closes #1553

* fix(app-router): apply basePath before prefetch URL validation

Mirror Next.js's createPrefetchURL exactly by passing the href through
withBasePath before parsing. With a non-empty basePath, an otherwise
broken-looking href can be parseable (e.g. `new URL("/app///", origin)`
succeeds while `new URL("///", origin)` throws), so applying basePath
first matches upstream behaviour for basePath-configured apps.
2026-05-26 09:06:35 +00:00
James Anderson 601a394582 ci: address zizmor security audit findings (#1577)
* ci: address zizmor security audit findings

Run `zizmor .` and address all findings:

- Pin actions to commit SHAs with version comments (unpinned-uses).
  Dependabot picks up the version comment for future bumps.
- Move `contents: write` / `id-token: write` to the publish job in
  publish.yml; set `permissions: {}` at the workflow level
  (excessive-permissions).
- Add `permissions: {}` to deploy-examples.yml workflow + scope
  smoke-test job to `contents: read` (excessive-permissions).
- Move `${{ ... }}` expansions inside run blocks into `env:` vars
  in publish.yml / benchmarks.yml / nextjs-deploy-suite.yml
  (template-injection).
- Set `persist-credentials: false` on all checkouts that don't push;
  publish.yml needs them for `git push origin v$VERSION`, so it
  carries an inline `zizmor: ignore[artipacked]` instead.
- Keep dependabot's 1-day cooldown but annotate with `zizmor: ignore`
  (intentional fast cooldown).

`zizmor .` is now clean.

* ci: add zizmor GitHub Actions workflow

Run zizmor on every push to main and every PR via the upstream
`zizmorcore/zizmor-action`. Findings surface in the GitHub Code
Scanning UI (Security → Code scanning alerts).

* ci(publish): explain zizmor artipacked ignore
2026-05-22 19:41:30 +01:00
Nathan Nguyen 62454ef447 fix: private use-cache request data (#1445)
* fix use cache private request data

* fix private cache nesting guard
2026-05-22 17:21:43 +01:00
Jim Geurts 3d5fc8dd84 fix(app-router): hoist inline beforeInteractive scripts above resource hints (#1558)
* fix(app-router): hoist inline beforeInteractive scripts above resource hints (#1557)

React Fizz puts every stylesheet, modulepreload, and preload link emitted via React Float above user-rendered <script> children of <head>, so an inline `<Script strategy="beforeInteractive">` placed first in the root layout lands near the bottom of head instead — well after styles begin to parse. This breaks the standard no-flash dark-mode pattern.

Capture inline beforeInteractive content via a new BeforeInteractiveContext that the App Router SSR pipeline wraps the rendered tree with. The Script shim registers the escaped innerHTML + nonce + attributes and returns null from render. The stream transform splices the captured tag immediately after `<head ...>` opens — before any chunk that contains React-emitted resource hints. On the client the shim suppresses the inline render when the App Router runtime is present (detected via the new hasAppNavigationRuntimeBootstrap helper) so hydration matches.

External-src and non-App-Router rendering paths are unchanged: external scripts still preload + render in source order, and Pages Router falls through to the existing inline render.

* fix: address PR feedback

- e2e spec now reads `self.__vinextThemeInitRan` (matches the fixture's actual marker; the prior `documentElement.dataset` assertion would always have been undefined)
- gate `renderBeforeInteractiveInlineScripts` on a `/^[a-zA-Z][\w.-]*$/` attribute-name regex so unknown developer-supplied prop keys can't break out of the unquoted name position
- drop unused `BeforeInteractiveProvider` export — the SSR entry uses `BeforeInteractiveContext.Provider` directly
- correct the fixture layout docstring to describe the actual `self.__vinextThemeInitRan` marker and explain why we avoid `<html>` dataset attributes here
- consolidate the duplicate CSP-nonce middleware blocks for `/revalidate-test` and `/beforeinteractive-head-ordering`

* docs/test: address PR nit feedback

- correct the createTickBufferedTransform jsdoc: only `injectHTML` falls back to end-of-stream; `injectAfterHeadOpenHTML` is silently dropped when `<head ...>` is never found in a discoverable chunk (covered by the existing unit test)
- drop the dead pre-request scaffolding from the CSP-nonce test; the meaningful assertions live in the second request, so the first one was just `void html` noise
2026-05-22 16:12:08 +01:00
James Anderson 40d23de157 fix(next-request): throw canonical relative URL error (#1560)
* fix(next-request): throw canonical relative URL error

Calling `new NextRequest('/foo')` previously bubbled up `Failed to parse
URL from /foo` because the constructor's fallback `new URL(input,
"http://localhost")` masked the real problem. Next.js's NextRequest
runs `validateURL(url)` at the top of its constructor and throws an
error containing `Please use only absolute URLs`. Middleware tests
that assert on this message text now match Next.js.

Closes #1521

* fix(next-request): collapse inline URL validation to validateURL helper

Address bot review nit: the 13-line inline URL validation duplicated the
existing `validateURL` helper at line 215. Replace it with a call to the
helper. Bonus: relative URL rejection now also picks up the
`assertSafeNavigationUrl` guard against `javascript:`/`data:`/`vbscript:`
URIs (defense-in-depth).
2026-05-22 16:07:20 +01:00
James Anderson 97c11cdc37 fix(pages-router): rename data-vinext-head to data-next-head (#1559)
Pages Router <Head> now emits the canonical `data-next-head=""`
attribute matching Next.js, rather than the vinext-specific
`data-vinext-head="true"`. This unblocks `_document` head lookups
in the deploy suite.
2026-05-22 15:54:59 +01:00
James Anderson e121d42ef0 fix(middleware): warn when legacy middleware filename is detected (#1561)
Next.js 16 renamed `middleware.{js,ts}` to `proxy.{js,ts}` and emits a
canonical deprecation warning when the legacy filename is detected. vinext
was emitting a custom message that didn't match the wording the Next.js
deprecation-warnings and app-middleware e2e suites assert on.

Update `findMiddlewareFile` to log the exact text Next.js uses (from
`packages/next/src/build/index.ts` adjacent to `MIDDLEWARE_FILENAME`):

  The "middleware" file convention is deprecated. Please use "proxy" instead.
  ...migration codemod and docs link...

The warning still fires only when `middleware.{js,ts}` is found (after
checking `proxy.{js,ts}` first) so behavior is unchanged for projects on
the new convention. Existing test in tests/shims.test.ts is tightened to
assert the canonical wording.

Closes #1455
2026-05-22 15:54:42 +01:00
James Anderson d6bf6413e9 feat(pages-router): consume _next/data JSON endpoint from the client (#1412)
* feat(pages-router): consume _next/data JSON endpoint from the client

The server-side `/_next/data/<buildId>/<page>.json` endpoint landed in
#1384, but the vinext client was still fetching the full HTML page on
every navigation and regex-extracting `__NEXT_DATA__` from it. This
wires the client to actually use the JSON endpoint, matching what
Next.js does and avoiding the HTML round trip.

## Client navigation (shims/router.ts)

`navigateClient` is split into two paths:

- `navigateClientData` (new): when `window.__VINEXT_PAGE_LOADERS__` has
  a registered code-split loader for the target route AND we have a
  buildId, fetch `/_next/data/<buildId>/<page>.json` with
  `Accept: application/json` + `x-nextjs-data: 1`. On 200 parse the
  envelope, look up the page module via the in-memory loader map (Vite
  has already split each page into its own chunk), and re-render.
- `navigateClientHtml`: the previous HTML-fetch + regex-extract logic,
  unchanged. Used in dev (where the inline hydration script does not
  populate the loader map) and as a fallback for any route not in the
  client loader map.

Failure modes — 404, 5xx, network error, malformed JSON, soft
redirect via `x-nextjs-redirect`, missing loader — all queue a hard
navigation (`window.location.href = url`). This is the deploy-skew
safety net: when the server's buildId has rotated, the data endpoint
returns 404 and the client lands on the new build via a full document
load.

## Code-split loader manifest (entries/pages-client-entry.ts)

Vite already code-splits each page into its own chunk via the dynamic
imports in `pageLoaders`. The generated client entry now exposes that
map on `window.__VINEXT_PAGE_LOADERS__` (route pattern → loader thunk),
plus `window.__VINEXT_PAGE_PATTERNS__` (ordered list) and
`window.__VINEXT_APP_LOADER__` (the `_app` thunk, when present). This
replaces Next.js' `_buildManifest.js` indirection — vinext already
generates the equivalent at build time, we just had to expose it.

## Data URL construction (shims/internal/pages-data-url.ts)

New pure helper module mirroring `getAssetPathFromRoute` +
`getDataHref` from Next.js. Handles:

- `/`            → `/_next/data/<id>/index.json`
- `/about`       → `/_next/data/<id>/about.json`
- `/index`       → `/_next/data/<id>/index/index.json` (round-trip)
- `/blog/foo`    → `/_next/data/<id>/blog/foo.json`

Also exports `matchPagesPattern` which uses the existing
`matchRoutePattern` to map a URL pathname to a registered route
pattern.

Round-trip tests verify the server's `parseNextDataPathname` agrees
with the client's `buildPagesDataPath` for every shape, and surfaced a
latent server bug: `/_next/data/<id>/index/foo.json` was parsing back
to `/index/foo` instead of `/foo`. Fixed in `pages-data-route.ts` so
the round-trip is now lossless.

## Prefetch (shims/link.tsx + shims/router.ts)

`<Link>` prefetch and `Router.prefetch()` now prefer the data path:

- Inject `<link rel="prefetch" as="fetch" crossorigin href={dataUrl}>`
  for the JSON, matching the actual navigation's request shape.
- Invoke the loader thunk to warm the chunk in parallel. Vite's
  `import()` caches the result, so the eventual click is a double
  cache hit.

When no loader matches (dev, unmapped route), fall back to the legacy
`<link rel="prefetch" as="document">` so the browser at least preloads
the HTML.

Notably this also fixes a long-standing prefetch bug: the previous
Pages branch of `link.tsx`'s prefetch check
(`__NEXT_DATA__.__vinext?.pageModuleUrl`) was only populated by the
dev server's inline hydration script, so prefetch in production was a
no-op.

## Tests

- `tests/pages-data-url.test.ts` (new): 27 tests covering URL
  construction and the client/server round trip.
- `tests/shims.test.ts` (+8): JSON-path navigation success, 404
  hard-reload (build-skew), `x-nextjs-redirect` soft-redirect handling,
  HTML fallback when no loader matches, dynamic param extraction,
  malformed JSON hard-reload, prefetch JSON path, prefetch fallback.
- Ports from Next.js: `getDataHref` in
  `.nextjs-ref/packages/next/src/client/page-loader.ts:154`,
  `fetchNextData` in
  `.nextjs-ref/packages/next/src/shared/lib/router/router.ts:498`,
  `getAssetPathFromRoute` in
  `.nextjs-ref/packages/next/src/shared/lib/router/utils/get-asset-path-from-route.ts`.

Verified: 1393 vitest tests pass across shims, pages-router,
link/navigation, prefetch, data-route, data-url, entry-templates, and
routing.

Closes #1330 follow-up.

* fix(pages-router): drop unused export on PagesPatternMatch

Knip flagged the exported `PagesPatternMatch` type as unused. It is only
referenced as the return type of `matchPagesPattern` in the same file, so
narrow it to a local type.

* fix(pages-router): merge search params into __NEXT_DATA__.query on data-path nav

bigbonk review found that navigateClientData was synthesising
__NEXT_DATA__.query from dynamic route params only, missing the URL search
params. Code reading window.__NEXT_DATA__.query directly (a public Next.js
surface) would see incomplete data after a JSON navigation, even though
useRouter().query happened to still work because getPathnameAndQuery()
re-reads window.location.search.

Parse target.search and merge it into query alongside the route params,
with route params winning on key collision — same merge order as
getPathnameAndQuery() and Next.js itself.

Also fix a misleading comment in pages-client-entry.ts: the patterns list
is already sorted by pagesRouter()/compareRoutes (static → dynamic →
catch-all), not by the router at runtime.

* test(pages-router): e2e regression guard for JSON nav path

Adds a Playwright assertion that a Link click on a hydrated Pages Router
app on Cloudflare Workers fires a request to /_next/data/<buildId>/<page>.json
and does NOT trigger a full HTML reload. If the build pipeline ever stops
exposing __VINEXT_PAGE_LOADERS__ (or the loader map mis-keys), every other
navigation test still passes — they all assert the URL changed and the new
page rendered, neither of which fails when navigateClient silently falls
back to the HTML path. This test fails loudly in that scenario.

* test(pages-router): use hydration timestamp instead of resource-type sniffing

The original assertion 'no document-resource request to /ssr' caught false
positives: <link rel="prefetch" as="document"> can fire on initial paint
for unrelated reasons, and Chromium does not always classify prefetch hints
in a stable way. Swap to checking that __VINEXT_HYDRATED_AT survives the
click — a document reload would reset it, an in-place re-render preserves it.
Combined with the data-URL network assertion, this remains a tight guard on
the JSON nav path without the flake.

* test(pages-router): use window sentinel instead of timestamp comparison

Timestamps are timing-related signals; swap to a window-scoped sentinel
that a document reload would wipe and an in-place re-render preserves.
Same guarantee, no flake.

* fix(pages-router): handle _next/data requests in Cloudflare Workers example

The Node prod-server normalizes /_next/data/<buildId>/<page>.json before
middleware and flags it as a data request so renderPage emits the JSON
envelope. The Cloudflare worker example never did — data requests hit
matchPageRoute which can't match /_next/data/... and returned 404, so
navigateClientData saw the 404 and triggered a hard navigation. The
client-side JSON nav path was effectively dead on Workers.

Mirror the prod-server normalization in the worker: detect the data path
before middleware, validate the buildId, strip it to the page path, and
pass { isDataReq: true } through to renderPage.

Export ./server/pages-data-route from the vinext package so user-written
workers can import the helpers.

* Revert "fix(pages-router): handle _next/data requests in Cloudflare Workers example"

This reverts commit 719575b9ae.

* fix(pages-router): normalize _next/data requests inside the server entry

The Node prod-server normalized /_next/data/<buildId>/<page>.json before
middleware so renderPage could emit the JSON envelope. Cloudflare Workers
go through a user-written worker entry that doesn't (and shouldn't) know
about the data endpoint protocol, so data requests were reaching
matchPageRoute as /_next/data/... URLs, returning 404, and the client
fell back to a hard navigation — defeating the whole JSON-path
optimisation on Workers.

Move the detection into the generated server entry so it works for any
caller. runMiddleware now normalizes the URL for middleware matcher and
signals via result.rewriteUrl, and renderPage auto-detects data requests
from request.url so the JSON envelope fires whether or not middleware
rewrote the URL. Mismatched buildId short-circuits to a JSON 404 in
both paths so stale clients hard-navigate onto the new build.

* chore: re-trigger CI

* refactor(pages-router): share data-nav target resolver between Link and Router

Extract `resolvePagesDataNavigationTarget` + `prefetchPagesData` into
`shims/internal/pages-data-target.ts` so the Link prefetch path and the
Router (`navigateClient` + `Router.prefetch`) share one decision helper
instead of maintaining two structurally identical copies.

Also strip the locale prefix before matching `__VINEXT_PAGE_PATTERNS__`
so locale transitions (e.g. `/fr/about`) hit the JSON data fast path
instead of falling through to the slower HTML round-trip — route
patterns are locale-unaware (`/about`), but the browser URL for a
locale-prefixed page includes the locale. The data URL itself keeps
the locale prefix because the server uses it to pick locale-specific
gSSP data.

When the data path is used and the URL implies a different locale than
the current `__NEXT_DATA__.locale`, refresh the locale fields on the
synthesised `__NEXT_DATA__` (the JSON envelope itself only carries
`pageProps`, so the locale must be derived from the URL). Without this,
`useRouter().locale` stays stale after a client-side locale transition.

* chore: re-trigger CI
2026-05-22 15:46:38 +01:00