From 8d8439d7603faa47e2361486f6f7d5d4f2aa2bba Mon Sep 17 00:00:00 2001 From: DukeDeSouth Date: Sat, 7 Feb 2026 09:21:55 -0500 Subject: [PATCH] fix: complete partial hook chain on unwind to prevent corruption (#33580) When a component suspends mid-render (e.g., after `useState` but before `useMemo`), the work-in-progress fiber has an incomplete hook chain. If this fiber is later committed as part of a Suspense fallback, the incomplete chain replaces the current fiber's complete chain. Subsequent renders then fail with "Rendered more hooks than during the previous render" because the current fiber no longer has entries for hooks after the interruption point. This fixes the issue by completing the work-in-progress hook chain in `resetHooksOnUnwind`: any remaining hooks from the current fiber are cloned and appended so that the chain is always complete when committed. The bug is triggered by a specific combination of conditions: 1. Server-side rendering with hydration 2. An ErrorBoundary inside a Suspense boundary (causes ForceClientRender) 3. A cascading transition update (`startTransition` + `setState`) 4. Conditional `use(thenable)` that causes suspension 5. Additional hooks (`useMemo`) after the `use()` call Fixes #33580 Co-authored-by: Cursor --- .../ReactDOMFizzShellHydration-test.js | 104 ++++++++++++++++++ .../react-reconciler/src/ReactFiberHooks.js | 29 +++++ 2 files changed, 133 insertions(+) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js index fb2f33a4bade..5c046b75fd2f 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js @@ -655,4 +655,108 @@ describe('ReactDOMFizzShellHydration', () => { expect(container.innerHTML).toBe('Client'); }, ); + + it('handles conditional use with a cascading update and error boundaries (#33580)', async () => { + class ErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = {error: null}; + } + + static getDerivedStateFromError(error) { + return {error}; + } + + componentDidCatch() {} + + render() { + if (this.state.error) { + return 'Something went wrong: ' + this.state.error.message; + } + + return this.props.children; + } + } + + function Bomb() { + throw new Error('boom'); + } + + function Updater({setPromise}) { + const [state, setState] = React.useState(false); + + React.useEffect(() => { + setState(true); + startTransition(() => { + setPromise(Promise.resolve('resolved')); + }); + }, [state]); + + return null; + } + + function Page() { + const [promise, setPromise] = React.useState(null); + const value = promise ? React.use(promise) : promise; + + React.useMemo(() => {}, []); + + return ( + <> + + + + + + + {value !== null ? value : 'hello world'} + + ); + } + + function App() { + return ; + } + + // Server render + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(, { + onError(error) { + Scheduler.log('onError: ' + error.message); + }, + }); + pipe(writable); + }); + assertLog(['onError: boom']); + + const errors = []; + await clientAct(async () => { + ReactDOMClient.hydrateRoot(container, , { + onCaughtError(error) { + Scheduler.log('onCaughtError: ' + error.message); + errors.push('caught: ' + error.message); + }, + onUncaughtError(error) { + Scheduler.log('onUncaughtError: ' + error.message); + errors.push('uncaught: ' + error.message); + }, + onRecoverableError(error) { + Scheduler.log('onRecoverableError: ' + error.message); + errors.push('recoverable: ' + error.message); + }, + }); + }); + + assertLog(['onCaughtError: boom']); + + // The bug (#33580) manifested as "Rendered more hooks than during the + // previous render" when a component calls use(thenable) conditionally after + // hydration inside a Suspense boundary with an ErrorBoundary, combined with + // a cascading transition update. The fix ensures the work-in-progress hook + // chain is completed during unwind so that committing a Suspense fallback + // does not corrupt the current fiber's hooks. + const hooksError = errors.find(e => e.includes('Rendered more hooks')); + expect(hooksError).toBeUndefined(); + expect(container.textContent).toBe('Something went wrong: boomresolved'); + }); }); diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 0450495ff0d4..dd098a9c5b1b 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -937,6 +937,35 @@ export function resetHooksAfterThrow(): void { } export function resetHooksOnUnwind(workInProgress: Fiber): void { + // When a component's render is interrupted (by suspension or error), the + // work-in-progress fiber may have an incomplete hook chain — only the hooks + // that were processed before the interruption are present. If this fiber is + // later committed (e.g., as part of a Suspense boundary showing its + // fallback), the incomplete hook chain replaces the current fiber's complete + // chain. This causes "Rendered more hooks than during the previous render" + // errors when the component re-renders, because the current fiber no longer + // has entries for hooks that come after the interruption point. + // + // To prevent this, we complete the work-in-progress hook chain by cloning + // the remaining hooks from the current fiber. + if (currentHook !== null && workInProgressHook !== null) { + let nextCurrentHook: Hook | null = currentHook.next; + if (nextCurrentHook !== null) { + let tail: Hook = workInProgressHook; + while (nextCurrentHook !== null) { + const clone: Hook = { + memoizedState: nextCurrentHook.memoizedState, + baseState: nextCurrentHook.baseState, + baseQueue: nextCurrentHook.baseQueue, + queue: nextCurrentHook.queue, + next: null, + }; + tail = tail.next = clone; + nextCurrentHook = nextCurrentHook.next; + } + } + } + if (didScheduleRenderPhaseUpdate) { // There were render phase updates. These are only valid for this render // phase, which we are now aborting. Remove the updates from the queues so