Skip to content

Commit e015958

Browse files
committed
fix: rendered more hooks issue
1 parent 8de7aed commit e015958

File tree

2 files changed

+205
-5
lines changed

2 files changed

+205
-5
lines changed

packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,4 +656,117 @@ describe('ReactDOMFizzShellHydration', () => {
656656
expect(container.innerHTML).toBe('Client');
657657
},
658658
);
659+
660+
it('handles conditional use with a cascading update and error boundaries', async () => {
661+
class ErrorBoundary extends React.Component {
662+
constructor(props) {
663+
super(props);
664+
this.state = {error: null};
665+
}
666+
667+
static getDerivedStateFromError(error) {
668+
return {error};
669+
}
670+
671+
componentDidCatch() {}
672+
673+
render() {
674+
if (this.state.error) {
675+
return 'Something went wrong: ' + this.state.error.message;
676+
}
677+
678+
return this.props.children;
679+
}
680+
}
681+
682+
function Bomb() {
683+
throw new Error('boom');
684+
}
685+
686+
function Updater({setPromise}) {
687+
const [state, setState] = React.useState(false);
688+
689+
React.useEffect(() => {
690+
// deleting this set state removes too many hooks error
691+
setState(true);
692+
// deleting this startTransition removes too many hooks error
693+
startTransition(() => {
694+
setPromise(Promise.resolve('resolved'));
695+
});
696+
}, [state]);
697+
698+
return null;
699+
}
700+
701+
function Page() {
702+
const [promise, setPromise] = React.useState(null);
703+
Scheduler.log('use: ' + promise);
704+
/**
705+
* this part is tricky, I cannot say confidently the conditional `use` is required for the reproduction.
706+
* If we tried to run use(promise ?? cachedPromise) we wouldn't be able renderToString without a parent suspense boundary
707+
* but with a parent suspense the bug is no longer reproducible (with or without conditional use)
708+
* and without renderToString + hydration, the bug is no longer reproducible
709+
*/
710+
const value = promise ? React.use(promise) : promise;
711+
Scheduler.log('used: ' + value);
712+
713+
React.useMemo(() => {}, []); // to trigger too many hooks error
714+
return (
715+
<>
716+
<Updater setPromise={setPromise} />
717+
<React.Suspense fallback="Loading...">
718+
<ErrorBoundary>
719+
<Bomb />
720+
</ErrorBoundary>
721+
</React.Suspense>
722+
hello world
723+
</>
724+
);
725+
}
726+
function App() {
727+
return <Page />;
728+
}
729+
730+
// Server render
731+
await serverAct(async () => {
732+
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />, {
733+
onError(error) {
734+
Scheduler.log('onError: ' + error.message);
735+
},
736+
});
737+
pipe(writable);
738+
});
739+
assertLog(['use: null', 'used: null', 'onError: boom']);
740+
741+
expect(container.textContent).toBe('Loading...hello world');
742+
743+
await clientAct(async () => {
744+
ReactDOMClient.hydrateRoot(container, <App />, {
745+
onCaughtError(error) {
746+
Scheduler.log('onCaughtError: ' + error.message);
747+
},
748+
onUncaughtError(error) {
749+
Scheduler.log('onUncaughtError: ' + error.message);
750+
},
751+
onRecoverableError(error) {
752+
Scheduler.log('onRecoverableError: ' + error.message);
753+
if (error.cause) {
754+
Scheduler.log('Cause: ' + error.cause.message);
755+
}
756+
},
757+
});
758+
});
759+
assertLog([
760+
'use: null',
761+
'used: null',
762+
'use: [object Promise]',
763+
'onCaughtError: boom',
764+
'use: [object Promise]',
765+
'use: [object Promise]',
766+
'used: resolved',
767+
]);
768+
769+
// Should've rendered something. The error was handled by the error boundary.
770+
expect(container.textContent).toBe('Something went wrong: boomhello world');
771+
});
659772
});

packages/react-reconciler/src/ReactFiberHooks.js

Lines changed: 92 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,9 @@ let localIdCounter: number = 0;
281281
let thenableIndexCounter: number = 0;
282282
let thenableState: ThenableState | null = null;
283283

284+
// Track whether we've switched dispatchers due to conditional use
285+
let hasDispatcherSwitchedDueToUse: boolean = false;
286+
284287
// Used for ids that are generated completely client-side (i.e. not during
285288
// hydration). This counter is global, so client ids are not stable across
286289
// render attempts.
@@ -624,6 +627,9 @@ export function renderWithHooks<Props, SecondArg>(
624627

625628
finishRenderingHooks(current, workInProgress, Component);
626629

630+
// Reset dispatcher switch flag for the next render
631+
hasDispatcherSwitchedDueToUse = false;
632+
627633
return children;
628634
}
629635

@@ -931,6 +937,9 @@ export function resetHooksAfterThrow(): void {
931937
// We can assume the previous dispatcher is always this one, since we set it
932938
// at the beginning of the render phase and there's no re-entrance.
933939
ReactSharedInternals.H = ContextOnlyDispatcher;
940+
941+
// Reset dispatcher switch flag
942+
hasDispatcherSwitchedDueToUse = false;
934943
}
935944

936945
export function resetHooksOnUnwind(workInProgress: Fiber): void {
@@ -1037,6 +1046,32 @@ function updateWorkInProgressHook(): Hook {
10371046
'Update hook called on initial render. This is likely a bug in React. Please file an issue.',
10381047
);
10391048
} else {
1049+
// Check if we're in a conditional use scenario
1050+
if (hasDispatcherSwitchedDueToUse) {
1051+
// We're in a situation where conditional use has caused hook count mismatch.
1052+
// Create a new hook and mark that we should treat the next hook as a mount.
1053+
const newHook: Hook = {
1054+
memoizedState: null,
1055+
baseState: null,
1056+
baseQueue: null,
1057+
queue: null,
1058+
next: null,
1059+
};
1060+
1061+
if (workInProgressHook === null) {
1062+
// This is the first hook in the list.
1063+
currentlyRenderingFiber.memoizedState = workInProgressHook =
1064+
newHook;
1065+
} else {
1066+
// Append to the end of the list.
1067+
workInProgressHook = workInProgressHook.next = newHook;
1068+
}
1069+
1070+
// Don't throw error - let the hook continue
1071+
// The specific hook implementation will handle initialization
1072+
return workInProgressHook;
1073+
}
1074+
10401075
// This is an update. We should always have a current hook.
10411076
throw new Error('Rendered more hooks than during the previous render.');
10421077
}
@@ -1130,11 +1165,15 @@ function useThenable<T>(thenable: Thenable<T>): T {
11301165
const currentFiber = workInProgressFiber.alternate;
11311166
if (__DEV__) {
11321167
if (currentFiber !== null && currentFiber.memoizedState !== null) {
1168+
hasDispatcherSwitchedDueToUse = true;
11331169
ReactSharedInternals.H = HooksDispatcherOnUpdateInDEV;
11341170
} else {
11351171
ReactSharedInternals.H = HooksDispatcherOnMountInDEV;
11361172
}
11371173
} else {
1174+
if (currentFiber !== null && currentFiber.memoizedState !== null) {
1175+
hasDispatcherSwitchedDueToUse = true;
1176+
}
11381177
ReactSharedInternals.H =
11391178
currentFiber === null || currentFiber.memoizedState === null
11401179
? HooksDispatcherOnMount
@@ -1301,13 +1340,36 @@ function updateReducerImpl<S, A>(
13011340
current: Hook,
13021341
reducer: (S, A) => S,
13031342
): [S, Dispatch<A>] {
1304-
const queue = hook.queue;
1343+
let queue = hook.queue;
13051344

13061345
if (queue === null) {
1307-
throw new Error(
1308-
'Should have a queue. You are likely calling Hooks conditionally, ' +
1309-
'which is not allowed. (https://react.dev/link/invalid-hook-call)',
1310-
);
1346+
// Check if this is a conditional use scenario
1347+
if (hasDispatcherSwitchedDueToUse && hook.memoizedState === null) {
1348+
// Initialize the hook for conditional use case
1349+
// For conditional use, we don't have the initial state, so we use the current state
1350+
// or a default value. This matches what would happen during mount.
1351+
const initialState = ((undefined: any): S);
1352+
hook.memoizedState = hook.baseState = initialState;
1353+
const newQueue: UpdateQueue<S, A> = {
1354+
pending: null,
1355+
lanes: NoLanes,
1356+
dispatch: null,
1357+
lastRenderedReducer: reducer,
1358+
lastRenderedState: initialState,
1359+
};
1360+
queue = hook.queue = newQueue;
1361+
const dispatch: Dispatch<A> = (dispatchReducerAction.bind(
1362+
null,
1363+
currentlyRenderingFiber,
1364+
newQueue,
1365+
): any);
1366+
newQueue.dispatch = dispatch;
1367+
} else {
1368+
throw new Error(
1369+
'Should have a queue. You are likely calling Hooks conditionally, ' +
1370+
'which is not allowed. (https://react.dev/link/invalid-hook-call)',
1371+
);
1372+
}
13111373
}
13121374

13131375
queue.lastRenderedReducer = reducer;
@@ -2900,6 +2962,14 @@ function updateCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
29002962
const hook = updateWorkInProgressHook();
29012963
const nextDeps = deps === undefined ? null : deps;
29022964
const prevState = hook.memoizedState;
2965+
2966+
// Handle conditional use scenario where hook was newly created
2967+
if (hasDispatcherSwitchedDueToUse && prevState === null) {
2968+
// This hook was created due to conditional use - initialize it properly
2969+
hook.memoizedState = [callback, nextDeps];
2970+
return callback;
2971+
}
2972+
29032973
if (nextDeps !== null) {
29042974
const prevDeps: Array<mixed> | null = prevState[1];
29052975
if (areHookInputsEqual(nextDeps, prevDeps)) {
@@ -2936,6 +3006,23 @@ function updateMemo<T>(
29363006
const hook = updateWorkInProgressHook();
29373007
const nextDeps = deps === undefined ? null : deps;
29383008
const prevState = hook.memoizedState;
3009+
3010+
// Handle conditional use scenario where hook was newly created
3011+
if (hasDispatcherSwitchedDueToUse && prevState === null) {
3012+
// This hook was created due to conditional use - initialize it properly
3013+
const nextValue = nextCreate();
3014+
if (shouldDoubleInvokeUserFnsInHooksDEV) {
3015+
setIsStrictModeForDevtools(true);
3016+
try {
3017+
nextCreate();
3018+
} finally {
3019+
setIsStrictModeForDevtools(false);
3020+
}
3021+
}
3022+
hook.memoizedState = [nextValue, nextDeps];
3023+
return nextValue;
3024+
}
3025+
29393026
// Assume these are defined. If they're not, areHookInputsEqual will warn.
29403027
if (nextDeps !== null) {
29413028
const prevDeps: Array<mixed> | null = prevState[1];

0 commit comments

Comments
 (0)