Skip to content

fix(app-router): avoid React 19 dev Flight decode crash in SSR#118

Merged
southpolesteve merged 6 commits intocloudflare:mainfrom
liuxiaopai-ai:fix/issue-50-react19-hooks
Mar 5, 2026
Merged

fix(app-router): avoid React 19 dev Flight decode crash in SSR#118
southpolesteve merged 6 commits intocloudflare:mainfrom
liuxiaopai-ai:fix/issue-50-react19-hooks

Conversation

@liuxiaopai-ai
Copy link
Contributor

@liuxiaopai-ai liuxiaopai-ai commented Feb 26, 2026

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 - add VinextFlightRoot wrapper and render it via createElement\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)\n

Fixes #50

@liuxiaopai-ai
Copy link
Contributor Author

Added Fixes #50 to the PR body and posted context on the issue thread.

@threepointone @southpolesteve this is ready for review when you have time.

Quick validation summary:

  • pnpm -s run lint
  • pnpm -s run typecheck
  • pnpm -s test tests/app-router.test.ts
  • pnpm -s test tests/static-export.test.ts
  • pnpm -s test tests/ecosystem.test.ts
  • full pnpm -s test: 47 files / 1820 passed / 3 skipped

southpolesteve added a commit that referenced this pull request Feb 27, 2026
Co-authored-by: ask-bonk[bot] <ask-bonk[bot]@users.noreply.github.com>
Co-authored-by: Steve Faulkner <[email protected]>
@southpolesteve
Copy link
Collaborator

/bigbonk review

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

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

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;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

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;
Copy link
Contributor

Choose a reason for hiding this comment

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

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");
Copy link
Contributor

Choose a reason for hiding this comment

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

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.

Comment on lines +2097 to +2111
/**
* 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.
*/
Copy link
Contributor

Choose a reason for hiding this comment

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

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 {
Copy link
Contributor

Choose a reason for hiding this comment

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

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.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 4, 2026

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 createFromReadableStream() inside a VinextFlightRoot React component so it executes within a React render context. This ensures React 19's hooks dispatcher is set when dev-mode Flight error decoding hits resolveErrorDev(), preventing the "Invalid hook call" / null dispatcher crashes.

Key points from the review:

  1. Dev/prod parity is fineprod-server.ts and app-router-entry.ts delegate to the built RSC/SSR entries generated from generateSsrEntry(), so the fix propagates automatically. The browser entry's createFromReadableStream call doesn't need the fix since the dispatcher is set during hydration.

  2. The memoization pattern (if (!flightRoot)) is correct — createFromReadableStream should only be called once per stream, and this ensures that even if React re-renders the component.

  3. Test coverage is appropriate — reproduces the exact crash path and checks both positive (client-render fallback) and negative (absence of crash signatures) conditions.

  4. Inline observations posted: a note about the test relying on React's internal "Switched to client rendering" message text, a question about whether the error.tsx fixture is exercised or just realistic setup, and minor style suggestions on the explanatory comment block.

github run

- 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
Copy link
Collaborator

@southpolesteve southpolesteve left a comment

Choose a reason for hiding this comment

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

Pushed review nit fixes and merged main (3ea9552).

Changes from review feedback:

  1. 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)
  2. Added note on VinextFlightRoot that it returns a thenable (not a ReactNode), relying on React 19's internal thenable-as-child suspend/resume behavior
  3. Added comment noting the "Switched to client rendering" test assertion depends on React internal message text that may change across versions
  4. Added comment to error.tsx fixture 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
@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 5, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@118

commit: 28e860e

@southpolesteve southpolesteve merged commit 5f59398 into cloudflare:main Mar 5, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: Invalid hook call + Cannot read properties of null (reading 'useContext')

2 participants