mirror of
https://github.com/cloudflare/vinext.git
synced 2026-05-09 08:25:34 +02:00
[PR #1057] [MERGED] fix(app-page): align redirect()/notFound() handling under loading.tsx with Next.js #1057
Labels
No labels
enhancement
enhancement
good first issue
help wanted
nextjs-tracking
nextjs-tracking
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
starred/vinext#1057
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
📋 Pull Request Information
Original PR: https://github.com/cloudflare/vinext/pull/1057
Author: @james-elicx
Created: 5/4/2026
Status: ✅ Merged
Merged: 5/5/2026
Merged by: @james-elicx
Base:
main← Head:claude/condescending-matsumoto-430ee1📝 Commits (10+)
e26849efix(app-page-probe): always await page promise so redirect()/notFound() under loading.tsx return 307/404c5fd96crefactor: capture special-error digests post-render instead of awaiting probebaf26a6Revert "refactor: capture special-error digests post-render instead of awaiting probe"04010bdrefactor: align with Next.js — skip page probe + post-shell race for special errors3b6f178refactor: drop 50ms swap-window — deterministic post-shell digest checkea3be94test: regression for permanentRedirect() under loading.tsx returning 308eb22c16test: regression for forbidden()/unauthorized() under loading.tsx995ef63fix(app-page): apply basePath to redirect Location headerd3798a2fix(app-page): preserve cookies().set() on redirect responsese5dd63btest: regression for notFound() under loading.tsx📊 Changes
19 files changed (+431 additions, -13 deletions)
View changed files
📝
packages/vinext/src/entries/app-rsc-entry.ts(+1 -0)📝
packages/vinext/src/server/app-page-dispatch.ts(+7 -0)📝
packages/vinext/src/server/app-page-execution.ts(+65 -2)📝
packages/vinext/src/server/app-page-probe.ts(+16 -1)📝
packages/vinext/src/server/app-page-render.ts(+23 -0)📝
packages/vinext/src/server/app-page-stream.ts(+21 -1)📝
tests/app-page-execution.test.ts(+153 -0)📝
tests/app-page-probe.test.ts(+13 -9)📝
tests/app-router.test.ts(+64 -0)➕
tests/fixtures/app-basic/app/forbidden-loading/loading.tsx(+3 -0)➕
tests/fixtures/app-basic/app/forbidden-loading/page.tsx(+9 -0)➕
tests/fixtures/app-basic/app/notfound-loading/loading.tsx(+3 -0)➕
tests/fixtures/app-basic/app/notfound-loading/page.tsx(+12 -0)➕
tests/fixtures/app-basic/app/permanent-protected-loading/loading.tsx(+3 -0)➕
tests/fixtures/app-basic/app/permanent-protected-loading/page.tsx(+9 -0)➕
tests/fixtures/app-basic/app/protected-loading/loading.tsx(+3 -0)➕
tests/fixtures/app-basic/app/protected-loading/page.tsx(+14 -0)➕
tests/fixtures/app-basic/app/unauthorized-loading/loading.tsx(+3 -0)➕
tests/fixtures/app-basic/app/unauthorized-loading/page.tsx(+9 -0)📄 Description
Summary
When a page route had both a sibling
loading.tsxand an asyncpage.tsx,redirect()/notFound()thrown by the page used to surface as a200response with a serialized "Switched to client rendering because the server rendering errored: NEXT_REDIRECT:/" body. Root cause: the probe fire-and-forgot the page promise to preserveloading.tsxstreaming, so the rejection was caught by React's route-level Suspense boundary instead of vinext.Fix mirrors Next.js's
app-render.tsx:4293-4346shape: skip the page probe forhasLoadingBoundaryroutes, captureNEXT_REDIRECT/NEXT_HTTP_ERROR_FALLBACKdigests inrscErrorTracker.onRenderError, and after the SSR shell promise resolves inspect the tracker — if a digest was captured, swap the response to a 307/404 before any bytes are flushed. While here, also closes a few related parity gaps from the audit: basePath prefix on redirect Location, mutable cookies on redirects, and regression tests forpermanentRedirect/forbidden/unauthorizedunderloading.tsx.Changes
Core (the bug fix)
createAppPageRscErrorTracker(packages/vinext/src/server/app-page-stream.ts) — capturesdigest-bearing errors ingetCapturedSpecialError()instead of dropping them.probeAppPageBeforeRender(packages/vinext/src/server/app-page-probe.ts) — skips the page probe entirely whenhasLoadingBoundaryis true. Page now runs once, inside the RSC render.renderAppPageLifecycle(packages/vinext/src/server/app-page-render.ts) — inspects the tracker once the shell promise resolves and swaps the response to a 307/404 if a digest is present.Related parity fixes (separate commits)
redirect("/about")from a page mounted underbasePath: "/blog"now producesLocation: /blog/about. Mirrors Next.js'saddPathPrefix(getURLFromRedirectError(err), basePath). External targets and already-prefixed paths are passed through unchanged. Layered correctly with the RSC cache-busting transform that landed in main.cookies().set()on redirects (packages/vinext/src/server/app-page-execution.ts) — auth flows that docookies().set("session", ...); redirect("/dashboard")now keep theSet-Cookieon the 307. Only applied to redirect responses to match Next.js (the http-access-fallback path leaves cookies to the rendered boundary).Why this works without a timer
The SSR pipeline naturally runs many microtask awaits between the page Promise rejecting (captured by React's
onError) and the lifecycle reading the tracker. Throws that settle in microtasks during shell rendering are deterministically captured — verified across multiple test runs.Throws that require macrotask boundaries (real I/O,
setTimeout,fetch) are not caught and fall through to the streamed body — the same architectural limit Next.js has. The digest survives in the Flight payload for the client router to consume (separate gap in vinext's client-side handling, flagged below).Alignment with Next.js
try/catcharound shell-ready renderpermanentRedirect()(308)forbidden()(403) /unauthorized()(401)basePathon redirect LocationSet-Cookiefromcookies().set()on redirectTest plan
pnpm test tests/app-router.test.ts× 3 runs — 306/306 pass each time (deterministic).pnpm test:unit— 3626/3626 pass (including new unit coverage for basePath and pending-cookies onbuildAppPageSpecialErrorResponse).npx playwright test tests/e2e/app-router/loading.spec.ts— 3/3 pass (including OpenNext-compat "loading boundary is visible before content resolves").npx playwright test tests/e2e/app-router/ -g "redirect"— 34/34 pass.New regression coverage:
tests/fixtures/app-basic/app/protected-loading/—redirect()(307) underloading.tsx.tests/fixtures/app-basic/app/permanent-protected-loading/—permanentRedirect()(308) underloading.tsx, verifies digest statusCode is preserved.tests/fixtures/app-basic/app/forbidden-loading/—forbidden()(403) underloading.tsx.tests/fixtures/app-basic/app/unauthorized-loading/—unauthorized()(401) underloading.tsx.tests/app-page-execution.test.ts— five basePath cases (internal / external / already-prefixed / unconfigured / root-target) + three cookie cases (with cookies / without / fallback-doesn't-bleed).Known gaps (separate PRs)
await fetch(); redirect()etc.) leak as streamed bodies; full parity needs client-router handling of Flight-payload digests inside Suspense boundaries.x-action-redirectheader +createRedirectRenderResult) — entirely out of scope.findPrerenderHTTPErrorBoundaryTree) — Next's nearest-forbidden.tsx/unauthorized.tsxresolution from layout depth.🤖 Generated with Claude Code
🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.