Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions tests/app-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -815,6 +815,14 @@ describe("App Router integration", () => {
expect(html).not.toContain("Missing use client react hook test");
});

it("error boundary catches string thrown in Server Component", async () => {
const { res, html } = await fetchHtml(baseUrl, "/throw-string-test");
expect(res.status).toBe(200);
expect(textContentByTestId(html, "string-error-message")).toBe(
"this is a test string thrown in a server component",
);
});

it("redirect() from Server Component returns redirect response", async () => {
const res = await fetch(`${baseUrl}/redirect-test`, { redirect: "manual" });
expect(res.status).toBeGreaterThanOrEqual(300);
Expand Down Expand Up @@ -3642,6 +3650,9 @@ describe("App Router next.config.js features (generateRscEntry)", () => {
// wrong return values, broken control flow).
describe("runtime behavior", () => {
let rscOnError: (error: unknown) => string | undefined;
let prodRscOnError: (error: unknown) => string | undefined;
let digestFn: string;
let onErrorFn: string;

beforeAll(() => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false);
Expand All @@ -3662,13 +3673,14 @@ describe("App Router next.config.js features (generateRscEntry)", () => {
throw new Error(`Unbalanced braces in ${name}`);
}

const digestFn = extractFunction(code, "__errorDigest");
const onErrorFn = extractFunction(code, "rscOnError");
digestFn = extractFunction(code, "__errorDigest");
onErrorFn = extractFunction(code, "rscOnError");

const body = `${digestFn}\n${onErrorFn}\nreturn rscOnError;`;
// oxlint-disable-next-line no-new-func, @typescript-eslint/no-implied-eval -- reconstructing emitted runtime code is the behavior under test
const factory = new Function("process", body);
rscOnError = factory({ env: { NODE_ENV: "development" } });
prodRscOnError = factory({ env: { NODE_ENV: "production" } });
});

it("returns the digest string for navigation errors (redirect/notFound)", () => {
Expand Down Expand Up @@ -3705,6 +3717,25 @@ describe("App Router next.config.js features (generateRscEntry)", () => {
spy.mockRestore();
}
});

it("does not swallow strings thrown in Server Components", () => {
const result = prodRscOnError("this is a test string");
// Should return a digest hash (not undefined), indicating the string
// was processed through the normal error path
expect(result).toBeDefined();
expect(typeof result).toBe("string");
expect((result as string).length).toBeGreaterThan(0);
// The digest should be based on the string content
const result2 = prodRscOnError("different string");
expect(result2).not.toBe(result);
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a minor point, but the runtime test duplicates the new Function(...) construction that beforeAll already performs. The only difference is NODE_ENV: "production" vs "development". Consider creating the production variant alongside the dev one in beforeAll (or a shared helper) instead of duplicating the ceremony here.

That said, this test is the most valuable one in this PR — it directly verifies the behavior. The assertion structure is good: it checks the return value is defined, is a string, is non-empty, and is content-dependent. This is a solid test.


it("does not have an early return for string thrown values in generated code", () => {
// Next.js had a bug where typeof thrownValue === 'string' returned
// early, swallowing the error before logging. Verify the generated
// rscOnError has no such early-return path.
expect(onErrorFn).not.toContain("typeof thrownValue === 'string'");
});
});
});

Expand Down
19 changes: 19 additions & 0 deletions tests/fixtures/app-basic/app/throw-string-test/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"use client";

export default function ThrowStringErrorBoundary({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div data-testid="string-error-boundary">
<h2>String Error Caught</h2>
<p data-testid="string-error-message">{error.message}</p>
<button data-testid="string-error-reset" onClick={reset}>
Retry
</button>
</div>
);
}
4 changes: 4 additions & 0 deletions tests/fixtures/app-basic/app/throw-string-test/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export default function ThrowStringTestPage() {
throw "this is a test string thrown in a server component";
return <div>This should never render</div>;
}
Loading