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