@samitouri / QOS-React-2 / commits / f312a3fc36

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', () => {