Hybrid App Router plus Pages Router production builds entered through the App Router server and only delegated route misses to renderPage. That meant pages/api routes were outside the fallback path and returned the App Router 404 in mixed apps.
The generated RSC entry now dispatches /api misses to the Pages server entry handleApiRoute, applies middleware response effects to that API response, and the hybrid SSR bridge re-exports the API dispatcher. Regression coverage ports the Next.js og-api Pages API ImageResponse case at the production boundary.
* perf(e2e): preinstall compatible React in deploy harness
App Router deploy-suite fixtures can start with React versions below the react-server-dom-webpack peer range. The first harness install then resolves that older React pair, and vinext build immediately runs its RSC compatibility upgrade, causing a second package-manager install inside each throwaway app.
Normalize App Router fixture manifests before the initial install by replacing older semver React and React DOM specs with the workspace-compatible specs. Pages-only fixtures and non-semver specs are left untouched.
* chore: rerun ci
* fix(e2e): derive React floor from harness spec
The deploy harness normalized App Router fixture React versions against a hardcoded 19.2.6 floor. That duplicated the compatibility threshold already expressed by vinext's install specs and could go stale when the workspace moves to a newer compatible React pair.
Compare each fixture dependency against the replacement spec that the harness would write. This keeps React and React DOM normalization tied to the package metadata instead of a separate constant.
Replaces 16 ad-hoc `globalThis.fetch = async () => new Response(...)`
swaps with a small `mockGoogleFontsCSS(body, opts?)` helper backed by
MSW's `server.use(http.get(...))`. The helper records every URL it
sees so the two tests that previously asserted on `fetchedUrls.find()`
just read `mock.urls` instead, and the one test that asserted on a
hit-count read `mock.count()`.
Two sites need bespoke handling:
- The Inter/Roboto URL-branching test uses an inline
`server.use(http.get("https://fonts.googleapis.com/*", ({ request }) => ...))`
that returns different CSS based on the URL.
- The "network error" test uses `HttpResponse.error()` to make the
fetch reject (replacing the previous `throw new TypeError(...)` in
the swapped fetch).
Handlers are reset between tests by the global `afterEach` in
`tests/_msw/setup.ts`, so the per-test `try { ... } finally
{ globalThis.fetch = originalFetch }` boilerplate disappears (the
`finally` blocks now only call `fs.rmSync` on the temp font root).
This is PR 2 of the MSW migration. PR 1 (#1444) introduced the
infrastructure and migrated `tests/font-google-build.test.ts`. A
follow-up will migrate `tests/fetch-cache.test.ts` and the in-test
fetch stub inside the font block of `tests/app-router.test.ts`.
App Router RSC deployment mismatches and related fallback paths could still write directly to window.location outside the navigation controller. That bypassed the persisted same-target hard-navigation guard, so a canary or rollback mismatch could repeatedly reload the current document instead of stopping after the first hard navigation.
Expose the controller's guarded hard-navigation executor and route RSC compatibility fallbacks, server-action deployment fallbacks, and RSC navigation error fallbacks through it. Add skip/cache canary rollback compatibility coverage plus a direct fallback guard regression test.
* test(msw): introduce MSW infrastructure and migrate font-google-build
Replaces ad-hoc `globalThis.fetch` hijacking in tests with Mock Service
Worker. MSW intercepts both `globalThis.fetch` and `node:http`/`https`,
so it covers fetches issued by in-process Vite servers and fixture pages
without each test having to manage its own stub.
The setup file enables `onUnhandledRequest: 'error'` so any future test
that issues an unmocked external request fails loudly instead of
silently hitting the network. Loopback hostnames (localhost, 127.0.0.1,
::1) are bypassed because the integration project deliberately hits
in-process test servers via fetch.
What this PR contains:
- `tests/_msw/{handlers,server,setup}.ts` — shared infra
- `setupFiles` wired into both vitest projects (unit + integration)
- `msw` + `@mswjs/interceptors` added to the workspace catalog
- `msw` postinstall skipped via `allowBuilds` (it only copies a browser
service worker; we exclusively use `msw/node`)
- `tests/font-google-build.test.ts` migrated as the proof of pattern
Temporary handlers for `httpbin.org/uuid` and
`example.com/not-cacheable` keep the existing fixture pages
(`revalidate-tag-test`, `dynamic-error-fetch`) working under the new
guard. These are placeholders flagged with `TODO(msw-migration)` — a
follow-up will replace those live URLs with test-local endpoints.
This is PR 1 of 3:
- PR 2 will migrate `tests/fetch-cache.test.ts`
- PR 3 will migrate `tests/font-google.test.ts` and the font block in
`tests/app-router.test.ts`
* test(msw): restrict MSW to the unit project + use passthrough for loopback
Two fixes to the initial MSW wiring that surfaced when CI ran the full
suite:
1. The integration project is now excluded from MSW. Integration tests
spin up in-process HTTP servers and fixture dev servers and exercise
them via `fetch("http://127.0.0.1:<port>/...")`. Even with a loopback
`passthrough()` handler installed, the @mswjs/interceptors layer
interferes with that traffic in subtle ways:
- `tests/prerender.test.ts > errors without writing .rsc when the
middleware short-circuit fallback RSC request fails` timed out at
30s — the 5xx response body never reached the caller.
- `tests/ecosystem.test.ts > nuqs` hung in `beforeAll` waiting for
its fixture process to come up, because the readiness fetch
stalled inside the interceptor.
Integration tests already talk to real local servers, not the
internet, so the unhandled-request guard buys little there. The
value of MSW for this repo is mocking external HTTP for unit tests
of fetch wrappers (font-google, font-google-build, fetch-cache),
which is preserved.
2. The `onUnhandledRequest` callback in `setup.ts` that tried to
silently let loopback through wasn't sufficient — MSW's interceptor
still ran for those requests and could stall them. Replaced with a
default `http.all(LOOPBACK_URL_PATTERN, () => passthrough())`
handler in `handlers.ts` and a plain `onUnhandledRequest: "error"`
in `setup.ts`. Unit tests like the middleware-rewrite proxy in
`tests/shims.test.ts` and the JSX-in-JS suite need this to keep
their in-process HTTP servers reachable.
* test(msw): address BigBonk review notes
- Update PR description in the commit body to drop the stale mention of
TODO(msw-migration) handlers — they were removed when the integration
project was excluded from MSW; the fixture pages run there.
- Tighten the loopback IPv4 octet regex from `\d+` to `\d{1,3}` so it
doesn't match absurdly long octet strings. `URL.hostname` normalises
real addresses, so this is purely a readability fix.
* test(msw): address remaining BigBonk notes from the initial review
- Add `0.0.0.0` to the loopback passthrough pattern so test servers that
bind to the unspecified address keep working without per-test mocks.
- Add a `TODO(msw-3)` comment in `server.ts` flagging the
`SetupServerApi` deprecation in MSW 2.14.6 — the `FetchInterceptor`-only
construction will need to migrate to `defineNetwork`'s interceptor
config when we bump to MSW 3.
Both raised by BigBonk on the first review of this PR (the second review
focused on the stale description and the loose `\d+` octet bound, which
were addressed in the previous commit).
* test(msw): scope server.use() inside the try block in font-google-build
Minor structural fix from BigBonk's review: moves the `server.use()`
handler registration inside the `try` block so all setup and the
matching `fs.unlink` cleanup share the same try/finally scope.
No behaviour change — `server.use()` is synchronous and infallible, so
this is purely a structural tidy-up.
* test(msw): correct comment in font-google-build to match interceptor config
BigBonk caught that the comment described the default `setupServer()`
behaviour (which uses both `FetchInterceptor` and `ClientRequestInterceptor`),
but this project's `server.ts` deliberately constructs `SetupServerApi`
with only `FetchInterceptor`. Update the comment to reflect that — it
intercepts `globalThis.fetch`, not `node:http`/`node:https`.
No code change, comment only.
* fix(router): preserve default locale for unprefixed root links
Unprefixed Pages Router URLs represent the default locale, even when the browser's preferred locale has previously been detected as a non-default locale. Treating the ambient runtime locale as authoritative makes a Link from /new to / request /id and returns the wrong page locale.
Derive implicit client navigation locale from the current URL first, then domain/default locale state, and render a locale-qualified default root fallback href so non-intercepted root links do not re-enter preferred-locale detection. The regression tests port the relevant Next.js i18n preferred-locale cases at the Link and router boundaries.
* refactor(router): share client locale inference
The review called out that Link and Router had identical browser locale fallback logic. Keeping that chain duplicated makes future i18n changes easy to apply in one place but miss in the other.
Move the URL-first browser locale lookup into a shared shim helper and add SSR coverage for the intentional default-locale root fallback href.
Mirrors Next.js's server-side default-locale path normalisation in
`packages/next/src/server/lib/router-utils/resolve-routes.ts` (lines
~250-263): every request that arrives without a locale prefix is
spliced with `/${defaultLocale}` before any `next.config.js`
redirect / rewrite / header rule (or filesystem route) match runs.
Without this, rules like `source: '/:locale/to-sv'` with
`locale: false` failed to match requests for `/to-sv` because there
was no segment for the `:locale` placeholder. After normalisation the
matcher sees `/en/to-sv` and the rule matches with `:locale=en`.
Per the merged PR #1382's description, the issue text's call for a 308
redirect from `/en/page` to `/page` is incorrect: Next.js serves
the same content under both URLs and normalises the path internally.
This change implements that internal normalisation; no redirect.
The new helper lives next to the existing pages i18n helpers so both
routers can use it. Wired into:
- `prod-server.ts` (Pages Router prod)
- `app-rsc-handler.ts` (App Router dev + prod)
- `index.ts` (Pages Router dev plugin)
- `deploy.ts` (Cloudflare Workers entry template)
The domain-mapped default locale (`i18n.domains[].defaultLocale`) is
preferred over the global default when the inbound host matches,
matching Next.js's `domainLocale.defaultLocale` branch in
resolve-routes.ts. `/_next/*` and `/__vinext/*` paths are excluded
to mirror Next.js's exception for build assets and prerender manifests.
The fallback "retain the original unprefixed source" branch in
`applyLocaleToRoutes` is intentionally kept as belt-and-braces: the
new normalisation pass renders it dead-code for ordinary requests, but
it harmlessly preserves backwards compatibility with any matcher
caller that still passes raw, unnormalised pathnames.
Refs #1336 (item 4)
Two workflows now share an identical playwright-version + cache-restore
pattern: ci.yml (which owns the cache, used by E2E jobs) and
nextjs-deploy-suite.yml (which restores it read-only, added in #1435).
Pull the duplication into .github/actions/playwright-cache so the cache
key format can't drift between the two callers — if it ever does, the
deploy-suite stops hitting ci.yml's cache and the upstream Playwright
extraction hang from 2026-05-22 (runs 26265986264 .. 26281758803)
reappears.
The composite action:
- Reads the playwright version from a configurable lockfile path
(`lockfile` input, defaults to pnpm-lock.yaml).
- Restores ~/.cache/ms-playwright keyed
`playwright-${browser}-${runner.os}-v${version}`.
- Supports two modes: `cache` (restore + save, default — for the workflow
that owns the cache) and `restore` (read-only — for downstream
consumers that don't want to fight over the save).
- Exposes `cache-hit`, `version`, and `key` outputs so callers can gate
install steps or use the version for sibling cache keys (e.g. ci.yml's
apt-archives cache for WebKit deps).
No behavioural change — same cache keys, same restore-keys, same
~/.cache/ms-playwright path.
Follow-up to #1428 and #1430. The previous two PRs added log markers
and skipped the wedged transitive postinstall, but the underlying
problem remained: the explicit `pnpm playwright install chromium
chromium-headless-shell` in the prepare script hits the same upstream
extraction hang (download to 100%, then no progress until step
timeout).
What's actually different between the runs that worked (May 19-21)
and the runs that hang (May 22): nothing on our side. Same Chrome
145.0.7632.6, same Playwright 1.58.2, same Node 24.16.0, same runner
image fleet. On the May 21 success the extraction took 2.7 seconds
after 100% download; on May 22 it never completes (>9 min, killed by
the 15-min step timeout). Some upstream change — bad bytes from
cdn.playwright.dev, an Azure storage hiccup affecting fsync, a
Playwright installer interaction we can't see from here — is wedging
extraction. We can't fix it from this repo.
What we *can* do: stop downloading. vinext's own ci.yml already keeps
a `playwright-chromium-Linux-v1.58.2` cache hot (261 MB, last accessed
within minutes on every CI run today). The deploy-suite has never used
this cache — its only cache (the post-build `Save build artifacts`
step) is keyed per-run, so every nightly starts with an empty
~/.cache/ms-playwright and re-runs the install that's now hanging.
Add a restore step before the prepare that uses ci.yml's exact key
format. On hit, `pnpm playwright install` sees the browsers already
present and is a fast no-op. On miss (e.g. Next.js bumps Playwright
before vinext catches up), we fall through to the existing behavior.
Restore-only: ci.yml owns the save side.
* feat(cache): add skip cache proof cross-checks
Skip manifest entries remain untrusted hints, but future skip transport needs a cheap server-side proof boundary before it can omit any payload. Cache presence, client-declared artifact metadata, and stored artifact metadata are not individually enough to prove correctness.
Add a pure skip/cache cross-check helper that requires an accepted static layout cache proof, compatible artifact metadata, matching cache variant, matching payload hash, and valid invalidation proof. The helper always returns the disabled render-and-send skip disposition in this slice.
Cover graph, deployment, render epoch, variant, payload hash, invalidation, missing proof, and rejected proof cases at the helper boundary.
* fix(cache): bind skip cache artifact proof metadata
Accepted static layout artifact reuse decisions proved candidate artifact compatibility but did not carry that compatibility envelope forward. That let skip/cache cross-checks accept independently supplied artifact metadata as long as the client entry matched it, even when the cache proof had authorized a different artifact.
Store a snapshot of the candidate artifact compatibility on accepted artifact reuse proofs and require skip/cache cross-checks to match it exactly before checking client compatibility, variants, payload hashes, or invalidation state.
The skip/cache test matrix now covers the rejected proof-binding case, root-boundary compatibility mismatches, and layout entry id mismatches.
Follow-up to #1428. The previous PR added log markers, which revealed
the actual hang: `playwright-chromium`'s postinstall (`node install.js`,
triggered transitively by the Next.js workspace install) reaches 100%
on the 167 MiB Chrome zip download from cdn.playwright.dev, then never
prints "Done" — extraction/verification silently wedges. Every other
postinstall in the same install (cpu-features, sharp, esbuild, etc.)
finishes cleanly. Confirmed on runs 26265986264, 26279790373, and the
post-#1428 retry 26281061094 — same fingerprint each time.
Set `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` on the Prepare step so the
transitive `playwright-chromium` postinstall becomes a no-op. The
explicit `pnpm playwright install chromium chromium-headless-shell`
that runs later in `scripts/run-nextjs-deploy-suite.sh` provisions the
browsers we actually use at test time, populating ~/.cache/ms-playwright
which is cached and restored by the test shards.
* ci(deps): add dependabot config with 1-day cooldown
Mirrors pnpm-workspace.yaml's `minimumReleaseAge: 1440` (24h) so
Dependabot won't open PRs for versions younger than the cooldown
window we already apply to local installs.
* ci(deps): run dependabot daily instead of weekly
The nightly deploy suite hung for 29m+ in "Prepare Next.js checkout"
on two consecutive runs (26265986264, 26279790373), eating the 30m job
timeout. Same v16.2.6 SHA (ee6e79b1) built fine the three previous
nights, so it's environmental, not source drift.
The first `pnpm install` completes around the 30s mark and then logs go
silent until cancellation. With no per-command markers, we couldn't tell
which of the remaining commands actually hangs.
This patch:
- Drops `--with-deps` from the build-job playwright install. It runs
`apt-get install` for browser system libraries, which is by far the
most plausible silent-hang culprit (stuck dpkg lock / slow apt repo).
The 16 test-shard runners each spin up a fresh ubuntu-latest and
already run `playwright install` without `--with-deps` — so the apt
state never transferred anyway. Doing it on the build runner is wasted
work even when it doesn't hang.
- Removes the redundant second `pnpm install`. The workspace symlinks
survive the build step; nothing between the build and re-install adds
new packages.
- Adds timestamped `>>> ...` markers around each prepare command so a
future hang is diagnosable from the logs.
- Adds `timeout-minutes: 15` on the Prepare step so a future hang fails
fast (well under the 30m job cap) instead of starving the whole job.
* feat(config): add experimental.appShells plumbing
Accepts experimental.appShells in next.config and defines
process.env.__NEXT_APP_SHELLS=false at build time, matching
Next.js PR #93997 plumbing scope. Behavorial implementation
is gated on upstream co-flags that vinext does not yet support.
- Adds experimental.appShells to CONFIG_SUPPORT as unsupported
- Sets __NEXT_APP_SHELLS define to false regardless of config value
- Tests for vinext check detection and Vite define injection
Fixes#1405
* review: address PR nits
- Expand experimental.appShells detail to explain why it's unsupported
- Move build-time define tests into their own describe block, so the
__NEXT_APP_SHELLS define test isn't nested under basePath
* review: drop orphaned server lifecycle from basePath describe
The two remaining resolveNextConfig tests import from source directly
and don't use the dev server.
---------
Co-authored-by: James <james@eli.cx>
* fix(app-router): render optimistic loading shells for dynamic navigation
Dynamic App Router links currently skip automatic prefetch for route patterns with params, so a navigation to a sibling dynamic URL waits for fresh RSC data before committing anything. That diverges from Next.js, where automatic prefetch learns the loading shell for dynamic routes and the router can commit the shell immediately while the full payload is fetched.
The client prefetch path now requests a loading-shell render for dynamic app routes, keeps that entry out of the navigation cache, learns reusable optimistic route templates from settled shell prefetches, and commits a detached optimistic payload for matching dynamic navigations. The server emits shell-only RSC payloads under a separate cache variant so normal navigation data is not polluted.
Tests cover matching semantics, shell-only page wiring, cache key isolation, prefetch cache consumption, Link prefetch behavior, and the upstream Next.js optimistic-routing deploy-suite case.
* chore(app-router): address optimistic routing review
has been stable since Next.js stabilized the feature upstream.
- Emit a deprecation warning when users still set
- Matches Next.js upstream wording for parity
Closes#1325
* 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.
* 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.
Closes the last sub-problem of #1332.
The Pages Router `Router.push` / `Router.replace` already short-circuit the
`_next/data` fetch when `options.shallow === true`, but `<Link shallow>` never
made it to the router because `shallow` was missing from `LinkProps`. The prop
silently fell into `...rest`, was spread onto the underlying `<a>`, and the
click handler called `Router.push(href, undefined, { scroll, locale })` with
no shallow flag — so every `<Link shallow>` did a full server roundtrip and
GSSP re-ran.
This matches Next.js's deploy-suite fixture
`test/e2e/middleware-trailing-slash/app/pages/shallow.js`, which clicks a
`<Link href="/sha?hello=world" shallow>` and asserts the GSSP-generated
random message stays unchanged afterwards.
The fix is mechanical: add `shallow?: boolean` to `LinkProps`, destructure
it in the Link component, forward it through `navigatePagesRouterLink` into
the router transition options. `shallow` is only included in the router
options when explicitly set so existing callers that omit it keep the same
options shape.
Plumb `trailingSlash` from the resolved next.config into NextURL via
NextRequest, so `NextResponse.redirect(request.nextUrl)` (and any other
stringification of a NextURL) emits a Location header that matches the
user's slash policy. Mirrors Next.js's `formatNextPathnameInfo`.
Pre-route trailing-slash normalization (e.g. `/foo` -> `/foo/` when
trailingSlash:true) already runs at every entry point via
`normalizeTrailingSlash` in request-pipeline. This commit closes the
remaining gap by making redirect Locations produced from inside
middleware (the common one-hop `request.nextUrl` redirect pattern in
the Next.js middleware-trailing-slash fixture) carry the slash too.
Refs #1332. Sub-problem 3 (shallow navigation triggering a server
roundtrip) is a separate Pages Router client-side concern and deferred
as follow-up.
* 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>
Match Next.js's observable behaviour when `<Link>` clicks, `router.push`,
`router.replace`, and `router.prefetch` block a `javascript:` (or `data:`/
`vbscript:`) URL. Vinext already prevented navigation, but the
`console.error` that Next.js's E2E suite asserts on
(`test/e2e/app-dir/javascript-urls`) was missing because:
- App Router `router.push`/`replace`/`prefetch` threw synchronously
inside `React.startTransition`, so the throw never reached React's
event-handler error reporter.
- `<Link>` rendered an inert anchor with no `href`, so clicking did
nothing — no throw, no log.
- Pages Router `next/router` had no `javascript:` guard at all and
would fall through to `window.location.assign(...)`.
Centralise the matching message in `url-safety.ts` via a new
`reportBlockedDangerousNavigation()` helper. `assertSafeNavigationUrl`
now logs before throwing, and Link's dangerous-click branch and the
Pages Router `performNavigation` both invoke the guard so the throw +
console.error pair mirrors Next.js exactly.
Closes#1349
References:
- packages/next/src/client/components/app-router-instance.ts:345,402,442,460
- packages/next/src/client/components/segment-cache/navigation.ts:537
- packages/next/src/shared/lib/router/router.ts:1025,1057
* 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`.
* fix(rsc): redirect returns flight payload (200) for RSC navigations
When a server component calls `redirect()` and the client makes an RSC
navigation request (`.rsc` URL), encode the redirect as a React error
chunk inside the flight stream and return HTTP 200 instead of a raw 307.
The client's `RedirectErrorBoundary` decodes the `NEXT_REDIRECT;...`
digest and performs the navigation — matching Next.js's behavior in
`app-render.tsx generateDynamicFlightRenderResult`.
`generateMetadata()` redirects now always return 200 with a flight
payload (both RSC and SSR document requests), because metadata is
suspended/streamed in Next.js — its redirects ride inside the flight
body, never the HTTP status line. Page-component redirects on SSR
document requests still emit a 307, unchanged.
Closes#1347
* fix(rsc): preserve soft-nav and useTransition pending across flight redirect
The previous commit returned a 200 + flight payload for RSC `redirect()`
responses but left vinext's client-side navigation loop without a way to
detect the redirect — the 200 response fell through to React's render,
threw at decode time, and hit the catch path's `window.location.href` hard
navigation. That tore down the page mid-transition and clobbered
`useTransition`'s isPending state.
Add an out-of-band header (`X-Vinext-Rsc-Redirect`) that carries the
redirect target alongside the flight body. The browser-entry loop now
detects the header right after the existing `resolveRscRedirectLifecycleHop`
(307-following) check, drains the body, and `continue`s the loop with the
new href. The same `pendingRouterState` spans both hops, so the outer
transition stays pending continuously — restoring pre-1347 behavior.
Also stamp the build-time `X-Vinext-Rsc-Compatibility-Id` header on the
flight-redirect response. Without it, `resolveRscCompatibilityNavigationDecision`
treats the response as cross-build and hard-navigates before the redirect
header is read.
Export `MAX_RSC_REDIRECT_DEPTH` from `app-browser-rsc-redirect` so the new
header path can reuse the same hop-limit ceiling.
* fix(i18n): strip locale prefix for API routes
Mirror Next.js's behaviour where a locale-prefixed request path like
`/fr/api/ok` is matched against `pages/api/ok` after stripping the
locale segment. Previously the locale prefix was kept through the
`/api/` startsWith check, so any locale-prefixed API URL 404'd.
Add a small helper `stripI18nLocaleForApiRoute(url, i18nConfig)` in
`server/pages-i18n.ts` that mirrors Next.js's
`normalizeLocalePath().pathname` (see
packages/next/src/shared/lib/i18n/normalize-locale-path.ts) and wire it
into all three Pages Router request pipelines so dev/prod parity holds:
- dev plugin: `packages/vinext/src/index.ts`
- prod (Node): `packages/vinext/src/server/prod-server.ts`
- prod (Cloudflare worker entry): `packages/vinext/src/deploy.ts`
App Router is intentionally unchanged: Next.js does not support the
`i18n` config field with App Router, so App Router route handlers
under `app/*/route.ts` never see a locale prefix to strip.
Refs #1336 (item 3). Items 2 (sticky locale on client navigations) and
4 (default-locale path normalisation) are being handled in parallel
PRs.
Tests:
- Unit tests for `stripI18nLocaleForApiRoute` covering basic, regional
(`nl-NL`), query-preserving, unconfigured-prefix, and null-config
cases (`tests/pages-i18n.test.ts`).
- Dev integration tests (`tests/features.test.ts`) and prod integration
tests (`tests/pages-i18n-prod.test.ts`) that GET `/fr/api/ok` and
assert a 200 "ok" response against the existing
`pages-i18n-domains` fixture, ported from Next.js's
`test/e2e/middleware-redirects/test/index.test.ts` ("should redirect
to api route with locale").
- Updates to `tests/deploy.test.ts` assertions for the generated
worker-entry template strings.
* test(after-deploy): update worker-entry assertion after locale strip
The Pages Router worker entry now calls handleApiRoute(request,
apiLookupUrl, ctx) instead of using resolvedUrl directly, so this
generated-code assertion needs the new variable name.
Refs #1336 (item 3).
* fix(basepath): enforce scoping on rewrites/redirects/routes
Sub-issues addressed under #1333:
1. Rewrites/redirects/headers were firing on requests outside the
configured `basePath`. Threaded a `BasePathMatchState` through the
matchers and prod-server / deploy worker / dev plugin so default
rules (no `basePath: false` opt-out) only evaluate when the request
was under basePath, and opt-out rules only evaluate outside it.
5. Pages Router requests that landed outside basePath fell through to
internal route matching. After redirects and beforeFiles rewrites
run, the request now 404s when it was outside basePath and no
`basePath: false` rule rewrote it — matching Next.js's
`resolve-routes.ts:304-309`.
4. With rewrites no longer firing on out-of-basepath requests, the
downstream code that prepends basePath to destinations no longer
sees a stripped-but-already-internal pathname, eliminating the
`/docs/docs/...` doubling for the rewrite-driven cases.
App Router uses the same gating; `basePath: false` rules don't yet
fire there because `normalizeRscRequest` 404s out-of-basepath requests
before the matchers see them — flagged as follow-up in the rule
gating site. External rewrite proxying (sub-issue #2) and static
asset doubling (sub-issue #3, partly addressed by #1337/#1383) are
deferred.
Refs #1333
* test: update deploy.test signature assertions and format basepath tests
* review: address bonk nits — add inverse basePath:false tests and TODO comment
When no `app/global-error.tsx` (or route-level `error.tsx`) is defined,
vinext's runtime returned null from the error boundary fallback, which
caused the request to bubble up as a generic 500 instead of rendering
the styled default error page Next.js ships out of the box.
Port Next.js's built-in default global error component
(`packages/next/src/client/components/builtin/global-error.tsx`) to
`vinext/shims/default-global-error.tsx`, including the 32x32 warning
icon, the "This page couldn't load" heading, theme CSS, the Reload form
button, the Back button (client errors only), and the
`ERROR <digest>` footer (server errors only).
Wire this default in via a small module wrapper
(`server/default-global-error-module.ts`) so both the runtime error
boundary fallback (`app-fallback-renderer.ts`) and the route-level
client `<ErrorBoundary>` wrapper (via `app-page-element-builder.ts`)
fall back to it whenever `globalErrorModule` is null. User-defined
global error modules continue to take precedence.
Closes#1362
* fix(i18n): honor locale: false on rewrites/redirects
When a rewrite or redirect carries `locale: false`, Next.js leaves the
source pattern untouched so user-supplied `:locale` segments can capture
the locale prefix themselves. Without `locale: false`, Next.js prepends
an internal locale alternation so the rule matches every locale-prefixed
URL automatically.
Vinext previously ignored the flag entirely and never injected the
locale alternation, so:
- rewrites with `locale: false` on a source like
`/:locale/rewrite-files/:path*` failed because the underlying
matcher could not handle a non-trailing named param combined with a
trailing catch-all;
- plain rewrites/redirects with i18n configured silently failed to
match locale-prefixed paths.
This change:
1. Adds `locale?: false` (and `basePath?: false` for forward
compatibility) to NextRedirect/NextRewrite.
2. Fixes matchConfigPattern to use the regex branch whenever a pattern
has multiple named params and a catch-all.
3. Adds applyLocaleToRoutes mirroring Next.js's processRoutes — emits
`/:nextInternalLocale(en|sv|nl)/...` and (for redirects) per
default-locale literal variants while skipping rules flagged with
`locale: false`. Retains the original unprefixed source as a
fallback so default-locale requests that arrive without a prefix
still match (Next.js handles this upstream via request path
normalisation; doing it here avoids a server-wide change).
Ported tests from
test/e2e/i18n-ignore-rewrite-source-locale/rewrites.test.ts
Refs #1336 — items 2, 3, 4 remain as follow-ups.
* fix(i18n): address Bonk review feedback
- Keep the locale-capture variants emitted by applyLocaleToRoutes on the
redirect index fast path. `_LOCALE_STATIC_RE` now matches both the
optional `/:locale(en|fr)?/foo` user-written form and the mandatory
`/:nextInternalLocale(en|fr)/foo` form. Each entry records whether the
locale segment is optional; the no-locale-prefix branch only fires for
optional entries so mandatory variants still require a prefix.
- Thread `trailingSlash` through `applyLocaleToRoutes` so the root source
is preserved (`/:nextInternalLocale(en|fr)/`) when trailingSlash is
configured, matching Next.js processRoutes.
- Promote the dynamic import in next-config.ts to a static import — the
module is already loaded for isExternalUrl.
- Drop the unnecessary `new Set([...])` dedup and document that the
defaultLocales list will grow once domain-locale support lands.
* 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.
Turbopack supports importing inline CSS via `data:text/css,...` and
`data:text/css+module,...` data URLs. Vite/Rolldown treats `data:`
specifiers as external, so the literal URL was reaching the bundled
output and Node/workerd refused to load it with
`ERR_UNKNOWN_MODULE_FORMAT: Unknown module format: text/css+module`,
breaking the entire build for any app that adopted the Turbopack-only
syntax (e.g. test/e2e/app-dir/css-modules-data-urls).
Add a pre-transform that rewrites these imports into synthetic
`\0vinext-data-css/<hash>.module.css` (or `.css`) specifiers. Vite's
existing CSS pipeline (LightningCSS, CSS modules) then handles
class-name hashing, JS export map generation, and stylesheet asset
emission. The decoded payload is cached by synthetic id so the same
data URL imported from RSC and client environments deduplicates the
way real CSS imports do.
Closes#1363
Middleware runs server-side, so importing `server-only` is semantically
correct. However, vinext bundles middleware into the SSR environment
via `virtual:vinext-server-entry`, and @vitejs/plugin-rsc's
`rsc:validate-imports` treats any non-RSC environment as a "client"
build, rejecting the bare specifier with:
'server-only' cannot be imported in client build ('ssr' environment):
imported by middleware.js
imported by virtual:vinext-server-entry
imported by virtual:vinext-app-ssr-entry
Next.js solves this with webpack `issuerLayer` rules: middleware sits
in the `neutralTarget` layer where `server-only` aliases to a no-op
while `client-only` still errors
(packages/next/src/build/webpack-config.ts).
Vite has no per-layer aliasing within an environment, so we mirror the
behavior with import-chain taint tracking: seed a Set with the
middleware entry path, propagate the taint along the resolveId graph,
and short-circuit `server-only` to the existing no-op shim when the
importer is tainted. The plugin uses `enforce: "pre"` so its resolveId
beats plugin-rsc's `order: "pre"` validate-imports filter on the bare
specifier.
Closes#1344
* 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.
* fix(middleware): redirect protocol — relative Location and x-nextjs-redirect
Match Next.js's edge adapter behaviour for middleware redirects:
1. Relativize the `Location` header for same-host redirects. Next.js only
emits absolute URLs when the redirect target is cross-origin; vinext
was always emitting `http://host/path` for same-host targets, which
broke deploy-suite parity tests.
2. Translate redirects to the `x-nextjs-redirect` soft-redirect protocol
when the request carries `x-nextjs-data: 1`. The client router uses
this header instead of a raw HTTP 3xx to avoid CORS issues on
cross-origin data fetches.
Both fixes live in `executeMiddleware`, so all four call sites
(prod-server, dev plugin, deploy worker, app-middleware) inherit them.
Refs #1334
* fix(middleware): thread isDataRequest through all callers
Address Bonk review. The previous commit checked `x-nextjs-data` on the
middleware request, but that header is in INTERNAL_HEADERS and is
stripped by `filterInternalHeaders` before any caller constructs the
middleware request -- so the soft-redirect branch was unreachable.
Make `isDataRequest` an explicit option on `ExecuteMiddlewareOptions`
and `ApplyAppMiddlewareOptions`, and capture it from the raw incoming
headers in each of the four entry points (dev plugin, prod server,
deploy worker, App Router RSC handler) before filtering. The generated
Pages Router runMiddleware now accepts a third `options` argument
carrying the flag through to the runtime helper.
Also: drop redundant lowercase `headers.delete("location")` -- the
Fetch spec makes Headers.delete case-insensitive.
Add a regression test ensuring a forged `x-nextjs-data: 1` request
header cannot trigger the soft-redirect path without the caller
explicitly opting in.
Refs #1334
vinext already exposes `<Link onNavigate>` and the matching `NavigateEvent`
contract (`{ url, preventDefault(), defaultPrevented }`), but the Next.js
deploy suite still fails the `replace history state for external links
with replace prop` case: when `<Link>` points at a truly external URL and
has `replace` set, vinext falls through to the browser's default click
navigation, which pushes a new entry instead of replacing the current one.
This mirrors `linkClicked` in
`.nextjs-ref/packages/next/src/client/link.tsx` for the external + replace
branch: call `e.preventDefault()` and `window.location.replace(href)` so
the entry is replaced rather than pushed.
Adds focused unit tests around the onNavigate contract: onClick + onNavigate
fire for internal clicks, `preventDefault()` cancels the navigation, and
onNavigate is skipped for modifier-key clicks, target=_blank, download
links, and external URLs (with and without `replace`).
Closes#1357
Next.js exposes `params` as `null` (effectively — via `params || null`
or `params ? await params : null` user-side) to route handlers and
`getServerSideProps`/`getStaticProps` on routes with no dynamic
segments. vinext was passing an empty object, which is truthy and
breaks user code that branches on `params`.
The fix computes a user-facing params value at each dispatch boundary
(`null` when `route.isDynamic === false`, otherwise the matched
object) while leaving internal bookkeeping — navigation context, query
merging, `useParams()` — on the empty-object shape that those
consumers expect.
References the Next.js test suite assertions:
- test/e2e/app-dir/app-routes/app-custom-routes.test.ts
"does not provide params to routes without dynamic parameters"
- test/e2e/edge-pages-support/index.test.ts
"should have correct query/params on index"
Closes#1350
* 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.
Pages Router API routes did not receive the per-request Workers
`ExecutionContext`, so any `after()` (or other shim) call inside a
`pages/api/*` handler had no `ctx.waitUntil()` to keep the isolate
alive past the response. Thread `ctx` through `handleApiRoute`
→ `handlePagesApiRoute`, and wrap the user handler in
`runWithExecutionContext(ctx, ...)` so the ALS surfaces the ctx
for downstream `after()` calls.
Also pass a Node-shaped execution context from `vinext start` so
behavior is consistent across the dev / start / deploy server
paths.
Closes#1365
The `next/form` shim was rendering raw `action` strings without applying
basePath, and its Pages Router fallback bypassed the Router singleton —
meaning route events, HTML fetch, and scroll handling didn't fire on
form submission. Bring it in line with `next/link`:
- Prefix the form-level `action` prop with `__NEXT_ROUTER_BASEPATH`
(matches Next.js `addBasePath(actionProp)`). A submitter's `formAction`
is intentionally left alone, matching upstream.
- In Pages Router, delegate to `next/router` Router.push/replace so the
navigation flows through `performNavigation` like Link does. Keep the
pushState+popstate path as a recovery fallback only.
Closes#1355
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
Two parity gaps with Next.js were causing edge runtime API routes to fail
with 500 errors:
1. Edge API handlers received a plain `Request` object, so accessing
`req.nextUrl.searchParams` (the idiomatic pattern in Next.js edge API
routes) threw at runtime. Next.js wraps the request in `NextRequest`
before invoking the handler (NextRequestHint in
next/src/server/web/adapter.ts).
2. Only `export const config = { runtime: 'edge' }` was recognised as an
edge runtime declaration. Next.js also accepts a bare
`export const runtime = 'edge'` and resolves the effective runtime as
`config.runtime ?? config.config?.runtime`
(next/src/build/analysis/get-page-static-info.ts).
Both fixes are applied in dev (`server/api-handler.ts`) and prod
(`server/pages-api-route.ts`) so the two paths stay in parity.
Refs #1338
`performNavigation()` and the `Router` singleton's `back`/`reload`/
`prefetch`/`beforePopState` methods referenced browser globals
(`window`, `document`) unconditionally. Invoking `router.push()` from a
Pages Router component during SSR or prerender (`VINEXT_PRERENDER=1`)
therefore crashed with `ReferenceError: window is not defined` and took
down the request pipeline.
Mirror Next.js's `ServerRouter` (packages/next/src/server/render.tsx),
where every navigation method synchronously throws "No router instance
found". The throw happens before any browser global is accessed, so the
failure surfaces as a normal render error instead of a ReferenceError —
and the documented message + docs link keeps userland error handling
intact.
Closes#1353
* fix(parallel-routes): resolve nested slot pages over default.tsx
When two sibling route groups occupy the same URL pattern (e.g.
`(group-a)/layout.tsx` next to `(group-b)/page.tsx`), the layout-only
sibling was dropped entirely because its pattern collided with the page
route, taking its parallel slots with it. Nested pages inside its
`@slot` directories were never materialized, so URLs that only the
catch-all slot could handle returned 404 / not-found instead of
rendering the matched slot page.
This change keeps the dropped layout-only routes as "ghost parents"
that participate in `discoverSlotSubRoutes`. Their slot sub-pages still
mint synthetic sub-routes (carrying the ghost's layout chain and slot
metadata), but the ghost itself is not added to the route table to
preserve the existing single-route-per-pattern invariant.
Closes#1339
Ported from Next.js tests:
- test/e2e/app-dir/parallel-routes-catchall-groups
* test(parallel-routes): cover shared-root URL of sibling route groups
Adds an e2e assertion that /parallel-group-catchall (the URL shared by
both (group-a) and (group-b)) renders (group-b)/page.tsx, not the
(group-a) ghost or its catch-all slot. Closes the coverage gap called
out in code review for the existing /foo and /bar tests.