mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
7b41e950bd
* 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>
392 lines
13 KiB
TypeScript
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();
|
|
});
|
|
});
|