diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index b8b204a8f..44addf414 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -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); @@ -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); @@ -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)", () => { @@ -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); + }); + + 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'"); + }); }); }); diff --git a/tests/fixtures/app-basic/app/throw-string-test/error.tsx b/tests/fixtures/app-basic/app/throw-string-test/error.tsx new file mode 100644 index 000000000..d6016db0c --- /dev/null +++ b/tests/fixtures/app-basic/app/throw-string-test/error.tsx @@ -0,0 +1,19 @@ +"use client"; + +export default function ThrowStringErrorBoundary({ + error, + reset, +}: { + error: Error; + reset: () => void; +}) { + return ( +
+

String Error Caught

+

{error.message}

+ +
+ ); +} diff --git a/tests/fixtures/app-basic/app/throw-string-test/page.tsx b/tests/fixtures/app-basic/app/throw-string-test/page.tsx new file mode 100644 index 000000000..609abede3 --- /dev/null +++ b/tests/fixtures/app-basic/app/throw-string-test/page.tsx @@ -0,0 +1,4 @@ +export default function ThrowStringTestPage() { + throw "this is a test string thrown in a server component"; + return
This should never render
; +}