main
js 677 lines 18 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 let React;
12 let ReactNoop;
13 let Scheduler;
14 let act;
15 let assertConsoleErrorDev;
16 let assertLog;
17 let useMemo;
18 let useState;
19 let useMemoCache;
20 let MemoCacheSentinel;
21 let ErrorBoundary;
22
23 describe('useMemoCache()', () => {
24 beforeEach(() => {
25 jest.resetModules();
26
27 React = require('react');
28 ReactNoop = require('react-noop-renderer');
29 Scheduler = require('scheduler');
30 const InternalTestUtils = require('internal-test-utils');
31 act = InternalTestUtils.act;
32 assertConsoleErrorDev = InternalTestUtils.assertConsoleErrorDev;
33 assertLog = InternalTestUtils.assertLog;
34 useMemo = React.useMemo;
35 useMemoCache = require('react/compiler-runtime').c;
36 useState = React.useState;
37 MemoCacheSentinel = Symbol.for('react.memo_cache_sentinel');
38
39 class _ErrorBoundary extends React.Component {
40 constructor(props) {
41 super(props);
42 this.state = {hasError: false};
43 }
44
45 static getDerivedStateFromError(error) {
46 // Update state so the next render will show the fallback UI.
47 return {hasError: true};
48 }
49
50 componentDidCatch(error, errorInfo) {}
51
52 render() {
53 if (this.state.hasError) {
54 // You can render any custom fallback UI
55 return <h1>Something went wrong.</h1>;
56 }
57
58 return this.props.children;
59 }
60 }
61 ErrorBoundary = _ErrorBoundary;
62 });
63
64 it('render component using cache', async () => {
65 function Component(props) {
66 const cache = useMemoCache(1);
67 expect(Array.isArray(cache)).toBe(true);
68 expect(cache.length).toBe(1);
69 expect(cache[0]).toBe(MemoCacheSentinel);
70
71 return 'Ok';
72 }
73 const root = ReactNoop.createRoot();
74 await act(() => {
75 root.render(<Component />);
76 });
77 expect(root).toMatchRenderedOutput('Ok');
78 });
79
80 it('update component using cache', async () => {
81 let setX;
82 let forceUpdate;
83 function Component(props) {
84 const cache = useMemoCache(5);
85
86 // x is used to produce a `data` object passed to the child
87 const [x, _setX] = useState(0);
88 setX = _setX;
89
90 // n is passed as-is to the child as a cache breaker
91 const [n, setN] = useState(0);
92 forceUpdate = () => setN(a => a + 1);
93
94 const c_0 = x !== cache[0];
95 let data;
96 if (c_0) {
97 data = {text: `Count ${x}`};
98 cache[0] = x;
99 cache[1] = data;
100 } else {
101 data = cache[1];
102 }
103 const c_2 = x !== cache[2];
104 const c_3 = n !== cache[3];
105 let t0;
106 if (c_2 || c_3) {
107 t0 = <Text data={data} n={n} />;
108 cache[2] = x;
109 cache[3] = n;
110 cache[4] = t0;
111 } else {
112 t0 = cache[4];
113 }
114 return t0;
115 }
116 let data;
117 const Text = jest.fn(function Text(props) {
118 data = props.data;
119 return data.text;
120 });
121
122 const root = ReactNoop.createRoot();
123 await act(() => {
124 root.render(<Component />);
125 });
126 expect(root).toMatchRenderedOutput('Count 0');
127 expect(Text).toHaveBeenCalledTimes(1);
128 const data0 = data;
129
130 // Changing x should reset the data object
131 await act(() => {
132 setX(1);
133 });
134 expect(root).toMatchRenderedOutput('Count 1');
135 expect(Text).toHaveBeenCalledTimes(2);
136 expect(data).not.toBe(data0);
137 const data1 = data;
138
139 // Forcing an unrelated update shouldn't recreate the
140 // data object.
141 await act(() => {
142 forceUpdate();
143 });
144 expect(root).toMatchRenderedOutput('Count 1');
145 expect(Text).toHaveBeenCalledTimes(3);
146 expect(data).toBe(data1); // confirm that the cache persisted across renders
147 });
148
149 it('update component using cache with setstate during render', async () => {
150 let setN;
151 function Component(props) {
152 const cache = useMemoCache(5);
153
154 // x is used to produce a `data` object passed to the child
155 const [x] = useState(0);
156
157 const c_0 = x !== cache[0];
158 let data;
159 if (c_0) {
160 data = {text: `Count ${x}`};
161 cache[0] = x;
162 cache[1] = data;
163 } else {
164 data = cache[1];
165 }
166
167 // n is passed as-is to the child as a cache breaker
168 const [n, _setN] = useState(0);
169 setN = _setN;
170
171 if (n === 1) {
172 setN(2);
173 return;
174 }
175
176 const c_2 = x !== cache[2];
177 const c_3 = n !== cache[3];
178 let t0;
179 if (c_2 || c_3) {
180 t0 = <Text data={data} n={n} />;
181 cache[2] = x;
182 cache[3] = n;
183 cache[4] = t0;
184 } else {
185 t0 = cache[4];
186 }
187 return t0;
188 }
189 let data;
190 const Text = jest.fn(function Text(props) {
191 data = props.data;
192 return `${data.text} (n=${props.n})`;
193 });
194
195 const root = ReactNoop.createRoot();
196 await act(() => {
197 root.render(<Component />);
198 });
199 expect(root).toMatchRenderedOutput('Count 0 (n=0)');
200 expect(Text).toHaveBeenCalledTimes(1);
201 const data0 = data;
202
203 // Trigger an update that will cause a setState during render. The `data` prop
204 // does not depend on `n`, and should remain cached.
205 await act(() => {
206 setN(1);
207 });
208 expect(root).toMatchRenderedOutput('Count 0 (n=2)');
209 expect(Text).toHaveBeenCalledTimes(2);
210 expect(data).toBe(data0);
211 });
212
213 it('update component using cache with throw during render', async () => {
214 let setN;
215 let shouldFail = true;
216 function Component(props) {
217 const cache = useMemoCache(5);
218
219 // x is used to produce a `data` object passed to the child
220 const [x] = useState(0);
221
222 const c_0 = x !== cache[0];
223 let data;
224 if (c_0) {
225 data = {text: `Count ${x}`};
226 cache[0] = x;
227 cache[1] = data;
228 } else {
229 data = cache[1];
230 }
231
232 // n is passed as-is to the child as a cache breaker
233 const [n, _setN] = useState(0);
234 setN = _setN;
235
236 if (n === 1) {
237 if (shouldFail) {
238 shouldFail = false;
239 throw new Error('failed');
240 }
241 }
242
243 const c_2 = x !== cache[2];
244 const c_3 = n !== cache[3];
245 let t0;
246 if (c_2 || c_3) {
247 t0 = <Text data={data} n={n} />;
248 cache[2] = x;
249 cache[3] = n;
250 cache[4] = t0;
251 } else {
252 t0 = cache[4];
253 }
254 return t0;
255 }
256 let data;
257 const Text = jest.fn(function Text(props) {
258 data = props.data;
259 return `${data.text} (n=${props.n})`;
260 });
261
262 const root = ReactNoop.createRoot();
263 await act(() => {
264 root.render(
265 <ErrorBoundary>
266 <Component />
267 </ErrorBoundary>,
268 );
269 });
270 expect(root).toMatchRenderedOutput('Count 0 (n=0)');
271 expect(Text).toHaveBeenCalledTimes(1);
272 const data0 = data;
273
274 await act(() => {
275 // this triggers a throw.
276 setN(1);
277 });
278 assertConsoleErrorDev([
279 'Error: There was an error during concurrent rendering but React was able to recover by instead synchronously rendering the entire root.' +
280 '\n in <stack>',
281 ]);
282 expect(root).toMatchRenderedOutput('Count 0 (n=1)');
283 expect(Text).toHaveBeenCalledTimes(2);
284 expect(data).toBe(data0);
285 const data1 = data;
286
287 // Forcing an unrelated update shouldn't recreate the
288 // data object.
289 await act(() => {
290 setN(2);
291 });
292 expect(root).toMatchRenderedOutput('Count 0 (n=2)');
293 expect(Text).toHaveBeenCalledTimes(3);
294 expect(data).toBe(data1); // confirm that the cache persisted across renders
295 });
296
297 it('update component and custom hook with caches', async () => {
298 let setX;
299 let forceUpdate;
300 function Component(props) {
301 const cache = useMemoCache(4);
302
303 // x is used to produce a `data` object passed to the child
304 const [x, _setX] = useState(0);
305 setX = _setX;
306 const c_x = x !== cache[0];
307 cache[0] = x;
308
309 // n is passed as-is to the child as a cache breaker
310 const [n, setN] = useState(0);
311 forceUpdate = () => setN(a => a + 1);
312 const c_n = n !== cache[1];
313 cache[1] = n;
314
315 let _data;
316 if (c_x) {
317 _data = cache[2] = {text: `Count ${x}`};
318 } else {
319 _data = cache[2];
320 }
321 const data = useData(_data);
322 if (c_x || c_n) {
323 return (cache[3] = <Text data={data} n={n} />);
324 } else {
325 return cache[3];
326 }
327 }
328 function useData(data) {
329 const cache = useMemoCache(2);
330 const c_data = data !== cache[0];
331 cache[0] = data;
332 let nextData;
333 if (c_data) {
334 nextData = cache[1] = {text: data.text.toLowerCase()};
335 } else {
336 nextData = cache[1];
337 }
338 return nextData;
339 }
340 let data;
341 const Text = jest.fn(function Text(props) {
342 data = props.data;
343 return data.text;
344 });
345
346 const root = ReactNoop.createRoot();
347 await act(() => {
348 root.render(<Component />);
349 });
350 expect(root).toMatchRenderedOutput('count 0');
351 expect(Text).toHaveBeenCalledTimes(1);
352 const data0 = data;
353
354 // Changing x should reset the data object
355 await act(() => {
356 setX(1);
357 });
358 expect(root).toMatchRenderedOutput('count 1');
359 expect(Text).toHaveBeenCalledTimes(2);
360 expect(data).not.toBe(data0);
361 const data1 = data;
362
363 // Forcing an unrelated update shouldn't recreate the
364 // data object.
365 await act(() => {
366 forceUpdate();
367 });
368 expect(root).toMatchRenderedOutput('count 1');
369 expect(Text).toHaveBeenCalledTimes(3);
370 expect(data).toBe(data1); // confirm that the cache persisted across renders
371 });
372
373 it('reuses computations from suspended/interrupted render attempts during an update', async () => {
374 // This test demonstrates the benefit of a shared memo cache. By "shared" I
375 // mean multiple concurrent render attempts of the same component/hook use
376 // the same cache. (When the feature flag is off, we don't do this — the
377 // cache is copy-on-write.)
378 //
379 // If an update is interrupted, either because it suspended or because of
380 // another update, we can reuse the memoized computations from the previous
381 // attempt. We can do this because the React Compiler performs atomic writes
382 // to the memo cache, i.e. it will not record the inputs to a memoization
383 // without also recording its output.
384 //
385 // This gives us a form of "resuming" within components and hooks.
386 //
387 // This only works when updating a component that already mounted. It has no
388 // impact during initial render, because the memo cache is stored on the
389 // fiber, and since we have not implemented resuming for fibers, it's always
390 // a fresh memo cache, anyway.
391 //
392 // However, this alone is pretty useful — it happens whenever you update the
393 // UI with fresh data after a mutation/action, which is extremely common in
394 // a Suspense-driven (e.g. RSC or Relay) app. That's the scenario that this
395 // test simulates.
396 //
397 // So the impact of this feature is faster data mutations/actions.
398
399 function someExpensiveProcessing(t) {
400 Scheduler.log(`Some expensive processing... [${t}]`);
401 return t;
402 }
403
404 function useWithLog(t, msg) {
405 try {
406 return React.use(t);
407 } catch (x) {
408 Scheduler.log(`Suspend! [${msg}]`);
409 throw x;
410 }
411 }
412
413 // Original code:
414 //
415 // function Data({chunkA, chunkB}) {
416 // const a = someExpensiveProcessing(useWithLog(chunkA, 'chunkA'));
417 // const b = useWithLog(chunkB, 'chunkB');
418 // return (
419 // <>
420 // {a}
421 // {b}
422 // </>
423 // );
424 // }
425 //
426 // function Input() {
427 // const [input, _setText] = useState('');
428 // return input;
429 // }
430 //
431 // function App({chunkA, chunkB}) {
432 // return (
433 // <>
434 // <div>
435 // Input: <Input />
436 // </div>
437 // <div>
438 // Data: <Data chunkA={chunkA} chunkB={chunkB} />
439 // </div>
440 // </>
441 // );
442 // }
443 function Data(t0) {
444 const $ = useMemoCache(5);
445 const {chunkA, chunkB} = t0;
446 const t1 = useWithLog(chunkA, 'chunkA');
447 let t2;
448
449 if ($[0] !== t1) {
450 t2 = someExpensiveProcessing(t1);
451 $[0] = t1;
452 $[1] = t2;
453 } else {
454 t2 = $[1];
455 }
456
457 const a = t2;
458 const b = useWithLog(chunkB, 'chunkB');
459 let t3;
460
461 if ($[2] !== a || $[3] !== b) {
462 t3 = (
463 <>
464 {a}
465 {b}
466 </>
467 );
468 $[2] = a;
469 $[3] = b;
470 $[4] = t3;
471 } else {
472 t3 = $[4];
473 }
474
475 return t3;
476 }
477
478 let setInput;
479 function Input() {
480 const [input, _set] = useState('');
481 setInput = _set;
482 return input;
483 }
484
485 function App(t0) {
486 const $ = useMemoCache(4);
487 const {chunkA, chunkB} = t0;
488 let t1;
489
490 if ($[0] === Symbol.for('react.memo_cache_sentinel')) {
491 t1 = (
492 <div>
493 Input: <Input />
494 </div>
495 );
496 $[0] = t1;
497 } else {
498 t1 = $[0];
499 }
500
501 let t2;
502
503 if ($[1] !== chunkA || $[2] !== chunkB) {
504 t2 = (
505 <>
506 {t1}
507 <div>
508 Data: <Data chunkA={chunkA} chunkB={chunkB} />
509 </div>
510 </>
511 );
512 $[1] = chunkA;
513 $[2] = chunkB;
514 $[3] = t2;
515 } else {
516 t2 = $[3];
517 }
518
519 return t2;
520 }
521
522 function createInstrumentedResolvedPromise(value) {
523 return {
524 then() {},
525 status: 'fulfilled',
526 value,
527 };
528 }
529
530 function createDeferred() {
531 let resolve;
532 const p = new Promise(res => {
533 resolve = res;
534 });
535 p.resolve = resolve;
536 return p;
537 }
538
539 // Initial render. We pass the data in as two separate "chunks" to simulate
540 // a stream (e.g. RSC).
541 const root = ReactNoop.createRoot();
542 const initialChunkA = createInstrumentedResolvedPromise('A1');
543 const initialChunkB = createInstrumentedResolvedPromise('B1');
544 await act(() =>
545 root.render(<App chunkA={initialChunkA} chunkB={initialChunkB} />),
546 );
547 assertLog(['Some expensive processing... [A1]']);
548 expect(root).toMatchRenderedOutput(
549 <>
550 <div>Input: </div>
551 <div>Data: A1B1</div>
552 </>,
553 );
554
555 // Update the UI in a transition. This would happen after a data mutation.
556 const updatedChunkA = createDeferred();
557 const updatedChunkB = createDeferred();
558 await act(() => {
559 React.startTransition(() => {
560 root.render(<App chunkA={updatedChunkA} chunkB={updatedChunkB} />);
561 });
562 });
563 assertLog(['Suspend! [chunkA]']);
564
565 // The data starts to stream in. Loading the data in the first chunk
566 // triggers an expensive computation in the UI. Later, we'll test whether
567 // this computation is reused.
568 await act(() => updatedChunkA.resolve('A2'));
569 assertLog(['Some expensive processing... [A2]', 'Suspend! [chunkB]']);
570
571 // The second chunk hasn't loaded yet, so we're still showing the
572 // initial UI.
573 expect(root).toMatchRenderedOutput(
574 <>
575 <div>Input: </div>
576 <div>Data: A1B1</div>
577 </>,
578 );
579
580 // While waiting for the data to finish loading, update a different part of
581 // the screen. This interrupts the refresh transition.
582 //
583 // In a real app, this might be an input or hover event.
584 await act(() => setInput('hi!'));
585
586 // Once the input has updated, we go back to rendering the transition.
587 if (gate(flags => flags.enableNoCloningMemoCache)) {
588 // We did not have process the first chunk again. We reused the
589 // computation from the earlier attempt.
590 assertLog(['Suspend! [chunkB]']);
591 } else {
592 // Because we clone/reset the memo cache after every aborted attempt, we
593 // must process the first chunk again.
594 assertLog(['Some expensive processing... [A2]', 'Suspend! [chunkB]']);
595 }
596
597 expect(root).toMatchRenderedOutput(
598 <>
599 <div>Input: hi!</div>
600 <div>Data: A1B1</div>
601 </>,
602 );
603
604 // Finish loading the data.
605 await act(() => updatedChunkB.resolve('B2'));
606 if (gate(flags => flags.enableNoCloningMemoCache)) {
607 // We did not have process the first chunk again. We reused the
608 // computation from the earlier attempt.
609 assertLog([]);
610 } else {
611 // Because we clone/reset the memo cache after every aborted attempt, we
612 // must process the first chunk again.
613 //
614 // That's three total times we've processed the first chunk, compared to
615 // just once when enableNoCloningMemoCache is on.
616 assertLog(['Some expensive processing... [A2]']);
617 }
618 expect(root).toMatchRenderedOutput(
619 <>
620 <div>Input: hi!</div>
621 <div>Data: A2B2</div>
622 </>,
623 );
624 });
625
626 it('(repro) infinite renders when used with setState during render', async () => {
627 // Output of react compiler on `useUserMemo`
628 function useCompilerMemo(value) {
629 let arr;
630 const $ = useMemoCache(2);
631 if ($[0] !== value) {
632 arr = [value];
633 $[0] = value;
634 $[1] = arr;
635 } else {
636 arr = $[1];
637 }
638 return arr;
639 }
640
641 // Baseline / source code
642 function useManualMemo(value) {
643 return useMemo(() => [value], [value]);
644 }
645
646 function makeComponent(hook) {
647 return function Component({value}) {
648 const state = hook(value);
649 const [prevState, setPrevState] = useState(null);
650 if (state !== prevState) {
651 setPrevState(state);
652 }
653 return <div>{state.join(',')}</div>;
654 };
655 }
656
657 /**
658 * Test with useMemoCache
659 */
660 let root = ReactNoop.createRoot();
661 const CompilerMemoComponent = makeComponent(useCompilerMemo);
662 await act(() => {
663 root.render(<CompilerMemoComponent value={2} />);
664 });
665 expect(root).toMatchRenderedOutput(<div>2</div>);
666
667 /**
668 * Test with useMemo
669 */
670 root = ReactNoop.createRoot();
671 const HookMemoComponent = makeComponent(useManualMemo);
672 await act(() => {
673 root.render(<HookMemoComponent value={2} />);
674 });
675 expect(root).toMatchRenderedOutput(<div>2</div>);
676 });
677 });