Commit Graph

103 Commits

Author SHA1 Message Date
Alexander Lichter 85d49ec814 perf(dev): externalize SSR React runtime (#2191)
* perf(dev): externalize SSR React runtime

* fix(test): pass Vite config environment
2026-06-20 01:33:33 +01:00
Jerry Zhao 680cbafabc perf(mdx): gate the MDX transform handler behind a native id filter (#2200) 2026-06-20 01:05:02 +01:00
James Anderson b2ea860284 fix(proxy): match Next.js file conventions (#2155)
* fix(proxy): match Next.js file conventions

* test(proxy): keep convention fixture out of workspace

* test(proxy): provide middleware path in handler fixture
2026-06-18 23:54:39 +01:00
Divanshu Chauhan 7487886b38 fix(prod-server): send headers-only for HEAD in sendCompressed (#1980) (#2058)
sendCompressed wrote the full body for every request. HEAD requests routed
through it (bot/crawler-buffered Pages HTML and Pages API routes) still
compressed and emitted a payload that Node then discards at the socket level.

Short-circuit with res.end() after writeHead when req.method is HEAD,
mirroring sendWebResponse. This skips spinning up a compressor for a body
that would be thrown away and keeps the buffered sender consistent with the
streamed and static-file senders, which already handle HEAD.

Co-authored-by: James <james@eli.cx>
2026-06-18 22:13:07 +00:00
Alexander Lichter 2eb394f0a4 perf(dev): skip typeof window folding for prebundles (#2118)
* perf(dev): skip typeof window folding for prebundles

* perf(dev): move prebundle skip into hook filter

* test: provide cacheDir in resolved config mock

* fix(dev): handle reused typeof-window plugins

* refactor(dev): share cache directory normalization

---------

Co-authored-by: James <james@eli.cx>
2026-06-17 18:13:31 +00:00
James Anderson bacee9ef52 fix(pages): emit canonical __NEXT_DATA__ JSON (#2043)
* 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
2026-06-15 23:16:23 +01:00
James Anderson 51d0208d3d fix(pages): re-enter filesystem routes after rewrites (#2041)
* fix(dev): defer rewrite conditions until after middleware

* docs(dev): clarify rewritten static handling
2026-06-15 23:15:25 +01:00
James Anderson b58adb3cfa fix(middleware): classify Pages data requests by URL (#2039) 2026-06-15 23:00:09 +01:00
James Anderson 0fb9bd32bf fix(pages): apply Document renderPage enhancers (#2034)
* fix(pages): apply document renderPage enhancers

* fix(pages): propagate document render errors

* fix(pages): delegate document initial props

* fix(pages): render dev errors through document

* fix(pages): preserve document-provided html

* fix(pages): keep render page enhancer type internal

* fix(pages): buffer custom dev error documents
2026-06-15 22:53:40 +01:00
James Anderson c3dcdba1fe fix(pages): honor basePath in dev module loaders (#2033)
* 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
2026-06-15 22:42:33 +01:00
Nathan Nguyen a3d2f92152 fix(router): honor hybrid pages route priority (#1997)
* 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>
2026-06-15 00:22:00 +01:00
Nathan Nguyen b7ef466742 fix(pages): preserve app props for GSSP requests (#1996)
* fix(pages): preserve app props for gssp requests

* docs(pages): update _next/data comment to reflect full props envelope

* fix(pages-router): preserve _app.getInitialProps during stale ISR regeneration

Stale ISR background regeneration called getStaticProps directly and rendered
HTML and __NEXT_DATA__ with only the page's pageProps, dropping app-level
props from _app.getInitialProps. The foreground path already loads
_app.getInitialProps first and merges its props, but the background
regeneration path skipped that step.

Extract a shared loadPagesAppInitialRenderProps helper from the production
foreground path and reuse it during background regeneration in both
pages-page-data.ts and dev-server.ts. This ensures regenerated pages carry the
same full props envelope as the original render.

Add a regression test in tests/pages-page-data.test.ts.

* fix(pages-router): skip _app.getInitialProps on ISR cache hits

The foreground render path ran _app.getInitialProps before consulting the
ISR cache, so fresh and stale cache hits executed userland App data code
even though the cached HTML was returned verbatim. That wasted work on the
hot path and ran side-effecting App code whose result the cached response
never reflected.

The eager App initial-props load happened up front, ahead of the HIT and
STALE short-circuits. Defer it until the request will actually render:
load it lazily inside the getServerSideProps / getStaticProps /
getInitialProps branches, after the cache short-circuits return.
Stale-while-revalidate already re-runs it inside the regeneration closure,
so regenerated HTML still carries app-level props. The dev SSR handler and
the production page-data resolver are updated in lockstep to preserve
dev/prod parity.

Tests cover the fresh-HIT skip and the stale foreground-skip vs
regeneration-run split at the page-data resolver boundary.

* refactor(pages-router): extract dev App getInitialProps loader into a tested helper

The dev SSR handler ran _app.getInitialProps inside an inline closure that
mutated captured pageProps/renderProps and read res directly, so its lazy
load-only-on-render behavior could only be covered by a heavyweight
integration test that vi.doMock'd the isr-cache and file-matcher modules —
a mock cascade with little signal.

Move the decision into loadDevAppInitialProps in pages-get-initial-props.ts.
It takes the App component, req/res, and an injected AppTree builder (keeping
React out of the module) and returns a discriminated result —
skip / response-sent / render — that the caller applies. The dev-server
closure is now thin glue: build the React tree, delegate, apply the result.

Adds focused unit tests that drive the helper through its real boundary
(genuine getInitialProps functions, plain req/res inputs) — no module mocks:
the skip path, App/page prop merging, pageProps normalization, the
response-sent short-circuit, and the router/ctx wiring passed to userland.

* fix(pages): preserve app props across data requests

* fix(pages): preserve fallback cache expiry

* fix(pages): preserve dev app context during ISR

* fix(pages): hydrate middleware rewrite fallbacks

* fix(pages): retarget middleware data rewrites

* fix(pages): preserve rewrite query on navigation

* fix(pages): finalize rewrite navigation state

* fix(pages): resolve rewrite data targets consistently

* fix(pages): await static props during regeneration

* fix(pages): preserve original query in resolved url

* Revert "fix(pages): await static props during regeneration"

This reverts commit cbdcdf65cd.

* test(app): cover rewrite params in app search

---------

Co-authored-by: James <james@eli.cx>
2026-06-14 20:17:05 +01:00
Nathan Nguyen 3253aafd77 fix(pages-router): pass rewrite URL to edge API requests (#1998)
* fix(pages-router): pass rewrite URL to edge API requests

* fix(pages-router): preserve edge API request URL parity

* fix(pages-router): configure edge API NextRequest

* fix(pages-router): preserve edge API locale config

* fix(pages-router): preserve edge request metadata

* fix(pages-router): align edge API URL formatting

* fix(pages-router): match API locale casing

* fix(pages-router): match domain locales by locale

* docs(pages-router): clarify edge API basePath parity

* test(deploy): expect metadata-preserving URL clone

* docs(pages-router): note basePath rewrite parity

---------

Co-authored-by: James <james@eli.cx>
2026-06-13 22:05:31 +01:00
James Anderson eef22e07a8 fix(pages-router): block fallback shell for crawler UAs on prerender (#1543) (#1663)
* fix(pages-router): block fallback shell for crawler UAs on prerender (#1543)

When `getStaticPaths` returns `fallback: true` and an unlisted path is hit
by a known bot/crawler, vinext was serving the `Loading...` shell so the
crawler indexed the fallback HTML instead of the real page. Mirror Next.js
`pages-handler.ts` and flip `isFallback` to false for bot User-Agents so
the request renders synchronously with real props.

- Add `isBotUserAgent` helper (DOM + HTML bot regex) in `utils/html-limited-bots.ts`,
  matching Next.js `shared/lib/router/utils/is-bot.ts`.
- Plumb `userAgent` + `htmlLimitedBots` into `resolvePagesPageData` and
  flip the fallback bit when the UA matches.
- Apply the same flip in the dev `createSSRHandler` for parity.
- Targeted unit + integration regression tests covering Googlebot,
  Bingbot, DuckDuckBot, YandexBot, facebookexternalhit, and a normal
  Chrome UA control. Ported from `.nextjs-ref/test/e2e/prerender-crawler.test.ts`.

Refs #1543.

* fix(pages-router): preserve array user-agent bot detection
2026-06-13 00:35:28 +00:00
James Anderson f5355964aa fix(pages-router): set x-nextjs-deployment-id on SSG _next/data responses (#1863)
* fix(pages-router): set x-nextjs-deployment-id on SSG _next/data responses

Pages Router `/_next/data/<buildId>/<page>.json` responses were missing the
`x-nextjs-deployment-id` response header that Next.js sets on these routes
when a deployment ID is configured. The client router reads this header to
detect when a new deployment has been rolled out and triggers a hard
navigation for deployment-skew protection.

Root cause: the `isDataReq` short-circuit path in `pages-page-handler.ts`
that builds the JSON envelope (`buildNextDataJsonResponse`) never injected
the header. Next.js's `pages-handler.ts` sets `NEXT_NAV_DEPLOYMENT_ID_HEADER`
on every `isNextDataRequest` response (success, redirect, and notFound cases).

Fix: read `process.env.__VINEXT_DEPLOYMENT_ID || process.env.NEXT_DEPLOYMENT_ID`
(the runtime-available deployment ID, inlined at build time by Vite's define
transform) and set `x-nextjs-deployment-id` on the `_next/data` response
headers when a deployment ID is configured.

Maps to test: `pages ssg data deployment skew - hard navigate when a new
deployment occurs > header with deployment id > header is set on data routes`.

Fixes #1829

* fix(pages-router): complete x-nextjs-deployment-id parity for _next/data (bonk #1829)

Address all three ask-bonk findings on PR #1863:

1. Incomplete parity — add x-nextjs-deployment-id to redirect and notFound _next/data
   exits in pages-page-data.ts (buildPagesRedirectResponse, buildPagesDataNotFoundResponse).
   Thread deploymentId through ResolvePagesPageDataOptions and pass it from
   pages-page-handler.ts, matching the success-path pattern already in that file.

2. Dev/prod parity — patch dev-server.ts to set x-nextjs-deployment-id on all three
   _next/data exits: the writeGsspRedirect isDataReq path, the getStaticPaths/
   getServerSideProps/getStaticProps notFound isDataReq paths, and the success
   JSON envelope short-circuit (isDataReq block). Mirrors the prod handler.

3. Add regression tests asserting x-nextjs-deployment-id is present on _next/data
   success / redirect / notFound responses in both pages-page-data.test.ts (unit,
   via deploymentId option) and pages-page-handler.test.ts (integration, via
   process.env.__VINEXT_DEPLOYMENT_ID), and absent when no env var is set.

* fix(pages-router): add x-nextjs-deployment-id to wrong-buildId/route-miss _next/data 404s (#1829)

Fixes the deployment-skew gap identified in review: `buildNextDataNotFoundResponse` now
includes the `x-nextjs-deployment-id` header so all three wrong-buildId/route-miss exits
(normalizePagesDataRequest, prod-server Node path, pages-page-handler route-miss) surface
the deployment ID to the client router. Adds unit tests for the new header on all relevant
exits.

* fix(pages-router): set deployment-id header on remaining two dev-mode _next/data 404 exits (#1829)

Patches the stale-buildId 404 in the vite-dev middleware (index.ts) and
the route-miss data 404 in createSSRHandler (dev-server.ts) to set
x-nextjs-deployment-id, matching prod parity via buildNextDataNotFoundResponse().

* test(pages-router): cover dev-mode _next/data deployment-id header exits

Drive the two dev-only exits flagged in review through the real fixture
dev server: the wrong-buildId data 404 in the plugin middleware
(index.ts) and createSSRHandler's route-miss data 404 (dev-server.ts),
plus the dev success path and the no-deployment-id case.

* fix(pages-router): skip deployment-id header on /_error and /500 data success responses

Next.js pages-handler.ts guards the success-path x-nextjs-deployment-id
injection with `!isErrorPage && !is500Page`. Mirror that exclusion in
both the prod success short-circuit (pages-page-handler.ts) and the dev
one (dev-server.ts), and lock it in with a prod unit test (/_error and
/500) plus a dev integration test (/500).
2026-06-11 00:39:16 +00:00
Nathan Nguyen 08504a7745 fix(app-router): preload next/dynamic chunks with CSP nonce (#1594)
* fix(app-router): preload next/dynamic chunks with CSP nonce

Rendered next/dynamic boundaries produced no SSR preload links for their client chunks. That diverged from Next.js and broke nonce-based CSP tests that inspect preload tags before hydration.

The runtime assumed React.lazy alone was enough for dynamic imports. It never carried the dynamic module IDs from the source call into SSR, and client JS chunk URLs did not use Next.js's static/chunks path shape.

Add a focused dynamic metadata transform, resolve boundary files from the client build manifest, emit nonce-bearing preload hints during SSR, and keep client JS assets under _next/static/chunks. Ported regression coverage verifies the nonce and the emitted path shape.

* fix: honor assetPrefix for dynamic preload globals

* test: cover asset-prefixed dynamic preload nonces

App Router production only covered the default dynamic preload URL path. That left assetPrefix regressions at the manifest-to-runtime emission boundary unguarded.

Add a startProdServer regression that builds app-basic with assetPrefix "/cdn" and asserts nonce-bearing next/dynamic chunk preloads render under /cdn/_next/static/chunks/.

* test: isolate asset-prefixed prod server globals

The assetPrefix regression starts a second production server in the same Vitest process. startProdServer and the generated RSC runtime install global manifest and module-loader state, so leaving the temporary build's globals in place breaks later production requests in the shared server.

Snapshot and restore the relevant globals around the temporary /cdn server while keeping the integration assertion at the real runtime boundary.

* refactor: reuse shared record guard in dynamic preload metadata

The dynamic preload metadata transform carried a local object guard even though the codebase already has a shared record predicate. That made the AST helper drift from the existing non-array record invariant used elsewhere.

Reuse isUnknownRecord from utils/record and keep the plugin-specific AST alias local to the transform.

* fix: make dynamic preload metadata binding-aware

The next/dynamic metadata transform matched calls by imported identifier text. Shadowed parameters and block bindings with the same name were therefore mutated even when they no longer referred to the next/dynamic import.

Walk the parsed module with lexical binding state so shadowed names are excluded, and collect object-form imports only from loader/modules rather than every property on the options object.

* fix: model switch and class scopes in dynamic preload transform

The dynamic preload metadata transform still treated imported next/dynamic names as active inside switch case lexical scopes and named class expression bodies. That could inject loadableGenerated metadata into calls that resolve to local bindings rather than the import.

The scope walker now removes switch case-block bindings while traversing case tests and consequents, leaves the switch discriminant in the outer scope, and scopes named class declarations and expressions through their children. Regression tests cover switch case shadowing and named class expressions.

* fix: address PR review feedback for next/dynamic CSP nonce

- Drop `as="style"` from `<link rel="stylesheet">` — `as` is a
  preload attribute per the HTML spec, semantically incorrect on
  stylesheet links. Browsers ignore it but it produces bad HTML.
- Remove brittle `import(` string gate from the transform — it could
  miss `import ("./x")` (whitespace between import and paren). The
  Vite filter already gates on "next/dynamic" and the AST parse is the
  real validation.
- Add comment explaining MagicString mutation safety in Promise.all.
- Add comment explaining AST end-exclusive assumption on closeParen.
- Add test for whitespace-separated dynamic import syntax.

* fix: update global.d.ts comment to mention asset prefix, fix formatting

* refactor: extract computeClientRuntimeMetadata shared helper

Extract the duplicated client-manifest runtime metadata computation
(lazy chunks, dynamic preloads, client entry) into a single shared
helper in utils/client-runtime-metadata.ts.

Both prod-server.ts (Pages Router Node.js production server) and
index.ts (Cloudflare closeBundle hook) now call the same helper,
eliminating the highest-risk seam in the PR where basePath/
assetPrefix handling produced bugs.

* test: add TSX-generic and object-form metadata regression tests

- Add post-type-strip generic call test verifying multi-line
  dynamic() calls work after TS/JSX stripping by esbuild.
- Add object-form existing loadableGenerated preservation test
  to guard against duplicate injection in the object overload.

* chore: remove unused ClientRuntimeMetadata type export

* revert: discard unrelated lockfile change from previous commit

* test: replace deploy test simulators with computeClientRuntimeMetadata

simulateCloseBundleAppRouter and simulateCloseBundlePagesRouter
no longer hand-roll lazy chunk and dynamic preload computation.
They call the shared computeClientRuntimeMetadata helper directly,
matching production wiring.

Also:
- Update helper doc comment to mention Node server startup
- Remove unused imports (manifestFileWithAssetPrefix,
  manifestFileWithBase, computeDynamicImportPreloads,
  dynamicImportPreloadsWithBase) from deploy.test.ts

* test: add absolute assetPrefix CSP nonce tests and clean up redundant globals

* fix(app-router): keep CSP nonce on next/dynamic preloads from Server Component call sites

DynamicPreloadChunks rendered in the environment of the dynamic() call site. For a Server Component call site it ran in the RSC environment, where the script-nonce React context is unavailable, so the emitted dynamic preload <link>s dropped the request nonce — a CSP violation under `script-src 'nonce-…' 'strict-dynamic'`. The existing tests missed this because every fixture called dynamic() from "use client" modules, which render in the SSR pass where the nonce is present.

Extract DynamicPreloadChunks into its own "use client" module (mirroring Next.js's PreloadChunks and vinext's next/script shim) so it always renders in the SSR pass where withScriptNonce() installs the provider, regardless of whether dynamic() is called from a Server or Client Component.

Add fixture regression tests: server-call-site JS and CSS preloads carry the nonce, an ssr:false boundary emits no server preload (Next.js loadable.tsx parity), and preloads are still emitted without a nonce when no CSP is set.

* test(app-router): add browser CSP-nonce e2e for Server Component next/dynamic call site

The existing csp.spec.ts browser test only exercises the client call-site page (/nextjs-compat/dynamic). Add a Playwright test for the Server Component call site (/nextjs-compat/dynamic/rsc-imports-client) asserting the lazily-loaded client widget hydrates under script-src 'nonce-…' 'strict-dynamic' with no CSP violations in the console.

* fix(app-router): correct next/dynamic preload key-spaces and harden the transform

Addresses review findings on the next/dynamic CSP-nonce feature.

- Lazy chunks must stay in the SSR-manifest key-space (basePath only); only dynamic preloads take the assetPrefix. Applying an absolute-URL assetPrefix to lazy chunks broke the Pages Router modulepreload-exclusion membership test and leaked lazy chunks into <link rel=modulepreload>.

- Resolve symlinks on both sides when computing a dynamic boundary's module ID (reusing relativeWithinRoot/tryRealpathSync), so the key matches the client manifest under pnpm/Cloudflare symlinked layouts instead of silently dropping the preload.

- Throw on dynamic() calls with >2 args (Next.js react-loadable parity).

- Insert the generated options arg right after the first arg, preserving comments/whitespace (the previous substring-comma scan ate commas inside comments).

- Recurse into _next/static/chunks/ in the on-disk client-entry fallback (client JS now lands under chunks/).

- Add typeof-window guard to DynamicPreloadChunks and document the deliberate as="style" omission for stylesheet links.

- Extract buildRuntimeGlobalsScript so the Cloudflare closeBundle injection shares one source of truth with the deploy tests (which now mirror includeClientEntry + the mixed app+pages branch).

Tests: lazy/dynamic key-space split incl. absolute-assetPrefix leak regression and a realistic already-prefixed manifest; mixed app+pages client-entry injection; symlinked module-id resolution; nested dynamic() calls; >2-arg throw; comment-safe insertion; ssr:false positive content assertion; honest dev-mode E2E naming (preload-nonce coverage lives in the prod-server Vitest suite).

* fix(app-router): fix parenthesized next/dynamic loader corruption + harden re-review findings

Follow-up to the re-review of 1709db35.

- Fix a regression where inserting the options arg at the first argument's AST end corrupted a parenthesized loader (dynamic((() => import('./x')))) into a sequence expression that DROPPED the loader. Insert at the call's closing paren (paren-safe) with a comment-aware trailing-comma check (keeps comment-safety, avoids ',,'). Verified all four arg shapes via the real transform.

- Add an end-to-end round-trip test: real computeClientRuntimeMetadata -> globalThis.__VINEXT_LAZY_CHUNKS__ -> real collectAssetTags, asserting a lazy chunk is excluded from modulepreload under basePath+assetPrefix (consumer-side guard for the lazy-chunk key-space fix).

- Replace the hand-rolled simulateAssetTagFiltering with the real exported collectAssetTags (kills drift + a stale 'in index.ts/can't import' comment).

- Add an on-disk client-entry fallback test for the real _next/static/chunks/ layout (prior tests only used the flat layout).

- Hardening: memoize root realpath in toManifestModuleId; .length/keys guards in buildRuntimeGlobalsScript; DEBUG-gated parse-failure log; line:col in the >2-arg error; document the preserveSymlinks:false dependency; negative test for a shadowed dynamic(a,b,c) not throwing; ssr:false asserts visible text; honest typeof-window guard + E2E comments.

* test(app-router): fix stale comments + harden next/dynamic tests (3rd review pass)

Addresses the 3rd re-review of the next/dynamic CSP-nonce work. No runtime behavior change (doc + test hardening + one behavior-neutral nonce-prop cleanup).

- Fix two stale comments claiming DynamicPreloadChunks is NOT 'use client' (the opposite of the implemented, correct behavior) in the prod-server test and the rsc-imports-client fixture — leftovers from when the bug was first reproduced.

- ssr:false / assetPrefix tests: match the fetchPriority attribute case-INSENSITIVELY so a future React lowercasing can't make the ssr:false negative assertion pass vacuously.

- Add bare-promise dynamic(import(...)) transform tests (+ parenthesized variant) via a shared firstDynamicCallArgTypes AST helper; strengthen the double-comma test with an exact-output assertion.

- Make the basePath+assetPrefix round-trip test assert lazy-exclusion by basename, not the exact modulepreload href (that URL is subject to a separate, pre-existing collectAssetTags asset-URL bug).

- Nits: 'absent/undefined map' wording; unify JS/CSS nonce-prop handling; document the process-lifetime root-realpath cache.

Investigation note: a sub-agent confirmed a separate PRE-EXISTING bug (out of scope here): under basePath + a distinct path-style assetPrefix, collectAssetTags emits modulepreload hrefs as /<basePath>/<assetPrefix>/_next/... (404) while assets serve at /<assetPrefix>/_next/...; DynamicPreloadChunks already emits the correct assetPrefix-only URL.

* fix(pages-router): serve assets from assetPrefix (not basePath) when both are set

When basePath and a distinct path/absolute assetPrefix were both configured, the Pages Router emitted asset hrefs as /<basePath>/<assetPrefix>/_next/... (and the client-nav pageModuleUrl likewise), which 404 — assets actually serve at /<assetPrefix>/_next/... . assetPrefix REPLACES basePath for asset URLs in Next.js; App Router (React bootstrapModules) and next/dynamic preloads were already correct — only the Pages Router collectAssetTags / resolveClientModuleUrl paths were wrong.

- Add assetServingUrlFromBaseAnchored(value, basePath, assetPrefix): strips the basePath segment from a base-anchored SSR-manifest value and re-anchors under the assetPrefix (no-op when assetPrefix is unset). Mirrors how next/dynamic preloads are computed.

- collectAssetTags: render <link>/<script> hrefs via the re-anchoring helper while KEEPING the base-anchored value for the lazy-chunk membership test + dedup. Thread basePath/assetPrefix from vinextConfig.

- resolveClientModuleUrl (pageModuleUrl/appModuleUrl, import()ed client-side for navigation): re-anchor the same way.

Tests: collectAssetTags re-anchoring unit tests (path/absolute assetPrefix, client entry, base-only no-op, lazy-exclusion still keyed on the base-anchored value) + an end-to-end Pages Router build with basePath:'/docs' + assetPrefix:'/cdn' that fetches every emitted asset URL and asserts 200 (the previously-missing guard). Found via the sub-agent investigation in the prior review round.

* chore(app-router): address bonk review comments on next/dynamic preloads

No behavior change — review-comment cleanup (comments + one behavior-neutral refactor).

- dynamic-preload-metadata: honor the ResolveDynamicImport `importer` arg in the plugin resolver closure instead of closing over `id` (equivalent today; removes the signature/impl footgun bonk flagged).

- dynamic-preload-metadata: expand the dynamicLoaderNode `?? modules` comment to explain the react-loadable parity (it's a harmless no-op for string-array modules), per the bonk note raised twice.

- lazy-chunks: document that computeDynamicImportPreloads intentionally does NOT subtract shared eager chunks from a boundary's preload set — harmless (browser/ReactDOM.preload/React stylesheet model all dedupe by URL) and mirrors Next.js's per-module file listing (the bonk 'redundant preloads' observation).

Already-resolved bonk items verified: the redundant installClientBuildManifestGlobals call is already guarded behind the App-Router-only else branch (prod-server.ts), and the absolute-assetPrefix CSP test already asserts the nonce on cross-origin https://cdn… preload hrefs.

---------

Co-authored-by: James <james@eli.cx>
2026-06-11 00:59:18 +01:00
Nathan Nguyen 3a1e56748f fix(app-router): match Pages navigation params in hybrid builds (#1741)
* fix(app-router): emit pages client entry for hybrid builds

* fix pages router navigation params readiness

* fix(pages-router): track hashed fallback client entry

* fix(pages-router): honor navigation readiness in hooks

* fix(pages-router): derive server router.isReady from SSR readiness

Address review feedback on #1741:

- getRouterSnapshot() now reads navigationIsReady from the SSR context on
  the server instead of unconditionally returning true. For pre-ready Pages
  routes (auto-export dynamic / query-string / rewrite-capable builds) this
  keeps useRouter().isReady consistent between SSR and client hydration,
  avoiding a hydration mismatch for components that read it in JSX. Mirrors
  Next.js render.tsx's server readiness rule.
- Drop the redundant guard branch in getClientParamsSnapshot.
- Add a regression test asserting server useRouter().isReady reflects the
  SSR navigationIsReady context.

* fix(pages-router): align param resolution and dev/prod readiness parity

Address review feedback (ask-bonk + self-review) on #1741:

- Align client Pages navigation params to query-first, matching the server
  snapshot and Next.js's adaptForPathParams (derives path params from
  router.query). __NEXT_DATA__.query is kept current on client navigation, so
  the two snapshots are now provably identical and avoid a latent post-
  hydration mismatch.
- Populate window.__VINEXT_PAGE_PATTERNS__ in the dev server (own script tag)
  so the next/navigation compat hooks can resolve a dynamic pattern from a
  resolved path in dev exactly as in production. Closes a dev/prod parity gap
  (prod set this via the client entry; dev never did).
- Drop the redundant pre-readiness setSSRContext in dev; the single post-
  module-load call now publishes navigationIsReady before rendering, matching
  the prod handler.
- Document that installPagesClientAssetGlobals is intentionally inert for
  App-only builds, and that getReadonlyPagesSearchParams' string-keyed cache
  is safe to share across concurrent SSR requests (immutable wrappers).
- Add a dev regression test asserting __VINEXT_PAGE_PATTERNS__ exposure.

* fix(pages-router): prefer URL params for client snapshots

* fix(app-router): gate pages asset globals

* test(app-router): add production test for hybrid basePath + assetPrefix

* fix(navigation): widen usePathname return type to string | null

* refactor(pages-router): dedupe navigation readiness modeling and cache pattern scan

Addresses code-review cleanup findings on the Pages Router navigation
readiness work:

- Extract buildPagesReadinessNextData() so the dev SSR handler and the
  production Pages page handler derive the gssp/gsp/gip/appGip/autoExport
  __NEXT_DATA__ readiness flags from one place, instead of two hand-kept
  copies that could drift and produce a dev/prod hydration mismatch.
- Extract resolvePagesNavigationPathname() shared by the client and SSR
  branches of getPagesNavigationContext, so the pathname null-ness rule
  (fallback / pre-ready auto-export dynamic) cannot diverge between them.
- Memoize resolvePagesRoutePatternForPath()'s pattern scan, which ran an
  uncached O(routes) loop on every useSyncExternalStore snapshot read.
- In getServerSearchParamsSnapshot, serialize the source only when the
  object-identity fast path misses, avoiding a toString() on every read.

* refactor(pages-router): consistent ISR readiness data + robustness cleanups

Addresses further code-review findings:

- Thread the readiness flags (gssp/gsp/gip/appGip/autoExport) through the ISR
  background-regeneration path: renderPagesIsrHtml now serializes the same
  __NEXT_DATA__ readiness state the initial render emits, so a regenerated,
  re-cached page hydrates with the identical initial router.isReady the server
  computed. Previously these flags were dropped on that path — benign today
  (ISR-eligible getStaticProps routes never trigger the flag-gated early-ready
  branches) but a latent dev/prod hydration-readiness drift. Adds a shared
  PagesNextDataExtras type so every render path stays in lockstep.
- readSsrManifest: degrade a malformed/unparseable SSR manifest to "no SSR
  manifest" with a warning instead of throwing and aborting server startup,
  restoring the previous tolerant behavior.
- pages-readiness: project PagesReadinessNextData from the canonical
  VinextNextData and accept PagesPageModule, instead of bespoke types that
  could drift from the __NEXT_DATA__ shape.
- getReadonlyPagesSearchParams: drop the redundant string-keyed cache slot; the
  per-object WeakMap is authoritative and concurrency-safe on its own.
- isPagesRouterReady: drop the redundant server-side window check.

* fix(pages-router): restore string-keyed readonly search-params cache

Reverts the cache simplification from the previous commit. The string-keyed
slot is load-bearing, not redundant: across the Pages Router pre-ready → ready
transition the navigation context swaps in a new URLSearchParams instance even
when the query string is unchanged, so the per-object WeakMap alone returns a
fresh ReadonlyURLSearchParams wrapper and useSearchParams() loses Object.is
stability — re-firing a [searchParams] effect. Caught by the rewrite-driven
pre-ready static page e2e (search-params-snapshots saw ['',''] not ['']).
Keeps the improved comment explaining why both cache levels are needed.

---------

Co-authored-by: James <james@eli.cx>
2026-06-09 11:30:38 +01:00
Nathan Nguyen 494e8d2d5b fix(pages): render custom errors for notFound results (#1737)
* fix(pages): render custom errors for notFound results

Pages getStaticProps and getServerSideProps notFound HTML results returned the built-in 404 response directly. That skipped the configured pages/404 or pages/_error render path, so _error.getInitialProps never received ctx.req.url or ctx.asPath for the original request.

The page-data resolver now returns a typed notFound signal for HTML requests while preserving JSON-shaped 404 data responses. The generated Pages entry rerenders the configured not-found route with the original asPath, and Pages getInitialProps is loaded through a shared typed helper for prod and dev parity.

* fix(pages): address PR review feedback on getInitialProps binding, err context, and response-sent handling

- Call getInitialProps with component as this receiver
- Thread caught errors through to _error.getInitialProps(ctx.err)
- Check headersSent || writableEnded in dev server after getInitialProps
- Add tests: gSSP notFound, explicit 404, data JSON 404, err on throw, this binding

* refactor(pages): use isResponseSent helper in resolvePagesPageData for sent-check consistency

The prod block in resolvePagesPageData checked only res.headersSent, while
the helper's isResponseSent and the dev server check both headersSent and
writableEnded. The prod PagesReqResResponse only declares headersSent (its
getter is backed by the ended flag), so this is functionally correct in
prod, but the asymmetry is hard to read next to the helper and a future
change to the prod response shape would silently break sent-detection.

Export isResponseSent from pages-get-initial-props and use it at both
gSSP and gIP call sites so the prod resolver reuses the same
sent-detection logic as the helper.

Also adds tests for the writableEnded-only path and a focused unit test
for isResponseSent covering both prod-shaped and node-shaped res mocks.

* perf(pages): gate getInitialProps req/res allocation behind presence check

The getInitialProps prod block in resolvePagesPageData unconditionally
called createGsspReqRes() for every static page without gSP/gSSP, even
when the page had no getInitialProps either. This allocated a req/res
pair and an unresolved responsePromise that was immediately discarded.

Export hasPagesGetInitialProps from pages-get-initial-props.ts and gate
the prod block on it, keeping the hot path for plain static pages
allocation-free.
2026-06-05 21:48:31 +01:00
Stephen Zhou fb233b0a34 feat: improve dev error overlay source frames (#1746)
* Improve dev overlay stack source mapping

* Polish dev error overlay stack display

* Add dev overlay code frames

* Align dev overlay message spacing

* Add dev overlay error copy action

* Fix dev overlay knip exports

* Polish dev overlay layout

* Polish dev error overlay spacing

* Fix dev overlay coverage and source frame metadata

* Fix dev overlay recovery for RSC HMR errors

* Handle Vite HMR build errors in dev overlay

* Fix knip

* Use Shadow DOM for dev error overlay
2026-06-05 12:49:11 +01:00
James Anderson 51690038d8 fix(pages-router): cancel in-flight nav on gSSP/gSP data redirect (#1465) (#1691)
* fix(pages-router): return __N_REDIRECT JSON for gSSP/gSP data redirects (#1465)

When getServerSideProps/getStaticProps returns `{ redirect }` on a
client-side `_next/data` navigation, vinext emitted a real HTTP redirect.
The client's fetch transparently followed it to the destination's HTML
(not a valid data envelope), forcing a hard reload with console errors and
leaving navigation on the wrong page.

Mirror Next.js: for data requests, reply 200 with `__N_REDIRECT` /
`__N_REDIRECT_STATUS` in pageProps. The client router now detects these
markers and re-dispatches a fresh client navigation to the destination,
which increments the navigation id and supersedes (cancels) the in-flight
navigation so the intermediate page never commits. HTML requests still emit
a real HTTP redirect.

Fixed in both server paths to keep dev/prod parity (see AGENTS.md):
- prod: buildPagesRedirectResponse() in pages-page-data.ts branches on isDataReq
- dev: writeGsspRedirect() in dev-server.ts branches on isDataReq
- client: navigateClientData() handles pageProps.__N_REDIRECT via
  handleDataRedirect(), preserving push/replace mode
- tests: data-endpoint redirect shape (vitest) + client cancellation (e2e)

* fix(pages-router): hard-navigate to external __N_REDIRECT destination (#1465)

Address review: handleDataRedirect() passed the originating page URL to the
hard-navigation fallback for external/non-absolute __N_REDIRECT destinations,
which would loop back to the redirecting page (e.g. an OAuth callback at
https://example.com/...). Hard-navigate to the destination instead, matching
Next.js's handleHardNavigation({ url: destination }). Drop the now-unused
fallback parameter and add a server test asserting an external destination is
preserved verbatim in the data envelope.
2026-06-03 22:34:36 +01:00
Nathan Nguyen b1f2e57bc4 fix(pages): preserve import.meta.url source paths (#1598)
* fix(pages): preserve import.meta.url source paths

Pages Router production bundles previously left direct import.meta.url expressions for the bundler to evaluate from generated output. Server renders could expose dist/server/entry.js, and prerendered Pages output could miss the client entry needed for hydration to replace the server value with the Turbopack-style browser source URL.

The fix rewrites direct import.meta.url reads in user modules to source-file URLs at transform time, keeps new URL asset bases intact, and loads the Pages client entry from the build manifest for Node production and prerender servers.

Regression coverage ports the Next.js import-meta deploy test, including the prerender and hydrated browser path.

* fix(tests): run import.meta browser coverage in e2e

The import-meta regression test launched Playwright from a Vitest integration shard. That shard does not install Playwright browser binaries in CI, so it failed before exercising the runtime behavior.

Move the browser assertion into the existing pages-router-prod Playwright project, keep the lower-boundary server and client chunk assertions in Vitest, and cache the import-meta plugin root paths after an early source-text bailout to address review feedback.

* refactor(import-meta-url): canonicalize each module once per transform

The transform path canonicalized the same module id twice: once in the
eligibility check and again when computing the replacement value, so every
rewritten module paid for two fs.realpathSync.native() syscalls.

The eligibility check now returns the canonical id and threads it through to
the value computation, leaving a single realpath per module.

Also widens the extension match to .cts/.mts/.cjs and documents that the
new URL() skip only covers the bare URL identifier, matching Vite's own
asset-detection scope.

* test(pages): drop unrelated prerender from top-level-await setup

The top-level-await production test ran runPrerender in its setup, but the
assertions pass identically whether the routes are prerendered or rendered on
demand. The step added unrelated build work to a test that is not about
prerendering or client-entry injection.

* fix(pages): share client entry fallback

* fix(import-meta-url): address PR review feedback

- Handle ChainExpression (import.meta?.url) in new URL() asset-detection
  skip so the optional-chain variant preserves Vite asset resolution
- Add explicit ChainExpression check in visit() to avoid fragile
  reliance on coincident inner-node start/end ranges
- Add test for new URL('./file', import.meta?.url) preservation
- Fix decodeHtmlText entity ordering (&amp; before &quot;) in both
  unit and E2E test helpers
- Add doc comments: excluded prefixes rationale, node_modules
  early-exit optimization intent

* fix(pages): select client entry by marker in build manifest

findClientEntryFileFromManifest returned the first chunk flagged
isEntry, but a client build can emit several entry chunks (client
entry plus instrumentation/middleware) and manifest order is not
guaranteed. The on-disk fallback already disambiguates by the
vinext-client-entry / vinext-app-browser-entry marker; apply the
same check to the manifest path so both lookups agree, falling back
to the first entry only when nothing is marked.

* refactor(pages): address review feedback on client-entry/import-meta-url

- prod-server: hoist findClientEntryFile out of the manifest if/else
  since it already handles a missing manifest via the on-disk fallback
- import-meta-url: document the two known limitations (destructured
  access, aliased new URL() base) that match Vite's own behavior
- import-meta-url: drop the dead visit(value.callee) for new URL() —
  the callee is always the bare URL identifier

---------

Co-authored-by: James <james@eli.cx>
2026-05-31 11:37:09 +01:00
James Anderson 5d9548dc92 fix(pages-router): invoke Document.getInitialProps so docProps reach SSR (#1633)
* fix(pages-router): invoke Document.getInitialProps so docProps reach SSR (#1361)

`pages/_document.tsx` files that override `static async getInitialProps`
to inject extra props were rendered with an empty `this.props`, because
both the dev-server and the production pages response builder created
the Document element with `React.createElement(DocumentComponent)` and
never called the hook.

The Next.js async-modules e2e fixture
(`test/e2e/async-modules/pages/_document.jsx`) uses exactly that pattern
to surface a `docValue` from `await Document.getInitialProps(ctx)` plus
a top-level `await` constant. PR #1381 (issue #1361) fixed the class
construction crash, but the deploy-suite continued to fail
"csr async page modules" because `<div id="doc-value">` rendered empty.

This change adds a small `loadUserDocumentInitialProps` helper used by
both the Pages Router dev-server (`streamPageToResponse` shell and the
custom-error-page shell) and the production response builder
(`buildPagesShellHtml`). It invokes the override (if any) and spreads
the resolved props on the Document element, mirroring Next.js's
`render.tsx` `<Document {...htmlProps} {...docProps} />` call. The
helper short-circuits when the user did not override the base shim, so
the existing fast path keeps the same number of awaits.

Reference: vercel/next.js packages/next/src/server/render.tsx
(`loadDocumentInitialProps`, `documentElement`).

Tests:
- Adds a new pages-router production e2e case that builds a
  `_document.tsx` with `static async getInitialProps` + top-level
  `await`, plus a custom `404.tsx` that uses top-level `await`, and
  asserts the resolved values reach the rendered HTML.
- Unit-tests `loadUserDocumentInitialProps` directly for the override,
  no-override, and throwing-override cases.

Refs #1361

* fix(pages-router): address bonk review on Document.getInitialProps helper

Two changes from review feedback on PR #1633:

1. Cache the base getInitialProps reference at module level. The previous
   helper did `await import("vinext/shims/document")` on every request to
   compare identity — unnecessary per-request work and an extra await on
   the fast path where the user has no override. Switched to a static
   import + module-scoped constant so the identity check is a synchronous
   reference compare. No-override callers now resolve with one less await.

2. Let user `getInitialProps` errors propagate. Next.js's `loadGetInitialProps`
   does not catch — a throw surfaces as a 500 to the caller. The previous
   version logged-and-swallowed the error and returned null, which
   silently erased docProps on every failing render and made user bugs
   harder to find. vinext now matches Next.js's contract.

Updated the corresponding unit test to assert the throw propagates rather
than that the helper resolves to null.

Refs #1361, #1633
2026-05-28 21:06:28 +01:00
James Anderson 76fe5d8b05 fix(pages-router): render custom pages/500 on SSR throw (#1676)
When getServerSideProps (or any other render-time code) threw in the
production server entry, vinext collapsed the response into a plain
"Internal Server Error" text body. Next.js instead renders the user's
custom pages/500.tsx (with pages/_error.tsx as a fallback). Match that
behaviour so frameworks that rely on custom error pages — and the
Next.js getserversideprops deploy suite — see the expected output.

The catch block in `_renderPage` now re-renders through the same entry
using `pages/500.tsx` (or the `_error` route) with status 500. A new
`__isInternalErrorRender` guard prevents infinite recursion if the error
page itself throws; data (`/_next/data/...`) requests still short-circuit
to the plain text body since they can't return HTML.

Mirrors Next.js base-server.ts `renderToResponseImpl` 500 fallback. The
dev server already renders custom 500/error pages via `renderErrorPage()`
— this aligns prod with that behaviour and adds explicit regression
coverage for both paths via a new gssp-throw fixture.

Refs #1458
2026-05-28 20:08:37 +01:00
James Anderson 15fbe66c47 test(pages-router): regression coverage for edge runtime & OG image API routes (#1338) (#1648)
Adds Pages Router fixtures and dev + production integration tests for the
two scenarios reported as failing in the Next.js Deploy Suite:

- `export const config = { runtime: 'edge' }` API route returns a Web
  Response (issue #1338 case 1 — edge API routes were observed as 500).
- `next/og` `ImageResponse` with `runtime: 'edge'` inside a Pages Router
  API route returns `image/png` (issue #1338 case 2 — OG routes were
  observed as 404).

Ports route handlers from `next.js/test/e2e/edge-pages-support/app/pages/api/hello.js`
and `next.js/test/e2e/og-api/app/pages/api/og.js`. The existing
implementation already wires both code paths correctly via
`handlePagesApiRoute` and the `vinext:og-inline-fetch-assets` plugin; this
PR locks in coverage so a Pages Router-only build cannot silently regress
either path.

Refs #1338
2026-05-28 15:58:51 +01:00
James Anderson f75845ef14 fix(pages-router): emit page scripts with defer in <head> by default (#1636)
* fix(pages-router): emit page scripts with defer in <head> by default

Next.js' Pages Router emits page scripts with the `defer` attribute in
`<head>` when `experimental.disableOptimizedLoading` is false (the
default). vinext emits `<script type="module">` (which already defers
implicitly), but upstream tests assert the literal `defer` attribute
and were failing against the deploy suite.

Add `defer` to module scripts emitted by `collectAssetTags` when
`experimental.disableOptimizedLoading` is not enabled. Surface the
flag in `ResolvedNextConfig` so prod-server respects an explicit
opt-out. Browser behaviour is unchanged (module scripts already
defer), but parity tests now pass.

Fixes #1519.

* test(e2e): update hydration regex to allow defer attribute

The script tag regex in `tests/e2e/cloudflare-pages-router/hydration.spec.ts`
required `type="module"` to be immediately followed by `src="…"`, but the
optimized-loading fix inserts a `defer` attribute between them. Relax the
regex to allow any whitespace-separated attributes between `type="module"`
and `src` so future attribute additions don't break it either.
2026-05-28 14:12:15 +01:00
James Anderson a20c49ead2 fix(middleware): stop merging original query into middleware rewrite target (#1632)
* fix(middleware): stop merging original query into middleware rewrite target

Next.js middleware constructs the rewrite-target URL itself — by mutating
`request.nextUrl` or by passing a fresh URL — and whatever search params
that URL carries IS the final query. vinext was silently re-merging the
original request's query on top, which resurrected keys that middleware
had explicitly removed via `searchParams.delete(...)`.

Tests:
  - Add `mw-clear-query-params` Pages Router fixture + dev test ported from
    Next.js `test/e2e/middleware-rewrites/test/index.test.ts` ("should
    clear query parameters").
  - Add prod-server tests for the original-query-preservation paths
    (middleware now propagates query via `request.nextUrl.clone()` in the
    fixture, mirroring Next.js `app/middleware.js`).
  - Add App Router parity test for original-query-preservation.

Closes #1342

* test(middleware): address bonk review nits for #1342

- Fix misleading comment in app-router.test.ts that referenced Object.assign
  merge semantics — the PR's premise is that vinext should NOT auto-merge.
- Add prod-server regression test for searchParams.delete on rewrite target,
  mirroring the dev coverage so both code paths are exercised explicitly.
2026-05-28 14:08:54 +01:00
James Anderson 35bb1a2250 fix(pages-router): return 405 with Allow: GET, HEAD on POST to static pages (#1631)
Pages Router previously rendered HTML for any HTTP method, including
POST/PUT/DELETE, on static (no `getServerSideProps`) pages — masking
client bugs that Next.js surfaces as 405.

Mirrors Next.js's behavior in `base-server.ts` (the
`(typeof components.Component === 'string' || isSSG)` gate), which
rejects non-GET/HEAD methods on static pages with
`Allow: GET, HEAD` and a `Method Not Allowed` body. Pages with
`getServerSideProps` continue to accept any method, since user code
may legitimately read `req.method` inside gSSP.

The new `resolvePagesPageMethodResponse` helper is wired into both
the dev-server SSR handler and the prod-server pages-server-entry, and
skips `/_error`, `/404`, `/500`, data requests, and explicit status
overrides — matching the upstream carve-outs.

Refs #1463. The `res.revalidate()` portion of #1463 is left for a
follow-up.
2026-05-28 14:05:30 +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
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
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 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
Nathan Nguyen 3ebc28f0e1 fix: preserve Next.js cache headers for prerendered app pages (#1424)
* fix: preserve Next.js cache headers for prerendered app pages

* fix: address cache header review feedback
2026-05-22 10:08:08 +01:00
James Anderson 81bdd6acd3 fix(pages-router): render loading shell for unlisted fallback: true paths (#1414)
* fix(pages-router): render loading shell for unlisted `fallback: true` paths

PR #1394 fixed the post-rewrite static-vs-dynamic priority sub-problem
from #1331 and explicitly tracked `fallback: true` rewrite behaviour
separately. When `getStaticPaths` returns `fallback: true` and a request
hits a path not in the pre-rendered list, vinext was SSRing the page
fully — running `getStaticProps` and emitting `isFallback: false` in
`__NEXT_DATA__`. Next.js instead ships a loading shell with empty props
and `useRouter().isFallback === true`, then the client hydrates by
fetching the data URL. The upstream regression test
`should rewrite to fallback: true page successfully` in
`test/e2e/middleware-rewrites/test/index.test.ts` asserts "Loading..."
in the SSR HTML, which vinext could not produce.

Thread an `isFallback` signal through `resolvePagesPageData`,
`buildPagesNextDataScript`, the Pages Router SSR context, and both the
prod (`pages-server-entry.ts`) and dev (`dev-server.ts`) renderers:

- `getStaticProps` and `getServerSideProps` are skipped on the fallback
  render (matches Next.js `render.tsx`'s `if (isSSG && !isFallback)`
  gate around `getStaticProps`).
- Data requests (`/_next/data/<buildId>/<page>.json`) still resolve real
  props so the client can swap in after the shell ships.
- `useRouter().isFallback` reads from the SSR context server-side and
  from `__NEXT_DATA__.isFallback` client-side — both flip to true on
  the shell render.

The dev-server comment that claimed Next.js dev "also renders fully"
was incorrect; Next.js's `pages-handler.ts` invokes `doRender` with
`isFallback: true` for dev fallback responses, so dev now serves the
shell too.

Tests:
- Updated `tests/pages-router.test.ts` "SSR renders unlisted path with
  getStaticPaths fallback: true" — previously documented the deviation;
  now asserts the loading shell, empty props, and `isFallback: true`.
- Added a counterpart test that the data URL for the same unlisted
  path still returns real props from `getStaticProps`.
- Added a production-side regression test mirroring the upstream
  "should rewrite to fallback: true page successfully" expectation.

Refs #1331

* test(pages-page-data): include isFallback: false in normalized render assertion

The render branch of `resolvePagesPageData` now returns `isFallback`
alongside the other fields (added in the parent commit for the
`fallback: true` loading shell). Update the strict-shape `toEqual` in
the cache-miss unit test so it matches the new return shape.
2026-05-21 22:17:05 +00:00
James Anderson 512be7f25c fix(middleware): preserve route priority on rewrite target resolution (#1394)
* fix(middleware): preserve route priority on rewrite target resolution

When `trailingSlash: true` is set in next.config.js, a request like
`/rewrite-1/` was matched against the next.config.js rewrite source
`/rewrite-1` literally, including the trailing slash. The match failed,
so the request fell through to the dynamic `[id]` route and rendered
the dynamic page with `id="rewrite-1"` instead of being rewritten to
the static target.

Strip a single trailing slash from the incoming pathname before
matching against config rewrite/redirect/header sources, matching
Next.js's `source + '(/)?'` behavior under `trailingSlash: true`. The
source pattern itself is preserved so catch-all (`:param*`, `:param+`)
and root (`/`) semantics are unaffected.

Refs #1331

* address bonk feedback: locale-static path, comment wording, tighter tests

- Extract `stripTrailingSlashForConfigMatch` and call it from
  `matchRedirect` so the locale-static fast path (Map.get on the
  pathname) also honors trailing slashes — previously a pure
  locale-static redirect rule could miss when `trailingSlash: true`.
- Clarify the comment to reflect that the strip is unconditional
  defense-in-depth rather than a faithful port of Next.js's
  conditional `(/)?` suffix.
- Disambiguate the pages-router integration tests by asserting on
  `id="home"` / `id="ssr"` so the assertion can't pass on the wrong
  page (both pages render "Hello World").
- Add direct unit tests for `matchConfigPattern` trailing-slash
  behavior covering exact, :param, regex-group, root, and catch-all
  cases.

* extend trailing-slash normalization to matchHeaders

Bonk flagged that matchHeaders runs its own compiled regex path that
doesn't go through matchConfigPattern, so a header source like
`/api/:path*` would miss `/api/foo/` under `trailingSlash: true`.

Normalize once at the top of matchHeaders the same way matchRedirect
already does, and add a regression test.
2026-05-21 22:15:23 +01:00
James Anderson d504f022b0 fix(assets): default assetsDir to _next/static (Next.js parity) (#1411)
* fix(assets): default assetsDir to _next/static (Next.js parity)

Closes #1337. Supersedes #1383.

vinext's default `assetsDir` was Vite's historical `assets/`, so URL emission
(`/_next/static/...` via `resolveAssetUrlPrefix("")`) and on-disk layout
(`dist/client/assets/...`) disagreed in the empty-`assetPrefix` case. The
`build.assetsDir` and `experimental.renderBuiltUrl` Vite overrides only
applied when `assetPrefix` was configured, leaving the no-prefix branch
serving from a different path than the URL contract Next.js's client
runtime and test harness assert against.

This commit flips the default to Next.js's canonical layout:

- `resolveAssetsDir("")` returns `_next/static` (was `assets`)
- `build.assetsDir` is now set unconditionally from `resolveAssetsDir`
- Vite's default `base + assetsDir` composition produces correct URLs
  in the no-prefix case; `renderBuiltUrl` stays gated on `assetPrefix`
  because it's only needed for the configured cases
- Drops the legacy `/assets/` branches in prod-server (3 sites) and
  static-file-cache (hard cutover — see PR description for rationale)
- Updates `_headers` generation, precompress default, fonts plugin
  `DEFAULT_ASSETS_DIR` to match the new default

With the layout aligned, invalid `_next/static/*` requests naturally
return plain-text `404 + "Not Found"` from the static-file layer instead
of falling through to the page renderer (which would produce an HTML
404 with bootstrap scripts and CSS). This replaces #1383's parity
short-circuit with the natural code path:

- Node prod-server (App + Pages branches): missing asset under
  `resolveAppRouterAssetPath` returns `text/plain; charset=utf-8` 404
- Cloudflare worker entry: `isNextStaticPath` recognises asset-shape
  after ASSETS-binding misses; returns `notFoundStaticAssetResponse`

Mirrors Next.js: packages/next/src/server/lib/router-server.ts.

Adds `tests/invalid-static-asset-404.test.ts` (3 App Router + 3 Pages
Router cases — no prefix / basePath / assetPrefix) ported from Next.js
e2e suites. Updates 11 source files + 12 test files; 914+ tests pass
in the touched suites (asset-prefix, app-router, pages-router, deploy,
font-google, static-file-cache, serve-static, precompress, features,
routing, isr-cache, build-optimization, app-rsc/ssr, standalone,
middleware, shims).

Ported from Next.js:
- test/e2e/invalid-static-asset-404-app/*.test.ts
- test/e2e/invalid-static-asset-404-pages/*.test.ts

* fix(e2e): update cloudflare-pages-router hydration spec for _next/static

* PR #1411 approved. Clean fix, 2 nits.

Co-authored-by: james-elicx <james-elicx@users.noreply.github.com>

* refactor(assets): derive isHashed checks from ASSET_PREFIX_URL_DIR

Replace hardcoded '_next/static' string literals in runtime code paths
with the ASSET_PREFIX_URL_DIR constant so the asset URL convention has
a single source of truth.

- server/prod-server.ts isHashed: pathname.includes(`/${ASSET_PREFIX_URL_DIR}/`)
- server/prod-server.ts public-dir guard: startsWith(`/${ASSET_PREFIX_URL_DIR}/`)
- server/static-file-cache.ts isHashed: both startsWith + includes derived
- plugins/fonts.ts DEFAULT_ASSETS_DIR = ASSET_PREFIX_URL_DIR

Behavioural no-op — the constant is '_next/static' so the resolved
strings are identical. Centralises the URL contract so future changes
to ASSET_PREFIX_URL_DIR (or a refactor that derives it from build
config) flow through to every consumer.

Doc-comment references to '_next/static' left in place — they
describe the canonical value for human readers and are not code paths.

---------

Co-authored-by: ask-bonk[bot] <ask-bonk[bot]@users.noreply.github.com>
Co-authored-by: james-elicx <james-elicx@users.noreply.github.com>
2026-05-21 20:36:04 +00:00
James Anderson 576c55d7db feat(pages-router): implement _next/data JSON endpoint (#1384)
* feat(pages-router): implement _next/data JSON endpoint

Recognize `/_next/data/<buildId>/<page>.json` in dev and prod request
pipelines. The URL is normalized to the page path BEFORE middleware
runs (matching Next.js' `handleNextDataRequest`), and the server emits
`{ pageProps }` as JSON with `Content-Type: application/json` instead
of the rendered HTML.

Closes #1330

* fix(pages-router): address bigbonk findings on _next/data endpoint

- Delegate JSON envelope construction from the generated entry to the
  typed `buildNextDataJsonResponse` helper so the envelope shape lives
  in one place (entries stay thin per AGENTS.md).
- Return a JSON 404 from `resolvePagesPageData` when `getStaticPaths`
  with `fallback: false` rejects the path for a data request. Without
  this, client-side navigations to unlisted slugs got an HTML 404 body
  which fails to parse as JSON in the Next.js client router.
- Apply the same JSON-404 gating in the dev server for
  `getStaticPaths fallback:false`, `getServerSideProps.notFound`, and
  `getStaticProps.notFound` paths.
- Read the dev buildId from the plugin's resolved `nextConfig.buildId`
  first so a user-supplied `generateBuildId` is honored, matching the
  value embedded into the prod server entry.
- Add coverage for `getStaticProps` data requests and the
  `fallback:false` JSON-404 path.

* chore: drop unused NextDataMatch export (knip)

* fix(pages-router): JSON 404 for dev stale buildId; HTML 404 for gSSP/gSP notFound on HTML nav

Follow-ups from bigbonk round 2:

- Dev request pipeline now returns a JSON 404 when `_next/data/<wrong>` is
  hit, mirroring the prod-server path. Previously the dev fall-through
  served Vite's HTML 404, breaking client routers that try to parse the
  body as JSON.
- `resolvePagesPageData` now gates the gSSP/gSP `notFound: true` 404 body
  on `isDataReq` so HTML navigations get the HTML 404 page and data
  requests get the JSON envelope. Previously both shapes always returned
  the JSON body, which surfaced text-content-type mismatches on HTML
  navigations through gSSP/gSP `notFound`.
2026-05-21 15:55:33 +01:00
James Anderson 2d035d47bd fix: await top-level await module imports (#1381)
* fix: make next/document default export a class

Next.js's `next/document` default export is `class Document extends
React.Component`. vinext's shim exported a function, so user
`_document.tsx` files that use the class-based form (the common Next.js
idiom: `class MyDocument extends Document`) ended up extending a
function. React refuses to call class constructors without `new`, which
caused SSR to throw "Class constructor MyDocument cannot be invoked
without 'new'" — surfacing in e2e as 500 / empty responses.

This is the actual root cause of the "async modules render empty pages"
report (issue #1361): the Next.js `test/e2e/async-modules` fixture's
`pages/_document.jsx` uses `class extends Document`, and once it crashes
the whole page renders empty. Top-level await itself propagates
correctly through Rolldown's static-import graph already.

Adds a class-based Document shim that mirrors Next.js's signature
(static `getInitialProps`, instance `render()`), plus production-build
e2e coverage for pages, `_app.tsx`, `getStaticProps`,
`getServerSideProps`, API routes, and class-based `_document.tsx`, all
using top-level `await`.

Closes #1361

Ports from Next.js: test/e2e/async-modules/index.test.ts
https://github.com/vercel/next.js/blob/canary/test/e2e/async-modules/index.test.ts

* fix(next/document): align Document base class with Next.js's typed contract

Addresses Bonk review feedback on PR #1381:

- Export DocumentContext / DocumentInitialProps types matching Next.js,
  so subclasses that delegate via `await Document.getInitialProps(ctx)`
  receive typed objects. The runtime path is still a stub (the Pages
  Router renderPage/defaultGetInitialProps chain is not wired up), but
  the signatures pin the contract.
- Declare the default Document class export in next-shims.d.ts so
  strict-mode consumers don't see `any` for `import Document from
  "next/document"`.
- Add a class-extends regression test in tests/document.test.ts — this
  is the contract that broke in issue #1361 (the original Next.js
  `pages/_document.jsx` uses `class extends Document`).
- Switch the class generic default from `unknown` to `{}` to match
  Next.js's `class Document<P = {}>`.
- Clarify App Router fixture comment: the upstream Next.js async-modules
  suite is Pages-Router-only, so this is adapted, not ported verbatim.
2026-05-21 12:03:33 +01:00
James Anderson bc586788a2 fix(middleware): preserve query params on rewrite (#1375)
* fix(middleware): preserve query params on rewrite

When middleware (or a `next.config.js` rewrite) sends a request to a new
target, vinext was throwing away the original request's query string,
breaking `getServerSideProps`/route-handler `query` for any URL of the
form `/rewrite-me?key=value`.

Match Next.js's behaviour:
`Object.assign(parsedUrl.query, rewrittenParsedUrl.query)` — keep the
original query first, let the rewrite-target's query win on key
conflicts. Applied to:

- middleware rewrites (middleware-runtime.ts)
- beforeFiles / afterFiles / fallback config rewrites (prod-server.ts,
  index.ts, deploy.ts)

Closes #1342

References:
- https://github.com/vercel/next.js/blob/canary/packages/next/src/server/route-modules/route-module.ts (Object.assign merge)
- https://github.com/vercel/next.js/blob/canary/test/e2e/edge-pages-support/index.test.ts (rewrite query expectations)

* test(query): document URL-encoded space normalization on rewrite merge

Addresses Bonk review observations on #1375:
- Add test confirming `%20` → `+` normalization through URLSearchParams
  is intentional and lossless (downstream consumers re-parse).
- Drop unreachable `?? ""` fallback in mergeRewriteQuery; `String#split`
  always yields a string.
2026-05-21 11:38:52 +01:00
James Anderson 517c2bde19 feat(pages-router): expose window.next.router on hydration (#1400)
* feat(pages-router): expose window.next.router on hydration

Promote next/router from a lazy dynamic import inside hydrate() to a
static side-effect import at the top of the generated client entry, so
window.next = { version, router, ... } is published before any test
harness or third-party script reads it.

Without this, the Next.js deploy test suite — which drives client-side
navigations via `browser.eval('next.router.push(...)')` — crashes with
`TypeError: Cannot read properties of undefined (reading 'router')` (~80
test failures across middleware-general, middleware-rewrites,
trailing-slashes, basepath, with-router, and ignore-invalid-popstateevent).

Mirrors Next.js's bootstrap order in
.nextjs-ref/packages/next/src/client/next.ts (line 5–13), which
statically imports the router from './' and sets window.next as a
top-level side effect before initialize/hydrate.

Closes #1329

* feat(router): expose state fields as live getters on Router singleton

window.next.router must expose `pathname`, `route`, `query`, `asPath`,
`basePath`, `locale`, `locales`, `defaultLocale`, `domainLocales`,
`isReady`, `isPreview`, and `isFallback` as live properties — not just
the navigation methods. The Next.js deploy test suite drives navigations
via `browser.eval('next.router.push(...)')` and then asserts on
`browser.eval('next.router.pathname')` to verify the navigation
completed; without the field getters the assertion reads undefined.

Mirrors Next.js's `singletonRouter` shape in
.nextjs-ref/packages/next/src/client/router.ts (lines 32-47), which
uses Object.defineProperty for urlPropertyFields so reads forward to
the active router state.

Strengthens the existing shims.test.ts coverage so future regressions
of the field surface fail at unit-test time.
2026-05-21 10:26:23 +00:00
James Anderson b01fe032fa fix(pages-router): avoid getServerSideProps identifier collision in build (#1370)
When a page declared `getServerSideProps` (or `getStaticProps` /
`getStaticPaths`) as a local binding and re-exported it via
`export { getServerSideProps }`, the client-bundle transform overwrote
the `export { ... }` statement with `export const getServerSideProps =
undefined;`. The original local `const getServerSideProps = ...` was
left in place, producing two declarations of the same identifier in
module scope. Rolldown/OXC rejects this with `Identifier
'getServerSideProps' has already been declared`, breaking the entire
production build (and cascading to 48 test failures in Next.js's
`test/e2e/getserversideprops` suite).

Drop the export specifier without emitting a stub, matching Next.js's
`next-ssg-transform` behaviour. The now-unused local declaration becomes
dead code and is tree-shaken later.

Closes #1354
2026-05-21 10:32:44 +01:00
James Anderson 9415a238f4 feat(config): implement assetPrefix (#1311)
* feat(config): parse and apply next.config assetPrefix at build time

Wire `assetPrefix` from next.config through to the Vite build so every
emitted JS/CSS/image/static asset URL is prefixed accordingly. Mirrors
Next.js semantics: `assetPrefix` controls asset URLs only, independent
of `basePath` (which still controls route URLs).

* Add `assetPrefix` to NextConfig and ResolvedNextConfig, normalised via
  a new `normalizeAssetPrefix` helper that trims trailing slashes,
  accepts absolute URLs (`https://cdn.example.com[/sub]`) and path
  prefixes (`/custom-asset-prefix`), and rejects non-string / unparseable
  values.
* New `packages/vinext/src/utils/asset-prefix.ts` module collects the
  shared helpers: `resolveAssetsDir` (on-disk layout), `resolveAssetUrlPrefix`
  (emitted URL prefix), `assetPrefixPathname` (path portion for runtime
  matching), and the canonical `_next/static` directory constant.
* In the Vite plugin, set `build.assetsDir` so on-disk layout matches the
  URL (`<prefix>/_next/static/` for path prefixes, `_next/static/` for
  absolute URLs). Use `experimental.renderBuiltUrl` to override Vite's
  default `base + url` URL emission so `assetPrefix` and `basePath` stay
  independent — routes continue to anchor at `base = basePath + '/'`.
* Persist `assetPrefix` in the embedded `vinextConfig` for the Pages
  Router server and in a new `vinext-runtime-config.json` sibling for
  the App Router prod server (the RSC entry only embeds `__basePath`).

@see https://nextjs.org/docs/app/api-reference/config/next-config-js/assetPrefix

* feat(server): serve assetPrefix-prefixed assets at runtime

Update the production server to resolve incoming asset requests under
`<assetPrefix>/_next/static/<filename>` (path-prefix form) and
`/_next/static/<filename>` (absolute-URL form) in addition to the
historical `/assets/` URLs. Both routers (Pages and App) share a new
`resolveAppRouterAssetPath` helper that returns the lookup path on disk
or `null` to fall through.

* App Router prod server loads `assetPrefix` from the new
  `vinext-runtime-config.json` sibling written by the Vite plugin.
* Pages Router prod server reads `assetPrefix` from the embedded
  `vinextConfig` and threads it through the existing
  `stripBasePath` lookup.
* StaticFileCache marks any path under `_next/static/` as content-hashed
  (alongside the existing `assets/` rule) so emitted bundles get the
  long-lived `immutable` cache headers. Same change in the slow-path
  branch of `tryServeStatic`.

Cloudflare Workers needs no code change: the ASSETS binding serves
`<prefix>/_next/static/...` directly from `dist/client/<prefix>/_next/static/...`
because the on-disk layout already mirrors the URL. Absolute-URL
prefixes are expected to be served by the CDN, so they don't reach the
worker.

* test(asset-prefix): add unit + e2e build tests for next.config assetPrefix

Covers the surface area added in the previous two commits:

* Unit tests on the new helpers: `normalizeAssetPrefix` (path vs URL,
  trailing-slash trimming, validation), `resolveAssetsDir` /
  `resolveAssetUrlPrefix` (on-disk vs emitted-URL layout),
  `assetPrefixPathname` (runtime matching), and
  `resolveAppRouterAssetPath` (request → on-disk lookup translation).
* End-to-end build tests that exercise `vinext build` against the
  `tests/fixtures/app-basic` app patched with each `assetPrefix` shape:
    - Path prefix (`/custom-asset-prefix`): SSR HTML references
      `/custom-asset-prefix/_next/static/` URLs, on-disk layout matches,
      and fetching the bootstrap URL from the prod server returns 200.
    - Absolute URL (`https://cdn.example.com`): emitted asset URLs are
      fully qualified; no `/assets/` leaks.
    - Combined `basePath: '/app'` + `assetPrefix: '/cdn-prefix'`: routes
      stay under `/app/` while asset URLs live under `/cdn-prefix/` —
      proving the two configs stay independent.
    - Unset (default): historical `/assets/` layout is preserved.

Ported from Next.js: test/e2e/app-dir/asset-prefix/asset-prefix.test.ts
https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/asset-prefix/asset-prefix.test.ts

* test(next-config): add assetPrefix field to makeResolved helper

ResolvedNextConfig requires assetPrefix; the test helper was missing
it which broke the type check.

* fix(config): replace polynomial regex with loop (CodeQL)

CodeQL flagged trim().replace(/\/+$/, "") and replace(/^\/+/, "")
as polynomial-time backtracking on uncontrolled input. Swap both for
explicit while loops with linear-time semantics. Same fix applied to
the URL pathname trim in assetPrefixPathname().

* refactor(prod-server): drop dead null fallback; tighten hashed-asset detection

- Remove the `?? null` after resolveAppRouterAssetPath; the helper
  already returns null on miss, so the fallback (and its obsolete
  comment) were no-ops.
- Add explicit startsWith("_next/static/") / startsWith("/_next/static/")
  prefix checks before the substring fallback in both static-file-cache
  and prod-server. The substring check stays so assetPrefix-prefixed
  layouts (e.g. "cdn/_next/static/") are still recognised.

* fix(build/precompress): use resolved assetsDir so assetPrefix builds get compressed

precompressAssets() hardcoded `<clientDir>/assets/`. When `assetPrefix`
is configured the build writes hashed assets to a different subdirectory
(`<prefix>/_next/static/` for path prefixes, `_next/static/` for
absolute URLs) and the precompression step walked an empty directory,
silently emitting zero .br/.gz/.zst files. Cloudflare Workers builds
with assetPrefix would then serve uncompressed assets.

Thread the resolved subdirectory through from resolveAssetsDir(assetPrefix)
at the plugin call site, default to `"assets"` for backward compat,
and add focused tests for both assetPrefix layouts.

* refactor: drop redundant isHashed startsWith; harden test cleanup against build failures

- prod-server.ts: drop the redundant `pathname.startsWith("/_next/static/")`
  check; `pathname` always has a leading slash so the existing
  `includes("/_next/static/")` already covers it.
- static-file-cache.ts: keep both `startsWith("_next/static/")` and
  `includes("/_next/static/")` because `relativePath` has no leading
  slash — the prefix and substring checks cover different layouts
  (absolute-URL prefix vs path-prefix). Clarify the comment.
- tests/asset-prefix.test.ts: register the tmp-dir cleanup with the
  shared `cleanups` array synchronously inside `buildFixtureWithConfig`,
  right after `mkdtempSync`. Previously the cleanup was only pushed
  after the build returned, so a thrown `createBuilder`/`buildApp`
  would leak the tmp dir.

* refactor(build): embed assetPrefix in App Router entry instead of sidecar JSON

Mirrors the existing __basePath embed pattern (see entries/app-rsc-entry.ts)
and how Pages Router exposes `vinextConfig.assetPrefix` from its own entry.
The runtime-config.json sidecar was unnecessary — the App Router entry
template already inlines config values as compile-time constants, so
assetPrefix should be inlined the same way.

Changes:
- Add `assetPrefix` to `AppRouterConfig` in entries/app-rsc-entry.ts.
- Emit `export const __assetPrefix = <json>` from the generated RSC
  entry alongside the existing `__basePath` / `__configHeaders` /
  `__expireTime` constants. Single-line declaration to stay within the
  "thin generated entry" guideline.
- Thread `nextConfig.assetPrefix` through the generateRscEntry call site
  in index.ts.
- App Router prod-server reads `rscModule.__assetPrefix` (with a string
  fallback for older builds) instead of `vinext-runtime-config.json`.
- Delete the vinext-runtime-config.json write in index.ts. Nothing else
  in the repo reads this file (verified via rg on packages/ tests/
  examples/).

Pages Router behaviour is unchanged: it already gets assetPrefix from
the embedded `vinextConfig` exported by pages-server-entry.ts.

Addresses james-elicx review feedback on #1311.

* fix(ssr): emit bootstrap as <script src> via bootstrapModules

The App Router SSR pipeline previously passed only `bootstrapScriptContent`
to React's renderToReadableStream, producing inline
`<script>import("/path/to/entry.js")</script>` tags. The URL was therefore
inaccessible to anything that reads `script.attribs.src` — including the
Next.js asset-prefix fixture test "bundles should return 200 on served
assetPrefix" which iterates script tags looking for a `src` attribute
prefixed by the configured assetPrefix.

Extract the bootstrap URL from the plugin-rsc-generated content and pass
it as `bootstrapModules: [url]` so React emits a real
`<script type="module" src="url">` tag. The existing
`extractModulePreloadHtml` helper already did the same extraction for
the modulepreload <link>, so the URL was already exposed (just renamed
`extractBootstrapModuleUrl` and lifted out of the head-injection helper
to share between the bootstrap and the preload tags).

The inline `bootstrapScriptContent` is no longer needed — it was only
carrying the dynamic-import call that bootstrapModules now performs
natively. Passing both would load the bootstrap module twice.

`bootstrapModules` works in both dev and prod: in dev, plugin-rsc emits
a Vite dev-server URL (e.g. `/@id/__x00__virtual:vinext-app-browser-entry`)
that the browser fetches as a module from the dev server; in prod it's the
hashed bundle URL, optionally prefixed by `assetPrefix`.

Mirrors Next.js's app-render path which combines both
`bootstrapScriptContent` (for session state) and `bootstrapScripts`
(for the actual chunk URL). See
.nextjs-ref/packages/next/src/server/app-render/required-scripts.tsx.
We use `bootstrapModules` instead of `bootstrapScripts` because vinext's
chunks are native ES modules (Vite output) — a `type="module"` tag is the
correct loader.

Tests updated where they asserted on the old `import("URL")` shape:
tests/asset-prefix.test.ts, tests/app-router.test.ts,
tests/e2e/cloudflare-workers/navigation.spec.ts.

Verified all related suites pass locally (asset-prefix, app-router,
script-nonce, script-preload, use-client-page-pathname, document,
prerender, ISR, fetch-cache, entry-templates, precompress).

* fix(build): use resolveAssetsDir for Pages Router client entry fallback scan

The Pages Router Cloudflare Workers post-build step scans the on-disk
assets directory for the client entry chunk when the SSR manifest
lookup doesn't surface one. The directory path was hardcoded to
`<clientDir>/assets/`, which is the Vite default — but when
`assetPrefix` is configured, the chunk lives under
`<prefix>/_next/static/` (path-prefix) or `_next/static/` (absolute-URL
prefix). The fallback would silently miss the entry and the worker
would ship without its client bootstrap reference.

Use the shared `resolveAssetsDir(assetPrefix)` helper that already
drives `build.assetsDir` and the prod-server lookup, so this scan
works for every supported asset layout.

Addresses round-4 reviewer feedback on #1311 (low-priority follow-up
item — only fires when the SSR manifest lookup fails).

* feat(config): mirror Next.js basePath → assetPrefix fallback

Next.js auto-sets `assetPrefix = basePath` when `basePath` is configured
and `assetPrefix` is left unset (packages/next/src/server/config.ts:528-531).
Without this, an app deployed under a basePath serves its routes
correctly but emits its assets from `<basePath>/assets/...` (Vite's
default `base + assetsDir` composition) rather than from the Next.js-
canonical `<basePath>/_next/static/...`.

Add the same fallback in resolveNextConfig, gated by Next.js's own
conditions:
  - basePath !== ""   (skip when basePath is unset)
  - basePath !== "/"  (Next.js rejects this earlier; we mirror the
                       guard so we never produce assetPrefix === "/")
  - assetPrefix === "" (user did not opt out)

The fallback feeds the rest of the asset-prefix pipeline (Vite's
`build.assetsDir`, `experimental.renderBuiltUrl`, and the prod-server
URL→file resolver) so no other code paths need to change.

Tests:
 - tests/next-config.test.ts: 5 unit tests for the four edge cases of
   the fallback condition.
 - tests/asset-prefix.test.ts: new end-to-end test that builds with
   `basePath: "/app"` (no assetPrefix), starts the prod-server, and
   asserts the bootstrap <script src> lands at /app/_next/static/...
   (and that the legacy /app/assets/... layout is gone).
 - tests/pages-router.test.ts: updated the "preserves basePath on
   backfilled SSR manifest entries" test to expect the new
   /docs/_next/static/ layout (was /docs/assets/). The manifest
   assertion is relaxed to startsWith("docs/") because Vite's raw
   SSR manifest now emits some duplicate-prefixed entries
   ("docs/docs/_next/static/...") alongside the backfilled ones; both
   forms anchor under basePath so the prod-server's URL→file lookup
   remains correct — and the user-visible HTML assertions in the same
   test are the source of truth.

Addresses round-4 reviewer feedback on #1311.

* fix(server): Pages Router must not strip basePath before asset lookup

The Pages Router prod-server stripped `basePath` from the request
pathname BEFORE handing it to `resolveAppRouterAssetPath`, which broke
the asset lookup whenever the assetPrefix path-prefix branch was active
with a path that happened to equal basePath.

Concrete failure: with the round-4 commit `bac8425` mirroring Next.js's
`basePath → assetPrefix` fallback, an app configured with
`basePath: "/docs"` (and no explicit `assetPrefix`) now resolves
`assetPrefix = "/docs"`. Emitted asset URLs are
`/docs/_next/static/…` (correct). But the Pages Router branch did:

  const staticLookupPath = stripBasePath(pathname, basePath);
  // "/docs/_next/static/x.js" → "/_next/static/x.js"
  resolveAppRouterAssetPath(staticLookupPath, "/docs", "/docs");
  // path-prefix branch needs pathname to start with "/docs/" → null → 404

The App Router branch already passes the un-stripped `pathname` to the
same helper (see line ~1140). Match that behaviour. `staticLookupPath`
is still computed and used downstream by non-asset paths
(image-optimization endpoint, page routing), where the basePath-stripped
form is correct.

Test coverage:
 - tests/pages-router.test.ts: extended the existing "preserves basePath
   on backfilled SSR manifest entries" test to FETCH every emitted asset
   URL and assert HTTP 200 (previously it only inspected HTML for the
   URL string). This is the missing assertion that would have caught
   the bug the first time.
 - tests/asset-prefix.test.ts: added a parallel Pages Router e2e test
   that builds an inline pages/ fixture with `basePath: "/docs"`,
   starts the prod-server, collects every script/link URL from the SSR
   HTML, and asserts every URL serves a 200 + correct content-type.
   Confirmed to fail on `bac8425` before this fix and pass after.

Addresses round-5 reviewer feedback on #1311.

* refactor(ssr): accept either quote style in bootstrap URL regex

plugin-rsc currently emits the bootstrap as `import("<url>")` with
double quotes, but the contract isn't documented and a future version
could switch to single quotes. Tighten the URL extraction regex to
`import(["'])([^"']+)\1` so either form works.

Addresses non-blocking round-5 review feedback on #1311.

* docs(ssr): note CSP implications of bootstrapModules switch

The round-3 commit `3a2c886` switched the App Router SSR bootstrap from
inline `bootstrapScriptContent` (`<script>import(…)</script>`) to
React's `bootstrapModules` option (`<script type="module" src=…>`).
That has user-visible CSP consequences worth documenting at the call
site so app authors hit them in code search rather than at runtime:

  - Apps no longer need `script-src 'unsafe-inline'` for the bootstrap
    (improvement — inline imports previously required it).
  - Apps that restrict script sources need `'self'` for same-origin,
    or the CDN origin when assetPrefix is an absolute URL.
  - React still threads nonce through to the emitted <script> tag, so
    nonce-based CSP (`script-src 'nonce-…' 'strict-dynamic'`) keeps
    working unchanged.

Addresses non-blocking round-5 review feedback on #1311.
2026-05-19 11:53:49 +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 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 4aa7663084 fix(app-router): hard navigate stale RSC build payloads (#1178)
* fix(app-router): hard navigate stale RSC build payloads

App Router soft navigation currently accepts any valid RSC payload after deploy. That lets stale tabs decode RSC responses produced by a different build, which can break client navigation when the server and browser artifacts no longer match.

The missing invariant was that every RSC response consumed by the browser must identify the build that produced it, and the browser must reject payloads from a different build before handing them to React.

Emit a vinext-owned build ID header on App Router RSC responses, preserve it in prefetched and visited response snapshots, and hard navigate when a response is missing or mismatches the current client build ID.

* chore: fix e2e fixture hook lint suppression

* chore: avoid hook-like fixture callback name

* fix(app-router): preserve build IDs for cached RSC payloads

* fix(app-router): use public RSC compatibility IDs
2026-05-15 12:01:33 +01:00
Nathan Nguyen 07be32258f fix(router): preserve filesystem routes before afterFiles rewrites (#1166)
* fix(router): preserve filesystem routes before afterFiles rewrites

afterFiles rewrites were evaluated before App and Pages filesystem route matches in several runtime paths. That let a rewrite override an existing non-dynamic page, which diverges from Next.js route ordering.

The fix checks the page/app match first and only applies afterFiles rewrites when no non-dynamic route wins, while still allowing afterFiles to run before dynamic routes. Regression coverage exercises App RSC handler, Pages dev/prod, and generated Worker wiring.

* test(router): align chained afterFiles rewrite expectations

The chained rewrite fixture expected an afterFiles rewrite to override a concrete /intermediate page. That is the route-ordering behavior this branch fixes.

Update the fixture to cover both intended contracts: middleware can chain into afterFiles when no page file wins, and concrete page files are not overridden by afterFiles rewrites.

* refactor(router): remove route-ordering type assertions

Validate generated Pages route metadata at the boundary instead of asserting its shape, and make the app handler test fixture route matching explicit.

* test(router): cover afterFiles order in Pages Worker

Add a built Cloudflare Pages Router regression where a concrete page route must win before an afterFiles rewrite. Align custom Pages Worker entries with the generated worker route-match gate.
2026-05-11 17:13:51 +01:00
Nathan Nguyen 3adf2da4c4 chore(lint): enable stricter promise and error rules (#1148)
* chore(lint): enable stricter promise and error rules

The lint config did not enforce several promise, exhaustive switch, template expression, self-import, and error-construction checks on source files. That let void callback promise boundaries and empty Error fixtures slip through review.

Enable the rules for vinext source files, apply the useful test subset, and make the existing async callback boundaries explicit. Also remove the redundant unknown union and close response bodies in the affected Pages Router tests so server teardown remains deterministic.

* chore(lint): require exhaustive test switches

Test files already enforce strict async callback and error construction checks, but exhaustive switch checking was source-only.

Enable switch exhaustiveness for tests and make the Pages Router response decoder handle the undefined encoding case explicitly. Tighten the allowedDevOrigins suite cleanup so the temporary dev server closes deterministically.
2026-05-11 08:13:00 +01:00
Steve Faulkner 0e62dc24c7 Prefer Pages Router route params over same-key query params (#1119) 2026-05-06 15:01:39 -05:00
Nathan Nguyen 634808bea4 perf(pages): cache ISR misses from streamed render (#1068)
* perf(pages): cache ISR misses from streamed render

Pages Router ISR cache misses currently render the page once for the live response and then render a second React tree to produce cache HTML. That doubles userland render, data reads, suspense work, and render-scoped side effects on every cold cache fill.

The cache writer now tees the already-rendered body stream, records the cache branch with streaming UTF-8 decoding, and writes the same shell and body that the client receives. Stream failures report through the Pages Router render error path and never persist partial HTML.

Tests cover the single-render cache fill, split UTF-8 chunks, failed stream writes, and the Pages Router fixture state contract.

* fix(pages): keep ISR cache error reporting non-fatal

* chore: rerun CI
2026-05-05 19:43:07 +01:00