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