useMutableSource: bugfix for new getSnapshot with mutation (#18297)
Brian Vaughn committed
Apr 3, 2020 at 10:20 UTC
f312a3fc36ef8b45e68a279f4c8113e14f0b0dd6
4 files changed
+410
-49
packages/react-reconciler/src/ReactFiberHooks.js
+69
-26
@@ -31,6 +31,7 @@ import type {
31
import ReactSharedInternals from 'shared/ReactSharedInternals';
32
import {enableUseEventAPI} from 'shared/ReactFeatureFlags';
33
34
+import {markRootExpiredAtTime} from './ReactFiberRoot';
35
import {NoWork, Sync} from './ReactFiberExpirationTime';
36
import {readContext} from './ReactFiberNewContext';
37
import {createDeprecatedResponderListener} from './ReactFiberDeprecatedEvents';
@@ -74,7 +75,7 @@ import {
75
getCurrentPriorityLevel,
76
} from './SchedulerWithReactIntegration';
77
import {
77
- getPendingExpirationTime,
78
+ getLastPendingExpirationTime,
79
getWorkInProgressVersion,
80
markSourceAsDirty,
81
setPendingExpirationTime,
@@ -886,6 +887,7 @@ function rerenderReducer<S, I, A>(
887
type MutableSourceMemoizedState<Source, Snapshot> = {|
888
refs: {
889
getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
890
+ setSnapshot: Snapshot => void,
891
},
892
source: MutableSource<any>,
893
subscribe: MutableSourceSubscribeFn<Source, Snapshot>,
@@ -914,16 +916,14 @@ function readFromUnsubcribedMutableSource<Source, Snapshot>(
916
isSafeToReadFromSource = currentRenderVersion === version;
917
} else {
918
// If there's no version, then we should fallback to checking the update time.
917
- const pendingExpirationTime = getPendingExpirationTime(root);
919
+ const pendingExpirationTime = getLastPendingExpirationTime(root);
920
921
if (pendingExpirationTime === NoWork) {
922
isSafeToReadFromSource = true;
923
} else {
924
// If the source has pending updates, we can use the current render's expiration
925
// time to determine if it's safe to read again from the source.
924
- isSafeToReadFromSource =
925
- pendingExpirationTime === NoWork ||
926
- pendingExpirationTime >= renderExpirationTime;
926
+ isSafeToReadFromSource = pendingExpirationTime >= renderExpirationTime;
927
}
928
929
if (isSafeToReadFromSource) {
@@ -972,7 +972,8 @@ function useMutableSource<Source, Snapshot>(
972
973
const dispatcher = ReactCurrentDispatcher.current;
974
975
- const [currentSnapshot, setSnapshot] = dispatcher.useState(() =>
975
+ // eslint-disable-next-line prefer-const
976
+ let [currentSnapshot, setSnapshot] = dispatcher.useState(() =>
977
readFromUnsubcribedMutableSource(root, source, getSnapshot),
978
);
979
let snapshot = currentSnapshot;
@@ -998,19 +999,51 @@ function useMutableSource<Source, Snapshot>(
999
subscribe,
1000
}: MutableSourceMemoizedState<Source, Snapshot>);
1001
1001
- // Sync the values needed by our subscribe function after each commit.
1002
+ // Sync the values needed by our subscription handler after each commit.
1003
dispatcher.useEffect(() => {
1004
refs.getSnapshot = getSnapshot;
1004
- }, [getSnapshot]);
1005
1006
- // If we got a new source or subscribe function,
1007
- // we'll need to subscribe in a passive effect,
1008
- // and also check for any changes that fire between render and subscribe.
1006
+ // Normally the dispatch function for a state hook never changes,
1007
+ // but this hook recreates the queue in certain cases to avoid updates from stale sources.
1008
+ // handleChange() below needs to reference the dispatch function without re-subscribing,
1009
+ // so we use a ref to ensure that it always has the latest version.
1010
+ refs.setSnapshot = setSnapshot;
1011
+
1012
+ // Check for a possible change between when we last rendered now.
1013
+ const maybeNewVersion = getVersion(source._source);
1014
+ if (!is(version, maybeNewVersion)) {
1015
+ const maybeNewSnapshot = getSnapshot(source._source);
1016
+ if (!is(snapshot, maybeNewSnapshot)) {
1017
+ setSnapshot(maybeNewSnapshot);
1018
+
1019
+ const currentTime = requestCurrentTimeForUpdate();
1020
+ const suspenseConfig = requestCurrentSuspenseConfig();
1021
+ const expirationTime = computeExpirationForFiber(
1022
+ currentTime,
1023
+ fiber,
1024
+ suspenseConfig,
1025
+ );
1026
+ setPendingExpirationTime(root, expirationTime);
1027
+
1028
+ // If the source mutated between render and now,
1029
+ // there may be state updates already scheduled from the old getSnapshot.
1030
+ // Those updates should not commit without this value.
1031
+ // There is no mechanism currently to associate these updates though,
1032
+ // so for now we fall back to synchronously flushing all pending updates.
1033
+ // TODO: Improve this later.
1034
+ markRootExpiredAtTime(root, getLastPendingExpirationTime(root));
1035
+ }
1036
+ }
1037
+ }, [getSnapshot, source, subscribe]);
1038
+
1039
+ // If we got a new source or subscribe function, re-subscribe in a passive effect.
1040
dispatcher.useEffect(() => {
1041
const handleChange = () => {
1042
const latestGetSnapshot = refs.getSnapshot;
1043
+ const latestSetSnapshot = refs.setSnapshot;
1044
+
1045
try {
1013
- setSnapshot(latestGetSnapshot(source._source));
1046
+ latestSetSnapshot(latestGetSnapshot(source._source));
1047
1048
// Record a pending mutable source update with the same expiration time.
1049
const currentTime = requestCurrentTimeForUpdate();
@@ -1027,9 +1060,11 @@ function useMutableSource<Source, Snapshot>(
1060
// e.g. it might try to read from a part of the store that no longer exists.
1061
// In this case we should still schedule an update with React.
1062
// Worst case the selector will throw again and then an error boundary will handle it.
1030
- setSnapshot(() => {
1031
- throw error;
1032
- });
1063
+ latestSetSnapshot(
1064
+ (() => {
1065
+ throw error;
1066
+ }: any),
1067
+ );
1068
}
1069
};
1070
@@ -1042,15 +1077,6 @@ function useMutableSource<Source, Snapshot>(
1077
}
1078
}
1079
1045
- // Check for a possible change between when we last rendered and when we just subscribed.
1046
- const maybeNewVersion = getVersion(source._source);
1047
- if (!is(version, maybeNewVersion)) {
1048
- const maybeNewSnapshot = getSnapshot(source._source);
1049
- if (!is(snapshot, maybeNewSnapshot)) {
1050
- setSnapshot(maybeNewSnapshot);
1051
- }
1052
- }
1053
-
1080
return unsubscribe;
1081
}, [source, subscribe]);
1082
@@ -1066,10 +1092,26 @@ function useMutableSource<Source, Snapshot>(
1092
// In both cases, we need to throw away pending udpates (since they are no longer relevant)
1093
// and treat reading from the source as we do in the mount case.
1094
if (
1095
+ !is(prevGetSnapshot, getSnapshot) ||
1096
!is(prevSource, source) ||
1070
- !is(prevSubscribe, subscribe) ||
1071
- !is(prevGetSnapshot, getSnapshot)
1097
+ !is(prevSubscribe, subscribe)
1098
) {
1099
+ // Create a new queue and setState method,
1100
+ // So if there are interleaved updates, they get pushed to the older queue.
1101
+ // When this becomes current, the previous queue and dispatch method will be discarded,
1102
+ // including any interleaving updates that occur.
1103
+ const newQueue = {
1104
+ pending: null,
1105
+ dispatch: null,
1106
+ lastRenderedReducer: basicStateReducer,
1107
+ lastRenderedState: snapshot,
1108
+ };
1109
+ newQueue.dispatch = setSnapshot = (dispatchAction.bind(
1110
+ null,
1111
+ currentlyRenderingFiber,
1112
+ newQueue,
1113
+ ): any);
1114
+ stateHook.queue = newQueue;
1115
stateHook.baseQueue = null;
1116
snapshot = readFromUnsubcribedMutableSource(root, source, getSnapshot);
1117
stateHook.memoizedState = stateHook.baseState = snapshot;
@@ -1087,6 +1129,7 @@ function mountMutableSource<Source, Snapshot>(
1129
hook.memoizedState = ({
1130
refs: {
1131
getSnapshot,
1132
+ setSnapshot: (null: any),
1133
},
1134
source,
1135
subscribe,
packages/react-reconciler/src/ReactFiberRoot.js
+3
-2
@@ -79,7 +79,7 @@ type BaseFiberRootProperties = {|
79
lastExpiredTime: ExpirationTime,
80
// Used by useMutableSource hook to avoid tearing within this root
81
// when external, mutable sources are read from during render.
82
- mutableSourcePendingUpdateTime: ExpirationTime,
82
+ mutableSourceLastPendingUpdateTime: ExpirationTime,
83
|};
84
85
// The following attributes are only used by interaction tracing builds.
@@ -130,7 +130,8 @@ function FiberRootNode(containerInfo, tag, hydrate) {
130
this.nextKnownPendingLevel = NoWork;
131
this.lastPingedTime = NoWork;
132
this.lastExpiredTime = NoWork;
133
- this.mutableSourcePendingUpdateTime = NoWork;
133
+ this.mutableSourceFirstPendingUpdateTime = NoWork;
134
+ this.mutableSourceLastPendingUpdateTime = NoWork;
135
136
if (enableSchedulerTracing) {
137
this.interactionThreadID = unstable_getThreadID();
packages/react-reconciler/src/ReactMutableSource.js
+13
-5
@@ -30,20 +30,28 @@ export function clearPendingUpdates(
30
root: FiberRoot,
31
expirationTime: ExpirationTime,
32
): void {
33
- if (root.mutableSourcePendingUpdateTime <= expirationTime) {
34
- root.mutableSourcePendingUpdateTime = NoWork;
33
+ if (expirationTime <= root.mutableSourceLastPendingUpdateTime) {
34
+ // All updates for this source have been processed.
35
+ root.mutableSourceLastPendingUpdateTime = NoWork;
36
}
37
}
38
38
-export function getPendingExpirationTime(root: FiberRoot): ExpirationTime {
39
- return root.mutableSourcePendingUpdateTime;
39
+export function getLastPendingExpirationTime(root: FiberRoot): ExpirationTime {
40
+ return root.mutableSourceLastPendingUpdateTime;
41
}
42
43
export function setPendingExpirationTime(
44
root: FiberRoot,
45
expirationTime: ExpirationTime,
46
): void {
46
- root.mutableSourcePendingUpdateTime = expirationTime;
47
+ const mutableSourceLastPendingUpdateTime =
48
+ root.mutableSourceLastPendingUpdateTime;
49
+ if (
50
+ mutableSourceLastPendingUpdateTime === NoWork ||
51
+ expirationTime < mutableSourceLastPendingUpdateTime
52
+ ) {
53
+ root.mutableSourceLastPendingUpdateTime = expirationTime;
54
+ }
55
}
56
57
export function markSourceAsDirty(mutableSource: MutableSource<any>): void {
packages/react-reconciler/src/__tests__/useMutableSource-test.internal.js
+325
-16
@@ -43,8 +43,8 @@ describe('useMutableSource', () => {
43
const callbacksA = [];
44
const callbacksB = [];
45
let revision = 0;
46
- let valueA = 'a:one';
47
- let valueB = 'b:one';
46
+ let valueA = initialValueA;
47
+ let valueB = initialValueB;
48
49
const subscribeHelper = (callbacks, callback) => {
50
if (callbacks.indexOf(callback) < 0) {
@@ -302,7 +302,7 @@ describe('useMutableSource', () => {
302
/>,
303
() => Scheduler.unstable_yieldValue('Sync effect'),
304
);
305
- expect(Scheduler).toFlushAndYieldThrough(['only:a-one', 'Sync effect']);
305
+ expect(Scheduler).toFlushAndYield(['only:a-one', 'Sync effect']);
306
ReactNoop.flushPassiveEffects();
307
expect(sourceA.listenerCount).toBe(1);
308
@@ -630,7 +630,7 @@ describe('useMutableSource', () => {
630
});
631
632
it('should only update components whose subscriptions fire', () => {
633
- const source = createComplexSource('one', 'one');
633
+ const source = createComplexSource('a:one', 'b:one');
634
const mutableSource = createMutableSource(source);
635
636
// Subscribe to part of the store.
@@ -672,7 +672,7 @@ describe('useMutableSource', () => {
672
});
673
674
it('should detect tearing in part of the store not yet subscribed to', () => {
675
- const source = createComplexSource('one', 'one');
675
+ const source = createComplexSource('a:one', 'b:one');
676
const mutableSource = createMutableSource(source);
677
678
// Subscribe to part of the store.
@@ -1054,32 +1054,31 @@ describe('useMutableSource', () => {
1054
};
1055
}
1056
1057
- function App({toggle}) {
1057
+ function App({getSnapshot}) {
1058
const state = useMutableSource(
1059
mutableSource,
1060
- toggle ? getSnapshotB : getSnapshotA,
1060
+ getSnapshot,
1061
defaultSubscribe,
1062
);
1063
- const result = (toggle ? 'on: ' : 'off: ') + state;
1064
- return result;
1063
+ return state;
1064
}
1065
1066
const root = ReactNoop.createRoot();
1067
await act(async () => {
1069
- root.render(<App toggle={false} />);
1068
+ root.render(<App getSnapshot={getSnapshotA} />);
1069
});
1071
- expect(root).toMatchRenderedOutput('off: initial');
1070
+ expect(root).toMatchRenderedOutput('initial');
1071
1072
await act(async () => {
1073
mutateB('Updated B');
1075
- root.render(<App toggle={true} />);
1074
+ root.render(<App getSnapshot={getSnapshotB} />);
1075
});
1077
- expect(root).toMatchRenderedOutput('on: Updated B');
1076
+ expect(root).toMatchRenderedOutput('Updated B');
1077
1078
await act(async () => {
1079
mutateB('Another update');
1080
});
1082
- expect(root).toMatchRenderedOutput('on: Another update');
1081
+ expect(root).toMatchRenderedOutput('Another update');
1082
});
1083
1084
it('should clear the update queue when getSnapshot changes with pending lower priority updates', async () => {
@@ -1238,7 +1237,7 @@ describe('useMutableSource', () => {
1237
ReactNoop.discreteUpdates(() => {
1238
// At high priority, toggle y so that it reads from A instead of B.
1239
// Simultaneously, mutate A.
1241
- mutateA('high-pri baz');
1240
+ mutateA('baz');
1241
root.render(
1242
<App
1243
getSnapshotFirst={getSnapshotA}
@@ -1247,7 +1246,7 @@ describe('useMutableSource', () => {
1246
);
1247
1248
// If this update were processed before the next mutation,
1250
- // it would be expected to yield "high-pri baz" and "high-pri baz".
1249
+ // it would be expected to yield "baz" and "baz".
1250
});
1251
1252
// At lower priority, mutate A again.
@@ -1265,6 +1264,316 @@ describe('useMutableSource', () => {
1264
expect(Scheduler).toHaveYielded(['x: bar, y: bar']);
1265
});
1266
1267
+ it('getSnapshot changes and then source is mutated in between paint and passive effect phase', async () => {
1268
+ const source = createSource({
1269
+ a: 'foo',
1270
+ b: 'bar',
1271
+ });
1272
+ const mutableSource = createMutableSource(source);
1273
+
1274
+ function mutateB(newB) {
1275
+ source.value = {
1276
+ ...source.value,
1277
+ b: newB,
1278
+ };
1279
+ }
1280
+
1281
+ const getSnapshotA = () => source.value.a;
1282
+ const getSnapshotB = () => source.value.b;
1283
+
1284
+ function App({getSnapshot}) {
1285
+ const value = useMutableSource(
1286
+ mutableSource,
1287
+ getSnapshot,
1288
+ defaultSubscribe,
1289
+ );
1290
+
1291
+ Scheduler.unstable_yieldValue('Render: ' + value);
1292
+ React.useEffect(() => {
1293
+ Scheduler.unstable_yieldValue('Commit: ' + value);
1294
+ }, [value]);
1295
+
1296
+ return value;
1297
+ }
1298
+
1299
+ const root = ReactNoop.createRoot();
1300
+ await act(async () => {
1301
+ root.render(<App getSnapshot={getSnapshotA} />);
1302
+ });
1303
+ expect(Scheduler).toHaveYielded(['Render: foo', 'Commit: foo']);
1304
+
1305
+ await act(async () => {
1306
+ // Switch getSnapshot to read from B instead
1307
+ root.render(<App getSnapshot={getSnapshotB} />);
1308
+ // Render and finish the tree, but yield right after paint, before
1309
+ // the passive effects have fired.
1310
+ expect(Scheduler).toFlushUntilNextPaint(['Render: bar']);
1311
+ // Then mutate B.
1312
+ mutateB('baz');
1313
+ });
1314
+ expect(Scheduler).toHaveYielded([
1315
+ // Fires the effect from the previous render
1316
+ 'Commit: bar',
1317
+ // During that effect, it should detect that the snapshot has changed
1318
+ // and re-render.
1319
+ 'Render: baz',
1320
+ 'Commit: baz',
1321
+ ]);
1322
+ expect(root).toMatchRenderedOutput('baz');
1323
+ });
1324
+
1325
+ it('getSnapshot changes and then source is mutated in between paint and passive effect phase, case 2', async () => {
1326
+ const source = createSource({
1327
+ a: 'a0',
1328
+ b: 'b0',
1329
+ });
1330
+ const mutableSource = createMutableSource(source);
1331
+
1332
+ const getSnapshotA = () => source.value.a;
1333
+ const getSnapshotB = () => source.value.b;
1334
+
1335
+ function mutateA(newA) {
1336
+ source.value = {
1337
+ ...source.value,
1338
+ a: newA,
1339
+ };
1340
+ }
1341
+
1342
+ function App({getSnapshotFirst, getSnapshotSecond}) {
1343
+ const first = useMutableSource(
1344
+ mutableSource,
1345
+ getSnapshotFirst,
1346
+ defaultSubscribe,
1347
+ );
1348
+ const second = useMutableSource(
1349
+ mutableSource,
1350
+ getSnapshotSecond,
1351
+ defaultSubscribe,
1352
+ );
1353
+
1354
+ return `first: ${first}, second: ${second}`;
1355
+ }
1356
+
1357
+ const root = ReactNoop.createRoot();
1358
+ await act(async () => {
1359
+ root.render(
1360
+ <App
1361
+ getSnapshotFirst={getSnapshotA}
1362
+ getSnapshotSecond={getSnapshotB}
1363
+ />,
1364
+ );
1365
+ });
1366
+ expect(root.getChildrenAsJSX()).toEqual('first: a0, second: b0');
1367
+
1368
+ await act(async () => {
1369
+ // Switch the second getSnapshot to also read from A
1370
+ root.render(
1371
+ <App
1372
+ getSnapshotFirst={getSnapshotA}
1373
+ getSnapshotSecond={getSnapshotA}
1374
+ />,
1375
+ );
1376
+ // Render and finish the tree, but yield right after paint, before
1377
+ // the passive effects have fired.
1378
+ expect(Scheduler).toFlushUntilNextPaint([]);
1379
+
1380
+ // Now mutate A. Both hooks should update.
1381
+ // This is at high priority so that it doesn't get batched with default
1382
+ // priority updates that might fire during the passive effect
1383
+ ReactNoop.discreteUpdates(() => {
1384
+ mutateA('a1');
1385
+ });
1386
+ expect(Scheduler).toFlushUntilNextPaint([]);
1387
+
1388
+ expect(root.getChildrenAsJSX()).toEqual('first: a1, second: a1');
1389
+ });
1390
+
1391
+ expect(root.getChildrenAsJSX()).toEqual('first: a1, second: a1');
1392
+ });
1393
+
1394
+ it('getSnapshot changes and then source is mutated during interleaved event', async () => {
1395
+ const {useEffect} = React;
1396
+
1397
+ const source = createComplexSource('1', '2');
1398
+ const mutableSource = createMutableSource(source);
1399
+
1400
+ // Subscribe to part of the store.
1401
+ const getSnapshotA = s => s.valueA;
1402
+ const subscribeA = (s, callback) => s.subscribeA(callback);
1403
+ const configA = [getSnapshotA, subscribeA];
1404
+
1405
+ const getSnapshotB = s => s.valueB;
1406
+ const subscribeB = (s, callback) => s.subscribeB(callback);
1407
+ const configB = [getSnapshotB, subscribeB];
1408
+
1409
+ function App({parentConfig, childConfig}) {
1410
+ const [getSnapshot, subscribe] = parentConfig;
1411
+ const parentValue = useMutableSource(
1412
+ mutableSource,
1413
+ getSnapshot,
1414
+ subscribe,
1415
+ );
1416
+
1417
+ Scheduler.unstable_yieldValue('Parent: ' + parentValue);
1418
+
1419
+ return (
1420
+ <Child
1421
+ parentConfig={parentConfig}
1422
+ childConfig={childConfig}
1423
+ parentValue={parentValue}
1424
+ />
1425
+ );
1426
+ }
1427
+
1428
+ function Child({parentConfig, childConfig, parentValue}) {
1429
+ const [getSnapshot, subscribe] = childConfig;
1430
+ const childValue = useMutableSource(
1431
+ mutableSource,
1432
+ getSnapshot,
1433
+ subscribe,
1434
+ );
1435
+
1436
+ Scheduler.unstable_yieldValue('Child: ' + childValue);
1437
+
1438
+ let result = `${parentValue}, ${childValue}`;
1439
+
1440
+ if (parentConfig === childConfig) {
1441
+ // When both components read using the same config, the two values
1442
+ // must be consistent.
1443
+ if (parentValue !== childValue) {
1444
+ result = 'Oops, tearing!';
1445
+ }
1446
+ }
1447
+
1448
+ useEffect(() => {
1449
+ Scheduler.unstable_yieldValue('Commit: ' + result);
1450
+ }, [result]);
1451
+
1452
+ return result;
1453
+ }
1454
+
1455
+ const root = ReactNoop.createRoot();
1456
+ await act(async () => {
1457
+ root.render(<App parentConfig={configA} childConfig={configB} />);
1458
+ });
1459
+ expect(Scheduler).toHaveYielded([
1460
+ 'Parent: 1',
1461
+ 'Child: 2',
1462
+ 'Commit: 1, 2',
1463
+ ]);
1464
+
1465
+ await act(async () => {
1466
+ // Switch the parent and the child to read using the same config
1467
+ root.render(<App parentConfig={configB} childConfig={configB} />);
1468
+ // Start rendering the parent, but yield before rendering the child
1469
+ expect(Scheduler).toFlushAndYieldThrough(['Parent: 2']);
1470
+
1471
+ // Mutate the config. This is at lower priority so that 1) to make sure
1472
+ // it doesn't happen to get batched with the in-progress render, and 2)
1473
+ // so it doesn't interrupt the in-progress render.
1474
+ Scheduler.unstable_runWithPriority(
1475
+ Scheduler.unstable_IdlePriority,
1476
+ () => {
1477
+ source.valueB = '3';
1478
+ },
1479
+ );
1480
+ });
1481
+
1482
+ expect(Scheduler).toHaveYielded([
1483
+ // The partial render completes
1484
+ 'Child: 2',
1485
+ 'Commit: 2, 2',
1486
+
1487
+ // Then we start rendering the low priority mutation
1488
+ 'Parent: 3',
1489
+
1490
+ // Eventually the child corrects itself, because of the check that
1491
+ // occurs when re-subscribing.
1492
+ 'Child: 3',
1493
+ 'Commit: 3, 3',
1494
+ ]);
1495
+ });
1496
+
1497
+ it('should not tear with newly mounted component when updates were scheduled at a lower priority', async () => {
1498
+ const source = createSource('one');
1499
+ const mutableSource = createMutableSource(source);
1500
+
1501
+ let committedA = null;
1502
+ let committedB = null;
1503
+
1504
+ const onRender = () => {
1505
+ if (committedB !== null) {
1506
+ expect(committedA).toBe(committedB);
1507
+ }
1508
+ };
1509
+
1510
+ function ComponentA() {
1511
+ const snapshot = useMutableSource(
1512
+ mutableSource,
1513
+ defaultGetSnapshot,
1514
+ defaultSubscribe,
1515
+ );
1516
+ Scheduler.unstable_yieldValue(`a:${snapshot}`);
1517
+ React.useEffect(() => {
1518
+ committedA = snapshot;
1519
+ }, [snapshot]);
1520
+ return <div>{`a:${snapshot}`}</div>;
1521
+ }
1522
+ function ComponentB() {
1523
+ const snapshot = useMutableSource(
1524
+ mutableSource,
1525
+ defaultGetSnapshot,
1526
+ defaultSubscribe,
1527
+ );
1528
+ Scheduler.unstable_yieldValue(`b:${snapshot}`);
1529
+ React.useEffect(() => {
1530
+ committedB = snapshot;
1531
+ }, [snapshot]);
1532
+ return <div>{`b:${snapshot}`}</div>;
1533
+ }
1534
+
1535
+ // Mount ComponentA with data version 1
1536
+ act(() => {
1537
+ ReactNoop.render(
1538
+ <React.Profiler id="root" onRender={onRender}>
1539
+ <ComponentA />
1540
+ </React.Profiler>,
1541
+ () => Scheduler.unstable_yieldValue('Sync effect'),
1542
+ );
1543
+ });
1544
+ expect(Scheduler).toHaveYielded(['a:one', 'Sync effect']);
1545
+ expect(source.listenerCount).toBe(1);
1546
+
1547
+ // Mount ComponentB with version 1 (but don't commit it)
1548
+ act(() => {
1549
+ ReactNoop.render(
1550
+ <React.Profiler id="root" onRender={onRender}>
1551
+ <ComponentA />
1552
+ <ComponentB />
1553
+ </React.Profiler>,
1554
+ () => Scheduler.unstable_yieldValue('Sync effect'),
1555
+ );
1556
+ expect(Scheduler).toFlushAndYieldThrough([
1557
+ 'a:one',
1558
+ 'b:one',
1559
+ 'Sync effect',
1560
+ ]);
1561
+ expect(source.listenerCount).toBe(1);
1562
+
1563
+ // Mutate -> schedule update for ComponentA
1564
+ Scheduler.unstable_runWithPriority(
1565
+ Scheduler.unstable_IdlePriority,
1566
+ () => {
1567
+ source.value = 'two';
1568
+ },
1569
+ );
1570
+
1571
+ // Commit ComponentB -> notice the change and schedule an update for ComponentB
1572
+ expect(Scheduler).toFlushAndYield(['a:two', 'b:two']);
1573
+ expect(source.listenerCount).toBe(2);
1574
+ });
1575
+ });
1576
+
1577
if (__DEV__) {
1578
describe('dev warnings', () => {
1579
it('should warn if the subscribe function does not return an unsubscribe function', () => {