* perf(ssr): isolate open redirect detection
* perf(ssr): isolate stream cleanup runtime
* Apply suggestions from code review
Co-authored-by: James Anderson <james@eli.cx>
* remove comments
---------
Co-authored-by: James Anderson <james@eli.cx>
* chore: version packages
* Apply suggestion from @james-elicx
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: James Anderson <james@eli.cx>
The font plugins inject an extra option into the user's `next/font` options
object by string-splicing before the closing `}`. They chose the separator by
testing whether the text before `}` ends with `,`, but that test ran on the
raw source, so a trailing comment hid a real trailing comma:
`Inter({ weight: "400", /* x */ })` produced `{ weight: "400", /* x */, _vinext: … }`
— a double comma and a syntax error that broke the build.
Add a shared, string-aware `hasTrailingComma`/`lastSignificantChar` helper
(`utils/has-trailing-comma.ts`) that forward-scans tracking string and comment
state, and use it at both font injection sites and in dynamic-preload-metadata
(which previously kept a private comment-stripper). A stateful scan — rather
than stripping comments with a regex — is required so a `//` inside a string
literal (e.g. a URL) can never be mistaken for a comment and corrupt the
trailing-comma decision.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Select the CSP nonce directive via startsWith("script-src") /
startsWith("default-src"), matching Next.js get-script-nonce-from-header.
Previously the exact-match predicate skipped script-src-elem/script-src-attr
directives, so a CSP declaring the nonce only on script-src-elem fell through
to default-src and SSR scripts could be emitted without the expected nonce
and blocked by the browser CSP.
Closes#1989
## Summary
- Register `beforeInteractive` Scripts with `src` through
BeforeInteractiveContext so they are hoisted into <head> ahead of
interactive, exactly like the inline form (previously they bypassed the
registry and rendered in source order, after React Float's head content).
- Suppress the client React render for both inline AND src beforeInteractive
Scripts in the App Router so the hoisted tag is never duplicated.
- Tag every hoisted script with Next.js's `data-nscript="beforeInteractive"`
marker.
- Map `crossOrigin` -> `crossorigin` and `referrerPolicy` -> `referrerpolicy`
on hoisted scripts (parity with Next.js set-attributes-from-props).
- Extract `renderBeforeInteractiveInlineScripts` into a dedicated, pure module
(before-interactive-head.ts) so the emit path is unit-testable.
## Root Cause
The beforeInteractive SSR branch computed `inlineContent = src ? null : ...`
and only registered when `inlineContent !== null`, so any Script with a `src`
was never captured by the registry and never spliced into <head>; it stayed
where the user wrote it, behind React Float's hoisted resource hints, defeating
the before-interactive guarantee. Hoisting src scripts then requires the client
to stop rendering them through React (the React-created <script> never executes
and would only add a duplicate DOM node), so the client dedupe condition is
aligned with the SSR registration condition. The hoisted output also lacked
Next.js's `data-nscript` marker, and REACT_TO_HTML_ATTR omitted
crossOrigin/referrerPolicy, so those attribute names did not match Next.js's
lowercased output.
## References
- Fixes#2016
- Next.js: .nextjs-ref/packages/next/src/client/script.tsx (data-nscript,
src/inline registry parity), set-attributes-from-props.ts (lowercased attrs)
- Ported: test/e2e/app-dir/script-before-interactive/script-before-interactive.test.ts
## Verification
- CI=true pnpm test tests/script.test.ts tests/script-head-ordering.test.ts
tests/nextjs-compat/script-nonce.test.ts tests/shims.test.ts -> 1139 passed
- CI=true npx vp check on all changed files -> pass
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Summary
- Resolve the `NEXT_LOCALE` cookie case-insensitively and return the
canonical configured locale (e.g. `NEXT_LOCALE=EN-US` -> `en-US`).
- Add unit tests covering uppercase/mixed-case cookies, exact-case, and
non-configured values.
## Root Cause
`parseCookieLocaleFromHeader` matched the cookie value against
`i18nConfig.locales` with a case-sensitive `Array.includes`. A cookie like
`NEXT_LOCALE=EN-US` therefore failed to match a configured `en-US`, so the
sticky locale was silently dropped and locale detection fell through to the
default. Next.js's `getLocaleFromCookie` lowercases the cookie value and
finds the configured locale case-insensitively, returning the canonical
form; vinext now mirrors that.
## References
- Next.js: packages/next/src/shared/lib/i18n/get-locale-redirect.ts
(`getLocaleFromCookie`).
- Next.js test: test/e2e/i18n-support/shared.ts (`addDefaultLocaleCookie`
sets `NEXT_LOCALE=EN-US` "to ensure it's case-insensitive").
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Twitter metadata renderer emitted `twitter:app:*` tags whenever a
`twitter.app` object was present and `twitter:player:*` tags whenever
`twitter.players` was present, regardless of the resolved card type. It also
emitted `twitter:app:id`/`twitter:app:url` for falsy values (`0`, `""`).
Next.js gates these blocks on the resolved card: `if (card === 'app')` and
`if (card === 'player')`, and uses truthy checks for app id/url. Mirror that:
gate the app block on `card === 'app'`, the player block on `card === 'player'`,
and skip falsy app ids/urls. The card is already defaulted in
postProcessMetadata (summary_large_image / summary), matching Next.js.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Summary
- Handle the `extends` array form (TypeScript 5.0+) when resolving
tsconfig/jsconfig path aliases and `baseUrl` for `next.config.ts` loading.
- Normalize `extends` (string | string[] | undefined) into an ordered list at
both loader sites; iterate parents so later entries override earlier ones.
- Add array-extends regression tests (single parent, multi-parent precedence,
child override).
## Root Cause
Both tsconfig loaders only handled the string form of `extends`
(`typeof parsed.extends === "string"`). When `extends` was an array
(`["./a.json", "./b.json"]`), it was silently dropped, so inherited
`compilerOptions.paths`/`baseUrl` were lost and path-aliased imports in
`next.config.ts` failed to resolve. Next.js normalizes via
`Array.isArray(config.extends) ? config.extends : [config.extends]` and merges
parents in order; this change mirrors that behavior at both vinext sites
(packages/vinext/src/config/tsconfig-paths.ts and packages/vinext/src/index.ts).
## References
- Fixes#1974
- Next.js parity: packages/next/src/build/next-config-ts/transpile-config.ts (loadTsConfigFile)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The App Router client carried an OperationToken on every navigation
decision but only read its `lane` field. The authority the token was
meant to own lived inline elsewhere: the commit-staleness gate was a raw
boolean (startedNavigationId !== activeNavigationId or a visible-commit
version mismatch) evaluated before the token was even built, and cache
reuse was gated separately. The token also lacked the lifecycle
navigation id, so it could not answer "does this result belong to the
active navigation?" at all — its operationId is a per-render counter.
Promote OperationToken to the single proof-of-eligibility object. A new
operation-token.ts module owns the token type and verifyOperationToken,
a pure verifier over four dimensions (active-navigation, visible-commit,
graph-version, cache-variant) that returns a branded VerifiedOperationToken
on success. The token verifies; ApprovedVisibleCommit still mutates — the
two stay separate.
- Add navigationId to the token (sourced from startedNavigationId) so it
can carry the active-navigation authority.
- Route the commit-staleness gate through verifyOperationTokenForCommit
and delete the inline boolean. The staleOperation skip + trace are
byte-identical; planPendingRootBoundaryFlightResponse now requires the
VerifiedOperationToken brand, making "verify before plan" a compile-time
guarantee.
- Share the authority with cache reuse: planFlightResponseArrived gates an
accepted cache-entry reuse decision through verifyOperationTokenForCacheReuse,
hard-navigating (cacheReuseTokenRejected) when the proof's graph version no
longer matches the installed route graph. Behavior-preserving today (the
token's graphVersion is minted from the same manifest); a real guard once
cross-document and segment reuse can diverge (PR 6/7).
Absence is not permission: a checked dimension tolerates an absent
authority fact, but a *required* one fails closed, and require implies
evaluation — the contract segment-cache/BFCache writes need in PR 6/7.
Tests: operation-token.test.ts covers every verdict dimension, the brand,
the absent/mismatch split, and require⇒evaluate; navigation-planner and
app-browser-entry suites stay green, proving the gate refactor preserved
behavior; two new planner tests cover the cache-reuse graph-version seam.
* fix(pages): emit canonical next data script
* test(pages): expect canonical next data markup
* fix(pages): initialize canonical next data before routing
* test(pages): parse canonical next data markup
* fix(pages): preserve router bootstrap ordering
* test(pages): expect readiness bootstrap import
* fix(pages): guard readiness bootstrap without next data
* fix(pages): honor basePath in dev module loaders
* fix(pages): preserve basePath in ISR loaders
* style(pages): format dev module loader changes
* fix(pages): preserve dynamic route module paths
* test(pages): run basePath dev coverage in CI
* chore: version packages
* Apply suggestion from @james-elicx
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: James Anderson <james@eli.cx>
* fix(image): allow any quality 1-100 when images.qualities is unset
The image optimization endpoint returned 400 for any quality other than
75 (e.g. `<Image quality={90}>`) because vinext defaulted the allowed
quality list to `[75]` whenever `images.qualities` was not configured.
Next.js only enforces a quality allowlist when `images.qualities` is set;
when unset it permits any integer quality from 1-100. This aligns vinext
with that behaviour: `parseImageParams`/`resolveDevImageRedirect` now take
an optional allowlist and only restrict quality when one is provided, and
the build/runtime plumbing emits `null` (not `[75]`) when unset so the
"allow all" semantics survive into the deploy worker.
Repro: GET /_next/image?url=%2Fshop%2Ftablet.png&w=750&q=90 -> 400.
Fixes the app-router-playground image optimization failure.
* test(image): update App Router blur-exception tests for quality parity
With `images.qualities` unset, any quality 1-100 is now permitted (Next.js
parity), so q=70 is a normal quality rather than a rejected one. Pin the
blur-quality-exception tests to `qualities: [75]` so they still exercise the
dev-only exception, and add a case asserting q=70 is allowed in production
when qualities is unset.
* fix(router): honor hybrid pages route priority
* fix(router): share hybrid owner decision with client navigation
PR #1997 fixed the server-side route ownership for direct document loads,
but the same invariant broke for client-side soft navigations and the
matching prefetch path:
- navigateClientSide() always delegated to the App runtime's RSC fetch,
even when the Pages route had higher priority. renderPagesFallback()
short-circuits RSC requests with null, so the App catch-all won.
- prefetchUrl() prefetched an RSC stream for any URL that matched an App
route, again ignoring Pages ownership.
- The Pages entry was loaded on every hybrid request, even when a static
App route had already matched (Pages can never win in that case).
Expose the Pages route manifest on the client via a new
__VINEXT_PAGES_LINK_PREFETCH_ROUTES__ window global emitted by both the
App and Pages browser entries. The link shim consults a shared
resolveHybridClientRouteOwner helper that mirrors the server-side
pagesRouteHasPriorityOverAppRoute comparison. When Pages owns the URL,
the click handler issues a window.location navigation and the prefetch
path returns early.
Gate the renderPagesFallback call behind a static-App-route check in
handleAppRscRequest: when a static App route matches, the bridge
cannot win, so skip the eager Pages entry load. Centralise the
comparison in a new resolveHybridRouteOwner helper so server and
client reach the same answer for the same (URL, route pair).
Adds the missing client-navigation coverage to the use-params e2e
fixture (Link from /app/ to /pages-dir/foobar) and unit tests for
the shared owner decision.
* fix(router): mirror server hybrid owner decision on the client
PR review flagged two split-brain bugs in the previous hybrid
ownership fix: a hand-copied client comparator and a Link-only
ownership gate that left programmatic App Router navigations on
the wrong path.
The hand-copied routePrecedence in hybrid-client-route-owner.ts
omitted the static-prefix reduction that lives in
routing/utils.ts#routePrecedence, and used a strict-less-than
comparison that returned App for identical dynamic patterns. The
server returns Pages for both cases (Pages providers sort ahead
of App providers, and routePrecedence subtracts 50 per static
prefix segment). The split produced a real ownership disagreement
on overlapping patterns like /_sites/:slug* (Pages) vs /:slug*
(App).
Add a shared compareHybridRoutePatterns to routing/utils.ts as
the single source of truth: the static/dynamic short-circuits plus
a sortRoutes call (which carries the static-prefix reduction and
the Pages-first equal-pattern tiebreak). The server
pagesRouteHasPriorityOverAppRoute and the client
resolveHybridClientRouteOwner both delegate to it, so they
cannot diverge. Drop the hand-copied routePrecedence entirely.
Wire the ownership check at the App navigation runtime boundary
so useRouter().push, useRouter().replace, gesturePush, and form
submits all get the same hard-nav contract as the Link click
handler. Specifically:
- navigateClientSide: after same-origin normalization, if Pages
owns the URL, hard-navigate via window.location and return
(matching the existing external-URL branch).
- _appRouter.prefetch: short-circuit RSC URL construction for
Pages-owned targets so we do not warm an unusable cache entry.
Centralise the existing two inline hard-nav branches in
navigateClientSide into a hardNavigateTo helper for clarity.
Tests:
- Direct unit tests for compareHybridRoutePatterns covering
identical-dynamic tiebreak, static-prefix dynamic overlap,
static-prefix catch-all overlap, and infix-static bonus.
- Direct unit tests for resolveHybridClientRouteOwner mirroring
the server assertions plus a basePath-stripping test.
- e2e: useRouter().push('/pages-dir/foobar') from an App page
resolves to the Pages document.
- e2e: useRouter().prefetch('/pages-dir/foobar') issues zero
RSC requests for the target URL.
* docs(router): fix stale score in static-prefix catch-all test comment
The hand-copied comparator note quoted the dynamic-segment scores
(51 / 1000) for the optional-catch-all example (/_sites/:slug*
vs /:slug*). The current optional-catch-all scoring actually
produces 1951 vs 2000. The test assertion was correct; only the
comment was stale.
* test(use-params): fix direct-load single dynamic param to use /a instead of /a/b
* fix(router): compare hybrid routes structurally
* fix(router): reject hybrid route conflicts
* fix(router): refresh hybrid route ownership
* fix(router): preserve hybrid routing lifecycle
* fix(router): recheck pages routes after rewrites
* fix(router): preserve rewritten pages queries
* fix(router): preserve rewritten route ownership
* fix(router): resolve client rewrites sequentially
* fix(router): apply rewrite phases sequentially
* fix(router): preserve rewrite params and endpoints
* fix(router): hand off endpoint navigations
* fix(router): preserve rewrite fragment params
* test(e2e): avoid hybrid fixture route conflicts
* test(pages): await async config validation
* test(hybrid): avoid duplicate page fixtures
* chore(router): clarify hybrid priority semantics
* refactor(router): remove duplicate app route matcher
* docs(router): clarify client hybrid comparator
---------
Co-authored-by: James <james@eli.cx>