@samitouri / QOS-React / commits / d72e477814

[compiler runtime] repro: infinite render with useMemoCache + render phase updates (#30849)

Repro for an infinite render bug we found when testing internally. See equivalent codesandbox repro [here](https://codesandbox.io/p/sandbox/epic-euclid-mr7lm3). When render phase updates cause a re-render, useMemoCache arrays for the fiber are [cleared](https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberHooks.js#L819) and [recreated on every retry](https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberHooks.js#L1223) while hook state is preserved. This pattern (queuing re-renders on the current fiber during render) is perfectly valid. I believe this is a bug as React compiler currently replaces `useMemo`s with `useMemoCache` calls and inlined instructions, taking care to preserve existing memoization dependencies. This should be the identity transform, but runtime implementation differences mean that uncompiled code behaves as expected (no infinite render) while compiled code fails to render.

mofeiZ committed Sep 5, 2024 at 13:30 UTC d72e477814209d79173a1b69da3172d891786fec
1 file changed +59
packages/react-reconciler/src/__tests__/useMemoCache-test.js
+59
@@ -13,8 +13,10 @@ let ReactNoop;
13 let Scheduler;
14 let act;
15 let assertLog;
16 +let useMemo;
17 let useState;
18 let useMemoCache;
19 +let waitForThrow;
20 let MemoCacheSentinel;
21 let ErrorBoundary;
22
@@ -27,8 +29,10 @@ describe('useMemoCache()', () => {
29 Scheduler = require('scheduler');
30 act = require('internal-test-utils').act;
31 assertLog = require('internal-test-utils').assertLog;
32 + useMemo = React.useMemo;
33 useMemoCache = require('react/compiler-runtime').c;
34 useState = React.useState;
35 + waitForThrow = require('internal-test-utils').waitForThrow;
36 MemoCacheSentinel = Symbol.for('react.memo_cache_sentinel');
37
38 class _ErrorBoundary extends React.Component {
@@ -645,4 +649,59 @@ describe('useMemoCache()', () => {
649 </>,
650 );
651 });
652 +
653 + // @gate enableUseMemoCacheHook
654 + it('(repro) infinite renders when used with setState during render', async () => {
655 + // Output of react compiler on `useUserMemo`
656 + function useCompilerMemo(value) {
657 + let arr;
658 + const $ = useMemoCache(2);
659 + if ($[0] !== value) {
660 + arr = [value];
661 + $[0] = value;
662 + $[1] = arr;
663 + } else {
664 + arr = $[1];
665 + }
666 + return arr;
667 + }
668 +
669 + // Baseline / source code
670 + function useUserMemo(value) {
671 + return useMemo(() => [value], [value]);
672 + }
673 +
674 + function makeComponent(hook) {
675 + return function Component({value}) {
676 + const state = hook(value);
677 + const [prevState, setPrevState] = useState(null);
678 + if (state !== prevState) {
679 + setPrevState(state);
680 + }
681 + return <div>{state.join(',')}</div>;
682 + };
683 + }
684 +
685 + /**
686 + * Test case: note that the initial render never completes
687 + */
688 + 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 + );
695 +
696 + /**
697 + * Baseline test: initial render is expected to complete after a retry
698 + * (triggered by the setState)
699 + */
700 + root = ReactNoop.createRoot();
701 + const CorrectComponent = makeComponent(useUserMemo);
702 + await act(() => {
703 + root.render(<CorrectComponent value={2} />);
704 + });
705 + expect(root).toMatchRenderedOutput(<div>2</div>);
706 + });
707 });