mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
8e58ead4b0
* 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.
231 lines
6.7 KiB
TypeScript
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"');
|
|
});
|
|
});
|