Files
cloudflare__vinext/tests/app-page-boundary-render.test.ts
T
James Anderson 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.
2026-03-23 08:41:04 +00:00

231 lines
6.7 KiB
TypeScript

import { describe, expect, it, vi } from "vite-plus/test";
import React from "react";
import ReactDOMServer from "react-dom/server";
import {
renderAppPageErrorBoundary,
renderAppPageHttpAccessFallback,
} from "../packages/vinext/src/server/app-page-boundary-render.js";
function createStreamFromMarkup(markup: string): ReadableStream<Uint8Array> {
return new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(markup));
controller.close();
},
});
}
function renderElementToStream(element: React.ReactNode): ReadableStream<Uint8Array> {
return createStreamFromMarkup(ReactDOMServer.renderToStaticMarkup(element));
}
function createCommonOptions() {
const clearRequestContext = vi.fn();
const loadSsrHandler = vi.fn(async () => ({
async handleSsr(rscStream: ReadableStream<Uint8Array>) {
return rscStream;
},
}));
return {
buildFontLinkHeader(preloads: readonly { href: string; type: string }[] | null | undefined) {
if (!preloads || preloads.length === 0) {
return "";
}
return preloads.map((preload) => `<${preload.href}>; rel=preload`).join(", ");
},
clearRequestContext,
createRscOnErrorHandler() {
return () => null;
},
getFontLinks() {
return ["/styles.css"];
},
getFontPreloads() {
return [{ href: "/font.woff2", type: "font/woff2" }];
},
getFontStyles() {
return [".font { font-family: Test; }"];
},
getNavigationContext() {
return { pathname: "/posts/missing" };
},
isRscRequest: false,
loadSsrHandler,
makeThenableParams<T>(params: T) {
return params;
},
renderToReadableStream: renderElementToStream,
requestUrl: "https://example.com/posts/missing",
resolveChildSegments() {
return [];
},
rootLayouts: [],
};
}
function RootLayout({
children,
params,
}: {
children: React.ReactNode;
params: { slug?: string };
}) {
return React.createElement("div", { "data-layout": "root", "data-slug": params.slug }, children);
}
function LeafLayout({
children,
params,
}: {
children: React.ReactNode;
params: { slug?: string };
}) {
return React.createElement(
"section",
{ "data-layout": "leaf", "data-slug": params.slug },
children,
);
}
function NotFoundBoundary() {
return React.createElement("p", { "data-boundary": "not-found" }, "Missing page");
}
function RouteErrorBoundary({ error }: { error: Error }) {
return React.createElement("p", { "data-boundary": "route-error" }, `route:${error.message}`);
}
function GlobalErrorBoundary({ error }: { error: Error }) {
return React.createElement("p", { "data-boundary": "global-error" }, `global:${error.message}`);
}
const rootLayoutModule = {
default: RootLayout as React.ComponentType<any>,
metadata: { description: "Root layout description" },
viewport: { themeColor: "#111111" },
};
const leafLayoutModule = {
default: LeafLayout as React.ComponentType<any>,
};
const notFoundModule = {
default: NotFoundBoundary as React.ComponentType<any>,
};
const routeErrorModule = {
default: RouteErrorBoundary as React.ComponentType<any>,
};
const globalErrorModule = {
default: GlobalErrorBoundary as React.ComponentType<any>,
};
describe("app page boundary render helpers", () => {
it("returns null when no HTTP access fallback boundary exists", async () => {
const common = createCommonOptions();
const response = await renderAppPageHttpAccessFallback({
...common,
matchedParams: { slug: "missing" },
route: {
pattern: "/posts/[slug]",
params: { slug: "missing" },
},
statusCode: 404,
});
expect(response).toBeNull();
expect(common.loadSsrHandler).not.toHaveBeenCalled();
expect(common.clearRequestContext).not.toHaveBeenCalled();
});
it("renders HTTP access fallbacks with layout metadata and wrapped HTML", async () => {
const common = createCommonOptions();
const response = await renderAppPageHttpAccessFallback({
...common,
matchedParams: { slug: "missing" },
rootLayouts: [rootLayoutModule],
route: {
layoutTreePositions: [0, 1],
layouts: [rootLayoutModule, leafLayoutModule],
notFound: notFoundModule,
params: { slug: "missing" },
pattern: "/posts/[slug]",
routeSegments: ["posts", "[slug]"],
},
statusCode: 404,
});
expect(common.loadSsrHandler).toHaveBeenCalledTimes(1);
expect(response?.status).toBe(404);
expect(response?.headers.get("link")).toContain("/font.woff2");
const html = await response?.text();
expect(common.clearRequestContext).toHaveBeenCalledTimes(1);
expect(html).toContain('data-layout="root"');
expect(html).toContain('data-layout="leaf"');
expect(html).toContain('data-boundary="not-found"');
expect(html).toContain('content="Root layout description"');
expect(html).toContain('name="viewport"');
expect(html).toContain('name="theme-color" content="#111111"');
expect(html).toContain('name="robots"');
expect(html).toContain('content="noindex"');
});
it("renders route error boundaries with sanitized errors inside layouts", async () => {
const common = createCommonOptions();
const sanitizeErrorForClient = vi.fn((error: Error) => new Error(`safe:${error.message}`));
const response = await renderAppPageErrorBoundary({
...common,
error: new Error("secret"),
matchedParams: { slug: "post" },
route: {
error: routeErrorModule,
layouts: [rootLayoutModule],
params: { slug: "post" },
pattern: "/posts/[slug]",
},
sanitizeErrorForClient,
});
expect(response?.status).toBe(200);
expect(sanitizeErrorForClient).toHaveBeenCalledTimes(1);
const html = await response?.text();
expect(common.clearRequestContext).toHaveBeenCalledTimes(1);
expect(html).toContain('data-layout="root"');
expect(html).toContain('data-boundary="route-error"');
expect(html).toContain("route:safe:secret");
});
it("renders global-error boundaries without layout wrapping", async () => {
const common = createCommonOptions();
const response = await renderAppPageErrorBoundary({
...common,
error: new Error("boom"),
globalErrorModule,
matchedParams: { slug: "post" },
route: {
layouts: [rootLayoutModule],
params: { slug: "post" },
pattern: "/posts/[slug]",
},
sanitizeErrorForClient(error: Error) {
return error;
},
});
expect(response?.status).toBe(200);
const html = await response?.text();
expect(html).toContain('data-boundary="global-error"');
expect(html).toContain("global:boom");
expect(html).not.toContain('data-layout="root"');
});
});