Files
cloudflare__vinext/tests/app-page-stream.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

256 lines
8.2 KiB
TypeScript

import { describe, expect, it, vi } from "vite-plus/test";
import {
createAppPageFontData,
createAppPageRscErrorTracker,
renderAppPageHtmlResponse,
renderAppPageHtmlStream,
renderAppPageHtmlStreamWithRecovery,
shouldRerenderAppPageWithGlobalError,
} from "../packages/vinext/src/server/app-page-stream.js";
function createStream(chunks: string[]): ReadableStream<Uint8Array> {
return new ReadableStream({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(new TextEncoder().encode(chunk));
}
controller.close();
},
});
}
describe("app page stream helpers", () => {
it("collects app page font data from RSC environment getters", () => {
expect(
createAppPageFontData({
getLinks() {
return ["/font.css"];
},
getPreloads() {
return [{ href: "/font.woff2", type: "font/woff2" }];
},
getStyles() {
return [".font { font-family: Test; }"];
},
}),
).toEqual({
links: ["/font.css"],
preloads: [{ href: "/font.woff2", type: "font/woff2" }],
styles: [".font { font-family: Test; }"],
});
});
it("renders the HTML stream through the SSR handler", async () => {
const fontData = createAppPageFontData({
getLinks: () => ["/font.css"],
getPreloads: () => [{ href: "/font.woff2", type: "font/woff2" }],
getStyles: () => [],
});
const htmlStream = await renderAppPageHtmlStream({
fontData,
navigationContext: { pathname: "/test" },
rscStream: createStream(["flight"]),
ssrHandler: {
async handleSsr(_rscStream, navigationContext, receivedFontData) {
expect(navigationContext).toEqual({ pathname: "/test" });
expect(receivedFontData).toEqual(fontData);
return createStream(["<html>ok</html>"]);
},
},
});
await expect(new Response(htmlStream).text()).resolves.toBe("<html>ok</html>");
});
it("defers clearRequestContext until the HTML stream body is fully consumed", async () => {
// Regression test for issue #660: clearRequestContext() must not race the
// lazy RSC/SSR stream pipeline. It should be called only after the HTTP
// response body has been fully consumed by the downstream consumer.
const contextCleared: string[] = [];
const clearRequestContext = vi.fn(() => {
contextCleared.push("cleared");
});
const response = await renderAppPageHtmlResponse({
clearRequestContext,
fontData: {
links: [],
preloads: [],
styles: [],
},
navigationContext: null,
rscStream: createStream(["flight"]),
ssrHandler: {
async handleSsr() {
return createStream(["<html>page</html>"]);
},
},
status: 200,
});
// The context must NOT be cleared yet — the response stream hasn't been
// consumed by the downstream caller (i.e. the HTTP layer) yet.
expect(contextCleared).toHaveLength(0);
// Consuming the stream simulates the HTTP layer reading the response body.
await response.text();
// Now that the stream is fully consumed, context must have been cleared.
expect(contextCleared).toHaveLength(1);
});
it("builds an HTML response, including link headers, and defers clearing request context until after body is consumed", async () => {
const clearRequestContext = vi.fn();
const response = await renderAppPageHtmlResponse({
clearRequestContext,
fontData: {
links: [],
preloads: [],
styles: [],
},
fontLinkHeader: "</font.woff2>; rel=preload; as=font; type=font/woff2; crossorigin",
navigationContext: null,
rscStream: createStream(["flight"]),
ssrHandler: {
async handleSsr() {
return createStream(["<html>page</html>"]);
},
},
status: 203,
});
// Context must NOT be cleared before body is consumed (see issue #660).
expect(clearRequestContext).toHaveBeenCalledTimes(0);
expect(response.status).toBe(203);
expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8");
expect(response.headers.get("link")).toBe(
"</font.woff2>; rel=preload; as=font; type=font/woff2; crossorigin",
);
await expect(response.text()).resolves.toBe("<html>page</html>");
// After body is consumed, context must be cleared exactly once.
expect(clearRequestContext).toHaveBeenCalledTimes(1);
});
it("returns the HTML stream and marks shell render completion when SSR succeeds", async () => {
const onShellRendered = vi.fn();
const result = await renderAppPageHtmlStreamWithRecovery({
onShellRendered,
async renderErrorBoundaryResponse() {
throw new Error("should not render an error boundary");
},
async renderHtmlStream() {
return createStream(["<html>ok</html>"]);
},
async renderSpecialErrorResponse() {
throw new Error("should not render a special response");
},
resolveSpecialError() {
return null;
},
});
expect(onShellRendered).toHaveBeenCalledTimes(1);
expect(result.response).toBeNull();
await expect(new Response(result.htmlStream).text()).resolves.toBe("<html>ok</html>");
});
it("turns special SSR failures into the provided response", async () => {
const ssrError = new Error("redirect");
const renderSpecialErrorResponse = vi.fn(async () => new Response("special", { status: 307 }));
const result = await renderAppPageHtmlStreamWithRecovery({
async renderErrorBoundaryResponse() {
throw new Error("should not render an error boundary");
},
async renderHtmlStream() {
throw ssrError;
},
renderSpecialErrorResponse,
resolveSpecialError(error) {
return error === ssrError
? {
kind: "redirect",
location: "/target",
statusCode: 307,
}
: null;
},
});
expect(renderSpecialErrorResponse).toHaveBeenCalledWith({
kind: "redirect",
location: "/target",
statusCode: 307,
});
expect(result.htmlStream).toBeNull();
expect(result.response?.status).toBe(307);
await expect(result.response?.text()).resolves.toBe("special");
});
it("falls back to the error boundary response for non-special SSR failures", async () => {
const ssrError = new Error("boom");
const renderErrorBoundaryResponse = vi.fn(
async () => new Response("boundary", { status: 200 }),
);
const result = await renderAppPageHtmlStreamWithRecovery({
renderErrorBoundaryResponse,
async renderHtmlStream() {
throw ssrError;
},
async renderSpecialErrorResponse() {
throw new Error("should not render a special response");
},
resolveSpecialError() {
return null;
},
});
expect(renderErrorBoundaryResponse).toHaveBeenCalledWith(ssrError);
expect(result.htmlStream).toBeNull();
expect(result.response?.status).toBe(200);
await expect(result.response?.text()).resolves.toBe("boundary");
});
it("tracks non-navigation RSC errors while preserving the base onError callback", () => {
const baseOnError = vi.fn(() => "base-result");
const tracker = createAppPageRscErrorTracker(baseOnError);
expect(tracker.onRenderError(new Error("boom"), { path: "/test" }, { chunk: 1 })).toBe(
"base-result",
);
expect(tracker.getCapturedError()).toBeInstanceOf(Error);
tracker.onRenderError({ digest: "NEXT_NOT_FOUND" }, { path: "/test" }, { chunk: 2 });
expect((tracker.getCapturedError() as Error).message).toBe("boom");
expect(baseOnError).toHaveBeenCalledTimes(2);
});
it("only rerenders with global-error when an RSC error was captured and no local boundary exists", () => {
expect(
shouldRerenderAppPageWithGlobalError({
capturedError: new Error("boom"),
hasLocalBoundary: false,
}),
).toBe(true);
expect(
shouldRerenderAppPageWithGlobalError({
capturedError: new Error("boom"),
hasLocalBoundary: true,
}),
).toBe(false);
expect(
shouldRerenderAppPageWithGlobalError({
capturedError: null,
hasLocalBoundary: false,
}),
).toBe(false);
});
});