fix: complete partial hook chain on unwind to prevent corruption (#33580) #35717
+133
−0
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Human View
Summary
Fixes #33580 — "Rendered more hooks than during the previous render" when a component calls
use(thenable)conditionally after hydration.Root Cause
When a component suspends mid-render (e.g., after
useStatebut beforeuseMemo),resetHooksOnUnwindis called and the work-in-progress (WIP) fiber retains only the hooks that were processed before the interruption. If this WIP fiber is later committed — for example, as part of a Suspense boundary showing its fallback — the incomplete hook chain replaces the current fiber's complete chain.On the next render,
updateWorkInProgressHooktries to clone hooks from the (now-corrupted) current fiber but runs out of entries, throwing:The Fix
In
resetHooksOnUnwind, before clearingcurrentHookandworkInProgressHook, we now clone the remaining unprocessed hooks from the current fiber and append them to the WIP hook chain. This ensures the chain is always complete when committed, preserving hook integrity across Suspense fallback commits.Reproduction Conditions
The bug requires a specific combination of 6 interacting conditions:
hydrateRoot)ForceClientRenderduring hydrationsetStateinuseEffecttriggers re-renderstartTransition(() => setPromise(...))at lower priorityuse(thenable)—promise ? use(promise) : promisecauses suspension when promise becomes non-nulluse()—useMemo(or any hook) comes after the conditionaluse()callSequence of Events
Test Plan
ReactDOMFizzShellHydration-test.jsthat reproduces the exact conditionsReactDOMFizzShellHydration-test.js— 11/11 ✅ReactHooks-test.internal.js— 72/72 ✅ReactSuspenseWithNoopRenderer-test.js— 69/69 ✅ReactUse-test.js— 48/48 ✅ReactHooksWithNoopRenderer-test.js— 96/96 ✅Related
use#34068 — Previous fix attempt (closed by stale bot, author was not confident in the approach)AI View (DCCE Protocol v1.0)
Metadata
AI Contribution Summary
Verification Steps Performed
Human Review Guidance
ReactDOMFizzShellHydration-test.js,ReactHooks-test.internal.js,ReactSuspenseWithNoopRenderer-test.jsMade with M7 Cursor