fix: React.use inside React.lazy-ed component on SSR
Project: facebook/react | Language: JavaScript | Concepts: concurrency, error-handling | Difficulty: senior
Link: https://github.com/facebook/react/pull/33941
The problem
When using React.use (the suspense hook) inside a component loaded with React.lazy during server-side rendering (SSR), React would incorrectly handle the promise tracking. This led to bugs where the server renderer could lose track of which promises were being awaited, resulting in hydration mismatches or incomplete SSR output.
Why it's non-trivial
A naive fix might attempt to simply reset or always update the thenable state when a promise is thrown, but React supports two different suspense mechanisms: the legacy "throw thenable" approach (used by React.lazy) and the newer thenable state tracking (used by React.use). These mechanisms interact subtly, and mixing them without care can break the promise tracking, leading to incorrect SSR output or breaking unrelated features (as seen in failed tests when trying other approaches).
The approach
The fix ensures that React only updates the thenable state when the suspension comes from the newer React.use-based mechanism, not from the legacy "throw thenable" mechanism used by React.lazy. This is accomplished by checking the type of the thrown value before updating the thenable state.
Key code snippets:
-
Conditional thenable state update:
- const thenableState = getThenableStateAfterSuspending(); + const thenableState = + thrownValue === SuspenseException + ? getThenableStateAfterSuspending() + : null;Annotation:
This pattern is repeated in several places inReactFizzServer.js. It ensures thatgetThenableStateAfterSuspending()is only called if the thrown value is the specialSuspenseExceptionused by theReact.use-based suspense system. If the suspension is caused by a plain thrown thenable (the legacy system), thenable state is set tonulland not updated. -
Test for the specific scenario:
+ it('should correctly handle different promises in React.use() across lazy components', async () => { + let promise1; + let promise2; + let promiseLazy; + + function Component1() { + promise1 ??= new Promise(r => setTimeout(() => r('value1'), 50)); + const data = React.use(promise1); + return ( + <div> + {data} + <Component2Lazy /> + </div> + ); + } + + function Component2() { + promise2 ??= new Promise(r => setTimeout(() => r('value2'), 50)); + const data = React.use(promise2); + return <div>{data}</div>; + } + + const Component2Lazy = React.lazy(async () => { + promiseLazy ??= new Promise(r => setTimeout(r, 50)); + await promiseLazy; + return {default: Component2}; + }); + + function App() { + return <Component1 />; + } + + // ... (test waits for all promises and checks output) + });Annotation:
This test reproduces the bug by combiningReact.useandReact.lazywith different promises. It ensures that both promises resolve correctly and the output is as expected, verifying the fix.
From the review thread
No notable review discussion on this PR.
Takeaways
- Always distinguish between similar but distinct mechanisms (e.g., legacy and modern suspense) when updating shared state, to avoid subtle bugs.
- When fixing concurrency or async control flow, add regression tests that cover the precise interleaving and promise resolution order that caused the original bug.
- Use explicit checks (like
thrownValue === SuspenseException) to guard logic that should only apply to specific pathways, rather than relying on implicit behavior.