Skip to content

fix: preserve hook chains when unwinding interrupted renders#36174

Open
arunanshub wants to merge 2 commits intofacebook:mainfrom
arunanshub:fix/35580-hook-state-corruption-on-fiber
Open

fix: preserve hook chains when unwinding interrupted renders#36174
arunanshub wants to merge 2 commits intofacebook:mainfrom
arunanshub:fix/35580-hook-state-corruption-on-fiber

Conversation

@arunanshub
Copy link
Copy Markdown

@arunanshub arunanshub commented Mar 31, 2026

Summary

Fixes #33580.

This PR fixes a hook-chain corruption bug in resetHooksOnUnwind that can surface as Rendered more hooks than during the previous render. after hydration when a component conditionally calls use(thenable) and a later update retries the render.

The failure mode is:

  1. A function component begins rendering and clones only part of its current hook list into the work-in-progress fiber.
  2. Rendering then unwinds because of suspension or error recovery.
  3. The incomplete work-in-progress hook list can later become current.
  4. On a later render, updateWorkInProgressHook runs out of current hook entries and throws Rendered more hooks than during the previous render.

What Changed

This change preserves hook-list integrity in both ways the work-in-progress list can be incomplete on unwind:

  1. If some tracked hooks were already processed, append the remaining current hooks to the existing work-in-progress tail.
  2. If render unwinds before the first tracked hook and workInProgress.memoizedState is still null, clone the entire current hook list into the work-in-progress fiber.

The fix intentionally completes the hook chain only after React finishes cleaning up render phase updates. Doing this earlier is incorrect because unprocessed hooks share queues with the current fiber, and cloning them before cleanup can cause legitimate pending updates on later hooks to be cleared.

Prior Art

This PR differs from #35717 in two important ways:

  1. [Fiber] Complete partial hook chain on unwind to prevent corruption #35717 appends the remaining current hooks before render phase update cleanup.

    • While validating that approach, I found a counterexample where a component suspends after its first useState, schedules a render phase update on that first hook, and then receives a normal pending update on a later useState hook that was never processed during the aborted render.
    • Cloning the later hook before cleanup causes resetHooksOnUnwind to walk the cloned hook list and clear queue.pending, which drops that legitimate later update.
    • The new ReactHooksWithNoopRenderer-test regression covers this case. Without the fix, the test regresses from the expected B:0 to A:0.
  2. [Fiber] Complete partial hook chain on unwind to prevent corruption #35717 only handles the partial-suffix case where both currentHook and workInProgressHook are already non-null.

    It does not handle the case where render unwinds before the first tracked hook. In that case, the full current hook list must be cloned to avoid remounting hooks on the next render.

Relevant prior work that helped narrow this down:

How did you test this change?

Tests

Added regression coverage for the original bug and the two additional edge cases found while validating the unwind fix:

Formatting, lint, and type checks:

node ./scripts/prettier/index.js --check packages/react-reconciler/src/ReactFiberHooks.js packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js

node ./scripts/tasks/eslint.js packages/react-reconciler/src/ReactFiberHooks.js packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js

node ./scripts/tasks/eslint.js

yarn flow dom-node

yarn flow-ci # for every Flow-typed host config in

scripts/shared/inlinedHostConfigs

Focused and adjacent test coverage:

yarn test ReactDOMFizzShellHydration-test ReactHooks-test.internal ReactSuspenseWithNoopRenderer-test ReactUse-test ReactHooksWithNoopRenderer-test --runInBand --ci
yarn test ReactDOMFizzShellHydration-test ReactDOMServerIntegrationHooks-test ReactDOMServerPartialHydration-test.internal ReactDOMServerPartialHydrationActivity-test.internal ReactDOMServerSelectiveHydration-test.internal ReactDOMServerSelectiveHydrationActivity-test.internal ReactErrorBoundariesHooks-test.internal ReactDOMHooks-test ReactDOMHydrationDiff-test ReactServerRenderingHydration-test ReactDOMServerRenderingHydration-test ReactDOMFizzServer-test ReactDOMFizzServerBrowser-test ReactDOMFizzServerNode-test ReactDOMFizzStatic-test ReactDOMFizzStaticBrowser-test ReactDOMFizzStaticNode-test ReactDOMFizzDeferredValue-test ReactDOMFizzSuppressHydrationWarning-test ReactDOMFizzSuspenseList-test ReactHooks-test.internal ReactHooksWithNoopRenderer-test ReactUse-test ReactSuspense-test.internal ReactSuspenseWithNoopRenderer-test ReactSuspenseEffectsSemantics-test ReactSuspenseEffectsSemanticsDOM-test ReactSuspenseFallback-test ReactSuspenseList-test ActivitySuspense-test ActivityLegacySuspense-test ReactCPUSuspense-test --runInBand --ci

Stable release-channel coverage:

yarn test-stable ReactDOMFizzShellHydration-test ReactDOMServerIntegrationHooks-test ReactDOMServerPartialHydration-test.internal ReactDOMServerPartialHydrationActivity-test.internal ReactDOMServerSelectiveHydration-test.internal ReactDOMServerSelectiveHydrationActivity-test.internal ReactErrorBoundariesHooks-test.internal ReactDOMHooks-test ReactDOMHydrationDiff-test ReactServerRenderingHydration-test ReactDOMServerRenderingHydration-test ReactDOMFizzServer-test ReactDOMFizzServerBrowser-test ReactDOMFizzServerNode-test ReactDOMFizzStatic-test ReactDOMFizzStaticBrowser-test ReactDOMFizzStaticNode-test ReactDOMFizzDeferredValue-test ReactDOMFizzSuppressHydrationWarning-test ReactDOMFizzSuspenseList-test ReactHooks-test.internal ReactHooksWithNoopRenderer-test ReactUse-test ReactSuspense-test.internal ReactSuspenseWithNoopRenderer-test ReactSuspenseEffectsSemantics-test ReactSuspenseEffectsSemanticsDOM-test ReactSuspenseFallback-test ReactSuspenseList-test ActivitySuspense-test ActivityLegacySuspense-test ReactCPUSuspense-test --runInBand --ci

Targeted bundle build:

node ./scripts/rollup/build.js react/index,react-dom/index,react-dom/client,react-dom/server,react-dom-server.node,react-dom-server-legacy.node,scheduler --type=NODE_DEV,NODE_PROD,NODE_ES2015

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: Rendered more hooks than during the previous render when component calls use(thenable) after hydration in a specific transition

1 participant