mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
codex/cacheability-platform-io
1925 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
456ddbef11 |
fix(actions): run redirect targets through full request pipeline (#2785)
* fix(actions): run middleware for server action redirect targets
A server action that throws `redirect()` renders the target page inline
and returns its Flight payload with the action response, instead of
making the client re-request the target. Middleware had only run for the
action's own path, so the target's middleware never saw the request: an
action reachable on a public path could redirect to a middleware-gated
page and return that page's server-rendered payload, which the browser
client decodes and commits. Next.js has no such hole because the client
re-requests the target through the full pipeline.
Run the target's middleware against the synthetic GET before rendering
it, after the redirect render's headers context is installed so
`NextResponse.next({ request: { headers } })` overrides reach the page.
Middleware response headers merge into the action response.
Only a clean pass-through is rendered inline. A block, redirect, rewrite,
or status override diverts to the header-only 303 that already exists for
non-App-route targets, which the client re-requests through the full
request pipeline. Apps without middleware, and targets no matcher
matches, are unaffected.
* fix(actions): match redirect targets with request route identity
Follow-up to the target-middleware fix, addressing three ways the target
could still be evaluated as something other than the request the client
would have made.
`matchRoute` decodes pathname segments, so an encoded alias like
`/adm%69n` resolved to the `/admin` page while middleware and a real
navigation both saw `/adm%69n` — inline-rendering a route neither
reached. Match redirect targets with request route identity instead.
`cloneActionRedirectHeaders` carried `x-vinext-mw-ctx` onto the target's
request. In hybrid app+pages dev that header holds the Pages handler's
middleware result for the *action* path, which
`applyForwardedMiddlewareContext` replays in place of executing
middleware for the target. Strip it, which also keeps the internal header
out of the redirect render's `headers()`.
Middleware object matchers support `has`/`missing` header predicates, so
a matcher gated on `Accept` took a different branch against the render
request, which drops `Accept` with the action transport headers. Give
middleware a request that keeps it.
* docs(actions): note the redirect-target middleware divert trade-off
* fix(actions): run target middleware before importing redirect-target modules
A middleware-blocked redirect target still executed its route modules'
top-level code: the target was hydrated (dynamically imported) to decide
renderability before the middleware probe ran, so an unauthorized action
redirect could trigger module side effects, or turn a module-eval throw
into a 500 instead of the header-only fallback. Renderability is now
decided from the manifest's lazy thunks (__loadPage/__loadRouteHandler),
and hydration happens only after middleware passes the target through.
The probe leaked two more behaviors a real navigation would not produce:
- Pass-through middleware response headers merged verbatim onto the 303
action wrapper, so a middleware-set Location gave the wrapper genuine
HTTP-redirect semantics and fetch followed it before the action client
could read x-action-redirect. Middleware header merges onto the wrapper
now strip Location.
- The synthetic target requests were built with bare new Request(), which
drops the Workers cf property, so target middleware keying off
request.cf (geo checks) failed open. Both requests now re-attach cf via
the metadata helper the request clone utilities already used.
* fix(actions): carry middleware cookie mutations onto the redirect target
When action-path middleware rotated or deleted an authentication cookie,
the synthetic redirect-target request was still built from the original
inbound Cookie header, so target middleware evaluated a credential the
response was simultaneously revoking and could render the protected page
inline. The middleware's Set-Cookie mutations now feed the same
request-cookie rebuild as the action's own cookies().set() calls, in
browser order (middleware first, action wins for the same name).
Also aligns two more target-request details with the real pipeline:
- The internal _rsc transport param is stripped from the redirect target
before matching, middleware, and render, as app-rsc-handler does for
navigations, so matchers cannot branch on a query no navigation
carries. The client-facing x-action-redirect header keeps the verbatim
URL; a diverted re-request still goes through real validation.
- The middleware header merge onto the action wrapper now restores all
protocol headers (Content-Type, x-action-redirect and friends) rather
than only stripping Location, so a pass-through middleware can neither
replace the wrapper's destination nor flip the client into treating
the Flight body as non-RSC.
* fix(actions): scope redirect-target cookies and framing to browser behavior
Applying every pending Set-Cookie to the redirect target's Cookie header
ignored the cookie's Path attribute, so a mutation scoped to another
path (admin=1; Path=/account) reached a target like /admin that a real
browser navigation would never send it to, and target middleware could
authorize on it. Mutations now apply only when their Path, or the RFC
6265 default path derived from the action URL, path-matches the target.
Cookies from cookies().set() and draftMode() always carry Path=/ and are
unaffected.
Also adds Content-Length to the wrapper's protected headers: a
pass-through middleware value would misframe the freshly generated
Flight stream and let adapters truncate or reject the action response.
Drops the fixture node_modules symlink an e2e run left staged; on
Windows checkouts with core.symlinks=false it materializes as a plain
file that defeats the Playwright server's link-creation guard. The
gitignore rule loses its trailing slash so it covers symlinks and stops
these from getting staged again.
* fix(actions): use redirect target CSP nonce
* docs(actions): clarify redirect cookie projection
* fix(actions): forward middleware headers to redirect targets
* fix(actions): preserve redirect response framing
* fix(actions): preserve middleware request overrides
* fix(actions): run redirect targets through full request pipeline
* fix(actions): match redirect target header semantics
* fix(actions): mirror Next cookie forwarding
* fix(actions): preserve forwarded cookie ordering
* fix(actions): avoid stale forwarded cookies
---------
Co-authored-by: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
|
||
|
|
ced0881690 |
fix(middleware): preserve safe origins for double-slash redirects (#2766)
* fix(middleware): preserve safe origins for double-slash redirects Same-origin absolute middleware redirects with a pathname beginning in two slashes were relativized into protocol-relative targets. Browsers could then navigate to the first path segment as an external host instead of remaining on the application origin. Keep open-redirect-shaped paths absolute and retain that invariant while applying trailing-slash policy. Boundary tests cover both HTTP Location and data-request x-nextjs-redirect responses. * fix(router): retain origin for middleware data redirects Pages Router converted same-origin absolute data redirects back to app paths before history navigation. For a double-slash pathname, that exposed a protocol-relative target and made replaceState fail as a cross-origin mutation. Keep the original safe target when origin or basePath removal would expose an authority-shaped app path. The navigation regression test covers history state, the follow-up HTML fetch, and render completion. --------- Co-authored-by: James <james@eli.cx> |
||
|
|
87fdc98c08 |
fix(server): prevent bot user-agent regex backtracking (#2765)
* fix(server): prevent bot user-agent regex backtracking Long non-matching User-Agent values made the default Google crawler suffix branch retry a greedy token scan from every character, causing quadratic request-time CPU work. Anchor that branch at token boundaries so the broad *-Google matching contract remains intact while each input segment is scanned once. Cover known suffix crawlers and the long non-match path at the public Pages detector boundary. * ci: retry flaky client-cache e2e --------- Co-authored-by: James <james@eli.cx> |
||
|
|
a9fe796d44 |
fix(server): evaluate lazy route modules outside the request context (#2740)
* fix(server): evaluate lazy route modules outside the request context Page and route-handler modules moved from eager RSC-entry evaluation to lazy `() => import()` thunks resolved on the first request that matches the route. A dynamic import propagates AsyncLocalStorage into the imported module's top-level evaluation, and `ensureAppRouteModulesLoaded` is awaited inside `runWithRequestContext`, so module-scope `headers()`/`cookies()` bound to whichever request happened to reach the route first. ESM evaluates a module once per isolate and the namespace is cached on the route with `__loaded = true`, so that first visitor's session data was then reused for every later request. Invoke every lazy thunk through a new `runOutsideRequestContext` (`AsyncLocalStorage.exit()`, which workerd implements) so module scope sees no request. This restores the eager-evaluation contract the lazy loader replaced and matches Next.js, which loads components in `base-server` before `app-render` enters the request store. * fix(server): load global-not-found outside the request context `resolveGlobalNotFoundModule` imports the user's `app/global-not-found.tsx` on the first route-miss 404 and caches the promise for the worker's lifetime. That import runs inside the 404-triggering request's context, so module-scope `headers()`/`cookies()` in global-not-found.tsx bound to that first visitor and were then served to everyone after them — the same mechanism as the lazy route-module thunks, on a sibling call path. Route it through `runOutsideRequestContext` too. * fix(server): exit every request-scoped ALS before module evaluation Exiting only the unified request context was not enough. The Cloudflare entry (`app-router-entry.ts:173`) enters the standalone execution-context ALS *outside* it, and prerendering enters the work-unit store *inside* it, so both stayed visible to module scope. Worse, `after()` takes its `getRequestExecutionContext()` fallback precisely when the unified store is absent — so the partial exit enabled that path instead of closing it, letting top-level `after()` attach to the first request's `waitUntil`. `runOutsideRequestScopes` now exits every ALS handed out by `getOrCreateAls`, which is future-proof by construction: new shims are enrolled as they are created, with no enumeration to keep in sync. `workUnitAsyncStorage` registers itself via `registerAlsForScopeExit` because it must stay module-local (Sentry resolves it by specifier), and registering from that side keeps `als-registry` importing nothing but `node:async_hooks` — it stays evaluable in client bundles where that resolves to a constructor-less stub. Reported by Codex on #2740. * fix(server): isolate intercept page and not-found module loads The intercepting-route page and not-found modules were imported inline at their call sites — `resolveAppPageInterceptState` in app-page-request.ts and the generated `probePage` path — so they missed the isolation every other route module gets. They are user modules cached on the intercept for the isolate's lifetime, so module-scope `cookies()`/`headers()` could still capture the first visitor to an intercepted route. Move both onto the shared loader as `loadAppInterceptPage` / `loadAppInterceptNotFound`, beside the `loadAppInterceptLayouts` that already lives there. Both call sites now route through one guarded implementation instead of two hand-rolled copies of the same dedup block. Reported by Codex on #2740. * fix(app-router): publish concurrent intercept module loads --------- Co-authored-by: James <james@eli.cx> |
||
|
|
81db6e314e |
fix(app-router): reject Route Handlers as interception source routes (#2732)
* fix(app-router): reject Route Handlers as interception source routes An RSC request whose path has no App route match is promoted to an interception source route selected by the `x-vinext-interception-context` header. `findIntercept` gates that header against the intercepting route's pattern with descendants allowed, then resolves the claimed pathname through the route trie and returns whichever concrete route it lands on. When that route is a Route Handler, the promoted match reaches the handler dispatch branch, so a crafted header executes a `route.ts` that merely lives under the intercepting route, having only run middleware for the requested target path. Applications that guard route handlers with middleware path checks therefore lose that boundary: a request to an interception-only target returns the protected handler's response. A Route Handler has no page, layouts, or parallel slots, so it can never own or sit inside an interception source tree. Skip it when resolving the concrete descendant source route and fall back to the slot owner, which is the fixed destination Next.js' generated interception rewrite targets. Descendant page routes still resolve concretely so dynamic source params survive. The interception source pathname remains unauthenticated, matching Next.js' `Next-URL` gating. Promoting a descendant *page* route can still render a middleware-guarded page for a target it does not own; closing that requires deciding whether descendant promotion should exist at all, and is left unchanged here. * fix(app-router): keep slot owner params when rejecting a descendant source Falling back from a rejected Route Handler resolved the slot owner's params with an exact pattern match against the claimed source pathname. That match can never succeed on this path: the source was approved by the descendants-allowed gate precisely because it carries extra segments beyond the owner's pattern, so the match returns null and `sourceMatchedParams` ends up empty. `matchInterceptRoute` derives the promoted owner's params solely from that object, so a dynamic owner such as `/[locale]/feed` rendered with no `locale` for a source of `/en/feed/admin`. Take the owner's params from the prefix the source gate already approved when the exact match fails. The same recovery covers a descendant source that resolves to no concrete route at all, which had the identical gap before this branch existed. The legacy manifest shape, which has no declared `sourceMatchPattern`, still requires an exact match so its secondary gate keeps rejecting unrelated sources. * fix(app-router): reject route handler interception owners --------- Co-authored-by: James <james@eli.cx> |
||
|
|
dc054592ea |
fix(pages-router): stream piped API responses with backpressure (#2735)
* fix(pages-router): stream piped API responses with backpressure A handler that pipes into `res` (proxying an upstream, echoing a request body) could queue the entire stream in memory. The response bridge acknowledged every write immediately, so `write()` never returned false and a piped source was never paused, regardless of how slowly the client consumed the body. The Node production server also read API responses with `arrayBuffer()`, which held the full body in memory and deferred delivery until the source closed, so long-lived streams (e.g. proxied SSE) never flushed. Hold the write callback while the response body's queue is full and release it from the stream's pull hook. A held callback makes `write()` return false, which pauses any piped source until the consumer reads. Mark responses that are still being written when the handler settles and send them through the streaming path in the Node production server. Complete bodies (`res.json`, `res.send`, `res.end(data)`) keep the buffered path and its Content-Length behaviour. * fix(pages-router): avoid API stream backpressure deadlocks * fix(pages-router): preserve streamed API handler lifecycle * docs(pages-router): clarify API stream backpressure --------- Co-authored-by: James <james@eli.cx> |
||
|
|
fb9be4bb01 |
fix(router): minimize client rewrite manifests (#2734)
* fix(router): minimize client rewrite data Browser entries currently serialize resolved rewrite objects verbatim even when the client cannot execute a rule locally. Project rewrites into a client-specific discriminated shape, retaining internal destinations that support SPA routing while handing external and missing-condition rules to the server. Keep the full matcher lazy on Pages and cover generated manifests and both client navigation ownership paths. * fix(router): keep server-only rewrite inputs private Cookie and header rewrite conditions can depend on values that browser JavaScript cannot observe. Evaluating them client-side can both publish those values and skip rewrites that the server would match. Project only query and host conditions, fail closed for every other condition type, and use the same projection for the public build manifest so all client outputs preserve the same boundary. --------- Co-authored-by: James <james@eli.cx> |
||
|
|
87a3f37a49 |
fix(build): exclude filtered require.context modules (#2736)
* fix(build): exclude filtered require.context modules require.context regexps previously filtered only the runtime map after a broad eager glob had imported every file. This evaluated and bundled excluded modules, including from client components. Resolve and filter context entries during the transform so only accepted files become static dependencies. Keep context directories watched so create and delete events can update the generated module set. * test: update require-context unit tests for static-import transform * fix(build): harden require.context enumeration and bindings Replace fs.glob (withFileTypes needs Node 22.2, engines allow >=22) with a readdir walk that follows directory symlinks like webpack and guards cycles via realpath, and grow the generated import binding prefix past any identifier already present in the source. * fix(build): scope symlink cycle guard to the recursion path A global realpath set deduplicated distinct symlink aliases of the same directory; track realpaths only along the current recursion path so aliases keep their own context keys while cycles still terminate. * fix(build): stat directory entries with unknown dirent types Filesystems without dirent type info (NFS, SMB, FUSE) report entries that are neither file nor directory; fall back to stat for any unknown type instead of only symlinks, and skip unresolvable ENOENT/ELOOP entries. * fix(build): make require.context deterministic and dev-invalidation complete Assign import binding indices after sorting so readdir order cannot change bundle bytes; invalidate recursive contexts on any membership event since a directory create/delete can change matching descendants without matching the file regexp; and drop watched-context entries for updated modules so importers that lose their last require.context call stop invalidating. --------- Co-authored-by: James <james@eli.cx> |
||
|
|
b3a82e5024 |
fix(server): transfer request bodies into NextRequest instead of teeing (#2741)
* fix(server): transfer request bodies into NextRequest instead of teeing `Request.clone()` tees the body stream, and a tee branch that is never read buffers every chunk the other branch pulls. The NextRequest constructor cloned any body-bearing input, so wrapping the incoming request left an unread branch holding the whole body. A route handler that streams a 128 MiB upload to storage — O(1) memory by design — retained the full 128 MiB instead, letting an unauthenticated client exhaust the process or Worker isolate by repeating the request. Upstream Next.js does `super(input, init)` here, transferring the body rather than branching it. Match that. Callers that genuinely need two live branches (middleware vs. downstream routing) already clone explicitly, so nothing loses the isolation added in #1132. The clone also defeated the mitigation added in #2026: `executeMiddleware` cancels the middleware body branch in a `finally`, but the constructor's extra tee sat between that cancel and the branch actually accumulating chunks, so the cancel released a branch nobody was filling. With the clone gone the existing cancel reaches the real branch again. Drop the same dead tee from the basePath re-prefix path in `createTrackedAppRouteRequest`, where the source request is replaced outright and its body is never read again. Measured with a 128 MiB streamed body, reading through and discarding: before: arrayBuffers +128.0 MiB (route handler wrap, and middleware that does not touch the body) after: arrayBuffers +0.0 MiB, matching an unwrapped baseline * fix(server): avoid teeing normalized RSC request bodies * fix(middleware): release isolated request body branches --------- Co-authored-by: James <james@eli.cx> |
||
|
|
9f1018cd23 |
fix(middleware): preserve headers for empty override value (#2767)
* fix(middleware): preserve headers for empty override value * fix(middleware): ignore stray headers for empty override * fix(middleware): require override list for forwarded headers * test(middleware): cover stray request header value * test(middleware): pair valid and stray header overrides * fix(middleware): preserve unlisted forwarded headers * fix(middleware): preserve literal forwarded headers |
||
|
|
350865c466 |
fix(create-vinext-app): generate next env on first run (#2768)
* fix(create-vinext-app): generate next env on first run * fix(types): keep next env renderer internal |
||
|
|
1607104749 |
fix(middleware): stop restoring credentials middleware deleted before external rewrites (#2739)
`x-middleware-override-headers` carries the complete post-middleware header set, not a diff: `NextResponse.next`/`rewrite` encode every key of the Headers object middleware passes, and Next.js deletes any request header missing from that list. Absence means deleted. `preserveCredentialHeaders` (#1121) read a short override list as a "partial" override and copied the base request's `cookie`/`authorization` back in. The documented deletion pattern — clone `request.headers`, delete the credential, return `NextResponse.rewrite(externalUrl, { request: { headers } })` — produces exactly such a list, so the option resurrected the stripped credentials. It was enabled only for external rewrites, so `proxyExternalRequest` then forwarded first-party session cookies and bearer tokens to a cross-origin target. The "partial override" the option guarded against cannot occur: `encodeMiddlewareRequestHeaders` is the only producer of the override list and always emits the full key set. Remove the option and restore Next.js-exact deletion semantics. Covered end to end (fixture middleware deleting credentials before an external rewrite), at the proxy boundary, and in the Pages Router pipeline. Co-authored-by: James <james@eli.cx> |
||
|
|
fd35d9d148 |
fix(cache): bypass shared "use cache" entries in draft mode (#2744)
* fix(cache): bypass shared "use cache" entries in draft mode `registerCachedFunction` keys shared entries by function id, build seed and arguments — never by draft-mode state. Since #1489 made `draftMode().isEnabled` readable inside cache scopes, a cached function that branches on it could have a preview request seed unpublished content into an entry later served to public requests, and vice versa. Skip the shared cache lookup and store when the request is in draft mode, alongside the existing dev bypass. This mirrors Next.js, which guards its cache-handler read with `shouldForceRevalidate()` (true when `isDraftMode`) and its write with an explicit `if (!workStore.isDraftMode)`. `unstable_cache` got the same guard in #2319; this closes the remaining public "use cache" path. "use cache: private" is unaffected — it never crosses a request boundary. * test(app-page): stub isDraftModeEnabled in the headers mock The element-builder suite replaces `shims/headers.js` wholesale rather than spreading `importOriginal()`, so the new `isDraftModeEnabled` import in cache-runtime.ts threw a mocker error through the shared "use cache" path. * test(cache): preserve public use cache entry in draft mode * test(cache): prove public draft bypass cache hit --------- Co-authored-by: James <james@eli.cx> |
||
|
|
af7bb923ce |
fix(app-router): preserve page result render ordering (#2760)
* fix(app-router): preserve page result render ordering * fix(app-router): gate page results on resolved layouts * fix(app-router): handle exotic layout components * fix(app-router): republish consumed prefetches promptly * fix(app-router): normalize consumed prefetch snapshots * fix(app-router): match Flight forward ref arguments * fix(app-router): gate exotic page initialization * fix(app-router): restore segment cache stale windows * fix(app-router): separate committed stale windows * fix(app-router): retain cache life through stream completion |
||
|
|
ad991a733f |
fix(pages): refresh next/head tags when regenerating ISR HTML (#2729)
* fix(pages): refresh next/head tags when regenerating ISR HTML ISR regeneration re-renders the page body but reuses the cached shell verbatim, so the <head> stays frozen at whatever the first cache-filling render produced. A page whose title or meta tags derive from getStaticProps data serves an updated body under permanently stale metadata, and the two never reconverge for the lifetime of the entry. next/head tags accumulate as a side effect of rendering into an AsyncLocalStorage scope that closes when the regeneration pass returns, so the refreshed head has to be read inside that pass. The render callback now accepts an onHeadReady hook that runs after the render and before the scope unwinds. The collected tags are located in the cached shell through the data-next-head attribute that getSSRHeadHTML stamps on everything it emits, which keeps the swap working on entries written before this change. _document.getInitialProps runs in the same hook because its head tags share that collector; omitting it would drop them from the regenerated shell. _document-rendered head children and CSS-in-JS styles still come from the cached shell, since regeneration does not re-render _document. * fix(pages): keep the ISR head refresh off degraded _document paths Two problems in the regeneration head swap, both raised in review. The regeneration hook called _document.getInitialProps through the bare callDocumentGetInitialProps helper, whose context deliberately omits req, res, pathname, query and asPath and supplies a no-op renderPage. That is not the contract the initial render uses: enhancePageElement is always wired in the prod handler, so any user override resolves its head through runDocumentRenderPage with the real request context instead. A Document reading ctx.req would therefore throw (swallowed by the helper) or emit fallback tags during regeneration, and the swap would replace a complete cached head with that degraded one. Reproducing the full document pipeline on regeneration is a much larger change, so those apps now keep the cached head, which is the behaviour they had before the refresh existed. Everyone else — no _document, or one that never overrides getInitialProps — is unaffected, and for them the helper was a no-op anyway, so the hook is now just getSSRHeadHTML. Locating the closing head with indexOf also read raw-text content as markup. headChildToHTML escapes </script and </style in inline bodies but not </head>, so a next/head script may legitimately contain that string. The scan then ended mid-element, matched nothing, and silently skipped the refresh — or, with a differently shaped shell, inserted the fresh head while leaving later stale tags in place. Raw-text elements are blanked length-preservingly before the boundary lookup so indices still map onto the original shell. * test(pages): cover ISR head regeneration end to end * fix(pages): ignore head text while locating ISR boundary * test(pages): exercise stale ISR head refresh --------- Co-authored-by: James <james@eli.cx> |
||
|
|
67e8b2bddc |
fix(document): HTML-escape NextScript.getInlineScriptSource output (#2727)
* fix(document): HTML-escape NextScript.getInlineScriptSource output The shim returned raw JSON.stringify(context.__NEXT_DATA__). JSON.stringify does not escape characters the HTML parser treats as significant, so a string value containing "</script>" terminates the inline script it is embedded in and the remainder is parsed as HTML. Next.js returns htmlEscapeJsonString(data) from every path of this method (pages/_document.tsx). A custom _document that calls the helper and inlines the result — the pattern the method exists for — therefore loses escaping it would keep on Next.js, turning page props or query-derived data into script injection. Use safeJsonStringify, the escaper this repo already applies to the framework-generated __NEXT_DATA__ tag in pages-page-response.ts, so both paths produce the same output. The default renderer was unaffected. * test(document): cover complete inline JSON escape set --------- Co-authored-by: James <james@eli.cx> |
||
|
|
9649fb9ec1 |
fix(app-router): render pages before consuming layouts (#2751)
* fix(app-router): render pages before consuming layouts * fix(app-router): preserve page loading semantics * fix(app-router): preserve page initialization and loading * fix(app-router): preserve loading for initialized pages * test(app-router): render Suspense payload children * fix(app-router): wait for initialized pages without loading * fix(app-router): unify page loading detection |
||
|
|
2c434ed893 | test(typegen): retry temporary directory cleanup (#2752) | ||
|
|
9ae681fc28 |
fix(deps): pin vitest and coverage-istanbul to 4.1.10 (#2748)
* fix(deps): pin vitest and coverage-istanbul to 4.1.10 * fix(deps): align lockfile vitest specifier with its override |
||
|
|
8f89a2d1df | fix(build): recognize Vite dist client aliases (#2750) | ||
|
|
052a2f7b78 | fix(server): support Node production entry contracts (#2749) | ||
|
|
25dc2f3f28 |
test(routing): isolate reserved-char param cases from Windows (#2698)
Two Pages Router tests build temp route fixtures whose param names use characters reserved in Windows filenames: `[repo:name].tsx` (`:` opens an NTFS alternate data stream, so pagesRouter never discovers the route) and `[id*].tsx` (`*` makes writeFile throw). Both errored on `vp test run` locally on Windows. Split each test so the valid-everywhere case (`.`, `+`) runs on all platforms and the reserved-char case is gated with `it.runIf(process.platform !== "win32")`. These scenarios are unreachable on Windows in production too, so gating loses no coverage; POSIX CI still exercises them. Related to #1792 |
||
|
|
e845eced7d | test(app-router): isolate * param route-graph case from Windows (#2697) | ||
|
|
a0e5d1be86 |
chore(deps): upgrade vite-plus to 0.2.6 (#2694)
* chore(deps): upgrade vite-plus to 0.2.6
* fix(app-router): suppress benign AbortError from superseded navigations
A fast follow-up navigation aborts the in-flight navigation's RSC fetch
mid-stream. When the aborted stream still holds an un-consumed React Flight
chunk (e.g. streamed metadata the superseded route never rendered), React
reports the resulting AbortError globally as a window `error` event rather
than to a specific consumer, which trips the "no console errors" e2e
assertion (metadata-icons.spec.ts).
Install a page-lifetime window listener at bootstrap that preventDefault()s
these benign navigation AbortErrors, mirroring the existing redirect-error
bridge. Installed once (not in a component effect) so there is no listener
gap while the router tree re-renders mid-navigation.
The vite-plus 0.2.6 toolchain shifted navigation timing enough to expose
this latent race on CI.
* chore: adapt to vite-plus 0.2.6 toolchain
Follow-up to the vite-plus 0.2.6 bump; keeps CI green under the new
oxfmt/oxlint/rolldown toolchain. No published runtime change.
- Formatting: oxfmt 0.60.0 reformats README.md, apps/web/next.config.ts and
tests/nextjs-compat/TRACKING.md (collapses empty-object-with-comment;
unpads markdown tables). Applied `vp check --fix`.
- Lint: oxlint 1.75.0 now flags dynamic `import("node:path")` under the
existing no-restricted-imports rule. Disabled inline in loadStaticPrerender
with a reason -- the resolved path feeds a dynamic import(), so pathslash's
forward-slash canonicalization buys nothing there.
- Test: rolldown code-splits the server build, so the font markers moved out
of index.js into _next/static/* chunks. Scan the whole server output for
the markers instead of index.js alone (verified they still exist).
* Revert "fix(app-router): suppress benign AbortError from superseded navigations"
Drop the runtime AbortError suppressor to keep this PR a pure toolchain bump.
The metadata-icons "rapid icon replacement" failure it addressed is a
pre-existing, timing-dependent race (not caused by the upgrade). Assessing
whether it triggers stably on CI / locally before deciding how and where to
fix it.
This reverts commit 84454d5fd of this branch.
|
||
|
|
795855ee4d |
fix(build): support dynamic package subpath imports (#2746)
* fix(build): support dynamic package subpath imports * fix(build): harden package dynamic import resolution |
||
|
|
73f56ef3f8 |
fix(app-router): honor cacheLife stale on the client router (#2708)
* fix(app-router): honor cacheLife stale on the client router
`cacheLife` profiles carry three independent numbers, and `stale` is the
client-router dimension: how long the browser may reuse cached route output
without asking the server. vinext aggregated it correctly (`resolveCacheLife`
min-reduces all three; the request scope accumulates min-wins) and then
projected it away at every consumer, so the only staleness a browser ever saw
was `dynamicStaleTimeSeconds` from `experimental.staleTimes` — a build-time
constant unrelated to the cached subtrees that produced the render. A subtree
declaring `cacheLife("seconds")` (stale: 30) was held for the full 5-minute
visited-response TTL.
Carry the resolved `stale` to the client on `x-nextjs-stale-time` (matching
Next.js's `NEXT_ROUTER_STALE_TIME_HEADER`) and combine it with the config
value by taking the minimum, so neither min-wins lattice overrides the other.
The 30s prefetch floor applies to the new value too.
Two normalization rules, both deliberate:
- An absent `stale` is never synthesized from `revalidate`/`expire`. The
`default` profile has no `stale` and an `expire` of ~136 years, so deriving
one would license session-long reuse without a refresh.
- The three numbers are not assumed to be ordered — `seconds` is
`{ stale: 30, revalidate: 1, expire: 60 }` — so `revalidate` never
constrains `stale`. Only the hard `expire` ceiling does.
* fix(app-router): source the client stale time from completed renders
The previous commit derived `x-nextjs-stale-time` from `peekRequestCacheLife()`
at response-construction time. That read happens after `probeAppPageBeforeRender`
but before the RSC stream is consumed, and the probe only awaits the page
component's own async result — it does not render the returned tree. Any
`use cache` scope in a child Server Component registers later, during stream
consumption, so the header described the probe rather than the output.
Because `cacheLife` aggregation is minimum-wins, missing one late scope is
enough to make the value wrong, and "it can only shorten" did not hold: with no
`dynamicStaleTimeSeconds` present (the usual case for `use cache` pages, which
are not dynamic renders), a peeked `stale: 300` widened the prefetch window from
`PREFETCH_CACHE_TTL` (30s) to 300s — 10x longer than today, in the direction the
change set out to fix.
Carry the value on the cache entry instead. The ISR write path reads the
request-scoped accumulation via the consuming `getRequestCacheLife()` inside the
cache-write closure, which runs after the captured stream has drained, so it
observes the completed render's minimum. `CacheControlMetadata` gains `stale`,
`isrSet` persists it, and `buildAppPageCachedResponse` re-advertises it on hits.
Prerender seeds carry it through `VINEXT_PRERENDER_CACHE_LIFE_HEADER` and the
prerender manifest so a seeded entry makes the same claim a runtime render would.
Two links that would otherwise silently drop the claim are closed with it: the
`use cache` entry write now persists `stale`, and `recordRequestScopedCacheControl`
re-registers it on a data-cache hit — without both, a page's advertised freshness
would depend on data-cache temperature rather than on what it declared.
Streaming responses now advertise nothing and leave the client on its configured
`experimental.staleTimes`, which is the honest answer for a value that is not yet
known when headers are committed. Covering fresh streaming renders needs a
render-completion signal the streaming RSC path does not have today; Next.js
solves it by streaming an `AsyncIterable<number>` in the RSC payload
(`app-render.tsx` `baseResponse.s`, closed after the render settles), which is
the natural follow-up.
The client-side combination logic is unchanged: both stale signals are still
min-reduced, absent `stale` is never synthesized from `revalidate`/`expire`,
`revalidate` never clamps `stale`, and `expire` still caps it.
* fix(app-router): deliver the resolved client stale time on fresh renders
The previous round persisted the completed render's cacheLife stale onto
the ISR entry and replayed it on cache hits, which left the value missing
or drifting wherever the entry's lifetime diverged from the render's:
- Fresh streaming renders advertised nothing, so the first visitor of any
route (and every dev render, since dev writes no ISR entry) stayed on
the configured staleTimes despite a declared cacheLife. The done-script
emitted by the RSC embed transform's finalize() runs only after the
full RSC stream has drained, so it can carry the completed render's
minimum where streaming headers cannot. Emit the peeked request-scoped
cacheLife there and seed the hydration visited-response entry from it.
This also makes the client's min-combination of the config and
cacheLife signals reachable: a dynamic render with use cache subtrees
now legitimately carries both.
- The expire clamp bounded the value but not the elapsed window: an
entry of { stale: 30, expire: 60 } hit at age 59s replayed a 30s reuse
window reaching 29s past expire. Age the serve-time clamp with the
entry's lastModified so only the remaining window is advertised.
- The two client caches applied different floors: prefetch entries
floored a cacheLife stale at 30s while cold navigations honored it
verbatim, so the same declaration produced two behaviors keyed on
whether a prefetch fired first. Floor the cacheLife signal once in the
shared resolver, mirroring Next.js getStaleTimeMs, before the min so
it can never raise the config-derived bound.
Mechanically, the app-page cache setter's six-position signature
(declared identically in four modules) collapses into one exported
AppPageCacheSetter taking an AppPageCacheWritePolicy object, with
isrSetAppPage adapting to the shared positional isrSet, which stays
unchanged for the Pages Router and route handlers.
* docs(app-router): pin the cold RSC stale contract and expire precedence
The cold RSC fetch gap is a design decision, not a pending follow-up:
Next.js's AsyncIterable stale transport only works under staged rendering
(cacheComponents), where cache scopes settle before the render task queue
drains — in vinext's lazy streaming model the iterable could never close.
Next.js's plain-mode mechanism is a blocking cold render, which #961
deliberately rejected to keep ISR page streams unblocked. Assert the
resulting no-header contract on the streaming-response test.
Also reword the write-policy expire comment: a cacheLife-declared expire
replaces the config expireTime fallback (Next.js precedence), it is not
min-merged with a route-level ceiling — no such ceiling exists outside
cacheLife.
* fix(app-router): carry stale through regen and bound cold responses
Three fixes from review round 3:
Background regeneration dropped the regenerating render's cacheLife stale:
renderAppPageCacheArtifacts returned only { revalidate, expire }, so
resolveRegeneratedAppPageCachePolicy could never receive the stale it was
built to preserve and the first regen silently widened client reuse back
to the configured fallback. The producer now carries it, with a
real-producer regression test the mocked-cacheControl tests could not
provide.
The age-aware expire clamp is removed. Composed with the client's 30s
floor it delivered neither contract (a clamped 1 re-floored to 30), and
cached HTML replayed the unclamped done-script value regardless. Next.js
stores the stale header at generation and replays it verbatim on every
hit; vinext now does the same, keeping expire a serve-side ceiling.
Cold cacheable RSC responses stream before their cacheLife resolves
(#961), which left them on the 300s client fallback — reproducing the
headline bug for the first request of every entry epoch. They now carry
X-Vinext-Stale-Time-Pending, and both client caches bound such responses
at the 30s floor: the unresolved claim, once floored, could never
license less.
* fix(app-router): bound pending-stale responses by the dynamic stale time
The pending marker meant 'capture was attempted', but the client read it
as 'a cacheLife claim exists'. Capture eligibility is decided before the
lazy stream runs, so a late request-API read can make the completed
render dynamic — the finalizer skips the ISR write and no claim ever
exists, yet the marker granted 30 seconds of reuse even under
staleTimes.dynamic: 0.
Pending responses now carry the configured dynamic stale time (including
0) and the client takes the minimum, so an unresolved response never
receives a wider window than the dynamic bound.
* fix(app-router): keep the pending cap independent of staleTimes.dynamic
Pairing the pending marker with the configured dynamic stale time broke
the segment-cache-client-params compat test: dynamic-param routes
prefetch with no minimum TTL, so the paired 0 default made every cold
prefetch entry expire instantly and navigations refetched routes that
Next.js serves entirely from a static prefetch.
A cold stream cannot distinguish a render that will resolve static from
one that turns dynamic mid-stream, and bounding both by staleTimes.dynamic
sacrifices the guaranteed-correct case for the ambiguous one. Pending
responses go back to the 30s floor cap; the late-dynamic exposure
(one epoch-cold response, at most 30s) is documented as the price of
non-blocking cold renders (#961).
* fix: carry the client stale claim through KV, the prerender index, and nested cache hits
- writePrerenderIndex now copies `stale` into vinext-prerender.json so
seedMemoryCacheFromPrerender actually receives it
- KVCacheHandler.set() and buildPrerenderKVPairs persist cacheControl.stale
so warm hits on the Cloudflare KV backend replay the producing render's
claim; validateCacheEntry accepts the field
- a nested use cache HIT pushes its stored lifetime into the enclosing
cache context's lifeConfigs, mirroring the MISS path, so the outer entry
keeps the child's stale claim once the outer goes warm
* perf: trim shipped comment bytes in the stale-time plumbing
The added modules ride in every consumer build environment; the review-grade
rationale lives in the PR body, the code keeps one-line constraints.
* refactor(app-router): model the cached client stale claim as one state
CachedRscResponse carried staleTimePending and staleTimeSeconds as two
independent optionals, so the cached form admitted a state the wire never
produces (pending and resolved at once) and every consumer had to encode
the precedence rule. Replace both with a discriminated serverStaleTime
(pending | resolved), collapsed once at the header-parse boundary.
* refactor(isr): give isrSet a cache-metadata write policy
The generic setter had grown to six positional arguments, the last an
App-Router-only `stale` value, with isrSetAppPage as a policy-object
wrapper that unpacked straight back into it. Take { cacheControl, tags }
instead: routers construct the metadata they actually claim, the wrapper
and its type disappear, and the shared isrCacheControl builder replaces
the cache-control literal duplicated across writers.
* fix(app-router): keep the dynamic bound on captured dynamic renders
The done-script metadata reused the RSC header's rule of dropping the
config-derived dynamic stale time while the speculative ISR capture was
armed. That rule only holds on the header path, which substitutes the
pending marker and its 30s floor; the done script emits the resolved
cacheLife instead, so a production render that turned dynamic shipped the
cacheLife claim as its only bound and let the hydration-seeded entry reuse
dynamic output for its full duration. Dev never took the capture path, so
the two diverged.
Also migrates the pages-basic seed fixture, the last positional isrSet
caller, which typecheck does not cover.
* ci: re-run after unrelated dev-overlay canary flake
* fix(app-router): preserve completed client stale metadata
* fix(app-router): strip completion metadata during HMR
* fix(app-router): stream completion metadata safely
* fix(app-router): validate completed stale metadata
* docs(cache): clarify zero stale client claim
---------
Co-authored-by: James <james@eli.cx>
|
||
|
|
f517288f40 |
fix(server): reject unsupported static asset methods (#2714)
* fix(server): reject unsupported static asset methods * fix(server): route public asset mutations to 405 * chore: retrigger CI * fix(server): preserve static method parity across runtimes * fix(server): discard static probe responses safely * fix(server): sanitize static method responses * fix(server): preserve static method status * fix(server): strip stale static range headers * fix(server): preserve static rewrite ordering * fix(dev): cache public file routes * fix(dev): harden cached public route handling * fix(worker): avoid unnecessary asset probes * fix(server): preserve static asset lookup coverage * fix(build): keep public path encoder internal --------- Co-authored-by: James <james@eli.cx> |
||
|
|
66ce8172f3 |
fix(build): skip unhelpful precompressed variants (#2712)
* fix(build): skip unhelpful precompressed variants * fix(build): require wire-beneficial precompression * fix(server): ignore precompression temp artifacts * fix(server): scope temp exclusion to hashed assets * test(server): keep range fixture wire-beneficial --------- Co-authored-by: James <james@eli.cx> |
||
|
|
bf2d7acce7 |
fix(navigation): reuse router.prefetch payloads during navigation (#2709)
* refactor(link): extract app prefetch policy helpers to internal module link.tsx is a "use client" React module, so its prefetch policy helpers (resolveAutoAppRoutePrefetch, resolveFullAppRoutePrefetch and their supporting trie/route-href logic) could not be reached from navigation.ts, which must stay importable without React. Move them verbatim into shims/internal/app-route-prefetch-policy.ts (same layering as internal/app-route-detection.ts) and name the previously inline policy shape AppRoutePrefetchPolicy. link.tsx re-exports canAutoPrefetchFullAppRoute and resolveAutoAppRoutePrefetch, keeping its public test surface unchanged. No behaviour change. * fix(navigation): reuse router.prefetch payloads during navigation router.prefetch() stored every response as a learning-only cache entry (cacheForNavigation: false), so a router.prefetch(x) followed by router.push(x) always issued a second RSC request; even an in-flight programmatic prefetch was invisible to navigation. Next.js reuses router.prefetch() results (PrefetchKind.AUTO by default, kind: "full" caches the full payload), and <Link> prefetches in vinext already produce reusable entries through the shared route-policy helpers. Resolve the same policy in router.prefetch(): kind: "full" maps to the full-prefetch policy, anything else to the auto policy for the target route. Reusable prefetches send the same segment-prefetch headers as <Link>, gate re-issue on a freshness-aware navigation cache probe (discarding a stale learning-only twin first), and store the payload with navigation TTLs plus ahead-of-navigation snapshot preparation. Routes the auto policy declines (no manifest match, loading shell, search params) keep the previous learning-only fetch, preserving optimistic-route-template learning and matching Next.js AUTO semantics for dynamic routes. In-flight sharing needs no extra machinery: consumePrefetchResponseForNavigation already awaits a pending entry once the entry is no longer learning-only. Fixes #2707 * test(e2e): cover router.prefetch reuse across an intercepted navigation Covers issue #2707 end to end on the /top -> /film/[imdbId] interception fixture: a programmatic router.prefetch followed by a Link click must produce exactly one /film RSC request while still rendering the intercepted film panel. A companion test without the prefetch pins the request counter so the reuse assertion cannot pass vacuously. * fix(navigation): resolve rewrites and alias matches in router.prefetch reuse Address review findings on the router.prefetch reuse path: - A configured client rewrite can map the prefetched href onto a different App route. The policy previously described the source pattern, so a destination with a loading boundary or a requires-dynamic flag was incorrectly marked reusable. Load the rewrite-aware hybrid module first and resolve the policy (and the freshness-gate lookup) against the rewritten destination, mirroring Link's prefetchPolicyHref. - When the freshness gate matched a reusable entry under a normalized _rsc variant or rendered-path alias, onInvalidate was attached via an exact-key lookup that silently missed, so the documented callback never fired on expiry. hasPrefetchCacheEntryForNavigation now attaches the callback to whichever entry it matched. - Import the prefetch policy module dynamically inside the prefetch closure so its route-trie dependencies stay off the startup path of every next/navigation consumer (AGENTS.md performance guidance). * fix(navigation): keep router.prefetch lifecycle correct across navigation Two lifecycle gaps in the router.prefetch navigation-reuse path: `router.prefetch()` awaits hybrid-route loading, the lazily imported prefetch policy module, and RSC URL generation before it registers anything in the prefetch cache. A navigation starting in the same task finds nothing to share, issues its own request, and the late prefetch then starts a second one. Track a navigation epoch alongside the existing link-navigation-start signal and bail out of prefetch setup when a navigation won the race, mirroring link.tsx's guard. Consuming a prefetched response deleted the entry with notify=false, which discarded `onInvalidate` without ever calling it -- so `prefetch(href, { onInvalidate })` followed by `push(href)` silently lost the subscription. Retain the callbacks past consumption so they still fire once when the original stale window elapses or the prefetch cache is invalidated, matching Next.js's prefetch-task contract. An entry that aged out before navigation reached it now notifies instead of dropping the callback. * refactor(navigation): de-duplicate app prefetch policy and snapshot prep No behavior change. `<Link>` and `router.prefetch()` carried byte-identical `prepareSnapshot` closures; extract `prepareNavigationPrefetchSnapshot` and share it. `resolveAutoAppRoutePrefetch` repeated the same five-field "no prefetch" literal at four early returns; collapse them to a single NO_APP_ROUTE_PREFETCH constant. Every call site reads the resolved policy without mutating or spreading it, so one shared object is safe. Fold `resolveMatchedAutoAppRoutePrefetch` and its post-hoc search-param override into one object literal: `prefetchShellFirst` becomes `hasSearchParams || !isDynamic`, matching the previous unconditional `true` on the search branch and `!isDynamic` otherwise. `canAutoPrefetchFullAppRoute` re-ran the window / manifest / href / trie-match checks before delegating; each of those failures already resolves to `cacheForNavigation: false`, so drop them. The policy module stays behind a dynamic import inside the prefetch closure, keeping its route-trie dependencies off the next/navigation startup path. * fix(navigation): notify superseded prefetches and void setup on invalidation Two more lifecycle gaps in the router.prefetch reuse path. Upgrading a learning-only prefetch to a navigation-reusable one (`router.prefetch(href)` then `router.prefetch(href, { kind: "full" })`, or the equivalent <Link> transition) discarded the old entry with notifications suppressed, so `onInvalidate` callbacks registered by the first call could never run. A superseded prefetch is dirty in Next.js terms, so notify instead of dropping. Fixed in the shared discard helper rather than at the router call site: link.tsx reaches the same path on the same upgrade. Entries are now collected before deletion so a subscriber that seeds a new prefetch cannot be visited by the loop that is still iterating the cache. The prefetch setup epoch advanced only on navigation start, but `router.refresh()` clears the caches via `clearNavigationCaches` -> `invalidatePrefetchCache` without starting a navigation. A closure still awaiting its policy import would resume afterwards and repopulate a navigation-reusable entry derived from the pre-refresh generation, undoing the refresh for that route. Advance the epoch on invalidation too, and move it next to the cache it guards. * refactor(navigation): reuse existing prefetch helpers and drop duplicated branches No behavior change. router.prefetch generated the RSC URL and its rewrite variant with two sequential awaits over the same headers; run them together. It also set the mounted-slots header by hand when createAppPrefetchRequestHeaders already accepts `mountedSlotsHeader`. The option sets the header for any non-null value while the call site skipped empty strings, so pass `|| null` to keep an empty slot header off the wire. link.tsx built a `full-after-shell` policy literal byte-identical to resolveFullAppRoutePrefetch(), which the policy extraction had already made shared and which the file already imports; both arms of the ternary now resolve the same way, so the branch collapses. hasPrefetchCacheEntryForNavigation had two byte-identical if bodies and a local closure that existed only to avoid duplicating their shared tail. Merge the conditions, and hoist the callback attachment into attachPrefetchInvalidationToEntry so the key-taking attachPrefetchInvalidationCallback delegates to it instead of repeating the "seeded entries need their invalidation timer scheduled" invariant. * fix(navigation): promote a queued prefetch when navigation consumes it Low-priority prefetches share a 4-slot queue whose runners do not start until a previous response body is read. A navigation that reuses an in-flight prefetch awaits that prefetch's promise, so when the request is still queued the navigation waits on unrelated response bodies before its own request starts — indefinitely if one of those streams stalls. Promote on consume: `consumePrefetchResponseForNavigation` now starts a queued request instead of waiting for a slot. A promoted request is no longer a prefetch, it is the navigation, so it bypasses the concurrency cap. Reaching the queued runner needs a handle the queue did not expose, so it keeps a WeakMap from the returned promise to its runner, and the cache entry records the schedulable promise (`entry.pending` is the derived chain, not the promise the queue knows). Pre-existing via <Link>: link.tsx passes cacheForNavigation through and prefetchRscResponse defaults it to true, so low-priority viewport prefetches already produced reusable entries that navigation awaited. Fixed in the shared queue rather than the router path so both benefit. Also drops prefetch cache-entry field assertions that restated the implementation where the same test already asserted the behavior those fields produce (consume returning a payload, or null). * test(navigation): cover router.prefetch then router.push at the browser boundary The issue reports router.prefetch(x) followed by router.push(x). The existing e2e covered router.prefetch(x) followed by a <Link> click, which proves cache interoperability but not the reported API sequence — a regression confined to _appRouter.push or its navigation-start handling would pass it. Adds the literal reproduction, plus the same-task variant where push() starts before prefetch setup has registered anything. Both assert the contract (destination renders, route fetched once) rather than the mechanism, so either cancelling the late prefetch or having navigation adopt an already-started one satisfies them. Removes the unit test that claimed to cover the same-task path. It asserted fetch was never called after prefetch() + push(), but push() issues no request in the unit environment, so it only ever proved the prefetch did not fire — never the one-request contract. The browser test replaces it and checks the actual invariant. * fix(navigation): scope prefetch-setup cancellation to the destination router.prefetch() guarded its asynchronous setup with a global generation counter that any navigation advanced. A navigation elsewhere therefore abandoned an unrelated programmatic prefetch: nothing was going to fetch that route, so the prefetch became timing-dependent and a later navigation to it had to fetch again. Replace the counter with a registry of pending setup tokens keyed by destination. A navigation cancels only the setups for the href it is about to fetch itself; invalidatePrefetchCache() still cancels every pending setup, which is the one case where all in-flight setup really is stale. Cancellation stays sticky per token, so a navigation to /a followed by one to /b leaves a pending /a prefetch cancelled — the property a "current destination" comparison would lose. Also check the caller's shouldConsume predicate before promoting a queued prefetch past the concurrency cap, so a navigation superseded while its cache lookup was being prepared cannot start a low-priority request that competes with the current navigation. * fix(navigation): do not cancel prefetch setup on shallow history updates The patched history.pushState/replaceState reused the navigation-start hook to clear a stale useLinkStatus() pending state. Once that hook began cancelling prefetch setup by destination, those two call sites started dropping a pending prefetch for the URL they moved to — but a raw history update only changes browser state and issues no RSC request, so nothing takes the prefetch's place. Split the hook: resetStaleLinkStatus() does the link-status half, and notifyAppNavigationStart() adds destination cancellation for the callers that really do fetch. The history patches take the former; popstate keeps the latter, since the App Router's popstate handler drives an RSC fetch. Covered by an e2e case rather than a unit test — the unit suite stubs window.history, so the shim's patch is never the function under test there. * fix(navigation): cancel superseded prefetch requests Normalize pending setup keys without URL fragments so same-resource navigations cancel late prefetch setup. Give queued and active prefetch requests abort controls, allowing learning-only entries superseded by a full prefetch to stop instead of downloading a duplicate payload. Avoid loading the rewrite-aware ownership chunk when client rewrites are disabled. * fix(navigation): capture prefetch context at call time Match Next.js by snapshotting interception, mounted-slot, nextUrl, and router-tree request context before asynchronous prefetch setup. This prevents same-task history changes from re-keying an explicit prefetch as though it originated from the destination route. * fix(navigation): keep prefetch setup across same-document hash changes Stripping the fragment from pending-prefetch destinations made hash-only navigations compare equal to the route they scroll within, so pushing /film/x#cast while already on /film/x cancelled a pending /film/x prefetch. That navigation issues no RSC request — the planner classifies it as a same-document scroll — so nothing replaced the prefetch it dropped. Skip cancellation for same-document hash changes. The fragment is still stripped when comparing destinations, which is what makes a real navigation to /film/x#cast supersede a pending /film/x prefetch; the two rules are complementary, not competing. popstate gets its own entry point: the browser has already applied the history entry by then, so the pre-navigation URL that isHashOnlyBrowserUrlChange needs is gone. It keeps cancelling by destination, which over-cancels a hash-only back/forward at the cost of one re-prefetch and never a duplicate request. Also fixes the eslint(no-unsafe-optional-chaining) failure in the queue test that turned CI's `vp run check` red. * test(navigation): keep queued prefetch assertions type-safe * fix(navigation): preserve prefetch identity through streaming Resolve relative prefetch ownership and policy from the call-time URL, matching the already captured request target. Keep abort controls alive until response bodies are consumed so superseded streaming prefetches release bandwidth and queue slots. --------- Co-authored-by: James <james@eli.cx> |
||
|
|
3d1ab6bd50 |
fix(server): honor static freshness validators (#2715)
* fix(server): honor static freshness validators * fix(server): preserve content type on cached 416 responses * fix(cloudflare): preserve partial asset response status * docs(server): clarify static range representation * fix(server): align cached precondition headers * fix(server): align slow-path not-modified headers * fix(server): adjust RFC 850 leap-day centuries * fix(server): align static range and method handling * fix(server): match weak static etags for If-Match --------- Co-authored-by: James <james@eli.cx> |
||
|
|
c9a4a843ce |
fix(server): use weak comparison for If-None-Match (#2710)
* fix(server): use weak comparison for If-None-Match * chore: retrigger CI * fix(server): validate conditional static requests * fix(server): tighten entity-tag parsing * test(server): document entity-tag comma behavior * fix(dev): weak-match public asset etags * fix(dev): normalize public etag lookup paths * fix(dev): canonicalize conditional public assets * fix(dev): honor Vite public file identity * fix(dev): mirror Vite public filesystem semantics * fix(dev): refresh public filesystem capabilities * fix(dev): verify public path aliases * fix(dev): index verified public request aliases * fix(dev): compose verified public path aliases * fix(dev): resolve Unicode public path classes * fix(dev): use Unicode simple case matching * fix(dev): match Unicode expansion tokens * fix(dev): keep public path node private * fix(dev): resolve public-root symlink redirects * refactor(dev): leave public etag handling to Vite * test(server): reject malformed etag list tails * fix(pages): share conditional etag matching * refactor(pages): remove reversed etag matcher --------- Co-authored-by: James <james@eli.cx> |
||
|
|
b779754ee6 |
fix(server): serve static assets with standard MIME types (#2713)
* fix(server): serve static assets with standard MIME types * fix(server): add UTF-8 to textual static asset types * fix(server): match Next static charset casing * fix(server): lowercase MIME charset values --------- Co-authored-by: James <james@eli.cx> |
||
|
|
357092873f |
test(e2e): gate clicks that can land before hydration commits (#2717)
* test(e2e): gate the dev-overlay soft-nav click on hydration The soft-nav canary test waited only for the navigation runtime to publish functions.navigate before clicking the Link. That happens before React commits the hydrated tree — a probe measures a 12-30ms window locally, wider on a loaded runner — so a click can land on an anchor with no listener attached, the browser follows the href, and the reload canary the test asserts on is lost. Use waitForAppRouterHydration, which also requires __VINEXT_HYDRATED_AT (an App Router post-commit effect) and settles two frames. It is what the file's six other tests and the sibling app-with-src spec covering the same contract already use. Closes #2716. Recurrence of #1769; same race as #2409. * test(e2e): gate use-params-app-pages clicks on hydration The 12 clicks in tests/e2e/use-params-app-pages/ were dispatched with no hydration gate. App Router publishes the navigation runtime 3-6 rAF ticks before __VINEXT_HYDRATED_AT, and hydration is complete neither when goto() resolves nor after one visibility assertion, so a click can land before React attaches its delegated listeners and be dropped. Gate each click on the helper the clicked element's router actually owns: waitForAppRouterHydration for the App Router page at /, waitForHydration for the Pages Router page at /form-source. The prefetch test additionally asserted zero RSC requests, which passes vacuously when the handler never runs. Add a positive control: the button now also prefetches an App-owned /prefetch-control, whose RSC request only appears if the handler fired. Its request filter also matched a literal ".rsc" path suffix, which this project never emits (_rsc is a query param), so switch to the shared isAppRouterRscRequestForPath helper. Refs #2716. |
||
|
|
0b58bbc8ff |
fix(metadata): preserve Content-Length for fully buffered responses (#2703)
* fix(metadata): preserve Content-Length for fully buffered responses closeAfterResponseWithBody() wraps every body-bearing response in a TransformStream to support function-form after(), which strips Content-Length even when the body is already fully materialized. Metadata file convention responses (robots(), sitemap(), manifest(), static icons) always serialize to a string or byte array with no producer left, so mark those bodies (markFullyBufferedBody) and skip the wrap when nothing is registered. Arbitrary Route Handler responses and a metadata route's Response passthrough stay wrapped: their body's producer may still call after() after the handler resolves, so a "nothing registered yet" check can't prove them safe. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqasUFjUPt2Q8gkrzrLGyB * fix(metadata): preserve deferred response lifecycle --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: James <james@eli.cx> |
||
|
|
cfcd60b9a9 |
test: give middleware auth guard assertion a CI-safe timeout (#2720)
* test: give the unit project the integration timeout The unit project set no `testTimeout`, so it inherited vitest's 5s default while holding 47 files that boot a dev server or run a production build. 39 of those pass a per-test timeout (up to 180_000); the ones that did not sat on a ~20% margin — `middleware-matcher-auth.test.ts`'s auth-guard case measures 4.0s under normal parallel load — which ordinary CI variance erases. It and `typegen.test.ts` each failed a different CI run that way while passing locally. Adopts the 30000 the integration project already declares. A timeout costs wall-clock only when a test hangs, so nothing that passes slows down, and per-test arguments still take precedence. Closes #2719 * test(middleware): scope auth guard timeout --------- Co-authored-by: James <james@eli.cx> |
||
|
|
da0a8ef1e5 |
fix(image): match Next.js 16 default image sizes (#2704)
* fix(image): match Next.js 16 default image sizes Next.js 16 removed the 16px default, but vinext continued generating and accepting it when imageSizes was unset. This added an unnecessary srcSet candidate and diverged from the pinned Next.js behavior. Use the shared server default for build-time defines and keep the client shim fallback aligned. Explicit imageSizes configurations that include 16px remain supported. * test(image): update generated default width expectation The Next.js 16 default-width change correctly updated generated App Router entries, but this behavior test still encoded the previous list. Align the expectation while preserving the neighboring explicit 16px opt-in coverage. |
||
|
|
39b10bae43 |
refactor(navigation): derive bfcache segment identity from route graph (#2070)
* feat(navigation): derive bfcache segment identity from route graph BFCache segment ids currently depend on AppElements wire keys and pathname segment counting. That lets route identity drift from the route graph and makes preservation sensitive to string surgery around pathnames, catch-all routes, slots, and intercepted state. The violated invariant is that a restorable segment should be keyed by the route graph state that owns it, using the same bound segment key encoding as React reset keys, with the artifact graph version only as an equality component inside the current document. Add a typed BFCache identity descriptor engine, carry canonical per-segment state keys over the AppElements wire metadata, and have SSR and browser identity construction read those same bytes. Missing metadata falls back to an empty bound segment key so older payloads remint instead of deriving a second identity path. The tests cover graph-version changes, carried key preservation and reminting, catch-all canonical keys, default versus unmatched slots, traversal restoration, interception, and server-action refresh preservation. * test(navigation): cover bfcache segment state key metadata * fix(navigation): expose segment state key wire metadata BFCache segment-state-key metadata is first-class AppElementsWire metadata, but the stable keys facade did not expose it. That left callers able to import the raw constant while bypassing the codec boundary. Add segmentStateKeys to AppElementsWire.keys, cover it in the codec tests, and tighten local graphVersion wording to artifact-compatibility equality semantics. * fix(navigation): require carried segment keys for bfcache identity Missing __segmentStateKeys parsed to an empty map, but the browser state code converted an absent per-segment key to an empty bound key and still produced a comparable identity. Legacy or low-context payloads could therefore preserve BFCache ids through graphId and slot facts alone. Return null when a segment lacks a carried key, preserve only proven non-null identities, and cover both absent-key reminting and explicitly carried empty-string keys. * refactor(navigation): drop unreachable bfcache descriptor paths The BfcacheSegmentDescriptor union carried a `kind: "default"` variant and a template `ownerLayoutId` field that no producer ever constructs. Default parallel slots are emitted as `kind: "slot"` with `state: "default"`, and the template branch in createBfcacheSegmentIdentity hardcoded `ownerLayoutId: null`. Their dedicated tests asserted behaviour the integration cannot reach, giving false coverage confidence. A template's owner layout shares the template's tree path, so a static owner id would be fully determined by graphId and add no distinguishing identity; real owner-remount re-keying would need the owner's live instance id, which is not available here. Dropping the field keeps the typed contract honest about which facts actually participate in segment identity. Also refresh the stale slot.tsx comment that still described pathname-tracked state keys (now route-graph-binding tracked) and annotate the state-key-map omission site so the intentional legacy-payload remint reads as deliberate. * fix(navigation): wire bfcache identity test metadata * fix(app-router): keep graph versions out of bfcache ids * fix(app-router): reuse slot reset keys for bfcache metadata BFCache metadata computed slot bindings from the configured route even when an interception override changed the effective route. That let preservation identity diverge from the React reset boundaries for overridden parallel slots. Populate each slot metadata key from the effective slot reset key at the render boundary, and assert the wire key follows changed interception bindings. * refactor(app-router): finalize element metadata after rendering Rendered element maps registered their dependency barriers before metadata was added, which made the metadata object reference part of the construction lifecycle. A final spread could also break dependency release because registration was attached to the pre-merge object. Build the rendered element map first, assemble the completed metadata map at return time, and register render dependencies on the final returned object. * fix(app-router): reject malformed segment identity maps atomically Valid-looking segment entries must not remain authoritative when a wire payload contains a malformed sibling or a non-segment element id. Partial parsing could preserve untrusted BFCache bindings. Validate each binding and wire id before accepting the map, returning an empty map on any failure so BFCache consumers remint conservatively. Add codec regressions for malformed values and route ids. * refactor(app-router): carry opaque bfcache identities BFCache consumers were rebuilding segment identity by joining bound keys, slot bindings, interception metadata, and parsed wire ids. That allowed independently valid metadata fragments to form a derived identity. Build the complete identity at the route-graph boundary and carry one validated opaque map. Compare carried identities directly in the browser, and replace route-reconstruction tests with route-boundary behavior coverage. * perf(app-router): avoid copying rendered element maps Final metadata assembly copied the complete rendered element map after the route graph was built, adding avoidable allocation to the cold request path.\n\nMerge the completed metadata into the existing rendered map at the return boundary, then register dependencies and return that same final object. * refactor(navigation): make bfcache identity graph-owned Build an immutable per-render segment plan from route graph IDs, resolved bindings, slot ownership, and interception facts before rendering. Emit completed opaque identities once, while keeping loading reset-key work in the rendering shell. Preserve concrete activeRouteId values for render planning, use semantic route IDs only inside BFCache descriptors, and use the graph route ID for synthetic carriers on layout-only routes. * fix(navigation): preserve bfcache identity with merged segments Planner-approved layouts and slots can retain their previous element value while the destination payload replaces the identity metadata map. That left preserved client state without its matching identity proof, so the following navigation minted a new BFCache ID. Treat each preserved element and its opaque identity as one merge unit. If the previous payload has no valid proof, remove any destination identity for that retained value so reuse remains conservative. The regression follows a preserved modal through the commit and a subsequent navigation. * refactor(navigation): remove wire ids from bfcache identity Intercepted segment identities still incorporated payload-facing slot and route IDs, so transport encoding changes could alter logical equality even when the route graph was unchanged. Carry the semantic interception target from route matching into the immutable segment plan and use graph-owned slot, layout, active-route, and target-route IDs in the identity tuple. Wire IDs now only address AppElements entries. If semantic interception data is unavailable, omit the proof and conservatively remint instead of falling back to transport identity. * fix(navigation): preserve bfcache segment identity parity * fix(navigation): retain nested bfcache segment ownership * fix(navigation): preserve intercepted segment ownership * fix(navigation): preserve slot shell segment ownership * fix(navigation): commit bfcache snapshots atomically * fix(navigation): key sibling interceptions by graph id * refactor(app-router): extract the bfcache segment planner Move createAppPageSegmentPlan and its helpers out of app-page-route-wiring into app-page-segment-plan, and have route matching hand the planner a resolved semantic branch (marker split off, binding param map chosen) instead of re-parsing interception syntax downstream. * docs(app-router): record template route-group keying parity Codex flagged the template state key stopping at a route group as a missed remount. It is not: Next keys a template from its immediate child segment (createRouterCacheKey in layout-router.tsx) and route groups are levels in the FlightRouterState, so sibling routes inside a group do share the template above it. Note the parity so the behaviour is not re-litigated. * refactor(app-router): tighten the bfcache segment planner Collapse the nested-segment candidate flags that were constant per slot, build the bound segment keys in one pass instead of re-slicing per index, memoise the route prefix state keys shared by layouts and slot owners, and index layout entries by id instead of scanning them per lookup. Share the interception marker table with the tests and drop the one-element array hop in resolveAppPageSemanticSegmentStateKey. * refactor(app-router): collapse identical bfcache descriptor arms page, layout and template serialise the same four fields; the kind tag already keeps them distinct. * chore: re-run CI benchmarks * refactor(navigation): drop the unreachable bfcache id map fallback getBfcacheIdMapContext only returns null when React.createContext is unavailable, but the fallback was itself created by an unconditional createContext call in this "use client" module: if the guarded condition held, constructing the guard would have thrown first. Use the same non-null context both call sites already assume. --------- Co-authored-by: James <james@eli.cx> |
||
|
|
05eee9fecc |
chore: version packages (beta) (#2673)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>@vinext/cloudflare@1.0.0-beta.4 vinext@1.0.0-beta.4 |
||
|
|
1c69ce3fef |
fix(pages-router): load custom _app/_document via resolved file paths in dev (#2692)
* fix(pages-router): load custom _app/_document via resolved file paths in dev The dev server imported pages/_app and pages/_document by extensionless id and relied on the module runner applying custom resolve.extensions (e.g. ".page.tsx" from pageExtensions). The runner no longer resolves those, so apps using pageExtensions silently lost their custom App/Document (the load failure is swallowed). Import the file path resolved by findFileWithExts instead, at all three call sites. * test(e2e): add pages-router-complex example app and behaviour suite A deliberately convoluted Pages Router app that serves as a compatibility target: the kinds of patterns that only surface in large, long-lived enterprise pages-router codebases, each pinned by a Playwright spec whose behaviour is verified against real Next.js (73/73 under next dev --webpack). Highlights: custom pageExtensions everywhere (middleware.page.ts, instrumentation.page.ts), app-shell getInitialProps with a TTL-memoised chrome fetch and embedded-shell/draft bypasses, class _document with its own GIP, a zone route dimension driven by middleware rewrites and a real i18next/react-i18next runtime, catch-all routes with static-sibling precedence and cacheable-404/redirect hygiene, record-driven template branching on a dynamic/static/dynamic route, an urql-style server-snapshot data layer, shallow routing + router.events + next/compat/router + next/navigation hooks in pages, next/image with a custom loader, draft-mode gateway APIs, a function-form next.config, and a Cloudflare setup matching the vinext init scaffold. The playwright project (pages-router-complex, port 4199) is intentionally not in the CI e2e matrix: vinext currently passes 59/73, and the failures are the compat backlog documented in the example README. * fix(pages-router): cover compound page extensions in CI |
||
|
|
956bed233b |
fix(pages): preserve live Set-Cookie header arrays (#2689)
* fix(pages): preserve live Set-Cookie header arrays * fix(pages): align writeHead cookie replacement |
||
|
|
756d7eeee4 |
fix(css): load Sass partials from paths containing tildes (#2691)
* fix(css): normalize Sass importer stylesheet keys * perf(sass): simplify path handle * style(sass): simplify stylesheet key handling |
||
|
|
69f03c871b | fix(test): prevent recursive deploy log growth (#2693) | ||
|
|
431b044879 |
fix: honor build --mode dotenv files (#2523)
* fix: honor build mode dotenv files * fix: preserve test-mode dotenv exclusion in config * fix(build): keep NODE_ENV production with custom mode * fix(build): set production env before config loading * fix(env): preserve dotenv expansion operators * fix(build): preserve explicit NODE_ENV for config loading --------- Co-authored-by: James <james@eli.cx> |
||
|
|
f2252edf15 | fix(build): normalize jsx-in-js module ids on Windows (#2687) | ||
|
|
391eda0ce9 |
test(link): isolate asynchronous prefetch setup (#2684)
Link prefetch tests could restore global mocks while detached RSC URL hashing was still in flight, allowing late requests to land in the next test. The dynamic-route case also counted a leaked viewport request as its second fetch.\n\nTrack cache-busting digest promises through teardown and make positive assertions wait for their owned requests. A delayed-digest regression keeps the original race reproducible. |
||
|
|
052b2661ab | fix(rsc): handle Windows paths in client reference dedup (#2686) | ||
|
|
5f259cee96 |
fix(app-router): install default not-found boundary (#2670)
* fix(app-router): install default not-found boundary * fix(app-router): own default not-found at root layout |
||
|
|
8e7fa22491 |
fix(app-router): scope dynamic params by segment (#2228)
* fix(app-router): scope dynamic params by segment * Reviewed PR #2228: correct, approved Co-authored-by: james-elicx <james-elicx@users.noreply.github.com> * fix(app-router): render static param misses through not-found * fix(app-router): preserve context for static param 404s --------- Co-authored-by: ask-bonk[bot] <ask-bonk[bot]@users.noreply.github.com> Co-authored-by: james-elicx <james-elicx@users.noreply.github.com> |
||
|
|
8e36c914e0 |
fix(app-router): honor client cache stale times (#2449)
* fix(app-router): honor client cache stale times * fix(app-router): preserve static reusable prefetch stale time * fix(app-router): reset fresh page state after prefetch * fix(app-router): preserve intercepted source state * fix(app-router): preserve client cache segment identity * fix(app-router): preserve dynamic slot cache semantics |
||
|
|
22f106e18d | perf(app-router): serialize streamed metadata once (#2675) |