mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
vinext@0.1.8
10 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
826ae4cc7c | perf(app-router): skip speculative page probes for HTML renders (#2226) | ||
|
|
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
|
||
|
|
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> |
||
|
|
a35e43cef3 |
feat(skip): add layout safety observation foundations (#1672)
* feat(skip): add layout safety observation foundations Introduce the per-layout observation primitives that later slices need to decide whether retained static layouts can be reused. This slice adds the machinery only; nothing reads the observations yet, so behavior is unchanged. - app-layout-param-observation: per-layout completeness, param keys, structural scope, finite revalidate, request API, cacheLife, cache tag, cacheable fetch, dynamic fetch, and unstable_cache observation tracker. - app-page-probe: bounded React server subtree probe that walks layout-returned children including memo, forwardRef, and React.lazy wrappers, enforces depth and node limits, and refuses single-use iterable children. - shims: thenable params observer for sync, await, destructure, and enumeration. Cache, fetch, and unified request context record unstable_cache, cacheable fetch, and request-scoped observation state. - app-page-params: structural segment-param scope so empty optional catch-all targets are not treated as param-free downstream. No skip transport behavior. Later slices wire the planner, manifest, and encoder onto these observations. * fix(skip): address review feedback on observation foundations - Add currentFetchSoftTags reset in runWithIsolatedProbeDependencies to prevent soft tag leak from parent scope (matches runWithFetchCache isolation) - Replace unnecessary JSON.stringify in cacheLife check with direct null comparison - Add comment documenting intentional omission of markProbeComplete on probe errors (keeps completeness as unknown, safe fallback to render-and-send) * fix(skip): record cacheable-fetch observation synchronously before first await The patched fetch deferred observation and tag collection past await buildFetchCacheKey(). If a layout probe started a cacheable fetch and returned synchronously without awaiting it, the probe snapshot would see cacheableFetchCount: 0 -- a false proof that could let the skip planner reuse a stale retained layout. Move observation and tag recording before the first await in the cacheable branch. If key generation later fails and the fetch falls back to dynamic, recording both is conservative (false unsafe costs performance, not correctness). Add an integration test that exercises the real patched fetch shim without mocks to verify synchronous observation capture. * fix(test): remove polluting fetch restore and catch background promise Do not restore globalThis.fetch in test cleanup. ensureFetchPatch sets a global _PATCH_KEY guard; restoring fetch without clearing that marker makes later calls skip reinstallation silently. Catch the background fetch promise inside the probe so the patched fetch continuation (cache lookup, network fetch) cannot produce an unhandled rejection after the synchronous assertions complete. * chore: empty commit to trigger CI * chore: empty commit to trigger CI |
||
|
|
5d5fe32a65 |
fix(app-router): forward searchParams correctly in probePage() (#1238)
The RSC entry's probePage() body read a non-existent key off collectAppPageSearchParams's return value, so the page component received undefined for searchParams. Any page that did `await searchParams` during probing threw TypeError, the probe swallowed it as a non-special error, and redirect()/notFound() calls were dropped from the probe path. The visible render path was unaffected because app-page-element-builder reads the correct key independently. Per AGENTS.md, the generated entry should be thin codegen glue. Move the probePage() body into a typed probeAppPage() helper in server/app-page-probe.ts and have the entry delegate to it, so the search-params wiring is unit-testable in isolation. Add focused tests covering thenable params, empty-query handling, the null page-component case, and redirect-throw propagation. Fixes https://github.com/cloudflare/vinext/issues/1235 |
||
|
|
7b41e950bd |
fix(app-page): align redirect()/notFound() handling under loading.tsx with Next.js (#1057)
* fix(app-page-probe): always await page promise so redirect()/notFound() under loading.tsx return 307/404 The probe used to fire-and-forget the page promise when a route-level loading.tsx was present (`awaitAsyncResult: !hasLoadingBoundary`). For async pages that throw `redirect()` or `notFound()`, that swallowed the rejection during probe; the RSC stream then re-ran the page under the route Suspense boundary, where React converted the throw into a "Switched to client rendering" error serialized into a 200 HTML body instead of a clean 307/404. Awaiting the page in the probe surfaces the special error early, parity with the no-loading-boundary path. The cost is that the outer page function runs serially with the RSC render rather than in parallel — in practice most async work in `loading.tsx`-shielded routes happens in nested Suspense children, not in the outer page function body. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor: capture special-error digests post-render instead of awaiting probe Replaces the previous "always await page in probe" approach with a post-render swap that mirrors Next.js's mechanism in app-render.tsx: the page render's onError captures NEXT_REDIRECT / NEXT_HTTP_ERROR_FALLBACK digests; after the HTML stream drains, the lifecycle inspects the tracker and swaps a 307/404 in place of the streamed "Switched to client rendering" body. This avoids the 2× page invocation cost of awaiting in the probe (the previous fix called the page once during probe and again during RSC render, serially). Now the page runs once, inside the RSC render, and the route-level Suspense boundary (loading.tsx) traps the throw as designed. The buffered stream still contains the same final HTML for non-redirecting pages. Trade-off matches Next.js: loading.tsx fallback visibility is sacrificed for routes that redirect (you cannot both flush the loading state and reverse course to a 307). Non-redirecting routes with loading.tsx now buffer the full response rather than streaming progressively — same trade-off Next.js makes with its prerender-based pipeline. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Revert "refactor: capture special-error digests post-render instead of awaiting probe" This reverts commit 179b20f88033f3731677a91faeb022389247223e. * refactor: align with Next.js — skip page probe + post-shell race for special errors Replaces the always-await fix with a Next.js-aligned pipeline: - Skip the page probe entirely when a route-level loading.tsx is present. The page now runs once, inside the RSC render, instead of twice serially. - Capture NEXT_REDIRECT / NEXT_HTTP_ERROR_FALLBACK digests in the rscErrorTracker via React's onError callback (previously dropped). - After the SSR shell is ready but before bytes are flushed, race the captured digest against a 50 ms swap window. A digest that fires within the window — sync throws and short-async like ~10 ms auth checks — is converted to a 307/404. Late rejections fall through to the streamed body, matching Next.js's "until-first-byte-is-flushed" semantic. Trade-offs vs. the previous always-await fix: - Page invoked once instead of twice (no more 2× latency for async outer page functions). - TTFB for loading.tsx routes goes from ~outer-page-time to ~50 ms. - loading.tsx fallback is visible immediately after the swap window closes — the OpenNext-compat E2E that broke under the buffer-everything approach passes. Slow async redirects (>50 ms) still leak as serialized stream errors. Next.js handles that case by injecting <meta http-equiv="refresh"> mid-stream; doing the same in vinext is a separate, larger change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor: drop 50ms swap-window — deterministic post-shell digest check Removes the timer-race hack and replaces it with the same shape Next.js uses (`app-render.tsx:4293`): after the SSR shell promise resolves, inspect the rscErrorTracker for a captured NEXT_REDIRECT/NEXT_HTTP_ERROR_FALLBACK digest and swap the response to a 307/404 if one is present. Why this works without a timer: the SSR pipeline naturally runs many microtask awaits between the page Promise rejecting (in onError) and the lifecycle reading the tracker. Throws that resolve in microtasks during shell rendering are deterministically captured. Throws that require macrotask boundaries (real I/O, setTimeout) are NOT caught and fall through to the streamed body — exactly the same trade-off Next.js makes (the digest survives in the Flight payload for the client router to consume). Updates the regression fixture to use the deterministic case (sync throw inside an async page) instead of the pre-fix repro's setTimeout trick. The setTimeout case can't be deterministically caught without either a timer (rejected as a hack) or React-internals patches. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: regression for permanentRedirect() under loading.tsx returning 308 Adds a fixture and integration test mirroring the redirect()-with- loading.tsx case but using permanentRedirect (308). Confirms the post-shell digest swap honors the statusCode field from the NEXT_REDIRECT digest rather than coercing to the 307 default — closes a flagged gap from the Next.js parity audit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: regression for forbidden()/unauthorized() under loading.tsx Adds fixtures and integration tests verifying the post-shell digest swap correctly preserves the status code from NEXT_HTTP_ERROR_FALLBACK digests: - forbidden() under loading.tsx → 403 with root forbidden.tsx body - unauthorized() under loading.tsx → 401 with root unauthorized.tsx body Confirms resolveAppPageSpecialError reads `parseInt(parts[1], 10)` from the digest rather than coercing to 404 — closes the parity-audit's "forbidden/unauthorized status coercion" gap (which turned out to already be handled correctly; this just locks it in). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(app-page): apply basePath to redirect Location header Mirrors Next.js's `addPathPrefix(getURLFromRedirectError(err), basePath)` in app-render.tsx — `redirect("/about")` from a page mounted under basePath "/blog" now produces `Location: /blog/about` instead of the raw "/about". - Adds `basePath` to BuildAppPageSpecialErrorResponseOptions and a small helper that resolves the redirect target against the request URL and prefixes app-internal absolute paths. - External redirects (different origin) and targets that already start with the basePath are passed through unchanged. - Threads the entry-generated `__basePath` through dispatch and into both the page and layout special-error response builders. - Closes a parity gap flagged in the Next.js audit; covered by 5 new unit assertions exercising internal / external / already-prefixed / unconfigured / root-target cases. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(app-page): preserve cookies().set() on redirect responses Mirrors Next.js's `appendMutableCookies(headers, requestStore.mutableCookies)` in app-render.tsx. Auth flows that do cookies().set("session", "..."); redirect("/dashboard"); must preserve the Set-Cookie on the 307, otherwise the redirected request lands without the just-issued session and the user bounces back to login. - Adds an optional `getAndClearPendingCookies` to BuildAppPageSpecialErrorResponseOptions that, when set, drains pending Set-Cookie values accumulated during the render and appends them to the redirect response. - Wires `getAndClearPendingCookies` from `vinext/shims/headers` into both the page and layout special-error response builders in dispatch. - Only applied to redirect responses to match Next.js — http-access-fallback responses leave cookies to the rendered boundary. - Closes a parity gap from the audit; covered by a new unit test exercising redirect-with-cookies, redirect-without-cookies, and the http-access-fallback-cookies-not-bled cases. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: regression for notFound() under loading.tsx Adds a fixture and integration test covering the most common loading-boundary special-error case in real apps: a dynamic detail page with a loading state that calls notFound() when the record is missing. Distinct from the forbidden()/unauthorized() coverage — notFound() throws the bare "NEXT_NOT_FOUND" digest (not "NEXT_HTTP_ERROR_FALLBACK;404"), which takes a separate branch in resolveAppPageSpecialError. This locks in that the post-shell digest swap handles both branches. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
18237955fa |
feat: static/dynamic layout detection for skip-header optimization (#767)
* feat: add classifyLayoutSegmentConfig for layout segment config detection
Reads `export const dynamic` and `export const revalidate` from layout
source files to classify them as static or dynamic. Unlike page
classification, positive revalidate values return null (ISR is a page
concept), deferring to module graph analysis for layout skip decisions.
* feat: add module graph layout classification (Layer 2)
BFS traversal of each layout's dependency tree via Vite's module graph.
If no transitive dynamic shim import (headers, cache, server) is found,
the layout is provably static. Otherwise it needs a runtime probe.
classifyAllRouteLayouts combines Layer 1 (segment config, from prior
commit) with Layer 2 (module graph), deduplicating shared layouts.
* feat: per-layout dynamic detection in probe phase (Layer 3)
Extends probeAppPageLayouts to return per-layout flags ("s"/"d")
alongside the existing Response. Three paths per layout:
- Build-time classified: pass flag through, still probe for errors
- Needs probe: run with isolated dynamic scope, detect usage
- No classification: original behavior (backward compat)
probeAppPageBeforeRender propagates layoutFlags through the result.
renderAppPageLifecycle updated to destructure the new return type.
* feat: add __layoutFlags payload metadata and thread through router state
Adds APP_LAYOUT_FLAGS_KEY to the RSC payload metadata, carrying
per-layout static/dynamic flags ("s"/"d"). readAppElementsMetadata
now parses layoutFlags with a type predicate guard.
AppRouterState and AppRouterAction carry layoutFlags. Navigate merges
flags (preserving previously-seen layouts), replace replaces them.
All dispatchBrowserTree call sites updated to pass layoutFlags.
* refactor: group classification options into single LayoutClassificationOptions type
The three optional fields (buildTimeClassifications, getLayoutId,
runWithIsolatedDynamicScope) had an all-or-nothing invariant enforced
only at runtime. Grouping them into a single optional `classification`
object makes the constraint type-safe — you either provide the full
classification context or nothing.
Also deduplicates the LayoutFlags type: canonical definition lives in
app-elements.ts, re-exported from app-page-execution.ts.
* fix: default to dynamic flag when layout probe throws non-special error
When runWithIsolatedDynamicScope throws and the error is non-special
(onLayoutError returns null), the layout was silently omitted from
layoutFlags. Now conservatively defaults to "d" — if probing failed,
the layout cannot be proven static.
* refactor: replace parallel arrays with LayoutEntry struct, tighten names
- Introduce LayoutEntry (moduleId, treePosition, segmentConfig) in
layout-classification.ts, collapsing the three parallel arrays
(layouts/layoutTreePositions/layoutSegmentConfigs) in
RouteForClassification into a single struct. Switches the inner loop
to for...of, removing the ?? 0 fallback that masked index mismatches.
- Update all five classifyAllRouteLayouts test fixtures to the struct shape.
- Add a JSDoc on LayoutFlags clarifying that "s" = static, "d" = dynamic.
- Tighten the buildTimeClassifications comment to say "keyed by layout index".
https://claude.ai/code/session_01MDJzCNao3dX9tmmVtSqsw5
* style: fix prettier formatting
https://claude.ai/code/session_01MDJzCNao3dX9tmmVtSqsw5
* Revert "style: fix prettier formatting"
This reverts commit
|
||
|
|
301b8cb8c8 |
fix: pass thenable params and searchParams to probePage() (#763)
probePage() passed raw null-prototype params instead of thenable params, causing TypeError for any page using the Next.js 15+ async params pattern (await params). The probe also omitted searchParams entirely. Both issues caused the probe to silently fail, defeating its purpose of early notFound() and redirect() detection. The layout probe already used the correct thenable params — the page probe was the only inconsistent path. |
||
|
|
1ff166eb92 |
chore: turn on more lint rules (#714)
* chore: turn on more lint rules * fmt * add todos |
||
|
|
42fa020ce7 |
refactor: extract app page probe runtime (#635)
* refactor: extract app page probe runtime * fix: handle SSR special errors in app page render |