main
js 570 lines 16 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 */
9
10 'use strict';
11
12 let useSyncExternalStore;
13 let React;
14 let ReactNoop;
15 let Scheduler;
16 let act;
17 let useLayoutEffect;
18 let forwardRef;
19 let useImperativeHandle;
20 let useRef;
21 let useState;
22 let use;
23 let startTransition;
24 let waitFor;
25 let waitForAll;
26 let assertLog;
27 let Suspense;
28 let useMemo;
29 let textCache;
30
31 // This tests the native useSyncExternalStore implementation, not the shim.
32 // Tests that apply to both the native implementation and the shim should go
33 // into useSyncExternalStoreShared-test.js. The reason they are separate is
34 // because at some point we may start running the shared tests against vendored
35 // React DOM versions (16, 17, etc) instead of React Noop.
36 describe('useSyncExternalStore', () => {
37 beforeEach(() => {
38 jest.resetModules();
39
40 React = require('react');
41 ReactNoop = require('react-noop-renderer');
42 Scheduler = require('scheduler');
43 useLayoutEffect = React.useLayoutEffect;
44 useImperativeHandle = React.useImperativeHandle;
45 forwardRef = React.forwardRef;
46 useRef = React.useRef;
47 useState = React.useState;
48 use = React.use;
49 useSyncExternalStore = React.useSyncExternalStore;
50 startTransition = React.startTransition;
51 Suspense = React.Suspense;
52 useMemo = React.useMemo;
53 textCache = new Map();
54 const InternalTestUtils = require('internal-test-utils');
55 waitFor = InternalTestUtils.waitFor;
56 waitForAll = InternalTestUtils.waitForAll;
57 assertLog = InternalTestUtils.assertLog;
58
59 act = require('internal-test-utils').act;
60 });
61
62 function resolveText(text) {
63 const record = textCache.get(text);
64 if (record === undefined) {
65 const newRecord = {
66 status: 'resolved',
67 value: text,
68 };
69 textCache.set(text, newRecord);
70 } else if (record.status === 'pending') {
71 const thenable = record.value;
72 record.status = 'resolved';
73 record.value = text;
74 thenable.pings.forEach(t => t());
75 }
76 }
77 function readText(text) {
78 const record = textCache.get(text);
79 if (record !== undefined) {
80 switch (record.status) {
81 case 'pending':
82 throw record.value;
83 case 'rejected':
84 throw record.value;
85 case 'resolved':
86 return record.value;
87 }
88 } else {
89 const thenable = {
90 pings: [],
91 then(resolve) {
92 if (newRecord.status === 'pending') {
93 thenable.pings.push(resolve);
94 } else {
95 Promise.resolve().then(() => resolve(newRecord.value));
96 }
97 },
98 };
99
100 const newRecord = {
101 status: 'pending',
102 value: thenable,
103 };
104 textCache.set(text, newRecord);
105
106 throw thenable;
107 }
108 }
109
110 function AsyncText({text}) {
111 const result = readText(text);
112 Scheduler.log(text);
113 return result;
114 }
115
116 function Text({text}) {
117 Scheduler.log(text);
118 return text;
119 }
120
121 function createExternalStore(initialState) {
122 const listeners = new Set();
123 let currentState = initialState;
124 return {
125 set(text) {
126 currentState = text;
127 ReactNoop.batchedUpdates(() => {
128 listeners.forEach(listener => listener());
129 });
130 },
131 subscribe(listener) {
132 listeners.add(listener);
133 return () => listeners.delete(listener);
134 },
135 getState() {
136 return currentState;
137 },
138 getSubscriberCount() {
139 return listeners.size;
140 },
141 };
142 }
143
144 it(
145 'detects interleaved mutations during a concurrent read before ' +
146 'layout effects fire',
147 async () => {
148 const store1 = createExternalStore(0);
149 const store2 = createExternalStore(0);
150
151 const Child = forwardRef(({store, label}, ref) => {
152 const value = useSyncExternalStore(store.subscribe, store.getState);
153 useImperativeHandle(ref, () => {
154 return value;
155 }, []);
156 return <Text text={label + value} />;
157 });
158
159 function App({store}) {
160 const refA = useRef(null);
161 const refB = useRef(null);
162 const refC = useRef(null);
163 useLayoutEffect(() => {
164 // This layout effect reads children that depend on an external store.
165 // This demostrates whether the children are consistent when the
166 // layout phase runs.
167 const aText = refA.current;
168 const bText = refB.current;
169 const cText = refC.current;
170 Scheduler.log(
171 `Children observed during layout: A${aText}B${bText}C${cText}`,
172 );
173 });
174 return (
175 <>
176 <Child store={store} ref={refA} label="A" />
177 <Child store={store} ref={refB} label="B" />
178 <Child store={store} ref={refC} label="C" />
179 </>
180 );
181 }
182
183 const root = ReactNoop.createRoot();
184 await act(async () => {
185 // Start a concurrent render that reads from the store, then yield.
186 startTransition(() => {
187 root.render(<App store={store1} />);
188 });
189
190 await waitFor(['A0', 'B0']);
191
192 // During an interleaved event, the store is mutated.
193 store1.set(1);
194
195 // Then we continue rendering.
196 await waitForAll([
197 // C reads a newer value from the store than A or B, which means they
198 // are inconsistent.
199 'C1',
200
201 // Before committing the layout effects, React detects that the store
202 // has been mutated. So it throws out the entire completed tree and
203 // re-renders the new values.
204 'A1',
205 'B1',
206 'C1',
207 // The layout effects reads consistent children.
208 'Children observed during layout: A1B1C1',
209 ]);
210 });
211
212 // Now we're going test the same thing during an update that
213 // switches stores.
214 await act(async () => {
215 startTransition(() => {
216 root.render(<App store={store2} />);
217 });
218
219 // Start a concurrent render that reads from the store, then yield.
220 await waitFor(['A0', 'B0']);
221
222 // During an interleaved event, the store is mutated.
223 store2.set(1);
224
225 // Then we continue rendering.
226 await waitForAll([
227 // C reads a newer value from the store than A or B, which means they
228 // are inconsistent.
229 'C1',
230
231 // Before committing the layout effects, React detects that the store
232 // has been mutated. So it throws out the entire completed tree and
233 // re-renders the new values.
234 'A1',
235 'B1',
236 'C1',
237 // The layout effects reads consistent children.
238 'Children observed during layout: A1B1C1',
239 ]);
240 });
241 },
242 );
243
244 it('next value is correctly cached when state is dispatched in render phase', async () => {
245 const store = createExternalStore('value:initial');
246
247 function App() {
248 const value = useSyncExternalStore(store.subscribe, store.getState);
249 const [sameValue, setSameValue] = useState(value);
250 if (value !== sameValue) setSameValue(value);
251 return <Text text={value} />;
252 }
253
254 const root = ReactNoop.createRoot();
255 await act(() => {
256 // Start a render that reads from the store and yields value
257 root.render(<App />);
258 });
259 assertLog(['value:initial']);
260
261 await act(() => {
262 store.set('value:changed');
263 });
264 assertLog(['value:changed']);
265
266 // If cached value was updated, we expect a re-render
267 await act(() => {
268 store.set('value:initial');
269 });
270 assertLog(['value:initial']);
271 });
272
273 it(
274 'regression: suspending in shell after synchronously patching ' +
275 'up store mutation',
276 async () => {
277 // Tests a case where a store is mutated during a concurrent event, then
278 // during the sync re-render, a synchronous render is triggered.
279
280 const store = createExternalStore('Initial');
281
282 let resolve;
283 const promise = new Promise(r => {
284 resolve = r;
285 });
286
287 function A() {
288 const value = useSyncExternalStore(store.subscribe, store.getState);
289
290 if (value === 'Updated') {
291 try {
292 use(promise);
293 } catch (x) {
294 Scheduler.log('Suspend A');
295 throw x;
296 }
297 }
298
299 return <Text text={'A: ' + value} />;
300 }
301
302 function B() {
303 const value = useSyncExternalStore(store.subscribe, store.getState);
304 return <Text text={'B: ' + value} />;
305 }
306
307 function App() {
308 return (
309 <>
310 <span>
311 <A />
312 </span>
313 <span>
314 <B />
315 </span>
316 </>
317 );
318 }
319
320 const root = ReactNoop.createRoot();
321 await act(async () => {
322 // A and B both read from the same store. Partially render A.
323 startTransition(() => root.render(<App />));
324 // A reads the initial value of the store.
325 await waitFor(['A: Initial']);
326
327 // Before B renders, mutate the store.
328 store.set('Updated');
329 });
330 assertLog([
331 // B reads the updated value of the store.
332 'B: Updated',
333 // This should a synchronous re-render of A using the updated value. In
334 // this test, this causes A to suspend.
335 'Suspend A',
336 // pre-warming
337 'B: Updated',
338 ]);
339 // Nothing has committed, because A suspended and no fallback
340 // was provided.
341 expect(root).toMatchRenderedOutput(null);
342
343 // Resolve the data and finish rendering.
344 await act(() => resolve());
345 assertLog(['A: Updated', 'B: Updated']);
346 expect(root).toMatchRenderedOutput(
347 <>
348 <span>A: Updated</span>
349 <span>B: Updated</span>
350 </>,
351 );
352 },
353 );
354
355 // Regression test for https://github.com/facebook/react/issues/27670
356 it('detects store mutations from a layout effect while an Activity subtree is being revealed', async () => {
357 const store = createExternalStore('revision:1');
358
359 function App({mode, revision}) {
360 return (
361 <React.Activity mode={mode}>
362 <Wrapper revision={revision}>
363 <Subscriber />
364 </Wrapper>
365 </React.Activity>
366 );
367 }
368
369 function Wrapper({children, revision}) {
370 useLayoutEffect(() => {
371 store.set('revision:' + revision);
372 }, [revision]);
373
374 return (
375 <>
376 wrapper:{revision}
377 {', '}
378 {children}
379 </>
380 );
381 }
382
383 function Subscriber() {
384 const revision = useSyncExternalStore(store.subscribe, store.getState);
385 return <Text text={revision} />;
386 }
387
388 const root = ReactNoop.createRoot();
389
390 // Mount the app
391 await act(() => {
392 root.render(<App mode="visible" revision="1" />);
393 });
394 assertLog(['revision:1']);
395 expect(root).toMatchRenderedOutput('wrapper:1, revision:1');
396 expect(store.getSubscriberCount()).toBe(1);
397
398 // Hide the subtree. React unsubscribes from the store.
399 await act(() => {
400 root.render(<App mode="hidden" revision="1" />);
401 });
402 assertLog(['revision:1']);
403 expect(store.getSubscriberCount()).toBe(0);
404
405 // Show the subtree again. A layout effect mutates the store during the
406 // reveal, after the Subscriber rendered but before it resubscribed. When
407 // it resubscribes, it must detect the mutation it missed.
408 await act(() => {
409 root.render(<App mode="visible" revision="2" />);
410 });
411 assertLog(['revision:1', 'revision:2']);
412 expect(store.getSubscriberCount()).toBe(1);
413 expect(root).toMatchRenderedOutput('wrapper:2, revision:2');
414 });
415
416 // Regression test for https://github.com/facebook/react/issues/27670
417 it(
418 'detects store mutations that happened while an Activity subtree was ' +
419 'hidden, even if the subtree bails out of rendering when revealed',
420 async () => {
421 const store = createExternalStore('initial');
422
423 // Memoized so that revealing the Activity boundary doesn't re-render
424 // the subscriber. This matches components memoized by React.memo or
425 // React Compiler.
426 const Subscriber = React.memo(({label}) => {
427 const value = useSyncExternalStore(store.subscribe, store.getState);
428 return <Text text={label + ':' + value} />;
429 });
430
431 function App({mode, label}) {
432 return (
433 <React.Activity mode={mode}>
434 <Subscriber label={label} />
435 </React.Activity>
436 );
437 }
438
439 const root = ReactNoop.createRoot();
440 await act(() => {
441 root.render(<App mode="visible" label="a" />);
442 });
443 assertLog(['a:initial']);
444 expect(root).toMatchRenderedOutput('a:initial');
445 expect(store.getSubscriberCount()).toBe(1);
446
447 // Re-render the subscriber once with different props, with no store
448 // change. This replaces its effect list with one that contains only
449 // the subscription effect, no interleaved mutation check.
450 await act(() => {
451 root.render(<App mode="visible" label="b" />);
452 });
453 assertLog(['b:initial']);
454 expect(root).toMatchRenderedOutput('b:initial');
455
456 // Hide the subtree. React unsubscribes from the store.
457 await act(() => {
458 root.render(<App mode="hidden" label="b" />);
459 });
460 expect(store.getSubscriberCount()).toBe(0);
461
462 // Mutate the store while the subtree is hidden. Nothing is subscribed,
463 // so no update is scheduled.
464 await act(() => {
465 store.set('updated');
466 });
467 assertLog([]);
468
469 // Show the subtree again. The memoized component bails out of
470 // rendering, so resubscribing to the store is the only chance to
471 // detect the mutation that happened while it was hidden.
472 await act(() => {
473 root.render(<App mode="visible" label="b" />);
474 });
475 assertLog(['b:updated']);
476 expect(store.getSubscriberCount()).toBe(1);
477 expect(root).toMatchRenderedOutput('b:updated');
478 },
479 );
480
481 it('regression: does not infinite loop for only changing store reference in render', async () => {
482 let store = {value: {}};
483 let listeners = [];
484
485 const ExternalStore = {
486 set(value) {
487 // Change the store ref, but not the value.
488 // This will cause a new snapshot to be returned if set is called in render,
489 // but the value is the same. Stores should not do this, but if they do
490 // we shouldn't infinitely render.
491 store = {...store};
492 setTimeout(() => {
493 store = {value};
494 emitChange();
495 }, 100);
496 emitChange();
497 },
498 subscribe(listener) {
499 listeners = [...listeners, listener];
500 return () => {
501 listeners = listeners.filter(l => l !== listener);
502 };
503 },
504 getSnapshot() {
505 return store;
506 },
507 };
508
509 function emitChange() {
510 listeners.forEach(l => l());
511 }
512
513 function StoreText() {
514 const {value} = useSyncExternalStore(
515 ExternalStore.subscribe,
516 ExternalStore.getSnapshot,
517 );
518
519 useMemo(() => {
520 // Set the store value on mount.
521 // This breaks the rules of React, but should be handled gracefully.
522 const newValue = {text: 'B'};
523 if (value == null || newValue !== value) {
524 ExternalStore.set(newValue);
525 }
526 }, []);
527
528 return <Text text={value.text || '(not set)'} />;
529 }
530
531 function App() {
532 return (
533 <>
534 <Suspense fallback={'Loading...'}>
535 <AsyncText text={'A'} />
536 <StoreText />
537 </Suspense>
538 </>
539 );
540 }
541
542 const root = ReactNoop.createRoot();
543
544 // The initial render suspends.
545 await act(async () => {
546 root.render(<App />);
547 });
548
549 // pre-warming
550 assertLog(['(not set)']);
551
552 expect(root).toMatchRenderedOutput('Loading...');
553
554 // Resolve the data and finish rendering.
555 // When resolving, the store should not get stuck in an infinite loop.
556 await act(() => {
557 resolveText('A');
558 });
559 assertLog([
560 'A',
561 'B',
562 'A',
563 'B',
564 'B',
565 ...(gate('alwaysThrottleRetries') ? [] : ['B']),
566 ]);
567
568 expect(root).toMatchRenderedOutput('AB');
569 });
570 });