Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<>
<Updater setPromise={setPromise} />
<React.Suspense fallback="Loading...">
<ErrorBoundary>
<Bomb />
</ErrorBoundary>
</React.Suspense>
{value !== null ? value : 'hello world'}
</>
);
}

function App() {
return <Page />;
}

// Server render
await serverAct(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />, {
onError(error) {
Scheduler.log('onError: ' + error.message);
},
});
pipe(writable);
});
assertLog(['onError: boom']);

const errors = [];
await clientAct(async () => {
ReactDOMClient.hydrateRoot(container, <App />, {
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');
});
});
29 changes: 29 additions & 0 deletions packages/react-reconciler/src/ReactFiberHooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down