Middleware matcher patterns are static (from config.matcher) but
matchPattern() was re-running the full tokeniser + isSafeRegex scan +
new RegExp() on every request for every matcher entry. This is the
same problem that config-matchers.ts solved with _compiledPatternCache
(which eliminated ~2.4s of CPU self-time in profiling).
Add a Map<string, RegExp | null> cache to both the runtime
matchPattern() and the codegen matchMiddlewarePattern() so each
pattern is compiled exactly once per process lifetime.
* fix: register background KV ops and ISR regen with ctx.waitUntil on Workers
On Cloudflare Workers, any async work started after a Response is returned
is killed immediately. This caused three silent failures:
1. ISR background regeneration (stale-while-revalidate) was never
registered with ctx.waitUntil, so every revalidation on Workers was
silently dropped.
2. KV deletes for corrupt/invalid/tag-invalidated cache entries were
awaited on the critical path, blocking the response for pure cleanup work.
3. KV writes in KVCacheHandler.set() were awaited on the critical path;
the client already has the rendered content so the write can happen
after the response is sent.
Fixes:
- Thread ExecutionContext through triggerBackgroundRegeneration and call
ctx.waitUntil(regenPromise) when ctx is present (isr-cache.ts and the
inlined copy in pages-server-entry.ts).
- Add _deleteInBackground / _putInBackground helpers to KVCacheHandler
that use ctx.waitUntil when ctx is present and fall back to
fire-and-forget on Node.js.
- Convert all three delete callsites (corrupt JSON, invalid shape,
tag-invalidation) and the set() KV write to use the new helpers.
- All changes are backward-compatible: ctx is optional everywhere;
Node.js dev/prod behaviour is unchanged.
* regen snaps
matchRedirect was running exec() on all 63+ compiled locale-prefix
regexes (e.g. ^/(en|es|fr|...)?/security$) for every request that
matched no redirect — the common case. Profiling showed 2992ms self-time
on compiled.re.exec.
The regex compilation was already cached (previous fix). What remained
was the execution cost: 63 anchored alternation regexes × ~2ms each =
~130ms per no-match request, cumulative 2992ms under load.
Fix: build a per-redirects-array index (WeakMap, computed once) that
classifies each rule:
localeStatic — source matches /:param(alt1|alt2|...)?/static-suffix
Indexed in Map<suffix, entry[]> for O(1) lookup. At match time we
extract the optional locale segment with one indexOf('/') call (no
regex), look up the suffix in the map, then validate the locale
against a tiny anchored alternation regex (one per match, not per
rule).
linear — all other rules. Unchanged O(n) matchConfigPattern scan.
For the no-match hot path (no redirect fires): 1 map miss + O(25)
linear rules instead of O(88 regex execs). For matching requests,
original ordering is preserved: locale-static entries store their
originalIndex; linear rules before a locale-static match are still
checked first via matchConfigPattern.
* perf: cache compiled regex patterns in matchConfigPattern
Redirect/rewrite patterns are static — they come from next.config.js and
never change at runtime. Previously every request re-ran the full tokeniser
walk + isSafeRegex + new RegExp() for every rule in the array. On apps with
locale-prefixed rules (all containing '(' so they enter the regex branch)
profiling showed ~2.4 s of CPU self-time in isSafeRegex per request cycle.
Fix: add a module-level Map that stores the compiled { re, paramNames } on
first encounter of each pattern string. Subsequent requests skip the
tokeniser and isSafeRegex entirely and go straight to re.exec(pathname).
null is cached for patterns rejected by safeRegExp so the ReDoS scanner
also runs at most once per pattern.
Adds two new test cases covering the cache hit path and the cached-null
(ReDoS-rejected) path.
* fmt
* perf: cache compiled regexes in matchHeaders and checkSingleCondition
matchHeaders called escapeHeaderSource() + safeRegExp() on every request for
every header rule. checkSingleCondition called safeRegExp(condition.value) on
every request for every has/missing condition across header/cookie/query/host
branches. Both are pure functions of their input strings, which come from
next.config.js and never change at runtime.
Fix: two new module-level Maps (_compiledHeaderSourceCache,
_compiledConditionCache) mirror the same approach used for matchConfigPattern
in the previous commit. A private _cachedConditionRegex() helper centralises
the cache lookup for the four checkSingleCondition branches.
Adds four new test cases covering the cache-hit paths for both functions.
* fmt
* perf: lazy-init mutable Headers copy in headersContextFromRequest
In Workerd, new Headers(request.headers) copies the entire header map
across the V8/C++ boundary on every request, showing up as ~815ms self-
time in production profiles for header-heavy requests.
Replace the eager copy with a Proxy that forwards reads directly to the
immutable request.headers. The mutable copy is only materialised on the
first write (.set/.delete/.append), which only occurs when middleware
calls NextResponse.next({ request: { headers } }). Pure read requests
(the vast majority) now pay zero copy cost.
Cookie parsing is also deferred behind a lazy getter: the cookie string
is not split until cookies() or draftMode() is first called during the
request, rather than unconditionally on every request entry.
* perf: hoist MUTATING_METHODS set to module scope
It was being allocated on every request inside headersContextFromRequest.
* fix: preserve x-middleware-request-* headers in dev-mode middleware runner
middleware.ts's runMiddleware() stripped ALL x-middleware-* headers,
including x-middleware-request-* headers that the dev server (index.ts)
needs to unpack into actual request headers. This caused
NextResponse.next({ request: { headers } }) to silently lose header
overrides in dev mode while working correctly in production.
The production inline codegen (pages-server-entry.ts) already preserved
these headers. This aligns the dev runner to match.
* fix: let RSC plugin handle fallback rewrites in App Router dev mode
The middleware.ts fix (preserving x-middleware-request-* headers) caused
index.ts to unpack middleware-injected cookies into req.headers. This
made the connect handler's fallback rewrite conditions match for App
Router paths, dispatching to the Pages Router SSR handler instead of
the RSC plugin — resulting in a 404.
Add the same hasAppDir guard that already exists after the fallback
block (line 2167) to the fallback match path itself.
* add oxfmt formatter: config, scripts, CI, editor setup, docs
* rebuild lockfile
* fix: add Format to required checks list, remove dead ignore pattern
* run fmt
* add format to agents.md again
* fix: resolve NEXT_DATA type conflict with next package types
next/dist/client/index.d.ts declares Window.__NEXT_DATA__ as NEXT_DATA,
which conflicts with our inline type in global.d.ts. The vinext-specific
extensions (__vinext, __pageModule, __appModule) aren't in NEXT_DATA,
causing TS2339 errors.
Extract vinext extensions into a VinextNextData interface and cast at
the two usage sites (client/entry.ts, shims/link.tsx) instead of
re-declaring __NEXT_DATA__ with a conflicting type.
* feat(next-intl): auto-detect next-intl config without createNextIntlPlugin
next-intl's createNextIntlPlugin() crashes in vinext because it calls
require('next/package.json') to check the Next.js version, and next
isn't installed. Instead, vinext now detects next-intl in project
dependencies and registers the next-intl/config alias automatically.
Detection probes i18n/request.{ts,tsx,js,jsx} then
src/i18n/request.{ts,tsx,js,jsx}, matching next-intl's own discovery
order. Explicit aliases from webpack/turbopack config take precedence.
Also adds a targeted warning when config loading fails due to
next-intl/plugin, telling users the wrapper is unnecessary.
Closes#202
* fix: address review feedback
- Update check.ts: next-intl status "partial" → "supported"
- Extend VinextNextData from NEXT_DATA in shims/internal/utils.ts
instead of duplicating fields
- Add JSDoc note about monorepo resolution walk-up behavior
* refactor: delete app-dev-server.ts and update all references to entries/ directly
The file was already a pure re-export shim with no logic of its own.
Update every import/dynamic-import site to point straight at the
individual entry modules:
entries/app-rsc-entry.ts ← generateRscEntry, AppRouterConfig
entries/app-ssr-entry.ts ← generateSsrEntry
entries/app-browser-entry.ts ← generateBrowserEntry
Sites updated:
packages/vinext/src/index.ts
tests/entry-templates.test.ts
tests/app-router.test.ts
tests/shims.test.ts
Also update stale comments in:
server/middleware-codegen.ts, server/request-pipeline.ts,
server/instrumentation.ts, shims/metadata.tsx,
tests/rsc-streaming.test.ts, tests/nextjs-compat/rsc-context-lazy-stream.test.ts,
examples/app-router-cloudflare/instrumentation*.ts
* docs: update app-dev-server.ts references in markdown files to entries/
Moves generateSsrEntry (app-ssr-entry.ts) and generateBrowserEntry
(app-browser-entry.ts) out of the 3,215-line app-dev-server.ts into a new
entries/ directory, as described in issue #253.
app-dev-server.ts re-exports both functions so all existing import sites
(including entry-templates.test.ts) continue to work without changes.
All 10 entry-template snapshot tests pass unchanged — generated output is
byte-for-byte identical to before the move.
* fix: pass parent promise to generateMetadata() for ancestor metadata access
Implements Next.js's eager-execution-with-serial-resolution pattern for
generateMetadata(): all calls kick off concurrently but each segment's
parent promise resolves serially (root → leaf), so `await parent` inside
a child's generateMetadata correctly gets the accumulated ancestor metadata.
Fixes#375
* regen snaps
* fix: address review comments — remove dead array, pass searchParams to generateMetadata
- Remove unused layoutParentPromises[] array (dead code pointed out in review)
- Pass searchParams to page's resolveModuleMetadata() — the URL query string was
previously always passed as undefined, so generateMetadata() could never read
searchParams from the URL
- Reuse the already-computed spObj for pageProps.searchParams to avoid double iteration
- Add 2 regression tests for searchParams in generateMetadata
* regen snaps
* regen snaps
* fix: pass actual route params to layout generateMetadata() in fallback render
renderHTTPAccessFallbackPage was always passing {} as params to
resolveModuleMetadata(), so layouts with generateMetadata({ params })
would get undefined for all dynamic route segments (e.g. params.slug)
when rendering not-found/forbidden/unauthorized fallback pages.
Fix: use opts?.matchedParams ?? route?.params ?? {} (same expression
already used for the layout component props just below) so that
generateMetadata() in fallback layouts receives the real matched params.
Adds two regression tests to not-found.test.ts: hit an invalid slug that
triggers notFound() and assert the title contains the actual slug value.
Also updates entry-templates snapshots.
* fix: forward params to resolveModuleViewport in renderHTTPAccessFallbackPage
generateViewport({ params }) in a layout wrapping a not-found/error
boundary now receives the actual route params instead of an empty object.
Mirrors the same fix already applied to resolveModuleMetadata.
Adds generateViewport export to the layout-params-notfound fixture and
two regression tests confirming the themeColor includes the real slug.
* perf: parallelise metadata and viewport resolution
* fix: propagate page generateMetadata errors to error boundary
When parallelising metadata resolution, layout errors should be
swallowed (.catch(() => null)) but page-level generateMetadata()
errors must propagate so the caller can route them to the nearest
error.tsx or global-error.tsx boundary.
Fixes the regression introduced by the parallelisation PR where
a throwing generateMetadata() on the page module was silently
swallowed instead of triggering the error boundary.
* test: update snapshots
* fix: log layout generateMetadata/generateViewport errors instead of silently swallowing them
* regen snaps
* refactor: extract shared request handling into config-matchers and request-pipeline
Deduplicate ~250 lines of inline request-handling logic from the generated
App Router RSC entry (app-dev-server.ts) and Pages Router server entry
(index.ts) by importing from the canonical config-matchers.ts and a new
request-pipeline.ts module.
Changes:
- Create server/request-pipeline.ts with shared utilities:
guardProtocolRelativeUrl, stripBasePath, normalizeTrailingSlash,
validateCsrfOrigin, isOriginAllowed, validateImageUrl,
processMiddlewareHeaders
- app-dev-server.ts: replace 14 inline __-prefixed functions with imports
from config-matchers.ts (matchRedirect, matchRewrite, matchHeaders,
isExternalUrl, proxyExternalRequest, sanitizeDestination,
requestContextFromRequest) and request-pipeline.ts (validateCsrfOrigin,
validateImageUrl)
- index.ts: replace inline parseCookies, sanitizeDestinationLocal, and
matchConfigPattern with imports/re-exports from config-matchers.ts;
replace manual RequestContext construction with requestContextFromRequest()
- Update tests to reference imported function names instead of old inline
__-prefixed names
Security fixes:
- matchConfigPattern: eliminates chained .replace() divergence (CodeQL
flagged) in favor of config-matchers single-pass tokenizer
- proxyExternalRequest: uses AbortController+setTimeout (broader runtime
support) instead of AbortSignal.timeout()
- escapeHeaderSource: uses proper config-matchers implementation instead of
simplified chained .replace()
Closes#254
t status
* fix: address bonk review - remove dead applyTo param, dangling comment
* fix: remove dangling comments (parseCookies stub, sanitizeDestination stub)
* fix: remove bare block around __tsRedirect, add multiple trailing slash test
* fix: stricter /api prefix check for trailing slash normalization (matches only /api or /api/), add tests for edge cases
* test: update entry-template snapshots after merging main
---------
Co-authored-by: James <james@eli.cx>
* fix: enforce segment boundary in catch-all pattern matching
`matchConfigPattern('/foobar', '/foo/:path*')` incorrectly matched
because `startsWith('/foo')` doesn't check for a `/` boundary after
the prefix. Add a character check after the prefix to ensure only
exact segment prefixes match.
* test: regression tests for catch-all prefix segment boundary overmatch
---------
Co-authored-by: James <james@eli.cx>
next/font/local variable mode works (className and variable both
supported) — the scanner still said it was broken.
styled-components and @emotion/react work via useServerInsertedHTML —
the scanner still said the hook wasn't implemented.
Updated both entries from partial to supported with accurate details,
and updated corresponding test assertions.
The locale prop correctly affected the rendered <a> href and prefetch
path, but click navigation and the onNavigate callback used the
non-localized resolvedHref. This meant clicking <Link href="/about"
locale="fr"> would SPA-navigate to /about instead of /fr/about.
draftMode() reads the __prerender_bypass cookie, which is
request-specific data. Without calling markDynamicUsage(), a Server
Component branching on draftMode().isEnabled could have its output
cached by ISR — serving draft content to regular users or stale
content to draft users.
headers() and cookies() already call markDynamicUsage(); draftMode()
was the only request-reading API that didn't.
* fix: preserve middleware redirect headers (Set-Cookie) in production
middleware.ts correctly collects non-internal headers (e.g. Set-Cookie)
on redirect responses, but three consumers dropped them:
- prod-server.ts: sent only Location header, ignored responseHeaders
- deploy.ts: worker entry sent only Location header
- index.ts: inline runMiddleware codegen didn't collect headers on redirect
(it already did for next() and rewrite(), just not redirect)
The dev server (index.ts:2934) handled this correctly, making this a
dev/prod parity bug where cookies set during middleware redirects worked
in development but were silently dropped in production.
* test: update entry template snapshot for redirect header fix
---------
Co-authored-by: James <james@eli.cx>
`__validateDevRequestOrigin()` is a dev-only cross-origin protection
that was running unconditionally in the generated App Router RSC entry.
When deployed to Cloudflare Workers, it blocked legitimate cross-origin
requests with 403 Forbidden.
Wrap the call site with `process.env.NODE_ENV !== "production"` — the
same pattern already used nearby. Vite statically replaces NODE_ENV at
build time, making the block dead code that gets tree-shaken away.
Closes#361
* fix: fail build when cloudflare() plugin is missing
Upgrade the deploy-time warning to a hard error and add a build-time
check in configResolved that detects a wrangler config without the
cloudflare() plugin registered.
Previously, forgetting to add cloudflare() to vite.config.ts produced
a cryptic "could not resolve virtual:vinext-rsc-entry" error deep in
wrangler esbuild. Now the build fails early with an actionable message
showing exactly what to add.
The validation only fires during builds (not dev) and is skipped when
Nitro is the deployment target.
Closes#325
* refactor: extract shared utilities for cloudflare plugin guard
Address code review feedback on PR #358:
- Extract `hasWranglerConfig(root)` utility from `detectProject()` inline
check, reuse in both `deploy.ts` and `index.ts:configResolved`
- Extract `formatMissingCloudflarePluginError({ isAppRouter, configFile? })`
shared error formatter used by both build-time and deploy-time guards
- Fix hardcoded `vite.config.ts` references: build-time guard now uses
`config.configFile`, deploy-time guard uses generic "your Vite config"
- Add 10 unit tests covering both new utilities
* Update packages/vinext/src/deploy.ts
Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>
---------
Co-authored-by: James Anderson <james@eli.cx>
Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>
* test: add snapshot tests for entry template generators
Add 7 snapshot tests covering all 5 virtual entry module generators:
App Router (imported directly — already exported standalone):
- generateRscEntry: minimal routes, with middleware, with config
- generateSsrEntry
- generateBrowserEntry
Pages Router (tested via pluginContainer.load on virtual modules):
- virtual:vinext-server-entry
- virtual:vinext-client-entry
These snapshots lock down the generated code so subsequent extraction
refactors (moving generators into entries/ modules) can be verified
against a known baseline.
Ref #253
* test: update entry template snapshots after merging main
* test: address bonk review comments on entry-template snapshot tests
- Add comment on PAGES_FIXTURE_DIR warning about snapshot coupling
- Add generateRscEntry snapshot test covering instrumentationPath arg
* test: address new bonk review comments on entry-template snapshot tests
- Add dynamic route fixture (/blog/:slug) to minimalAppRoutes to cover
dynamic segment serialization in the generated route table
- Add generateRscEntry snapshot test covering globalErrorPath arg
* test: address final bonk review comments
- Add allowedOrigins and allowedDevOrigins to the 'with config' test
case to cover CSRF validation and dev origin checking code paths
- Replace as any cast with proper type guard for pluginContainer.load()
result
* test: update snapshots after merge with main
Merge upstream/main which includes f1e98b9 (don't clear RSC context
before lazy stream is consumed). Update 5 snapshots to reflect the
replaced setHeadersContext/setNavigationContext(null) calls with
comments explaining lazy stream context lifecycle.
* test: add metadata routes test, enrich route fixture with loading/error/template/notFound
* test: fix snapshot portability - apply stabilize() to all App Router tests, update snapshots after merge
---------
Co-authored-by: James <james@eli.cx>
@mdx-js/rollup strips the query before matching the file extension,
so it would compile 'foo.mdx?raw' as MDX and return compiled JSX
instead of the raw file text. The proxy now returns early when the
id contains '?', matching Vite's own convention for query imports.
Adds two regression tests: one unit test that reproduces the bug
inline (guard vs no-guard with a mock delegate), and one that
asserts the real vinext:mdx plugin returns undefined for query-param ids.
* feat(og): add @next/og support with Cloudflare Workers compatibility
- Add vinext:og-font-fix transform plugin that patches @vercel/og/dist/index.edge.js
at Vite transform time: inlines the fallback font as base64 (avoids fetch() with
import.meta.url which breaks in workerd) and defers WASM init to first use (avoids
WebAssembly.instantiate() being blocked in Node.js module runner)
- Add @vercel/og to optimizeDeps.exclude so Vite's esbuild pre-bundler doesn't cache
the module before the transform hook runs
- Add vinext:og-assets build plugin that copies font/WASM assets to RSC output dir
- Add /app/api/og route to app-router-cloudflare example and app-basic fixture
- Add shared Playwright e2e spec (tests/e2e/og-image.spec.ts) asserting PNG validity,
correct 1200x630 dimensions, param-driven content, and determinism
- Register og-image.spec.ts in app-router, cloudflare-workers, and cloudflare-dev
Playwright projects (18 tests total, all passing)
* refactor(og): generalize fetch+import.meta.url asset inlining, drop WASM lazy-init patch
Replace the @vercel/og-specific vinext:og-font-fix plugin with a general
vinext:og-inline-fetch-assets plugin that:
- Matches ANY fetch(new URL('./asset', import.meta.url)).then(res => res.arrayBuffer())
pattern, not just the hardcoded noto-sans filename
- Resolves each asset relative to the module's on-disk path (via the transform id)
and inlines it as base64 — works for any font version or additional assets
- Applies to any library using this pattern, not just @vercel/og
Also removes the WASM lazy-init patch: now that @vercel/og is in optimizeDeps.exclude,
the module runs through workerd (not the Node.js pre-bundler cache) where
WebAssembly.instantiate() works fine. The lazy-init workaround was only needed
when the pre-bundler cache bypassed the transform hook.
The og-assets build plugin is updated to only copy resvg.wasm (not the font,
which is now inlined and will no longer appear in the bundle by filename).
* fix(og): inline readFileSync assets from @vercel/og node build
The production RSC bundle resolves @vercel/og under the 'import' export
condition, getting dist/index.node.js instead of dist/index.edge.js.
The node build loads font and WASM via fs.readFileSync(fileURLToPath(
new URL('./file', import.meta.url))), which breaks after bundling because
import.meta.url points to the bundle output file, not the dist/ dir.
Extend vinext:og-inline-fetch-assets with a second regex pattern that
matches readFileSync(fileURLToPath(new URL(...))) and replaces it with
Buffer.from('<base64>', 'base64'), inlining both noto-sans font and
resvg.wasm at transform time. This eliminates the ENOENT errors seen
in the production build CI tests.
* refactor(og): address code review feedback
- Remove Fix 1 (font inlining) from vinext:og-font-patch — it was dead
code since vinext:og-inline-fetch-assets (earlier in the plugin array)
already handles the same fetch(new URL(...)) pattern generically. Only
Fix 2 (yoga WASM extraction) remains in og-font-patch.
- Use split().join() instead of String.replace() in both Pattern 1 and
Pattern 2 loops of og-inline-fetch-assets. String.replace(string, ...)
only replaces the first occurrence; split().join() replaces all of them,
which matters for libraries that may use the same pattern multiple times.
* refactor(og): use replaceAll instead of split().join()
* feat: support experimental.serverActions.bodySizeLimit from next.config
* fix: address PR review feedback for parseBodySizeLimit
- Accept bare number strings (e.g. "1048576") as bytes, matching Next.js bytes library behavior
- Add tb and pb unit support
- Throw on zero/negative values to match Next.js validation behavior
- Remove unreachable type guard (value is always string at that point)
- Update tests: add tb/pb/bare-number/validation cases, fix "10tb" assertion
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: warn on invalid bodySizeLimit string instead of silently falling back
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Projects that lack "type":"module" in package.json caused Vite to bundle
vite.config.ts through esbuild's CJS path, producing a .vite-temp/*.mjs file
that contains a CJS require() for @cloudflare/vite-plugin. On Node 22 in CI
(GitHub Actions ubuntu-latest), require()-ing an .mjs file is not supported
and the build fails with:
Error: Dynamic require of "…/@cloudflare/vite-plugin/dist/index.mjs" is
not supported
Fix: call ensureESModule(root) + renameCJSConfigs(root) in both the dev() and
buildApp() CLI commands, before Vite is loaded. This mirrors what vinext init
already does, but applies lazily for projects that were set up manually or
before the init step was introduced.
- renameCJSConfigs renames postcss.config.js / tailwind.config.js / .eslintrc.js
etc. to .cjs first, to avoid breakage in those files after ESM mode is enabled
- ensureESModule adds "type":"module" to package.json
- Both are no-ops when the project is already correctly configured
- A warning is emitted when a change is made, pointing users to vinext init
- 9 new unit tests cover the full scenario including multi-file rename
Fixes#184
When a server component, client SSR, or generateMetadata() throws with no
local error.tsx boundary, global-error.tsx renders as the last resort. It
provides its own <html> and <body> (replacing the root layout), matching
Next.js behavior.
Previously, global-error was wrapped inside the root layout in both paths:
1. In-tree ErrorBoundary in buildPageElement — the root layout's <html>
was already serialized in the RSC stream before the ErrorBoundary
activated during SSR, producing double <html>/<body> tags.
2. renderErrorBoundaryPage — explicitly wrapped the error component with
layouts regardless of whether it was a local error.tsx or global-error.
Fix:
- Remove global ErrorBoundary from buildPageElement (it can't work for
SSR because the RSC stream already contains the layout output).
- Add it conditionally for RSC requests only (client-side navigation
needs it for error recovery after hydration).
- In renderErrorBoundaryPage, track whether the resolved component is
global-error and skip layout wrapping when it is.
Strengthen test assertions to match Next.js behavior:
- Assert HTTP 200 (error boundary "handles" the error)
- Assert global-error.tsx content renders (data-testid, error message)
- Assert exactly one <html> and one <body> tag per response
* fix: nav context hydration mismatch
* fix: address bonk review — add __VINEXT_RSC_NAV__ type, serialise searchParams as entry pairs
- Add __VINEXT_RSC_NAV__ global type declaration to global.d.ts with
searchParams typed as [string, string][] (array of entry pairs)
- Change generateSsrEntry() to serialise searchParams via
[...navContext.searchParams.entries()] instead of Object.fromEntries(),
preserving duplicate keys (e.g. ?tag=a&tag=b)
- Update tests to assert the new array-of-pairs shape; new URLSearchParams()
on the client correctly handles both single and duplicate keys
* fix(app-router): don't clear RSC context before lazy stream is consumed
* fix(app-router): remove premature context clears from server action and intercepting route RSC branches
Same bug as fixed for renderHTTPAccessFallbackPage/renderErrorBoundaryPage: context
was cleared immediately after renderToReadableStream() returned, before the stream
was lazily consumed by the client. Async server components running during stream
consumption would see null context and fail.
Two additional instances fixed:
- Server action RSC re-render branch (after action.apply())
- Intercepting route RSC response branch
The getAndClearPendingCookies() / getDraftModeCookieHeader() calls in the server action
branch are intentionally preserved — they read synchronously from context set during
action execution (not during stream rendering) and must run before the response is sent.
Context cleanup is handled naturally by the AsyncLocalStorage scope from
runWithHeadersContext() unwinding after all async continuations complete.
Vinext now automatically detects installed packages with broken ESM
resolution and adds them to resolve.noExternal for both the rsc and
ssr environments. This fixes 'Cannot find module' errors when using
packages like validator that have extensionless internal ESM imports.
Root cause: Vite externalizes dependencies for SSR by default, but
some packages use extensionless imports (e.g. ./util/assertString)
that fail under Node.js strict ESM resolution. Additionally, Vite 7's
Environment API means ssr.noExternal only affects the ssr environment,
not the rsc environment where server components run.
Changes:
- Add KNOWN_PROBLEMATIC_ESM_PACKAGES list (validator, date-fns)
- Add collectNoExternalPackages() to merge user config with auto-detected packages
- Propagate noExternal to both rsc and ssr environments
- Add validator ecosystem test fixture and integration tests
- Add troubleshooting documentation
Fixes#189
* fix: pass NextFetchEvent as second argument to middleware
All 3 middleware invocation sites were calling `middlewareFn(nextRequest)`
with only one argument. The `NextMiddleware` type expects `(request, event)`
but `NextFetchEvent` was never instantiated or passed. Any middleware using
`event.waitUntil()` would throw at runtime because `event` is `undefined`.
Fix: instantiate `NextFetchEvent({ page })` at each call site, pass it as
the second argument, and drain `_waitUntilPromises` via
`Promise.allSettled()` (fire-and-forget) after middleware returns.
Call sites fixed:
- `packages/vinext/src/server/middleware.ts` (shared runner, used by prod)
- `packages/vinext/src/index.ts` (Pages Router dev codegen)
- `packages/vinext/src/server/app-dev-server.ts` (App Router dev codegen)
* address review: add drainWaitUntil(), wire ctx.waitUntil() in Workers entry, fix async test
* fix deploy tests: update assertions for ctx-aware fetch and runMiddleware signatures
* address review: fix misleading comment, update JSDoc, use queueMicrotask in test
* pass undefined explicitly to runMiddleware in prod-server (no ctx on Node.js)
---------
Co-authored-by: James <james@eli.cx>
The previous depth-based approach sliced the pathname by a numeric URL
depth, which broke when route groups were present — they don't appear
in URLs but Next.js includes them in the segments array. This also
meant dynamic params were returned as raw URL strings rather than
resolved values matching the route tree.
Replace `layoutSegmentDepths: number[]` with two new fields:
- `routeSegments: string[]` — filesystem segments from app/ root
- `layoutTreePositions: number[]` — directory depth per layout
Each layout now receives pre-computed child segments through React
context (including route groups, with dynamic params resolved to
actual values and catch-all segments joined with "/").
Additional fixes found during code review:
- Pass matched params to error/fallback rendering paths so dynamic
segments resolve correctly during error page rendering
- Skip optional catch-all segments when param is empty/undefined
- Extend routeSegments for parallel slot sub-routes
- Update stale type declaration in next-shims.d.ts
Ported from Next.js: test/e2e/app-dir/hooks/hooks.test.ts
https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/hooks/hooks.test.ts
* fix: normalize same-origin absolute URLs for client-side navigation
When <Link href="https://example.com/path"> is used and example.com
matches the current origin, vinext now does SPA navigation instead
of a full page reload. This matches Next.js behavior.
Adds toSameOriginPath() utility that extracts the local path from a
same-origin absolute URL. Applied consistently across:
- link.tsx handleClick() and prefetch paths
- navigation.ts navigateImpl() (App Router)
- router.ts push()/replace() (Pages Router hook + singleton)
Closes#335
* fix: handle protocol-relative URLs in toSameOriginPath()
Use window.location.origin as base for `//` URLs so they resolve
correctly for same-origin detection, while keeping `new URL(url)`
(no base) for http/https URLs to preserve existing behavior.
Addresses Copilot review comment on PR #336.
* Update tests/link.test.ts
Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>
---------
Co-authored-by: James Anderson <james@eli.cx>
Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>