useMutableSource hook (#18000)
useMutableSource hook useMutableSource() enables React components to safely and efficiently read from a mutable external source in Concurrent Mode. The API will detect mutations that occur during a render to avoid tearing and it will automatically schedule updates when the source is mutated. RFC: reactjs/rfcs#147
Brian Vaughn committed
Mar 11, 2020 at 12:34 UTC
322cdcd3abfaca985a001a12247f02c5d31d311e
24 files changed
+2062
-27
packages/react-debug-tools/src/ReactDebugHooks.js
+31
@@ -8,6 +8,9 @@
8
*/
9
10
import type {
11
+ MutableSource,
12
+ MutableSourceGetSnapshotFn,
13
+ MutableSourceSubscribeFn,
14
ReactContext,
15
ReactProviderType,
16
ReactEventResponder,
@@ -72,6 +75,16 @@ function getPrimitiveStackCache(): Map<string, Array<any>> {
75
Dispatcher.useDebugValue(null);
76
Dispatcher.useCallback(() => {});
77
Dispatcher.useMemo(() => null);
78
+ Dispatcher.useMutableSource(
79
+ {
80
+ _source: {},
81
+ _getVersion: () => 1,
82
+ _workInProgressVersionPrimary: null,
83
+ _workInProgressVersionSecondary: null,
84
+ },
85
+ () => null,
86
+ () => () => {},
87
+ );
88
} finally {
89
readHookLog = hookLog;
90
hookLog = [];
@@ -229,6 +242,23 @@ function useMemo<T>(
242
return value;
243
}
244
245
+function useMutableSource<Source, Snapshot>(
246
+ source: MutableSource<Source>,
247
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
248
+ subscribe: MutableSourceSubscribeFn<Source, Snapshot>,
249
+): Snapshot {
250
+ // useMutableSource() composes multiple hooks internally.
251
+ // Advance the current hook index the same number of times
252
+ // so that subsequent hooks have the right memoized state.
253
+ nextHook(); // MutableSource
254
+ nextHook(); // State
255
+ nextHook(); // Effect
256
+ nextHook(); // Effect
257
+ const value = getSnapshot(source._source);
258
+ hookLog.push({primitive: 'MutableSource', stackError: new Error(), value});
259
+ return value;
260
+}
261
+
262
function useResponder(
263
responder: ReactEventResponder<any, any>,
264
listenerProps: Object,
@@ -299,6 +329,7 @@ const Dispatcher: DispatcherType = {
329
useState,
330
useResponder,
331
useTransition,
332
+ useMutableSource,
333
useDeferredValue,
334
useEvent,
335
};
packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js
+34
@@ -785,4 +785,38 @@ describe('ReactHooksInspectionIntegration', () => {
785
},
786
]);
787
});
788
+
789
+ if (__EXPERIMENTAL__) {
790
+ it('should support composite useMutableSource hook', () => {
791
+ const mutableSource = React.createMutableSource({}, () => 1);
792
+ function Foo(props) {
793
+ React.useMutableSource(
794
+ mutableSource,
795
+ () => 'snapshot',
796
+ () => {},
797
+ );
798
+ React.useMemo(() => 'memo', []);
799
+ return <div />;
800
+ }
801
+ let renderer = ReactTestRenderer.create(<Foo />);
802
+ let childFiber = renderer.root.findByType(Foo)._currentFiber();
803
+ let tree = ReactDebugTools.inspectHooksOfFiber(childFiber);
804
+ expect(tree).toEqual([
805
+ {
806
+ id: 0,
807
+ isStateEditable: false,
808
+ name: 'MutableSource',
809
+ value: 'snapshot',
810
+ subHooks: [],
811
+ },
812
+ {
813
+ id: 1,
814
+ isStateEditable: false,
815
+ name: 'Memo',
816
+ value: 'memo',
817
+ subHooks: [],
818
+ },
819
+ ]);
820
+ });
821
+ }
822
});
packages/react-dom/src/server/ReactPartialRendererHooks.js
+17
@@ -13,6 +13,9 @@ import type {
13
} from 'react-reconciler/src/ReactFiberHooks';
14
import type {ThreadID} from './ReactThreadIDAllocator';
15
import type {
16
+ MutableSource,
17
+ MutableSourceGetSnapshotFn,
18
+ MutableSourceSubscribeFn,
19
ReactContext,
20
ReactEventResponderListener,
21
} from 'shared/ReactTypes';
@@ -461,6 +464,18 @@ function useResponder(responder, props): ReactEventResponderListener<any, any> {
464
};
465
}
466
467
+// TODO Decide on how to implement this hook for server rendering.
468
+// If a mutation occurs during render, consider triggering a Suspense boundary
469
+// and falling back to client rendering.
470
+function useMutableSource<Source, Snapshot>(
471
+ source: MutableSource<Source>,
472
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
473
+ subscribe: MutableSourceSubscribeFn<Source, Snapshot>,
474
+): Snapshot {
475
+ resolveCurrentlyRenderingComponent();
476
+ return getSnapshot(source._source);
477
+}
478
+
479
function useDeferredValue<T>(value: T, config: TimeoutConfig | null | void): T {
480
resolveCurrentlyRenderingComponent();
481
return value;
@@ -510,4 +525,6 @@ export const Dispatcher: DispatcherType = {
525
useDeferredValue,
526
useTransition,
527
useEvent,
528
+ // Subscriptions are not setup in a server environment.
529
+ useMutableSource,
530
};
packages/react-reconciler/src/ReactFiberBeginWork.js
+2
-2
@@ -180,7 +180,7 @@ import {
180
markSpawnedWork,
181
requestCurrentTimeForUpdate,
182
retryDehydratedSuspenseBoundary,
183
- scheduleWork,
183
+ scheduleUpdateOnFiber,
184
renderDidSuspendDelayIfPossible,
185
markUnprocessedUpdateTime,
186
} from './ReactFiberWorkLoop';
@@ -2121,7 +2121,7 @@ function updateDehydratedSuspenseComponent(
2121
// at even higher pri.
2122
let attemptHydrationAtExpirationTime = renderExpirationTime + 1;
2123
suspenseState.retryTime = attemptHydrationAtExpirationTime;
2124
- scheduleWork(current, attemptHydrationAtExpirationTime);
2124
+ scheduleUpdateOnFiber(current, attemptHydrationAtExpirationTime);
2125
// TODO: Early abort this render.
2126
} else {
2127
// We have already tried to ping at a higher priority than we're rendering with
packages/react-reconciler/src/ReactFiberClassComponent.js
+4
-4
@@ -53,7 +53,7 @@ import {readContext} from './ReactFiberNewContext';
53
import {
54
requestCurrentTimeForUpdate,
55
computeExpirationForFiber,
56
- scheduleWork,
56
+ scheduleUpdateOnFiber,
57
} from './ReactFiberWorkLoop';
58
import {requestCurrentSuspenseConfig} from './ReactFiberSuspenseConfig';
59
@@ -200,7 +200,7 @@ const classComponentUpdater = {
200
}
201
202
enqueueUpdate(fiber, update);
203
- scheduleWork(fiber, expirationTime);
203
+ scheduleUpdateOnFiber(fiber, expirationTime);
204
},
205
enqueueReplaceState(inst, payload, callback) {
206
const fiber = getInstance(inst);
@@ -224,7 +224,7 @@ const classComponentUpdater = {
224
}
225
226
enqueueUpdate(fiber, update);
227
- scheduleWork(fiber, expirationTime);
227
+ scheduleUpdateOnFiber(fiber, expirationTime);
228
},
229
enqueueForceUpdate(inst, callback) {
230
const fiber = getInstance(inst);
@@ -247,7 +247,7 @@ const classComponentUpdater = {
247
}
248
249
enqueueUpdate(fiber, update);
250
- scheduleWork(fiber, expirationTime);
250
+ scheduleUpdateOnFiber(fiber, expirationTime);
251
},
252
};
253
packages/react-reconciler/src/ReactFiberCompleteWork.js
+2
@@ -26,6 +26,7 @@ import type {
26
SuspenseListRenderState,
27
} from './ReactFiberSuspenseComponent';
28
import type {SuspenseContext} from './ReactFiberSuspenseContext';
29
+import {resetWorkInProgressVersions as resetMutableSourceWorkInProgressVersions} from './ReactMutableSource';
30
31
import {now} from './SchedulerWithReactIntegration';
32
@@ -662,6 +663,7 @@ function completeWork(
663
case HostRoot: {
664
popHostContainer(workInProgress);
665
popTopLevelLegacyContextObject(workInProgress);
666
+ resetMutableSourceWorkInProgressVersions();
667
const fiberRoot = (workInProgress.stateNode: FiberRoot);
668
if (fiberRoot.pendingContext) {
669
fiberRoot.context = fiberRoot.pendingContext;
packages/react-reconciler/src/ReactFiberHooks.js
+310
-2
@@ -8,6 +8,9 @@
8
*/
9
10
import type {
11
+ MutableSource,
12
+ MutableSourceGetSnapshotFn,
13
+ MutableSourceSubscribeFn,
14
ReactEventResponder,
15
ReactContext,
16
ReactEventResponderListener,
@@ -17,6 +20,7 @@ import type {ExpirationTime} from './ReactFiberExpirationTime';
20
import type {HookEffectTag} from './ReactHookEffectTags';
21
import type {SuspenseConfig} from './ReactFiberSuspenseConfig';
22
import type {ReactPriorityLevel} from './SchedulerWithReactIntegration';
23
+import type {FiberRoot} from './ReactFiberRoot';
24
import type {
25
ReactListenerEvent,
26
ReactListenerMap,
@@ -39,7 +43,8 @@ import {
43
Passive as HookPassive,
44
} from './ReactHookEffectTags';
45
import {
42
- scheduleWork,
46
+ getWorkInProgressRoot,
47
+ scheduleUpdateOnFiber,
48
computeExpirationForFiber,
49
requestCurrentTimeForUpdate,
50
warnIfNotCurrentlyActingEffectsInDEV,
@@ -60,6 +65,14 @@ import {
65
runWithPriority,
66
getCurrentPriorityLevel,
67
} from './SchedulerWithReactIntegration';
68
+import {
69
+ getPendingExpirationTime,
70
+ getWorkInProgressVersion,
71
+ markSourceAsDirty,
72
+ setPendingExpirationTime,
73
+ setWorkInProgressVersion,
74
+ warnAboutMultipleRenderersDEV,
75
+} from './ReactMutableSource';
76
77
const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals;
78
@@ -103,6 +116,11 @@ export type Dispatcher = {|
116
useTransition(
117
config: SuspenseConfig | void | null,
118
): [(() => void) => void, boolean],
119
+ useMutableSource<Source, Snapshot>(
120
+ source: MutableSource<Source>,
121
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
122
+ subscribe: MutableSourceSubscribeFn<Source, Snapshot>,
123
+ ): Snapshot,
124
useEvent(event: ReactListenerEvent): ReactListenerMap,
125
|};
126
@@ -137,6 +155,7 @@ export type HookType =
155
| 'useResponder'
156
| 'useDeferredValue'
157
| 'useTransition'
158
+ | 'useMutableSource'
159
| 'useEvent';
160
161
let didWarnAboutMismatchedHooksForComponent;
@@ -855,6 +874,225 @@ function rerenderReducer<S, I, A>(
874
return [newState, dispatch];
875
}
876
877
+type MutableSourceMemoizedState<Source, Snapshot> = {|
878
+ refs: {
879
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
880
+ },
881
+ source: MutableSource<any>,
882
+ subscribe: MutableSourceSubscribeFn<Source, Snapshot>,
883
+|};
884
+
885
+function readFromUnsubcribedMutableSource<Source, Snapshot>(
886
+ root: FiberRoot,
887
+ source: MutableSource<Source>,
888
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
889
+): Snapshot {
890
+ if (__DEV__) {
891
+ warnAboutMultipleRenderersDEV(source);
892
+ }
893
+
894
+ const getVersion = source._getVersion;
895
+ const version = getVersion(source._source);
896
+
897
+ // Is it safe for this component to read from this source during the current render?
898
+ let isSafeToReadFromSource = false;
899
+
900
+ // Check the version first.
901
+ // If this render has already been started with a specific version,
902
+ // we can use it alone to determine if we can safely read from the source.
903
+ const currentRenderVersion = getWorkInProgressVersion(source);
904
+ if (currentRenderVersion !== null) {
905
+ isSafeToReadFromSource = currentRenderVersion === version;
906
+ } else {
907
+ // If there's no version, then we should fallback to checking the update time.
908
+ const pendingExpirationTime = getPendingExpirationTime(root);
909
+
910
+ if (pendingExpirationTime === NoWork) {
911
+ isSafeToReadFromSource = true;
912
+ } else {
913
+ // If the source has pending updates, we can use the current render's expiration
914
+ // time to determine if it's safe to read again from the source.
915
+ isSafeToReadFromSource =
916
+ pendingExpirationTime === NoWork ||
917
+ pendingExpirationTime >= renderExpirationTime;
918
+ }
919
+
920
+ if (isSafeToReadFromSource) {
921
+ // If it's safe to read from this source during the current render,
922
+ // store the version in case other components read from it.
923
+ // A changed version number will let those components know to throw and restart the render.
924
+ setWorkInProgressVersion(source, version);
925
+ }
926
+ }
927
+
928
+ if (isSafeToReadFromSource) {
929
+ return getSnapshot(source._source);
930
+ } else {
931
+ // This handles the special case of a mutable source being shared beween renderers.
932
+ // In that case, if the source is mutated between the first and second renderer,
933
+ // The second renderer don't know that it needs to reset the WIP version during unwind,
934
+ // (because the hook only marks sources as dirty if it's written to their WIP version).
935
+ // That would cause this tear check to throw again and eventually be visible to the user.
936
+ // We can avoid this infinite loop by explicitly marking the source as dirty.
937
+ //
938
+ // This can lead to tearing in the first renderer when it resumes,
939
+ // but there's nothing we can do about that (short of throwing here and refusing to continue the render).
940
+ markSourceAsDirty(source);
941
+
942
+ invariant(
943
+ false,
944
+ 'Cannot read from mutable source during the current render without tearing. This is a bug in React. Please file an issue.',
945
+ );
946
+ }
947
+}
948
+
949
+function useMutableSource<Source, Snapshot>(
950
+ hook: Hook,
951
+ source: MutableSource<Source>,
952
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
953
+ subscribe: MutableSourceSubscribeFn<Source, Snapshot>,
954
+): Snapshot {
955
+ const root = ((getWorkInProgressRoot(): any): FiberRoot);
956
+ invariant(
957
+ root !== null,
958
+ 'Expected a work-in-progress root. This is a bug in React. Please file an issue.',
959
+ );
960
+
961
+ const getVersion = source._getVersion;
962
+ const version = getVersion(source._source);
963
+
964
+ const dispatcher = ReactCurrentDispatcher.current;
965
+
966
+ let [snapshot, setSnapshot] = dispatcher.useState(() =>
967
+ readFromUnsubcribedMutableSource(root, source, getSnapshot),
968
+ );
969
+
970
+ // Grab a handle to the state hook as well.
971
+ // We use it to clear the pending update queue if we have a new source.
972
+ const stateHook = ((workInProgressHook: any): Hook);
973
+
974
+ const memoizedState = ((hook.memoizedState: any): MutableSourceMemoizedState<
975
+ Source,
976
+ Snapshot,
977
+ >);
978
+ const refs = memoizedState.refs;
979
+ const prevGetSnapshot = refs.getSnapshot;
980
+ const prevSource = memoizedState.source;
981
+ const prevSubscribe = memoizedState.subscribe;
982
+
983
+ const fiber = currentlyRenderingFiber;
984
+
985
+ hook.memoizedState = ({
986
+ refs,
987
+ source,
988
+ subscribe,
989
+ }: MutableSourceMemoizedState<Source, Snapshot>);
990
+
991
+ // Sync the values needed by our subscribe function after each commit.
992
+ dispatcher.useEffect(() => {
993
+ refs.getSnapshot = getSnapshot;
994
+ }, [getSnapshot]);
995
+
996
+ // If we got a new source or subscribe function,
997
+ // we'll need to subscribe in a passive effect,
998
+ // and also check for any changes that fire between render and subscribe.
999
+ dispatcher.useEffect(() => {
1000
+ const handleChange = () => {
1001
+ const latestGetSnapshot = refs.getSnapshot;
1002
+ try {
1003
+ setSnapshot(latestGetSnapshot(source._source));
1004
+
1005
+ // Record a pending mutable source update with the same expiration time.
1006
+ const currentTime = requestCurrentTimeForUpdate();
1007
+ const suspenseConfig = requestCurrentSuspenseConfig();
1008
+ const expirationTime = computeExpirationForFiber(
1009
+ currentTime,
1010
+ fiber,
1011
+ suspenseConfig,
1012
+ );
1013
+
1014
+ setPendingExpirationTime(root, expirationTime);
1015
+ } catch (error) {
1016
+ // A selector might throw after a source mutation.
1017
+ // e.g. it might try to read from a part of the store that no longer exists.
1018
+ // In this case we should still schedule an update with React.
1019
+ // Worst case the selector will throw again and then an error boundary will handle it.
1020
+ setSnapshot(() => {
1021
+ throw error;
1022
+ });
1023
+ }
1024
+ };
1025
+
1026
+ const unsubscribe = subscribe(source._source, handleChange);
1027
+ if (__DEV__) {
1028
+ if (typeof unsubscribe !== 'function') {
1029
+ console.error(
1030
+ 'Mutable source subscribe function must return an unsubscribe function.',
1031
+ );
1032
+ }
1033
+ }
1034
+
1035
+ // Check for a possible change between when we last rendered and when we just subscribed.
1036
+ const maybeNewVersion = getVersion(source._source);
1037
+ if (!is(version, maybeNewVersion)) {
1038
+ const maybeNewSnapshot = getSnapshot(source._source);
1039
+ if (!is(snapshot, maybeNewSnapshot)) {
1040
+ setSnapshot(maybeNewSnapshot);
1041
+ }
1042
+ }
1043
+
1044
+ return unsubscribe;
1045
+ }, [source, subscribe]);
1046
+
1047
+ // If any of the inputs to useMutableSource change, reading is potentially unsafe.
1048
+ //
1049
+ // If either the source or the subscription have changed we can't can't trust the update queue.
1050
+ // Maybe the source changed in a way that the old subscription ignored but the new one depends on.
1051
+ //
1052
+ // If the getSnapshot function changed, we also shouldn't rely on the update queue.
1053
+ // It's possible that the underlying source was mutated between the when the last "change" event fired,
1054
+ // and when the current render (with the new getSnapshot function) is processed.
1055
+ //
1056
+ // In both cases, we need to throw away pending udpates (since they are no longer relevant)
1057
+ // and treat reading from the source as we do in the mount case.
1058
+ if (
1059
+ !is(prevSource, source) ||
1060
+ !is(prevSubscribe, subscribe) ||
1061
+ !is(prevGetSnapshot, getSnapshot)
1062
+ ) {
1063
+ stateHook.baseQueue = null;
1064
+ snapshot = readFromUnsubcribedMutableSource(root, source, getSnapshot);
1065
+ stateHook.memoizedState = stateHook.baseState = snapshot;
1066
+ }
1067
+
1068
+ return snapshot;
1069
+}
1070
+
1071
+function mountMutableSource<Source, Snapshot>(
1072
+ source: MutableSource<Source>,
1073
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
1074
+ subscribe: MutableSourceSubscribeFn<Source, Snapshot>,
1075
+): Snapshot {
1076
+ const hook = mountWorkInProgressHook();
1077
+ hook.memoizedState = ({
1078
+ refs: {
1079
+ getSnapshot,
1080
+ },
1081
+ source,
1082
+ subscribe,
1083
+ }: MutableSourceMemoizedState<Source, Snapshot>);
1084
+ return useMutableSource(hook, source, getSnapshot, subscribe);
1085
+}
1086
+
1087
+function updateMutableSource<Source, Snapshot>(
1088
+ source: MutableSource<Source>,
1089
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
1090
+ subscribe: MutableSourceSubscribeFn<Source, Snapshot>,
1091
+): Snapshot {
1092
+ const hook = updateWorkInProgressHook();
1093
+ return useMutableSource(hook, source, getSnapshot, subscribe);
1094
+}
1095
+
1096
function mountState<S>(
1097
initialState: (() => S) | S,
1098
): [S, Dispatch<BasicStateAction<S>>] {
@@ -1383,7 +1621,7 @@ function dispatchAction<S, A>(
1621
warnIfNotCurrentlyActingUpdatesInDev(fiber);
1622
}
1623
}
1386
- scheduleWork(fiber, expirationTime);
1624
+ scheduleUpdateOnFiber(fiber, expirationTime);
1625
}
1626
}
1627
@@ -1474,6 +1712,7 @@ export const ContextOnlyDispatcher: Dispatcher = {
1712
useResponder: throwInvalidHookError,
1713
useDeferredValue: throwInvalidHookError,
1714
useTransition: throwInvalidHookError,
1715
+ useMutableSource: throwInvalidHookError,
1716
useEvent: throwInvalidHookError,
1717
};
1718
@@ -1493,6 +1732,7 @@ const HooksDispatcherOnMount: Dispatcher = {
1732
useResponder: createDeprecatedResponderListener,
1733
useDeferredValue: mountDeferredValue,
1734
useTransition: mountTransition,
1735
+ useMutableSource: mountMutableSource,
1736
useEvent: mountEventListener,
1737
};
1738
@@ -1512,6 +1752,7 @@ const HooksDispatcherOnUpdate: Dispatcher = {
1752
useResponder: createDeprecatedResponderListener,
1753
useDeferredValue: updateDeferredValue,
1754
useTransition: updateTransition,
1755
+ useMutableSource: updateMutableSource,
1756
useEvent: updateEventListener,
1757
};
1758
@@ -1531,6 +1772,7 @@ const HooksDispatcherOnRerender: Dispatcher = {
1772
useResponder: createDeprecatedResponderListener,
1773
useDeferredValue: rerenderDeferredValue,
1774
useTransition: rerenderTransition,
1775
+ useMutableSource: updateMutableSource,
1776
useEvent: updateEventListener,
1777
};
1778
@@ -1681,6 +1923,15 @@ if (__DEV__) {
1923
mountHookTypesDev();
1924
return mountTransition(config);
1925
},
1926
+ useMutableSource<Source, Snapshot>(
1927
+ source: MutableSource<Source>,
1928
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
1929
+ subscribe: MutableSourceSubscribeFn<Source, Snapshot>,
1930
+ ): Snapshot {
1931
+ currentHookNameInDev = 'useMutableSource';
1932
+ mountHookTypesDev();
1933
+ return mountMutableSource(source, getSnapshot, subscribe);
1934
+ },
1935
useEvent(event: ReactListenerEvent): ReactListenerMap {
1936
currentHookNameInDev = 'useEvent';
1937
mountHookTypesDev();
@@ -1803,6 +2054,15 @@ if (__DEV__) {
2054
updateHookTypesDev();
2055
return mountTransition(config);
2056
},
2057
+ useMutableSource<Source, Snapshot>(
2058
+ source: MutableSource<Source>,
2059
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
2060
+ subscribe: MutableSourceSubscribeFn<Source, Snapshot>,
2061
+ ): Snapshot {
2062
+ currentHookNameInDev = 'useMutableSource';
2063
+ updateHookTypesDev();
2064
+ return mountMutableSource(source, getSnapshot, subscribe);
2065
+ },
2066
useEvent(event: ReactListenerEvent): ReactListenerMap {
2067
currentHookNameInDev = 'useEvent';
2068
updateHookTypesDev();
@@ -1925,6 +2185,15 @@ if (__DEV__) {
2185
updateHookTypesDev();
2186
return updateTransition(config);
2187
},
2188
+ useMutableSource<Source, Snapshot>(
2189
+ source: MutableSource<Source>,
2190
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
2191
+ subscribe: MutableSourceSubscribeFn<Source, Snapshot>,
2192
+ ): Snapshot {
2193
+ currentHookNameInDev = 'useMutableSource';
2194
+ updateHookTypesDev();
2195
+ return updateMutableSource(source, getSnapshot, subscribe);
2196
+ },
2197
useEvent(event: ReactListenerEvent): ReactListenerMap {
2198
currentHookNameInDev = 'useEvent';
2199
updateHookTypesDev();
@@ -2047,6 +2316,15 @@ if (__DEV__) {
2316
updateHookTypesDev();
2317
return rerenderTransition(config);
2318
},
2319
+ useMutableSource<Source, Snapshot>(
2320
+ source: MutableSource<Source>,
2321
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
2322
+ subscribe: MutableSourceSubscribeFn<Source, Snapshot>,
2323
+ ): Snapshot {
2324
+ currentHookNameInDev = 'useMutableSource';
2325
+ updateHookTypesDev();
2326
+ return updateMutableSource(source, getSnapshot, subscribe);
2327
+ },
2328
useEvent(event: ReactListenerEvent): ReactListenerMap {
2329
currentHookNameInDev = 'useEvent';
2330
updateHookTypesDev();
@@ -2183,6 +2461,16 @@ if (__DEV__) {
2461
mountHookTypesDev();
2462
return mountTransition(config);
2463
},
2464
+ useMutableSource<Source, Snapshot>(
2465
+ source: MutableSource<Source>,
2466
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
2467
+ subscribe: MutableSourceSubscribeFn<Source, Snapshot>,
2468
+ ): Snapshot {
2469
+ currentHookNameInDev = 'useMutableSource';
2470
+ warnInvalidHookAccess();
2471
+ mountHookTypesDev();
2472
+ return mountMutableSource(source, getSnapshot, subscribe);
2473
+ },
2474
useEvent(event: ReactListenerEvent): ReactListenerMap {
2475
currentHookNameInDev = 'useEvent';
2476
warnInvalidHookAccess();
@@ -2320,6 +2608,16 @@ if (__DEV__) {
2608
updateHookTypesDev();
2609
return updateTransition(config);
2610
},
2611
+ useMutableSource<Source, Snapshot>(
2612
+ source: MutableSource<Source>,
2613
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
2614
+ subscribe: MutableSourceSubscribeFn<Source, Snapshot>,
2615
+ ): Snapshot {
2616
+ currentHookNameInDev = 'useMutableSource';
2617
+ warnInvalidHookAccess();
2618
+ updateHookTypesDev();
2619
+ return updateMutableSource(source, getSnapshot, subscribe);
2620
+ },
2621
useEvent(event: ReactListenerEvent): ReactListenerMap {
2622
currentHookNameInDev = 'useEvent';
2623
warnInvalidHookAccess();
@@ -2457,6 +2755,16 @@ if (__DEV__) {
2755
updateHookTypesDev();
2756
return rerenderTransition(config);
2757
},
2758
+ useMutableSource<Source, Snapshot>(
2759
+ source: MutableSource<Source>,
2760
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
2761
+ subscribe: MutableSourceSubscribeFn<Source, Snapshot>,
2762
+ ): Snapshot {
2763
+ currentHookNameInDev = 'useMutableSource';
2764
+ warnInvalidHookAccess();
2765
+ updateHookTypesDev();
2766
+ return updateMutableSource(source, getSnapshot, subscribe);
2767
+ },
2768
useEvent(event: ReactListenerEvent): ReactListenerMap {
2769
currentHookNameInDev = 'useEvent';
2770
warnInvalidHookAccess();
packages/react-reconciler/src/ReactFiberHotReloading.js
+2
-2
@@ -15,7 +15,7 @@ import type {ReactNodeList} from 'shared/ReactTypes';
15
16
import {
17
flushSync,
18
- scheduleWork,
18
+ scheduleUpdateOnFiber,
19
flushPassiveEffects,
20
} from './ReactFiberWorkLoop';
21
import {updateContainer, syncUpdates} from './ReactFiberReconciler';
@@ -319,7 +319,7 @@ function scheduleFibersWithFamiliesRecursively(
319
fiber._debugNeedsRemount = true;
320
}
321
if (needsRemount || needsRender) {
322
- scheduleWork(fiber, Sync);
322
+ scheduleUpdateOnFiber(fiber, Sync);
323
}
324
if (child !== null && !needsRemount) {
325
scheduleFibersWithFamiliesRecursively(
packages/react-reconciler/src/ReactFiberReconciler.js
+9
-9
@@ -51,7 +51,7 @@ import {injectInternals, onScheduleRoot} from './ReactFiberDevToolsHook';
51
import {
52
requestCurrentTimeForUpdate,
53
computeExpirationForFiber,
54
- scheduleWork,
54
+ scheduleUpdateOnFiber,
55
flushRoot,
56
batchedEventUpdates,
57
batchedUpdates,
@@ -294,7 +294,7 @@ export function updateContainer(
294
}
295
296
enqueueUpdate(current, update);
297
- scheduleWork(current, expirationTime);
297
+ scheduleUpdateOnFiber(current, expirationTime);
298
299
return expirationTime;
300
}
@@ -338,7 +338,7 @@ export function attemptSynchronousHydration(fiber: Fiber): void {
338
}
339
break;
340
case SuspenseComponent:
341
- flushSync(() => scheduleWork(fiber, Sync));
341
+ flushSync(() => scheduleUpdateOnFiber(fiber, Sync));
342
// If we're still blocked after this, we need to increase
343
// the priority of any promises resolving within this
344
// boundary so that they next attempt also has higher pri.
@@ -377,7 +377,7 @@ export function attemptUserBlockingHydration(fiber: Fiber): void {
377
return;
378
}
379
let expTime = computeInteractiveExpiration(requestCurrentTimeForUpdate());
380
- scheduleWork(fiber, expTime);
380
+ scheduleUpdateOnFiber(fiber, expTime);
381
markRetryTimeIfNotHydrated(fiber, expTime);
382
}
383
@@ -389,7 +389,7 @@ export function attemptContinuousHydration(fiber: Fiber): void {
389
// Suspense.
390
return;
391
}
392
- scheduleWork(fiber, ContinuousHydration);
392
+ scheduleUpdateOnFiber(fiber, ContinuousHydration);
393
markRetryTimeIfNotHydrated(fiber, ContinuousHydration);
394
}
395
@@ -401,7 +401,7 @@ export function attemptHydrationAtCurrentPriority(fiber: Fiber): void {
401
}
402
const currentTime = requestCurrentTimeForUpdate();
403
const expTime = computeExpirationForFiber(currentTime, fiber, null);
404
- scheduleWork(fiber, expTime);
404
+ scheduleUpdateOnFiber(fiber, expTime);
405
markRetryTimeIfNotHydrated(fiber, expTime);
406
}
407
@@ -484,7 +484,7 @@ if (__DEV__) {
484
// Shallow cloning props works as a workaround for now to bypass the bailout check.
485
fiber.memoizedProps = {...fiber.memoizedProps};
486
487
- scheduleWork(fiber, Sync);
487
+ scheduleUpdateOnFiber(fiber, Sync);
488
}
489
};
490
@@ -494,11 +494,11 @@ if (__DEV__) {
494
if (fiber.alternate) {
495
fiber.alternate.pendingProps = fiber.pendingProps;
496
}
497
- scheduleWork(fiber, Sync);
497
+ scheduleUpdateOnFiber(fiber, Sync);
498
};
499
500
scheduleUpdate = (fiber: Fiber) => {
501
- scheduleWork(fiber, Sync);
501
+ scheduleUpdateOnFiber(fiber, Sync);
502
};
503
504
setSuspenseHandler = (newShouldSuspendImpl: Fiber => boolean) => {
packages/react-reconciler/src/ReactFiberRoot.js
+8
@@ -26,6 +26,7 @@ import {
26
import {unstable_getThreadID} from 'scheduler/tracing';
27
import {NoPriority} from './SchedulerWithReactIntegration';
28
import {initializeUpdateQueue} from './ReactUpdateQueue';
29
+import {clearPendingUpdates as clearPendingMutableSourceUpdates} from './ReactMutableSource';
30
31
export type PendingInteractionMap = Map<ExpirationTime, Set<Interaction>>;
32
@@ -74,6 +75,9 @@ type BaseFiberRootProperties = {|
75
// render again
76
lastPingedTime: ExpirationTime,
77
lastExpiredTime: ExpirationTime,
78
+ // Used by useMutableSource hook to avoid tearing within this root
79
+ // when external, mutable sources are read from during render.
80
+ mutableSourcePendingUpdateTime: ExpirationTime,
81
|};
82
83
// The following attributes are only used by interaction tracing builds.
@@ -123,6 +127,7 @@ function FiberRootNode(containerInfo, tag, hydrate) {
127
this.nextKnownPendingLevel = NoWork;
128
this.lastPingedTime = NoWork;
129
this.lastExpiredTime = NoWork;
130
+ this.mutableSourcePendingUpdateTime = NoWork;
131
132
if (enableSchedulerTracing) {
133
this.interactionThreadID = unstable_getThreadID();
@@ -249,6 +254,9 @@ export function markRootFinishedAtTime(
254
// Clear the expired time
255
root.lastExpiredTime = NoWork;
256
}
257
+
258
+ // Clear any pending updates that were just processed.
259
+ clearPendingMutableSourceUpdates(root, finishedExpirationTime);
260
}
261
262
export function markRootExpiredAtTime(
packages/react-reconciler/src/ReactFiberUnwindWork.js
+3
@@ -11,6 +11,7 @@ import type {Fiber} from './ReactFiber';
11
import type {ExpirationTime} from './ReactFiberExpirationTime';
12
import type {SuspenseState} from './ReactFiberSuspenseComponent';
13
14
+import {resetWorkInProgressVersions as resetMutableSourceWorkInProgressVersions} from './ReactMutableSource';
15
import {
16
ClassComponent,
17
HostRoot,
@@ -55,6 +56,7 @@ function unwindWork(
56
case HostRoot: {
57
popHostContainer(workInProgress);
58
popTopLevelLegacyContextObject(workInProgress);
59
+ resetMutableSourceWorkInProgressVersions();
60
const effectTag = workInProgress.effectTag;
61
invariant(
62
(effectTag & DidCapture) === NoEffect,
@@ -120,6 +122,7 @@ function unwindInterruptedWork(interruptedWork: Fiber) {
122
case HostRoot: {
123
popHostContainer(interruptedWork);
124
popTopLevelLegacyContextObject(interruptedWork);
125
+ resetMutableSourceWorkInProgressVersions();
126
break;
127
}
128
case HostComponent: {
packages/react-reconciler/src/ReactFiberWorkLoop.js
+4
-1
@@ -298,6 +298,10 @@ let spawnedWorkDuringRender: null | Array<ExpirationTime> = null;
298
// receive the same expiration time. Otherwise we get tearing.
299
let currentEventTime: ExpirationTime = NoWork;
300
301
+export function getWorkInProgressRoot(): FiberRoot | null {
302
+ return workInProgressRoot;
303
+}
304
+
305
export function requestCurrentTimeForUpdate() {
306
if ((executionContext & (RenderContext | CommitContext)) !== NoContext) {
307
// We're inside React, so it's fine to read the actual time.
@@ -451,7 +455,6 @@ export function scheduleUpdateOnFiber(
455
}
456
}
457
}
454
-export const scheduleWork = scheduleUpdateOnFiber;
458
459
// This is split into a separate function so we can mark a fiber with pending
460
// work without treating it as a typical update that originates from an event;
packages/react-reconciler/src/ReactMutableSource.js
new
+120
@@ -0,0 +1,120 @@
1
+/**
2
+ * Copyright (c) Facebook, Inc. and its 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
+ * @flow
8
+ */
9
+
10
+import type {ExpirationTime} from 'react-reconciler/src/ReactFiberExpirationTime';
11
+import type {FiberRoot} from 'react-reconciler/src/ReactFiberRoot';
12
+import type {MutableSource, MutableSourceVersion} from 'shared/ReactTypes';
13
+
14
+import {isPrimaryRenderer} from './ReactFiberHostConfig';
15
+import {NoWork} from './ReactFiberExpirationTime';
16
+
17
+// Work in progress version numbers only apply to a single render,
18
+// and should be reset before starting a new render.
19
+// This tracks which mutable sources need to be reset after a render.
20
+let workInProgressPrimarySources: Array<MutableSource<any>> = [];
21
+let workInProgressSecondarySources: Array<MutableSource<any>> = [];
22
+
23
+let rendererSigil;
24
+if (__DEV__) {
25
+ // Used to detect multiple renderers using the same mutable source.
26
+ rendererSigil = {};
27
+}
28
+
29
+export function clearPendingUpdates(
30
+ root: FiberRoot,
31
+ expirationTime: ExpirationTime,
32
+): void {
33
+ if (root.mutableSourcePendingUpdateTime <= expirationTime) {
34
+ root.mutableSourcePendingUpdateTime = NoWork;
35
+ }
36
+}
37
+
38
+export function getPendingExpirationTime(root: FiberRoot): ExpirationTime {
39
+ return root.mutableSourcePendingUpdateTime;
40
+}
41
+
42
+export function setPendingExpirationTime(
43
+ root: FiberRoot,
44
+ expirationTime: ExpirationTime,
45
+): void {
46
+ root.mutableSourcePendingUpdateTime = expirationTime;
47
+}
48
+
49
+export function markSourceAsDirty(mutableSource: MutableSource<any>): void {
50
+ if (isPrimaryRenderer) {
51
+ workInProgressPrimarySources.push(mutableSource);
52
+ } else {
53
+ workInProgressSecondarySources.push(mutableSource);
54
+ }
55
+}
56
+
57
+export function resetWorkInProgressVersions(): void {
58
+ if (isPrimaryRenderer) {
59
+ for (let i = 0; i < workInProgressPrimarySources.length; i++) {
60
+ const mutableSource = workInProgressPrimarySources[i];
61
+ mutableSource._workInProgressVersionPrimary = null;
62
+ }
63
+ workInProgressPrimarySources.length = 0;
64
+ } else {
65
+ for (let i = 0; i < workInProgressSecondarySources.length; i++) {
66
+ const mutableSource = workInProgressSecondarySources[i];
67
+ mutableSource._workInProgressVersionSecondary = null;
68
+ }
69
+ workInProgressSecondarySources.length = 0;
70
+ }
71
+}
72
+
73
+export function getWorkInProgressVersion(
74
+ mutableSource: MutableSource<any>,
75
+): null | MutableSourceVersion {
76
+ if (isPrimaryRenderer) {
77
+ return mutableSource._workInProgressVersionPrimary;
78
+ } else {
79
+ return mutableSource._workInProgressVersionSecondary;
80
+ }
81
+}
82
+
83
+export function setWorkInProgressVersion(
84
+ mutableSource: MutableSource<any>,
85
+ version: MutableSourceVersion,
86
+): void {
87
+ if (isPrimaryRenderer) {
88
+ mutableSource._workInProgressVersionPrimary = version;
89
+ workInProgressPrimarySources.push(mutableSource);
90
+ } else {
91
+ mutableSource._workInProgressVersionSecondary = version;
92
+ workInProgressSecondarySources.push(mutableSource);
93
+ }
94
+}
95
+
96
+export function warnAboutMultipleRenderersDEV(
97
+ mutableSource: MutableSource<any>,
98
+): void {
99
+ if (__DEV__) {
100
+ if (isPrimaryRenderer) {
101
+ if (mutableSource._currentPrimaryRenderer == null) {
102
+ mutableSource._currentPrimaryRenderer = rendererSigil;
103
+ } else if (mutableSource._currentPrimaryRenderer !== rendererSigil) {
104
+ console.error(
105
+ 'Detected multiple renderers concurrently rendering the ' +
106
+ 'same mutable source. This is currently unsupported.',
107
+ );
108
+ }
109
+ } else {
110
+ if (mutableSource._currentSecondaryRenderer == null) {
111
+ mutableSource._currentSecondaryRenderer = rendererSigil;
112
+ } else if (mutableSource._currentSecondaryRenderer !== rendererSigil) {
113
+ console.error(
114
+ 'Detected multiple renderers concurrently rendering the ' +
115
+ 'same mutable source. This is currently unsupported.',
116
+ );
117
+ }
118
+ }
119
+ }
120
+}
packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js
+7
-6
@@ -44,6 +44,7 @@ function loadModules({
44
ReactFeatureFlags.flushSuspenseFallbacksInTests = false;
45
ReactFeatureFlags.deferPassiveEffectCleanupDuringUnmount = deferPassiveEffectCleanupDuringUnmount;
46
ReactFeatureFlags.runAllPassiveEffectDestroysBeforeCreates = runAllPassiveEffectDestroysBeforeCreates;
47
+ ReactFeatureFlags.enableProfilerTimer = true;
48
React = require('react');
49
ReactNoop = require('react-noop-renderer');
50
Scheduler = require('scheduler');
@@ -1901,10 +1902,10 @@ function loadModules({
1902
}
1903
act(() => {
1904
ReactNoop.render(
1904
- <React.Fragment>
1905
+ <>
1906
<Counter label="A" count={0} />
1907
<Counter label="B" count={0} />
1907
- </React.Fragment>,
1908
+ </>,
1909
() => Scheduler.unstable_yieldValue('Sync effect'),
1910
);
1911
expect(Scheduler).toFlushAndYieldThrough([
@@ -1922,10 +1923,10 @@ function loadModules({
1923
1924
act(() => {
1925
ReactNoop.render(
1925
- <React.Fragment>
1926
+ <>
1927
<Counter label="A" count={1} />
1928
<Counter label="B" count={1} />
1928
- </React.Fragment>,
1929
+ </>,
1930
() => Scheduler.unstable_yieldValue('Sync effect'),
1931
);
1932
expect(Scheduler).toFlushAndYieldThrough([
@@ -1947,10 +1948,10 @@ function loadModules({
1948
1949
act(() => {
1950
ReactNoop.render(
1950
- <React.Fragment>
1951
+ <>
1952
<Counter label="B" count={2} />
1953
<Counter label="C" count={0} />
1953
- </React.Fragment>,
1954
+ </>,
1955
() => Scheduler.unstable_yieldValue('Sync effect'),
1956
);
1957
expect(Scheduler).toFlushAndYieldThrough([
packages/react-reconciler/src/__tests__/useMutableSource-test.internal.js
new
+1409
@@ -0,0 +1,1409 @@
1
+/**
2
+ * Copyright (c) Facebook, Inc. and its 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
+/* eslint-disable no-func-assign */
12
+
13
+'use strict';
14
+
15
+let React;
16
+let ReactFeatureFlags;
17
+let ReactNoop;
18
+let Scheduler;
19
+let useMutableSource;
20
+let act;
21
+
22
+function loadModules() {
23
+ jest.resetModules();
24
+ jest.useFakeTimers();
25
+
26
+ ReactFeatureFlags = require('shared/ReactFeatureFlags');
27
+ ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false;
28
+ ReactFeatureFlags.enableSchedulerTracing = true;
29
+ ReactFeatureFlags.flushSuspenseFallbacksInTests = false;
30
+ ReactFeatureFlags.enableProfilerTimer = true;
31
+ React = require('react');
32
+ ReactNoop = require('react-noop-renderer');
33
+ Scheduler = require('scheduler');
34
+ useMutableSource = React.useMutableSource;
35
+ act = ReactNoop.act;
36
+}
37
+
38
+describe('useMutableSource', () => {
39
+ const defaultGetSnapshot = source => source.value;
40
+ const defaultSubscribe = (source, callback) => source.subscribe(callback);
41
+
42
+ function createComplexSource(initialValueA, initialValueB) {
43
+ const callbacksA = [];
44
+ const callbacksB = [];
45
+ let revision = 0;
46
+ let valueA = 'a:one';
47
+ let valueB = 'b:one';
48
+
49
+ const subscribeHelper = (callbacks, callback) => {
50
+ if (callbacks.indexOf(callback) < 0) {
51
+ callbacks.push(callback);
52
+ }
53
+ return () => {
54
+ const index = callbacks.indexOf(callback);
55
+ if (index >= 0) {
56
+ callbacks.splice(index, 1);
57
+ }
58
+ };
59
+ };
60
+
61
+ return {
62
+ subscribeA(callback) {
63
+ return subscribeHelper(callbacksA, callback);
64
+ },
65
+ subscribeB(callback) {
66
+ return subscribeHelper(callbacksB, callback);
67
+ },
68
+
69
+ get listenerCountA() {
70
+ return callbacksA.length;
71
+ },
72
+ get listenerCountB() {
73
+ return callbacksB.length;
74
+ },
75
+
76
+ set valueA(newValue) {
77
+ revision++;
78
+ valueA = newValue;
79
+ callbacksA.forEach(callback => callback());
80
+ },
81
+ get valueA() {
82
+ return valueA;
83
+ },
84
+
85
+ set valueB(newValue) {
86
+ revision++;
87
+ valueB = newValue;
88
+ callbacksB.forEach(callback => callback());
89
+ },
90
+ get valueB() {
91
+ return valueB;
92
+ },
93
+
94
+ get version() {
95
+ return revision;
96
+ },
97
+ };
98
+ }
99
+
100
+ function createSource(initialValue) {
101
+ const callbacks = [];
102
+ let revision = 0;
103
+ let value = initialValue;
104
+ return {
105
+ subscribe(callback) {
106
+ if (callbacks.indexOf(callback) < 0) {
107
+ callbacks.push(callback);
108
+ }
109
+ return () => {
110
+ const index = callbacks.indexOf(callback);
111
+ if (index >= 0) {
112
+ callbacks.splice(index, 1);
113
+ }
114
+ };
115
+ },
116
+ get listenerCount() {
117
+ return callbacks.length;
118
+ },
119
+ set value(newValue) {
120
+ revision++;
121
+ value = newValue;
122
+ callbacks.forEach(callback => callback());
123
+ },
124
+ get value() {
125
+ return value;
126
+ },
127
+ get version() {
128
+ return revision;
129
+ },
130
+ };
131
+ }
132
+
133
+ function createMutableSource(source) {
134
+ return React.createMutableSource(source, param => param.version);
135
+ }
136
+
137
+ function Component({getSnapshot, label, mutableSource, subscribe}) {
138
+ const snapshot = useMutableSource(mutableSource, getSnapshot, subscribe);
139
+ Scheduler.unstable_yieldValue(`${label}:${snapshot}`);
140
+ return <div>{`${label}:${snapshot}`}</div>;
141
+ }
142
+
143
+ if (!__EXPERIMENTAL__) {
144
+ it("empty test so Jest doesn't complain", () => {});
145
+ } else {
146
+ beforeEach(loadModules);
147
+
148
+ it('should subscribe to a source and schedule updates when it changes', () => {
149
+ const source = createSource('one');
150
+ const mutableSource = createMutableSource(source);
151
+
152
+ act(() => {
153
+ ReactNoop.renderToRootWithID(
154
+ <>
155
+ <Component
156
+ label="a"
157
+ getSnapshot={defaultGetSnapshot}
158
+ mutableSource={mutableSource}
159
+ subscribe={defaultSubscribe}
160
+ />
161
+ <Component
162
+ label="b"
163
+ getSnapshot={defaultGetSnapshot}
164
+ mutableSource={mutableSource}
165
+ subscribe={defaultSubscribe}
166
+ />
167
+ </>,
168
+ 'root',
169
+ () => Scheduler.unstable_yieldValue('Sync effect'),
170
+ );
171
+ expect(Scheduler).toFlushAndYieldThrough([
172
+ 'a:one',
173
+ 'b:one',
174
+ 'Sync effect',
175
+ ]);
176
+
177
+ // Subscriptions should be passive
178
+ expect(source.listenerCount).toBe(0);
179
+ ReactNoop.flushPassiveEffects();
180
+ expect(source.listenerCount).toBe(2);
181
+
182
+ // Changing values should schedule an update with React
183
+ source.value = 'two';
184
+ expect(Scheduler).toFlushAndYieldThrough(['a:two', 'b:two']);
185
+
186
+ // Umounting a component should remove its subscriptino.
187
+ ReactNoop.renderToRootWithID(
188
+ <>
189
+ <Component
190
+ label="a"
191
+ getSnapshot={defaultGetSnapshot}
192
+ mutableSource={mutableSource}
193
+ subscribe={defaultSubscribe}
194
+ />
195
+ </>,
196
+ 'root',
197
+ () => Scheduler.unstable_yieldValue('Sync effect'),
198
+ );
199
+ expect(Scheduler).toFlushAndYield(['a:two', 'Sync effect']);
200
+ ReactNoop.flushPassiveEffects();
201
+ expect(source.listenerCount).toBe(1);
202
+
203
+ // Umounting a root should remove the remaining event listeners
204
+ ReactNoop.unmountRootWithID('root');
205
+ expect(Scheduler).toFlushAndYield([]);
206
+ ReactNoop.flushPassiveEffects();
207
+ expect(source.listenerCount).toBe(0);
208
+
209
+ // Changes to source should not trigger an updates or warnings.
210
+ source.value = 'three';
211
+ expect(Scheduler).toFlushAndYield([]);
212
+ });
213
+ });
214
+
215
+ it('should restart work if a new source is mutated during render', () => {
216
+ const source = createSource('one');
217
+ const mutableSource = createMutableSource(source);
218
+
219
+ act(() => {
220
+ ReactNoop.render(
221
+ <>
222
+ <Component
223
+ label="a"
224
+ getSnapshot={defaultGetSnapshot}
225
+ mutableSource={mutableSource}
226
+ subscribe={defaultSubscribe}
227
+ />
228
+ <Component
229
+ label="b"
230
+ getSnapshot={defaultGetSnapshot}
231
+ mutableSource={mutableSource}
232
+ subscribe={defaultSubscribe}
233
+ />
234
+ </>,
235
+ () => Scheduler.unstable_yieldValue('Sync effect'),
236
+ );
237
+
238
+ // Do enough work to read from one component
239
+ expect(Scheduler).toFlushAndYieldThrough(['a:one']);
240
+
241
+ // Mutate source before continuing work
242
+ source.value = 'two';
243
+
244
+ // Render work should restart and the updated value should be used
245
+ expect(Scheduler).toFlushAndYield(['a:two', 'b:two', 'Sync effect']);
246
+ });
247
+ });
248
+
249
+ it('should schedule an update if a new source is mutated between render and commit (subscription)', () => {
250
+ const source = createSource('one');
251
+ const mutableSource = createMutableSource(source);
252
+
253
+ act(() => {
254
+ ReactNoop.render(
255
+ <>
256
+ <Component
257
+ label="a"
258
+ getSnapshot={defaultGetSnapshot}
259
+ mutableSource={mutableSource}
260
+ subscribe={defaultSubscribe}
261
+ />
262
+ <Component
263
+ label="b"
264
+ getSnapshot={defaultGetSnapshot}
265
+ mutableSource={mutableSource}
266
+ subscribe={defaultSubscribe}
267
+ />
268
+ </>,
269
+ () => Scheduler.unstable_yieldValue('Sync effect'),
270
+ );
271
+
272
+ // Finish rendering
273
+ expect(Scheduler).toFlushAndYieldThrough([
274
+ 'a:one',
275
+ 'b:one',
276
+ 'Sync effect',
277
+ ]);
278
+
279
+ // Mutate source before subscriptions are attached
280
+ expect(source.listenerCount).toBe(0);
281
+ source.value = 'two';
282
+
283
+ // Mutation should be detected, and a new render should be scheduled
284
+ expect(Scheduler).toFlushAndYield(['a:two', 'b:two']);
285
+ });
286
+ });
287
+
288
+ it('should unsubscribe and resubscribe if a new source is used', () => {
289
+ const sourceA = createSource('a-one');
290
+ const mutableSourceA = createMutableSource(sourceA);
291
+
292
+ const sourceB = createSource('b-one');
293
+ const mutableSourceB = createMutableSource(sourceB);
294
+
295
+ act(() => {
296
+ ReactNoop.render(
297
+ <Component
298
+ label="only"
299
+ getSnapshot={defaultGetSnapshot}
300
+ mutableSource={mutableSourceA}
301
+ subscribe={defaultSubscribe}
302
+ />,
303
+ () => Scheduler.unstable_yieldValue('Sync effect'),
304
+ );
305
+ expect(Scheduler).toFlushAndYieldThrough(['only:a-one', 'Sync effect']);
306
+ ReactNoop.flushPassiveEffects();
307
+ expect(sourceA.listenerCount).toBe(1);
308
+
309
+ // Changing values should schedule an update with React
310
+ sourceA.value = 'a-two';
311
+ expect(Scheduler).toFlushAndYield(['only:a-two']);
312
+
313
+ // If we re-render with a new source, the old one should be unsubscribed.
314
+ ReactNoop.render(
315
+ <Component
316
+ label="only"
317
+ getSnapshot={defaultGetSnapshot}
318
+ mutableSource={mutableSourceB}
319
+ subscribe={defaultSubscribe}
320
+ />,
321
+ () => Scheduler.unstable_yieldValue('Sync effect'),
322
+ );
323
+ expect(Scheduler).toFlushAndYield(['only:b-one', 'Sync effect']);
324
+ ReactNoop.flushPassiveEffects();
325
+ expect(sourceA.listenerCount).toBe(0);
326
+ expect(sourceB.listenerCount).toBe(1);
327
+
328
+ // Changing to original source should not schedule updates with React
329
+ sourceA.value = 'a-three';
330
+ expect(Scheduler).toFlushAndYield([]);
331
+
332
+ // Changing new source value should schedule an update with React
333
+ sourceB.value = 'b-two';
334
+ expect(Scheduler).toFlushAndYield(['only:b-two']);
335
+ });
336
+ });
337
+
338
+ it('should unsubscribe and resubscribe if a new subscribe function is provided', () => {
339
+ const source = createSource('a-one');
340
+ const mutableSource = createMutableSource(source);
341
+
342
+ const unsubscribeA = jest.fn();
343
+ const subscribeA = jest.fn(s => {
344
+ const unsubscribe = defaultSubscribe(s);
345
+ return () => {
346
+ unsubscribe();
347
+ unsubscribeA();
348
+ };
349
+ });
350
+ const unsubscribeB = jest.fn();
351
+ const subscribeB = jest.fn(s => {
352
+ const unsubscribe = defaultSubscribe(s);
353
+ return () => {
354
+ unsubscribe();
355
+ unsubscribeB();
356
+ };
357
+ });
358
+
359
+ act(() => {
360
+ ReactNoop.renderToRootWithID(
361
+ <Component
362
+ label="only"
363
+ getSnapshot={defaultGetSnapshot}
364
+ mutableSource={mutableSource}
365
+ subscribe={subscribeA}
366
+ />,
367
+ 'root',
368
+ () => Scheduler.unstable_yieldValue('Sync effect'),
369
+ );
370
+ expect(Scheduler).toFlushAndYield(['only:a-one', 'Sync effect']);
371
+ ReactNoop.flushPassiveEffects();
372
+ expect(source.listenerCount).toBe(1);
373
+ expect(subscribeA).toHaveBeenCalledTimes(1);
374
+
375
+ // If we re-render with a new subscription function,
376
+ // the old unsubscribe function should be called.
377
+ ReactNoop.renderToRootWithID(
378
+ <Component
379
+ label="only"
380
+ getSnapshot={defaultGetSnapshot}
381
+ mutableSource={mutableSource}
382
+ subscribe={subscribeB}
383
+ />,
384
+ 'root',
385
+ () => Scheduler.unstable_yieldValue('Sync effect'),
386
+ );
387
+ expect(Scheduler).toFlushAndYield(['only:a-one', 'Sync effect']);
388
+ ReactNoop.flushPassiveEffects();
389
+ expect(source.listenerCount).toBe(1);
390
+ expect(unsubscribeA).toHaveBeenCalledTimes(1);
391
+ expect(subscribeB).toHaveBeenCalledTimes(1);
392
+
393
+ // Unmounting should call the newer unsunscribe.
394
+ ReactNoop.unmountRootWithID('root');
395
+ expect(Scheduler).toFlushAndYield([]);
396
+ ReactNoop.flushPassiveEffects();
397
+ expect(source.listenerCount).toBe(0);
398
+ expect(unsubscribeB).toHaveBeenCalledTimes(1);
399
+ });
400
+ });
401
+
402
+ it('should re-use previously read snapshot value when reading is unsafe', () => {
403
+ const source = createSource('one');
404
+ const mutableSource = createMutableSource(source);
405
+
406
+ act(() => {
407
+ ReactNoop.render(
408
+ <>
409
+ <Component
410
+ label="a"
411
+ getSnapshot={defaultGetSnapshot}
412
+ mutableSource={mutableSource}
413
+ subscribe={defaultSubscribe}
414
+ />
415
+ <Component
416
+ label="b"
417
+ getSnapshot={defaultGetSnapshot}
418
+ mutableSource={mutableSource}
419
+ subscribe={defaultSubscribe}
420
+ />
421
+ </>,
422
+ () => Scheduler.unstable_yieldValue('Sync effect'),
423
+ );
424
+ expect(Scheduler).toFlushAndYield(['a:one', 'b:one', 'Sync effect']);
425
+
426
+ // Changing values should schedule an update with React.
427
+ // Start working on this update but don't finish it.
428
+ source.value = 'two';
429
+ expect(Scheduler).toFlushAndYieldThrough(['a:two']);
430
+
431
+ // Re-renders that occur before the udpate is processed
432
+ // should reuse snapshot so long as the config has not changed
433
+ ReactNoop.flushSync(() => {
434
+ ReactNoop.render(
435
+ <>
436
+ <Component
437
+ label="a"
438
+ getSnapshot={defaultGetSnapshot}
439
+ mutableSource={mutableSource}
440
+ subscribe={defaultSubscribe}
441
+ />
442
+ <Component
443
+ label="b"
444
+ getSnapshot={defaultGetSnapshot}
445
+ mutableSource={mutableSource}
446
+ subscribe={defaultSubscribe}
447
+ />
448
+ </>,
449
+ () => Scheduler.unstable_yieldValue('Sync effect'),
450
+ );
451
+ });
452
+ expect(Scheduler).toHaveYielded(['a:one', 'b:one', 'Sync effect']);
453
+
454
+ expect(Scheduler).toFlushAndYield(['a:two', 'b:two']);
455
+ });
456
+ });
457
+
458
+ it('should read from source on newly mounted subtree if no pending updates are scheduled for source', () => {
459
+ const source = createSource('one');
460
+ const mutableSource = createMutableSource(source);
461
+
462
+ act(() => {
463
+ ReactNoop.render(
464
+ <>
465
+ <Component
466
+ label="a"
467
+ getSnapshot={defaultGetSnapshot}
468
+ mutableSource={mutableSource}
469
+ subscribe={defaultSubscribe}
470
+ />
471
+ </>,
472
+ () => Scheduler.unstable_yieldValue('Sync effect'),
473
+ );
474
+ expect(Scheduler).toFlushAndYield(['a:one', 'Sync effect']);
475
+
476
+ ReactNoop.render(
477
+ <>
478
+ <Component
479
+ label="a"
480
+ getSnapshot={defaultGetSnapshot}
481
+ mutableSource={mutableSource}
482
+ subscribe={defaultSubscribe}
483
+ />
484
+ <Component
485
+ label="b"
486
+ getSnapshot={defaultGetSnapshot}
487
+ mutableSource={mutableSource}
488
+ subscribe={defaultSubscribe}
489
+ />
490
+ </>,
491
+ () => Scheduler.unstable_yieldValue('Sync effect'),
492
+ );
493
+ expect(Scheduler).toFlushAndYield(['a:one', 'b:one', 'Sync effect']);
494
+ });
495
+ });
496
+
497
+ it('should throw and restart render if source and snapshot are unavailable during an update', () => {
498
+ const source = createSource('one');
499
+ const mutableSource = createMutableSource(source);
500
+
501
+ act(() => {
502
+ ReactNoop.render(
503
+ <>
504
+ <Component
505
+ label="a"
506
+ getSnapshot={defaultGetSnapshot}
507
+ mutableSource={mutableSource}
508
+ subscribe={defaultSubscribe}
509
+ />
510
+ <Component
511
+ label="b"
512
+ getSnapshot={defaultGetSnapshot}
513
+ mutableSource={mutableSource}
514
+ subscribe={defaultSubscribe}
515
+ />
516
+ </>,
517
+ () => Scheduler.unstable_yieldValue('Sync effect'),
518
+ );
519
+ expect(Scheduler).toFlushAndYield(['a:one', 'b:one', 'Sync effect']);
520
+ ReactNoop.flushPassiveEffects();
521
+
522
+ // Changing values should schedule an update with React.
523
+ // Start working on this update but don't finish it.
524
+ Scheduler.unstable_runWithPriority(
525
+ Scheduler.unstable_LowPriority,
526
+ () => {
527
+ source.value = 'two';
528
+ expect(Scheduler).toFlushAndYieldThrough(['a:two']);
529
+ },
530
+ );
531
+
532
+ const newGetSnapshot = s => 'new:' + defaultGetSnapshot(s);
533
+
534
+ // Force a higher priority render with a new config.
535
+ // This should signal that the snapshot is not safe and trigger a full re-render.
536
+ Scheduler.unstable_runWithPriority(
537
+ Scheduler.unstable_UserBlockingPriority,
538
+ () => {
539
+ ReactNoop.render(
540
+ <>
541
+ <Component
542
+ label="a"
543
+ getSnapshot={newGetSnapshot}
544
+ mutableSource={mutableSource}
545
+ subscribe={defaultSubscribe}
546
+ />
547
+ <Component
548
+ label="b"
549
+ getSnapshot={newGetSnapshot}
550
+ mutableSource={mutableSource}
551
+ subscribe={defaultSubscribe}
552
+ />
553
+ </>,
554
+ () => Scheduler.unstable_yieldValue('Sync effect'),
555
+ );
556
+ },
557
+ );
558
+ expect(Scheduler).toFlushAndYieldThrough([
559
+ 'a:new:two',
560
+ 'b:new:two',
561
+ 'Sync effect',
562
+ ]);
563
+ });
564
+ });
565
+
566
+ it('should throw and restart render if source and snapshot are unavailable during a sync update', () => {
567
+ const source = createSource('one');
568
+ const mutableSource = createMutableSource(source);
569
+
570
+ act(() => {
571
+ ReactNoop.render(
572
+ <>
573
+ <Component
574
+ label="a"
575
+ getSnapshot={defaultGetSnapshot}
576
+ mutableSource={mutableSource}
577
+ subscribe={defaultSubscribe}
578
+ />
579
+ <Component
580
+ label="b"
581
+ getSnapshot={defaultGetSnapshot}
582
+ mutableSource={mutableSource}
583
+ subscribe={defaultSubscribe}
584
+ />
585
+ </>,
586
+ () => Scheduler.unstable_yieldValue('Sync effect'),
587
+ );
588
+ expect(Scheduler).toFlushAndYield(['a:one', 'b:one', 'Sync effect']);
589
+ ReactNoop.flushPassiveEffects();
590
+
591
+ // Changing values should schedule an update with React.
592
+ // Start working on this update but don't finish it.
593
+ Scheduler.unstable_runWithPriority(
594
+ Scheduler.unstable_LowPriority,
595
+ () => {
596
+ source.value = 'two';
597
+ expect(Scheduler).toFlushAndYieldThrough(['a:two']);
598
+ },
599
+ );
600
+
601
+ const newGetSnapshot = s => 'new:' + defaultGetSnapshot(s);
602
+
603
+ // Force a higher priority render with a new config.
604
+ // This should signal that the snapshot is not safe and trigger a full re-render.
605
+ ReactNoop.flushSync(() => {
606
+ ReactNoop.render(
607
+ <>
608
+ <Component
609
+ label="a"
610
+ getSnapshot={newGetSnapshot}
611
+ mutableSource={mutableSource}
612
+ subscribe={defaultSubscribe}
613
+ />
614
+ <Component
615
+ label="b"
616
+ getSnapshot={newGetSnapshot}
617
+ mutableSource={mutableSource}
618
+ subscribe={defaultSubscribe}
619
+ />
620
+ </>,
621
+ () => Scheduler.unstable_yieldValue('Sync effect'),
622
+ );
623
+ });
624
+ expect(Scheduler).toHaveYielded([
625
+ 'a:new:two',
626
+ 'b:new:two',
627
+ 'Sync effect',
628
+ ]);
629
+ });
630
+ });
631
+
632
+ it('should only update components whose subscriptions fire', () => {
633
+ const source = createComplexSource('one', 'one');
634
+ const mutableSource = createMutableSource(source);
635
+
636
+ // Subscribe to part of the store.
637
+ const getSnapshotA = s => s.valueA;
638
+ const subscribeA = (s, callback) => s.subscribeA(callback);
639
+ const getSnapshotB = s => s.valueB;
640
+ const subscribeB = (s, callback) => s.subscribeB(callback);
641
+
642
+ act(() => {
643
+ ReactNoop.render(
644
+ <>
645
+ <Component
646
+ label="a"
647
+ getSnapshot={getSnapshotA}
648
+ mutableSource={mutableSource}
649
+ subscribe={subscribeA}
650
+ />
651
+ <Component
652
+ label="b"
653
+ getSnapshot={getSnapshotB}
654
+ mutableSource={mutableSource}
655
+ subscribe={subscribeB}
656
+ />
657
+ </>,
658
+ () => Scheduler.unstable_yieldValue('Sync effect'),
659
+ );
660
+ expect(Scheduler).toFlushAndYield([
661
+ 'a:a:one',
662
+ 'b:b:one',
663
+ 'Sync effect',
664
+ ]);
665
+
666
+ // Changes to part of the store (e.g. A) should not render other parts.
667
+ source.valueA = 'a:two';
668
+ expect(Scheduler).toFlushAndYield(['a:a:two']);
669
+ source.valueB = 'b:two';
670
+ expect(Scheduler).toFlushAndYield(['b:b:two']);
671
+ });
672
+ });
673
+
674
+ it('should detect tearing in part of the store not yet subscribed to', () => {
675
+ const source = createComplexSource('one', 'one');
676
+ const mutableSource = createMutableSource(source);
677
+
678
+ // Subscribe to part of the store.
679
+ const getSnapshotA = s => s.valueA;
680
+ const subscribeA = (s, callback) => s.subscribeA(callback);
681
+ const getSnapshotB = s => s.valueB;
682
+ const subscribeB = (s, callback) => s.subscribeB(callback);
683
+
684
+ act(() => {
685
+ ReactNoop.render(
686
+ <>
687
+ <Component
688
+ label="a"
689
+ getSnapshot={getSnapshotA}
690
+ mutableSource={mutableSource}
691
+ subscribe={subscribeA}
692
+ />
693
+ </>,
694
+ () => Scheduler.unstable_yieldValue('Sync effect'),
695
+ );
696
+ expect(Scheduler).toFlushAndYield(['a:a:one', 'Sync effect']);
697
+
698
+ // Because the store has not chagned yet, there are no pending updates,
699
+ // so it is considered safe to read from when we start this render.
700
+ ReactNoop.render(
701
+ <>
702
+ <Component
703
+ label="a"
704
+ getSnapshot={getSnapshotA}
705
+ mutableSource={mutableSource}
706
+ subscribe={subscribeA}
707
+ />
708
+ <Component
709
+ label="b"
710
+ getSnapshot={getSnapshotB}
711
+ mutableSource={mutableSource}
712
+ subscribe={subscribeB}
713
+ />
714
+ <Component
715
+ label="c"
716
+ getSnapshot={getSnapshotB}
717
+ mutableSource={mutableSource}
718
+ subscribe={subscribeB}
719
+ />
720
+ </>,
721
+ () => Scheduler.unstable_yieldValue('Sync effect'),
722
+ );
723
+ expect(Scheduler).toFlushAndYieldThrough(['a:a:one', 'b:b:one']);
724
+
725
+ // Mutating the source should trigger a tear detection on the next read,
726
+ // which should throw and re-render the entire tree.
727
+ source.valueB = 'b:two';
728
+
729
+ expect(Scheduler).toFlushAndYield([
730
+ 'a:a:one',
731
+ 'b:b:two',
732
+ 'c:b:two',
733
+ 'Sync effect',
734
+ ]);
735
+ });
736
+ });
737
+
738
+ it('does not schedule an update for subscriptions that fire with an unchanged snapshot', () => {
739
+ const MockComponent = jest.fn(Component);
740
+
741
+ const source = createSource('one');
742
+ const mutableSource = createMutableSource(source);
743
+
744
+ act(() => {
745
+ ReactNoop.render(
746
+ <MockComponent
747
+ label="only"
748
+ getSnapshot={defaultGetSnapshot}
749
+ mutableSource={mutableSource}
750
+ subscribe={defaultSubscribe}
751
+ />,
752
+ () => Scheduler.unstable_yieldValue('Sync effect'),
753
+ );
754
+ expect(Scheduler).toFlushAndYieldThrough(['only:one', 'Sync effect']);
755
+ ReactNoop.flushPassiveEffects();
756
+ expect(source.listenerCount).toBe(1);
757
+
758
+ // Notify subscribe function but don't change the value
759
+ source.value = 'one';
760
+ expect(Scheduler).toFlushWithoutYielding();
761
+ });
762
+ });
763
+
764
+ it('should throw and restart if getSnapshot changes between scheduled update and re-render', () => {
765
+ const source = createSource('one');
766
+ const mutableSource = createMutableSource(source);
767
+
768
+ const newGetSnapshot = s => 'new:' + defaultGetSnapshot(s);
769
+
770
+ let updateGetSnapshot;
771
+
772
+ function WrapperWithState() {
773
+ const tuple = React.useState(() => defaultGetSnapshot);
774
+ updateGetSnapshot = tuple[1];
775
+ return (
776
+ <Component
777
+ label="only"
778
+ getSnapshot={tuple[0]}
779
+ mutableSource={mutableSource}
780
+ subscribe={defaultSubscribe}
781
+ />
782
+ );
783
+ }
784
+
785
+ act(() => {
786
+ ReactNoop.render(<WrapperWithState />, () =>
787
+ Scheduler.unstable_yieldValue('Sync effect'),
788
+ );
789
+ expect(Scheduler).toFlushAndYield(['only:one', 'Sync effect']);
790
+ ReactNoop.flushPassiveEffects();
791
+
792
+ // Change the source (and schedule an update).
793
+ Scheduler.unstable_runWithPriority(
794
+ Scheduler.unstable_LowPriority,
795
+ () => {
796
+ source.value = 'two';
797
+ },
798
+ );
799
+
800
+ // Schedule a higher priority update that changes getSnapshot.
801
+ Scheduler.unstable_runWithPriority(
802
+ Scheduler.unstable_UserBlockingPriority,
803
+ () => {
804
+ updateGetSnapshot(() => newGetSnapshot);
805
+ },
806
+ );
807
+
808
+ expect(Scheduler).toFlushAndYield(['only:new:two']);
809
+ });
810
+ });
811
+
812
+ it('should recover from a mutation during yield when other work is scheduled', () => {
813
+ const source = createSource('one');
814
+ const mutableSource = createMutableSource(source);
815
+
816
+ act(() => {
817
+ // Start a render that uses the mutable source.
818
+ ReactNoop.render(
819
+ <>
820
+ <Component
821
+ label="a"
822
+ getSnapshot={defaultGetSnapshot}
823
+ mutableSource={mutableSource}
824
+ subscribe={defaultSubscribe}
825
+ />
826
+ <Component
827
+ label="b"
828
+ getSnapshot={defaultGetSnapshot}
829
+ mutableSource={mutableSource}
830
+ subscribe={defaultSubscribe}
831
+ />
832
+ </>,
833
+ );
834
+ expect(Scheduler).toFlushAndYieldThrough(['a:one']);
835
+
836
+ // Mutate source
837
+ source.value = 'two';
838
+
839
+ // Now render something different.
840
+ ReactNoop.render(<div />);
841
+ expect(Scheduler).toFlushAndYield([]);
842
+ });
843
+ });
844
+
845
+ it('should not throw if the new getSnapshot returns the same snapshot value', () => {
846
+ const source = createSource('one');
847
+ const mutableSource = createMutableSource(source);
848
+
849
+ const onRenderA = jest.fn();
850
+ const onRenderB = jest.fn();
851
+
852
+ let updateGetSnapshot;
853
+
854
+ function WrapperWithState() {
855
+ const tuple = React.useState(() => defaultGetSnapshot);
856
+ updateGetSnapshot = tuple[1];
857
+ return (
858
+ <Component
859
+ label="b"
860
+ getSnapshot={tuple[0]}
861
+ mutableSource={mutableSource}
862
+ subscribe={defaultSubscribe}
863
+ />
864
+ );
865
+ }
866
+
867
+ act(() => {
868
+ ReactNoop.render(
869
+ <>
870
+ <React.Profiler id="a" onRender={onRenderA}>
871
+ <Component
872
+ label="a"
873
+ getSnapshot={defaultGetSnapshot}
874
+ mutableSource={mutableSource}
875
+ subscribe={defaultSubscribe}
876
+ />
877
+ </React.Profiler>
878
+ <React.Profiler id="b" onRender={onRenderB}>
879
+ <WrapperWithState />
880
+ </React.Profiler>
881
+ </>,
882
+ () => Scheduler.unstable_yieldValue('Sync effect'),
883
+ );
884
+ expect(Scheduler).toFlushAndYield(['a:one', 'b:one', 'Sync effect']);
885
+ ReactNoop.flushPassiveEffects();
886
+ expect(onRenderA).toHaveBeenCalledTimes(1);
887
+ expect(onRenderB).toHaveBeenCalledTimes(1);
888
+
889
+ // If B's getSnapshot function updates, but the snapshot it returns is the same,
890
+ // only B should re-render (to update its state).
891
+ updateGetSnapshot(() => s => defaultGetSnapshot(s));
892
+ expect(Scheduler).toFlushAndYield(['b:one']);
893
+ ReactNoop.flushPassiveEffects();
894
+ expect(onRenderA).toHaveBeenCalledTimes(1);
895
+ expect(onRenderB).toHaveBeenCalledTimes(2);
896
+ });
897
+ });
898
+
899
+ it('should not throw if getSnapshot changes but the source can be safely read from anyway', () => {
900
+ const source = createSource('one');
901
+ const mutableSource = createMutableSource(source);
902
+
903
+ const newGetSnapshot = s => 'new:' + defaultGetSnapshot(s);
904
+
905
+ let updateGetSnapshot;
906
+
907
+ function WrapperWithState() {
908
+ const tuple = React.useState(() => defaultGetSnapshot);
909
+ updateGetSnapshot = tuple[1];
910
+ return (
911
+ <Component
912
+ label="only"
913
+ getSnapshot={tuple[0]}
914
+ mutableSource={mutableSource}
915
+ subscribe={defaultSubscribe}
916
+ />
917
+ );
918
+ }
919
+
920
+ act(() => {
921
+ ReactNoop.render(<WrapperWithState />, () =>
922
+ Scheduler.unstable_yieldValue('Sync effect'),
923
+ );
924
+ expect(Scheduler).toFlushAndYield(['only:one', 'Sync effect']);
925
+ ReactNoop.flushPassiveEffects();
926
+
927
+ // Change the source (and schedule an update)
928
+ // but also change the snapshot function too.
929
+ ReactNoop.batchedUpdates(() => {
930
+ source.value = 'two';
931
+ updateGetSnapshot(() => newGetSnapshot);
932
+ });
933
+
934
+ expect(Scheduler).toFlushAndYield(['only:new:two']);
935
+ });
936
+ });
937
+
938
+ it('should still schedule an update if an eager selector throws after a mutation', () => {
939
+ const source = createSource({
940
+ friends: [
941
+ {id: 1, name: 'Foo'},
942
+ {id: 2, name: 'Bar'},
943
+ ],
944
+ });
945
+ const mutableSource = createMutableSource(source);
946
+
947
+ function FriendsList() {
948
+ const getSnapshot = React.useCallback(
949
+ ({value}) => Array.from(value.friends),
950
+ [],
951
+ );
952
+ const friends = useMutableSource(
953
+ mutableSource,
954
+ getSnapshot,
955
+ defaultSubscribe,
956
+ );
957
+ return (
958
+ <ul>
959
+ {friends.map(friend => (
960
+ <Friend key={friend.id} id={friend.id} />
961
+ ))}
962
+ </ul>
963
+ );
964
+ }
965
+
966
+ function Friend({id}) {
967
+ const getSnapshot = React.useCallback(
968
+ ({value}) => {
969
+ // This selector is intentionally written in a way that will throw
970
+ // if no matching friend exists in the store.
971
+ return value.friends.find(friend => friend.id === id).name;
972
+ },
973
+ [id],
974
+ );
975
+ const name = useMutableSource(
976
+ mutableSource,
977
+ getSnapshot,
978
+ defaultSubscribe,
979
+ );
980
+ Scheduler.unstable_yieldValue(`${id}:${name}`);
981
+ return <li>{name}</li>;
982
+ }
983
+
984
+ act(() => {
985
+ ReactNoop.render(<FriendsList />, () =>
986
+ Scheduler.unstable_yieldValue('Sync effect'),
987
+ );
988
+ expect(Scheduler).toFlushAndYield(['1:Foo', '2:Bar', 'Sync effect']);
989
+
990
+ // This mutation will cause the "Bar" component to throw,
991
+ // since its value will no longer be a part of the store.
992
+ // Mutable source should still schedule an update though,
993
+ // which should unmount "Bar" and mount "Baz".
994
+ source.value = {
995
+ friends: [
996
+ {id: 1, name: 'Foo'},
997
+ {id: 3, name: 'Baz'},
998
+ ],
999
+ };
1000
+ expect(Scheduler).toFlushAndYield(['1:Foo', '3:Baz']);
1001
+ });
1002
+ });
1003
+
1004
+ it('should not warn about updates that fire between unmount and passive unsubcribe', () => {
1005
+ const source = createSource('one');
1006
+ const mutableSource = createMutableSource(source);
1007
+
1008
+ function Wrapper() {
1009
+ React.useLayoutEffect(() => () => {
1010
+ Scheduler.unstable_yieldValue('layout unmount');
1011
+ });
1012
+ return (
1013
+ <Component
1014
+ label="only"
1015
+ getSnapshot={defaultGetSnapshot}
1016
+ mutableSource={mutableSource}
1017
+ subscribe={defaultSubscribe}
1018
+ />
1019
+ );
1020
+ }
1021
+
1022
+ act(() => {
1023
+ ReactNoop.renderToRootWithID(<Wrapper />, 'root', () =>
1024
+ Scheduler.unstable_yieldValue('Sync effect'),
1025
+ );
1026
+ expect(Scheduler).toFlushAndYield(['only:one', 'Sync effect']);
1027
+ ReactNoop.flushPassiveEffects();
1028
+
1029
+ // Umounting a root should remove the remaining event listeners in a passive effect
1030
+ ReactNoop.unmountRootWithID('root');
1031
+ expect(Scheduler).toFlushAndYieldThrough(['layout unmount']);
1032
+
1033
+ // Changes to source should not cause a warning,
1034
+ // even though the unsubscribe hasn't run yet (since it's a pending passive effect).
1035
+ source.value = 'two';
1036
+ expect(Scheduler).toFlushAndYield([]);
1037
+ });
1038
+ });
1039
+
1040
+ it('should support inline selectors and updates that are processed after selector change', async () => {
1041
+ const source = createSource({
1042
+ a: 'initial',
1043
+ b: 'initial',
1044
+ });
1045
+ const mutableSource = createMutableSource(source);
1046
+
1047
+ const getSnapshotA = () => source.value.a;
1048
+ const getSnapshotB = () => source.value.b;
1049
+
1050
+ function mutateB(newB) {
1051
+ source.value = {
1052
+ ...source.value,
1053
+ b: newB,
1054
+ };
1055
+ }
1056
+
1057
+ function App({toggle}) {
1058
+ const state = useMutableSource(
1059
+ mutableSource,
1060
+ toggle ? getSnapshotB : getSnapshotA,
1061
+ defaultSubscribe,
1062
+ );
1063
+ const result = (toggle ? 'on: ' : 'off: ') + state;
1064
+ return result;
1065
+ }
1066
+
1067
+ const root = ReactNoop.createRoot();
1068
+ await act(async () => {
1069
+ root.render(<App toggle={false} />);
1070
+ });
1071
+ expect(root).toMatchRenderedOutput('off: initial');
1072
+
1073
+ await act(async () => {
1074
+ mutateB('Updated B');
1075
+ root.render(<App toggle={true} />);
1076
+ });
1077
+ expect(root).toMatchRenderedOutput('on: Updated B');
1078
+
1079
+ await act(async () => {
1080
+ mutateB('Another update');
1081
+ });
1082
+ expect(root).toMatchRenderedOutput('on: Another update');
1083
+ });
1084
+
1085
+ it('should clear the update queue when getSnapshot changes with pending lower priority updates', async () => {
1086
+ const source = createSource({
1087
+ a: 'initial',
1088
+ b: 'initial',
1089
+ });
1090
+ const mutableSource = createMutableSource(source);
1091
+
1092
+ const getSnapshotA = () => source.value.a;
1093
+ const getSnapshotB = () => source.value.b;
1094
+
1095
+ function mutateA(newA) {
1096
+ source.value = {
1097
+ ...source.value,
1098
+ a: newA,
1099
+ };
1100
+ }
1101
+
1102
+ function mutateB(newB) {
1103
+ source.value = {
1104
+ ...source.value,
1105
+ b: newB,
1106
+ };
1107
+ }
1108
+
1109
+ function App({toggle}) {
1110
+ const state = useMutableSource(
1111
+ mutableSource,
1112
+ toggle ? getSnapshotB : getSnapshotA,
1113
+ defaultSubscribe,
1114
+ );
1115
+ const result = (toggle ? 'B: ' : 'A: ') + state;
1116
+ return result;
1117
+ }
1118
+
1119
+ const root = ReactNoop.createRoot();
1120
+ await act(async () => {
1121
+ root.render(<App toggle={false} />);
1122
+ });
1123
+ expect(root).toMatchRenderedOutput('A: initial');
1124
+
1125
+ await act(async () => {
1126
+ ReactNoop.discreteUpdates(() => {
1127
+ // Update both A and B to the same value
1128
+ mutateA('Update');
1129
+ mutateB('Update');
1130
+ // Toggle to B in the same batch
1131
+ root.render(<App toggle={true} />);
1132
+ });
1133
+ // Mutate A at lower priority. This should never be rendered, because
1134
+ // by the time we get to the lower priority, we've already switched
1135
+ // to B.
1136
+ mutateA('OOPS! This mutation should be ignored');
1137
+ });
1138
+ expect(root).toMatchRenderedOutput('B: Update');
1139
+ });
1140
+
1141
+ it('should clear the update queue when source changes with pending lower priority updates', async () => {
1142
+ const sourceA = createSource('initial');
1143
+ const sourceB = createSource('initial');
1144
+ const mutableSourceA = createMutableSource(sourceA);
1145
+ const mutableSourceB = createMutableSource(sourceB);
1146
+
1147
+ function App({toggle}) {
1148
+ const state = useMutableSource(
1149
+ toggle ? mutableSourceB : mutableSourceA,
1150
+ defaultGetSnapshot,
1151
+ defaultSubscribe,
1152
+ );
1153
+ const result = (toggle ? 'B: ' : 'A: ') + state;
1154
+ return result;
1155
+ }
1156
+
1157
+ const root = ReactNoop.createRoot();
1158
+ await act(async () => {
1159
+ root.render(<App toggle={false} />);
1160
+ });
1161
+ expect(root).toMatchRenderedOutput('A: initial');
1162
+
1163
+ await act(async () => {
1164
+ ReactNoop.discreteUpdates(() => {
1165
+ // Update both A and B to the same value
1166
+ sourceA.value = 'Update';
1167
+ sourceB.value = 'Update';
1168
+ // Toggle to B in the same batch
1169
+ root.render(<App toggle={true} />);
1170
+ });
1171
+ // Mutate A at lower priority. This should never be rendered, because
1172
+ // by the time we get to the lower priority, we've already switched
1173
+ // to B.
1174
+ sourceA.value = 'OOPS! This mutation should be ignored';
1175
+ });
1176
+ expect(root).toMatchRenderedOutput('B: Update');
1177
+ });
1178
+
1179
+ it('should always treat reading as potentially unsafe when getSnapshot changes between renders', async () => {
1180
+ const source = createSource({
1181
+ a: 'foo',
1182
+ b: 'bar',
1183
+ });
1184
+ const mutableSource = createMutableSource(source);
1185
+
1186
+ const getSnapshotA = () => source.value.a;
1187
+ const getSnapshotB = () => source.value.b;
1188
+
1189
+ function mutateA(newA) {
1190
+ source.value = {
1191
+ ...source.value,
1192
+ a: newA,
1193
+ };
1194
+ }
1195
+
1196
+ function App({getSnapshotFirst, getSnapshotSecond}) {
1197
+ const first = useMutableSource(
1198
+ mutableSource,
1199
+ getSnapshotFirst,
1200
+ defaultSubscribe,
1201
+ );
1202
+ const second = useMutableSource(
1203
+ mutableSource,
1204
+ getSnapshotSecond,
1205
+ defaultSubscribe,
1206
+ );
1207
+
1208
+ let result = `x: ${first}, y: ${second}`;
1209
+
1210
+ if (getSnapshotFirst === getSnapshotSecond) {
1211
+ // When both getSnapshot functions are equal,
1212
+ // the two values must be consistent.
1213
+ if (first !== second) {
1214
+ result = 'Oops, tearing!';
1215
+ }
1216
+ }
1217
+
1218
+ React.useEffect(() => {
1219
+ Scheduler.unstable_yieldValue(result);
1220
+ }, [result]);
1221
+
1222
+ return result;
1223
+ }
1224
+
1225
+ const root = ReactNoop.createRoot();
1226
+ await act(async () => {
1227
+ root.render(
1228
+ <App
1229
+ getSnapshotFirst={getSnapshotA}
1230
+ getSnapshotSecond={getSnapshotB}
1231
+ />,
1232
+ );
1233
+ });
1234
+ // x and y start out reading from different parts of the store.
1235
+ expect(Scheduler).toHaveYielded(['x: foo, y: bar']);
1236
+
1237
+ await act(async () => {
1238
+ ReactNoop.discreteUpdates(() => {
1239
+ // At high priority, toggle y so that it reads from A instead of B.
1240
+ // Simultaneously, mutate A.
1241
+ mutateA('high-pri baz');
1242
+ root.render(
1243
+ <App
1244
+ getSnapshotFirst={getSnapshotA}
1245
+ getSnapshotSecond={getSnapshotA}
1246
+ />,
1247
+ );
1248
+
1249
+ // If this update were processed before the next mutation,
1250
+ // it would be expected to yield "high-pri baz" and "high-pri baz".
1251
+ });
1252
+
1253
+ // At lower priority, mutate A again.
1254
+ // This happens to match the initial value of B.
1255
+ mutateA('bar');
1256
+
1257
+ // When this update is processed,
1258
+ // it is expected to yield "bar" and "bar".
1259
+ });
1260
+
1261
+ // Check that we didn't commit any inconsistent states.
1262
+ // The actual sequence of work will be:
1263
+ // 1. React renders the high-pri update, sees a new getSnapshot, detects the source has been further mutated, and throws
1264
+ // 2. React re-renders with all pending updates, including the second mutation, and renders "bar" and "bar".
1265
+ expect(Scheduler).toHaveYielded(['x: bar, y: bar']);
1266
+ });
1267
+
1268
+ if (__DEV__) {
1269
+ describe('dev warnings', () => {
1270
+ it('should warn if the subscribe function does not return an unsubscribe function', () => {
1271
+ const source = createSource('one');
1272
+ const mutableSource = createMutableSource(source);
1273
+
1274
+ const brokenSubscribe = () => {};
1275
+
1276
+ expect(() => {
1277
+ act(() => {
1278
+ ReactNoop.render(
1279
+ <Component
1280
+ label="only"
1281
+ getSnapshot={defaultGetSnapshot}
1282
+ mutableSource={mutableSource}
1283
+ subscribe={brokenSubscribe}
1284
+ />,
1285
+ );
1286
+ });
1287
+ }).toErrorDev(
1288
+ 'Mutable source subscribe function must return an unsubscribe function.',
1289
+ );
1290
+ });
1291
+
1292
+ it('should error if multiple renderers of the same type use a mutable source at the same time', () => {
1293
+ const source = createSource('one');
1294
+ const mutableSource = createMutableSource(source);
1295
+
1296
+ act(() => {
1297
+ // Start a render that uses the mutable source.
1298
+ ReactNoop.render(
1299
+ <>
1300
+ <Component
1301
+ label="a"
1302
+ getSnapshot={defaultGetSnapshot}
1303
+ mutableSource={mutableSource}
1304
+ subscribe={defaultSubscribe}
1305
+ />
1306
+ <Component
1307
+ label="b"
1308
+ getSnapshot={defaultGetSnapshot}
1309
+ mutableSource={mutableSource}
1310
+ subscribe={defaultSubscribe}
1311
+ />
1312
+ </>,
1313
+ );
1314
+ expect(Scheduler).toFlushAndYieldThrough(['a:one']);
1315
+
1316
+ const PrevScheduler = Scheduler;
1317
+
1318
+ // Get a new copy of ReactNoop.
1319
+ loadModules();
1320
+
1321
+ spyOnDev(console, 'error');
1322
+
1323
+ // Use the mutablesource again but with a different renderer.
1324
+ ReactNoop.render(
1325
+ <Component
1326
+ label="c"
1327
+ getSnapshot={defaultGetSnapshot}
1328
+ mutableSource={mutableSource}
1329
+ subscribe={defaultSubscribe}
1330
+ />,
1331
+ );
1332
+ expect(Scheduler).toFlushAndYieldThrough(['c:one']);
1333
+
1334
+ expect(console.error.calls.argsFor(0)[0]).toContain(
1335
+ 'Detected multiple renderers concurrently rendering the ' +
1336
+ 'same mutable source. This is currently unsupported.',
1337
+ );
1338
+
1339
+ // TODO (useMutableSource) Act will automatically flush remaining work from render 1,
1340
+ // but at this point something in the hooks dispatcher has been broken by jest.resetModules()
1341
+ // Figure out what this is and remove this catch.
1342
+ expect(() =>
1343
+ PrevScheduler.unstable_flushAllWithoutAsserting(),
1344
+ ).toThrow('Invalid hook call');
1345
+ });
1346
+ });
1347
+
1348
+ it('should error if multiple renderers of the same type use a mutable source at the same time with mutation between', () => {
1349
+ const source = createSource('one');
1350
+ const mutableSource = createMutableSource(source);
1351
+
1352
+ act(() => {
1353
+ // Start a render that uses the mutable source.
1354
+ ReactNoop.render(
1355
+ <>
1356
+ <Component
1357
+ label="a"
1358
+ getSnapshot={defaultGetSnapshot}
1359
+ mutableSource={mutableSource}
1360
+ subscribe={defaultSubscribe}
1361
+ />
1362
+ <Component
1363
+ label="b"
1364
+ getSnapshot={defaultGetSnapshot}
1365
+ mutableSource={mutableSource}
1366
+ subscribe={defaultSubscribe}
1367
+ />
1368
+ </>,
1369
+ );
1370
+ expect(Scheduler).toFlushAndYieldThrough(['a:one']);
1371
+
1372
+ const PrevScheduler = Scheduler;
1373
+
1374
+ // Get a new copy of ReactNoop.
1375
+ loadModules();
1376
+
1377
+ spyOnDev(console, 'error');
1378
+
1379
+ // Mutate before the new render reads from the source.
1380
+ source.value = 'two';
1381
+
1382
+ // Use the mutablesource again but with a different renderer.
1383
+ ReactNoop.render(
1384
+ <Component
1385
+ label="c"
1386
+ getSnapshot={defaultGetSnapshot}
1387
+ mutableSource={mutableSource}
1388
+ subscribe={defaultSubscribe}
1389
+ />,
1390
+ );
1391
+ expect(Scheduler).toFlushAndYieldThrough(['c:two']);
1392
+
1393
+ expect(console.error.calls.argsFor(0)[0]).toContain(
1394
+ 'Detected multiple renderers concurrently rendering the ' +
1395
+ 'same mutable source. This is currently unsupported.',
1396
+ );
1397
+
1398
+ // TODO (useMutableSource) Act will automatically flush remaining work from render 1,
1399
+ // but at this point something in the hooks dispatcher has been broken by jest.resetModules()
1400
+ // Figure out what this is and remove this catch.
1401
+ expect(() =>
1402
+ PrevScheduler.unstable_flushAllWithoutAsserting(),
1403
+ ).toThrow('Invalid hook call');
1404
+ });
1405
+ });
1406
+ });
1407
+ }
1408
+ }
1409
+});
packages/react/index.classic.fb.js
+2
@@ -26,6 +26,8 @@ export {
26
useReducer,
27
useRef,
28
useState,
29
+ useMutableSource,
30
+ createMutableSource,
31
Fragment,
32
Profiler,
33
StrictMode,
packages/react/index.experimental.js
+2
@@ -26,6 +26,8 @@ export {
26
useReducer,
27
useRef,
28
useState,
29
+ useMutableSource,
30
+ createMutableSource,
31
Fragment,
32
Profiler,
33
StrictMode,
packages/react/index.js
+2
@@ -55,6 +55,8 @@ export {
55
useReducer,
56
useRef,
57
useState,
58
+ useMutableSource,
59
+ createMutableSource,
60
Fragment,
61
Profiler,
62
StrictMode,
packages/react/index.modern.fb.js
+2
@@ -23,6 +23,8 @@ export {
23
useDebugValue,
24
useLayoutEffect,
25
useMemo,
26
+ useMutableSource,
27
+ createMutableSource,
28
useReducer,
29
useRef,
30
useState,
packages/react/src/React.js
+4
@@ -39,6 +39,7 @@ import {
39
useDebugValue,
40
useLayoutEffect,
41
useMemo,
42
+ useMutableSource,
43
useReducer,
44
useRef,
45
useState,
@@ -55,6 +56,7 @@ import {
56
jsxWithValidationStatic,
57
jsxWithValidationDynamic,
58
} from './ReactElementValidator';
59
+import createMutableSource from './createMutableSource';
60
import ReactSharedInternals from './ReactSharedInternals';
61
import createFundamental from 'shared/createFundamentalComponent';
62
import createResponder from 'shared/createEventResponder';
@@ -81,6 +83,7 @@ const Children = {
83
84
export {
85
Children,
86
+ createMutableSource,
87
createRef,
88
Component,
89
PureComponent,
@@ -95,6 +98,7 @@ export {
98
useDebugValue,
99
useLayoutEffect,
100
useMemo,
101
+ useMutableSource,
102
useReducer,
103
useRef,
104
useState,
packages/react/src/ReactHooks.js
+12
@@ -8,6 +8,9 @@
8
*/
9
10
import type {
11
+ MutableSource,
12
+ MutableSourceGetSnapshotFn,
13
+ MutableSourceSubscribeFn,
14
ReactContext,
15
ReactEventResponder,
16
ReactEventResponderListener,
@@ -177,3 +180,12 @@ export function useDeferredValue<T>(value: T, config: ?Object): T {
180
const dispatcher = resolveDispatcher();
181
return dispatcher.useDeferredValue(value, config);
182
}
183
+
184
+export function useMutableSource<Source, Snapshot>(
185
+ source: MutableSource<Source>,
186
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
187
+ subscribe: MutableSourceSubscribeFn<Source, Snapshot>,
188
+): Snapshot {
189
+ const dispatcher = resolveDispatcher();
190
+ return dispatcher.useMutableSource(source, getSnapshot, subscribe);
191
+}
packages/react/src/createMutableSource.js
new
+29
@@ -0,0 +1,29 @@
1
+/**
2
+ * Copyright (c) Facebook, Inc. and its 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
+ * @flow
8
+ */
9
+
10
+import type {MutableSource, MutableSourceGetVersionFn} from 'shared/ReactTypes';
11
+
12
+export default function createMutableSource<Source: $NonMaybeType<mixed>>(
13
+ source: Source,
14
+ getVersion: MutableSourceGetVersionFn,
15
+): MutableSource<Source> {
16
+ const mutableSource: MutableSource<Source> = {
17
+ _getVersion: getVersion,
18
+ _source: source,
19
+ _workInProgressVersionPrimary: null,
20
+ _workInProgressVersionSecondary: null,
21
+ };
22
+
23
+ if (__DEV__) {
24
+ mutableSource._currentPrimaryRenderer = null;
25
+ mutableSource._currentSecondaryRenderer = null;
26
+ }
27
+
28
+ return mutableSource;
29
+}
packages/shared/ReactTypes.js
+44
@@ -190,3 +190,47 @@ export type ReactScopeInstance = {|
190
fiber: Object,
191
methods: null | ReactScopeMethods,
192
|};
193
+
194
+// Mutable source version can be anything (e.g. number, string, immutable data structure)
195
+// so long as it changes every time any part of the source changes.
196
+export type MutableSourceVersion = $NonMaybeType<mixed>;
197
+
198
+export type MutableSourceGetSnapshotFn<
199
+ Source: $NonMaybeType<mixed>,
200
+ Snapshot,
201
+> = (source: Source) => Snapshot;
202
+
203
+export type MutableSourceSubscribeFn<Source: $NonMaybeType<mixed>, Snapshot> = (
204
+ source: Source,
205
+ callback: (snapshot: Snapshot) => void,
206
+) => () => void;
207
+
208
+export type MutableSourceGetVersionFn = (
209
+ source: $NonMaybeType<mixed>,
210
+) => MutableSourceVersion;
211
+
212
+export type MutableSource<Source: $NonMaybeType<mixed>> = {|
213
+ _source: Source,
214
+
215
+ _getVersion: MutableSourceGetVersionFn,
216
+
217
+ // Tracks the version of this source at the time it was most recently read.
218
+ // Used to determine if a source is safe to read from before it has been subscribed to.
219
+ // Version number is only used during mount,
220
+ // since the mechanism for determining safety after subscription is expiration time.
221
+ //
222
+ // As a workaround to support multiple concurrent renderers,
223
+ // we categorize some renderers as primary and others as secondary.
224
+ // We only expect there to be two concurrent renderers at most:
225
+ // React Native (primary) and Fabric (secondary);
226
+ // React DOM (primary) and React ART (secondary).
227
+ // Secondary renderers store their context values on separate fields.
228
+ // We use the same approach for Context.
229
+ _workInProgressVersionPrimary: null | MutableSourceVersion,
230
+ _workInProgressVersionSecondary: null | MutableSourceVersion,
231
+
232
+ // DEV only
233
+ // Used to detect multiple renderers using the same mutable source.
234
+ _currentPrimaryRenderer?: Object | null,
235
+ _currentSecondaryRenderer?: Object | null,
236
+|};
scripts/error-codes/codes.json
+3
-1
@@ -346,5 +346,7 @@
346
"345": "Root did not complete. This is a bug in React.",
347
"346": "An event responder context was used outside of an event cycle.",
348
"347": "Maps are not valid as a React child (found: %s). Consider converting children to an array of keyed ReactElements instead.",
349
- "348": "ensureListeningTo(): received a container that was not an element node. This is likely a bug in React."
349
+ "348": "ensureListeningTo(): received a container that was not an element node. This is likely a bug in React.",
350
+ "349": "Expected a work-in-progress root. This is a bug in React. Please file an issue.",
351
+ "350": "Cannot read from mutable source during the current render without tearing. This is a bug in React. Please file an issue."
352
}