Skip to content

Commit 2510af9

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: d0508ab Pull Request resolved: #30889
1 parent a06cd9e commit 2510af9

File tree

2 files changed

+43
-53
lines changed

2 files changed

+43
-53
lines changed

packages/react-reconciler/src/ReactFiberHooks.js

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -522,7 +522,9 @@ export function renderWithHooks<Props, SecondArg>(
522522
}
523523

524524
workInProgress.memoizedState = null;
525-
workInProgress.updateQueue = null;
525+
if (workInProgress.updateQueue != null) {
526+
resetFunctionComponentUpdateQueue(workInProgress.updateQueue);
527+
}
526528
workInProgress.lanes = NoLanes;
527529

528530
// The following should have already been reset
@@ -828,7 +830,9 @@ function renderWithHooksAgain<Props, SecondArg>(
828830
currentHook = null;
829831
workInProgressHook = null;
830832

831-
workInProgress.updateQueue = null;
833+
if (workInProgress.updateQueue != null) {
834+
resetFunctionComponentUpdateQueue(workInProgress.updateQueue);
835+
}
832836

833837
if (__DEV__) {
834838
// Also validate hook order for cascading updates.
@@ -1101,6 +1105,22 @@ if (enableUseMemoCacheHook) {
11011105
};
11021106
}
11031107

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

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

Lines changed: 21 additions & 51 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 {
@@ -577,11 +575,7 @@ describe('useMemoCache()', () => {
577575
'Some expensive processing... [A2]',
578576
'Suspend! [chunkB]',
579577

580-
...(gate('enableSiblingPrerendering')
581-
? gate('enableNoCloningMemoCache')
582-
? ['Suspend! [chunkB]']
583-
: ['Some expensive processing... [A2]', 'Suspend! [chunkB]']
584-
: []),
578+
...(gate('enableSiblingPrerendering') ? ['Suspend! [chunkB]'] : []),
585579
]);
586580

587581
// The second chunk hasn't loaded yet, so we're still showing the
@@ -600,26 +594,13 @@ describe('useMemoCache()', () => {
600594
await act(() => setInput('hi!'));
601595

602596
// Once the input has updated, we go back to rendering the transition.
603-
if (gate(flags => flags.enableNoCloningMemoCache)) {
604-
// We did not have process the first chunk again. We reused the
605-
// computation from the earlier attempt.
606-
assertLog([
607-
'Suspend! [chunkB]',
608-
609-
...(gate('enableSiblingPrerendering') ? ['Suspend! [chunkB]'] : []),
610-
]);
611-
} else {
612-
// Because we clone/reset the memo cache after every aborted attempt, we
613-
// must process the first chunk again.
614-
assertLog([
615-
'Some expensive processing... [A2]',
616-
'Suspend! [chunkB]',
617-
618-
...(gate('enableSiblingPrerendering')
619-
? ['Some expensive processing... [A2]', 'Suspend! [chunkB]']
620-
: []),
621-
]);
622-
}
597+
// We did not have process the first chunk again. We reused the
598+
// computation from the earlier attempt.
599+
assertLog([
600+
'Suspend! [chunkB]',
601+
602+
...(gate('enableSiblingPrerendering') ? ['Suspend! [chunkB]'] : []),
603+
]);
623604

624605
expect(root).toMatchRenderedOutput(
625606
<>
@@ -630,18 +611,9 @@ describe('useMemoCache()', () => {
630611

631612
// Finish loading the data.
632613
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-
}
614+
// We did not have process the first chunk again. We reused the
615+
// computation from the earlier attempt.
616+
assertLog([]);
645617
expect(root).toMatchRenderedOutput(
646618
<>
647619
<div>Input: hi!</div>
@@ -667,7 +639,7 @@ describe('useMemoCache()', () => {
667639
}
668640

669641
// Baseline / source code
670-
function useUserMemo(value) {
642+
function useManualMemo(value) {
671643
return useMemo(() => [value], [value]);
672644
}
673645

@@ -683,24 +655,22 @@ describe('useMemoCache()', () => {
683655
}
684656

685657
/**
686-
* Test case: note that the initial render never completes
658+
* Test with useMemoCache
687659
*/
688660
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-
);
661+
const CompilerMemoComponent = makeComponent(useCompilerMemo);
662+
await act(() => {
663+
root.render(<CompilerMemoComponent value={2} />);
664+
});
665+
expect(root).toMatchRenderedOutput(<div>2</div>);
695666

696667
/**
697-
* Baseline test: initial render is expected to complete after a retry
698-
* (triggered by the setState)
668+
* Test with useMemo
699669
*/
700670
root = ReactNoop.createRoot();
701-
const CorrectComponent = makeComponent(useUserMemo);
671+
const HookMemoComponent = makeComponent(useManualMemo);
702672
await act(() => {
703-
root.render(<CorrectComponent value={2} />);
673+
root.render(<HookMemoComponent value={2} />);
704674
});
705675
expect(root).toMatchRenderedOutput(<div>2</div>);
706676
});

0 commit comments

Comments
 (0)