Commit Graph

1163 Commits

Author SHA1 Message Date
James Anderson 4aaae3d23e fix(build): pin cssTarget so esbuild preserves max-width media-query syntax (#1310)
* fix(build): pin cssTarget so esbuild preserves `max-width` media-query syntax

Vite's default `build.cssTarget` follows `build.target` (modern evergreens),
which lets esbuild's CSS minifier rewrite `@media (max-width: 768px)` to
the Media Queries Level 4 range syntax `@media (width <= 768px)`. Both
forms are semantically equivalent in modern browsers, but the rewrite is
observable to user code that inspects `cssText` on `CSSMediaRule`s and
breaks tools that pattern-match the raw query string. Next.js's CSS
pipeline (webpack + cssnano, or lightningcss only when opted in via
`experimental.useLightningcss`) preserves the original syntax by default,
so user code carried over from Next.js can break when migrating to vinext.

Pin `build.cssTarget` to ["chrome87", "edge88", "firefox78", "safari15"],
which predate Safari 16.4 — the first browser to support MQ Level 4 range
syntax. esbuild checks the configured target and skips lowering when any
target is too old. Only literal media-query syntax is affected; matching
behavior at runtime is unchanged.

Mirrors the Next.js fixture
test/e2e/app-dir/css-media-query/css-media-query.test.ts which asserts
`cssText` preserves `max-width: 768px`.

Tests:
- New tests/css-media-query-target.test.ts builds a minimal fixture
  and asserts the produced CSS asset contains `max-width: 768px` and
  does not contain `width<=768px`. Fails without this change; passes
  with it.
- All existing CSS / SCSS / build-optimization / nextjs-compat app-css
  tests continue to pass.

* refactor(build): narrow cssTarget to only pin Safari low

Address review feedback on #1310. Only Safari needs to be pinned to a
version that predates MQ Level 4 range syntax (Safari 16.4) so esbuild
skips the @media (max-width: …) → @media (width <= …) rewrite. The other
targets were pinned far older than necessary, which caused esbuild to
downlevel unrelated modern CSS features (`:is()`, CSS Nesting, `lab()`,
`oklch()`, gradient interpolation, etc.).

Bumps chrome/edge/firefox targets to ~2023 baselines while keeping
safari15 to retain the MQ rewrite fix.
2026-05-18 16:59:56 +01:00
Divanshu Chauhan (divkix) 54e271ddc9 fix(cache-runtime): attach inner "use cache" call site as cause of nested-dynamic error (#1289)
* fix(cache-runtime): attach inner "use cache" call site as cause of nested-dynamic error

Ports Next.js PR #93707 to vinext. When a nested "use cache" propagates a
dynamic cache life (revalidate: 0 or expire < 5 minutes) to a parent without an
explicit cacheLife(), the error now attaches the inner invocation as .

Changes:
- Add NestedDynamicUseCacheError class (captured eagerly at inner call site)
- Track hasExplicitRevalidate / hasExplicitExpire on CacheContext
- Propagate dynamicNestedCacheError via ??= (first child wins)
- Throw with  when outer lacks explicit cacheLife for the relevant field
- Update error messages to consistently backtick "use cache"
- Add unit tests covering revalidate:0, short expire, explicit outer, first-child-wins

Refs: #1194
Ported from: https://github.com/vercel/next.js/pull/93707

* docs(cache-runtime): document nested-dynamic error propagation invariants

Addresses bigbonk review feedback on #1289:

- Guard `Error.captureStackTrace` with a typeof check for non-V8 runtime
  robustness. The constructor already captures a stack; this call only
  trims the constructor frame.
- Document that propagating the inner's resolved life into the parent's
  `lifeConfigs` is load-bearing: minimum-wins must reflect the inner's
  dynamic values for the outer's nested-dynamic detection to fire, and
  the outer's explicit `cacheLife()` correctly suppresses the throw by
  pushing the resolved values above the dynamic thresholds. Matches
  Next.js `propagateCacheLifeAndTagsToRevalidateStore`.
- Clarify that `hasExplicitRevalidate` / `hasExplicitExpire` flags
  really mean "cacheLife() was called and the resolved config includes
  this field", not "the user explicitly passed this field" — matches
  upstream which tracks the flag at the work unit store level.
- Strengthen the first-child-wins test to assert the outer error
  message contains "revalidate" (not "expire"), rigorously verifying
  that `dynamicNestedCacheError ??=` preserves the first dynamic
  child's eager error as the cause.

* fix(cache-runtime): gate nested-dynamic throw on prerender/dev to match Next.js

Addresses bigbonk review feedback on #1289 (round 2):

- Previously the nested-dynamic-cache error threw unconditionally, even
  during normal production dynamic SSR. The error messages say
  "not allowed during prerendering", which is misleading outside the
  prerender phase. Next.js gates the equivalent check on the work unit
  type being `prerender` (or `request` in development) — see
  use-cache-wrapper.ts cases 'prerender'/'request' at the read site.
- Gate the throw on `process.env.VINEXT_PRERENDER === "1"` (set by
  build/prerender.ts during the prerender phase) or NODE_ENV ===
  "development". Production runtime SSR no longer throws; the outer
  function still executes and its result simply isn't cached
  (minimum-wins resolves its effective revalidate to 0).
- Add a regression test verifying no-throw behavior under production
  runtime conditions (no VINEXT_PRERENDER, NODE_ENV=production).
- Document caller paths (shared MISS, private, dev) on
  `runCachedFunctionWithContext` — all three reach this helper and
  propagate lifeConfigs / dynamicNestedCacheError up to the parent,
  matching Next.js's `propagateCacheEntryMetadata`. Request-scoped
  recording uses minimum-wins so inner-vs-outer order doesn't matter.
- Add a test documenting the potentially surprising minimum-wins
  semantics: with `cacheLife({ revalidate: 60 })` on the outer and
  `cacheLife({ revalidate: 0 })` on the inner, the throw is suppressed
  but the outer's effective revalidate resolves to 0, so the outer's
  result isn't cached and the function executes on every call. Matches
  Next.js `propagateCacheLifeAndTagsToRevalidateStore`.
- Wrap prerender-gated tests in a `withPrerenderFlag` helper that
  sets/restores VINEXT_PRERENDER around each test.

* fix(cache-runtime): make nested-dynamic error message context-aware; pin dev/prod gate

Addresses bigbonk review feedback on #1289 (round 3):

- Make the nested-dynamic error messages context-aware: emit "in
  development" when NODE_ENV=development, "during prerendering"
  otherwise. The previous unconditional "during prerendering" wording
  was misleading when the gate fired in dev SSR. Done via two small
  message-builder functions instead of constants.
- Add a dev-mode test ("does throw in development mode even without
  VINEXT_PRERENDER set") that pins down the other side of the gate.
  Without this, Vitest's default NODE_ENV=test would mask a regression
  that broke the gate to e.g. `!== "production"` instead of
  `=== "development"`. Together with the existing production test,
  both branches of the gate are now covered.
- Document the sequencing invariant between the inner's
  `parentCtx.lifeConfigs.push(effectiveLife)` and the outer's
  `resolveCacheLife(ctx.lifeConfigs)`. The correctness depends on the
  inner's await completing (including its post-execution propagation)
  before the outer's resolveCacheLife runs. A future refactor away from
  `await` (e.g. Promise.all with short-circuit) would silently break
  this and produce stale minimum-wins results.
- Document the eager-Error allocation overhead trade-off: matches
  Next.js (captured eagerly so `cause` points at the original call
  site), with the lazy-capture optimization noted for future profiling.

Verified: tests/shims.test.ts (918 tests), cache + ISR + fetch-cache
suites, and full `vp check` all clean.

* docs(cache-runtime): clarify gate priority, effectiveLife semantics, and per-field flag note

Addresses bigbonk review feedback on #1289 (round 4):

- Prioritize VINEXT_PRERENDER over NODE_ENV=development in
  nestedCacheContextPhrase. If the prerender flag is set, the user is
  prerendering regardless of NODE_ENV (e.g. a dev-config prerender),
  so "during prerendering" is the accurate phrase.
- Document the semantic difference between checking the outer's merged
  effectiveLife.revalidate vs the inner's entry metadata (as Next.js
  does). Behavior is observably equivalent in all cases due to the
  hasExplicit* guards; we use effectiveLife because vinext doesn't
  model a CacheResultMetadata type.
- Extend the hasExplicit* comment in cache.ts to clarify that the
  `!== undefined` checks are effectively unconditional (default profile
  always provides both fields). They remain as defensive guards; per-
  field suppression would require inspecting the raw user input and
  would diverge from Next.js semantics, so it's noted as a deliberate
  design change rather than an incidental one.

Bonk's remaining comments are informational:
- Comment 2 ("lifeConfigs.push is a behavioral change"): the PR
  description already calls this out; no code change.
- Comment 4 ("NODE_ENV=test gate coverage gap"): explicitly flagged as
  not blocking; the dev + prod test pair already pins both branches.

Verified: tests/shims.test.ts (918 tests) and full `vp check` clean.

* docs(cache-runtime): correct hasExplicit guard explanation; note parallel-call invariant

Addresses bigbonk review feedback on #1289 (round 5):

- Fix incorrect minimum-wins explanation in the throw block comment.
  The previous wording claimed minimum-wins "drops" the inner's values
  when the outer has an explicit cacheLife() — that's wrong. Minimum-
  wins takes Math.min(60, 0) = 0, so the threshold check still
  evaluates true. What actually suppresses the throw is the
  `!ctx.hasExplicitRevalidate` / `!ctx.hasExplicitExpire` guard.
  Future maintainers reading the old comment might have concluded the
  hasExplicit* guards were redundant; the corrected wording makes
  clear they are load-bearing.
- Also corrected the same misconception in the comment above the
  `parentCtx.lifeConfigs.push` block ("the outer only suppresses the
  throw when its own explicit cacheLife() pushed the effective values
  above the dynamic thresholds"). Replaced with the accurate two-step
  description: lifeConfigs.push makes the threshold checks possible;
  hasExplicit* guards independently decide suppression.
- Add a sentence to the sequencing-invariant comment noting it holds
  for both sequential (`await innerA(); await innerB()`) and parallel
  (`await Promise.all([innerA(), innerB()])`) inner call patterns,
  since the outer's `await cacheContextStorage.run(...)` only resolves
  after fn's returned promise settles.
- Updated PR description to call out `parentCtx.lifeConfigs.push` as a
  standalone behavioral change in production (not just during
  prerender/dev), separate from the error-cause feature. Linked to
  the minimum-wins documenting test that pins this behavior.

Verified: tests/shims.test.ts (918 tests) and `vp check` clean.

---------

Co-authored-by: James <james@eli.cx>
2026-05-18 15:55:42 +01:00
James Anderson 558cec0328 fix(shims/script): preload <script> sources during SSR via ReactDOM.preload (#1309)
Mirror Next.js's App Router behavior at
.nextjs-ref/packages/next/src/client/script.tsx:298-376: when SSR-rendering
`<Script src={...} />` with `strategy="afterInteractive"` (the default)
or `strategy="beforeInteractive"`, call `ReactDOM.preload(src, { as: 'script', ... })`
so React Float hoists `<link rel="preload" as="script" href={src} />` into
`<head>`. The script is then fetched in parallel with the streamed HTML.

Before this change, the SSR branch of the Script shim only emitted a
`<script>` tag for `beforeInteractive` and returned `null` for every other
strategy, with no preload. The matching Next.js fixture test
test/e2e/app-dir/app-esm-js/index.test.ts ("should be able to render
nextjs api in app router") asserts:

  expect($('head link[href="/test-ext.js"]').length).toBe(1)
  expect($('head link[href="/test.js"]').length).toBe(1)

…which fails today because no preload links are emitted.

The existing Image shim already uses this exact React Float pattern
(packages/vinext/src/shims/image.tsx:285-286), so the SSR pipeline
already supports Float hoisting.

Tests:
- Updated existing Script SSR tests in tests/script.test.ts and
  tests/shims.test.ts that asserted afterInteractive/default rendered
  empty — they now assert the preload <link> is emitted (no <script>
  tag), matching Next.js.
- Added tests/nextjs-compat/script-preload.test.ts that ports the
  app-esm-js fixture's preload assertions against the app-basic
  /script-nonce page (which already exercises afterInteractive and
  beforeInteractive strategies via the existing fixture).
- crossOrigin handling matches React's type contract
  ("anonymous" | "use-credentials" | undefined). React normalises
  "anonymous" to `crossorigin=""` in HTML; tests accept both forms.

All shim / script / page / document / nonce tests pass (1151 tests).
2026-05-18 15:29:07 +01:00
James Anderson 14ec0a1e62 fix(shims): treat draftMode() as non-dynamic; mark dynamic only on enable/disable (#1308)
Calling `draftMode()` and reading `isEnabled` are plain reads and must not
opt the route out of static prerendering. Only `enable()` / `disable()`
mutate state and need to be tracked as dynamic, matching Next.js's
`trackDynamicDraftMode` (see
.nextjs-ref/packages/next/src/server/request/draft-mode.ts:152-165).

Before this change, any App Router page that called `draftMode()` — even
just to check `isEnabled` — set `dynamicUsageDetected = true`, which:
- caused build-time speculative prerender to skip the route
- caused the request-time ISR HTML write to bail out

Tests for the matching Next.js fixture
(test/e2e/app-dir/draft-mode/draft-mode.test.ts, "should not generate
rand when draft mode disabled during next start") fail today because
vinext returns a fresh random value on every request instead of the
cached prerender. After this change, the page is prerendered/cached
correctly, and `enable()`/`disable()` still bail out of caching as
expected.

The existing test that enforced the old (wrong) behavior is updated to
match Next.js, and dedicated assertions are added that `enable()` and
`disable()` each mark dynamic usage.
2026-05-18 15:27:55 +01:00
James Anderson c483253b58 fix(rsc): allow JSX in .js client modules inside node_modules (#1295)
Many third-party Next.js client libraries ship plain `.js` files containing
`'use client'` (or `'use server'`) plus JSX. Next.js's SWC pipeline compiles
JSX in `.js` transparently, but vinext's `vinext:jsx-in-js` plugin
previously skipped node_modules entirely, so `@vitejs/plugin-rsc`'s
`rsc:use-client` analysis pass would feed the raw source to rolldown/oxc
with `lang: 'js'` and fail:

    [plugin rsc:use-client] node_modules/.../client/index.js
    RolldownError: Parse failed with 1 error:
    Unexpected JSX expression
    4:   return <p>hello world</p>

This affected the Next.js deploy-suite test fixtures:
  - test/e2e/app-dir/next-dist-client-esm-import
  - test/e2e/app-dir/client-module-with-package-type

Fix: extend `vinext:jsx-in-js` to also handle `.js`/`.mjs` files inside
`node_modules` *when* they begin with a React directive. We mirror
`@vitejs/plugin-rsc`'s detection by parsing the directive prologue
(skipping BOM, hashbang, comments, and earlier directives like
`'use strict'`), which keeps the transform cost essentially zero for the
vast majority of node_modules files.

Validation:
  - vp check (format / lint / type) — clean
  - vp test run tests/jsx-in-js.test.ts tests/jsx-in-js-node-modules.test.ts
    tests/intercepting-routes-build.test.ts tests/build-optimization.test.ts
  - vp test run tests/nextjs-compat/ tests/font-google-build.test.ts
    tests/build-time-classification-integration.test.ts
    tests/tsconfig-path-alias-build.test.ts (281 passing, 0 regressions)
  - vp run vinext#build (clean)
  - New tests/jsx-in-js-node-modules.test.ts reproduces the reported error
    on main and passes with the fix.
2026-05-18 14:41:38 +01:00
Nathan Nguyen 3e0ae047fc fix(build-report): parse route exports with OXC AST (#1260)
* fix(build-report): parse route exports with OXC AST

Route report classification relied on regexes and a hand-written return-object scanner. That made comments look like exports and missed valid static syntax such as no-substitution template literals and numeric separators.

The report now parses route modules with oxc-parser, reads top-level static exports from the AST, and keeps getStaticProps revalidate extraction scoped to the exported function body. Tests cover the prior false positives, supported literal forms, TypeScript generic syntax fallback, and Next.js-style export specifier handling.

* fix(cli): lazy-load build report generation

The CLI imported the route report module at startup, which also loaded the native parser dependency added for build report classification. That widened the parser and native binding path to non-build commands.

Load the build report module inside buildApp() immediately before printing the report so dev, start, check, lint, deploy, and help paths do not touch the parser-backed report code.

* perf(build-report): reuse parsed route modules

Route classification reparsed the same source for each static export query. With the TSX-to-TS fallback, a single route could invoke the parser several times before producing one report row.

Thread the parsed OXC Program through internal route-report helpers so classifier paths parse each source once while preserving the public string-based helper signatures. Also clarifies the synthetic getStaticProps wrapper path and the unsupported unary-operator branch.

* fix(build-report): use Vite parser export

Route report parsing now uses Vite's OXC-backed parser export instead of depending on oxc-parser directly. Adding the parser as a package dependency duplicated the toolchain parser and exposed native optional bindings through vinext itself.

Import parseSync and ESTree types from Vite, keep the existing parser options and AST analysis unchanged, and remove the direct package/catalog entries so the lockfile only retains transitive parser usage from dev tooling.
2026-05-18 14:39:17 +01:00
James Anderson 69c72b02e2 fix(build): emit SSR CSS assets referenced by SSR entry (#1304)
Set `build.ssrEmitAssets: true` so the SSR environment writes asset
files (CSS, etc.) that JS chunks reference. Vite defaults
`emitAssets` to `false` for environments with `consumer: "server"`,
so when the CSS plugin rewrites a server-component CSS import into
`import "<hash>.css"` and emits the CSS asset, the asset file is
subsequently stripped from the bundle by Vite's asset-cleanup hook.
The dangling import then crashes `vinext start` with
ERR_MODULE_NOT_FOUND on `dist/server/ssr/style.css`.

We set the top-level `build.ssrEmitAssets` rather than
`environments.ssr.build.emitAssets` because Vite re-applies the
top-level value during `resolveConfig` and would otherwise overwrite
any per-environment value. Mirrors `@vitejs/plugin-rsc`'s existing
`emitAssets: true` on the `rsc` environment.

Adds tests asserting both the App Router and Pages Router SSR
environments resolve to `emitAssets: true` and that every CSS import
in the SSR build output resolves to a real file on disk.
2026-05-18 12:42:18 +01:00
James Anderson 1d57274ba4 test(config): regression coverage for next.config runtime value shapes (#1297)
Adds 12 ported upstream fixtures as unit tests for loadNextConfig,
covering shapes not exercised by the four extension/import tests added
in #1296 (which validates the runner's resolve.extensions + commonjs()
configuration). These complement that coverage by asserting the
returned config value is actually surfaced for shapes that historically
build successfully but resolve to empty/undefined when normalisation
has gaps:

  - async default-function export (next-config-ts/async-function)
  - plain default object export (next-config-ts/export-default)
  - aliased default export, 'export { x as default }' (export-as-default)
  - mixed sibling extensions under both type:commonjs and type:module
    (next-config-ts/import-js-extensions-{cjs,esm})
  - nested-import propagation under both module-type flavours
    (next-config-ts/nested-imports-{cjs,esm})
  - Node API at module scope under type:module (next-config-ts/node-api-cjs)
  - bare-specifier CJS packages (next-config-ts/import-from-node-modules)

Originally this PR also introduced a hand-rolled cjsImportsPlugin that
used createRequire + globalThis stashing to interop CJS siblings. After
#1296 (cf20845) was refactored to reuse the existing vite-plugin-commonjs
dependency, that hand-rolled plugin became fully redundant — all 12
tests pass against main's commonjs()-based loader unchanged. Dropped
the implementation and kept the tests as additional regression
coverage.
2026-05-18 12:25:49 +01:00
James Anderson 8de3afdf25 fix(config): recognize next.config.mts and interop CJS imports in config loader (#1296)
* fix(config): recognize next.config.mts and interop CJS imports in config

The next.config.ts loader did not recognize next.config.mts and did not
interop CJS imports (.cjs/.cts/.js/.ts files that assign to module.exports)
brought in by the config file. As a result, fixtures from the Next.js
test/e2e/app-dir/next-config-ts-native-mts/ family failed with either
"Cannot find next.config.*" (silent null return) or "module is not
defined" inside Vite's ESM-only module runner.

Two changes:

1) The loader's local CONFIG_FILES list is extended to include
   next.config.mts (matching the canonical list in shims/constants.ts,
   which already mirrors Next.js packages/next/src/shared/lib/constants.ts)
   while keeping .cjs for backwards-compat with existing vinext users.
   deploy.ts and check.ts pick up the same extended list so their config
   scanners stay in sync.

2) A new Vite plugin (cjsImportsInteropPlugin) is installed on the inline
   runnerImport instance. It wraps .cjs/.cts files (and .js/.ts files that
   statically assign to module.exports) so that module.exports becomes the
   ESM default export, mirroring how Next.js's SWC pipeline auto-converts
   CJS imports from inside next.config.ts. The transform is scoped to the
   config-load instance only, so it never affects the user's application
   module graph.

Tests ported from .nextjs-ref/test/e2e/app-dir/next-config-ts-native-mts/
{nested-imports,import-js-extensions-cjs,import-js-extensions-esm,
import-from-node-modules}/.

* docs(config): clarify CJS exports/module.exports semantics in comment

The previous comment said "Named exports off module.exports are also
reflected", which glosses over the asymmetric behaviour that Node CJS
modules exhibit when user code does `module.exports = X` followed by
`exports.foo = Y`: the latter writes to the discarded original object
and does not show up on the final default export. Rewrite the comment to
spell out the aliasing, the reassignment break, and the
`module.exports.foo` workaround.

* refactor(config): reuse vite-plugin-commonjs for next.config sibling imports

The hand-rolled cjsImportsInteropPlugin duplicated logic that vinext
already pulls in via vite-plugin-commonjs (used globally for application
code in packages/vinext/src/index.ts:795). Drop the bespoke plugin and
install commonjs() on the inline runnerImport instance instead.

Two pieces of configuration are needed for parity with the previous
behaviour:

1. resolve.extensions explicitly includes .cjs and .cts. vite-plugin-commonjs
   keys its 'should I transform this id?' check off config.resolve.extensions,
   and Vite's default list (.mjs/.js/.mts/.ts/.jsx/.tsx/.json) omits both
   CJS-only extensions. Without this, .cjs/.cts imports flowed through
   the runner untransformed and still crashed with 'module is not defined'.

2. A filter that:
   - Returns false for the config file itself, so cjsGlobalsInjectorPlugin
     remains the sole producer of the file's module wrapper (otherwise
     both plugins inject 'module = …' and the runner trips on
     'Identifier module has already been declared').
   - Returns true for ids under node_modules, so bare-import packages
     imported by next.config.* (CJS plugin wrappers, etc.) get transformed.
     vite-plugin-commonjs defaults to skipping node_modules.

Net diff: -59 lines, all four next-config-extensions tests still pass,
no regression on tests/next-config.test.ts (108 passing) or the broader
deploy/features/shims/check suites (1637 passing in aggregate).
2026-05-18 12:07:44 +01:00
Divanshu Chauhan (divkix) aed79d46b0 test: regression tests for issue #1196 (catch-all route params with basePath + rewrites + middleware) (#1291)
* test: add regression tests for issue #1196 (catch-all route params with basePath + rewrites + middleware)

Adds integration and unit tests to verify that catch-all route params
are preserved when basePath, rewrites, and middleware are combined.

- Integration test in pages-router.test.ts: checks server-rendered
  __NEXT_DATA__ contains correct page and query for a catch-all route.
- Unit test in shims.test.ts: verifies client-side router.query stays
  intact for a catch-all page under basePath.

Both tests are ported from the Next.js test suite and target the
regression described in GitHub issue #1196.

* test: address review feedback on #1196 regression tests

- Use {.*?} regex for consistency with other __NEXT_DATA__ extractions
- Drop unused { appRouter: false } option (vinext auto-detects)
- Rename shims test to describe what it actually verifies (initial state)
- Move vi.resetModules() after window restore for clean teardown ordering

* test: add real client-nav test for #1196 catch-all router.query

Adds a Router.push() test that mocks fetch and exercises the actual
client-side navigation code path: performNavigation → pushState →
navigateClient → fetch. The mocked fetch handler captures router state
mid-navigation via a Probe component, then asserts router.query.path
reflects the destination URL segments after basePath is stripped.

This addresses the bonk review feedback that the previous tests only
verified initial hydration state and didn't exercise client navigation.
The new test also uses the previously-unused buildNavHtml `query`
parameter, addressing the dead-signature concern.

vinext's navigateClient doesn't construct _next/data/... URLs the way
Next.js does, so it doesn't have the architectural pattern that causes
#1196 in Next.js. But the route-param extraction path that ultimately
produces router.query must still strip basePath from the resolved
pathname before matching the catch-all pattern — and that's what this
test verifies.

---------

Co-authored-by: opencode <noreply@opencode.ai>
2026-05-18 11:59:53 +01:00
James Anderson 0a77ebd46f fix(router): gate intercept matching on Next-URL source pattern (#1302)
Vinext's app-rsc route matcher previously fired interception on any RSC
request whose URL matched an intercept target, regardless of the source
pathname. Next.js implements interception as a rewrite that is only
activated when the Next-URL header matches the *intercepting route*
regex (`^<interceptingRoute>(?:/.*)?$`). Without that gate, a direct
`.rsc` fetch to an intercept target would incorrectly render the modal
page instead of the underlying route, and unrelated sources could pull
in modals they have no slot for.

This change:

1. Captures the *intercepting route* URL pattern (`sourceMatchPattern`)
   alongside each `InterceptingRoute` at scan time. The pattern is the
   slot owner's path with route groups and `@slot` segments stripped,
   matching Next.js' `extractInterceptionRouteInformation`. The value
   is propagated through the App RSC manifest.

2. Adds source-pathname filtering to `createAppRscRouteMatcher`'s
   `findIntercept`: an entry only matches when the provided source
   pathname satisfies its `sourceMatchPattern` (with descendants
   allowed). When no source pathname is provided (no Next-URL /
   X-Vinext-Interception-Context header), no intercept fires.

3. Adds unit coverage in `app-rsc-route-matching.test.ts` and
   `app-route-graph.test.ts` for the new behavior, including the
   patterns used by the failing Next.js deploy suite cases
   (`parallel-routes-and-interception-basepath`,
   `parallel-routes-and-interception-from-root`, multi-catchall, and
   two-levels-up).

4. Updates `app-router.test.ts` integration tests that previously
   relied on the buggy behavior; intercept fires only when the test
   sends `X-Vinext-Interception-Context` and that context matches the
   slot's intercepting-route prefix.

Cross-references:
- https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/router/utils/interception-routes.ts
- https://github.com/vercel/next.js/blob/canary/packages/next/src/lib/generate-interception-routes-rewrites.ts

PR #1249 is orthogonal: it carries proof of interception through the
planner/lifecycle when the matcher has already fired. This change
restricts when the matcher fires in the first place.
2026-05-18 11:55:31 +01:00
James Anderson c6e8bda55e feat(app-router): support app/global-not-found.tsx (Next.js 16 parity) (#1301)
When users define app/global-not-found.tsx, route-miss 404s should render
that module standalone — it provides its own <html>/<body> and replaces
the root layout, matching the behavior of Next.js 16's
experimental.globalNotFound feature.

  https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/global-not-found
  https://github.com/vercel/next.js/blob/canary/packages/next/src/server/app-render/app-render.tsx#L495-L520

Implementation:

- Discover app/global-not-found.{tsx,ts,jsx,js} alongside the existing
  global-error scan (src/index.ts).
- Thread the path through generateRscEntry via AppRouterConfig and
  buildAppRscManifestCode so the generated entry imports it and binds
  it as globalNotFoundModule.
- Pass the module into createAppFallbackRenderer; renderHttpAccessFallback
  switches to the global-not-found document when statusCode === 404, the
  route is null (route-miss), and no boundary override was supplied.
- Add a skipLayoutWrapping flag to renderAppPageHttpAccessFallback so
  the standalone document isn't wrapped in the root layout, and so the
  RSC payload omits layout-id entries that would otherwise refer to a
  layout tree the markup doesn't contain.

Page-triggered notFound() calls keep the existing path: they still
resolve through the regular not-found.tsx boundary inside layouts —
global-not-found.tsx only takes over for route-miss 404s.

Tests:

- tests/app-fallback-renderer.test.ts adds four unit tests covering
  the new branch (route-miss with global-not-found, page-call with
  global-not-found, absent global-not-found, 403/401 unaffected).
- tests/entry-templates.test.ts adds four codegen assertions covering
  the manifest import and the generated-entry wiring in both directions
  (present and absent).
- tests/nextjs-compat/global-not-found.test.ts ports the basic Next.js
  fixture as an integration test against a dev server, asserting that
  the route-miss 404 renders global-not-found's <html> standalone with
  exactly one <html>/<body>, and that page-call notFound() does not
  use the global-not-found document.
2026-05-18 11:48:04 +01:00
James Anderson f526d59dff fix(basepath): thread basePath through metadata, manifest, and server-action redirects (#1300)
App Router file-based metadata URLs (icon, opengraph-image, twitter-image,
apple-icon, favicon, manifest) and server-action redirect targets did not
include the configured next.config basePath. Next.js prefixes basePath into
both surfaces — bakes it into static metadata route URLs at webpack build
time and threads it through the dynamic image loader's pathnamePrefix, and
applies addPathPrefix() to redirect URLs in app-render and action-handler.

Fixes for two of the five Next.js parity gaps surfaced by
test/e2e/app-dir/app-basepath/index.test.ts:

  - Metadata image URLs: opengraph-image, icon, apple-icon, twitter-image,
    favicon now emit '/<basePath>/<route>/<file>?<hash>' in the rendered
    <head>. Applies to both static (build-time resolved) and dynamic
    (per-request) metadata file routes via a shared post-resolution helper
    in applyFileBasedMetadata. Idempotent: hrefs that already start with the
    configured basePath are left untouched. External URLs (http://, https://,
    //, data:) are never prefixed.

  - Manifest URL: the <link rel="manifest"> href is prefixed with basePath
    via the same helper, so an app with basePath='/base' emits
    '/base/manifest.webmanifest' instead of '/manifest.webmanifest'.

  - Server-action redirect (progressive form POST): the Location header
    is computed from the basePath-prefixed redirect target so
    redirect('/foo') from a server action mounted at /base/... lands the
    browser at /base/foo.

  - Server-action redirect (RSC): the ACTION_REDIRECT_HEADER value is
    prefixed with basePath. The client-side handler reads this header and
    calls window.location.assign/replace verbatim, so the value must
    already be a fully basePath-prefixed pathname.

Adds a shared applyActionRedirectBasePath() helper exported from
app-server-action-execution and a prefixMetadataHrefWithBasePath() helper
in file-based-metadata. Both are idempotent, external-aware, and no-op
when basePath is unset.

Surfaces still pending follow-up (tracked separately):
  - Server-side redirect() from page renders ALREADY works via
    applyAppPageRedirectBasePath in app-page-execution. No change needed.
  - RSC fetch URLs ALREADY work because Link, router.push, and
    useRouter all funnel through toBrowserNavigationHref() which
    prepends basePath before createRscRequestUrl().
  - Pages Router redirect basePath (separate code path in
    pages-server-entry) — not covered here.

Ported test cases live in tests/nextjs-compat/basepath.test.ts.

See:
  https://github.com/vercel/next.js/blob/canary/packages/next/src/build/webpack/loaders/metadata/discover.ts
  https://github.com/vercel/next.js/blob/canary/packages/next/src/build/webpack/loaders/next-metadata-image-loader.ts
  https://github.com/vercel/next.js/blob/canary/packages/next/src/server/app-render/action-handler.ts
  https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-basepath/index.test.ts
2026-05-18 11:44:29 +01:00
Nathan Nguyen bafbcd4d75 fix(server): clear browser globals before SSR user modules (#1285)
Server-rendered user modules can currently observe a partially populated browser global when the runtime provides one. That violates Next.js SSR semantics, where server code must see browser globals as absent, and it breaks common typeof window guards during module evaluation.

Add a shared server globals bootstrap that removes window and document before user modules load, preserves the existing AsyncLocalStorage edge-runtime global, and import it ahead of generated App and Pages Router user imports. Cover the runtime behavior and generated import order with focused regression tests.
2026-05-18 11:33:14 +01:00
James Anderson 329b89e3c3 fix(navigation): populate useParams/useSearchParams under Pages Router (#1303)
Under the Pages Router, hooks from `next/navigation` returned empty
objects:

  expect(pagesParamsJSON).toEqual({ slug: 'foobar' })       // got {}
  expect(pagesSearchparamsJSON).toEqual({ q: 'pages' })     // got {}

Next.js treats `next/navigation` as a cross-router compat shim. The
Pages Router server (render.tsx) and client (client/index.tsx) wrap
each page with SearchParamsContext / PathParamsContext / PathnameContext
providers populated from the router via `adaptForSearchParams` and
`adaptForPathParams` in
.nextjs-ref/packages/next/src/shared/lib/router/adapters.tsx.

vinext drives those hooks from a module-level navigation context that is
only set for App Router renders. For Pages Router renders the context is
null, so useParams / useSearchParams / usePathname fell through to empty
fallbacks.

This change adds a Pages Router compat source published by next/router
through a Symbol.for global handle. next/navigation reads that handle
when the App Router context is null and derives:

- pathname from window.location / SSR asPath
- searchParams from the URL query string only (not merged route params)
- params from the route pattern matched against the resolved path

The publisher is exposed via globalThis[Symbol.for(...)] rather than a
direct module import so navigation.ts does not become a transitive
dependency of every next/router consumer (which would pull in the
window.history.pushState patch at module-load time and break unit tests
that only need next/router).

Ports the behaviour covered by these Next.js tests:
- test/e2e/app-dir/params-hooks-compat/index.test.ts
- test/e2e/app-dir/use-params/use-params.test.ts ("should work on pages
  router")

Adds vitest coverage at tests/pages-router.test.ts ("next/navigation
useParams returns only dynamic route params under Pages Router" and
"next/navigation useSearchParams returns only query string under
Pages Router") via a new fixture page at
tests/fixtures/pages-basic/pages/nav-compat/[slug].tsx that mirrors
.nextjs-ref/test/e2e/app-dir/params-hooks-compat/shared/params-component.js.
2026-05-18 11:32:10 +01:00
Nathan Nguyen f4a37b4ade fix(app-router): track hash-only traversal metadata (#1306)
* fix(app-router): track hash-only traversal metadata

Hash-only App Router navigations wrote null history state even though they are app-owned browser-visible entries. That kept traversal direction unknown, but it broke the metadata invariant introduced by the App Router navigation lifecycle work.

Route hash-only push and replace through a browser-entry-owned commit hook so the same traversal index allocator and metadata shape are used without fetching RSC. Also update the local traversal index during same-route hash popstate so replace-after-back uses the current entry index.

Closes #1252

* fix(app-router): avoid stale hash replace scroll state

Hash-only App Router replace now passes the effective scroll option into the browser-entry commit hook.

Default-scroll replaces strip vinext scroll restoration keys before adding traversal metadata, avoiding contradictory history entries whose URL points at a hash target but whose state restores the old scroll position. Explicit scroll:false keeps the previous scroll restoration state.

Add browser coverage for replace-after-back to #content, including the traversal back to the replaced entry.
2026-05-18 11:20:02 +01:00
Nathan Nguyen 7397d676c6 refactor(router): share interception matched-url validation (#1298)
Interception proof construction and AppElements wire parsing enforce the same matched-url path shape. Keeping the predicates separate makes future proof validation changes easy to drift while preserving the same runtime contract today.

Extract the shared path validator and use it from both callers. Proof construction still returns null for invalid input, and wire parsing still throws for invalid serialized metadata.
2026-05-18 11:16:50 +01:00
Divanshu Chauhan (divkix) 69165d4557 fix(app-page-render): surface invalid dynamic usage errors via flight in dev (#1290)
Wraps the RSC response body in dev mode to check for invalid dynamic usage errors (e.g. cookies()/headers() inside "use cache") after the stream is fully consumed. This mirrors Next.js behavior where workStore.invalidDynamicUsageError is surfaced via the Flight pipeline so errors caught by user try/catch don't silently disappear.

- Dedups against React's Flight error chunk using digest presence

- Only applies to RSC responses (client-side navigations) in dev

- HTML path intentionally deferred; error still flows through rscErrorTracker

- Ported from Next.js: https://github.com/vercel/next.js/commit/f5e54c06726b571a042fce67417e40a29f6b8689

Closes #1195
2026-05-18 11:10:40 +01:00
Nathan Nguyen 6790e3431f feat(cache): add static layout proof and variant budget guardrails (#1288)
* feat(cache): add disabled static layout reuse proof

Static layout cache artifacts can already carry render observations, but the cache proof model could not prove or reject static layout reuse independently of cache serving. That left private, dynamic, and incomplete observations covered only by downgrade classification, not by the reusable artifact boundary.

The missing invariant was that a reusable layout needs positive layout/root/boundary proof and negative request API proof before any future cache authority can consume it.

Add a disabled static layout proof evaluator with rejection trace codes for mismatched layout output, unknown root boundaries, private variant dimensions, private/dynamic observations, and missing or observed request APIs. Keep runtime cache reuse disabled by carrying the proof only on the disabled decision.

* feat(cache): enforce route variant budget fallback

Cache variant construction previously had a lossy scalar route count, so callers could not distinguish a duplicate admissible variant from a genuinely new variant that would exceed the route ceiling.

That violated the #726 cache proof invariant that variant cardinality must have an owned breaker path. Route budget admission now tracks canonical variant keys per route, allows duplicate variants without consuming budget, and returns structured breaker fallbacks for route mismatches or over-ceiling variants.

Tests cover duplicate admission, second distinct admission, and the over-budget breaker fallback for #726-CACHE-10.

* fix(cache): derive static layout proof authority

* docs(cache): clarify static proof evidence copy

* perf(cache): binary-search route variant keys

* refactor(cache): move sorted key search to utils

* test(cache): cover static proof boundary mismatch
2026-05-18 11:09:36 +01:00
Nathan Nguyen 8dfd6c8ede fix(image): preserve inline styles on remote images (#1287)
* fix(image): preserve inline styles on remote images

Remote next/image renders with explicit width and height delegate through UnpicImage, but that branch dropped the user style prop. This made remote images ignore inline styling such as borderRadius, objectPosition, and transform while local images and fill images preserved it.

The shim now passes style through the remote constrained Unpic boundary and isolates the Unpic type gap where the runtime supports style but the public type omits it. A focused SSR regression test covers the rendered remote image style contract.

* refactor(image): avoid remote style type assertion

The previous fix isolated an assertion around @unpic/react because the runtime forwards style but the public component type omits it.

Keep the existing dependency shape and pass the runtime-supported style prop through a typed spread object instead. The remote image style regression coverage remains unchanged.
2026-05-18 10:44:31 +01:00
Nathan Nguyen e8cf0dad13 fix(script): honor async false for client scripts (#1286)
Client-side next/script insertion left dynamically created script elements in the browser's forced async state when callers passed async={false}. That breaks ordered third-party script loading because the shim skipped false boolean props instead of clearing the DOM script async flag.

Set script boolean attributes through their DOM properties and explicitly set/remove disabled boolean attributes to clear force async. Cover the regression with a focused handleClientScriptLoad test.
2026-05-18 10:43:32 +01:00
Nathan Nguyen 16f9ea393f fix(app-router): model RSC redirect and traversal lifecycle (#1248)
RSC redirects were followed by mutating browser history before the redirected payload had passed the visible commit lifecycle. That could collapse the initiating history entry and leave back/forward traversal without an explicit intent model.

Keep redirect hops inside the initiating navigation until an approved commit publishes history, add explicit terminal redirect decisions, and record per-entry traversal metadata so unknown browser history state stays unknown instead of being guessed.

Add focused unit coverage for redirect decisions and traversal metadata plus a browser regression for router.push redirect back navigation.
2026-05-18 10:42:19 +01:00
Nathan Nguyen 8dc6810fb6 fix(rsc): preserve exported client reference subpaths (#1257)
Client reference dedupe currently rewrites every package file imported through the RSC package proxy back to the package root. That loses subpath identity when a package exposes client modules through an exported subpath instead of its root barrel.

The plugin now derives a bare import specifier from package metadata, preserving exact and pattern exports while keeping private internals on the package root so existing context dedupe behavior stays intact.

Regression coverage exercises exported subpaths, scoped packages, export patterns, private internals, and legacy deep imports.
2026-05-18 10:34:18 +01:00
Nathan Nguyen 656d954abf feat(router): promote intercepted preservation through planner (#1249)
* feat(router): promote intercepted preservation through planner

Intercepted navigation could still preserve source UI through context-bearing payload shape and same-URL history state. That made modal preservation depend on transport metadata instead of the visible route world that had actually been committed.

The planner now requires explicit interception proof before preserving source layouts or unrelated slots, and the browser lifecycle clears stale previous-next history state when normal target payloads commit. Wire metadata carries proof, while missing, stale, or incompatible proof falls back to hard navigation.

Tests cover approved source-slot preservation, missing, stale, and malformed proof rejection, current-context refresh and action handling, traverse restoration, and direct-target refresh clearing stale interception state.

* fix(router): normalize intercepted proof paths

Interception proof could be built from browser-derived percent-encoded paths while committed route snapshots used the server-normalized route-state form. Non-ASCII intercepted source or target paths could then fail proof validation and fall back to a hard navigation.

Normalize proof matched URLs before encoding source and target route IDs, and normalize client planner snapshot matched URLs to the same route-state representation without changing the user-facing navigation snapshot.

Adds regressions for encoded non-ASCII proof generation and planner validation.

* review: address ask-bonk PR review comments

- Add interceptedRejectedTargetMismatch reason code for target URL
  mismatch in planner traces (was misusing UnknownSource)
- Document double-attempt replaceState fallback for browser quirks
- Clarify navigate branches: proven interception vs legacy fallback
- Document cache migration safety for legacy interceptionContext
- Tighten isInterceptionMetadataValue type guard with string checks
- Extract interception attachment into shared step in wire metadata
  entries to remove asymmetric branching

* fix(router): ignore context-only interception metadata

Context-only AppElements payloads could enter intercepted preservation planning or leave context-suffixed route identity as later proof authority. That made transport metadata influence visible-world preservation when a normal response was rendered from an intercepted request context.

Only explicit interception proof now enters the preservation branch. Planner snapshots also canonicalize context-suffixed route IDs when no proof is present, keeping render keys partitioned while preventing stale context from poisoning future explicit interception proof.

Tests cover the direct planner decision and the two-step browser lifecycle regression.

* test(router): cover intercepted proof rejection matrix

Planner traces now expose distinct rejection reasons for mismatched intercepted targets and incompatible roots. Without direct tests, those branches could regress while the broader intercepted route scenarios still passed.

Add focused planner tests for target mismatch and incompatible root rejection, use the local target snapshot consistently, document two lifecycle ordering contracts, and avoid the no-op interception metadata spread on non-intercepted payloads.
2026-05-18 10:18:49 +01:00
Nathan Nguyen 7283e44beb fix(router): keep next/router import free of popstate side effects (#1294)
* fix(router): keep next/router import free of popstate side effects

Importing next/router currently installs the Pages Router popstate listener as module-level browser state. That listener can be pulled into App Router client bundles by compatibility imports, where it races the App Router history runtime and can turn back/forward traversal into a document navigation.

Move Pages Router popstate installation behind an internal vinext runtime module that only Pages Router entrypoints call after hydration setup. Keep the existing popstate decision logic in the router shim, but make global listener ownership explicit at the Pages boundary.

Add unit coverage for side-effect-free next/router imports and an App Router e2e regression that imports next/router, traverses history, and asserts no document request occurs.

* refactor(router): remove redundant beforePopState assertion

The Pages Router popstate handler already narrows the beforePopState callback before invoking it. Call the narrowed function directly so the production router code does not carry an unnecessary type assertion.
2026-05-18 10:08:40 +01:00
Ghxst 85f2c10fab chore: update @vitejs/plugin-rsc to 0.5.26 (#1293) 2026-05-18 08:28:33 +00:00
Nathan Nguyen d558923fde fix(script): dedupe concurrent same-src loads (#1263)
* fix(script): dedupe concurrent same-src loads

Multiple next/script components with the same src could append duplicate DOM scripts when they mounted before the first script load event fired. That double-executed third-party code and diverged from Next.js' in-flight script cache behavior.

The shim now tracks remote scripts that are currently loading separately from scripts that have completed loading, and fans out load/error callbacks without adding another script element.

Adds a Pages Router browser regression that verifies simultaneous same-src scripts produce one DOM script and one execution.

* fix: address review feedback for script load dedupe

- Defer loadedScripts.add(key) to after in-flight promise resolves
- Add onReady() callback for duplicate callers joining in-flight load
- Clean up loadingScripts entry on promise settlement to allow retry
2026-05-16 20:16:38 +01:00
Nathan Nguyen ff46514563 fix(link): preserve native URI scheme navigation (#1268)
* fix(link): preserve native URI scheme navigation

Link treated only http, https, and protocol-relative hrefs as browser-owned. Native schemes such as mailto:, tel:, and sms: were therefore locale-prefixed, eligible for prefetch normalization, and intercepted by client navigation.

The URL boundary now mirrors Next.js absolute URL scheme classification while preserving vinext's protocol-relative handling. Link rendering, click interception, relative resolution, and prefetch normalization all share that classification so non-local schemes stay with the browser while same-origin absolute URLs remain routable.

Adds focused regression coverage for native scheme rendering, click handling, and prefetch decisions.

* refactor(router): reuse shared URL scheme detection

Router and navigation shims already had local external URL predicates with the same browser-owned URL semantics now used by Link.

Reuse the shared url-utils predicate for Pages Router locale handling, Pages Router external detection, App Router external detection, and App Router prefetch normalization. Add focused shim coverage for native URI schemes through the Pages Router helpers.

* test(link): add hasAttribute stub to native URI scheme click event

The merge of main introduced a download-attribute check in handleClick that
runs before the native-URI early return, so test click events must provide
currentTarget.hasAttribute.

---------

Co-authored-by: James <james@eli.cx>
2026-05-16 19:14:26 +00:00
Nathan Nguyen 1cd7255881 fix(image): emit preload hints for priority images (#1266)
Priority images currently only render eager loading and high fetch priority. That misses the resource hint Next.js emits, so the browser cannot discover the optimized image until it reaches the img element.

The image shim now calls ReactDOM.preload with the final optimized image URL, srcset, sizes, and fetch priority metadata for priority and preload images. Focused image component coverage asserts the SSR preload hint and the modern preload prop behaviour.

Co-authored-by: James <james@eli.cx>
2026-05-16 19:12:28 +00:00
James Anderson e69721f392 fix(config): provide CJS globals when loading next.config.ts (#1278)
* fix(config): provide CJS globals when loading next.config.ts

Next.js's `next.config.ts` loader transpiles the file to CommonJS via SWC
and evaluates it through Node's `Module._compile`, which exposes the CJS
globals (`__filename`, `__dirname`, `module`, `exports`, `require`) even
when the source uses ESM syntax. vinext previously loaded the config
through Vite's ESM-only module runner, so configs that read `__dirname`,
called `require()`, or used `module.exports` threw `ReferenceError`s
during build.

Inject the same globals via a small Vite `transform` plugin that runs
only on the `.ts`/`.mts`/`.cts` config entry point. Reassigning
`module.exports` is preserved by exposing the wrapper object as a named
export and reading it back in `unwrapConfig`.

Reference: packages/next/src/build/next-config-ts/{transpile-config,require-hook}.ts

* fix(config): guard CJS-globals injection on static reference check

Skip cjsGlobalsInjectorPlugin's transform entirely when the next.config
source contains no bareword reference to __filename, __dirname, require,
module, or exports. The vast majority of next.config.ts files are pure
ESM (every upstream `test/e2e/app-dir/next-config-ts` fixture except
node-api-cjs is) and pay no cost from the transform.

Substring matching is intentional: a false positive (e.g. the word
`module` inside a comment or `node:module` specifier) costs at most one
wasted, idempotent transform, while a false negative would be a
correctness regression. Bareword `\b` boundaries already exclude
`requireSomething` and `myModule` style identifiers, and `\bexports\b`
does not match `export default`.

* fix(config): skip module.exports wrapper when source uses ESM-only

Statically detect whether a `next.config.ts` source assigns to
`module.exports` (via `module.exports =`, `module.exports.foo =`, or
`module.exports[...] =`) with a new `reassignsModuleExports` helper.
Only emit the `__vinext_cjs_exports` named export and its companion
initial-reference export when that check matches. Pure-ESM configs —
the overwhelming majority — get a slightly smaller injected preamble
and a simpler unwrap path that just reads the ESM `default` export.

The previous sentinel-on-module.exports approach tagged the initial
exports object with a `Symbol.for("vinext:cjs-initial")` property on
every config load and checked for it during unwrap. That worked but
ran unconditionally and left a stray symbol-keyed property on the
final exports object whenever the user reassigned. The new shape only
pays for the wrapper when the source plausibly needs it, and keeps a
referential-identity check (`module.exports !== __vinext_cjs_initial_exports`)
as belt-and-braces so that a `reassignsModuleExports` false positive
inside a string or comment still falls back to the ESM default
instead of returning an empty `{}`.
2026-05-16 20:10:23 +01:00
Nathan Nguyen ba1829a804 fix(image): clear blur placeholder after image load (#1267)
* fix(image): clear blur placeholder after image load

Blur placeholders currently stay on the img background after the high resolution image finishes loading. That is wrong for transparent image formats because the low quality placeholder remains visible through the rendered pixels.

The shim treated placeholder styling as static props instead of load lifecycle state. Track blur completion per src, clear local and remote placeholder backgrounds after successful load or error, and cover the transparent-image regression in an app-router browser test.

* chore: rerun ci

---------

Co-authored-by: James <james@eli.cx>
2026-05-16 20:06:34 +01:00
James Anderson da9998a00f fix(link): normalise href for trailingSlash config (#1282)
* fix(link): normalise href for trailingSlash config

Port `normalizePathTrailingSlash` from Next.js (client/normalize-trailing-slash.ts)
into a small util under `shims/url-utils.ts`, wire the resolved `trailingSlash`
config through a new `process.env.__VINEXT_TRAILING_SLASH` build-time define,
and apply it inside `<Link>` so the rendered `href` matches the canonical URL
form instead of relying on a 308 redirect bounce.

This unblocks the upstream `e2e/trailing-slashes/*` suite, which asserts on
rendered `<Link>` hrefs (not just redirect Location headers).

* fix(link): normalise href again after applying basePath

Matches Next.js's `addBasePath`, which runs `normalizePathTrailingSlash`
after prefixing. Without this second pass, joining a non-empty basePath
with the bare root (`/`) under `trailingSlash: false` would render
`/foo/` instead of the canonical `/foo`.

* Update packages/vinext/src/shims/url-utils.ts

Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>

---------

Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>
2026-05-16 19:05:38 +00:00
Nathan Nguyen fdf3029ea4 fix(router): share pages useRouter state through context (#1271)
Pages Router useRouter previously owned a hook-local router snapshot and subscribed each hook call to vinext:navigate. That diverged from Next.js RouterContext semantics and made mounted hook count scale global listeners.

Move the reactive snapshot into the Pages Router provider, make next/router consume RouterContext like Next.js, and keep singleton navigation methods as the shared command surface. Add regression coverage for context reads, missing context errors, and per-hook listener scaling.

Co-authored-by: James <james@eli.cx>
2026-05-16 20:04:12 +01:00
Nathan Nguyen d280e3620b fix(app-router): skip RSC navigation for hash-only history traversal (#1270)
App Router popstate currently sends every history traversal through __VINEXT_RSC_NAVIGATE__, even when the target pathname and search already match the committed route and only the hash entry changed. That violates the hash-only traversal invariant and replays cached RSC payloads unnecessarily.\n\nCompare popstate targets against the committed App Router pathname and search before dispatching an RSC traversal. Same-route entries now restore scroll directly, while real route traversals keep the existing pending-navigation coordination.\n\nAdd an App Router E2E regression that wraps __VINEXT_RSC_NAVIGATE__ and verifies hash back/forward traversal does not call it.

Co-authored-by: James <james@eli.cx>
2026-05-16 18:59:46 +00:00
Nathan Nguyen be9f338306 fix(router): derive shallow dynamic params from the URL (#1272)
* fix(router): derive shallow dynamic params from the URL

Pages Router shallow navigation across one dynamic route template updated the browser URL but kept router.query params from the original __NEXT_DATA__ payload. That left client state stale after transitions such as /posts/42 to /posts/43.

The router now matches the current pathname against the hydrated route pattern before falling back to __NEXT_DATA__.query. This preserves shallow data-fetch skipping while keeping dynamic params aligned with the visible URL.

Tests cover the router shim boundary and the browser-visible Pages Router shallow transition.

* refactor(router): reuse route-pattern matching for shallow params

The shallow dynamic params fix originally duplicated route-pattern parsing inside the router shim.

Reuse the existing routePatternParts and matchRoutePattern helpers instead, keeping the __NEXT_DATA__ fallback behavior unchanged.

---------

Co-authored-by: James <james@eli.cx>
2026-05-16 18:58:11 +00:00
James Anderson 9252ca2966 fix(css): pass sassOptions from next.config to Vite preprocessor (#1277)
* fix(css): pass sassOptions from next.config to Vite preprocessor

Next.js threads `sassOptions` from `next.config.js` into sass-loader,
destructuring `prependData` / `additionalData` / `implementation` and
forwarding the rest (e.g. `includePaths`, `loadPaths`) as Sass options.
vinext was dropping this entirely, so SCSS fixtures that rely on
`additionalData` to define variables, `includePaths`/`loadPaths` to
resolve partials, or a custom `implementation` all failed to build
with `Undefined variable` / `Can't find stylesheet to import`.

Resolve `sassOptions` from next.config into `ResolvedNextConfig`, then
translate it onto Vite's `css.preprocessorOptions.scss` (and `.sass`)
shape:

  - prependData precedence matches Next.js (prependData || additionalData)
  - legacy includePaths is merged into modern loadPaths (Vite uses
    modern Sass, which dropped includePaths)
  - implementation and any remaining Sass options are passed through

Affected upstream fixtures:
  - test/e2e/app-dir/scss/basic-module-include-paths
  - test/e2e/app-dir/scss/basic-module-additional-data
  - test/e2e/app-dir/scss/basic-module-prepend-data

Refs: cloudflare/vinext CI run 25951111875
Source: packages/next/src/build/webpack/config/blocks/css/index.ts

* fix(css): address sass passthrough review feedback

- Drop `export` on `VitePreprocessorOptions` so Knip stops flagging it
  as unused (CI Check blocker).
- Use truthy-OR (`prependData || additionalData`) to match Next.js's
  `sassPrependData || sassAdditionalData` exactly — empty-string
  `prependData` now falls through to `additionalData` instead of
  overriding it.
- Widen `additionalData` type to accept Vite's function form
  (`(source, filename) => string | Promise<string>`) and drop the
  inaccurate `as string` cast.
- Add tests covering the empty-`prependData` fallthrough and the
  function-form `additionalData` passthrough.
2026-05-16 19:45:03 +01:00
Nathan Nguyen deef64470b fix(app-router): keep refresh transitions pending (#1269)
router.refresh() currently skips the pending browser router state even when it is dispatched from a React transition. That lets useTransition return to idle while the refresh RSC request is still in flight.

The refresh path should follow the same App Router invariant as other programmatic navigations: publish a deferred router-state promise before the request and resolve it when the refreshed tree commits.

Allow programmatic refresh navigations to create pending router state, and cover the behaviour with a browser regression that observes isPending during a delayed same-route refresh.
2026-05-16 19:36:48 +01:00
James Anderson 1d66c09e15 refactor(edge-globals): consolidate into installEdgeGlobals() helper (#1284)
Replaces the bare-side-effect install in edge-globals.ts with an exported
installEdgeGlobals() function, called explicitly from each runtime entry
that may evaluate user edge code (App Router worker entry, middleware
runtime, Pages API route).

Also documents that URLPattern is intentionally NOT installed: Cloudflare
Workers expose it natively, and CI now pins Node 24+ (#1283), which has
the global URLPattern. The TODO(edge-globals) marker is dropped.
2026-05-16 18:34:17 +00:00
James Anderson 746d6b5970 chore(ci): bump Node to 24 for native URLPattern support (#1283) 2026-05-16 19:33:46 +01:00
Nathan Nguyen aec23e3b68 fix(image): preserve fill positioning for remote images (#1265)
Remote Image with fill=true currently routes through Unpic fullWidth, which renders a flowing image without data-nimg="fill" or absolute positioning. That violates Next.js's fill invariant for parent-filling layouts.

Centralize fill styles and render remote fill with the same DOM contract as local fill and getImageProps. Add SSR regression coverage for remote fill.
2026-05-16 19:29:15 +01:00
Nathan Nguyen 7d1f482050 fix(link): preserve unsafe href click handlers (#1264)
Vinext strips dangerous Link href values before rendering the anchor. That keeps javascript: URLs inert, but the unsafe render branch also dropped the developer-provided onClick handler, so links used as click targets stopped working after hydration.

The dangerous-href branch now preserves the anchor ref, link status provider, and user click handler while continuing to omit href. The regression fixture verifies that the click handler runs and the browser URL stays unchanged.
2026-05-16 19:13:44 +01:00
Nathan Nguyen 25ac4db7f9 fix(link): preserve native download clicks (#1262)
Link currently treats anchors with the HTML download attribute like normal same-origin SPA navigations. That prevents the browser from performing its native download behavior and can also run onNavigate for a click that should never become client-side navigation.

The click handler now bails out after user onClick and before preventDefault whenever the anchor has a download attribute, matching Next.js Link semantics. A focused regression test covers the default action, onNavigate, transition, and RSC navigate boundaries.
2026-05-16 19:12:30 +01:00
James Anderson 7790a0a1ef fix(edge): expose AsyncLocalStorage as a global in edge runtime (#1276)
Next.js's edge sandbox installs `AsyncLocalStorage` on the runtime's
global context (see `packages/next/src/server/web/sandbox/context.ts` —
`context.AsyncLocalStorage = AsyncLocalStorage`). vinext executes user
code directly on the Cloudflare Workers runtime, which only exposes
`AsyncLocalStorage` via `import { AsyncLocalStorage } from "node:async_hooks"`
under the `nodejs_compat` flag, not as a global.

Edge fixtures like `.nextjs-ref/test/e2e/edge-async-local-storage/` do
`new AsyncLocalStorage()` without an import and were failing with
`ReferenceError: AsyncLocalStorage is not defined`.

Install `AsyncLocalStorage` on `globalThis` (idempotently) from every
entry point that may evaluate user edge code: the App Router worker
entry, the middleware runtime, and the Pages API route runtime.

A TODO marks the spot for future consolidation with the parallel
`URLPattern` work into a single `installEdgeGlobals()` helper.
2026-05-16 19:08:32 +01:00
Nathan Nguyen 496a0247af fix(app-router): emit streamed redirect and not-found meta tags (#1261)
* fix(app-router): emit streamed redirect and not-found meta tags

Streaming App Router SSR currently returns a 200 once the shell commits, but redirect() or notFound() discovered inside a later Suspense boundary never emits the browser-visible meta tag that Next.js relies on. That leaves the control-flow digest only in the RSC payload until client hydration handles it.

The SSR onError path now captures digest-bearing render errors and a request-local flusher injects noindex or refresh meta tags through the existing server-inserted HTML stream hook. The app page pipeline also forwards basePath into SSR so streamed redirect meta tags match early redirect responses.

* fix(app-router): avoid double-prefixing basePath root redirects

Streamed redirect meta tags checked basePath against the full redirect location string. Already-prefixed root URLs with a query or hash, such as /docs?from=checkout, failed the pathname-shaped basePath check and were prefixed a second time.

Add a shared pathname-only basePath prefix helper and use it from both early redirect responses and streamed redirect metadata. The streamed path now splits query/hash before checking the pathname.
2026-05-16 19:07:27 +01:00
Nathan Nguyen 4988436b6c fix(app-router): preserve history metadata for external state updates (#1259)
External history.pushState and history.replaceState currently store caller data as-is. That drops vinext's App Router previousNextUrl marker when apps perform shallow URL updates inside an intercepted route, so later traversal can lose the interception source.

The History API wrappers now copy the current App Router metadata onto caller-provided state before delegating to the browser history method. A focused shim regression covers object, null, and undefined caller state against the same contract Next.js preserves.
2026-05-16 18:57:11 +01:00
James Anderson 3e8db450f4 ci(nextjs-deploy): add test-path filter input (#1280)
Lets workflow_dispatch runs target a single suite (e.g.
test/e2e/edge-async-local-storage) instead of the full ~16-shard run,
shortening the iteration loop when investigating a specific failure.
The compat-ingest step is gated off for path-filtered runs so partial
snapshots don't drag the historical pass-rate trend.
2026-05-16 17:47:22 +00:00
Nathan Nguyen 042ac438b0 fix(link): align visible and intent prefetching (#1258)
* fix(link): reschedule visible prefetches

Visible App Router links were prefetched as one-shot observer callbacks. That lost the mounted visibility state needed to re-prefetch after cache invalidation or router state changes, and automatic dynamic links could not be upgraded to a full prefetch on hover.

The Link shim now keeps mounted prefetch instances, tracks the visible set, exposes an internal ping hook for cache and router commits, and persists the full-prefetch strategy after unstable_dynamicOnHover intent.

Regression coverage verifies visible-link cache invalidation and dynamic-on-hover strategy upshift.

* fix(link): preserve pages intent prefetch

Pages Router Link prefetch=false should only disable viewport prefetch. The shared gate also blocked hover and touch intent prefetches, which matches App Router but diverges from the Pages Router contract.

Split intent eligibility by router mode so App Router keeps the stricter gate while Pages Router intent still prefetches in production. Pages viewport prefetch remains one-shot per mounted link to avoid duplicate document prefetch nodes under the visible-link registry.

Regression coverage verifies App Router prefetch=false intent suppression, Pages Router hover and touch intent prefetch, and Pages viewport re-entry dedupe.
2026-05-16 18:42:01 +01:00
James Anderson 1f2030b8d2 fix(config): load CJS next.config.js under "type": "module" (#1275)
`createRequire` of a `.js` file in a `"type": "module"` package still gets
resolved as ESM by Node (extension + parent type wins over the require()
caller), so the existing CJS fallback in loadNextConfig threw `require is
not defined` for every plain `module.exports = {}` config after `vinext
init`. Worked around in the Nextjs deploy suite by rewriting every
fixture's config from CJS to ESM in-place via scripts/cjs-to-esm-config.mjs.

Copy the source to a sibling temp `.cjs` (extension overrides the parent
type field) and require that instead. Relative imports inside the config
still resolve against the original directory. Drop the deploy-suite
rewrite step and its 145-line shim.
2026-05-16 18:36:22 +01:00
James Anderson eac3ef3e8a refactor(router): dedupe push/replace logic in next/router shim (#1274)
Both useRouter() and the Router singleton carried near-identical push/replace
implementations (~250 lines combined). Extract a shared performNavigation()
helper plus small primitives (updateHistory, dispatchNavigateEvent,
extractHash, prefetchUrl). Behaviour and event ordering are preserved.
2026-05-16 17:21:40 +00:00
Nathan Nguyen b69844ec3f fix(metadata): apply ancestor templates to title defaults (#1256)
Metadata title defaults currently render without the active ancestor template. That diverges from Next.js when a child layout or page provides title.default under a parent layout title.template.

The merge path resolved only the final title after collecting templates, so object defaults skipped the stashed template used for that segment. Resolve each title as it is encountered against the current ancestor template, then stash the current layout template for descendants.

Covers the child-layout default regression and updates page-default expectations to match Next.js resolveTitle semantics.
2026-05-16 18:08:40 +01:00