mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
vinext@0.1.5
25 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
47b38a91f3 |
fix(app-router): recover SSR shell render errors via __next_error__ document (#1908)
* fix(plugin): resolve vinext/shims/* package subpaths to local shim files Runtime helper modules embedded into generated entries import vinext's own shims by package subpath (e.g. `vinext/shims/headers`), while source checkouts alias userland `next/*` imports to the local shim files. The two specifiers resolved to different module instances, so request-scoped singleton state (navigation context, headers) split between the source shim copy and the package export copy. The violated invariant is that every shim module must be a per-request singleton regardless of import specifier. Resolve `vinext/shims/*` through the same plugin path as the `next/*` aliases so both forms land on the local shim files. Exercised by the SSR shell-error recovery browser spec, whose fixture imports vinext from the source checkout and depends on shared navigation state across both import forms. * fix(app-router): recover SSR shell render errors via __next_error__ document When the HTML (Fizz) render rejects during SSR, vinext re-rendered a server-side global-error page whose flight payload encodes the error tree. For an app without a custom global-error.tsx that meant the default error card with no path back to the real page: an SSR-phase-only throw (e.g. a client component using the "throw to opt out of server rendering" pattern) left the browser stuck on the card even though the client render would succeed. The violated expectation is Next.js's shell-error semantics: a failed HTML shell is served as the default `__next_error__` error document carrying the ORIGINAL flight payload and the bootstrap module, and the browser re-renders the real tree from that payload with createRoot instead of hydrating. Local error.tsx boundaries still win — they ship inside the flight payload and catch the re-thrown error client-side. handleSsr now resolves to that recovery document instead of rejecting, but only when both hold: - the error did not originate in the RSC render (no string `digest`), so flight errors, redirect()/notFound(), and server-component throws keep driving the existing rejection-based boundary machinery, and - the app has no custom global-error.tsx (the generated entry knows at build time and threads hasCustomGlobalError through dispatch/render options); apps with one keep the server-rendered boundary re-render. The browser entry switches from hydrateRoot to createRoot when the document root carries id="__next_error__", dropping the error-shell styles first. Covered by the new ssr-error-shell-recovery browser spec (recovery to real content, local error.tsx for SSR-only and unconditional client throws) and the existing tests/nextjs-compat/global-error.test.ts boundary-semantics suite. * refactor(review): address PR 1908 non-blocking notes - Add static prerender no-boundary recovery regression test to ssr-error-shell-recovery.browser.spec.ts - Document broad __next_error__ browser marker in app-browser-entry.ts - Extract stripJsExtension to utils/path.ts and wire into all shim resolution sites (vinext/shims/* + react-server shims) Non-functional: targeted regression coverage + code documentation + minor resolver hardening per reviewer feedback. * refactor(review): clarify shell recovery assumptions * fix(ssr): cancel abandoned prerender streams * refactor(ssr): clarify error shell root options * fix(isr): recover shell errors during regeneration * fix(ssr): scope client recovery to marked shells * fix(plugin): explicitly filter vinext shim subpaths * fix(plugin): retain null-prefixed shim resolution * test(nextjs-compat): cover no-boundary shell recovery fallback An unconditional client throw during SSR shell recovery without a local error boundary must still land on the default global-error card. Without a regression test, a future change could tear down the recovery shell and leave a blank document.\n\nAdd a production browser case that exercises the no-boundary route and asserts the default error UI after the client re-render throws again. * fix(app-router): preserve shell recovery error semantics * test(app-router): harden shell recovery cache semantics * fix(app-router): mark global error responses uncacheable * test(app-router): align error response status expectations * fix(build): preserve null-prefixed og resolution * fix(cache): delegate cache header cleanup to adapters --------- Co-authored-by: James <james@eli.cx> |
||
|
|
dea4472c2f |
fix(app-router): include parallel slot params in useParams (#1904)
* fix(app-router): include parallel slot params in useParams App Router navigation state previously used only the primary route match params when setting request navigation context, RSC param headers, render observations, and optimistic navigation payloads. That lost catch-all params from active parallel slot branches, so useParams() diverged from the slot page's params prop after SSR and client navigations. Next.js derives params from the selected router tree across parallel route branches. Resolve active slot params from route metadata, merge them into the navigation params boundary, and feed that selected param set through server render and optimistic navigation payloads. Port the upstream parallel-route-navigations fixture and cover the route graph, selected params helper, and optimistic payload boundary. * fix(app-router): preserve canonical pathname in navigation context after middleware rewrite * fix(app-router): resolve parallel slot params for static, revalidation, and action paths * chore: retrigger CI * fix(app-router): address ask-bonk review comments on parallel slot params - Remove unused parameter from - Add clarifying comment on routeParams fallback being deliberate no-op - Use (full matched params) instead of narrowed as base for slot param merge, preserving interception-specific extras on ISR regeneration path * fix(app-router): use full navigationParams in server-action re-render path The server-action re-render path was passing narrowed actionRerenderTarget.params to resolveAppPageNavigationParams, dropping interception-specific extras from source-route intercepts. Use actionRerenderTarget.navigationParams (full matched params) instead, matching the pattern used in the dispatch ISR path at app-page-dispatch.ts:602. Adds unit tests for the intercepted-slot and mixed intercepted + non-intercepted slot branches of resolveAppPageNavigationParams. * docs(router): clarify decode invariant and type cast rationale in parallel-slot params - app-optimistic-routing.ts: add comment explaining the pre-existing double-decode asymmetry between route params and slot params, noting TODO to converge decode passes - app-server-action-execution.ts: expand the comment on the interceptOpts cast to document why it's safe and what a follow-up would look like * fix(app-router): merge slot params for fresh intercepts --------- Co-authored-by: James <james@eli.cx> |
||
|
|
f5bee1a26f |
fix(app-router): preserve unstable_catchError boundaries (#1906)
* fix(app-router): preserve unstable_catchError boundaries
* test(e2e): add Next-parity test for shouldRerenderAppPageWithGlobalError (#1906)
Add a test proving that for uncaught post-shell RSC errors (with no
client boundary), the server preserves the original HTML stream and the
client renders global-error. This is the Next.js behavior that justifies
keeping shouldRerenderAppPageWithGlobalError unconditional at false.
The server cannot detect userland client boundaries like
unstable_catchError from the route manifest, so the only safe way to
get a global-error rerender is to make a targeted error-boundary test.
The test uses a loading.tsx boundary so the error is truly post-shell,
not caught by the pre-shell error-recovery catch block.
* fix(error): restore sentinel wrapper for falsy thrown values in unstable_catchError
Restores the _CatchErrorState sentinel wrapper ({ thrownValue: unknown }) so
falsy thrown values (null, undefined, 0, "") are routed to the fallback
instead of being treated as no error. Also restores ErrorInfo.error as unknown.
Adds back unit tests for falsy values, class-component lifecycle, and
unstable_retry behavior parity with the component-rendered fallback shape.
Refs: #1906
* fix(error): align ambient ErrorInfo.error type with runtime implementation
* refactor(app-router): remove shouldRerenderAppPageWithGlobalError dead code
The function always returned false and its _options parameter and
ShouldRerenderAppPageWithGlobalErrorOptions type were vestigial.
The call site at app-page-render.ts:892-904 was therefore never taken.
Remove the function, type, import, call site, and the now-redundant
unit test, leaving the shell-level recovery path in
renderAppPageHtmlStreamWithRecovery as the sole error recovery seam.
Addresses ask-bonk review comment on #1906.
* fix(test): restore typed globalAny view and document Fallback as any intent
Addresses two non-blocking review findings from ask-bonk on PR #1906:
- Restored the typed `globalThis as unknown as { window?: unknown }`
pattern for window stubs in the unstable_catchError test section,
replacing the `(globalThis as any).window` pattern that was a type-safety
regression from the prior rewrite.
- Added an inline comment explaining why `Fallback as any` is necessary
in the class-component lifecycle test: the test probes the inner
_CatchError class directly, where the fallback function is used as a
React component type (receiving a single `{ props, errorInfo }` object)
rather than the public API signature `(props, errorInfo)`.
* chore: trigger CI
* fix(test): rename forwardedProps to props in unstable-catchError test
Addresses ask-bonk review finding on PR #1906: the test was seeding
the inner _CatchError class with the stale key name forwardedProps,
but the production wrapper now passes props (and isPagesRouter).
Since this test only exercises the App Router unstable_retry path
(which reads neither key), behavior was unaffected, but renaming
aligns the test with the current boundary shape for future readers.
* refactor(server): remove dead routeHasLocalBoundary field
Removing shouldRerenderAppPageWithGlobalError (commit
|
||
|
|
cda19e1c58 |
fix(app-router): preserve recent segment state with Activity BFCache (#1739)
* fix(app-router): preserve recent segment state with Activity BFCache Fresh App Router navigations were reusing the active React tree when moving between dynamic segment values. That leaked client state into a newly entered segment and made the upstream back-forward-cache suite fail before it even reached browser traversal assertions. The root cause was that vinext had history-entry bfcacheId plumbing but no separate segment state-key cache. Next.js treats those identities separately: render state is keyed by segment identity/pathname, while useRouter().bfcacheId identifies a concrete history entry. Add state-key maps for app router slots, render retained entries through React.Activity when cacheComponents is enabled, keep the active one-entry keyed reset path otherwise, and port focused Next.js regression coverage for the three-entry BFCache behavior. * fix(bfcache): store stateKeyMap in retained slot entries and re-provide on render Review feedback requested two changes: 1. Add stateKeyMap to BfcacheSlotEntry so each retained BFCache entry carries the state-key map that belonged to it when active, rather than borrowing the current navigation's map. This prevents nested hidden entries from being rendered with old elements but new pathname-derived state keys — an identity corruption bug that upstream Next.js avoids by rendering each retained entry from its own state. 2. Add a nested dynamic regression test: /nested/[section]/item/[id] with client counters in both the [section] layout and [id] page. The existing flat /page/[n] tests do not exercise nested retained-entry identity, which is exactly where the missing state-key-map bug lives. Changes: - BfcacheSlotEntry gains stateKeyMap?: Readonly<Record<string, string>> - updateBfcacheSlotEntries accepts and stores an optional stateKeyMap argument - BfcacheSlotBoundary stores the current stateKeyMap alongside elements when caching the active entry - Both the Activity visible/hidden path and the non-cacheComponents single-entry path now wrap rendered entries in BfcacheStateKeyMapContext.Provider using the entry's own stateKeyMap (falling back to current map for forward compatibility) - New e2e test verifies nested layout + page counter preservation across segment navigations * refactor(bfcache): extract shared provider stack into BfcacheEntryProviders BfcacheSlotBoundary rendered the BfcacheStateKeyMapContext / ElementsContext / SegmentContext provider stack in two places: the one-entry keyed reset path and the per-entry Activity retention list. The two stacks were identical except for where the React key sat, so the retained-entry bookkeeping and its render shape were tangled together and harder to audit than needed. Extract BfcacheEntryProviders as a pure presentational helper and render it from both paths. The keyed-reset path keeps its remount semantics: the key now sits on the BfcacheEntryProviders element, so a fresh active stateKey still remounts the whole provider subtree. Hook ordering is unchanged. All hooks stay above the early returns in BfcacheSlotBoundary, so the boundary still satisfies the Rules of Hooks across the activeStateKey undefined-to-defined transition. Covered by the slot BFCache entry-state suite in tests/shims.test.ts. * test(bfcache): isolate cacheComponents BFCache coverage in a dedicated fixture The back/forward-cache e2e suite ran against the shared app-basic fixture with cacheComponents: true. app-basic backs the single app-router Playwright server that every app-router spec uses, so enabling cacheComponents globally changed a core DOM invariant for unrelated tests: inactive route trees stay mounted as hidden Activity DOM. The contamination already leaked a :visible workaround into the interception-modal bfcacheId spec, which had nothing to do with Activity. cacheComponents is an opt-in semantic mode, not default app-router behaviour, so it does not belong on the shared baseline fixture. Move the BFCache routes into a dedicated tests/fixtures/app-bfcache fixture with cacheComponents: true, served by a new app-router-bfcache Playwright project on port 4183, and run the back-forward-cache spec there against the project baseURL. The :visible locators stay in that spec, where duplicate hidden DOM is the expected contract. Remove cacheComponents and the BFCache routes from app-basic so the shared fixture models default behaviour again, and revert the :visible workaround in use-router-bfcache-id.spec.ts now that the modal route no longer retains hidden DOM. Fix the nested item page import while moving it: nested/[section]/item/[id]/ page.tsx imported ../counter, but the counter lives two levels up at nested/[section]/counter.tsx, so it must be ../../counter. Validation: PLAYWRIGHT_PROJECT=app-router-bfcache (5 passed) and the full use-router-bfcache-id spec under app-router (11 passed), plus the slot BFCache unit suite (1172 passed) and vp check (formatting + lint + types clean). * ci: run the app-router-bfcache e2e project in the matrix The dedicated app-router-bfcache Playwright project was added to playwright.config.ts but not to the CI E2E matrix, so the back/forward-cache specs never ran in CI. Add it to the matrix project list so the BFCache suite runs on every push. It uses the default chromium browser, so the existing setup and browser-cache steps apply unchanged. * fix(bfcache): keep persisted slots mounted when cacheComponents is off Without cacheComponents there is no Activity retention, yet BfcacheSlotBoundary still keyed the active entry by its segment stateKey. That stateKey tracks the pathname (createBfcacheSegmentIdentity), so any slot whose identity moves with the URL (shared layouts, interception source slots) was remounted on navigation, discarding client state that survives a normal route change. The baseline router reconciles these slots in place. This regressed seven existing app-router e2e tests once cacheComponents was no longer masking it on the shared fixture: interception source-context and modal preservation in advanced.spec.ts, and layout-persistence.spec.ts dynamic-segment layout counter survival. Fresh-entry reset is driven by userland bfcacheId keying in the fixtures, not by remounting the slot subtree, so the keying was pure overreach in the non-cacheComponents path. Render the single active entry unkeyed when cacheComponents is off so the boundary reconciles in place like the baseline. The keyed Activity retention path is unchanged for cacheComponents. Validation: full app-router project 436 passed / 0 failed (the seven regressions now pass); use-router-bfcache-id 11 passed (fresh-push reset intact); app-bfcache 5 passed (Activity retention intact); shims unit suite 1024 passed. * docs(test): point bfcacheId spec comment at the app-router-bfcache project The header noted that Activity-backed preservation is covered in back-forward-cache.spec.ts, but that spec moved to the dedicated app-bfcache fixture under the app-router-bfcache project. Name the project and path so the cross-reference stays accurate. * docs: align cacheComponents comment with unkeyed active-segment behavior * test(bfcache): assert fresh bfcacheId after Activity-restored return * style: collapse locator chains per oxfmt * refactor(bfcache): split slot entry order from snapshots * fix(bfcache): keep non-cacheComponents slot path baseline * fix(bfcache): use string cacheComponents define * test: remove external fetch from revalidate tag fixture * fix(bfcache): harden Activity state key retention * docs(bfcache): document cacheComponents Activity caveats * fix(bfcache): preserve navigation context for fallback SSR Fallback boundary rendering can outlive the original request-scoped navigation context. That made app SSR fail fast with a missing navigation context when loading-boundary digest routes rendered their HTTP fallback shell. The SSR boundary contract still requires a navigation context for real request renders, but boundary renderers now synthesize the same typed shape from the request URL and matched params when request scope has already been cleared. * refactor(bfcache): normalize pathname in state-key map, document invariant callers Moves hydration-pathname normalization into createBfcacheSegmentStateKeyMap so SSR and client always produce byte-identical Activity keys. Documents the requireNavigationContext guaranteeing callers per review feedback. * fix(bfcache): tolerate malformed state-key pathnames * docs(bfcache): document known Next.js divergence and dead-branch contract * chore(deps): update lockfile for vite 0.1.24 * fix(app-router): preserve encoded delimiters in bfcache keys * docs(bfcache): track router-owned retention follow-up * chore: retrigger ci * fix(app-router): preserve redirect bridge fallback |
||
|
|
7d280edfbe |
refactor(server): replace prerender HTML buffering with metadataReady contract (#1809)
* refactor(server): replace prerender HTML buffering with metadataReady contract * fix(server): wait for allReady before piping through tick-buffered transform when waitForAllReady is true * refactor(server): clean up AppSsrRenderResult normalization and metadataReady promise resolution * test(server): cover ISR revalidation waitForAllReady; address review nits - Add focused test asserting stale HTML cache regeneration passes waitForAllReady: true to handleSsr so Suspense fallbacks never leak into the cache entry - Document why metadataReady resolves eagerly (allReady is already awaited for the prerender path) in app-ssr-entry.ts - Remove stray blank line left by the readStreamAsText import removal - Type capturedOnError in the prerender special-error test instead of any * fix(pages): export PagesPageModule type consumed by pages-page-handler The dts bundle build (vp pack) failed because pages-page-handler.ts imports PagesPageModule from pages-page-data.js, but the type was not exported, so rolldown-plugin-dts could not resolve it. --------- Co-authored-by: James <james@eli.cx> |
||
|
|
1a480016f6 |
fix(app-router): track searchParams access for static bailout (#1788)
* fix(app-router): track searchParams access for static bailout App Router pages that awaited or used the searchParams prop during prerender could still be classified as static when the prerender request had an empty query string. That seeded no-query output into the deploy cache and served it for requests like ?search=hello. The dynamic decision was based on query-string content instead of page prop access. Observe the searchParams thenable itself, including React use() status reads, and mark searchParams dynamic only when the prop is consumed. * chore: rerun ci * fix(app-router): avoid static cache for searchParams pages * fix(app-router): tolerate synthetic page paths in manifest codegen * fix(app-router): observe searchParams access for static bailout * fix(app-router): preserve app page cache hits * fix(app-router): observe loading search params during render * refactor(app-router): extract searchParams probe fan-out into helper Move the per-request page probe fan-out (matched page + parallel slot pages + interception page) out of the generated RSC entry and into a unit-testable buildAppPageProbes() helper in server/app-page-probe.ts, keeping entries/app-rsc-entry.ts thin (AGENTS.md guidance). Addresses bonk review concern #3 on #1788. Document the known slot over-bail (probing inactive parallel-route slot pages can mark an otherwise-static page dynamic; fails safe toward dynamic) and track it as a caching-efficiency follow-up in #1798 (bonk review concern #1). * fix(app-router): skip probing interception-overridden slot pages Narrow the searchParams probe fan-out so it only probes page components that actually render for the request. When an interception matches, it replaces the page of the slot named by intercept.slotKey (the element builder sets overrides[slotKey].pageModule to the interception page, which wins over slot.page in app-page-route-wiring.tsx). We now probe the interception page in place of that slot's own page instead of probing both — probing the overridden slot page marked an otherwise static request dynamic for a component that never renders. Fixes the over-bail flagged in review of #1788. * docs(app-router): clarify buildAppPageProbes slot precision Address review feedback: the doc comment overstated precision without explaining why non-overridden slots are exact. Spell out that a slot with a page.tsx always renders that page (so probing it is correct), default-only slots have no slot.page.default so probing is a no-op (not an over-bail), and a default.tsx awaiting searchParams is backstopped by the real render's observation (so no under-bail). * fix(app-router): gate searchParams interception probe on isRscRequest Interception only fires for RSC navigations (resolveAppPageInterceptState returns kind:none when !isRscRequest, app-page-request.ts:324). The probe fan-out called findIntercept unconditionally, so on HTML requests it would skip the matched route's slot page (which DOES render normally) and probe the interception page (which never renders). buildAppPageProbes now ignores the interception match when !isRscRequest, so HTML requests probe every slot's own page and skip the interception probe. The source-route interception case (a different route renders) never reaches this probe: dispatchAppPage returns the intercepted response before calling probePage, so any interception seen here is the current-route override case. Documented both in the buildAppPageProbes doc comment. Addresses review feedback on #1788. --------- Co-authored-by: James <james@eli.cx> |
||
|
|
fb233b0a34 |
feat: improve dev error overlay source frames (#1746)
* Improve dev overlay stack source mapping * Polish dev error overlay stack display * Add dev overlay code frames * Align dev overlay message spacing * Add dev overlay error copy action * Fix dev overlay knip exports * Polish dev overlay layout * Polish dev error overlay spacing * Fix dev overlay coverage and source frame metadata * Fix dev overlay recovery for RSC HMR errors * Handle Vite HMR build errors in dev overlay * Fix knip * Use Shadow DOM for dev error overlay |
||
|
|
b8cecf8b46 |
fix(skip): centralize final static-layout skip rejection (#1722)
* feat(skip): reject runtime-unsafe layouts from static transport skip The enable slice omits a static layout from RSC transport once it passes the cache cross-check. The cross-check proves artifact and payload identity, but it does not, on its own, assert that the layout's render was free of per-request behaviour. A layout that reads params, calls a request API, sets a finite revalidate, opens a cacheLife scope, reads unstable_cache, or issues cache-tagged or dynamic fetches must never be reused across a sibling-route navigation. This adds an explicit observation gate at the skip decision point. Before a static layout is omitted, its per-layout observation is checked: an incomplete probe, observed param keys, structural param scope, request APIs, finite revalidate, cacheLife, unstable_cache, cache tags, cacheable fetches, or dynamic fetches each reject the entry with a specific SKIP_LAYOUT_* code and fall the layout back to render-and-send. Scope note: the enable base already classifies most runtime-unsafe layouts as dynamic (so they never enter the static skip set) and the cache cross-check rejects payload-divergent reuse. This gate is a deliberate last line of defence at the omission boundary, with diagnostic rejection codes. The tests assert the end-to-end contract (unsafe layouts are never omitted); they pass on the enable base alone, so this PR is kept as a draft pending rebase once the enable PR merges. * fix(skip): centralize final static-layout skip rejection * refactor(skip): table-drive static layout rejection rules * docs(skip): clarify !observation guards missing-tracker, not missing observation |
||
|
|
610cf2e63a |
fix(app-router): honor per-response dynamic stale times on the client (#1712)
* fix(app-router): honor per-response dynamic stale times on the client App Router navigation reused and evicted cached RSC responses using one global prefetch TTL, so pages with different unstable_dynamicStaleTime values could not stay fresh independently. Traversal also needed a route instance boundary so browser history restores hidden state instead of reusing a forward-navigation component instance. The client cache now snapshots dynamic stale-time response metadata, computes expiry per entry, preserves staleAt across cache replays, and derives mounted-slot cache variants from the decoded payload. Browser traversal keeps route instances under React Activity while normal navigations allocate fresh route instances, matching Next's BFCache/stale-time contract. * fix(link): make prefetch dedupe freshness-aware for navigation prefetches The old prefetched.has(cacheKey) gate in prefetchUrl returned early for any exact key match, including stale entries. This meant an expired prefetch with the same cache key would never reach hasPrefetchCacheEntryForNavigation, so it would never be deleted and never refreshed. The new helper is built to check resolvePrefetchCacheEntryExpiresAt, delete stale entries, and return false. But the Link path bypassed it when the old prefetched Set contained the exact key. Fix: gate the exact-key check on the helper's freshness decision. When the helper reports the exact entry is stale and deletes it, also remove the key from the prefetched Set so the fall-through path can issue a fresh fetch. Keep the second helper call for equivalent _rsc variants. Add an integration test proving visible Links re-prefetch when their exact cache entry goes stale, not just that the helper reports stale entries as unavailable. * refactor(app-router): collapse redundant prefetch/visited-cache gates Address review feedback on the client dynamic-stale-time changes: - link.tsx: the prefetch dedupe path called hasPrefetchCacheEntryForNavigation twice when an exact entry already existed. Collapse to a single freshness-aware gate that covers both the exact entry and equivalent _rsc variants. - app-browser-entry.ts: drop the redundant early refresh return in getVisitedResponse; isVisitedResponseCacheEntryFresh already bypasses the cache for refresh navigations (and the miss path evicts the entry). * docs(app-router): document intentional refresh eviction in visited cache Per review feedback: getVisitedResponse now routes refresh navigations through isVisitedResponseCacheEntryFresh (the single source of truth), which returns false for refresh and therefore falls through to the read eviction. Document at the fall-through delete that a refresh intentionally drops any prior snapshot, since the navigation re-fetches and re-stores a fresh one. * docs(navigation): clarify load-bearing prefetch-cache dual checks Per review feedback, document two checks that look superficially redundant but preserve observable contracts: - isPrefetchCacheEntryCompatibleWithMountedSlots: the two clauses accept either the entry's pinned request-time slot context or the server-declared snapshot header; they diverge in the prefetchRscResponse case. - consumePrefetchResponse expiresAt guard: only synthesize expiresAt onto the returned snapshot when the entry already carried one, so entries without an explicit expiry round-trip unchanged for callers/tests. --------- Co-authored-by: James <james@eli.cx> |
||
|
|
65eaea49a4 |
feat(skip): omit proven static layouts from RSC transport (#1437)
* feat(skip): omit proven static layouts from RSC transport
App Router RSC responses re-send every layout payload on each navigation, even
when the client already holds an identical static layout from a sibling route.
That repeats transport bytes on the common sibling-page navigation path.
When the client sends a verified reuse manifest, the render lifecycle now runs a
skip-transport planner. Each manifest entry is cross-checked against the
static-layout cache proof (exact artifact compatibility, variant cache key,
payload hash, and invalidation). Layouts that pass are omitted from the outgoing
RSC payload, recorded under __skippedLayoutIds, and their per-element render
dependencies are released. The skip disposition also bypasses the RSC cache so a
partial payload never enters a shared cache, and it is suppressed for HTML
responses.
This is the enable slice. Layouts are gated on the cache proof, not yet on
per-layout runtime observations. The observation-based safety gates (param
scope, request APIs, finite revalidate, cacheLife, unstable_cache, cache-tagged
and dynamic fetches) land in the stacked hardening follow-up.
* fix(skip): preserve parallel slots owned by skipped static layouts
When the skip-transport merge restores a server-omitted static layout into
preserveElementIds, it must also restore the default/unmatched parallel slots
that layout owns. Otherwise the planner invariant ("every preserved slot's owner
layout is present in preserveElementIds, and vice versa") is violated: in the
topology-unknown path the planner returns empty slot persistence, so a
slot-owning layout skipped server-side commits with a missing slot
(mergeElements starts from the next payload and, with preserveAbsentSlots:
false, never restores it).
mergeSkippedLayoutPreservation now derives the owned slots for newly-preserved
skipped layouts via the planner's resolveDefaultOrUnmatchedSlotPersistenceForLayouts,
covering both the topology-unknown path and the known-topology past-divergence
case. Added a regression with a mounted @slot default that fails without the fix.
Also hardcode the static-layout cache-proof's observed request-API set to []
(with a comment naming the invariant) instead of reading it back from the
per-layout observation, so a future reordering of the classification gate cannot
feed stale request-API reads into the synthetic proof. Reaching that point
already requires staticLayoutIds membership, which excludes any layout that
observed a request API.
* docs(skip): explain broader-than-planner layout membership guard
Per review: clarify that the set-membership check in
mergeSkippedLayoutPreservation is intentionally broader than the planner's
prefix-based persistence, and why preserving a proven-identical layout past the
ancestor divergence point is sound. Comment-only.
* refactor(skip): drop redundant cacheable-fetch observation record
Per review: the post-key-generation recordCacheableFetchObservation in
fetch-cache.ts was strictly redundant. The synchronous record before the first
await already covers every path that reaches it (the only intervening early
return records a dynamic observation instead), and the store is an idempotent
set, so the second call added no coverage.
* fix(test): match real RSC request URL in client-reuse-manifest e2e
The 'sends a client reuse manifest for retained static layouts on soft
navigation' e2e hand-rolled its RSC-request matcher as
url.pathname === '/client-nav-test.rsc', but App Router soft-navigation RSC
requests use the bare pathname with a _rsc query param and an rsc: 1 header
(e.g. GET /client-nav-test?_rsc), so the predicate never matched and
page.waitForResponse timed out at 30s. The manifest header was being attached
correctly the whole time. Use the shared isAppRouterRscRequestForPath helper
(as the sibling test already does) so the matcher tracks the real request shape.
---------
Co-authored-by: James <james@eli.cx>
|
||
|
|
8498e4a37e |
fix(app-router): emit per-page dynamic stale time metadata (#1711)
* fix(app-router): emit per-page dynamic stale time metadata App Router pages that export unstable_dynamicStaleTime currently behave as if no page-level stale window exists. The server segment config resolver discards the export, so RSC responses cannot carry the per-page metadata that the client cache needs for independently configured pages. The violated invariant is that route segment config resolution must preserve page-only stale-time metadata across active parallel pages before response construction. Parse the page export, reduce active page slots by shortest stale time, thread the value through generated App page dispatch, and emit it on RSC responses for the client cache to consume. * fix(app-router): gate dynamic stale time header to dynamic-only renders Only emit X-Vinext-Dynamic-Stale-Time when the render is truly dynamic (not prerender or force-static), matching Next.js behavior where the response `d` field is only set for non-static-generation renders. The client treats the headers presence as authoritative; leaking it onto static/prerendered RSC responses would cause the client cache to apply dynamic stale time semantics to a statically-cached entry. * test(app-router): add behavioral tests for dynamic stale time gating * docs(app-page-render): add line-numbered link to Next.js source * refactor(app-router): drop redundant Number.isFinite check Number.isInteger already returns false for Infinity/NaN, so the preceding Number.isFinite guard is redundant in both the dynamic stale-time header check and resolveDynamicStaleTimeSeconds. * fix(app-router): gate dynamic stale time on cache-capture predicate The previous gating (isPrerender || isForceStatic) was too narrow for production: isPrerender is only true during the build-time prerender pass, so a production ISR render (revalidate > 0) still emitted X-Vinext-Dynamic-Stale-Time even though that render is captured into the ISR cache. Next.js gates on !workStore.isStaticGeneration; the runtime analog here is shouldCaptureRscForCacheMetadata, so add it to the guard. Adds production ISR coverage to lock down the gating. * docs(app-router): document dynamic stale time over-gating boundary Add a comment explaining that shouldCaptureRscForCacheMetadata is computed from config before the stream runs, so a production default-config route that turns dynamic via a late dynamic API loses the advisory stale-time hint even though Next.js (!isStaticGeneration) would emit it. The value is unknowable until after headers are built. Adds a complementary test pinning down this boundary behavior. --------- Co-authored-by: James <james@eli.cx> |
||
|
|
69165d4557 |
fix(app-page-render): surface invalid dynamic usage errors via flight in dev (#1290)
Wraps the RSC response body in dev mode to check for invalid dynamic usage errors (e.g. cookies()/headers() inside "use cache") after the stream is fully consumed. This mirrors Next.js behavior where workStore.invalidDynamicUsageError is surfaced via the Flight pipeline so errors caught by user try/catch don't silently disappear. - Dedups against React's Flight error chunk using digest presence - Only applies to RSC responses (client-side navigations) in dev - HTML path intentionally deferred; error still flows through rscErrorTracker - Ported from Next.js: https://github.com/vercel/next.js/commit/f5e54c06726b571a042fce67417e40a29f6b8689 Closes #1195 |
||
|
|
b4898289e7 |
fix(app-router): match Next.js RSC Content-Type and 404 plain-text body (#1233)
* fix(app-router): match Next.js RSC Content-Type (no charset suffix)
Next.js sets the RSC (React Server Components / 'flight') response
Content-Type to exactly 'text/x-component' (RSC_CONTENT_TYPE_HEADER in
.nextjs-ref/packages/next/src/client/components/app-router-headers.ts:17,
used by FlightRenderResult in
.nextjs-ref/packages/next/src/server/app-render/flight-render-result.ts:15).
vinext was sending 'text/x-component; charset=utf-8'. The suffix breaks
Next.js's own segment-cache deduplication checks (action-handler.ts uses
.startsWith(RSC_CONTENT_TYPE_HEADER) on the unsuffixed string) and fails
Next.js e2e tests that assert strict equality, e.g.:
- test/e2e/app-dir/app/index.test.ts L362, L371
- test/e2e/app-dir/segment-cache/deployment-skew/deployment-skew.test.ts L80
Replace the duplicated literal at seven sites with the existing
VINEXT_RSC_CONTENT_TYPE constant in app-rsc-cache-busting.ts.
* fix(app-router): match Next.js 404 plain-text body 'This page could not be found'
Next.js's plain-text 404 response body is exactly
'This page could not be found' (no trailing period in the response body;
the React-rendered not-found component uses the same text with a period).
vinext was returning 'Not Found' / '404 Not Found' / '404 - Not found',
which fails Next.js e2e tests that assert .toContain('This page could
not be found'), e.g.:
- test/e2e/app-dir/app/index.test.ts (several cases)
- test/e2e/app-dir/parallel-route-not-found/parallel-route-not-found.test.ts L52
- test/e2e/app-dir/prefetching-not-found/prefetching-not-found.test.ts L16
Next.js source citations (in .nextjs-ref):
- packages/next/src/server/route-modules/pages/pages-handler.ts L121, L535
- packages/next/src/build/templates/app-route.ts L170, L349
- packages/next/src/build/templates/app-page.ts L701, L1043
- packages/next/src/pages/_error.tsx L7
- packages/next/src/client/components/builtin/not-found.tsx L7 (HTML variant)
Updates the body in notFoundResponse() and the open-redirect / pages-router
SSR-fallback / worker-entry inline 404 sites. The HTTP status reason phrase
('Not Found') is unchanged. Route-specific bodies like '404 - API route not
found' are intentionally retained.
|
||
|
|
1d01d2989a |
feat(cache): record app render observations (#1198)
* feat(cache): record app render observations App Router cache artifacts and payloads did not carry the render-observation metadata required by #726-CACHE-05/07. That left later cache-proof work without an attached record of request APIs, dynamic fetches, output scope, cache tags, and boundary outcome. The missing boundary was that render-scoped observations lived only as dynamic/cache state, not as metadata owned by each produced payload or artifact. Record render request API usage and dynamic fetch observations in request-scoped state, build redacted RenderObservation payloads for AppElements and APP_PAGE cache writes, and preserve complete observations for HTML/RSC ISR artifacts including stale regeneration. Targeted coverage asserts payload metadata, artifact metadata, redaction, and existing cache-proof behavior. * test(cache): cover search param render observation usage The CI unit shard failed because app-page-element-builder tests fully mocked the headers shim and omitted the new markRenderRequestApiUsage export. The production path now records searchParams as a render observation whenever populated search params make the page dynamic. Update the mock to expose the new shim export and assert that populated search params mark both dynamic usage and render-observation usage, while empty search params mark neither. * refactor(cache): tighten render observation review handling Render observation metadata now reuses the app page observation state shape instead of duplicating cache-local types. The deferred cache writes also document the stream-consumption boundary that preserves late request API observations. Dynamic fetch observations are now stored in a Set so repeated no-store fetches do not inflate metadata, and the collector has focused regression coverage. The Link transition test also patches and restores the runtime React default so the full unit project is not order-sensitive when other files instantiate the module graph first. * fix(cache): preserve parent render observations |
||
|
|
11cc64a3af |
fix(app-router): preserve useActionState form state after hydration (#1162)
* fix(app-router): preserve useActionState form state after hydration Progressive multipart server actions executed before the initial HTML render were dropping React form state. That meant useActionState hydrated from the JavaScript initializer instead of the server action result after a no-JS form submission. The progressive action path now decodes React form state, carries it through the App Router render pipeline, passes it to React's server and client hydration APIs, and marks form-state HTML responses as uncacheable so action results cannot be replaced by stale ISR HTML. Adds targeted unit coverage for action decoding, SSR forwarding, cache policy, cache read bypass, and a Playwright no-JS useActionState regression. * fix(app-router): keep progressive action result type internal Knip reported ProgressiveServerActionResult as an unused exported type. The type is only part of this module's implementation detail for the progressive action return path, so keep it module-local. * fix(app-router): separate progressive action cache bypass * chore(app-router): tighten form state bootstrap wiring |
||
|
|
38e6b9f6c2 |
fix(app-router): bypass app page cache in draft mode (#1123)
App Page ISR could serve a cached production response to a request carrying a valid __prerender_bypass cookie. That violates Next.js draft-mode semantics because preview users must render dynamically and must not populate shared page cache entries. The dispatch layer now identifies draft-mode requests with the same cookie parser and secret used by draftMode(), skips App Page cache reads, and passes that state into render response policy. HTML and RSC render paths now treat draft mode as no-store and avoid cache capture/write work. Tests cover valid draft cookies bypassing cached HTML, arbitrary cookie values preserving cache hits, and response-policy no-store behavior. |
||
|
|
15c8079604 |
feat(app-router): add artifact compatibility metadata (#1062)
* feat(app-router): add artifact compatibility envelope App Router payloads now carry a parsed compatibility envelope at the existing payload metadata boundary. Without this skeleton, future cache and skip work would have no typed place to record graph, deployment, schema, root-boundary, or render-epoch proof. Add a small constructor/parser pair, attach a default unknown-proof envelope to outgoing record payloads, and reject malformed envelope metadata when reading payload metadata. Tests cover explicit envelopes, legacy missing envelopes, malformed envelopes, and non-mutating payload writes. * test(app-router): cover future artifact schema rejection The review correctly identified that the envelope parser had malformed-field coverage but no direct test for the version guard path. Add the future-schema-version case and document why the Wave01 skeleton emits separate schema versions and an unknown-proof envelope without yet splitting mismatch from corruption behavior. * feat(app-router): attach artifact compatibility metadata App Router payloads previously carried only the COMPAT-01 unknown envelope unless a caller manually supplied metadata. That left COMPAT-02/03 without concrete graph, deployment, root-boundary, and fallback behavior in the PR. The render lifecycle now attaches compatibility metadata for record payloads from route/root/deployment facts, while renderEpoch remains unknown until an epoch owner exists. The compatibility evaluator returns explicit compatible, unknown, or incompatible decisions so unknown proof always falls back to render fresh instead of becoming reuse proof. Tests cover render-boundary metadata attachment and unknown graph, deployment, root-boundary, and render-epoch decisions. * fix(app-router): tolerate unused compatibility metadata parse failures App Router metadata parsing crashed when __artifactCompatibility was malformed or from a future schema, even though current render and navigation callers do not consume that field yet. The parser treated compatibility scaffolding like required route metadata. That made an unused proof capable of failing initial mount, SSR, and client navigation paths. Default invalid compatibility metadata to the unknown proof for Wave01, document the planned hard-fail point for #726-COMPAT-04/05, and cover malformed, future-version, and non-object payloads. |
||
|
|
f45fce00d5 |
fix(isr): honor route expire ceilings (#961)
* fix(isr): honor route expire ceilings Track expireAt alongside revalidateAt in the memory and KV cache handlers so ISR entries past their expire ceiling become blocking misses instead of stale responses. Plumb expireTime and request cacheLife expire values through App Router, Pages Router, prerender seeding, and cache writes while keeping generated entries as thin app-shape wiring over normal server modules. Match Next.js cache-control semantics for finite stale-while-revalidate windows when an expire value is known. * fix(isr): address cache life review follow-ups * chore: address latest review follow-ups * ci: pin vite-plus setup version * Revert "ci: pin vite-plus setup version" This reverts commit ee1d1d4e8db7b7406734414c53fa1b79f955a4d1. * fix: avoid blocking ISR page streams on cache metadata * fix: preserve headers for speculative cacheLife probes * fix: preserve prerender cacheLife metadata * fix: preserve legacy ISR cache metadata behavior * fix: preserve prerender seed revalidate context * fix: harden app page cache policy metadata * fix: resolve app router prerender conflict |
||
|
|
d45c5905f3 |
perf: fuse RSC stream tees reducing allocs from 3 to 2 (#984)
* perf: fuse RSC stream tees reducing allocs from 3 to 2 ISR cache path teed the RSC stream twice (once for capture, once for embed). Fuse into single tee: sideStream serves both embed transform and raw byte accumulation via createRscEmbedTransform. Tee 1 (capture) and Tee 2 (embed) now share one tee. Tee 3 (HTML cache) remains separate — it operates on post-SSR HTML with a conditional write decision. Fixes #981 * fix: address PR review feedback for fused RSC stream tees (#981) - Fix AppPageSsrHandler type to include sideStream and capturedRscDataRef - Clear rawChunks after getRawBuffer() to reduce peak memory usage - Remove misleading no-op null assignment in handleSsr else branch - Guard await null in ISR cache write to prevent undefined in cache * fix: repair CSP nonce handling and update snapshots for #981 - Fix renderAppPageHtmlStream to always pass ssrOptions (including scriptNonce) to handleSsr, instead of conditionally when sideStream is defined. This fixes CSP nonce regressions in dev mode. - Update 6 snapshot occurrences in entry-templates.test.ts.snap to match the updated __revalCapturedRscRef.value access pattern with null safety. Fixes CI failures for the fuse RSC stream tees PR. |
||
|
|
cf5471e95a | fix(app-router): fixing cache request leaks (#907) | ||
|
|
9791a63381 |
feat(app-router): emit per-layout flags in the RSC payload [2/6] (#839)
* feat(app-router): emit per-layout flags in the RSC payload Wire runtime layout classification through renderAppPageLifecycle and attach the resulting flags as __layoutFlags in the outgoing RSC payload via buildOutgoingAppPayload. Payload-shape helpers (withLayoutFlags, isAppElementsRecord, buildOutgoingAppPayload, AppOutgoingElements) live alongside the existing readAppElementsMetadata so the write and read boundaries sit next to each other. * fix(app-elements): widen isAppElementsRecord narrowing and drop redundant clone - isAppElementsRecord narrowed to Record<string, ReactNode>, but the outgoing payload legitimately carries heterogeneous values (ReactNode for the rendered tree plus LayoutFlags under __layoutFlags). Widen the predicate to Readonly<Record<string, unknown>> so the return type matches what the runtime check actually proves. - buildOutgoingAppPayload spread input.element into a new object before passing it to withLayoutFlags, which immediately spreads again. Drop the outer copy; withLayoutFlags already guarantees immutability. * fix(app-router): clear leaked dynamic usage on layout probe errors |
||
|
|
14ff303cbd |
feat: csp support (#795)
* fix: support csp * tweaks * tweaks * tweaks * tweaks * tweaks * tweaks * clean * tweaks * clean * fix test * tweaks * fix lock * fix test * trigger ci * tweaks * tweaks |
||
|
|
1ff166eb92 |
chore: turn on more lint rules (#714)
* chore: turn on more lint rules * fmt * add todos |
||
|
|
8e58ead4b0 |
fix: defer clearRequestContext() until HTML stream is fully consumed (#660) (#661)
* fix: defer clearRequestContext() until HTML stream is fully consumed headers()/cookies() throw on warm requests because clearRequestContext() was called synchronously after receiving the RSC/SSR stream handle, before any Server Components had actually executed. The RSC/SSR pipeline is lazy — components run while the response body is pulled by the HTTP layer. Fix: pipe the HTML stream through a TransformStream whose flush() callback calls clearRequestContext(), deferring cleanup until the last byte is consumed. Eagerly-read values (draftCookie, consumeDynamicUsage, etc.) are still captured synchronously before the stream is returned. Two sites fixed: - server/app-page-render.ts (renderAppPageLifecycle, covers both the plain and ISR-cacheable HTML paths) - server/app-page-stream.ts (renderAppPageHtmlResponse) Regression tests added for both sites. Fixes #660 * fix: guard against stream-cancelled crashes in tick-buffered transform and early-disconnect cleanup - createTickBufferedTransform: wrap the setTimeout callback in try/catch so that a stream cancellation between scheduling and firing (e.g. a health-check that calls res.body.cancel()) does not throw ERR_INVALID_STATE and crash the Vite dev server process. - deferUntilStreamConsumed: add a cancel() handler so clearRequestContext() is still called when the HTTP consumer drops the connection before the stream is fully drained, preventing per-request context leaks on early disconnect. |
||
|
|
114ebac08d |
refactor: extract app page render lifecycle (#638)
* refactor: extract app page render lifecycle * test: refresh page render lifecycle generator assertions |