Commit Graph

1495 Commits

Author SHA1 Message Date
Michael Hart 6a68fd1c94 fix: harden origin validation and proxy request handling (#571)
* Treat `Origin: "null"` as an explicit cross-origin value

Also strip Transfer-Encoding

* Further hardening

* Add Vite HMR websocket token regression tests

Verify that websocket upgrades with an Origin header are rejected when the HMR token is missing or invalid, and accepted when the token matches the value exposed by @vite/client.

* Add clarifying comment and share a single dev server
2026-03-16 21:24:23 -05:00
James Anderson 69a0cff2d5 fix: add assets.directory to generated wrangler.jsonc (#569) 2026-03-16 20:14:13 +00:00
James Anderson 6d261846d5 refactor: migrate wrangler unstable_dev to unstable_startWorker (#568)
* fix: migrate wrangler unstable_dev to unstable_startWorker

Replace the deprecated wrangler unstable_dev API with unstable_startWorker
across the prerender pipeline. Key changes:

- Use unstable_startWorker({ entrypoint, config, bindings }) instead of
  unstable_dev(script, { local, vars, experimental, logLevel })
- Replace Unstable_DevWorker type with the Worker type returned by
  unstable_startWorker
- Replace .stop() cleanup calls with .dispose()
- Pass VINEXT_PRERENDER via bindings: { type: 'plain_text', value: '1' }
  instead of vars
- Update all comments and test descriptions to reference the new API
- Update entry-template snapshots to reflect updated comment text

* fix: silence wrangler logs during prerender via dev.logLevel: none

* fix: update stale JSDoc comments referencing unstable_dev
2026-03-16 20:10:00 +00:00
James Anderson 75eb3c1e8d feat: production prerender pipeline (#553)
* feat: production prerender pipeline (no dev server)

Add prerenderPages() and prerenderApp() that load from production bundles
exclusively — no ViteDevServer dependency. runPrerender() replaces
runPrerenderWithDevServer() (kept as a shim for cli.ts/deploy.ts callers).

Key changes:
- packages/vinext/src/build/prerender.ts — new: prerenderPages/prerenderApp
- packages/vinext/src/build/run-prerender.ts — new: runPrerender + shim
- packages/vinext/src/build/static-export.ts — delegates to prerender layer
- packages/vinext/src/index.ts — adds rscOutDir/ssrOutDir/clientOutDir/disableAppRouter options
- packages/vinext/src/entries/app-rsc-entry.ts — exports generateStaticParamsMap
- packages/vinext/src/entries/pages-server-entry.ts — exports pageRoutes
- packages/vinext/src/shims/cache.ts — adds NoOpCacheHandler
- packages/vinext/src/server/prod-server.ts — serves prerendered .html files
- packages/vinext/src/build/report.ts — integrates PrerenderResult for build report
- tests/helpers.ts — buildPagesFixture/buildAppFixture helpers (isolated outDirs)
- tests/prerender.test.ts — 36 new tests
- tests/static-export.test.ts/app-router.test.ts/pages-router.test.ts — updated

All 487 tests pass. Typecheck, lint, fmt clean.

* fix: build Pages Router SSR bundle for hybrid app+pages projects

For hybrid projects that have both app/ and pages/ directories, the
App Router multi-env build (createBuilder/buildApp) does not produce
a Pages Router SSR bundle. The prerender phase then fails with 'bundle
not found at dist/server/entry.js'.

Fix: after buildApp() completes, detect pages/ presence and run a
separate standalone Vite SSR build with vinext({ disableAppRouter: true })
so the plugin's multi-env environments config does not override the SSR
input/entryFileNames. The build is run with emptyOutDir: false to preserve
RSC artefacts from the App Router build.

* Clean up run-prerender: remove deprecated shim, fix progress arithmetic and typos

* regen snaps

* refactor: clean up prerender pipeline, cache shim, and report utilities

- Extract loadBundle() helper used by both prerenderPages and prerenderApp
- Save/restore CacheHandler around prerender calls (isolation fix)
- Rename isExplicitlyDynamic -> isConfiguredDynamic for clarity
- Export getRscOutputPath and findDir for cross-module reuse
- Import findDir in run-prerender.ts; remove duplicated inline logic
- Fix single-row table corner character in formatBuildReport
- Remove duplicate JSDoc block before revalidateTag in cache shim
- Add SetCtx interface; eliminate (ctx as any) casts in MemoryCacheHandler
- Replace string sentinel in unstable_cache with structural CacheResultWrapper
- Use completedUrls += 1 consistently in Pages Router phase

* fix: remove unused appDir from PrerenderAppOptions and all callers

prerenderApp() never reads appDir — the bundle is loaded via rscBundlePath
and route scanning is done before the call. Remove the field from
PrerenderAppOptions and AppStaticExportOptions, and drop the now-unused
argument from all callers in run-prerender.ts, static-export.ts, and tests.

* refactor: miscellaneous cleanups across prerender, cache, cli, and deploy

* refactor: document gaps, re-detect project after installDeps, fix Vite resolution in deploy.ts

* refactor(prerender): unify onProgress to single call site; document pageCount semantics

Extract renderUrl() inner function from prerenderApp's concurrency loop so
that onProgress is called exactly once per URL at the outer loop level, rather
than being duplicated inside two early-return branches. Eliminates the risk
of accidentally omitting the callback when adding future early exits.

Add a comment in toStaticExportResult clarifying when pageCount and
files.length can diverge (currently they stay in sync).

* build(prerender): write non-export prerender output to dist/server/prerendered-routes/

On Cloudflare Workers, wrangler.jsonc uses not_found_handling: "none" so
every request hits the worker first. Files in dist/client/ are never
auto-served for page requests — they are uploaded but remain inert.
Writing prerendered HTML/RSC to dist/server/prerendered-routes/ keeps
them co-located with server artifacts and away from the static assets
directory.

This also prevents a future issue: when KV pre-population is implemented,
ISR route files must not be in dist/client/ or they would be served as
stale static files forever (bypassing revalidation).

output: 'export' builds are unaffected — static-export.ts passes its
own outDir explicitly, and runPrerender still writes to dist/client/
when mode === 'export'.

* .

* build: suppress IMPORT_IS_UNDEFINED warnings for generateStaticParams

Dynamic route pages that don't export generateStaticParams produce noisy
IMPORT_IS_UNDEFINED warnings because the virtual RSC entry unconditionally
emits mod?.generateStaticParams for every dynamic route. The optional
chaining guards the access safely at runtime; suppress the build-time noise
in the existing onwarn handler alongside the MODULE_LEVEL_DIRECTIVE filter.

* .

* .

* .

* fix conflict regression

* regen snaps

* update cli comment

* add todo for rsc double req

* encode uri components

* encode uri components

* fix: inherit user plugins in hybrid Pages Router secondary build

The secondary `vinext build` step for hybrid (App Router + Pages Router)
projects was constructed with `configFile: false` and only `vinext({ disableAppRouter: true })`,
dropping all user-supplied plugins including `cloudflare()`. This caused a
false-positive "Missing @cloudflare/vite-plugin" error on any hybrid project
with a `wrangler.jsonc` present.

Fix by inheriting the resolved plugin list from the already-completed App
Router builder, filtering out the `vinext:*` sub-plugins and re-injecting
`vinext({ disableAppRouter: true })` in their place. `configFile: false` is
retained to prevent the user's `environments` block from overriding the SSR
input and `entryFileNames`.

* fix: filter vite:react and vite-tsconfig-paths from inherited hybrid build plugins

vinext auto-registers both @vitejs/plugin-react (vite:react*) and
vite-tsconfig-paths when disableAppRouter is false, so inheriting the
resolved plugin list from the App Router builder caused duplicates.
Filter them out alongside the vinext:* sub-plugins.

* fix: filter rsc:* and vite-rsc-* plugins from inherited hybrid build plugins

@vitejs/plugin-rsc registers resolveId for App Router virtual modules
(virtual:vinext-app-ssr-entry etc.) but the corresponding load hooks live
in vinext:* — which we already strip. With rsc:* present but vinext:* absent
the virtual module resolves but can't load, crashing with PLUGIN_ERROR.
Filter rsc:* and vite-rsc-load-module-dev-proxy alongside the other
auto-registered plugin families.

* fix: skip missing-Cloudflare-plugin guard for hybrid Pages Router secondary build

The secondary build uses configFile:false + vinext({disableAppRouter:true})
so it never loads cloudflare() — by design, it's a plain SSR Rollup bundle
with no Workers config. The configResolved guard was incorrectly treating
this as a misconfigured user build.

Revert the inherited-plugins approach (cloudflare() reconstructs the full
multi-env environments block from its own plugin config hook, independent
of vite.config, causing the App SSR virtual entry to be resolved but not
loadable). Instead, gate the guard on !options.disableAppRouter, which is
exclusively set by this internal invocation.

* fix: inherit user transform plugins in hybrid Pages Router secondary build

The secondary SSR build for hybrid projects (app/ + pages/) was introduced
on this branch and always used configFile:false with only
vinext({disableAppRouter:true}), silently dropping any user transform
plugins from vite.config.ts (SVG loaders, CSS-in-JS, etc.).

Fix by loading the raw user config via loadConfigFromFile (before any
plugin config() hooks fire, so cloudflare() hasn't yet injected its
multi-env environments block) and forwarding all plugins except the
families that vinext auto-registers or that would break the plain SSR
build: vinext:*, vite:react*, rsc:*, vite-tsconfig-paths,
vite-rsc-load-module-dev-proxy, and vite-plugin-cloudflare*.

* fix: reset ANSI styling before prerender and route report output

Vite's logger leaves the terminal in a styled state after build output.
Write an ANSI reset before the prerender label and route report so they
always print at full brightness regardless of preceding Vite output.

* fix: skip instrumentation register() during prerender to prevent process hang

Instrumentation modules like @vercel/otel register OpenTelemetry SDK
exporters with background timers that keep the Node process alive
indefinitely. During prerender these side-effects cause the process to
hang after rendering completes, preventing the route report from printing.

Set VINEXT_PRERENDER=1 in prerenderApp/prerenderPages and gate
__ensureInstrumentation() on that env var so instrumentation is skipped
during prerender builds.

* feat: production prerender pipeline for Cloudflare Workers (App Router + Pages Router)

- Add wrangler devDep to packages/vinext; add `@cloudflare/workers-types` to catalog
- Fix prerender.ts: typed wrangler import, Unstable_DevWorker, undici Response casts,
  URL-string extraction for dev.fetch() (no Request object), staticParamsMap Proxy
- Remove process.env.VINEXT_PRERENDER gate from /__vinext/prerender/static-params endpoint
- Add cf-app-basic pages/ fixture (index, about, posts/[slug], api/ping)
- Update buildCloudflareAppFixture to also run buildPagesFixture for Pages Router bundle
- Add CF prerender tests: shared beforeAll, nested App Router + Pages Router describes (46/46)

* .

* feat: CF Workers hybrid build Pages Router prerender support

* regen snaps

* fix(prerender): detect CF Workers build via @cloudflare/vite-plugin in node_modules

Replace wrangler.json file-presence check with node_modules detection,
consistent with how deploy.ts detects CF projects. The old check looked
in dist/server/ which never has a wrangler.json, so isWorkersBuild was
always false for CF projects, causing only the 404 to be prerendered.

The generated dist/server/wrangler.json (from @cloudflare/vite-plugin)
is now used as the config path for unstable_dev, with the project-root
wrangler.jsonc as a fallback. This ensures assets.directory is present,
which wrangler 4+ requires.

* fix(prerender): address bonk review comments

- runWithConcurrency: early return on empty items instead of spawning a
  spurious worker via the '|| 1' fallback
- staticParamsMap Proxy: flip has() trap to return false so the typeof-fn
  check works for routes without generateStaticParams on CF Workers builds;
  also handle null return from the proxy fn (no generateStaticParams) in
  the parent-params expansion path
- loadWrangler(): extract shared helper with a two-candidate fallback
  (wrangler-dist/cli.js → index.js) used by both prerenderApp and
  runPrerender, replacing duplicated path resolution + existence checks
- VINEXT_PRERENDER process.env mutation: add explanatory comments
  documenting why the global mutation is intentional and safe for the
  sequential-call contract

* refactor(prerender): extract findWranglerConfig, pass CF detection through, cache static-params

- Extract findWranglerConfig(serverDir, projectRoot) helper from both prerender.ts and
  run-prerender.ts, eliminating the duplicated 4-candidate wrangler.json search
- Add isWorkersBuild and wranglerConfigPath optional fields to PrerenderAppOptions so
  runPrerender can pass its already-computed values into prerenderApp, avoiding a
  redundant findInNodeModules walk + 4-candidate fs.existsSync loop on every build
- Add a per-build staticParamsCache (Map keyed on pattern+parentParams) inside the CF
  Proxy to dedup repeated /__vinext/prerender/static-params round-trips for deeply
  nested dynamic routes
- Add clarifying comment on renderUrl's runWithHeadersContext wrapper explaining it is
  a no-op for the CF Workers path (rscHandler is an HTTP proxy; ALS context never
  crosses the isolate boundary) but kept for shape-compatibility across both modes

* fix(prerender): address remaining bonk review comments

- Security: gate /__vinext/prerender/* endpoints behind VINEXT_PRERENDER=1 check
  to prevent exposure in normal deployments (process.env works for both Node and
  CF Workers via Miniflare's var injection into process.env)
- Bug: buildUrlFromParams now throws a clear error when a required param is
  missing instead of silently producing 'undefined' in the URL
- Design: add TODO comments for layout-level generateStaticParams limitation in
  both resolveParentParams() and generateStaticParamsMap
- Correctness: prerenderPages() now uses runtime module exports (getServerSideProps/
  getStaticProps) to classify page type on Node builds instead of static file
  analysis; CF builds continue to use classifyPagesRoute() as fallback

* fix(prerender): address latest bonk review comments

- Add shape validation for parentParams on the /__vinext/prerender/static-params
  endpoint: JSON.parse result is guarded to ensure user generateStaticParams always
  receives a plain object, never a primitive, array, or null
- Add .gitignore for tests/fixtures/cf-app-basic/dist/ to prevent accidental commits
  of build output if test cleanup fails (CF fixture builds to source tree, unlike
  other fixtures that use tmpdirs)
- Add comment on nextConfigOverride shallow merge in run-prerender.ts to make the
  limitation explicit for future maintainers

* regen snaps

* fix: build CF fixture in tmpdir instead of source tree

Use createIsolatedFixture in buildCloudflareAppFixture so the CF Vite
build output goes to a tmpdir rather than tests/fixtures/cf-app-basic/dist/.
Adds an optional nodeModulesDir param to createIsolatedFixture so callers
with fixture-scoped deps (like @cloudflare/vite-plugin) can point the
symlink at the fixture's own node_modules instead of the workspace root.
Removes the stopgap .gitignore and the afterAll that deleted dist/ from
the source tree.

* fix: cache-bust prod-server import() to prevent stale module reuse in tests

startProdServer() used a bare file:// URL for its dynamic import() of the RSC
entry bundle. Node's module cache keyed on that URL, so when two test describe
blocks rebuild to the same output path the second invocation always got the
cached module from the first build. The stale module had __instrumentationInitialized
already set to true and globalThis.__VINEXT_onRequestErrorHandler__ pointing at the
first build's instrumentation instance, whose capturedErrors array lived in a
different module instance than the one the production route handler was reading.

Fix: append ?t=<mtime> to the import URL, matching the pattern used by prerender.ts
loadBundle(). Same mtime means same content (cache hit, no-op); new mtime means
a fresh build and gets a fresh module. Applied to both startAppRouterServer and
startPagesRouterServer.

Also removes debug console.log calls from the instrumentation production test.

* fix: use globalThis for instrumentation test state to survive sequential prod builds

In Vitest, the 'App Router Production build' and 'App Router Production
server' describes run in the same process. The first build's preview server
imports dist/server/index.js uncached, setting
globalThis.__VINEXT_onRequestErrorHandler__ to onRequestError_v1 from the
first module instance. The second build is loaded cache-busted (by mtime),
producing a fresh module instance (v2). After v2 sets the handler, v1's
__ensureInstrumentation can re-fire and overwrite it, causing errors from
v2 to be recorded in capturedErrors_v1 (a different array) while the GET
route reads capturedErrors_v2 (empty).

Fix: store capturedErrors and registerCalled on globalThis (same pattern
as the middleware counter) so all module instances write to and read from
the same shared state regardless of which build instance is active.

Also removes debug logging and restores the afterAll dist cleanup that was
commented out during investigation.

* add process.exit(0) at end of build
2026-03-16 19:18:37 +00:00
Stephen Zhou 25f6e0fc87 chore: enable typeAware and typeCheck, use vp check (#551)
* chore: enable typeAware and typeCheck, use vp check

* Build

* Try cache false

* Revert "Try cache false"

This reverts commit 5f76ed02f1.

* Update

* workaround for vp check

* Try no workaround

* Fix check

* Update to 0.1.12

* denyWarnings
2026-03-16 11:00:47 -05:00
James Bithell 2f42e05dae fix: merge top-level optimizeDeps with per-environment config (#539)
* fix: merge top-level optimizeDeps with per-environment config

When vinext's config() hook builds per-environment optimizeDeps, it now
merges any exclude/include entries from config.optimizeDeps (populated by
earlier plugins like @lingui/vite-plugin) instead of overwriting them.
This ensures packages excluded by other plugins remain excluded across
all three environments (rsc, ssr, client).

Fixes #538

https://claude.ai/code/session_01T7kpjyEcsZTUG6wHscx3nb

* fix: merge top-level optimizeDeps with per-environment config

When vinext's config() hook builds per-environment optimizeDeps, it now
merges any exclude/include entries from config.optimizeDeps (populated by
earlier plugins like @lingui/vite-plugin) instead of overwriting them.
This ensures packages excluded by other plugins remain excluded across
all three environments (rsc, ssr, client).

Add test verifying top-level optimizeDeps entries are preserved in all
per-environment configs after vinext's config hook runs.

Fixes #538

https://claude.ai/code/session_01T7kpjyEcsZTUG6wHscx3nb

* docs: add PR description for optimizeDeps merge fix

https://claude.ai/code/session_01T7kpjyEcsZTUG6wHscx3nb

* chore: remove pr-description.md

https://claude.ai/code/session_01T7kpjyEcsZTUG6wHscx3nb

* fix: merge optimizeDeps for Pages Router + improve test coverage

Address PR review feedback:
- Move incomingExclude/incomingInclude capture above the hasAppDir
  branch so Pages Router builds also merge entries from earlier plugins
  (e.g. @lingui/vite-plugin) instead of overwriting them.
- Add @vercel/og assertion to confirm vinext's own entries survive merging.
- Add Set-based deduplication assertions to all environments.
- Test overlapping entries (incoming "vinext" + vinext's own "vinext")
  to validate the Set dedup logic.

https://claude.ai/code/session_01T7kpjyEcsZTUG6wHscx3nb

* fix: preserve optimizeDeps.include for Pages Router + fix formatting

Address upstream PR review feedback:
- Spread incomingInclude into top-level optimizeDeps so Pages Router
  builds preserve include entries from upstream plugins (e.g. @lingui/vite-plugin)
- Run pnpm run fmt to fix formatting in test file

https://claude.ai/code/session_01T7kpjyEcsZTUG6wHscx3nb

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-16 14:09:45 +00:00
Nathan Nguyen f3c02b84a0 test: add production coverage for global-error compat (#556)
* Add production coverage for global-error compat

* Address global-error review feedback

* docs: update tracker notes for tests #3 and #4 to reflect production preview coverage
2026-03-16 11:19:40 +00:00
Nathan Nguyen 847260ccde fix: align next/headers cookie path semantics (#555)
* Align next/headers cookie path semantics

* Address cookie path review feedback
2026-03-16 11:15:35 +00:00
Nathan Nguyen e720372e8e fix: preserve named has captures in config destinations (#557)
* fix: preserve named has captures in config destinations

* fix: align locale-static has capture precedence
2026-03-16 11:13:26 +00:00
Nathan Nguyen ad368d9c53 fix: use waitUntil in after() for Cloudflare Workers (#543)
* fix: use waitUntil in after() to prevent task loss on Cloudflare Workers

after() was scheduling tasks as fire-and-forget microtasks, which are
silently dropped when the Cloudflare Workers runtime terminates after
the response stream completes.

Now uses getRequestExecutionContext()?.waitUntil() to keep tasks alive
past the response, falling back to fire-and-forget when no execution
context exists (e.g. Node.js dev server).

Also adds throwIfInsideCacheScope() guard matching Next.js behavior —
after() must not be called inside "use cache" scopes.

* fix: make after() synchronous with static imports per review feedback

after() was async with dynamic imports, which broke the API contract:
- Next.js after() returns void (fire-and-forget), so making it async
  causes unhandled rejections when throwIfInsideCacheScope throws
- await import() deferred waitUntil registration to a later microtask,
  risking task loss if the Worker tears down in between

Changed to static top-level imports and synchronous function signature.
Updated tests to assert synchronous throw behavior and remove setTimeout
hacks that were only needed due to the async implementation.

* fix: inline cache-scope check in after() to avoid headers.ts static import

The static `import { throwIfInsideCacheScope } from "./headers.js"` pulled
headers.ts into the module graph for every consumer of next/server. headers.ts
contains the literal string "use cache" which triggers Vite's cache-transform
handler (`code.includes("use cache")`). Pages Router fixtures without
@vitejs/plugin-rsc then fail at dev-server startup.

Inline the cache-scope check using the same Symbol.for ALS keys, avoiding the
problematic import chain. This mirrors how connection() already avoids eagerly
importing headers.ts (via dynamic import), but keeps after() synchronous.

* address review: fix cache-scope error message and add unstable_cache test for after()

* fix: use AST directive check before throwing missing-RSC error in use-cache transform

The fast-path code.includes("use cache") check in the vinext:use-cache transform
handler was firing on files that contain the string only in error messages or
comments (after TS constant folding), not as actual directives. This caused Pages
Router fixtures without @vitejs/plugin-rsc to throw at startup when server.ts
(which uses "use cache" in its cache-scope error message) was transformed.

Fix: parse the AST first and confirm an actual file-level or function-level
"use cache" directive exists before throwing the missing-RSC error.

Also address second-round review comments:
- server.ts: add TODO about silent waitUntil fallback vs Next.js throw behavior
- server.ts: document timing divergence (concurrent vs post-response) on task execution
- shims.test.ts: drop unnecessary async from runWithExecutionContext callback
- shims.test.ts: bump fire-and-forget fallback timeout 10ms -> 50ms for CI robustness

* fix: tighten AST walker and clarify after() TODO per review

- Add MethodDefinition to the explicit node-type check in astHasInlineCache
  (was handled by generic key traversal but should be listed explicitly)
- Fix array heuristic in astHasInlineCache to use .some() instead of checking
  child[0], which could miss arrays starting with null
- Remove node.consequent fallback from nodeHasInlineCacheDirective — consequent
  is a SwitchCase property and a 'use cache' directive there would not be a
  valid function-level directive
- Clarify the after() TODO comment: the dev server always returns null from
  getRequestExecutionContext(), so the fire-and-forget fallback is correct there;
  the warning concern is production-only (Workers with missing runWithExecutionContext)

* address bonk review: clarify comments and improve test determinism

- Add inline comment explaining why `?? node.value` is needed for MethodDefinition nodes in the AST walker
- Expand TODO on the after() fire-and-forget fallback to note the warn should be gated to production (NODE_ENV or Workers runtime check) and use a module-level _warned flag
- Replace 50ms setTimeout in the fire-and-forget test with two await Promise.resolve() ticks, which is deterministic since after() wraps tasks in Promise.resolve().then(task)

* test: make error-swallowing after() test deterministic

Replace 10ms setTimeout with two microtask awaits in the pre-existing
error-swallowing test, matching the same pattern used in the fire-and-forget
test. after() wraps function tasks in Promise.resolve().then(task) so two
await Promise.resolve() ticks are sufficient and deterministic.

* address bonk review: clarify MethodDefinition unwrapping, unhandledrejection safety note, and ALS test isolation comment

* fix: unwrap BlockStatement in nodeHasInlineCacheDirective to restore inline use-cache detection

---------

Co-authored-by: James <james@eli.cx>
2026-03-16 09:37:03 +00:00
Nathan Nguyen abb1aa617c fix: eliminate double middleware execution in hybrid app+pages dev mode (#541)
* fix: eliminate double middleware execution in hybrid app+pages dev mode

In hybrid app+pages dev setups, middleware ran twice per App Router
request — once in the connect handler (SSR env) and again in the RSC
entry (RSC env). This caused double side effects for auth checks,
logging, analytics, and any middleware state mutations.

The connect handler now forwards its middleware results to the RSC entry
via an x-vinext-mw-ctx request header containing the response headers,
rewrite URL, and rewrite status. The RSC entry reconstructs _mwCtx from
the forwarded data instead of re-executing the middleware function.

Key details:
- Response headers are deferred (not applied to res) in the connect
  handler when hasAppDir is true, avoiding duplicates. They're applied
  to res only for Pages routes handled directly by the connect handler.
- The RSC plugin constructs its Request from the original HTTP request,
  not from req.url, so middleware rewrites must be forwarded explicitly
  (the connect handler's req.url rewrite is invisible to the RSC entry).
- Pure App Router (no pages/) and pure Pages Router (no app/) setups
  are unaffected — they already use a single middleware execution path.

* test: update entry template snapshot for middleware forwarding

* fix: address code review findings for middleware forwarding

- Guard x-vinext-mw-ctx check with NODE_ENV !== "production" so the
  forwarding header cannot be exploited in production builds
- Replace bare catch {} with error logging and fallback to running
  middleware normally when the forwarded context is malformed
- Filter x-middleware-next and x-middleware-rewrite from forwarded
  header entries to match the RSC entry's inline filtering behavior
- Apply deferred middleware response headers before all three
  proxyExternalRewriteNode call sites (beforeFiles, afterFiles,
  fallback external rewrites)

* test: update entry template snapshot for review fixes

* fix: extract deferred headers helper, clarify flag placement per review

- Extract applyDeferredMwHeaders() helper to deduplicate 6 identical
  inline blocks in the connect handler
- Add comment clarifying that __mwCtxApplied flag placement inside try
  block is intentional — falls back to re-running middleware on error
2026-03-16 09:28:59 +00:00
Nathan Nguyen 39791bf3cc feat: implement revalidateByPathPrefix on KVCacheHandler (#552)
* feat: implement revalidateByPathPrefix on KVCacheHandler

Uses KV list metadata to discover entry tags without extra get() calls.
set() now stores tags in KV metadata, and revalidateByPathPrefix reads
them back via kv.list() — making prefix invalidation O(list_pages)
instead of O(entries × get).

Entries written before metadata support are gracefully skipped.

* fix: guard against KV metadata 1024-byte limit

Cloudflare KV limits metadata to 1024 bytes per key. If an entry's
tags exceed this budget when JSON-serialized, omit metadata entirely
and fall back gracefully — prefix invalidation skips entries without
metadata, and exact-path invalidation via revalidateTag still works.

* test: add regression test for KV metadata 1024-byte overflow
2026-03-16 09:23:45 +00:00
gagipro 9aa1748ea6 fix(ssr): preload client reference modules before first SSR render (#257)
On cold start, client reference modules ('use client' components) are loaded
lazily via async import() through @vitejs/plugin-rsc's memoized
__vite_rsc_client_require__. The memoize cache is cold, so the function returns
an unresolved Promise. Without <Suspense> wrapping the root shell, React SSR
rejects and the server returns 500. Subsequent requests work because the cache
is warm from the first (failed) attempt.

The fix imports virtual:vite-rsc/client-references in the generated SSR entry
and awaits all client reference modules via Promise.all before
renderToReadableStream runs. This warms the memoize cache so all calls during
rendering resolve synchronously.

Changes addressing reviewer feedback:
- Add regression test replicating the memoize + cold-cache pattern (thanks to
  @NathanDrake2406 for contributing the test via PR #546 comment — greatly
- Replace silent .catch(() => {}) with dev-mode console.warn per review
- Add _clientRefsPreloaded flag to skip preloading after first request
- Remove unnecessary optional chaining on .default and .catch

Closes #256

Co-authored-by: NathanDrake2406 <NathanDrake2406@users.noreply.github.com>
2026-03-16 08:39:17 +00:00
Nathan Nguyen cd08cbaa6e fix: add ResponseCookies.has() shim (#554) 2026-03-16 07:21:03 +00:00
James Anderson 7b6a419024 fix: use server closure variable collision with local declarations (#527)
* fix: `use server` closure variable collision with local declarations

* fix type error

* address comments

* more tweaks

* more tweaks

* more tweaks

* fix: address remaining edge cases in use-server-closure-collision plugin

- Extend local-decl scan to use collectAllDeclaredNames recursively, fixing
  false negatives for for...of / for...in loop variables
- Collect ClassDeclaration and ImportDeclaration names as ancestor bindings
  so collisions with class/import names are detected
- Guard LabeledStatement.label, BreakStatement.label, ContinueStatement.label
  in renamingWalk — these are Identifiers but not variable references
- Use a suffixed rename target (__local_0_name, etc.) when __local_name is
  already declared in the function body, preventing secondary collisions
- Clean up stale test comments that described the plugin as commented-out
- Add 7 new tests covering all of the above cases (26 total, all passing)

* fix: handle ClassDeclaration in collectAllDeclaredNames

collectAllDeclaredNames was missing a ClassDeclaration case, so a
`class cookies {}` declared inside a 'use server' function body would
not be added to localDecls. The collision with an outer `cookies`
binding would be silently missed — the original periscopic SyntaxError
would persist for this pattern.

Fix by adding a ClassDeclaration branch that records the class name and
returns without recursing into the class body (same pattern already used
for FunctionDeclaration and consistent with collectFunctionScopedNames).

Adds a regression test for the class-in-body collision case (27 tests total).

* chore: update test imports to vite-plus/test and vite-plus
2026-03-16 07:14:23 +00:00
Stephen Zhou 62910e80e9 chore: move to vp pack, merge vitest config (#550)
* chore: migrate to vp pack

* No copy

* Move test

* Update tsconfig
2026-03-15 11:58:42 +00:00
Stephen Zhou c17d6941be chore: migrate to vite plus (#535)
* chore: migrate to vite plus

* Disable typeAware and typeCheck

* Update CI

* Fix CI

* Fix test

* Clean

* Run test with vp

* Try revert

* react: false In test

* Fix test

* Revert "Try revert"

This reverts commit 009da10473.

* Update

* Update

* Try revert ci changes

* revert

* Run vp migrate

* Disable typeAware and typeCheck for now

* Better resolve for test

* Use vp dev instead of vite

* Update expect

* Fix NormalizeManifestModuleId

* Try increase timeout

* Update to use vp

* Try new check

* Bring back npx vp

* Migrate CI

* Make next-intl resolvable

* Update

* Update

* Update
2026-03-15 10:50:13 +00:00
aidan 04bff2c823 fix: concurrent SSR isolation for pages router head/router state (#532)
* test: add concurrent ssr isolation fixture pages for pages router

* test: add pages router dev concurrency isolation tests

* fix: register head/router als accessors in ssr module graph

* test: add prod concurrency isolation tests

* docs: add als architecture and per call scope docs

* fix: move als accessor registration to one time setup in createSSRHandler

* test: add assertion messages and explicit guards for ci debugging

* test: use lookahead in extractMetaContent for attribute order independence

* test: add comment explaining prod head test limitations

* docs: clarify pr #450 wording in als architecture doc

* test: add random delay in fixture getServerSideProps to increase interleaving

* test: align concurrent-router query type with ParsedUrlQuery

* test: use shared createIsolatedFixture helper in prod concurrency tests

* docs: clarify ssrLoadModule guidance in als architecture doc
2026-03-15 10:09:44 +00:00
Nathan Nguyen 6723edb3cd fix: use closest ancestor parallel slot instead of farthest (#545)
discoverInheritedParallelSlots iterates from root to leaf, but used
a `has()` guard that let the first (farthest) ancestor win. Remove
the guard so later (closer) ancestors overwrite earlier ones.
2026-03-15 10:01:29 +00:00
Nathan Nguyen 2ae0b9c090 fix: prevent user searchParams from leaking into ISR cache (#542)
The ISR background regeneration callback in the generated App Router entry
was passing the triggering user's url.searchParams into setNavigationContext()
and buildPageElement(). This meant cached content reflected one user's query
params and got served to all subsequent users — a data leak.

Headers were already correctly emptied (new Headers()), but searchParams were
missed. Now both use new URLSearchParams() to produce clean cached content.
2026-03-15 09:29:07 +00:00
Nathan Nguyen f12c2005a6 feat: add ISR caching for App Router route handlers (#523)
* feat: add ISR caching for App Router route handlers

Route handlers with `export const revalidate = N` now get server-side
ISR caching (MISS/HIT/STALE with background regeneration), matching
the existing page ISR behavior. Previously only Cache-Control headers
were emitted — the handler re-executed on every request.

- Add `__isrRouteKey` helper (suffix "route") for cache key construction
- ISR cache READ before handler execution (HIT skips handler entirely)
- ISR cache WRITE on MISS via `response.clone()` + `waitUntil`
- STALE serves stale data and triggers background regen with clean
  context (synthetic request, empty headers/cookies)
- Guards: production-only, GET/HEAD only, skips when handler is dynamic
  (uses headers()/cookies()), skips when handler sets own Cache-Control,
  filters Infinity from revalidateSeconds
- Reuses existing CachedRouteValue (kind: "APP_ROUTE") and KV serialization

* chore: update entry-templates snapshots for route handler ISR

* test: add auto-HEAD ISR cache test for route handlers

* fix: review findings — force-dynamic guard, consistent header filtering in cache writes

* test: poll for ISR background regen instead of fixed sleep

Replace fixed 1000ms sleep with a polling loop (up to 5s) to wait
for the background regen to complete. The fixed sleep was flaky
because regen timing varies with system load. Also bumps the stale
wait from 1500→2000ms for slow CI.

* fix: capture request.url before regen closure to avoid pinning Request

Hoist request.url into a local const (__revalUrl) before the
background regeneration closure. This lets the original Request
object (with its body stream and headers) be GC'd sooner on
memory-constrained Workers isolates.

* fix: address remaining review findings

- Hoist url.searchParams into __revalSearchParams before the regen
  closure so the outer URL object can be GC'd during background regen
  (same class of fix as the request.url hoisting)
- Add test for force-dynamic + revalidate route handler guard
- Add comment noting order-dependent test cache state
2026-03-14 12:48:40 +00:00
Stephen Zhou 20285ccb83 chore: move to pnpm catalogs (#531) 2026-03-13 17:14:35 +00:00
Stephen Zhou 21558f3092 feat: support @vitejs/plugin-react v6 as peer (#525)
* feat: support @vitejs/plugin-react v6 as peer

* Update

* Update

* Update

* Update

* Update

* Update

* Update

* Update

* Update

* Update

* Update
2026-03-13 15:35:36 +00:00
James Anderson 23f9e1e13d feat: propagate next config serverExternalPackages (#526)
* feat: propagate next config serverExternalPackages

* .
2026-03-13 13:44:06 +00:00
Sékou Dayifourou KEITA d29ee0eebb fix(route-handlers): align Allow and default-export behavior with Next.js (#497)
* fix: ensure route handlers send correct Allow header

* Inline route handler Allow helpers in RSC entry

* fix: refresh entry snapshots and fmt

* fix(route-handlers): align Allow and default-export behavior with Next.js

* fix(route-handlers): align default-export behavior with Next.js per review

Follow-up for PR #497 review feedback (requested changes).

Changes:

- stop throwing on default export in route handlers

- log dev-only warning when default export is detected

- ignore default export for dispatch so default-only handlers return 405

- keep sorted Allow on implicit OPTIONS and no Allow on 405

- update integration/e2e tests, snapshots, and tracking notes
2026-03-13 12:16:46 +00:00
Nathan Nguyen 995d858633 Ignore @slot and _private dirs in metadata scan (#520)
* Ignore @slot and _private dirs in metadata scan

* Disambiguate metadata routes under invisible segments

* Exclude @children from metadata suffixing
2026-03-13 11:55:40 +00:00
Nathan Nguyen f3994c655c Fix next/head key deduplication (#518)
* Fix next/head key deduplication

* Fix multi-child next/head deduplication

* Align next/head key normalization with Next.js
2026-03-13 10:11:44 +00:00
Nathan Nguyen eed47a3117 fix: add missing set/delete/clear/size/toString to RequestCookies (#517)
* fix: add missing set/delete/clear/size/toString to RequestCookies

The RequestCookies class in server.ts (used by NextRequest.cookies) was
missing mutation methods that @edge-runtime/cookies provides. Middleware
that calls cookies.set() or cookies.delete() to forward modified cookies
downstream would fail at runtime.

- Add set(), delete(), clear(), size, toString() methods
- Cache parsed map in constructor instead of re-parsing on every call
- Encode values with encodeURIComponent when syncing back to Cookie header
- delete() accepts string | string[] and returns boolean | boolean[]
  matching @edge-runtime/cookies API

* refactor: DRY up RequestCookies clear/toString/syncHeader

Address review feedback:
- clear() delegates to _syncHeader() instead of inlining header deletion
- Extract _serialize() helper shared by toString() and _syncHeader()
2026-03-13 07:38:43 +00:00
Nathan Nguyen 08f9d94bbb fix: add missing .js alias variants for next/config and next/amp (#522)
Libraries that import `next/config.js` or `next/amp.js` (with the
explicit .js extension) failed to resolve to vinext shims. Every other
public next/* entrypoint already had both variants.

Adds a regression test that verifies all top-level next/* aliases have
a corresponding .js variant, preventing future omissions.
2026-03-13 07:27:38 +00:00
Jared Stowell 5c7b8a4f7c fix: image optimizer fallback stream reuse (#513)
* fix image optimizer fallback

* refetch on failure
2026-03-13 07:25:53 +00:00
Nathan Nguyen 1c4f5ad549 fix: propagate middleware waitUntil to Workers execution context (#515)
The App Router generated entry called mwFetchEvent.drainWaitUntil() but
discarded the return value, so middleware background promises registered
via event.waitUntil() were abandoned when the response was sent on
Workers. Register the drained promise with the execution context,
matching the Pages Router entry pattern.
2026-03-13 07:20:54 +00:00
Divanshu Chauhan (divkix) e6439ffce8 refactor: phase 1/2 unify per-request ALS into a shared request context (#450)
* refactor: consolidate 5 nested ALS scopes into unified request context

Every App Router request previously ran through 5-6 nested
AsyncLocalStorage.run() calls (headers, navigation, cache-state,
private-cache, fetch-cache, plus optional execution-context). Each
ALS scope push/pop has measurable cost on Workers isolates.

Introduce a single UnifiedRequestContext that holds all per-request
state in one flat object, backed by one ALS instance. Each shim module
checks isInsideUnifiedScope() first and reads its sub-fields from the
unified store, falling back to its own standalone ALS when outside
(SSR environment, Pages Router, tests).

- Create unified-request-context.ts with createRequestContext(),
  runWithRequestContext(), getRequestContext(), isInsideUnifiedScope()
- Rewire _getState() in headers, navigation-state, cache, cache-runtime,
  fetch-cache, and request-context to dual-path (unified first, own ALS
  fallback)
- Make each runWith*() a no-op inside unified scope
- Replace 5-deep nesting in app-rsc-entry.ts (main handler + ISR regen)
  with single _runWithUnifiedCtx() call
- Export ensureFetchPatch() from fetch-cache for standalone patch install
- Add 17 unit tests for unified context (isolation, nesting, concurrency)

* test: update entry-templates snapshots for unified ALS context

Snapshots were stale after the unified request context refactor changed
the generated entry code (new imports, removed 5-deep nesting).

* test: update app router waitUntil assertion

* refactor: unify request ALS across router flows

* fix: address unified request context review feedback

* fix: address unified ALS review feedback

* fix: address remaining unified ALS review feedback

* fix: wrap Pages Router ISR regeneration in unified context

Ensures patched fetch and ALS-based context are available during
background regeneration, matching App Router behavior.

- Creates fresh unified context with execution context for regen
- Calls ensureFetchPatch() to enable cache tagging
- Adds test verifying _runWithUnifiedCtx and ensureFetchPatch presence
- Updates snapshot

Part of Phase 1.5: minimal Pages Router fix for ISR parity.

* Fix dev ISR unified context parity

* fix: address unified ALS review followups

* Fix pages router unified execution context seeding

* Fix Pages Router ISR rerender state isolation

* fix: address unified ALS review feedback
2026-03-13 07:19:13 +00:00
James Anderson 735a443169 chore: upgrade vitest to 4.1 (#512)
* chore: upgrade vitest to 4.1

* fix: clear console.warn spy state before asserting call counts

Vitest 4 changed vi.spyOn to return the same mock when called on an
already-intercepted function. This means console.warn call records from
earlier tests accumulate in the spy, causing toHaveBeenCalledTimes(2) to
see 4 calls instead of 2.

Adding mockClear() after each spy creation resets the call count so the
assertions only reflect calls made within the test itself.
2026-03-13 05:53:39 +00:00
Webdesign29 85f8c493b7 fix: handle URL objects in metadata resolveUrl (#465)
* fix: handle URL objects in metadata resolveUrl

The Next.js Metadata API types URL fields as `string | URL` (e.g.
`metadataBase`, `openGraph.url`, icon/image URLs). The internal
`resolveUrl()` function in `MetadataHead` only accepted `string`,
so passing a `URL` object (which is valid per Next.js types) crashed
with `url.startsWith is not a function`.

Add type coercion at the top of `resolveUrl()` to convert `URL`
objects to strings before processing.

Reproduction:
1. Set `metadataBase: new URL("https://example.com")` in a layout
2. Set `openGraph: { url: "/page" }` in a page's metadata
3. `resolveUrl` receives the URL object, calls `.startsWith()` on it
4. Crash: `url.startsWith is not a function`

With this fix, `URL` objects are coerced to strings via `.toString()`.

* fix: update Metadata types and rendering to accept string | URL fields

Address bonk review feedback:
- Add test covering URL object input for alternates.canonical and
  openGraph.url to lock in the fix against regressions
- Update Metadata interface to type URL fields as string | URL,
  matching Next.js types (openGraph.url/images/videos/audio,
  twitter.images, icons, manifest, alternates.canonical/languages/
  media/types)
- Add overload to resolveUrl() so it returns string when called with
  a non-undefined argument, eliminating string | URL fallback issues
- Fix image/icon array normalisation to handle bare URL objects
  alongside strings and descriptor objects

* fmt

* fix: remove dead-code ?? fallbacks and unnecessary test casts

Address bonk re-review feedback:
- Remove 'as unknown as string' casts in the URL-object test since the
  Metadata interface now types those fields as string | URL
- Remove all 17 remaining 'resolveUrl(x) ?? x' fallbacks; resolveUrl
  is overloaded to return string for any truthy input so these never
  fired, and with string | URL fields the fallback value would have
  passed a URL object directly to content=/href= attributes

* fmt

---------

Co-authored-by: James <james@eli.cx>
2026-03-12 19:32:31 +00:00
Nathan Nguyen 7e5894103f fix: serve metadata routes in dynamic segments (#500)
* fix: serve metadata routes in dynamic segments (opengraph-image, icon, etc.)

Metadata routes like opengraph-image.tsx inside dynamic segments
(e.g., app/blog/[slug]/opengraph-image.tsx) returned 404 because the
runtime matching used strict equality against the servedUrl which
contains literal bracket notation ([slug]).

Two fixes:
- Generate patternParts for metadata routes with dynamic segments so
  the runtime uses matchPattern() instead of strict equality
- Pass extracted params to the metadata function via makeThenableParams()
  matching Next.js behavior where dynamic metadata routes receive { params }

* fix: address review — scope patternParts to dynamic routes, fix indentation

- Only generate patternParts for dynamic metadata routes (static files
  don't use params)
- Add "keep in sync" comment for segment-to-pattern conversion
- Fix mis-indented try/catch braces in generated static metadata code
2026-03-12 18:56:06 +00:00
Nathan Nguyen 7c425ef34d fix: escape XML special characters in sitemap generation (#505)
sitemapToXml() interpolated user-supplied values (URLs, video titles,
descriptions, uploader names) directly into XML without escaping. URLs
containing & (extremely common with query params) produced invalid XML
that search engines reject silently.

Add escapeXml() helper that handles all five XML special characters
(&, <, >, ", ') and apply it to every user-supplied value in the
sitemap serializer.

This intentionally diverges from Next.js's resolveSitemap() which also
raw-interpolates, producing malformed XML.
2026-03-12 18:50:31 +00:00
Nathan Nguyen ef9fa6876c fix: preserve query params on middleware rewrite in App Router (#504)
* fix: preserve query params on middleware rewrite in App Router

The middleware rewrite handler in app-rsc-entry.ts only extracted
pathname from the rewrite URL, silently discarding the query string.
All downstream code (searchParams props, useSearchParams(), navigation
context, ISR cache keys) continued using the original request's search
params instead of the rewrite destination's.

Fix: mutate url.search after extracting the rewrite pathname so all
~30 downstream url.searchParams references automatically pick up the
rewrite's query string. This matches the Pages Router's existing
behavior in server/middleware.ts which already preserves pathname+search.

* test: update entry-templates snapshot
2026-03-12 18:26:17 +00:00
Nathan Nguyen dcff154aa3 fix: dedup stale background refetches in fetch cache (#498)
* fix: dedup stale background refetches in fetch cache

Every concurrent request hitting a stale cache entry was independently
firing its own originalFetch(), creating a thundering herd on popular
endpoints. Add a per-cache-key dedup map (pendingRefetches) shared
across RSC/SSR environments via Symbol.for(), matching the existing
ISR regeneration dedup pattern in isr-cache.ts.

Also adds a 60s timeout safety net that force-cleans dedup entries
when upstream fetches hang, with identity checks in both .finally()
and setTimeout to prevent slot-ownership races.

* test: add fetch cache stale dedup concurrency tests

- Concurrent stale hits trigger only one background refetch
- Completed refetches allow new ones (lifecycle test)
- Failed refetches clean up dedup entry (error-path test)
- Hung fetches are force-cleaned after timeout
- Late-settling hung fetch does not evict replacement refetch
- _resetPendingRefetches() called in beforeEach for test isolation

* Update packages/vinext/src/shims/fetch-cache.ts

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

* fix: repair broken setTimeout structure from review suggestion

The applied suggestion duplicated the setTimeout block and broke
brace nesting. Fix the structure and also clear the timeout in
.finally() so completed refetches don't leave dangling timers.

* fix: wrap fake timer tests in try/finally for safety

If a test throws before vi.useRealTimers(), subsequent tests would
run under fake timers and break. Wrap both fake timer tests in
try/finally to guarantee cleanup.

* fix: guard background revalidation against caching error responses

The stale-while-revalidate background refetch unconditionally cached
whatever response came back, including 500s. A transient server error
would permanently replace good cached data. Add a `response.ok` guard
matching the existing cache-miss path.

* fix: tighten cache guard to status === 200

Per review feedback, narrow the cache guard from `response.ok` (2xx)
to `response.status === 200` in both the background revalidation path
and the cache-miss path. Non-200 success codes (201, 204, 206) are
not meaningful to cache in the fetch cache context.

---------

Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>
2026-03-12 17:58:49 +00:00
Nathan Nguyen ce8629be96 fix: use AsyncLocalStorage for per-request i18n locale state (#501)
The i18n locale context (locale, defaultLocale, domainLocales,
hostname) was stored on bare globalThis properties, which are
shared across concurrent requests. Under concurrent traffic
(Cloudflare Workers or Node.js production server), request A's
locale could be overwritten by request B before A finishes
rendering, causing Link components to produce incorrect
locale-prefixed URLs.

Introduce ALS-backed i18n state following the existing pattern
used by router-state.ts and head-state.ts:

- i18n-context.ts: bridge module with fallback globalThis
  accessors and a registration API for ALS-backed overrides
- i18n-state.ts: server-only module that registers ALS-backed
  accessors on import, providing per-request isolation

link.tsx now reads locale context via getI18nContext() instead
of bare globalThis, dev-server.ts sets context via ssrLoadModule
for the SSR environment, and the production entry wraps
rendering in runWithI18nState().
2026-03-12 17:36:17 +00:00
Nathan Nguyen 8d10bc64fd fix: MemoryCacheHandler revalidate:0 creates immediately-stale entries (#503)
* fix: MemoryCacheHandler revalidate:0 creates immediately-stale entries

MemoryCacheHandler.set had an inconsistent guard on data.revalidate:
the ctx path checked `revalidate > 0` but the data path only checked
`typeof data.revalidate === "number"`, allowing revalidate:0 to set
revalidateAt = Date.now() — making entries immediately stale.

This was inconsistent with KVCacheHandler which already had the > 0
guard on both paths. The two handlers would produce different staleness
behavior for the same input: Memory → immediately stale, KV → no TTL.

Add the missing `> 0` guard to align both code paths and both handlers.

* fix: revalidate:0 should skip cache storage entirely

Both MemoryCacheHandler and KVCacheHandler had inconsistent handling of
revalidate:0. In Next.js, revalidate:0 means "don't cache." But the
handlers would still store entries — Memory made them immediately stale
(data path lacked > 0 guard), KV cached them forever (both paths
rejected 0, so revalidateAt=null meant no expiry).

Resolve effective revalidate from both ctx and data paths (data
overrides ctx), and early-return without storing when it's 0. Both
handlers now use identical logic.

Fix existing test data that incidentally used data.revalidate:0 in
tests meant to exercise stale-while-revalidate and tag invalidation.
2026-03-12 17:34:12 +00:00
Stephen Zhou 832d81d456 fix: useSelectedLayoutSegment in dev (#502)
* fix: can not get selected layout segment

* Update

* Update

* Revert

* Revert "Revert"

This reverts commit 136633b239.

* Update

* Update

* Update

* Updated
2026-03-12 17:23:59 +00:00
Nathan Nguyen 38dc409a43 feat: Add Pages Router i18n domain routing (#471)
* Add Pages Router domain locale routing

* Add coverage for Pages Router domain locale behavior

* Format Pages Router i18n domain routing changes

* Handle basePath in Pages Router i18n domains

* Refactor Pages Router i18n domain fixtures

* Update Pages Router entry snapshot after rebase

* Align domain locale redirects with Next.js

* Clarify Pages Router domain locale helpers

* Fix case-insensitive locale prefixes

* Simplify same-domain locale URL handling

* Cover same-domain locale alias redirects

* Document Pages Router i18n invariants

* Guard preferred locale in domain redirects
2026-03-12 13:49:59 +00:00
Nathan Nguyen 3d05dceeab fix: intercept routes (..) climb visible route segments, not filesystem dirs (#499)
computeInterceptTarget() used path.dirname() to climb parent directories,
but Next.js defines interception levels in terms of route segments. Route
groups like (marketing) are filesystem directories but invisible in the
URL, so path.dirname() would waste a climb on them.

Now walks backward through segments, counting only visible ones (skipping
route groups, parallel slots, and ".").

Also consolidates three copies of invisible-segment detection into a
single isInvisibleSegment() helper.
2026-03-12 13:38:21 +00:00
Nathan Nguyen eaade0ff71 fix: support onLoadingComplete in modern next/image shim (#496)
* fix: support onLoadingComplete in modern next/image shim

Add the deprecated-but-still-supported onLoadingComplete prop to the
modern next/image component, matching Next.js behavior where the
callback receives the underlying HTMLImageElement on load.

- Add onLoadingComplete to ImageProps interface and type declarations
- Wire handleLoad into all render paths (local, custom loader, remote)
- Destructure onLoad/onLoadingComplete in getImageProps to prevent leak

* fix: correct callback order and add remote URL test

- Swap onLoad/onLoadingComplete invocation order to match Next.js
  (onLoad fires first, then onLoadingComplete)
- Fix stale comment that described legacy { naturalWidth, naturalHeight }
- Add remote URL (UnpicImage) SSR test for full path coverage
2026-03-12 12:44:56 +00:00
Nathan Nguyen 0ac61c0bfe fix: parse :param(constraint) in middleware matchers (#495)
* fix: parse :param(constraint) in middleware matchers

The middleware matcher compiler in compileMatcherPattern() treated any
pattern containing ( as a raw regex, so /blog/:id(\d+) was compiled as
the literal regex ^/blog/:id(\d+)$ — matching "/blog/:id123" instead
of "/blog/123".

Port the constraint-aware extractConstraint() approach from
config-matchers.ts: when a ( follows a :param name, extract it as an
inline regex constraint and emit a proper capture group. Patterns
without :param( syntax (e.g. /((?!api|_next).*)) still take the raw
regex path.

Also supports :param(constraint)? for optional constrained segments
(e.g. /:locale(en|es|fr)?/about matches both /about and /en/about).

Mirrored in middleware-codegen.ts to keep runtime and generated code
in sync.

* fix: support constraints on catch-all params and add negative optional test

Address review feedback:
- Add :param*(constraint) and :param+(constraint) support so catch-all
  params can also have inline regex constraints (e.g. /:path*(api|static)).
  Update hasConstraints detection to match :param[*+]?( patterns.
- Add negative test for optional constrained params: /de/about must not
  match /:locale(en|es|fr)?/about since de is not in the alternation.
2026-03-12 12:14:22 +00:00
Nathan Nguyen 8f8c2b1d99 fix: emit hashChangeStart/Complete and beforeHistoryChange router events (#468)
* fix: emit hashChangeStart/Complete and beforeHistoryChange events

Next.js emits hashChangeStart/hashChangeComplete for hash-only
navigation and beforeHistoryChange before every history.pushState/
replaceState call. Our router emitted neither.

- Hash-only push/replace now emit hashChangeStart before and
  hashChangeComplete after (instead of routeChange* events)
- Normal push/replace now emit beforeHistoryChange between
  routeChangeStart and the pushState/replaceState call
- All events now pass { shallow } as the second argument per Next.js
- Popstate handler also emits beforeHistoryChange
- Updated in both useRouter() hook and Router singleton code paths

* fix: emit full URL in hash change events, not just fragment

When router.push("#section") is called, emit the full resolved URL
(e.g., "/page#section") in hashChangeStart/hashChangeComplete events
instead of just "#section". Matches Next.js behavior where the event
URL includes the pathname.

* fix: handle hash-only popstate and address review feedback

- Detect hash-only back/forward in popstate handler by tracking
  pathname+search across navigations; emit hashChangeStart/Complete
  instead of routeChangeStart/Complete and skip unnecessary page fetch
- Add comment explaining beforeHistoryChange semantics in popstate
  (fires after URL change, emitted for API compatibility)
- Cache resolveHashUrl() result in all 4 hash code paths
- Add beforeHistoryChange negative assertions in hash-only tests
- Add E2E test for hash-only back/forward navigation events

* fix: pass { shallow } to routeChangeError, add goForward hash test

- routeChangeError now passes { shallow: false } to match Next.js
  signature (err, url, { shallow }), completing the { shallow }
  migration across all router events
- Add E2E test for hash-only goForward() popstate detection

* fix: include hash in popstate beforeHistoryChange URL, stabilize forward test

- beforeHistoryChange in popstate now emits appUrl + window.location.hash
  for consistency with push/replace paths that preserve the hash fragment
- Add waitForFunction between goBack() and clear-events in the forward
  test to prevent race between popstate handler and event clearing

* fix: strip basePath from full-path hash URLs, include hash in popstate event URLs

- resolveHashUrl now handles full-path hash URLs (e.g. /base/page#hash)
  by parsing and stripping basePath, not just fragment-only URLs (#foo)
- routeChangeStart and routeChangeComplete in popstate now include
  window.location.hash for consistency with beforeHistoryChange and
  the push/replace code paths

* fix: update _lastPathnameAndSearch after beforePopState check

Move tracker update to after beforePopState confirms navigation
proceeds. Previously, if beforePopState returned false (cancelling
navigation), the tracker was already updated to the new browserUrl,
causing the next popstate to compare against the wrong baseline and
potentially misdetect a route change as hash-only.
2026-03-12 11:33:23 +00:00
Stephen Zhou 5909e005dd feat: support vite8 resolve.tsconfigPaths (#494)
* feat: support vite8 resolve.tsconfigPaths

* Update
2026-03-12 09:29:02 +00:00
Nathan Nguyen 9bd7f811e0 fix: make useParams() reactive on client-side navigation (#489)
* fix: make useParams() reactive on client-side navigation

useParams() was reading a module-level variable directly instead of
subscribing via useSyncExternalStore, so navigating between dynamic
routes (e.g. /posts/1 → /posts/2) wouldn't re-render components.

Two changes:
- useParams() now uses useSyncExternalStore with the shared listener
  system, matching usePathname() and useSearchParams()
- setClientParams() now calls notifyListeners() when params change,
  so the external store knows to re-check snapshots

* chore: fix formatting in test fixture

* fix: stabilize useParams server snapshot and deduplicate subscribe

- Add _EMPTY_PARAMS constant to avoid creating a new {} on every
  getServerSnapshot call (Object.is would never match, causing
  hydration churn)
- Extract getClientParamsSnapshot/getServerParamsSnapshot named
  functions for consistency with usePathname/useSearchParams
- Extract subscribeToNavigation helper to deduplicate the identical
  subscribe callback across all three hooks
2026-03-12 09:23:10 +00:00
Nathan Nguyen e9063411b8 fix: register reportRequestError with ctx.waitUntil on Workers (#493)
On Cloudflare Workers the isolate can terminate after the Response is
sent, killing in-flight error reports (e.g. Sentry HTTP requests).

Fix by having reportRequestError() self-register with
ctx.waitUntil() via getRequestExecutionContext(). This is the same
pattern used by KVCacheHandler, ISR regeneration, and fetch cache
revalidation. On Node.js (dev or vinext start) the context is null
so it's a no-op — fire-and-forget is fine because the process stays
alive.

Since reportRequestError never rejects (internal try/catch), the
redundant .catch() at all 5 call sites is removed.
2026-03-12 08:57:24 +00:00
Nathan Nguyen d093c3317b fix: Pages Router ISR background regeneration re-renders HTML (#487)
* fix: Pages Router ISR background regeneration re-renders HTML

Background regeneration only re-ran getStaticProps but cached the old
HTML, so server-rendered content was permanently stale after the first
revalidation cycle. The pageData JSON was updated but never used to
re-render the page.

Now the regeneration callback:
- Re-renders the page component with fresh props
- Rebuilds __NEXT_DATA__ with fresh props
- Updates the revalidate duration map (dev server)
- Passes full locale context to getStaticProps

Fixed in both dev server and production server entry.

* ci: retrigger checks

* fix: preserve document shell during prod ISR regeneration

The prod entry's regeneration was hardcoding a simple HTML suffix,
losing custom _document content between <Main /> and <NextScript />.

Now splits the cached HTML at known markers (<div id="__next"> and
__NEXT_DATA__ script) to preserve the full document structure: head
section, gap content, and tail are carried forward from the original
cached entry.

Also updates the entry-templates snapshot.

* ci: retrigger checks

* ci: retrigger checks
2026-03-12 08:28:15 +00:00