main
js 560 lines 17.6 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 *
7 * @emails react-core
8 * @jest-environment node
9 */
10
11 /* eslint-disable no-func-assign */
12
13 'use strict';
14
15 let React;
16 let ReactNoop;
17 let Suspense;
18 let Scheduler;
19 let act;
20 let waitForAll;
21 let assertLog;
22 let assertConsoleErrorDev;
23
24 describe('memo', () => {
25 beforeEach(() => {
26 jest.resetModules();
27
28 React = require('react');
29 ReactNoop = require('react-noop-renderer');
30 Scheduler = require('scheduler');
31 act = require('internal-test-utils').act;
32 ({Suspense} = React);
33
34 const InternalTestUtils = require('internal-test-utils');
35 waitForAll = InternalTestUtils.waitForAll;
36 assertLog = InternalTestUtils.assertLog;
37 assertConsoleErrorDev = InternalTestUtils.assertConsoleErrorDev;
38 });
39
40 function Text(props) {
41 Scheduler.log(props.text);
42 return <span prop={props.text} />;
43 }
44
45 async function fakeImport(result) {
46 return {default: result};
47 }
48
49 // Tests should run against both the lazy and non-lazy versions of `memo`.
50 // To make the tests work for both versions, we wrap the non-lazy version in
51 // a lazy function component.
52 sharedTests('normal', (...args) => {
53 const Memo = React.memo(...args);
54 function Indirection(props) {
55 return <Memo {...props} />;
56 }
57 return React.lazy(() => fakeImport(Indirection));
58 });
59 sharedTests('lazy', (...args) => {
60 const Memo = React.memo(...args);
61 return React.lazy(() => fakeImport(Memo));
62 });
63
64 function sharedTests(label, memo) {
65 describe(`${label}`, () => {
66 it('bails out on props equality', async () => {
67 function Counter({count}) {
68 return <Text text={count} />;
69 }
70 Counter = memo(Counter);
71
72 await act(() =>
73 ReactNoop.render(
74 <Suspense fallback={<Text text="Loading..." />}>
75 <Counter count={0} />
76 </Suspense>,
77 ),
78 );
79 assertLog(['Loading...', 0]);
80 expect(ReactNoop).toMatchRenderedOutput(<span prop={0} />);
81
82 // Should bail out because props have not changed
83 ReactNoop.render(
84 <Suspense>
85 <Counter count={0} />
86 </Suspense>,
87 );
88 await waitForAll([]);
89 expect(ReactNoop).toMatchRenderedOutput(<span prop={0} />);
90
91 // Should update because count prop changed
92 ReactNoop.render(
93 <Suspense>
94 <Counter count={1} />
95 </Suspense>,
96 );
97 await waitForAll([1]);
98 expect(ReactNoop).toMatchRenderedOutput(<span prop={1} />);
99 });
100
101 it("does not bail out if there's a context change", async () => {
102 const CountContext = React.createContext(0);
103
104 function readContext(Context) {
105 const dispatcher =
106 React
107 .__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE
108 .H;
109 return dispatcher.readContext(Context);
110 }
111
112 function Counter(props) {
113 const count = readContext(CountContext);
114 return <Text text={`${props.label}: ${count}`} />;
115 }
116 Counter = memo(Counter);
117
118 class Parent extends React.Component {
119 state = {count: 0};
120 render() {
121 return (
122 <Suspense fallback={<Text text="Loading..." />}>
123 <CountContext.Provider value={this.state.count}>
124 <Counter label="Count" />
125 </CountContext.Provider>
126 </Suspense>
127 );
128 }
129 }
130
131 const parent = React.createRef(null);
132 await act(() => ReactNoop.render(<Parent ref={parent} />));
133 assertLog(['Loading...', 'Count: 0']);
134 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
135
136 // Should bail out because props have not changed
137 ReactNoop.render(<Parent ref={parent} />);
138 await waitForAll([]);
139 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
140
141 // Should update because there was a context change
142 parent.current.setState({count: 1});
143 await waitForAll(['Count: 1']);
144 expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
145 });
146
147 it('consistent behavior for reusing props object across different function component types', async () => {
148 // This test is a bit complicated because it relates to an
149 // implementation detail. We don't have strong guarantees that the props
150 // object is referentially equal during updates where we can't bail
151 // out anyway — like if the props are shallowly equal, but there's a
152 // local state or context update in the same batch.
153 //
154 // However, as a principle, we should aim to make the behavior
155 // consistent across different ways of memoizing a component. For
156 // example, React.memo has a different internal Fiber layout if you pass
157 // a normal function component (SimpleMemoComponent) versus if you pass
158 // a different type like forwardRef (MemoComponent). But this is an
159 // implementation detail. Wrapping a component in forwardRef (or
160 // React.lazy, etc) shouldn't affect whether the props object is reused
161 // during a bailout.
162 //
163 // So this test isn't primarily about asserting a particular behavior
164 // for reusing the props object; it's about making sure the behavior
165 // is consistent.
166
167 const {useEffect, useState} = React;
168
169 let setSimpleMemoStep;
170 const SimpleMemo = React.memo(props => {
171 const [step, setStep] = useState(0);
172 setSimpleMemoStep = setStep;
173
174 const prevProps = React.useRef(props);
175 useEffect(() => {
176 if (props !== prevProps.current) {
177 prevProps.current = props;
178 Scheduler.log('Props changed [SimpleMemo]');
179 }
180 }, [props]);
181
182 return <Text text={`SimpleMemo [${props.prop}${step}]`} />;
183 });
184
185 let setComplexMemo;
186 const ComplexMemo = React.memo(
187 React.forwardRef((props, ref) => {
188 const [step, setStep] = useState(0);
189 setComplexMemo = setStep;
190
191 const prevProps = React.useRef(props);
192 useEffect(() => {
193 if (props !== prevProps.current) {
194 prevProps.current = props;
195 Scheduler.log('Props changed [ComplexMemo]');
196 }
197 }, [props]);
198
199 return <Text text={`ComplexMemo [${props.prop}${step}]`} />;
200 }),
201 );
202
203 let setMemoWithIndirectionStep;
204 const MemoWithIndirection = React.memo(props => {
205 return <Indirection props={props} />;
206 });
207 function Indirection({props}) {
208 const [step, setStep] = useState(0);
209 setMemoWithIndirectionStep = setStep;
210
211 const prevProps = React.useRef(props);
212 useEffect(() => {
213 if (props !== prevProps.current) {
214 prevProps.current = props;
215 Scheduler.log('Props changed [MemoWithIndirection]');
216 }
217 }, [props]);
218
219 return <Text text={`MemoWithIndirection [${props.prop}${step}]`} />;
220 }
221
222 function setLocalUpdateOnChildren(step) {
223 setSimpleMemoStep(step);
224 setMemoWithIndirectionStep(step);
225 setComplexMemo(step);
226 }
227
228 function App({prop}) {
229 return (
230 <>
231 <SimpleMemo prop={prop} />
232 <ComplexMemo prop={prop} />
233 <MemoWithIndirection prop={prop} />
234 </>
235 );
236 }
237
238 const root = ReactNoop.createRoot();
239 await act(() => {
240 root.render(<App prop="A" />);
241 });
242 assertLog([
243 'SimpleMemo [A0]',
244 'ComplexMemo [A0]',
245 'MemoWithIndirection [A0]',
246 ]);
247
248 // Demonstrate what happens when the props change
249 await act(() => {
250 root.render(<App prop="B" />);
251 });
252 assertLog([
253 'SimpleMemo [B0]',
254 'ComplexMemo [B0]',
255 'MemoWithIndirection [B0]',
256 'Props changed [SimpleMemo]',
257 'Props changed [ComplexMemo]',
258 'Props changed [MemoWithIndirection]',
259 ]);
260
261 // Demonstrate what happens when the prop object changes but there's a
262 // bailout because all the individual props are the same.
263 await act(() => {
264 root.render(<App prop="B" />);
265 });
266 // Nothing re-renders
267 assertLog([]);
268
269 // Demonstrate what happens when the prop object changes, it bails out
270 // because all the props are the same, but we still render the
271 // children because there's a local update in the same batch.
272 await act(() => {
273 root.render(<App prop="B" />);
274 setLocalUpdateOnChildren(1);
275 });
276 // The components should re-render with the new local state, but none
277 // of the props objects should have changed
278 assertLog([
279 'SimpleMemo [B1]',
280 'ComplexMemo [B1]',
281 'MemoWithIndirection [B1]',
282 ]);
283
284 // Do the same thing again. We should still reuse the props object.
285 await act(() => {
286 root.render(<App prop="B" />);
287 setLocalUpdateOnChildren(2);
288 });
289 // The components should re-render with the new local state, but none
290 // of the props objects should have changed
291 assertLog([
292 'SimpleMemo [B2]',
293 'ComplexMemo [B2]',
294 'MemoWithIndirection [B2]',
295 ]);
296 });
297
298 it('accepts custom comparison function', async () => {
299 function Counter({count}) {
300 return <Text text={count} />;
301 }
302 Counter = memo(Counter, (oldProps, newProps) => {
303 Scheduler.log(
304 `Old count: ${oldProps.count}, New count: ${newProps.count}`,
305 );
306 return oldProps.count === newProps.count;
307 });
308
309 await act(() =>
310 ReactNoop.render(
311 <Suspense fallback={<Text text="Loading..." />}>
312 <Counter count={0} />
313 </Suspense>,
314 ),
315 );
316 assertLog(['Loading...', 0]);
317 expect(ReactNoop).toMatchRenderedOutput(<span prop={0} />);
318
319 // Should bail out because props have not changed
320 ReactNoop.render(
321 <Suspense>
322 <Counter count={0} />
323 </Suspense>,
324 );
325 await waitForAll(['Old count: 0, New count: 0']);
326 expect(ReactNoop).toMatchRenderedOutput(<span prop={0} />);
327
328 // Should update because count prop changed
329 ReactNoop.render(
330 <Suspense>
331 <Counter count={1} />
332 </Suspense>,
333 );
334 await waitForAll(['Old count: 0, New count: 1', 1]);
335 expect(ReactNoop).toMatchRenderedOutput(<span prop={1} />);
336 });
337
338 it('supports non-pure class components', async () => {
339 class CounterInner extends React.Component {
340 static defaultProps = {suffix: '!'};
341 render() {
342 return <Text text={this.props.count + String(this.props.suffix)} />;
343 }
344 }
345 const Counter = memo(CounterInner);
346
347 await act(() =>
348 ReactNoop.render(
349 <Suspense fallback={<Text text="Loading..." />}>
350 <Counter count={0} />
351 </Suspense>,
352 ),
353 );
354 assertLog(['Loading...', '0!']);
355 expect(ReactNoop).toMatchRenderedOutput(<span prop="0!" />);
356
357 // Should bail out because props have not changed
358 ReactNoop.render(
359 <Suspense>
360 <Counter count={0} />
361 </Suspense>,
362 );
363 await waitForAll([]);
364 expect(ReactNoop).toMatchRenderedOutput(<span prop="0!" />);
365
366 // Should update because count prop changed
367 ReactNoop.render(
368 <Suspense>
369 <Counter count={1} />
370 </Suspense>,
371 );
372 await waitForAll(['1!']);
373 expect(ReactNoop).toMatchRenderedOutput(<span prop="1!" />);
374 });
375
376 it('warns if the first argument is undefined', () => {
377 memo();
378 assertConsoleErrorDev([
379 'memo: The first argument must be a component. Instead ' +
380 'received: undefined',
381 ]);
382 });
383
384 it('warns if the first argument is null', () => {
385 memo(null);
386 assertConsoleErrorDev([
387 'memo: The first argument must be a component. Instead ' +
388 'received: null',
389 ]);
390 });
391
392 it('does not drop lower priority state updates when bailing out at higher pri (simple)', async () => {
393 const {useState} = React;
394
395 let setCounter;
396 const Counter = memo(() => {
397 const [counter, _setCounter] = useState(0);
398 setCounter = _setCounter;
399 return counter;
400 });
401
402 function App() {
403 return (
404 <Suspense fallback="Loading...">
405 <Counter />
406 </Suspense>
407 );
408 }
409
410 const root = ReactNoop.createRoot();
411 await act(() => {
412 root.render(<App />);
413 });
414 expect(root).toMatchRenderedOutput('0');
415
416 await act(() => {
417 setCounter(1);
418 ReactNoop.discreteUpdates(() => {
419 root.render(<App />);
420 });
421 });
422 expect(root).toMatchRenderedOutput('1');
423 });
424
425 it('does not drop lower priority state updates when bailing out at higher pri (complex)', async () => {
426 const {useState} = React;
427
428 let setCounter;
429 const Counter = memo(
430 () => {
431 const [counter, _setCounter] = useState(0);
432 setCounter = _setCounter;
433 return counter;
434 },
435 (a, b) => a.complexProp.val === b.complexProp.val,
436 );
437
438 function App() {
439 return (
440 <Suspense fallback="Loading...">
441 <Counter complexProp={{val: 1}} />
442 </Suspense>
443 );
444 }
445
446 const root = ReactNoop.createRoot();
447 await act(() => {
448 root.render(<App />);
449 });
450 expect(root).toMatchRenderedOutput('0');
451
452 await act(() => {
453 setCounter(1);
454 ReactNoop.discreteUpdates(() => {
455 root.render(<App />);
456 });
457 });
458 expect(root).toMatchRenderedOutput('1');
459 });
460 });
461
462 it('should skip memo in the stack if neither displayName nor name are present', async () => {
463 const MemoComponent = React.memo(props => [<span />]);
464 ReactNoop.render(
465 <p>
466 <MemoComponent />
467 </p>,
468 );
469 await waitForAll([]);
470 assertConsoleErrorDev([
471 'Each child in a list should have a unique "key" prop. ' +
472 'See https://react.dev/link/warning-keys for more information.\n' +
473 ' in span (at **)\n' +
474 ' in **/ReactMemo-test.js:**:** (at **)',
475 ]);
476 });
477
478 it('should use the inner function name for the stack', async () => {
479 const MemoComponent = React.memo(function Inner(props, ref) {
480 return [<span />];
481 });
482 ReactNoop.render(
483 <p>
484 <MemoComponent />
485 </p>,
486 );
487 await waitForAll([]);
488 assertConsoleErrorDev([
489 'Each child in a list should have a unique "key" prop.' +
490 '\n\nCheck the top-level render call using <Inner>. It was passed a child from Inner. ' +
491 'See https://react.dev/link/warning-keys for more information.\n' +
492 ' in span (at **)\n' +
493 ' in Inner (at **)',
494 ]);
495 });
496
497 it('should use the inner name in the stack', async () => {
498 const fn = (props, ref) => {
499 return [<span />];
500 };
501 Object.defineProperty(fn, 'name', {value: 'Inner'});
502 const MemoComponent = React.memo(fn);
503 ReactNoop.render(
504 <p>
505 <MemoComponent />
506 </p>,
507 );
508 await waitForAll([]);
509 assertConsoleErrorDev([
510 'Each child in a list should have a unique "key" prop.' +
511 '\n\nCheck the top-level render call using <Inner>. It was passed a child from Inner. ' +
512 'See https://react.dev/link/warning-keys for more information.\n' +
513 ' in span (at **)\n' +
514 ' in Inner (at **)',
515 ]);
516 });
517
518 it('can use the outer displayName in the stack', async () => {
519 const MemoComponent = React.memo((props, ref) => {
520 return [<span />];
521 });
522 MemoComponent.displayName = 'Outer';
523 ReactNoop.render(
524 <p>
525 <MemoComponent />
526 </p>,
527 );
528 await waitForAll([]);
529 assertConsoleErrorDev([
530 'Each child in a list should have a unique "key" prop.' +
531 '\n\nCheck the top-level render call using <Outer>. It was passed a child from Outer. ' +
532 'See https://react.dev/link/warning-keys for more information.\n' +
533 ' in span (at **)\n' +
534 ' in Outer (at **)',
535 ]);
536 });
537
538 it('should prefer the inner to the outer displayName in the stack', async () => {
539 const fn = (props, ref) => {
540 return [<span />];
541 };
542 Object.defineProperty(fn, 'name', {value: 'Inner'});
543 const MemoComponent = React.memo(fn);
544 MemoComponent.displayName = 'Outer';
545 ReactNoop.render(
546 <p>
547 <MemoComponent />
548 </p>,
549 );
550 await waitForAll([]);
551 assertConsoleErrorDev([
552 'Each child in a list should have a unique "key" prop.' +
553 '\n\nCheck the top-level render call using <Inner>. It was passed a child from Inner. ' +
554 'See https://react.dev/link/warning-keys for more information.\n' +
555 ' in span (at **)\n' +
556 ' in Inner (at **)',
557 ]);
558 });
559 }
560 });