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 = [];