fix: add tests verifying strings thrown in Server Components are not swallowed#950
Conversation
…swallowed (cloudflare#810) Next.js had a bug where createReactServerErrorHandler returned early for typeof thrownValue === 'string', swallowing the error before logging. vinext's rscOnError already handles strings correctly (they flow through the normal error path and get wrapped into Error objects). Added: - Fixture page that throws a string in a Server Component - Integration test verifying the error boundary renders with the string message - Runtime test confirming rscOnError returns a valid digest for string throws - Static test confirming generated code has no early string-return path Upstream: vercel/next.js@b9ca95c
commit: |
|
/bigbonk review |
There was a problem hiding this comment.
Review
The integration test and fixture are good additions. However, the two unit tests added to the runtime behavior describe block have issues that need fixing before merge.
Summary of issues
-
Duplicate
rscOnErrorconstruction — The "does not swallow strings" test reconstructsrscOnErrorvianew Functionidentically to whatbeforeAllalready does, but withNODE_ENV=production. The existingrscOnErrorfrombeforeAllis in dev mode, so testing production behavior is valid, but the approach should use the already-extracteddigestFn/onErrorFnstrings without duplicating the Function construction ceremony. More importantly, the test creates a second instance but thebeforeAllalready provides one — the test should just create a production variant cleanly. -
Static test asserts on implementation details that could break — The
"does not have an early return for string thrown values"test assertsexpect(onErrorFn).toContain("error instanceof Error")andexpect(onErrorFn).toContain("String(error)"). These are asserting on incidental implementation details of the generated code that could change for valid reasons (e.g., refactoring the error handler to use a helper function). The negative assertion (not toContain "typeof thrownValue === 'string'") is the valuable part — it guards against the specific anti-pattern from the Next.js bug. The positive assertions add brittleness without proportional value. -
Integration test could be more precise — The test checks
htmlcontains the thrown string, but doesn't verify it appears inside the error boundary'sdata-testid="string-error-message"element. The string could appear in a React error overlay or console dump rather than the error boundary. Using the existingtextContentByTestIdhelper would make this more robust.
See inline comments for details.
| // The digest should be based on the string content | ||
| const result2 = prodRscOnError("different string"); | ||
| expect(result2).not.toBe(result); | ||
| }); |
There was a problem hiding this comment.
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.
| expect(onErrorFn).not.toContain("typeof thrownValue === 'string'"); | ||
| // Should still handle strings for digest generation | ||
| expect(onErrorFn).toContain("error instanceof Error"); | ||
| expect(onErrorFn).toContain("String(error)"); |
There was a problem hiding this comment.
The negative assertion on line 3740 is the valuable guard — it prevents the specific anti-pattern from the Next.js bug. But these two positive assertions (toContain("error instanceof Error") and toContain("String(error)")) are asserting on incidental implementation details of the generated code. If someone refactors rscOnError to use a helper like getErrorMessage(error) internally, these assertions break even though the behavior is correct.
I'd recommend removing the two positive toContain assertions and keeping only the negative one. The runtime test above already proves strings are handled correctly — this static test's job is just to ensure the known-bad pattern isn't reintroduced.
| expect(onErrorFn).toContain("String(error)"); | |
| expect(onErrorFn).not.toContain("typeof thrownValue === 'string'"); | |
| }); |
| 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(html).toContain("this is a test string thrown in a server component"); |
There was a problem hiding this comment.
This checks that the thrown string appears somewhere in the HTML, but it could be in a React error overlay or devtools dump rather than inside the error boundary. The file already has a textContentByTestId helper — using it here would confirm the string actually reached the error boundary component:
| expect(html).toContain("this is a test string thrown in a server component"); | |
| expect(textContentByTestId(html, "string-error-message")).toBe( | |
| "this is a test string thrown in a server component", | |
| ); |
This is more precise and exercises the same code path the fixture was designed to test.
|
Review submitted. Here's a summary of the feedback I posted: Requested changes on PR #950 with 3 inline comments:
|
…re#950) - Move prodRscOnError creation into beforeAll to avoid duplicating new Function construction - Remove brittle positive assertions (instanceof Error, String(error)) from static test; keep only the negative anti-pattern guard - Use textContentByTestId helper for integration test precision over loose html.toContain
|
/bigbonk review |
There was a problem hiding this comment.
All three comments from the previous review have been addressed cleanly:
prodRscOnErrormoved intobeforeAll— no more duplicatednew Functionconstruction. The factory is called once with both dev and productionNODE_ENVvalues.- Brittle positive assertions removed — the static test now only has the negative anti-pattern guard (
not.toContain("typeof thrownValue === 'string'")). - Integration test uses
textContentByTestId— confirms the string actually rendered inside the error boundary element, not just somewhere in the HTML.
CI is all green. The test coverage is well-structured: integration test proves end-to-end behavior, runtime test proves rscOnError handles strings correctly in production mode, and static test guards against the specific Next.js anti-pattern being reintroduced. Good to merge.
|
Approved PR #950. All three comments from the previous review were addressed in the second commit:
CI is fully green. The test coverage is well-layered (integration, runtime, static) and the fixtures are clean. Good to merge. |
Fixes #810
Summary
rscOnErroralready correctly handles strings thrown in Server Components — no early-return swallow bug exists (unlike the Next.js bug fixed in vercel/next.js@b9ca95c62)throw-string-test) that throws a string in a Server Component with an error boundaryrscOnErrorreturns a valid digest hash for string throws in productiontypeof thrownValue === 'string'early-return pathTest plan
rscOnErrorprocesses string throws through normal error path (produces digest in production)Upstream: vercel/next.js@b9ca95c