fix(app-router): avoid React 19 dev Flight decode crash in SSR#118
Conversation
|
Added @threepointone @southpolesteve this is ready for review when you have time. Quick validation summary:
|
Co-authored-by: ask-bonk[bot] <ask-bonk[bot]@users.noreply.github.com> Co-authored-by: Steve Faulkner <[email protected]>
|
/bigbonk review |
There was a problem hiding this comment.
Review
The core fix is sound — wrapping createFromReadableStream() inside a React component render ensures the hooks dispatcher is set when React 19 dev-mode Flight error decoding hits resolveErrorDev(). The approach mirrors what Next.js does internally and is a clean minimal fix.
Dev/prod parity: prod-server.ts and app-router-entry.ts delegate to the built RSC/SSR entries generated from generateSsrEntry(), so the fix automatically propagates to production builds. The browser entry's createFromReadableStream call is fine as-is since the dispatcher is set during hydration. No parity issues.
Test coverage is appropriate: the fixture reproduces the exact crash path (async throw inside Suspense during Flight streaming), and the assertions check both the positive behavior (client-render fallback, loading marker) and negative conditions (absence of the two crash signatures).
Ecosystem test hardening (detached process groups, randomized emails) is a welcome improvement for CI stability.
A few observations inline.
| flightRoot = createFromReadableStream(ssrStream); | ||
| } | ||
| return flightRoot; | ||
| } |
There was a problem hiding this comment.
Nit: VinextFlightRoot is a React component but it returns a thenable (the result of createFromReadableStream) rather than a ReactNode. This works because React 19's renderer can consume thenables during render (it suspends and resumes), but it's worth noting that this relies on React's internal thenable-as-child behavior.
This is correct and matches how Next.js handles it, just flagging for future maintainers — if React ever changes how non-element thenables are handled during render, this would be the spot to revisit.
| const root = createFromReadableStream(ssrStream); | ||
| // Lazily create the Flight root inside render so React's hook dispatcher is set | ||
| // (avoids React 19 dev-mode resolveErrorDev() crash). | ||
| let flightRoot; |
There was a problem hiding this comment.
Minor: flightRoot is captured in a closure shared between the VinextFlightRoot function and handleSsr. Since handleSsr is called once per request and VinextFlightRoot should only render once per request (the same element instance), the memoization via if (!flightRoot) is fine. But if React ever renders this component more than once (e.g., during a retry after suspense), it would still return the same stream reference — which is correct, since createFromReadableStream should only be called once per stream.
Good defensive pattern.
| expect(res.status).toBe(200); | ||
| // In React 19 dev mode, this route currently switches to client rendering. | ||
| // The key regression check is that decode no longer crashes with a null dispatcher. | ||
| expect(html).toContain("Switched to client rendering"); |
There was a problem hiding this comment.
The assertion expect(html).toContain("Switched to client rendering") depends on React's internal error message text, which could change across React versions. Consider adding a brief comment noting this is a React internal message that may need updating if React changes its client-render fallback messaging.
Also — the test name mentions "React 19 regression" but the fixture has an error.tsx boundary. The test asserts client-render fallback ("Switched to client rendering") rather than the error boundary rendering. Is the error.tsx fixture file actually exercised by this test, or is it there just as a realistic setup? If the error boundary doesn't fire during SSR (because SSR falls back to client rendering before the boundary can catch it), the error.tsx could be omitted to keep the fixture minimal, or a comment could clarify its purpose.
| /** | ||
| * React 19 dev-mode workaround: | ||
| * | ||
| * In dev, Flight error decoding in react-server-dom-webpack/client.edge | ||
| * can hit resolveErrorDev() which (via React's dev error stack capture) | ||
| * expects a non-null hooks dispatcher. | ||
| * | ||
| * Vinext previously called createFromReadableStream() outside of any React render. | ||
| * When an RSC stream contains an error, dev-mode decoding could crash with: | ||
| * - "Invalid hook call" | ||
| * - "Cannot read properties of null (reading 'useContext')" | ||
| * | ||
| * Fix: call createFromReadableStream() lazily inside a React component render. | ||
| * This mirrors Next.js behavior and ensures the dispatcher is set. | ||
| */ |
There was a problem hiding this comment.
The block comment explaining the workaround is excellent — well-written and gives the exact error messages for searchability. One small thing: it ends with an empty line before the next doc comment block, which is fine stylistically, but the two consecutive /** blocks (this explanation + the createRscEmbedTransform doc) could be slightly confusing at a glance since this explanation isn't attached to any symbol. Consider converting it to a plain //-style block comment or merging the blank line to make it clearer this is a standalone explanation rather than a JSDoc block.
Very minor — not blocking.
| return; | ||
| } | ||
|
|
||
| try { |
There was a problem hiding this comment.
Good improvement — process.kill(-pid, "SIGTERM") sends the signal to the entire process group, which ensures child processes spawned by Vite (esbuild, etc.) are also cleaned up. The fallback to SIGKILL on the individual process is a reasonable safety net.
One edge case: if the process exits between the proc.killed check and the process.kill(-pid) call, the ESRCH error is silently caught by the outer catch, which is correct.
|
Review posted. Here's a summary of my findings: Overall: The PR looks good. The core fix is correct and well-targeted. The fix: Wraps Key points from the review:
|
- Convert standalone JSDoc block to plain // comment to avoid confusion with the adjacent createRscEmbedTransform JSDoc - Add note that VinextFlightRoot returns a thenable (not ReactNode), relying on React 19's internal thenable-as-child behavior - Add comment noting the 'Switched to client rendering' assertion depends on React internal message text - Clarify error.tsx fixture exists for realistic route structure, not because the error boundary fires during SSR - Merge main into branch
southpolesteve
left a comment
There was a problem hiding this comment.
Pushed review nit fixes and merged main (3ea9552).
Changes from review feedback:
- Converted the standalone
/**JSDoc block (React 19 workaround explanation) to plain//comments so it doesn't look like it's attached to the next symbol (createRscEmbedTransform) - Added note on
VinextFlightRootthat it returns a thenable (not a ReactNode), relying on React 19's internal thenable-as-child suspend/resume behavior - Added comment noting the "Switched to client rendering" test assertion depends on React internal message text that may change across versions
- Added comment to
error.tsxfixture clarifying it exists for realistic route structure, not because the error boundary fires during SSR (React falls back to client rendering first)
Dev/prod parity check: The fix is in generateSsrEntry() which produces the SSR entry used by both dev and production. The only other createFromReadableStream call is in generateBrowserEntry() (client-side), which doesn't need the fix since the dispatcher is set during hydration. No parity issues.
Typecheck, lint, and regression test all pass. LGTM.
…hooks # Conflicts: # packages/vinext/src/server/app-dev-server.ts
commit: |
Summary\nFixes React 19 dev-mode App Router SSR regression where Flight error decode could crash with:\n-
Invalid hook call\n-Cannot read properties of null (reading 'useContext')\n\n## Changes\n- Decode Flight stream lazily from inside a React render path in dev SSR:\n - addVinextFlightRootwrapper and render it viacreateElement\n - remove the production-decoder fallback path used previously\n- Update regression test to assert crash is gone while preserving current React 19 dev behavior\n - checks client-render fallback markers\n - negative assertions for the two crash signatures above\n- Harden ecosystem fixture stability (test-only):\n - detached fixture process + process-group termination\n - randomize better-auth test emails to avoid cross-run signup collisions\n\n## Validation\n-pnpm -s run lint\n-pnpm -s run typecheck\n-pnpm -s test tests/app-router.test.ts\n-pnpm -s test tests/static-export.test.ts\n-pnpm -s test tests/ecosystem.test.ts\n-pnpm -s test(47 files, 1820 passed, 3 skipped)\nFixes #50