diff --git a/packages/react-reconciler/src/__tests__/useMemo-invalidation-test.js b/packages/react-reconciler/src/__tests__/useMemo-invalidation-test.js new file mode 100644 index 0000000000000..a8f3e23bfcc27 --- /dev/null +++ b/packages/react-reconciler/src/__tests__/useMemo-invalidation-test.js @@ -0,0 +1,89 @@ +let React; +let ReactNoop; +let act; +let useState; + +describe('possible useMemo invalidation bug', () => { + beforeEach(() => { + jest.resetModules(); + + React = require('react'); + ReactNoop = require('react-noop-renderer'); + act = require('jest-react').act; + useState = React.useState; + }); + + // @gate experimental || www + test('caches values whose inputs are unchanged after setstate in render (useMemo)', async () => { + let setX; + function Component({limit}) { + const [x, _setX] = useState(0); + setX = _setX; + + // `x` is a controlled state that can't be set higher than the provided `limit` + if (x > limit) { + setX(limit); + } + + // `obj` is an object that captures the value of `x` + const obj = React.useMemo( + // POSSIBLE BUG: because useMemo tries to reuse the WIP memo value after a setState-in-render, + // the previous value is discarded. This means that even though the final render has the same + // inputs as the last commit, the memoized value is discarded and recreated, breaking + // memoization of all the child components down the tree. + // + // 1) First render: cache is initialized to {count: 0} with deps of [x=0, limit=10] + // 2) Update: cache updated to {count: 10} with deps of [x=10, limit=10] + // 3) Second update: + // 3a) initially renders and caches {count: 12} with deps of [x=12, limit=10] + // 3b) re-renders bc of setstate, caches {count: 10} with deps of [x=10, limit=10] + // If this last step started from the `current` fiber's memo cache (from 2, instead of from 3a), + // then it would not re-execute and preserve object identity + () => { + return {count: x}; + }, + // Note that `limit` isn't technically a dependency, + // it's included here to show that even if we modeled that + // `x` can depend on the value of `limit`, it isn't sufficient + // to avoid breaking memoization across renders + [x, limit], + ); + + return ; + } + + const Child = React.memo(function Child({obj}) { + const text = React.useMemo(() => { + return {text: `Count ${obj.count}`}; + }, [obj]); + return ; + }); + + // Text should only ever re-render if the object identity of `value` + // changes. + let renderCount = 0; + const Text = React.memo(function Text({value}) { + renderCount++; + return value.text; + }); + + const root = ReactNoop.createRoot(); + await act(async () => { + root.render(); + }); + expect(root).toMatchRenderedOutput('Count 0'); + expect(renderCount).toBe(1); + + await act(async () => { + setX(10); // set to the limit + }); + expect(root).toMatchRenderedOutput('Count 10'); + expect(renderCount).toBe(2); + + await act(async () => { + setX(12); // exceeds limit, will be reset in setState during render + }); + expect(root).toMatchRenderedOutput('Count 10'); + expect(renderCount).toBe(2); // should not re-render, since value has not changed + }); +});