Skip to content

Commit 4adeee9

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: 4d25c92 Pull Request resolved: #30889
1 parent a06cd9e commit 4adeee9

File tree

2 files changed

+32
-27
lines changed

2 files changed

+32
-27
lines changed

packages/react-reconciler/src/ReactFiberHooks.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -828,7 +828,9 @@ function renderWithHooksAgain<Props, SecondArg>(
828828
currentHook = null;
829829
workInProgressHook = null;
830830

831-
workInProgress.updateQueue = null;
831+
if (workInProgress.updateQueue != null) {
832+
resetFunctionComponentUpdateQueue(workInProgress.updateQueue);
833+
}
832834

833835
if (__DEV__) {
834836
// Also validate hook order for cascading updates.
@@ -1101,6 +1103,22 @@ if (enableUseMemoCacheHook) {
11011103
};
11021104
}
11031105

1106+
function resetFunctionComponentUpdateQueue(
1107+
updateQueue: FunctionComponentUpdateQueue,
1108+
): void {
1109+
updateQueue.lastEffect = null;
1110+
updateQueue.events = null;
1111+
updateQueue.stores = null;
1112+
if (enableUseMemoCacheHook) {
1113+
if (updateQueue.memoCache != null) {
1114+
// NOTE: this function intentionally does not reset memoCache data. We reuse updateQueue for the memo
1115+
// cache to avoid increasing the size of fibers that don't need a cache, but we don't want to reset
1116+
// the cache when other properties are reset.
1117+
updateQueue.memoCache.index = 0;
1118+
}
1119+
}
1120+
}
1121+
11041122
function useThenable<T>(thenable: Thenable<T>): T {
11051123
// Track the position of the thenable within this fiber.
11061124
const index = thenableIndexCounter;

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

Lines changed: 13 additions & 26 deletions
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 {
@@ -630,18 +628,9 @@ describe('useMemoCache()', () => {
630628

631629
// Finish loading the data.
632630
await act(() => updatedChunkB.resolve('B2'));
633-
if (gate(flags => flags.enableNoCloningMemoCache)) {
634-
// We did not have process the first chunk again. We reused the
635-
// computation from the earlier attempt.
636-
assertLog([]);
637-
} else {
638-
// Because we clone/reset the memo cache after every aborted attempt, we
639-
// must process the first chunk again.
640-
//
641-
// That's three total times we've processed the first chunk, compared to
642-
// just once when enableNoCloningMemoCache is on.
643-
assertLog(['Some expensive processing... [A2]']);
644-
}
631+
// We did not have process the first chunk again. We reused the
632+
// computation from the earlier attempt.
633+
assertLog([]);
645634
expect(root).toMatchRenderedOutput(
646635
<>
647636
<div>Input: hi!</div>
@@ -667,7 +656,7 @@ describe('useMemoCache()', () => {
667656
}
668657

669658
// Baseline / source code
670-
function useUserMemo(value) {
659+
function useManualMemo(value) {
671660
return useMemo(() => [value], [value]);
672661
}
673662

@@ -683,24 +672,22 @@ describe('useMemoCache()', () => {
683672
}
684673

685674
/**
686-
* Test case: note that the initial render never completes
675+
* Test with useMemoCache
687676
*/
688677
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-
);
678+
const CompilerMemoComponent = makeComponent(useCompilerMemo);
679+
await act(() => {
680+
root.render(<CompilerMemoComponent value={2} />);
681+
});
682+
expect(root).toMatchRenderedOutput(<div>2</div>);
695683

696684
/**
697-
* Baseline test: initial render is expected to complete after a retry
698-
* (triggered by the setState)
685+
* Test with useMemo
699686
*/
700687
root = ReactNoop.createRoot();
701-
const CorrectComponent = makeComponent(useUserMemo);
688+
const HookMemoComponent = makeComponent(useManualMemo);
702689
await act(() => {
703-
root.render(<CorrectComponent value={2} />);
690+
root.render(<HookMemoComponent value={2} />);
704691
});
705692
expect(root).toMatchRenderedOutput(<div>2</div>);
706693
});

0 commit comments

Comments
 (0)