Files
cloudflare__vinext/tests/app-page-probe.test.ts
T
James Anderson 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>
2026-05-05 16:46:55 +00:00

392 lines
13 KiB
TypeScript

import { describe, expect, it, vi } from "vite-plus/test";
import { probeAppPageBeforeRender } from "../packages/vinext/src/server/app-page-probe.js";
// Mirrors makeThenableParams() from app-rsc-entry.ts — the function that
// converts raw null-prototype params into objects that work with both
// `await params` (Next.js 15+) and `params.id` (pre-15).
function makeThenableParams<T extends Record<string, unknown>>(obj: T): Promise<T> & T {
const plain = { ...obj } as T;
return Object.assign(Promise.resolve(plain), plain);
}
describe("app page probe helpers", () => {
it("handles layout special errors before probing the page", async () => {
const layoutError = new Error("layout failed");
const pageProbe = vi.fn(() => "page");
const renderLayoutSpecialError = vi.fn(
async () => new Response("layout-fallback", { status: 404 }),
);
const renderPageSpecialError = vi.fn();
const probedLayouts: number[] = [];
const result = await probeAppPageBeforeRender({
hasLoadingBoundary: false,
layoutCount: 3,
probeLayoutAt(layoutIndex) {
probedLayouts.push(layoutIndex);
if (layoutIndex === 1) {
throw layoutError;
}
return null;
},
probePage: pageProbe,
renderLayoutSpecialError,
renderPageSpecialError,
resolveSpecialError(error) {
return error === layoutError
? {
kind: "http-access-fallback",
statusCode: 404,
}
: null;
},
runWithSuppressedHookWarning(probe) {
return probe();
},
});
expect(probedLayouts).toEqual([2, 1]);
expect(pageProbe).not.toHaveBeenCalled();
expect(renderLayoutSpecialError).toHaveBeenCalledWith(
{
kind: "http-access-fallback",
statusCode: 404,
},
1,
);
expect(renderPageSpecialError).not.toHaveBeenCalled();
expect(result.response?.status).toBe(404);
await expect(result.response?.text()).resolves.toBe("layout-fallback");
});
it("falls through to the page probe when layout failures are not special", async () => {
const layoutError = new Error("ordinary layout failure");
const pageProbe = vi.fn(() => null);
const renderLayoutSpecialError = vi.fn();
const result = await probeAppPageBeforeRender({
hasLoadingBoundary: false,
layoutCount: 2,
probeLayoutAt(layoutIndex) {
if (layoutIndex === 1) {
throw layoutError;
}
return null;
},
probePage: pageProbe,
renderLayoutSpecialError,
renderPageSpecialError() {
throw new Error("should not render a page special error");
},
resolveSpecialError() {
return null;
},
runWithSuppressedHookWarning(probe) {
return probe();
},
});
expect(result.response).toBeNull();
expect(pageProbe).toHaveBeenCalledTimes(1);
expect(renderLayoutSpecialError).not.toHaveBeenCalled();
});
it("turns special page probe failures into immediate responses", async () => {
const pageError = new Error("page failed");
const renderPageSpecialError = vi.fn(
async () => new Response("page-fallback", { status: 307 }),
);
const result = await probeAppPageBeforeRender({
hasLoadingBoundary: false,
layoutCount: 0,
probeLayoutAt() {
throw new Error("should not probe layouts");
},
probePage() {
return Promise.reject(pageError);
},
renderLayoutSpecialError() {
throw new Error("should not render a layout special error");
},
renderPageSpecialError,
resolveSpecialError(error) {
return error === pageError
? {
kind: "redirect",
location: "/target",
statusCode: 307,
}
: null;
},
runWithSuppressedHookWarning(probe) {
return probe();
},
});
expect(renderPageSpecialError).toHaveBeenCalledWith({
kind: "redirect",
location: "/target",
statusCode: 307,
});
expect(result.response?.status).toBe(307);
await expect(result.response?.text()).resolves.toBe("page-fallback");
});
it("propagates layoutFlags from layout probe result", async () => {
const pageProbe = vi.fn(() => null);
const result = await probeAppPageBeforeRender({
hasLoadingBoundary: false,
layoutCount: 2,
probeLayoutAt() {
return null;
},
probePage: pageProbe,
renderLayoutSpecialError() {
throw new Error("should not render a layout special error");
},
renderPageSpecialError() {
throw new Error("should not render a page special error");
},
resolveSpecialError() {
return null;
},
runWithSuppressedHookWarning(probe) {
return probe();
},
classification: {
buildTimeClassifications: new Map([
[0, "static"],
[1, "dynamic"],
]),
getLayoutId(layoutIndex) {
return ["layout:/", "layout:/admin"][layoutIndex];
},
runWithIsolatedDynamicScope(fn) {
return Promise.resolve({ result: fn(), dynamicDetected: false });
},
},
});
expect(result.response).toBeNull();
expect(result.layoutFlags).toEqual({
"layout:/": "s",
"layout:/admin": "d",
});
});
it("still handles special errors with classification enabled", async () => {
const layoutError = new Error("layout failed");
const result = await probeAppPageBeforeRender({
hasLoadingBoundary: false,
layoutCount: 2,
probeLayoutAt(layoutIndex) {
if (layoutIndex === 1) {
throw layoutError;
}
return null;
},
probePage() {
throw new Error("should not probe page");
},
renderLayoutSpecialError: vi.fn(async () => new Response("layout-fallback", { status: 404 })),
renderPageSpecialError() {
throw new Error("should not render a page special error");
},
resolveSpecialError(error) {
return error === layoutError ? { kind: "http-access-fallback", statusCode: 404 } : null;
},
runWithSuppressedHookWarning(probe) {
return probe();
},
classification: {
getLayoutId(layoutIndex) {
return ["layout:/", "layout:/admin"][layoutIndex];
},
runWithIsolatedDynamicScope(fn) {
return Promise.resolve({ result: fn(), dynamicDetected: false });
},
},
});
// Special error response should still be returned
expect(result.response?.status).toBe(404);
});
// ── Regression: probePage must receive thenable params/searchParams ──
// probePage() in the generated entry was passing raw null-prototype params
// (from trieMatch) instead of thenable params. Pages using `await params`
// (Next.js 15+ pattern) threw TypeError during probe, causing the probe to
// silently swallow the error instead of detecting notFound()/redirect().
it("detects notFound() from an async-params page when params are thenable", async () => {
const NOT_FOUND_ERROR = new Error("NEXT_NOT_FOUND");
const params = Object.create(null);
params.id = "invalid";
// Simulates a page that does `const { id } = await params; notFound()`
async function AsyncParamsPage(props: { params: Promise<{ id: string }> }) {
const { id } = await props.params;
if (id === "invalid") throw NOT_FOUND_ERROR;
return null;
}
const renderPageSpecialError = vi.fn(
async () => new Response("not-found-fallback", { status: 404 }),
);
// With thenable params, the probe should catch notFound()
const result = await probeAppPageBeforeRender({
hasLoadingBoundary: false,
layoutCount: 0,
probeLayoutAt() {
return null;
},
probePage() {
return AsyncParamsPage({ params: makeThenableParams(params) });
},
renderLayoutSpecialError() {
throw new Error("unreachable");
},
renderPageSpecialError,
resolveSpecialError(error) {
return error === NOT_FOUND_ERROR ? { kind: "http-access-fallback", statusCode: 404 } : null;
},
runWithSuppressedHookWarning(probe) {
return probe();
},
});
expect(renderPageSpecialError).toHaveBeenCalledOnce();
expect(result.response?.status).toBe(404);
});
it("detects redirect() from an async-searchParams page when searchParams are thenable", async () => {
const REDIRECT_ERROR = new Error("NEXT_REDIRECT");
// Simulates a page that does `const { dest } = await searchParams; redirect(dest)`
async function AsyncSearchPage(props: {
params: Promise<Record<string, unknown>>;
searchParams: Promise<{ dest?: string }>;
}) {
const { dest } = await props.searchParams;
if (dest) throw REDIRECT_ERROR;
return null;
}
const renderPageSpecialError = vi.fn(
async () => new Response(null, { status: 307, headers: { location: "/about" } }),
);
const result = await probeAppPageBeforeRender({
hasLoadingBoundary: false,
layoutCount: 0,
probeLayoutAt() {
return null;
},
probePage() {
return AsyncSearchPage({
params: makeThenableParams({}),
searchParams: makeThenableParams({ dest: "/about" }),
});
},
renderLayoutSpecialError() {
throw new Error("unreachable");
},
renderPageSpecialError,
resolveSpecialError(error) {
return error === REDIRECT_ERROR
? { kind: "redirect", location: "/about", statusCode: 307 }
: null;
},
runWithSuppressedHookWarning(probe) {
return probe();
},
});
expect(renderPageSpecialError).toHaveBeenCalledOnce();
expect(result.response?.status).toBe(307);
});
it("probe silently fails when searchParams is omitted and page awaits it", async () => {
const REDIRECT_ERROR = new Error("NEXT_REDIRECT");
// When the old probePage() omitted searchParams, the component received
// undefined for that prop. `await undefined` produces undefined, then
// destructuring undefined throws TypeError. The probe catches it but
// doesn't recognize it as a special error, so it returns null.
const renderPageSpecialError = vi.fn(async () => new Response(null, { status: 307 }));
const result = await probeAppPageBeforeRender({
hasLoadingBoundary: false,
layoutCount: 0,
probeLayoutAt() {
return null;
},
probePage() {
// Simulate what happens at runtime when searchParams is not passed:
// the page component receives no searchParams prop, then tries to
// destructure it after await. This throws TypeError.
return Promise.resolve().then(() => {
throw new TypeError("Cannot destructure property 'dest' of undefined");
});
},
renderLayoutSpecialError() {
throw new Error("unreachable");
},
renderPageSpecialError,
resolveSpecialError(error) {
return error === REDIRECT_ERROR
? { kind: "redirect", location: "/about", statusCode: 307 }
: null;
},
runWithSuppressedHookWarning(probe) {
return probe();
},
});
// The probe catches the TypeError but resolveSpecialError returns null
// for it (TypeError is not a special error) so the probe returns null.
// The redirect is never detected early.
expect(result.response).toBeNull();
expect(renderPageSpecialError).not.toHaveBeenCalled();
});
it("skips the page probe when a loading boundary is present (special errors handled post-shell)", async () => {
// With a route-level loading.tsx Suspense boundary, the probe can't
// catch a redirect()/notFound() thrown by the page without serializing
// on the page promise — which would defeat loading.tsx's whole point.
// Recovery instead happens later in renderAppPageLifecycle: the
// rscErrorTracker captures the digest from React's onError, and a short
// race window after shell-ready swaps the response to a 307/404 before
// bytes are flushed.
const probePage = vi.fn(() => new Promise<void>(() => {}));
const renderPageSpecialError = vi.fn();
const result = await probeAppPageBeforeRender({
hasLoadingBoundary: true,
layoutCount: 0,
probeLayoutAt() {
throw new Error("should not probe layouts");
},
probePage,
renderLayoutSpecialError() {
throw new Error("should not render a layout special error");
},
renderPageSpecialError,
resolveSpecialError() {
throw new Error("should not be reached when the page probe is skipped");
},
runWithSuppressedHookWarning(probe) {
return probe();
},
});
expect(probePage).not.toHaveBeenCalled();
expect(renderPageSpecialError).not.toHaveBeenCalled();
expect(result.response).toBeNull();
});
});