Skip to content

Commit 727b361

Browse files
committed
Fix useMemoCache with setState in render
Fixes the bug that @alexmckenley and @mofeiZ found where setState-in-render can reset useMemoCache and cause an infinite loop. The bug was that renderWithHooksAgain() was not resetting hook state when rerendering (so useMemo values were preserved) but was resetting the updateQueue. This meant that the entire memo cache was cleared on a setState-in-render. The fix here is to call a new helper function to clear the update queue. It nulls out other properties, but for memoCache it just sets the index back to zero. ghstack-source-id: fc0947ce219334117075df6a4e33b39975af2bc4 Pull Request resolved: #30889
1 parent bd788b4 commit 727b361

File tree

2 files changed

+42
-24
lines changed

2 files changed

+42
-24
lines changed

packages/react-reconciler/src/ReactFiberHooks.js

+32-10
Original file line numberDiff line numberDiff line change
@@ -770,6 +770,12 @@ export function replaySuspendedComponentWithHooks<Props, SecondArg>(
770770
ignorePreviousDependencies =
771771
current !== null && current.type !== workInProgress.type;
772772
}
773+
// renderWithHooks only resets the updateQueue but does not clear it, since
774+
// it needs to work for both this case (suspense replay) as well as for double
775+
// renders in dev and setState-in-render. However, for the suspense replay case
776+
// we need to reset the updateQueue to correctly handle unmount effects, so we
777+
// clear the queue here
778+
workInProgress.updateQueue = null;
773779
const children = renderWithHooksAgain(
774780
workInProgress,
775781
Component,
@@ -828,7 +834,9 @@ function renderWithHooksAgain<Props, SecondArg>(
828834
currentHook = null;
829835
workInProgressHook = null;
830836

831-
workInProgress.updateQueue = null;
837+
if (workInProgress.updateQueue != null) {
838+
resetFunctionComponentUpdateQueue((workInProgress.updateQueue: any));
839+
}
832840

833841
if (__DEV__) {
834842
// Also validate hook order for cascading updates.
@@ -1101,6 +1109,22 @@ if (enableUseMemoCacheHook) {
11011109
};
11021110
}
11031111

1112+
function resetFunctionComponentUpdateQueue(
1113+
updateQueue: FunctionComponentUpdateQueue,
1114+
): void {
1115+
updateQueue.lastEffect = null;
1116+
updateQueue.events = null;
1117+
updateQueue.stores = null;
1118+
if (enableUseMemoCacheHook) {
1119+
if (updateQueue.memoCache != null) {
1120+
// NOTE: this function intentionally does not reset memoCache data. We reuse updateQueue for the memo
1121+
// cache to avoid increasing the size of fibers that don't need a cache, but we don't want to reset
1122+
// the cache when other properties are reset.
1123+
updateQueue.memoCache.index = 0;
1124+
}
1125+
}
1126+
}
1127+
11041128
function useThenable<T>(thenable: Thenable<T>): T {
11051129
// Track the position of the thenable within this fiber.
11061130
const index = thenableIndexCounter;
@@ -2496,17 +2520,15 @@ function pushEffect(
24962520
if (componentUpdateQueue === null) {
24972521
componentUpdateQueue = createFunctionComponentUpdateQueue();
24982522
currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any);
2523+
}
2524+
const lastEffect = componentUpdateQueue.lastEffect;
2525+
if (lastEffect === null) {
24992526
componentUpdateQueue.lastEffect = effect.next = effect;
25002527
} else {
2501-
const lastEffect = componentUpdateQueue.lastEffect;
2502-
if (lastEffect === null) {
2503-
componentUpdateQueue.lastEffect = effect.next = effect;
2504-
} else {
2505-
const firstEffect = lastEffect.next;
2506-
lastEffect.next = effect;
2507-
effect.next = firstEffect;
2508-
componentUpdateQueue.lastEffect = effect;
2509-
}
2528+
const firstEffect = lastEffect.next;
2529+
lastEffect.next = effect;
2530+
effect.next = firstEffect;
2531+
componentUpdateQueue.lastEffect = effect;
25102532
}
25112533
return effect;
25122534
}

packages/react-reconciler/src/__tests__/useMemoCache-test.js

+10-14
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ let assertLog;
1616
let useMemo;
1717
let useState;
1818
let useMemoCache;
19-
let waitForThrow;
2019
let MemoCacheSentinel;
2120
let ErrorBoundary;
2221

@@ -32,7 +31,6 @@ describe('useMemoCache()', () => {
3231
useMemo = React.useMemo;
3332
useMemoCache = require('react/compiler-runtime').c;
3433
useState = React.useState;
35-
waitForThrow = require('internal-test-utils').waitForThrow;
3634
MemoCacheSentinel = Symbol.for('react.memo_cache_sentinel');
3735

3836
class _ErrorBoundary extends React.Component {
@@ -667,7 +665,7 @@ describe('useMemoCache()', () => {
667665
}
668666

669667
// Baseline / source code
670-
function useUserMemo(value) {
668+
function useManualMemo(value) {
671669
return useMemo(() => [value], [value]);
672670
}
673671

@@ -683,24 +681,22 @@ describe('useMemoCache()', () => {
683681
}
684682

685683
/**
686-
* Test case: note that the initial render never completes
684+
* Test with useMemoCache
687685
*/
688686
let root = ReactNoop.createRoot();
689-
const IncorrectInfiniteComponent = makeComponent(useCompilerMemo);
690-
root.render(<IncorrectInfiniteComponent value={2} />);
691-
await waitForThrow(
692-
'Too many re-renders. React limits the number of renders to prevent ' +
693-
'an infinite loop.',
694-
);
687+
const CompilerMemoComponent = makeComponent(useCompilerMemo);
688+
await act(() => {
689+
root.render(<CompilerMemoComponent value={2} />);
690+
});
691+
expect(root).toMatchRenderedOutput(<div>2</div>);
695692

696693
/**
697-
* Baseline test: initial render is expected to complete after a retry
698-
* (triggered by the setState)
694+
* Test with useMemo
699695
*/
700696
root = ReactNoop.createRoot();
701-
const CorrectComponent = makeComponent(useUserMemo);
697+
const HookMemoComponent = makeComponent(useManualMemo);
702698
await act(() => {
703-
root.render(<CorrectComponent value={2} />);
699+
root.render(<HookMemoComponent value={2} />);
704700
});
705701
expect(root).toMatchRenderedOutput(<div>2</div>);
706702
});

0 commit comments

Comments
 (0)