Add back useMutableSource temporarily (#22396)
Recoil uses useMutableSource behind a flag. I thought this was fine because Recoil isn't used in any concurrent roots, so the behavior would be the same, but it turns out that it is used by concurrent roots in a few places. I'm not expecting it to be hard to migrate to useSyncExternalStore, but to de-risk the change I'm going to roll it out gradually with a flag. In the meantime, I've added back the useMutableSource API.
Andrew Clark committed
Sep 21, 2021 at 23:38 UTC
82c8fa90be86fc0afcbff2dc39486579cff1ac9a
37 files changed
+3732
-16
packages/react-debug-tools/src/ReactDebugHooks.js
+25
-1
@@ -7,7 +7,13 @@
7
* @flow
8
*/
9
10
-import type {ReactContext, ReactProviderType} from 'shared/ReactTypes';
10
+import type {
11
+ MutableSource,
12
+ MutableSourceGetSnapshotFn,
13
+ MutableSourceSubscribeFn,
14
+ ReactContext,
15
+ ReactProviderType,
16
+} from 'shared/ReactTypes';
17
import type {
18
Fiber,
19
Dispatcher as DispatcherType,
@@ -255,6 +261,23 @@ function useMemo<T>(
261
return value;
262
}
263
264
+function useMutableSource<Source, Snapshot>(
265
+ source: MutableSource<Source>,
266
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
267
+ subscribe: MutableSourceSubscribeFn<Source, Snapshot>,
268
+): Snapshot {
269
+ // useMutableSource() composes multiple hooks internally.
270
+ // Advance the current hook index the same number of times
271
+ // so that subsequent hooks have the right memoized state.
272
+ nextHook(); // MutableSource
273
+ nextHook(); // State
274
+ nextHook(); // Effect
275
+ nextHook(); // Effect
276
+ const value = getSnapshot(source._source);
277
+ hookLog.push({primitive: 'MutableSource', stackError: new Error(), value});
278
+ return value;
279
+}
280
+
281
function useSyncExternalStore<T>(
282
subscribe: (() => void) => () => void,
283
getSnapshot: () => T,
@@ -335,6 +358,7 @@ const Dispatcher: DispatcherType = {
358
useRef,
359
useState,
360
useTransition,
361
+ useMutableSource,
362
useSyncExternalStore,
363
useDeferredValue,
364
useOpaqueIdentifier,
packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js
+37
@@ -1019,6 +1019,43 @@ describe('ReactHooksInspectionIntegration', () => {
1019
]);
1020
});
1021
1022
+ it('should support composite useMutableSource hook', () => {
1023
+ const createMutableSource =
1024
+ React.createMutableSource || React.unstable_createMutableSource;
1025
+ const useMutableSource =
1026
+ React.useMutableSource || React.unstable_useMutableSource;
1027
+
1028
+ const mutableSource = createMutableSource({}, () => 1);
1029
+ function Foo(props) {
1030
+ useMutableSource(
1031
+ mutableSource,
1032
+ () => 'snapshot',
1033
+ () => {},
1034
+ );
1035
+ React.useMemo(() => 'memo', []);
1036
+ return <div />;
1037
+ }
1038
+ const renderer = ReactTestRenderer.create(<Foo />);
1039
+ const childFiber = renderer.root.findByType(Foo)._currentFiber();
1040
+ const tree = ReactDebugTools.inspectHooksOfFiber(childFiber);
1041
+ expect(tree).toEqual([
1042
+ {
1043
+ id: 0,
1044
+ isStateEditable: false,
1045
+ name: 'MutableSource',
1046
+ value: 'snapshot',
1047
+ subHooks: [],
1048
+ },
1049
+ {
1050
+ id: 1,
1051
+ isStateEditable: false,
1052
+ name: 'Memo',
1053
+ value: 'memo',
1054
+ subHooks: [],
1055
+ },
1056
+ ]);
1057
+ });
1058
+
1059
// @gate experimental || www
1060
it('should support composite useSyncExternalStore hook', () => {
1061
const useSyncExternalStore = React.unstable_useSyncExternalStore;
packages/react-dom/src/client/ReactDOMRoot.js
+26
-1
@@ -8,7 +8,7 @@
8
*/
9
10
import type {Container} from './ReactDOMHostConfig';
11
-import type {ReactNodeList} from 'shared/ReactTypes';
11
+import type {MutableSource, ReactNodeList} from 'shared/ReactTypes';
12
import type {FiberRoot} from 'react-reconciler/src/ReactInternalTypes';
13
14
export type RootType = {
@@ -24,6 +24,7 @@ export type CreateRootOptions = {
24
hydrationOptions?: {
25
onHydrated?: (suspenseNode: Comment) => void,
26
onDeleted?: (suspenseNode: Comment) => void,
27
+ mutableSources?: Array<MutableSource<any>>,
28
...
29
},
30
// END OF TODO
@@ -34,6 +35,7 @@ export type CreateRootOptions = {
35
36
export type HydrateRootOptions = {
37
// Hydration options
38
+ hydratedSources?: Array<MutableSource<any>>,
39
onHydrated?: (suspenseNode: Comment) => void,
40
onDeleted?: (suspenseNode: Comment) => void,
41
// Options for all roots
@@ -59,6 +61,7 @@ import {
61
createContainer,
62
updateContainer,
63
findHostInstanceWithNoPortals,
64
+ registerMutableSourceForHydration,
65
} from 'react-reconciler/src/ReactFiberReconciler';
66
import invariant from 'shared/invariant';
67
import {ConcurrentRoot} from 'react-reconciler/src/ReactRootTags';
@@ -126,6 +129,11 @@ export function createRoot(
129
const hydrate = options != null && options.hydrate === true;
130
const hydrationCallbacks =
131
(options != null && options.hydrationOptions) || null;
132
+ const mutableSources =
133
+ (options != null &&
134
+ options.hydrationOptions != null &&
135
+ options.hydrationOptions.mutableSources) ||
136
+ null;
137
// END TODO
138
139
const isStrictMode = options != null && options.unstable_strictMode === true;
@@ -151,6 +159,15 @@ export function createRoot(
159
container.nodeType === COMMENT_NODE ? container.parentNode : container;
160
listenToAllSupportedEvents(rootContainerElement);
161
162
+ // TODO: Delete this path
163
+ if (mutableSources) {
164
+ for (let i = 0; i < mutableSources.length; i++) {
165
+ const mutableSource = mutableSources[i];
166
+ registerMutableSourceForHydration(root, mutableSource);
167
+ }
168
+ }
169
+ // END TODO
170
+
171
return new ReactDOMRoot(root);
172
}
173
@@ -168,6 +185,7 @@ export function hydrateRoot(
185
// For now we reuse the whole bag of options since they contain
186
// the hydration callbacks.
187
const hydrationCallbacks = options != null ? options : null;
188
+ const mutableSources = (options != null && options.hydratedSources) || null;
189
const isStrictMode = options != null && options.unstable_strictMode === true;
190
191
let concurrentUpdatesByDefaultOverride = null;
@@ -190,6 +208,13 @@ export function hydrateRoot(
208
// This can't be a comment node since hydration doesn't work on comment nodes anyway.
209
listenToAllSupportedEvents(container);
210
211
+ if (mutableSources) {
212
+ for (let i = 0; i < mutableSources.length; i++) {
213
+ const mutableSource = mutableSources[i];
214
+ registerMutableSourceForHydration(root, mutableSource);
215
+ }
216
+ }
217
+
218
// Render the initial children
219
updateContainer(initialChildren, root, null, null);
220
packages/react-dom/src/server/ReactPartialRendererHooks.js
+20
-1
@@ -9,7 +9,12 @@
9
10
import type {Dispatcher as DispatcherType} from 'react-reconciler/src/ReactInternalTypes';
11
12
-import type {ReactContext} from 'shared/ReactTypes';
12
+import type {
13
+ MutableSource,
14
+ MutableSourceGetSnapshotFn,
15
+ MutableSourceSubscribeFn,
16
+ ReactContext,
17
+} from 'shared/ReactTypes';
18
import type PartialRenderer from './ReactPartialRenderer';
19
20
import {validateContextBounds} from './ReactPartialRendererContext';
@@ -461,6 +466,18 @@ export function useCallback<T>(
466
return useMemo(() => callback, deps);
467
}
468
469
+// TODO Decide on how to implement this hook for server rendering.
470
+// If a mutation occurs during render, consider triggering a Suspense boundary
471
+// and falling back to client rendering.
472
+function useMutableSource<Source, Snapshot>(
473
+ source: MutableSource<Source>,
474
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
475
+ subscribe: MutableSourceSubscribeFn<Source, Snapshot>,
476
+): Snapshot {
477
+ resolveCurrentlyRenderingComponent();
478
+ return getSnapshot(source._source);
479
+}
480
+
481
function useSyncExternalStore<T>(
482
subscribe: (() => void) => () => void,
483
getSnapshot: () => T,
@@ -527,6 +544,8 @@ export const Dispatcher: DispatcherType = {
544
useDeferredValue,
545
useTransition,
546
useOpaqueIdentifier,
547
+ // Subscriptions are not setup in a server environment.
548
+ useMutableSource,
549
useSyncExternalStore,
550
};
551
packages/react-reconciler/src/ReactFiberBeginWork.new.js
+18
@@ -12,6 +12,7 @@ import type {LazyComponent as LazyComponentType} from 'react/src/ReactLazy';
12
import type {Fiber, FiberRoot} from './ReactInternalTypes';
13
import type {TypeOfMode} from './ReactTypeOfMode';
14
import type {Lanes, Lane} from './ReactFiberLane.new';
15
+import type {MutableSource} from 'shared/ReactTypes';
16
import type {
17
SuspenseState,
18
SuspenseListRenderState,
@@ -144,6 +145,7 @@ import {
145
isSuspenseInstancePending,
146
isSuspenseInstanceFallback,
147
registerSuspenseInstanceRetry,
148
+ supportsHydration,
149
isPrimaryRenderer,
150
supportsPersistence,
151
getOffscreenContainerProps,
@@ -218,6 +220,7 @@ import {
220
RetryAfterError,
221
NoContext,
222
} from './ReactFiberWorkLoop.new';
223
+import {setWorkInProgressVersion} from './ReactMutableSource.new';
224
import {
225
requestCacheFromPool,
226
pushCacheProvider,
@@ -1297,6 +1300,21 @@ function updateHostRoot(current, workInProgress, renderLanes) {
1300
// We always try to hydrate. If this isn't a hydration pass there won't
1301
// be any children to hydrate which is effectively the same thing as
1302
// not hydrating.
1303
+
1304
+ if (supportsHydration) {
1305
+ const mutableSourceEagerHydrationData =
1306
+ root.mutableSourceEagerHydrationData;
1307
+ if (mutableSourceEagerHydrationData != null) {
1308
+ for (let i = 0; i < mutableSourceEagerHydrationData.length; i += 2) {
1309
+ const mutableSource = ((mutableSourceEagerHydrationData[
1310
+ i
1311
+ ]: any): MutableSource<any>);
1312
+ const version = mutableSourceEagerHydrationData[i + 1];
1313
+ setWorkInProgressVersion(mutableSource, version);
1314
+ }
1315
+ }
1316
+ }
1317
+
1318
const child = mountChildFibers(
1319
workInProgress,
1320
null,
packages/react-reconciler/src/ReactFiberBeginWork.old.js
+18
@@ -12,6 +12,7 @@ import type {LazyComponent as LazyComponentType} from 'react/src/ReactLazy';
12
import type {Fiber, FiberRoot} from './ReactInternalTypes';
13
import type {TypeOfMode} from './ReactTypeOfMode';
14
import type {Lanes, Lane} from './ReactFiberLane.old';
15
+import type {MutableSource} from 'shared/ReactTypes';
16
import type {
17
SuspenseState,
18
SuspenseListRenderState,
@@ -144,6 +145,7 @@ import {
145
isSuspenseInstancePending,
146
isSuspenseInstanceFallback,
147
registerSuspenseInstanceRetry,
148
+ supportsHydration,
149
isPrimaryRenderer,
150
supportsPersistence,
151
getOffscreenContainerProps,
@@ -218,6 +220,7 @@ import {
220
RetryAfterError,
221
NoContext,
222
} from './ReactFiberWorkLoop.old';
223
+import {setWorkInProgressVersion} from './ReactMutableSource.old';
224
import {
225
requestCacheFromPool,
226
pushCacheProvider,
@@ -1297,6 +1300,21 @@ function updateHostRoot(current, workInProgress, renderLanes) {
1300
// We always try to hydrate. If this isn't a hydration pass there won't
1301
// be any children to hydrate which is effectively the same thing as
1302
// not hydrating.
1303
+
1304
+ if (supportsHydration) {
1305
+ const mutableSourceEagerHydrationData =
1306
+ root.mutableSourceEagerHydrationData;
1307
+ if (mutableSourceEagerHydrationData != null) {
1308
+ for (let i = 0; i < mutableSourceEagerHydrationData.length; i += 2) {
1309
+ const mutableSource = ((mutableSourceEagerHydrationData[
1310
+ i
1311
+ ]: any): MutableSource<any>);
1312
+ const version = mutableSourceEagerHydrationData[i + 1];
1313
+ setWorkInProgressVersion(mutableSource, version);
1314
+ }
1315
+ }
1316
+ }
1317
+
1318
const child = mountChildFibers(
1319
workInProgress,
1320
null,
packages/react-reconciler/src/ReactFiberCompleteWork.new.js
+3
@@ -30,6 +30,8 @@ import type {SuspenseContext} from './ReactFiberSuspenseContext.new';
30
import type {OffscreenState} from './ReactFiberOffscreenComponent';
31
import type {Cache, SpawnedCachePool} from './ReactFiberCacheComponent.new';
32
33
+import {resetWorkInProgressVersions as resetMutableSourceWorkInProgressVersions} from './ReactMutableSource.new';
34
+
35
import {now} from './Scheduler';
36
37
import {
@@ -852,6 +854,7 @@ function completeWork(
854
}
855
popHostContainer(workInProgress);
856
popTopLevelLegacyContextObject(workInProgress);
857
+ resetMutableSourceWorkInProgressVersions();
858
if (fiberRoot.pendingContext) {
859
fiberRoot.context = fiberRoot.pendingContext;
860
fiberRoot.pendingContext = null;
packages/react-reconciler/src/ReactFiberCompleteWork.old.js
+3
@@ -30,6 +30,8 @@ import type {SuspenseContext} from './ReactFiberSuspenseContext.old';
30
import type {OffscreenState} from './ReactFiberOffscreenComponent';
31
import type {Cache, SpawnedCachePool} from './ReactFiberCacheComponent.old';
32
33
+import {resetWorkInProgressVersions as resetMutableSourceWorkInProgressVersions} from './ReactMutableSource.old';
34
+
35
import {now} from './Scheduler';
36
37
import {
@@ -852,6 +854,7 @@ function completeWork(
854
}
855
popHostContainer(workInProgress);
856
popTopLevelLegacyContextObject(workInProgress);
857
+ resetMutableSourceWorkInProgressVersions();
858
if (fiberRoot.pendingContext) {
859
fiberRoot.context = fiberRoot.pendingContext;
860
fiberRoot.pendingContext = null;
packages/react-reconciler/src/ReactFiberHooks.new.js
+366
-1
@@ -7,7 +7,12 @@
7
* @flow
8
*/
9
10
-import type {ReactContext} from 'shared/ReactTypes';
10
+import type {
11
+ MutableSource,
12
+ MutableSourceGetSnapshotFn,
13
+ MutableSourceSubscribeFn,
14
+ ReactContext,
15
+} from 'shared/ReactTypes';
16
import type {Fiber, Dispatcher, HookType} from './ReactInternalTypes';
17
import type {Lanes, Lane} from './ReactFiberLane.new';
18
import type {HookFlags} from './ReactHookEffectTags';
@@ -45,6 +50,7 @@ import {
50
intersectLanes,
51
isTransitionLane,
52
markRootEntangled,
53
+ markRootMutableRead,
54
NoTimestamp,
55
} from './ReactFiberLane.new';
56
import {
@@ -96,6 +102,12 @@ import {
102
makeClientIdInDEV,
103
makeOpaqueHydratingObject,
104
} from './ReactFiberHostConfig';
105
+import {
106
+ getWorkInProgressVersion,
107
+ markSourceAsDirty,
108
+ setWorkInProgressVersion,
109
+ warnAboutMultipleRenderersDEV,
110
+} from './ReactMutableSource.new';
111
import {getIsRendering} from './ReactCurrentFiber';
112
import {logStateUpdateScheduled} from './DebugTracing';
113
import {markStateUpdateScheduled} from './SchedulingProfiler';
@@ -935,6 +947,289 @@ function rerenderReducer<S, I, A>(
947
return [newState, dispatch];
948
}
949
950
+type MutableSourceMemoizedState<Source, Snapshot> = {|
951
+ refs: {
952
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
953
+ setSnapshot: Snapshot => void,
954
+ },
955
+ source: MutableSource<any>,
956
+ subscribe: MutableSourceSubscribeFn<Source, Snapshot>,
957
+|};
958
+
959
+function readFromUnsubscribedMutableSource<Source, Snapshot>(
960
+ root: FiberRoot,
961
+ source: MutableSource<Source>,
962
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
963
+): Snapshot {
964
+ if (__DEV__) {
965
+ warnAboutMultipleRenderersDEV(source);
966
+ }
967
+
968
+ const getVersion = source._getVersion;
969
+ const version = getVersion(source._source);
970
+
971
+ // Is it safe for this component to read from this source during the current render?
972
+ let isSafeToReadFromSource = false;
973
+
974
+ // Check the version first.
975
+ // If this render has already been started with a specific version,
976
+ // we can use it alone to determine if we can safely read from the source.
977
+ const currentRenderVersion = getWorkInProgressVersion(source);
978
+ if (currentRenderVersion !== null) {
979
+ // It's safe to read if the store hasn't been mutated since the last time
980
+ // we read something.
981
+ isSafeToReadFromSource = currentRenderVersion === version;
982
+ } else {
983
+ // If there's no version, then this is the first time we've read from the
984
+ // source during the current render pass, so we need to do a bit more work.
985
+ // What we need to determine is if there are any hooks that already
986
+ // subscribed to the source, and if so, whether there are any pending
987
+ // mutations that haven't been synchronized yet.
988
+ //
989
+ // If there are no pending mutations, then `root.mutableReadLanes` will be
990
+ // empty, and we know we can safely read.
991
+ //
992
+ // If there *are* pending mutations, we may still be able to safely read
993
+ // if the currently rendering lanes are inclusive of the pending mutation
994
+ // lanes, since that guarantees that the value we're about to read from
995
+ // the source is consistent with the values that we read during the most
996
+ // recent mutation.
997
+ isSafeToReadFromSource = isSubsetOfLanes(
998
+ renderLanes,
999
+ root.mutableReadLanes,
1000
+ );
1001
+
1002
+ if (isSafeToReadFromSource) {
1003
+ // If it's safe to read from this source during the current render,
1004
+ // store the version in case other components read from it.
1005
+ // A changed version number will let those components know to throw and restart the render.
1006
+ setWorkInProgressVersion(source, version);
1007
+ }
1008
+ }
1009
+
1010
+ if (isSafeToReadFromSource) {
1011
+ const snapshot = getSnapshot(source._source);
1012
+ if (__DEV__) {
1013
+ if (typeof snapshot === 'function') {
1014
+ console.error(
1015
+ 'Mutable source should not return a function as the snapshot value. ' +
1016
+ 'Functions may close over mutable values and cause tearing.',
1017
+ );
1018
+ }
1019
+ }
1020
+ return snapshot;
1021
+ } else {
1022
+ // This handles the special case of a mutable source being shared between renderers.
1023
+ // In that case, if the source is mutated between the first and second renderer,
1024
+ // The second renderer don't know that it needs to reset the WIP version during unwind,
1025
+ // (because the hook only marks sources as dirty if it's written to their WIP version).
1026
+ // That would cause this tear check to throw again and eventually be visible to the user.
1027
+ // We can avoid this infinite loop by explicitly marking the source as dirty.
1028
+ //
1029
+ // This can lead to tearing in the first renderer when it resumes,
1030
+ // but there's nothing we can do about that (short of throwing here and refusing to continue the render).
1031
+ markSourceAsDirty(source);
1032
+
1033
+ // Intentioally throw an error to force React to retry synchronously. During
1034
+ // the synchronous retry, it will block interleaved mutations, so we should
1035
+ // get a consistent read. Therefore, the following error should never be
1036
+ // visible to the user.
1037
+
1038
+ // We expect this error not to be thrown during the synchronous retry,
1039
+ // because we blocked interleaved mutations.
1040
+ invariant(
1041
+ false,
1042
+ 'Cannot read from mutable source during the current render without tearing. This may be a bug in React. Please file an issue.',
1043
+ );
1044
+ }
1045
+}
1046
+
1047
+function useMutableSource<Source, Snapshot>(
1048
+ hook: Hook,
1049
+ source: MutableSource<Source>,
1050
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
1051
+ subscribe: MutableSourceSubscribeFn<Source, Snapshot>,
1052
+): Snapshot {
1053
+ const root = ((getWorkInProgressRoot(): any): FiberRoot);
1054
+ invariant(
1055
+ root !== null,
1056
+ 'Expected a work-in-progress root. This is a bug in React. Please file an issue.',
1057
+ );
1058
+
1059
+ const getVersion = source._getVersion;
1060
+ const version = getVersion(source._source);
1061
+
1062
+ const dispatcher = ReactCurrentDispatcher.current;
1063
+
1064
+ // eslint-disable-next-line prefer-const
1065
+ let [currentSnapshot, setSnapshot] = dispatcher.useState(() =>
1066
+ readFromUnsubscribedMutableSource(root, source, getSnapshot),
1067
+ );
1068
+ let snapshot = currentSnapshot;
1069
+
1070
+ // Grab a handle to the state hook as well.
1071
+ // We use it to clear the pending update queue if we have a new source.
1072
+ const stateHook = ((workInProgressHook: any): Hook);
1073
+
1074
+ const memoizedState = ((hook.memoizedState: any): MutableSourceMemoizedState<
1075
+ Source,
1076
+ Snapshot,
1077
+ >);
1078
+ const refs = memoizedState.refs;
1079
+ const prevGetSnapshot = refs.getSnapshot;
1080
+ const prevSource = memoizedState.source;
1081
+ const prevSubscribe = memoizedState.subscribe;
1082
+
1083
+ const fiber = currentlyRenderingFiber;
1084
+
1085
+ hook.memoizedState = ({
1086
+ refs,
1087
+ source,
1088
+ subscribe,
1089
+ }: MutableSourceMemoizedState<Source, Snapshot>);
1090
+
1091
+ // Sync the values needed by our subscription handler after each commit.
1092
+ dispatcher.useEffect(() => {
1093
+ refs.getSnapshot = getSnapshot;
1094
+
1095
+ // Normally the dispatch function for a state hook never changes,
1096
+ // but this hook recreates the queue in certain cases to avoid updates from stale sources.
1097
+ // handleChange() below needs to reference the dispatch function without re-subscribing,
1098
+ // so we use a ref to ensure that it always has the latest version.
1099
+ refs.setSnapshot = setSnapshot;
1100
+
1101
+ // Check for a possible change between when we last rendered now.
1102
+ const maybeNewVersion = getVersion(source._source);
1103
+ if (!is(version, maybeNewVersion)) {
1104
+ const maybeNewSnapshot = getSnapshot(source._source);
1105
+ if (__DEV__) {
1106
+ if (typeof maybeNewSnapshot === 'function') {
1107
+ console.error(
1108
+ 'Mutable source should not return a function as the snapshot value. ' +
1109
+ 'Functions may close over mutable values and cause tearing.',
1110
+ );
1111
+ }
1112
+ }
1113
+
1114
+ if (!is(snapshot, maybeNewSnapshot)) {
1115
+ setSnapshot(maybeNewSnapshot);
1116
+
1117
+ const lane = requestUpdateLane(fiber);
1118
+ markRootMutableRead(root, lane);
1119
+ }
1120
+ // If the source mutated between render and now,
1121
+ // there may be state updates already scheduled from the old source.
1122
+ // Entangle the updates so that they render in the same batch.
1123
+ markRootEntangled(root, root.mutableReadLanes);
1124
+ }
1125
+ }, [getSnapshot, source, subscribe]);
1126
+
1127
+ // If we got a new source or subscribe function, re-subscribe in a passive effect.
1128
+ dispatcher.useEffect(() => {
1129
+ const handleChange = () => {
1130
+ const latestGetSnapshot = refs.getSnapshot;
1131
+ const latestSetSnapshot = refs.setSnapshot;
1132
+
1133
+ try {
1134
+ latestSetSnapshot(latestGetSnapshot(source._source));
1135
+
1136
+ // Record a pending mutable source update with the same expiration time.
1137
+ const lane = requestUpdateLane(fiber);
1138
+
1139
+ markRootMutableRead(root, lane);
1140
+ } catch (error) {
1141
+ // A selector might throw after a source mutation.
1142
+ // e.g. it might try to read from a part of the store that no longer exists.
1143
+ // In this case we should still schedule an update with React.
1144
+ // Worst case the selector will throw again and then an error boundary will handle it.
1145
+ latestSetSnapshot(
1146
+ (() => {
1147
+ throw error;
1148
+ }: any),
1149
+ );
1150
+ }
1151
+ };
1152
+
1153
+ const unsubscribe = subscribe(source._source, handleChange);
1154
+ if (__DEV__) {
1155
+ if (typeof unsubscribe !== 'function') {
1156
+ console.error(
1157
+ 'Mutable source subscribe function must return an unsubscribe function.',
1158
+ );
1159
+ }
1160
+ }
1161
+
1162
+ return unsubscribe;
1163
+ }, [source, subscribe]);
1164
+
1165
+ // If any of the inputs to useMutableSource change, reading is potentially unsafe.
1166
+ //
1167
+ // If either the source or the subscription have changed we can't can't trust the update queue.
1168
+ // Maybe the source changed in a way that the old subscription ignored but the new one depends on.
1169
+ //
1170
+ // If the getSnapshot function changed, we also shouldn't rely on the update queue.
1171
+ // It's possible that the underlying source was mutated between the when the last "change" event fired,
1172
+ // and when the current render (with the new getSnapshot function) is processed.
1173
+ //
1174
+ // In both cases, we need to throw away pending updates (since they are no longer relevant)
1175
+ // and treat reading from the source as we do in the mount case.
1176
+ if (
1177
+ !is(prevGetSnapshot, getSnapshot) ||
1178
+ !is(prevSource, source) ||
1179
+ !is(prevSubscribe, subscribe)
1180
+ ) {
1181
+ // Create a new queue and setState method,
1182
+ // So if there are interleaved updates, they get pushed to the older queue.
1183
+ // When this becomes current, the previous queue and dispatch method will be discarded,
1184
+ // including any interleaving updates that occur.
1185
+ const newQueue: UpdateQueue<Snapshot, BasicStateAction<Snapshot>> = {
1186
+ pending: null,
1187
+ interleaved: null,
1188
+ lanes: NoLanes,
1189
+ dispatch: null,
1190
+ lastRenderedReducer: basicStateReducer,
1191
+ lastRenderedState: snapshot,
1192
+ };
1193
+ newQueue.dispatch = setSnapshot = (dispatchAction.bind(
1194
+ null,
1195
+ currentlyRenderingFiber,
1196
+ newQueue,
1197
+ ): any);
1198
+ stateHook.queue = newQueue;
1199
+ stateHook.baseQueue = null;
1200
+ snapshot = readFromUnsubscribedMutableSource(root, source, getSnapshot);
1201
+ stateHook.memoizedState = stateHook.baseState = snapshot;
1202
+ }
1203
+
1204
+ return snapshot;
1205
+}
1206
+
1207
+function mountMutableSource<Source, Snapshot>(
1208
+ source: MutableSource<Source>,
1209
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
1210
+ subscribe: MutableSourceSubscribeFn<Source, Snapshot>,
1211
+): Snapshot {
1212
+ const hook = mountWorkInProgressHook();
1213
+ hook.memoizedState = ({
1214
+ refs: {
1215
+ getSnapshot,
1216
+ setSnapshot: (null: any),
1217
+ },
1218
+ source,
1219
+ subscribe,
1220
+ }: MutableSourceMemoizedState<Source, Snapshot>);
1221
+ return useMutableSource(hook, source, getSnapshot, subscribe);
1222
+}
1223
+
1224
+function updateMutableSource<Source, Snapshot>(
1225
+ source: MutableSource<Source>,
1226
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
1227
+ subscribe: MutableSourceSubscribeFn<Source, Snapshot>,
1228
+): Snapshot {
1229
+ const hook = updateWorkInProgressHook();
1230
+ return useMutableSource(hook, source, getSnapshot, subscribe);
1231
+}
1232
+
1233
function mountSyncExternalStore<T>(
1234
subscribe: (() => void) => () => void,
1235
getSnapshot: () => T,
@@ -2035,6 +2330,7 @@ export const ContextOnlyDispatcher: Dispatcher = {
2330
useDebugValue: throwInvalidHookError,
2331
useDeferredValue: throwInvalidHookError,
2332
useTransition: throwInvalidHookError,
2333
+ useMutableSource: throwInvalidHookError,
2334
useSyncExternalStore: throwInvalidHookError,
2335
useOpaqueIdentifier: throwInvalidHookError,
2336
@@ -2061,6 +2357,7 @@ const HooksDispatcherOnMount: Dispatcher = {
2357
useDebugValue: mountDebugValue,
2358
useDeferredValue: mountDeferredValue,
2359
useTransition: mountTransition,
2360
+ useMutableSource: mountMutableSource,
2361
useSyncExternalStore: mountSyncExternalStore,
2362
useOpaqueIdentifier: mountOpaqueIdentifier,
2363
@@ -2087,6 +2384,7 @@ const HooksDispatcherOnUpdate: Dispatcher = {
2384
useDebugValue: updateDebugValue,
2385
useDeferredValue: updateDeferredValue,
2386
useTransition: updateTransition,
2387
+ useMutableSource: updateMutableSource,
2388
useSyncExternalStore: updateSyncExternalStore,
2389
useOpaqueIdentifier: updateOpaqueIdentifier,
2390
@@ -2113,6 +2411,7 @@ const HooksDispatcherOnRerender: Dispatcher = {
2411
useDebugValue: updateDebugValue,
2412
useDeferredValue: rerenderDeferredValue,
2413
useTransition: rerenderTransition,
2414
+ useMutableSource: updateMutableSource,
2415
useSyncExternalStore: mountSyncExternalStore,
2416
useOpaqueIdentifier: rerenderOpaqueIdentifier,
2417
@@ -2262,6 +2561,15 @@ if (__DEV__) {
2561
mountHookTypesDev();
2562
return mountTransition();
2563
},
2564
+ useMutableSource<Source, Snapshot>(
2565
+ source: MutableSource<Source>,
2566
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
2567
+ subscribe: MutableSourceSubscribeFn<Source, Snapshot>,
2568
+ ): Snapshot {
2569
+ currentHookNameInDev = 'useMutableSource';
2570
+ mountHookTypesDev();
2571
+ return mountMutableSource(source, getSnapshot, subscribe);
2572
+ },
2573
useSyncExternalStore<T>(
2574
subscribe: (() => void) => () => void,
2575
getSnapshot: () => T,
@@ -2394,6 +2702,15 @@ if (__DEV__) {
2702
updateHookTypesDev();
2703
return mountTransition();
2704
},
2705
+ useMutableSource<Source, Snapshot>(
2706
+ source: MutableSource<Source>,
2707
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
2708
+ subscribe: MutableSourceSubscribeFn<Source, Snapshot>,
2709
+ ): Snapshot {
2710
+ currentHookNameInDev = 'useMutableSource';
2711
+ updateHookTypesDev();
2712
+ return mountMutableSource(source, getSnapshot, subscribe);
2713
+ },
2714
useSyncExternalStore<T>(
2715
subscribe: (() => void) => () => void,
2716
getSnapshot: () => T,
@@ -2526,6 +2843,15 @@ if (__DEV__) {
2843
updateHookTypesDev();
2844
return updateTransition();
2845
},
2846
+ useMutableSource<Source, Snapshot>(
2847
+ source: MutableSource<Source>,
2848
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
2849
+ subscribe: MutableSourceSubscribeFn<Source, Snapshot>,
2850
+ ): Snapshot {
2851
+ currentHookNameInDev = 'useMutableSource';
2852
+ updateHookTypesDev();
2853
+ return updateMutableSource(source, getSnapshot, subscribe);
2854
+ },
2855
useSyncExternalStore<T>(
2856
subscribe: (() => void) => () => void,
2857
getSnapshot: () => T,
@@ -2659,6 +2985,15 @@ if (__DEV__) {
2985
updateHookTypesDev();
2986
return rerenderTransition();
2987
},
2988
+ useMutableSource<Source, Snapshot>(
2989
+ source: MutableSource<Source>,
2990
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
2991
+ subscribe: MutableSourceSubscribeFn<Source, Snapshot>,
2992
+ ): Snapshot {
2993
+ currentHookNameInDev = 'useMutableSource';
2994
+ updateHookTypesDev();
2995
+ return updateMutableSource(source, getSnapshot, subscribe);
2996
+ },
2997
useSyncExternalStore<T>(
2998
subscribe: (() => void) => () => void,
2999
getSnapshot: () => T,
@@ -2805,6 +3140,16 @@ if (__DEV__) {
3140
mountHookTypesDev();
3141
return mountTransition();
3142
},
3143
+ useMutableSource<Source, Snapshot>(
3144
+ source: MutableSource<Source>,
3145
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
3146
+ subscribe: MutableSourceSubscribeFn<Source, Snapshot>,
3147
+ ): Snapshot {
3148
+ currentHookNameInDev = 'useMutableSource';
3149
+ warnInvalidHookAccess();
3150
+ mountHookTypesDev();
3151
+ return mountMutableSource(source, getSnapshot, subscribe);
3152
+ },
3153
useSyncExternalStore<T>(
3154
subscribe: (() => void) => () => void,
3155
getSnapshot: () => T,
@@ -2953,6 +3298,16 @@ if (__DEV__) {
3298
updateHookTypesDev();
3299
return updateTransition();
3300
},
3301
+ useMutableSource<Source, Snapshot>(
3302
+ source: MutableSource<Source>,
3303
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
3304
+ subscribe: MutableSourceSubscribeFn<Source, Snapshot>,
3305
+ ): Snapshot {
3306
+ currentHookNameInDev = 'useMutableSource';
3307
+ warnInvalidHookAccess();
3308
+ updateHookTypesDev();
3309
+ return updateMutableSource(source, getSnapshot, subscribe);
3310
+ },
3311
useSyncExternalStore<T>(
3312
subscribe: (() => void) => () => void,
3313
getSnapshot: () => T,
@@ -3102,6 +3457,16 @@ if (__DEV__) {
3457
updateHookTypesDev();
3458
return rerenderTransition();
3459
},
3460
+ useMutableSource<Source, Snapshot>(
3461
+ source: MutableSource<Source>,
3462
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
3463
+ subscribe: MutableSourceSubscribeFn<Source, Snapshot>,
3464
+ ): Snapshot {
3465
+ currentHookNameInDev = 'useMutableSource';
3466
+ warnInvalidHookAccess();
3467
+ updateHookTypesDev();
3468
+ return updateMutableSource(source, getSnapshot, subscribe);
3469
+ },
3470
useSyncExternalStore<T>(
3471
subscribe: (() => void) => () => void,
3472
getSnapshot: () => T,
packages/react-reconciler/src/ReactFiberHooks.old.js
+366
-1
@@ -7,7 +7,12 @@
7
* @flow
8
*/
9
10
-import type {ReactContext} from 'shared/ReactTypes';
10
+import type {
11
+ MutableSource,
12
+ MutableSourceGetSnapshotFn,
13
+ MutableSourceSubscribeFn,
14
+ ReactContext,
15
+} from 'shared/ReactTypes';
16
import type {Fiber, Dispatcher, HookType} from './ReactInternalTypes';
17
import type {Lanes, Lane} from './ReactFiberLane.old';
18
import type {HookFlags} from './ReactHookEffectTags';
@@ -45,6 +50,7 @@ import {
50
intersectLanes,
51
isTransitionLane,
52
markRootEntangled,
53
+ markRootMutableRead,
54
NoTimestamp,
55
} from './ReactFiberLane.old';
56
import {
@@ -96,6 +102,12 @@ import {
102
makeClientIdInDEV,
103
makeOpaqueHydratingObject,
104
} from './ReactFiberHostConfig';
105
+import {
106
+ getWorkInProgressVersion,
107
+ markSourceAsDirty,
108
+ setWorkInProgressVersion,
109
+ warnAboutMultipleRenderersDEV,
110
+} from './ReactMutableSource.old';
111
import {getIsRendering} from './ReactCurrentFiber';
112
import {logStateUpdateScheduled} from './DebugTracing';
113
import {markStateUpdateScheduled} from './SchedulingProfiler';
@@ -935,6 +947,289 @@ function rerenderReducer<S, I, A>(
947
return [newState, dispatch];
948
}
949
950
+type MutableSourceMemoizedState<Source, Snapshot> = {|
951
+ refs: {
952
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
953
+ setSnapshot: Snapshot => void,
954
+ },
955
+ source: MutableSource<any>,
956
+ subscribe: MutableSourceSubscribeFn<Source, Snapshot>,
957
+|};
958
+
959
+function readFromUnsubscribedMutableSource<Source, Snapshot>(
960
+ root: FiberRoot,
961
+ source: MutableSource<Source>,
962
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
963
+): Snapshot {
964
+ if (__DEV__) {
965
+ warnAboutMultipleRenderersDEV(source);
966
+ }
967
+
968
+ const getVersion = source._getVersion;
969
+ const version = getVersion(source._source);
970
+
971
+ // Is it safe for this component to read from this source during the current render?
972
+ let isSafeToReadFromSource = false;
973
+
974
+ // Check the version first.
975
+ // If this render has already been started with a specific version,
976
+ // we can use it alone to determine if we can safely read from the source.
977
+ const currentRenderVersion = getWorkInProgressVersion(source);
978
+ if (currentRenderVersion !== null) {
979
+ // It's safe to read if the store hasn't been mutated since the last time
980
+ // we read something.
981
+ isSafeToReadFromSource = currentRenderVersion === version;
982
+ } else {
983
+ // If there's no version, then this is the first time we've read from the
984
+ // source during the current render pass, so we need to do a bit more work.
985
+ // What we need to determine is if there are any hooks that already
986
+ // subscribed to the source, and if so, whether there are any pending
987
+ // mutations that haven't been synchronized yet.
988
+ //
989
+ // If there are no pending mutations, then `root.mutableReadLanes` will be
990
+ // empty, and we know we can safely read.
991
+ //
992
+ // If there *are* pending mutations, we may still be able to safely read
993
+ // if the currently rendering lanes are inclusive of the pending mutation
994
+ // lanes, since that guarantees that the value we're about to read from
995
+ // the source is consistent with the values that we read during the most
996
+ // recent mutation.
997
+ isSafeToReadFromSource = isSubsetOfLanes(
998
+ renderLanes,
999
+ root.mutableReadLanes,
1000
+ );
1001
+
1002
+ if (isSafeToReadFromSource) {
1003
+ // If it's safe to read from this source during the current render,
1004
+ // store the version in case other components read from it.
1005
+ // A changed version number will let those components know to throw and restart the render.
1006
+ setWorkInProgressVersion(source, version);
1007
+ }
1008
+ }
1009
+
1010
+ if (isSafeToReadFromSource) {
1011
+ const snapshot = getSnapshot(source._source);
1012
+ if (__DEV__) {
1013
+ if (typeof snapshot === 'function') {
1014
+ console.error(
1015
+ 'Mutable source should not return a function as the snapshot value. ' +
1016
+ 'Functions may close over mutable values and cause tearing.',
1017
+ );
1018
+ }
1019
+ }
1020
+ return snapshot;
1021
+ } else {
1022
+ // This handles the special case of a mutable source being shared between renderers.
1023
+ // In that case, if the source is mutated between the first and second renderer,
1024
+ // The second renderer don't know that it needs to reset the WIP version during unwind,
1025
+ // (because the hook only marks sources as dirty if it's written to their WIP version).
1026
+ // That would cause this tear check to throw again and eventually be visible to the user.
1027
+ // We can avoid this infinite loop by explicitly marking the source as dirty.
1028
+ //
1029
+ // This can lead to tearing in the first renderer when it resumes,
1030
+ // but there's nothing we can do about that (short of throwing here and refusing to continue the render).
1031
+ markSourceAsDirty(source);
1032
+
1033
+ // Intentioally throw an error to force React to retry synchronously. During
1034
+ // the synchronous retry, it will block interleaved mutations, so we should
1035
+ // get a consistent read. Therefore, the following error should never be
1036
+ // visible to the user.
1037
+
1038
+ // We expect this error not to be thrown during the synchronous retry,
1039
+ // because we blocked interleaved mutations.
1040
+ invariant(
1041
+ false,
1042
+ 'Cannot read from mutable source during the current render without tearing. This may be a bug in React. Please file an issue.',
1043
+ );
1044
+ }
1045
+}
1046
+
1047
+function useMutableSource<Source, Snapshot>(
1048
+ hook: Hook,
1049
+ source: MutableSource<Source>,
1050
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
1051
+ subscribe: MutableSourceSubscribeFn<Source, Snapshot>,
1052
+): Snapshot {
1053
+ const root = ((getWorkInProgressRoot(): any): FiberRoot);
1054
+ invariant(
1055
+ root !== null,
1056
+ 'Expected a work-in-progress root. This is a bug in React. Please file an issue.',
1057
+ );
1058
+
1059
+ const getVersion = source._getVersion;
1060
+ const version = getVersion(source._source);
1061
+
1062
+ const dispatcher = ReactCurrentDispatcher.current;
1063
+
1064
+ // eslint-disable-next-line prefer-const
1065
+ let [currentSnapshot, setSnapshot] = dispatcher.useState(() =>
1066
+ readFromUnsubscribedMutableSource(root, source, getSnapshot),
1067
+ );
1068
+ let snapshot = currentSnapshot;
1069
+
1070
+ // Grab a handle to the state hook as well.
1071
+ // We use it to clear the pending update queue if we have a new source.
1072
+ const stateHook = ((workInProgressHook: any): Hook);
1073
+
1074
+ const memoizedState = ((hook.memoizedState: any): MutableSourceMemoizedState<
1075
+ Source,
1076
+ Snapshot,
1077
+ >);
1078
+ const refs = memoizedState.refs;
1079
+ const prevGetSnapshot = refs.getSnapshot;
1080
+ const prevSource = memoizedState.source;
1081
+ const prevSubscribe = memoizedState.subscribe;
1082
+
1083
+ const fiber = currentlyRenderingFiber;
1084
+
1085
+ hook.memoizedState = ({
1086
+ refs,
1087
+ source,
1088
+ subscribe,
1089
+ }: MutableSourceMemoizedState<Source, Snapshot>);
1090
+
1091
+ // Sync the values needed by our subscription handler after each commit.
1092
+ dispatcher.useEffect(() => {
1093
+ refs.getSnapshot = getSnapshot;
1094
+
1095
+ // Normally the dispatch function for a state hook never changes,
1096
+ // but this hook recreates the queue in certain cases to avoid updates from stale sources.
1097
+ // handleChange() below needs to reference the dispatch function without re-subscribing,
1098
+ // so we use a ref to ensure that it always has the latest version.
1099
+ refs.setSnapshot = setSnapshot;
1100
+
1101
+ // Check for a possible change between when we last rendered now.
1102
+ const maybeNewVersion = getVersion(source._source);
1103
+ if (!is(version, maybeNewVersion)) {
1104
+ const maybeNewSnapshot = getSnapshot(source._source);
1105
+ if (__DEV__) {
1106
+ if (typeof maybeNewSnapshot === 'function') {
1107
+ console.error(
1108
+ 'Mutable source should not return a function as the snapshot value. ' +
1109
+ 'Functions may close over mutable values and cause tearing.',
1110
+ );
1111
+ }
1112
+ }
1113
+
1114
+ if (!is(snapshot, maybeNewSnapshot)) {
1115
+ setSnapshot(maybeNewSnapshot);
1116
+
1117
+ const lane = requestUpdateLane(fiber);
1118
+ markRootMutableRead(root, lane);
1119
+ }
1120
+ // If the source mutated between render and now,
1121
+ // there may be state updates already scheduled from the old source.
1122
+ // Entangle the updates so that they render in the same batch.
1123
+ markRootEntangled(root, root.mutableReadLanes);
1124
+ }
1125
+ }, [getSnapshot, source, subscribe]);
1126
+
1127
+ // If we got a new source or subscribe function, re-subscribe in a passive effect.
1128
+ dispatcher.useEffect(() => {
1129
+ const handleChange = () => {
1130
+ const latestGetSnapshot = refs.getSnapshot;
1131
+ const latestSetSnapshot = refs.setSnapshot;
1132
+
1133
+ try {
1134
+ latestSetSnapshot(latestGetSnapshot(source._source));
1135
+
1136
+ // Record a pending mutable source update with the same expiration time.
1137
+ const lane = requestUpdateLane(fiber);
1138
+
1139
+ markRootMutableRead(root, lane);
1140
+ } catch (error) {
1141
+ // A selector might throw after a source mutation.
1142
+ // e.g. it might try to read from a part of the store that no longer exists.
1143
+ // In this case we should still schedule an update with React.
1144
+ // Worst case the selector will throw again and then an error boundary will handle it.
1145
+ latestSetSnapshot(
1146
+ (() => {
1147
+ throw error;
1148
+ }: any),
1149
+ );
1150
+ }
1151
+ };
1152
+
1153
+ const unsubscribe = subscribe(source._source, handleChange);
1154
+ if (__DEV__) {
1155
+ if (typeof unsubscribe !== 'function') {
1156
+ console.error(
1157
+ 'Mutable source subscribe function must return an unsubscribe function.',
1158
+ );
1159
+ }
1160
+ }
1161
+
1162
+ return unsubscribe;
1163
+ }, [source, subscribe]);
1164
+
1165
+ // If any of the inputs to useMutableSource change, reading is potentially unsafe.
1166
+ //
1167
+ // If either the source or the subscription have changed we can't can't trust the update queue.
1168
+ // Maybe the source changed in a way that the old subscription ignored but the new one depends on.
1169
+ //
1170
+ // If the getSnapshot function changed, we also shouldn't rely on the update queue.
1171
+ // It's possible that the underlying source was mutated between the when the last "change" event fired,
1172
+ // and when the current render (with the new getSnapshot function) is processed.
1173
+ //
1174
+ // In both cases, we need to throw away pending updates (since they are no longer relevant)
1175
+ // and treat reading from the source as we do in the mount case.
1176
+ if (
1177
+ !is(prevGetSnapshot, getSnapshot) ||
1178
+ !is(prevSource, source) ||
1179
+ !is(prevSubscribe, subscribe)
1180
+ ) {
1181
+ // Create a new queue and setState method,
1182
+ // So if there are interleaved updates, they get pushed to the older queue.
1183
+ // When this becomes current, the previous queue and dispatch method will be discarded,
1184
+ // including any interleaving updates that occur.
1185
+ const newQueue: UpdateQueue<Snapshot, BasicStateAction<Snapshot>> = {
1186
+ pending: null,
1187
+ interleaved: null,
1188
+ lanes: NoLanes,
1189
+ dispatch: null,
1190
+ lastRenderedReducer: basicStateReducer,
1191
+ lastRenderedState: snapshot,
1192
+ };
1193
+ newQueue.dispatch = setSnapshot = (dispatchAction.bind(
1194
+ null,
1195
+ currentlyRenderingFiber,
1196
+ newQueue,
1197
+ ): any);
1198
+ stateHook.queue = newQueue;
1199
+ stateHook.baseQueue = null;
1200
+ snapshot = readFromUnsubscribedMutableSource(root, source, getSnapshot);
1201
+ stateHook.memoizedState = stateHook.baseState = snapshot;
1202
+ }
1203
+
1204
+ return snapshot;
1205
+}
1206
+
1207
+function mountMutableSource<Source, Snapshot>(
1208
+ source: MutableSource<Source>,
1209
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
1210
+ subscribe: MutableSourceSubscribeFn<Source, Snapshot>,
1211
+): Snapshot {
1212
+ const hook = mountWorkInProgressHook();
1213
+ hook.memoizedState = ({
1214
+ refs: {
1215
+ getSnapshot,
1216
+ setSnapshot: (null: any),
1217
+ },
1218
+ source,
1219
+ subscribe,
1220
+ }: MutableSourceMemoizedState<Source, Snapshot>);
1221
+ return useMutableSource(hook, source, getSnapshot, subscribe);
1222
+}
1223
+
1224
+function updateMutableSource<Source, Snapshot>(
1225
+ source: MutableSource<Source>,
1226
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
1227
+ subscribe: MutableSourceSubscribeFn<Source, Snapshot>,
1228
+): Snapshot {
1229
+ const hook = updateWorkInProgressHook();
1230
+ return useMutableSource(hook, source, getSnapshot, subscribe);
1231
+}
1232
+
1233
function mountSyncExternalStore<T>(
1234
subscribe: (() => void) => () => void,
1235
getSnapshot: () => T,
@@ -2035,6 +2330,7 @@ export const ContextOnlyDispatcher: Dispatcher = {
2330
useDebugValue: throwInvalidHookError,
2331
useDeferredValue: throwInvalidHookError,
2332
useTransition: throwInvalidHookError,
2333
+ useMutableSource: throwInvalidHookError,
2334
useSyncExternalStore: throwInvalidHookError,
2335
useOpaqueIdentifier: throwInvalidHookError,
2336
@@ -2061,6 +2357,7 @@ const HooksDispatcherOnMount: Dispatcher = {
2357
useDebugValue: mountDebugValue,
2358
useDeferredValue: mountDeferredValue,
2359
useTransition: mountTransition,
2360
+ useMutableSource: mountMutableSource,
2361
useSyncExternalStore: mountSyncExternalStore,
2362
useOpaqueIdentifier: mountOpaqueIdentifier,
2363
@@ -2087,6 +2384,7 @@ const HooksDispatcherOnUpdate: Dispatcher = {
2384
useDebugValue: updateDebugValue,
2385
useDeferredValue: updateDeferredValue,
2386
useTransition: updateTransition,
2387
+ useMutableSource: updateMutableSource,
2388
useSyncExternalStore: updateSyncExternalStore,
2389
useOpaqueIdentifier: updateOpaqueIdentifier,
2390
@@ -2113,6 +2411,7 @@ const HooksDispatcherOnRerender: Dispatcher = {
2411
useDebugValue: updateDebugValue,
2412
useDeferredValue: rerenderDeferredValue,
2413
useTransition: rerenderTransition,
2414
+ useMutableSource: updateMutableSource,
2415
useSyncExternalStore: mountSyncExternalStore,
2416
useOpaqueIdentifier: rerenderOpaqueIdentifier,
2417
@@ -2262,6 +2561,15 @@ if (__DEV__) {
2561
mountHookTypesDev();
2562
return mountTransition();
2563
},
2564
+ useMutableSource<Source, Snapshot>(
2565
+ source: MutableSource<Source>,
2566
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
2567
+ subscribe: MutableSourceSubscribeFn<Source, Snapshot>,
2568
+ ): Snapshot {
2569
+ currentHookNameInDev = 'useMutableSource';
2570
+ mountHookTypesDev();
2571
+ return mountMutableSource(source, getSnapshot, subscribe);
2572
+ },
2573
useSyncExternalStore<T>(
2574
subscribe: (() => void) => () => void,
2575
getSnapshot: () => T,
@@ -2394,6 +2702,15 @@ if (__DEV__) {
2702
updateHookTypesDev();
2703
return mountTransition();
2704
},
2705
+ useMutableSource<Source, Snapshot>(
2706
+ source: MutableSource<Source>,
2707
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
2708
+ subscribe: MutableSourceSubscribeFn<Source, Snapshot>,
2709
+ ): Snapshot {
2710
+ currentHookNameInDev = 'useMutableSource';
2711
+ updateHookTypesDev();
2712
+ return mountMutableSource(source, getSnapshot, subscribe);
2713
+ },
2714
useSyncExternalStore<T>(
2715
subscribe: (() => void) => () => void,
2716
getSnapshot: () => T,
@@ -2526,6 +2843,15 @@ if (__DEV__) {
2843
updateHookTypesDev();
2844
return updateTransition();
2845
},
2846
+ useMutableSource<Source, Snapshot>(
2847
+ source: MutableSource<Source>,
2848
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
2849
+ subscribe: MutableSourceSubscribeFn<Source, Snapshot>,
2850
+ ): Snapshot {
2851
+ currentHookNameInDev = 'useMutableSource';
2852
+ updateHookTypesDev();
2853
+ return updateMutableSource(source, getSnapshot, subscribe);
2854
+ },
2855
useSyncExternalStore<T>(
2856
subscribe: (() => void) => () => void,
2857
getSnapshot: () => T,
@@ -2659,6 +2985,15 @@ if (__DEV__) {
2985
updateHookTypesDev();
2986
return rerenderTransition();
2987
},
2988
+ useMutableSource<Source, Snapshot>(
2989
+ source: MutableSource<Source>,
2990
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
2991
+ subscribe: MutableSourceSubscribeFn<Source, Snapshot>,
2992
+ ): Snapshot {
2993
+ currentHookNameInDev = 'useMutableSource';
2994
+ updateHookTypesDev();
2995
+ return updateMutableSource(source, getSnapshot, subscribe);
2996
+ },
2997
useSyncExternalStore<T>(
2998
subscribe: (() => void) => () => void,
2999
getSnapshot: () => T,
@@ -2805,6 +3140,16 @@ if (__DEV__) {
3140
mountHookTypesDev();
3141
return mountTransition();
3142
},
3143
+ useMutableSource<Source, Snapshot>(
3144
+ source: MutableSource<Source>,
3145
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
3146
+ subscribe: MutableSourceSubscribeFn<Source, Snapshot>,
3147
+ ): Snapshot {
3148
+ currentHookNameInDev = 'useMutableSource';
3149
+ warnInvalidHookAccess();
3150
+ mountHookTypesDev();
3151
+ return mountMutableSource(source, getSnapshot, subscribe);
3152
+ },
3153
useSyncExternalStore<T>(
3154
subscribe: (() => void) => () => void,
3155
getSnapshot: () => T,
@@ -2953,6 +3298,16 @@ if (__DEV__) {
3298
updateHookTypesDev();
3299
return updateTransition();
3300
},
3301
+ useMutableSource<Source, Snapshot>(
3302
+ source: MutableSource<Source>,
3303
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
3304
+ subscribe: MutableSourceSubscribeFn<Source, Snapshot>,
3305
+ ): Snapshot {
3306
+ currentHookNameInDev = 'useMutableSource';
3307
+ warnInvalidHookAccess();
3308
+ updateHookTypesDev();
3309
+ return updateMutableSource(source, getSnapshot, subscribe);
3310
+ },
3311
useSyncExternalStore<T>(
3312
subscribe: (() => void) => () => void,
3313
getSnapshot: () => T,
@@ -3102,6 +3457,16 @@ if (__DEV__) {
3457
updateHookTypesDev();
3458
return rerenderTransition();
3459
},
3460
+ useMutableSource<Source, Snapshot>(
3461
+ source: MutableSource<Source>,
3462
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
3463
+ subscribe: MutableSourceSubscribeFn<Source, Snapshot>,
3464
+ ): Snapshot {
3465
+ currentHookNameInDev = 'useMutableSource';
3466
+ warnInvalidHookAccess();
3467
+ updateHookTypesDev();
3468
+ return updateMutableSource(source, getSnapshot, subscribe);
3469
+ },
3470
useSyncExternalStore<T>(
3471
subscribe: (() => void) => () => void,
3472
getSnapshot: () => T,
packages/react-reconciler/src/ReactFiberLane.new.js
+8
-3
@@ -283,9 +283,9 @@ export function getNextLanes(root: FiberRoot, wipLanes: Lanes): Lanes {
283
// time it takes to show the final state, which is what they are actually
284
// waiting for.
285
//
286
- // For those exceptions where entanglement is semantically important, we
287
- // should ensure that there is no partial work at the time we apply the
288
- // entanglement.
286
+ // For those exceptions where entanglement is semantically important, like
287
+ // useMutableSource, we should ensure that there is no partial work at the
288
+ // time we apply the entanglement.
289
const entangledLanes = root.entangledLanes;
290
if (entangledLanes !== NoLanes) {
291
const entanglements = root.entanglements;
@@ -617,6 +617,10 @@ export function markRootPinged(
617
root.pingedLanes |= root.suspendedLanes & pingedLanes;
618
}
619
620
+export function markRootMutableRead(root: FiberRoot, updateLane: Lane) {
621
+ root.mutableReadLanes |= updateLane & root.pendingLanes;
622
+}
623
+
624
export function markRootFinished(root: FiberRoot, remainingLanes: Lanes) {
625
const noLongerPendingLanes = root.pendingLanes & ~remainingLanes;
626
@@ -627,6 +631,7 @@ export function markRootFinished(root: FiberRoot, remainingLanes: Lanes) {
631
root.pingedLanes = 0;
632
633
root.expiredLanes &= remainingLanes;
634
+ root.mutableReadLanes &= remainingLanes;
635
636
root.entangledLanes &= remainingLanes;
637
packages/react-reconciler/src/ReactFiberLane.old.js
+8
-3
@@ -283,9 +283,9 @@ export function getNextLanes(root: FiberRoot, wipLanes: Lanes): Lanes {
283
// time it takes to show the final state, which is what they are actually
284
// waiting for.
285
//
286
- // For those exceptions where entanglement is semantically important, we
287
- // should ensure that there is no partial work at the time we apply the
288
- // entanglement.
286
+ // For those exceptions where entanglement is semantically important, like
287
+ // useMutableSource, we should ensure that there is no partial work at the
288
+ // time we apply the entanglement.
289
const entangledLanes = root.entangledLanes;
290
if (entangledLanes !== NoLanes) {
291
const entanglements = root.entanglements;
@@ -617,6 +617,10 @@ export function markRootPinged(
617
root.pingedLanes |= root.suspendedLanes & pingedLanes;
618
}
619
620
+export function markRootMutableRead(root: FiberRoot, updateLane: Lane) {
621
+ root.mutableReadLanes |= updateLane & root.pendingLanes;
622
+}
623
+
624
export function markRootFinished(root: FiberRoot, remainingLanes: Lanes) {
625
const noLongerPendingLanes = root.pendingLanes & ~remainingLanes;
626
@@ -627,6 +631,7 @@ export function markRootFinished(root: FiberRoot, remainingLanes: Lanes) {
631
root.pingedLanes = 0;
632
633
root.expiredLanes &= remainingLanes;
634
+ root.mutableReadLanes &= remainingLanes;
635
636
root.entangledLanes &= remainingLanes;
637
packages/react-reconciler/src/ReactFiberReconciler.js
+5
@@ -46,6 +46,7 @@ import {
46
findBoundingRects as findBoundingRects_old,
47
focusWithin as focusWithin_old,
48
observeVisibleRects as observeVisibleRects_old,
49
+ registerMutableSourceForHydration as registerMutableSourceForHydration_old,
50
runWithPriority as runWithPriority_old,
51
getCurrentUpdatePriority as getCurrentUpdatePriority_old,
52
} from './ReactFiberReconciler.old';
@@ -82,6 +83,7 @@ import {
83
findBoundingRects as findBoundingRects_new,
84
focusWithin as focusWithin_new,
85
observeVisibleRects as observeVisibleRects_new,
86
+ registerMutableSourceForHydration as registerMutableSourceForHydration_new,
87
runWithPriority as runWithPriority_new,
88
getCurrentUpdatePriority as getCurrentUpdatePriority_new,
89
} from './ReactFiberReconciler.new';
@@ -182,6 +184,9 @@ export const focusWithin = enableNewReconciler
184
export const observeVisibleRects = enableNewReconciler
185
? observeVisibleRects_new
186
: observeVisibleRects_old;
187
+export const registerMutableSourceForHydration = enableNewReconciler
188
+ ? registerMutableSourceForHydration_new
189
+ : registerMutableSourceForHydration_old;
190
export const runWithPriority = enableNewReconciler
191
? runWithPriority_new
192
: runWithPriority_old;
packages/react-reconciler/src/ReactFiberReconciler.new.js
+1
@@ -90,6 +90,7 @@ import {
90
} from './ReactFiberHotReloading.new';
91
import {markRenderScheduled} from './SchedulingProfiler';
92
import ReactVersion from 'shared/ReactVersion';
93
+export {registerMutableSourceForHydration} from './ReactMutableSource.new';
94
export {createPortal} from './ReactPortal';
95
export {
96
createComponentSelector,
packages/react-reconciler/src/ReactFiberReconciler.old.js
+1
@@ -90,6 +90,7 @@ import {
90
} from './ReactFiberHotReloading.old';
91
import {markRenderScheduled} from './SchedulingProfiler';
92
import ReactVersion from 'shared/ReactVersion';
93
+export {registerMutableSourceForHydration} from './ReactMutableSource.old';
94
export {createPortal} from './ReactPortal';
95
export {
96
createComponentSelector,
packages/react-reconciler/src/ReactFiberRoot.new.js
+6
-1
@@ -10,7 +10,7 @@
10
import type {FiberRoot, SuspenseHydrationCallbacks} from './ReactInternalTypes';
11
import type {RootTag} from './ReactRootTags';
12
13
-import {noTimeout} from './ReactFiberHostConfig';
13
+import {noTimeout, supportsHydration} from './ReactFiberHostConfig';
14
import {createHostRootFiber} from './ReactFiber.new';
15
import {
16
NoLane,
@@ -49,6 +49,7 @@ function FiberRootNode(containerInfo, tag, hydrate) {
49
this.suspendedLanes = NoLanes;
50
this.pingedLanes = NoLanes;
51
this.expiredLanes = NoLanes;
52
+ this.mutableReadLanes = NoLanes;
53
this.finishedLanes = NoLanes;
54
55
this.entangledLanes = NoLanes;
@@ -59,6 +60,10 @@ function FiberRootNode(containerInfo, tag, hydrate) {
60
this.pooledCacheLanes = NoLanes;
61
}
62
63
+ if (supportsHydration) {
64
+ this.mutableSourceEagerHydrationData = null;
65
+ }
66
+
67
if (enableSuspenseCallback) {
68
this.hydrationCallbacks = null;
69
}
packages/react-reconciler/src/ReactFiberRoot.old.js
+6
-1
@@ -10,7 +10,7 @@
10
import type {FiberRoot, SuspenseHydrationCallbacks} from './ReactInternalTypes';
11
import type {RootTag} from './ReactRootTags';
12
13
-import {noTimeout} from './ReactFiberHostConfig';
13
+import {noTimeout, supportsHydration} from './ReactFiberHostConfig';
14
import {createHostRootFiber} from './ReactFiber.old';
15
import {
16
NoLane,
@@ -49,6 +49,7 @@ function FiberRootNode(containerInfo, tag, hydrate) {
49
this.suspendedLanes = NoLanes;
50
this.pingedLanes = NoLanes;
51
this.expiredLanes = NoLanes;
52
+ this.mutableReadLanes = NoLanes;
53
this.finishedLanes = NoLanes;
54
55
this.entangledLanes = NoLanes;
@@ -59,6 +60,10 @@ function FiberRootNode(containerInfo, tag, hydrate) {
60
this.pooledCacheLanes = NoLanes;
61
}
62
63
+ if (supportsHydration) {
64
+ this.mutableSourceEagerHydrationData = null;
65
+ }
66
+
67
if (enableSuspenseCallback) {
68
this.hydrationCallbacks = null;
69
}
packages/react-reconciler/src/ReactFiberUnwindWork.new.js
+3
@@ -13,6 +13,7 @@ import type {Lanes} from './ReactFiberLane.new';
13
import type {SuspenseState} from './ReactFiberSuspenseComponent.new';
14
import type {Cache, SpawnedCachePool} from './ReactFiberCacheComponent.new';
15
16
+import {resetWorkInProgressVersions as resetMutableSourceWorkInProgressVersions} from './ReactMutableSource.new';
17
import {
18
ClassComponent,
19
HostRoot,
@@ -82,6 +83,7 @@ function unwindWork(workInProgress: Fiber, renderLanes: Lanes) {
83
}
84
popHostContainer(workInProgress);
85
popTopLevelLegacyContextObject(workInProgress);
86
+ resetMutableSourceWorkInProgressVersions();
87
const flags = workInProgress.flags;
88
invariant(
89
(flags & DidCapture) === NoFlags,
@@ -177,6 +179,7 @@ function unwindInterruptedWork(interruptedWork: Fiber, renderLanes: Lanes) {
179
}
180
popHostContainer(interruptedWork);
181
popTopLevelLegacyContextObject(interruptedWork);
182
+ resetMutableSourceWorkInProgressVersions();
183
break;
184
}
185
case HostComponent: {
packages/react-reconciler/src/ReactFiberUnwindWork.old.js
+3
@@ -13,6 +13,7 @@ import type {Lanes} from './ReactFiberLane.old';
13
import type {SuspenseState} from './ReactFiberSuspenseComponent.old';
14
import type {Cache, SpawnedCachePool} from './ReactFiberCacheComponent.old';
15
16
+import {resetWorkInProgressVersions as resetMutableSourceWorkInProgressVersions} from './ReactMutableSource.old';
17
import {
18
ClassComponent,
19
HostRoot,
@@ -82,6 +83,7 @@ function unwindWork(workInProgress: Fiber, renderLanes: Lanes) {
83
}
84
popHostContainer(workInProgress);
85
popTopLevelLegacyContextObject(workInProgress);
86
+ resetMutableSourceWorkInProgressVersions();
87
const flags = workInProgress.flags;
88
invariant(
89
(flags & DidCapture) === NoFlags,
@@ -177,6 +179,7 @@ function unwindInterruptedWork(interruptedWork: Fiber, renderLanes: Lanes) {
179
}
180
popHostContainer(interruptedWork);
181
popTopLevelLegacyContextObject(interruptedWork);
182
+ resetMutableSourceWorkInProgressVersions();
183
break;
184
}
185
case HostComponent: {
packages/react-reconciler/src/ReactInternalTypes.js
+20
-1
@@ -8,7 +8,14 @@
8
*/
9
10
import type {Source} from 'shared/ReactElementType';
11
-import type {RefObject, ReactContext} from 'shared/ReactTypes';
11
+import type {
12
+ RefObject,
13
+ ReactContext,
14
+ MutableSourceSubscribeFn,
15
+ MutableSourceGetSnapshotFn,
16
+ MutableSourceVersion,
17
+ MutableSource,
18
+} from 'shared/ReactTypes';
19
import type {SuspenseInstance} from './ReactFiberHostConfig';
20
import type {WorkTag} from './ReactWorkTags';
21
import type {TypeOfMode} from './ReactTypeOfMode';
@@ -34,6 +41,7 @@ export type HookType =
41
| 'useDebugValue'
42
| 'useDeferredValue'
43
| 'useTransition'
44
+ | 'useMutableSource'
45
| 'useSyncExternalStore'
46
| 'useOpaqueIdentifier'
47
| 'useCacheRefresh';
@@ -206,6 +214,11 @@ type BaseFiberRootProperties = {|
214
// Determines if we should attempt to hydrate on the initial mount
215
+hydrate: boolean,
216
217
+ // Used by useMutableSource hook to avoid tearing during hydration.
218
+ mutableSourceEagerHydrationData?: Array<
219
+ MutableSource<any> | MutableSourceVersion,
220
+ > | null,
221
+
222
// Node returned by Scheduler.scheduleCallback. Represents the next rendering
223
// task that the root will work on.
224
callbackNode: *,
@@ -217,6 +230,7 @@ type BaseFiberRootProperties = {|
230
suspendedLanes: Lanes,
231
pingedLanes: Lanes,
232
expiredLanes: Lanes,
233
+ mutableReadLanes: Lanes,
234
235
finishedLanes: Lanes,
236
@@ -291,6 +305,11 @@ export type Dispatcher = {|
305
useDebugValue<T>(value: T, formatterFn: ?(value: T) => mixed): void,
306
useDeferredValue<T>(value: T): T,
307
useTransition(): [boolean, (() => void) => void],
308
+ useMutableSource<Source, Snapshot>(
309
+ source: MutableSource<Source>,
310
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
311
+ subscribe: MutableSourceSubscribeFn<Source, Snapshot>,
312
+ ): Snapshot,
313
useSyncExternalStore<T>(
314
subscribe: (() => void) => () => void,
315
getSnapshot: () => T,
packages/react-reconciler/src/ReactMutableSource.new.js
new
+108
@@ -0,0 +1,108 @@
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, MutableSourceVersion} from 'shared/ReactTypes';
11
+import type {FiberRoot} from './ReactInternalTypes';
12
+
13
+import {isPrimaryRenderer} from './ReactFiberHostConfig';
14
+
15
+// Work in progress version numbers only apply to a single render,
16
+// and should be reset before starting a new render.
17
+// This tracks which mutable sources need to be reset after a render.
18
+const workInProgressSources: Array<MutableSource<any>> = [];
19
+
20
+let rendererSigil;
21
+if (__DEV__) {
22
+ // Used to detect multiple renderers using the same mutable source.
23
+ rendererSigil = {};
24
+}
25
+
26
+export function markSourceAsDirty(mutableSource: MutableSource<any>): void {
27
+ workInProgressSources.push(mutableSource);
28
+}
29
+
30
+export function resetWorkInProgressVersions(): void {
31
+ for (let i = 0; i < workInProgressSources.length; i++) {
32
+ const mutableSource = workInProgressSources[i];
33
+ if (isPrimaryRenderer) {
34
+ mutableSource._workInProgressVersionPrimary = null;
35
+ } else {
36
+ mutableSource._workInProgressVersionSecondary = null;
37
+ }
38
+ }
39
+ workInProgressSources.length = 0;
40
+}
41
+
42
+export function getWorkInProgressVersion(
43
+ mutableSource: MutableSource<any>,
44
+): null | MutableSourceVersion {
45
+ if (isPrimaryRenderer) {
46
+ return mutableSource._workInProgressVersionPrimary;
47
+ } else {
48
+ return mutableSource._workInProgressVersionSecondary;
49
+ }
50
+}
51
+
52
+export function setWorkInProgressVersion(
53
+ mutableSource: MutableSource<any>,
54
+ version: MutableSourceVersion,
55
+): void {
56
+ if (isPrimaryRenderer) {
57
+ mutableSource._workInProgressVersionPrimary = version;
58
+ } else {
59
+ mutableSource._workInProgressVersionSecondary = version;
60
+ }
61
+ workInProgressSources.push(mutableSource);
62
+}
63
+
64
+export function warnAboutMultipleRenderersDEV(
65
+ mutableSource: MutableSource<any>,
66
+): void {
67
+ if (__DEV__) {
68
+ if (isPrimaryRenderer) {
69
+ if (mutableSource._currentPrimaryRenderer == null) {
70
+ mutableSource._currentPrimaryRenderer = rendererSigil;
71
+ } else if (mutableSource._currentPrimaryRenderer !== rendererSigil) {
72
+ console.error(
73
+ 'Detected multiple renderers concurrently rendering the ' +
74
+ 'same mutable source. This is currently unsupported.',
75
+ );
76
+ }
77
+ } else {
78
+ if (mutableSource._currentSecondaryRenderer == null) {
79
+ mutableSource._currentSecondaryRenderer = rendererSigil;
80
+ } else if (mutableSource._currentSecondaryRenderer !== rendererSigil) {
81
+ console.error(
82
+ 'Detected multiple renderers concurrently rendering the ' +
83
+ 'same mutable source. This is currently unsupported.',
84
+ );
85
+ }
86
+ }
87
+ }
88
+}
89
+
90
+// Eager reads the version of a mutable source and stores it on the root.
91
+// This ensures that the version used for server rendering matches the one
92
+// that is eventually read during hydration.
93
+// If they don't match there's a potential tear and a full deopt render is required.
94
+export function registerMutableSourceForHydration(
95
+ root: FiberRoot,
96
+ mutableSource: MutableSource<any>,
97
+): void {
98
+ const getVersion = mutableSource._getVersion;
99
+ const version = getVersion(mutableSource._source);
100
+
101
+ // TODO Clear this data once all pending hydration work is finished.
102
+ // Retaining it forever may interfere with GC.
103
+ if (root.mutableSourceEagerHydrationData == null) {
104
+ root.mutableSourceEagerHydrationData = [mutableSource, version];
105
+ } else {
106
+ root.mutableSourceEagerHydrationData.push(mutableSource, version);
107
+ }
108
+}
packages/react-reconciler/src/ReactMutableSource.old.js
new
+108
@@ -0,0 +1,108 @@
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, MutableSourceVersion} from 'shared/ReactTypes';
11
+import type {FiberRoot} from './ReactInternalTypes';
12
+
13
+import {isPrimaryRenderer} from './ReactFiberHostConfig';
14
+
15
+// Work in progress version numbers only apply to a single render,
16
+// and should be reset before starting a new render.
17
+// This tracks which mutable sources need to be reset after a render.
18
+const workInProgressSources: Array<MutableSource<any>> = [];
19
+
20
+let rendererSigil;
21
+if (__DEV__) {
22
+ // Used to detect multiple renderers using the same mutable source.
23
+ rendererSigil = {};
24
+}
25
+
26
+export function markSourceAsDirty(mutableSource: MutableSource<any>): void {
27
+ workInProgressSources.push(mutableSource);
28
+}
29
+
30
+export function resetWorkInProgressVersions(): void {
31
+ for (let i = 0; i < workInProgressSources.length; i++) {
32
+ const mutableSource = workInProgressSources[i];
33
+ if (isPrimaryRenderer) {
34
+ mutableSource._workInProgressVersionPrimary = null;
35
+ } else {
36
+ mutableSource._workInProgressVersionSecondary = null;
37
+ }
38
+ }
39
+ workInProgressSources.length = 0;
40
+}
41
+
42
+export function getWorkInProgressVersion(
43
+ mutableSource: MutableSource<any>,
44
+): null | MutableSourceVersion {
45
+ if (isPrimaryRenderer) {
46
+ return mutableSource._workInProgressVersionPrimary;
47
+ } else {
48
+ return mutableSource._workInProgressVersionSecondary;
49
+ }
50
+}
51
+
52
+export function setWorkInProgressVersion(
53
+ mutableSource: MutableSource<any>,
54
+ version: MutableSourceVersion,
55
+): void {
56
+ if (isPrimaryRenderer) {
57
+ mutableSource._workInProgressVersionPrimary = version;
58
+ } else {
59
+ mutableSource._workInProgressVersionSecondary = version;
60
+ }
61
+ workInProgressSources.push(mutableSource);
62
+}
63
+
64
+export function warnAboutMultipleRenderersDEV(
65
+ mutableSource: MutableSource<any>,
66
+): void {
67
+ if (__DEV__) {
68
+ if (isPrimaryRenderer) {
69
+ if (mutableSource._currentPrimaryRenderer == null) {
70
+ mutableSource._currentPrimaryRenderer = rendererSigil;
71
+ } else if (mutableSource._currentPrimaryRenderer !== rendererSigil) {
72
+ console.error(
73
+ 'Detected multiple renderers concurrently rendering the ' +
74
+ 'same mutable source. This is currently unsupported.',
75
+ );
76
+ }
77
+ } else {
78
+ if (mutableSource._currentSecondaryRenderer == null) {
79
+ mutableSource._currentSecondaryRenderer = rendererSigil;
80
+ } else if (mutableSource._currentSecondaryRenderer !== rendererSigil) {
81
+ console.error(
82
+ 'Detected multiple renderers concurrently rendering the ' +
83
+ 'same mutable source. This is currently unsupported.',
84
+ );
85
+ }
86
+ }
87
+ }
88
+}
89
+
90
+// Eager reads the version of a mutable source and stores it on the root.
91
+// This ensures that the version used for server rendering matches the one
92
+// that is eventually read during hydration.
93
+// If they don't match there's a potential tear and a full deopt render is required.
94
+export function registerMutableSourceForHydration(
95
+ root: FiberRoot,
96
+ mutableSource: MutableSource<any>,
97
+): void {
98
+ const getVersion = mutableSource._getVersion;
99
+ const version = getVersion(mutableSource._source);
100
+
101
+ // TODO Clear this data once all pending hydration work is finished.
102
+ // Retaining it forever may interfere with GC.
103
+ if (root.mutableSourceEagerHydrationData == null) {
104
+ root.mutableSourceEagerHydrationData = [mutableSource, version];
105
+ } else {
106
+ root.mutableSourceEagerHydrationData.push(mutableSource, version);
107
+ }
108
+}
packages/react-reconciler/src/__tests__/useMutableSource-test.internal.js
new
+1981
@@ -0,0 +1,1981 @@
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 act;
20
+let createMutableSource;
21
+let useMutableSource;
22
+
23
+function loadModules() {
24
+ jest.resetModules();
25
+ jest.useFakeTimers();
26
+
27
+ ReactFeatureFlags = require('shared/ReactFeatureFlags');
28
+ ReactFeatureFlags.enableProfilerTimer = true;
29
+
30
+ React = require('react');
31
+ ReactNoop = require('react-noop-renderer');
32
+ Scheduler = require('scheduler');
33
+ act = require('jest-react').act;
34
+
35
+ // Stable entrypoints export with "unstable_" prefix.
36
+ createMutableSource =
37
+ React.createMutableSource || React.unstable_createMutableSource;
38
+ useMutableSource = React.useMutableSource || React.unstable_useMutableSource;
39
+}
40
+
41
+describe('useMutableSource', () => {
42
+ const defaultGetSnapshot = source => source.value;
43
+ const defaultSubscribe = (source, callback) => source.subscribe(callback);
44
+
45
+ function createComplexSource(initialValueA, initialValueB) {
46
+ const callbacksA = [];
47
+ const callbacksB = [];
48
+ let revision = 0;
49
+ let valueA = initialValueA;
50
+ let valueB = initialValueB;
51
+
52
+ const subscribeHelper = (callbacks, callback) => {
53
+ if (callbacks.indexOf(callback) < 0) {
54
+ callbacks.push(callback);
55
+ }
56
+ return () => {
57
+ const index = callbacks.indexOf(callback);
58
+ if (index >= 0) {
59
+ callbacks.splice(index, 1);
60
+ }
61
+ };
62
+ };
63
+
64
+ return {
65
+ subscribeA(callback) {
66
+ return subscribeHelper(callbacksA, callback);
67
+ },
68
+ subscribeB(callback) {
69
+ return subscribeHelper(callbacksB, callback);
70
+ },
71
+
72
+ get listenerCountA() {
73
+ return callbacksA.length;
74
+ },
75
+ get listenerCountB() {
76
+ return callbacksB.length;
77
+ },
78
+
79
+ set valueA(newValue) {
80
+ revision++;
81
+ valueA = newValue;
82
+ callbacksA.forEach(callback => callback());
83
+ },
84
+ get valueA() {
85
+ return valueA;
86
+ },
87
+
88
+ set valueB(newValue) {
89
+ revision++;
90
+ valueB = newValue;
91
+ callbacksB.forEach(callback => callback());
92
+ },
93
+ get valueB() {
94
+ return valueB;
95
+ },
96
+
97
+ get version() {
98
+ return revision;
99
+ },
100
+ };
101
+ }
102
+
103
+ function createSource(initialValue) {
104
+ const callbacks = [];
105
+ let revision = 0;
106
+ let value = initialValue;
107
+ return {
108
+ subscribe(callback) {
109
+ if (callbacks.indexOf(callback) < 0) {
110
+ callbacks.push(callback);
111
+ }
112
+ return () => {
113
+ const index = callbacks.indexOf(callback);
114
+ if (index >= 0) {
115
+ callbacks.splice(index, 1);
116
+ }
117
+ };
118
+ },
119
+ get listenerCount() {
120
+ return callbacks.length;
121
+ },
122
+ set value(newValue) {
123
+ revision++;
124
+ value = newValue;
125
+ callbacks.forEach(callback => callback());
126
+ },
127
+ get value() {
128
+ return value;
129
+ },
130
+ get version() {
131
+ return revision;
132
+ },
133
+ };
134
+ }
135
+
136
+ function Component({getSnapshot, label, mutableSource, subscribe}) {
137
+ const snapshot = useMutableSource(mutableSource, getSnapshot, subscribe);
138
+ Scheduler.unstable_yieldValue(`${label}:${snapshot}`);
139
+ return <div>{`${label}:${snapshot}`}</div>;
140
+ }
141
+
142
+ beforeEach(loadModules);
143
+
144
+ it('should subscribe to a source and schedule updates when it changes', () => {
145
+ const source = createSource('one');
146
+ const mutableSource = createMutableSource(source, param => param.version);
147
+
148
+ act(() => {
149
+ ReactNoop.renderToRootWithID(
150
+ <>
151
+ <Component
152
+ label="a"
153
+ getSnapshot={defaultGetSnapshot}
154
+ mutableSource={mutableSource}
155
+ subscribe={defaultSubscribe}
156
+ />
157
+ <Component
158
+ label="b"
159
+ getSnapshot={defaultGetSnapshot}
160
+ mutableSource={mutableSource}
161
+ subscribe={defaultSubscribe}
162
+ />
163
+ </>,
164
+ 'root',
165
+ () => Scheduler.unstable_yieldValue('Sync effect'),
166
+ );
167
+ expect(Scheduler).toFlushAndYieldThrough([
168
+ 'a:one',
169
+ 'b:one',
170
+ 'Sync effect',
171
+ ]);
172
+
173
+ // Subscriptions should be passive
174
+ expect(source.listenerCount).toBe(0);
175
+ ReactNoop.flushPassiveEffects();
176
+ expect(source.listenerCount).toBe(2);
177
+
178
+ // Changing values should schedule an update with React
179
+ source.value = 'two';
180
+ expect(Scheduler).toFlushAndYieldThrough(['a:two', 'b:two']);
181
+
182
+ // Unmounting a component should remove its subscription.
183
+ ReactNoop.renderToRootWithID(
184
+ <>
185
+ <Component
186
+ label="a"
187
+ getSnapshot={defaultGetSnapshot}
188
+ mutableSource={mutableSource}
189
+ subscribe={defaultSubscribe}
190
+ />
191
+ </>,
192
+ 'root',
193
+ () => Scheduler.unstable_yieldValue('Sync effect'),
194
+ );
195
+ expect(Scheduler).toFlushAndYield(['a:two', 'Sync effect']);
196
+ ReactNoop.flushPassiveEffects();
197
+ expect(source.listenerCount).toBe(1);
198
+
199
+ // Unmounting a root should remove the remaining event listeners
200
+ ReactNoop.unmountRootWithID('root');
201
+ expect(Scheduler).toFlushAndYield([]);
202
+ ReactNoop.flushPassiveEffects();
203
+ expect(source.listenerCount).toBe(0);
204
+
205
+ // Changes to source should not trigger an updates or warnings.
206
+ source.value = 'three';
207
+ expect(Scheduler).toFlushAndYield([]);
208
+ });
209
+ });
210
+
211
+ it('should restart work if a new source is mutated during render', () => {
212
+ const source = createSource('one');
213
+ const mutableSource = createMutableSource(source, param => param.version);
214
+
215
+ act(() => {
216
+ if (gate(flags => flags.enableSyncDefaultUpdates)) {
217
+ React.startTransition(() => {
218
+ ReactNoop.render(
219
+ <>
220
+ <Component
221
+ label="a"
222
+ getSnapshot={defaultGetSnapshot}
223
+ mutableSource={mutableSource}
224
+ subscribe={defaultSubscribe}
225
+ />
226
+ <Component
227
+ label="b"
228
+ getSnapshot={defaultGetSnapshot}
229
+ mutableSource={mutableSource}
230
+ subscribe={defaultSubscribe}
231
+ />
232
+ </>,
233
+ () => Scheduler.unstable_yieldValue('Sync effect'),
234
+ );
235
+ });
236
+ } else {
237
+ ReactNoop.render(
238
+ <>
239
+ <Component
240
+ label="a"
241
+ getSnapshot={defaultGetSnapshot}
242
+ mutableSource={mutableSource}
243
+ subscribe={defaultSubscribe}
244
+ />
245
+ <Component
246
+ label="b"
247
+ getSnapshot={defaultGetSnapshot}
248
+ mutableSource={mutableSource}
249
+ subscribe={defaultSubscribe}
250
+ />
251
+ </>,
252
+ () => Scheduler.unstable_yieldValue('Sync effect'),
253
+ );
254
+ }
255
+ // Do enough work to read from one component
256
+ expect(Scheduler).toFlushAndYieldThrough(['a:one']);
257
+
258
+ // Mutate source before continuing work
259
+ source.value = 'two';
260
+
261
+ // Render work should restart and the updated value should be used
262
+ expect(Scheduler).toFlushAndYield(['a:two', 'b:two', 'Sync effect']);
263
+ });
264
+ });
265
+
266
+ it('should schedule an update if a new source is mutated between render and commit (subscription)', () => {
267
+ const source = createSource('one');
268
+ const mutableSource = createMutableSource(source, param => param.version);
269
+
270
+ act(() => {
271
+ ReactNoop.render(
272
+ <>
273
+ <Component
274
+ label="a"
275
+ getSnapshot={defaultGetSnapshot}
276
+ mutableSource={mutableSource}
277
+ subscribe={defaultSubscribe}
278
+ />
279
+ <Component
280
+ label="b"
281
+ getSnapshot={defaultGetSnapshot}
282
+ mutableSource={mutableSource}
283
+ subscribe={defaultSubscribe}
284
+ />
285
+ </>,
286
+ () => Scheduler.unstable_yieldValue('Sync effect'),
287
+ );
288
+
289
+ // Finish rendering
290
+ expect(Scheduler).toFlushAndYieldThrough([
291
+ 'a:one',
292
+ 'b:one',
293
+ 'Sync effect',
294
+ ]);
295
+
296
+ // Mutate source before subscriptions are attached
297
+ expect(source.listenerCount).toBe(0);
298
+ source.value = 'two';
299
+
300
+ // Mutation should be detected, and a new render should be scheduled
301
+ expect(Scheduler).toFlushAndYield(['a:two', 'b:two']);
302
+ });
303
+ });
304
+
305
+ it('should unsubscribe and resubscribe if a new source is used', () => {
306
+ const sourceA = createSource('a-one');
307
+ const mutableSourceA = createMutableSource(
308
+ sourceA,
309
+ param => param.versionA,
310
+ );
311
+
312
+ const sourceB = createSource('b-one');
313
+ const mutableSourceB = createMutableSource(
314
+ sourceB,
315
+ param => param.versionB,
316
+ );
317
+
318
+ act(() => {
319
+ ReactNoop.render(
320
+ <Component
321
+ label="only"
322
+ getSnapshot={defaultGetSnapshot}
323
+ mutableSource={mutableSourceA}
324
+ subscribe={defaultSubscribe}
325
+ />,
326
+ () => Scheduler.unstable_yieldValue('Sync effect'),
327
+ );
328
+ expect(Scheduler).toFlushAndYield(['only:a-one', 'Sync effect']);
329
+ ReactNoop.flushPassiveEffects();
330
+ expect(sourceA.listenerCount).toBe(1);
331
+
332
+ // Changing values should schedule an update with React
333
+ sourceA.value = 'a-two';
334
+ expect(Scheduler).toFlushAndYield(['only:a-two']);
335
+
336
+ // If we re-render with a new source, the old one should be unsubscribed.
337
+ ReactNoop.render(
338
+ <Component
339
+ label="only"
340
+ getSnapshot={defaultGetSnapshot}
341
+ mutableSource={mutableSourceB}
342
+ subscribe={defaultSubscribe}
343
+ />,
344
+ () => Scheduler.unstable_yieldValue('Sync effect'),
345
+ );
346
+ expect(Scheduler).toFlushAndYield(['only:b-one', 'Sync effect']);
347
+ ReactNoop.flushPassiveEffects();
348
+ expect(sourceA.listenerCount).toBe(0);
349
+ expect(sourceB.listenerCount).toBe(1);
350
+
351
+ // Changing to original source should not schedule updates with React
352
+ sourceA.value = 'a-three';
353
+ expect(Scheduler).toFlushAndYield([]);
354
+
355
+ // Changing new source value should schedule an update with React
356
+ sourceB.value = 'b-two';
357
+ expect(Scheduler).toFlushAndYield(['only:b-two']);
358
+ });
359
+ });
360
+
361
+ it('should unsubscribe and resubscribe if a new subscribe function is provided', () => {
362
+ const source = createSource('a-one');
363
+ const mutableSource = createMutableSource(source, param => param.version);
364
+
365
+ const unsubscribeA = jest.fn();
366
+ const subscribeA = jest.fn(s => {
367
+ const unsubscribe = defaultSubscribe(s);
368
+ return () => {
369
+ unsubscribe();
370
+ unsubscribeA();
371
+ };
372
+ });
373
+ const unsubscribeB = jest.fn();
374
+ const subscribeB = jest.fn(s => {
375
+ const unsubscribe = defaultSubscribe(s);
376
+ return () => {
377
+ unsubscribe();
378
+ unsubscribeB();
379
+ };
380
+ });
381
+
382
+ act(() => {
383
+ ReactNoop.renderToRootWithID(
384
+ <Component
385
+ label="only"
386
+ getSnapshot={defaultGetSnapshot}
387
+ mutableSource={mutableSource}
388
+ subscribe={subscribeA}
389
+ />,
390
+ 'root',
391
+ () => Scheduler.unstable_yieldValue('Sync effect'),
392
+ );
393
+ expect(Scheduler).toFlushAndYield(['only:a-one', 'Sync effect']);
394
+ ReactNoop.flushPassiveEffects();
395
+ expect(source.listenerCount).toBe(1);
396
+ expect(subscribeA).toHaveBeenCalledTimes(1);
397
+
398
+ // If we re-render with a new subscription function,
399
+ // the old unsubscribe function should be called.
400
+ ReactNoop.renderToRootWithID(
401
+ <Component
402
+ label="only"
403
+ getSnapshot={defaultGetSnapshot}
404
+ mutableSource={mutableSource}
405
+ subscribe={subscribeB}
406
+ />,
407
+ 'root',
408
+ () => Scheduler.unstable_yieldValue('Sync effect'),
409
+ );
410
+ expect(Scheduler).toFlushAndYield(['only:a-one', 'Sync effect']);
411
+ ReactNoop.flushPassiveEffects();
412
+ expect(source.listenerCount).toBe(1);
413
+ expect(unsubscribeA).toHaveBeenCalledTimes(1);
414
+ expect(subscribeB).toHaveBeenCalledTimes(1);
415
+
416
+ // Unmounting should call the newer unsubscribe.
417
+ ReactNoop.unmountRootWithID('root');
418
+ expect(Scheduler).toFlushAndYield([]);
419
+ ReactNoop.flushPassiveEffects();
420
+ expect(source.listenerCount).toBe(0);
421
+ expect(unsubscribeB).toHaveBeenCalledTimes(1);
422
+ });
423
+ });
424
+
425
+ it('should re-use previously read snapshot value when reading is unsafe', () => {
426
+ const source = createSource('one');
427
+ const mutableSource = createMutableSource(source, param => param.version);
428
+
429
+ act(() => {
430
+ ReactNoop.render(
431
+ <>
432
+ <Component
433
+ label="a"
434
+ getSnapshot={defaultGetSnapshot}
435
+ mutableSource={mutableSource}
436
+ subscribe={defaultSubscribe}
437
+ />
438
+ <Component
439
+ label="b"
440
+ getSnapshot={defaultGetSnapshot}
441
+ mutableSource={mutableSource}
442
+ subscribe={defaultSubscribe}
443
+ />
444
+ </>,
445
+ () => Scheduler.unstable_yieldValue('Sync effect'),
446
+ );
447
+ expect(Scheduler).toFlushAndYield(['a:one', 'b:one', 'Sync effect']);
448
+
449
+ // Changing values should schedule an update with React.
450
+ // Start working on this update but don't finish it.
451
+ if (gate(flags => flags.enableSyncDefaultUpdates)) {
452
+ React.startTransition(() => {
453
+ source.value = 'two';
454
+ });
455
+ } else {
456
+ source.value = 'two';
457
+ }
458
+ expect(Scheduler).toFlushAndYieldThrough(['a:two']);
459
+
460
+ // Re-renders that occur before the update is processed
461
+ // should reuse snapshot so long as the config has not changed
462
+ ReactNoop.flushSync(() => {
463
+ ReactNoop.render(
464
+ <>
465
+ <Component
466
+ label="a"
467
+ getSnapshot={defaultGetSnapshot}
468
+ mutableSource={mutableSource}
469
+ subscribe={defaultSubscribe}
470
+ />
471
+ <Component
472
+ label="b"
473
+ getSnapshot={defaultGetSnapshot}
474
+ mutableSource={mutableSource}
475
+ subscribe={defaultSubscribe}
476
+ />
477
+ </>,
478
+ () => Scheduler.unstable_yieldValue('Sync effect'),
479
+ );
480
+ });
481
+ expect(Scheduler).toHaveYielded(['a:one', 'b:one', 'Sync effect']);
482
+
483
+ expect(Scheduler).toFlushAndYield(['a:two', 'b:two']);
484
+ });
485
+ });
486
+
487
+ it('should read from source on newly mounted subtree if no pending updates are scheduled for source', () => {
488
+ const source = createSource('one');
489
+ const mutableSource = createMutableSource(source, param => param.version);
490
+
491
+ act(() => {
492
+ ReactNoop.render(
493
+ <>
494
+ <Component
495
+ label="a"
496
+ getSnapshot={defaultGetSnapshot}
497
+ mutableSource={mutableSource}
498
+ subscribe={defaultSubscribe}
499
+ />
500
+ </>,
501
+ () => Scheduler.unstable_yieldValue('Sync effect'),
502
+ );
503
+ expect(Scheduler).toFlushAndYield(['a:one', 'Sync effect']);
504
+
505
+ ReactNoop.render(
506
+ <>
507
+ <Component
508
+ label="a"
509
+ getSnapshot={defaultGetSnapshot}
510
+ mutableSource={mutableSource}
511
+ subscribe={defaultSubscribe}
512
+ />
513
+ <Component
514
+ label="b"
515
+ getSnapshot={defaultGetSnapshot}
516
+ mutableSource={mutableSource}
517
+ subscribe={defaultSubscribe}
518
+ />
519
+ </>,
520
+ () => Scheduler.unstable_yieldValue('Sync effect'),
521
+ );
522
+ expect(Scheduler).toFlushAndYield(['a:one', 'b:one', 'Sync effect']);
523
+ });
524
+ });
525
+
526
+ it('should throw and restart render if source and snapshot are unavailable during an update', () => {
527
+ const source = createSource('one');
528
+ const mutableSource = createMutableSource(source, param => param.version);
529
+
530
+ act(() => {
531
+ ReactNoop.render(
532
+ <>
533
+ <Component
534
+ label="a"
535
+ getSnapshot={defaultGetSnapshot}
536
+ mutableSource={mutableSource}
537
+ subscribe={defaultSubscribe}
538
+ />
539
+ <Component
540
+ label="b"
541
+ getSnapshot={defaultGetSnapshot}
542
+ mutableSource={mutableSource}
543
+ subscribe={defaultSubscribe}
544
+ />
545
+ </>,
546
+ () => Scheduler.unstable_yieldValue('Sync effect'),
547
+ );
548
+ expect(Scheduler).toFlushAndYield(['a:one', 'b:one', 'Sync effect']);
549
+ ReactNoop.flushPassiveEffects();
550
+
551
+ // Changing values should schedule an update with React.
552
+ // Start working on this update but don't finish it.
553
+ ReactNoop.idleUpdates(() => {
554
+ source.value = 'two';
555
+ expect(Scheduler).toFlushAndYieldThrough(['a:two']);
556
+ });
557
+
558
+ const newGetSnapshot = s => 'new:' + defaultGetSnapshot(s);
559
+
560
+ // Force a higher priority render with a new config.
561
+ // This should signal that the snapshot is not safe and trigger a full re-render.
562
+ ReactNoop.flushSync(() => {
563
+ ReactNoop.render(
564
+ <>
565
+ <Component
566
+ label="a"
567
+ getSnapshot={newGetSnapshot}
568
+ mutableSource={mutableSource}
569
+ subscribe={defaultSubscribe}
570
+ />
571
+ <Component
572
+ label="b"
573
+ getSnapshot={newGetSnapshot}
574
+ mutableSource={mutableSource}
575
+ subscribe={defaultSubscribe}
576
+ />
577
+ </>,
578
+ () => Scheduler.unstable_yieldValue('Sync effect'),
579
+ );
580
+ });
581
+ expect(Scheduler).toHaveYielded([
582
+ 'a:new:two',
583
+ 'b:new:two',
584
+ 'Sync effect',
585
+ ]);
586
+ });
587
+ });
588
+
589
+ it('should throw and restart render if source and snapshot are unavailable during a sync update', () => {
590
+ const source = createSource('one');
591
+ const mutableSource = createMutableSource(source, param => param.version);
592
+
593
+ act(() => {
594
+ ReactNoop.render(
595
+ <>
596
+ <Component
597
+ label="a"
598
+ getSnapshot={defaultGetSnapshot}
599
+ mutableSource={mutableSource}
600
+ subscribe={defaultSubscribe}
601
+ />
602
+ <Component
603
+ label="b"
604
+ getSnapshot={defaultGetSnapshot}
605
+ mutableSource={mutableSource}
606
+ subscribe={defaultSubscribe}
607
+ />
608
+ </>,
609
+ () => Scheduler.unstable_yieldValue('Sync effect'),
610
+ );
611
+ expect(Scheduler).toFlushAndYield(['a:one', 'b:one', 'Sync effect']);
612
+ ReactNoop.flushPassiveEffects();
613
+
614
+ // Changing values should schedule an update with React.
615
+ // Start working on this update but don't finish it.
616
+ ReactNoop.idleUpdates(() => {
617
+ source.value = 'two';
618
+ expect(Scheduler).toFlushAndYieldThrough(['a:two']);
619
+ });
620
+
621
+ const newGetSnapshot = s => 'new:' + defaultGetSnapshot(s);
622
+
623
+ // Force a higher priority render with a new config.
624
+ // This should signal that the snapshot is not safe and trigger a full re-render.
625
+ ReactNoop.flushSync(() => {
626
+ ReactNoop.render(
627
+ <>
628
+ <Component
629
+ label="a"
630
+ getSnapshot={newGetSnapshot}
631
+ mutableSource={mutableSource}
632
+ subscribe={defaultSubscribe}
633
+ />
634
+ <Component
635
+ label="b"
636
+ getSnapshot={newGetSnapshot}
637
+ mutableSource={mutableSource}
638
+ subscribe={defaultSubscribe}
639
+ />
640
+ </>,
641
+ () => Scheduler.unstable_yieldValue('Sync effect'),
642
+ );
643
+ });
644
+ expect(Scheduler).toHaveYielded([
645
+ 'a:new:two',
646
+ 'b:new:two',
647
+ 'Sync effect',
648
+ ]);
649
+ });
650
+ });
651
+
652
+ it('should only update components whose subscriptions fire', () => {
653
+ const source = createComplexSource('a:one', 'b:one');
654
+ const mutableSource = createMutableSource(source, param => param.version);
655
+
656
+ // Subscribe to part of the store.
657
+ const getSnapshotA = s => s.valueA;
658
+ const subscribeA = (s, callback) => s.subscribeA(callback);
659
+ const getSnapshotB = s => s.valueB;
660
+ const subscribeB = (s, callback) => s.subscribeB(callback);
661
+
662
+ act(() => {
663
+ ReactNoop.render(
664
+ <>
665
+ <Component
666
+ label="a"
667
+ getSnapshot={getSnapshotA}
668
+ mutableSource={mutableSource}
669
+ subscribe={subscribeA}
670
+ />
671
+ <Component
672
+ label="b"
673
+ getSnapshot={getSnapshotB}
674
+ mutableSource={mutableSource}
675
+ subscribe={subscribeB}
676
+ />
677
+ </>,
678
+ () => Scheduler.unstable_yieldValue('Sync effect'),
679
+ );
680
+ expect(Scheduler).toFlushAndYield(['a:a:one', 'b:b:one', 'Sync effect']);
681
+
682
+ // Changes to part of the store (e.g. A) should not render other parts.
683
+ source.valueA = 'a:two';
684
+ expect(Scheduler).toFlushAndYield(['a:a:two']);
685
+ source.valueB = 'b:two';
686
+ expect(Scheduler).toFlushAndYield(['b:b:two']);
687
+ });
688
+ });
689
+
690
+ it('should detect tearing in part of the store not yet subscribed to', () => {
691
+ const source = createComplexSource('a:one', 'b:one');
692
+ const mutableSource = createMutableSource(source, param => param.version);
693
+
694
+ // Subscribe to part of the store.
695
+ const getSnapshotA = s => s.valueA;
696
+ const subscribeA = (s, callback) => s.subscribeA(callback);
697
+ const getSnapshotB = s => s.valueB;
698
+ const subscribeB = (s, callback) => s.subscribeB(callback);
699
+
700
+ act(() => {
701
+ ReactNoop.render(
702
+ <>
703
+ <Component
704
+ label="a"
705
+ getSnapshot={getSnapshotA}
706
+ mutableSource={mutableSource}
707
+ subscribe={subscribeA}
708
+ />
709
+ </>,
710
+ () => Scheduler.unstable_yieldValue('Sync effect'),
711
+ );
712
+ expect(Scheduler).toFlushAndYield(['a:a:one', 'Sync effect']);
713
+
714
+ // Because the store has not changed yet, there are no pending updates,
715
+ // so it is considered safe to read from when we start this render.
716
+ if (gate(flags => flags.enableSyncDefaultUpdates)) {
717
+ React.startTransition(() => {
718
+ ReactNoop.render(
719
+ <>
720
+ <Component
721
+ label="a"
722
+ getSnapshot={getSnapshotA}
723
+ mutableSource={mutableSource}
724
+ subscribe={subscribeA}
725
+ />
726
+ <Component
727
+ label="b"
728
+ getSnapshot={getSnapshotB}
729
+ mutableSource={mutableSource}
730
+ subscribe={subscribeB}
731
+ />
732
+ <Component
733
+ label="c"
734
+ getSnapshot={getSnapshotB}
735
+ mutableSource={mutableSource}
736
+ subscribe={subscribeB}
737
+ />
738
+ </>,
739
+ () => Scheduler.unstable_yieldValue('Sync effect'),
740
+ );
741
+ });
742
+ } else {
743
+ ReactNoop.render(
744
+ <>
745
+ <Component
746
+ label="a"
747
+ getSnapshot={getSnapshotA}
748
+ mutableSource={mutableSource}
749
+ subscribe={subscribeA}
750
+ />
751
+ <Component
752
+ label="b"
753
+ getSnapshot={getSnapshotB}
754
+ mutableSource={mutableSource}
755
+ subscribe={subscribeB}
756
+ />
757
+ <Component
758
+ label="c"
759
+ getSnapshot={getSnapshotB}
760
+ mutableSource={mutableSource}
761
+ subscribe={subscribeB}
762
+ />
763
+ </>,
764
+ () => Scheduler.unstable_yieldValue('Sync effect'),
765
+ );
766
+ }
767
+ expect(Scheduler).toFlushAndYieldThrough(['a:a:one', 'b:b:one']);
768
+
769
+ // Mutating the source should trigger a tear detection on the next read,
770
+ // which should throw and re-render the entire tree.
771
+ source.valueB = 'b:two';
772
+
773
+ expect(Scheduler).toFlushAndYield([
774
+ 'a:a:one',
775
+ 'b:b:two',
776
+ 'c:b:two',
777
+ 'Sync effect',
778
+ ]);
779
+ });
780
+ });
781
+
782
+ it('does not schedule an update for subscriptions that fire with an unchanged snapshot', () => {
783
+ const MockComponent = jest.fn(Component);
784
+
785
+ const source = createSource('one');
786
+ const mutableSource = createMutableSource(source, param => param.version);
787
+
788
+ act(() => {
789
+ ReactNoop.render(
790
+ <MockComponent
791
+ label="only"
792
+ getSnapshot={defaultGetSnapshot}
793
+ mutableSource={mutableSource}
794
+ subscribe={defaultSubscribe}
795
+ />,
796
+ () => Scheduler.unstable_yieldValue('Sync effect'),
797
+ );
798
+ expect(Scheduler).toFlushAndYieldThrough(['only:one', 'Sync effect']);
799
+ ReactNoop.flushPassiveEffects();
800
+ expect(source.listenerCount).toBe(1);
801
+
802
+ // Notify subscribe function but don't change the value
803
+ source.value = 'one';
804
+ expect(Scheduler).toFlushWithoutYielding();
805
+ });
806
+ });
807
+
808
+ it('should throw and restart if getSnapshot changes between scheduled update and re-render', () => {
809
+ const source = createSource('one');
810
+ const mutableSource = createMutableSource(source, param => param.version);
811
+
812
+ const newGetSnapshot = s => 'new:' + defaultGetSnapshot(s);
813
+
814
+ let updateGetSnapshot;
815
+
816
+ function WrapperWithState() {
817
+ const tuple = React.useState(() => defaultGetSnapshot);
818
+ updateGetSnapshot = tuple[1];
819
+ return (
820
+ <Component
821
+ label="only"
822
+ getSnapshot={tuple[0]}
823
+ mutableSource={mutableSource}
824
+ subscribe={defaultSubscribe}
825
+ />
826
+ );
827
+ }
828
+
829
+ act(() => {
830
+ ReactNoop.render(<WrapperWithState />, () =>
831
+ Scheduler.unstable_yieldValue('Sync effect'),
832
+ );
833
+ expect(Scheduler).toFlushAndYield(['only:one', 'Sync effect']);
834
+ ReactNoop.flushPassiveEffects();
835
+
836
+ // Change the source (and schedule an update).
837
+ source.value = 'two';
838
+
839
+ // Schedule a higher priority update that changes getSnapshot.
840
+ ReactNoop.flushSync(() => {
841
+ updateGetSnapshot(() => newGetSnapshot);
842
+ });
843
+
844
+ expect(Scheduler).toHaveYielded(['only:new:two']);
845
+ });
846
+ });
847
+
848
+ it('should recover from a mutation during yield when other work is scheduled', () => {
849
+ const source = createSource('one');
850
+ const mutableSource = createMutableSource(source, param => param.version);
851
+
852
+ act(() => {
853
+ // Start a render that uses the mutable source.
854
+ if (gate(flags => flags.enableSyncDefaultUpdates)) {
855
+ React.startTransition(() => {
856
+ ReactNoop.render(
857
+ <>
858
+ <Component
859
+ label="a"
860
+ getSnapshot={defaultGetSnapshot}
861
+ mutableSource={mutableSource}
862
+ subscribe={defaultSubscribe}
863
+ />
864
+ <Component
865
+ label="b"
866
+ getSnapshot={defaultGetSnapshot}
867
+ mutableSource={mutableSource}
868
+ subscribe={defaultSubscribe}
869
+ />
870
+ </>,
871
+ );
872
+ });
873
+ } else {
874
+ ReactNoop.render(
875
+ <>
876
+ <Component
877
+ label="a"
878
+ getSnapshot={defaultGetSnapshot}
879
+ mutableSource={mutableSource}
880
+ subscribe={defaultSubscribe}
881
+ />
882
+ <Component
883
+ label="b"
884
+ getSnapshot={defaultGetSnapshot}
885
+ mutableSource={mutableSource}
886
+ subscribe={defaultSubscribe}
887
+ />
888
+ </>,
889
+ );
890
+ }
891
+ expect(Scheduler).toFlushAndYieldThrough(['a:one']);
892
+
893
+ // Mutate source
894
+ source.value = 'two';
895
+
896
+ // Now render something different.
897
+ ReactNoop.render(<div />);
898
+ expect(Scheduler).toFlushAndYield([]);
899
+ });
900
+ });
901
+
902
+ it('should not throw if the new getSnapshot returns the same snapshot value', () => {
903
+ const source = createSource('one');
904
+ const mutableSource = createMutableSource(source, param => param.version);
905
+
906
+ const onRenderA = jest.fn();
907
+ const onRenderB = jest.fn();
908
+
909
+ let updateGetSnapshot;
910
+
911
+ function WrapperWithState() {
912
+ const tuple = React.useState(() => defaultGetSnapshot);
913
+ updateGetSnapshot = tuple[1];
914
+ return (
915
+ <Component
916
+ label="b"
917
+ getSnapshot={tuple[0]}
918
+ mutableSource={mutableSource}
919
+ subscribe={defaultSubscribe}
920
+ />
921
+ );
922
+ }
923
+
924
+ act(() => {
925
+ ReactNoop.render(
926
+ <>
927
+ <React.Profiler id="a" onRender={onRenderA}>
928
+ <Component
929
+ label="a"
930
+ getSnapshot={defaultGetSnapshot}
931
+ mutableSource={mutableSource}
932
+ subscribe={defaultSubscribe}
933
+ />
934
+ </React.Profiler>
935
+ <React.Profiler id="b" onRender={onRenderB}>
936
+ <WrapperWithState />
937
+ </React.Profiler>
938
+ </>,
939
+ () => Scheduler.unstable_yieldValue('Sync effect'),
940
+ );
941
+ expect(Scheduler).toFlushAndYield(['a:one', 'b:one', 'Sync effect']);
942
+ ReactNoop.flushPassiveEffects();
943
+ expect(onRenderA).toHaveBeenCalledTimes(1);
944
+ expect(onRenderB).toHaveBeenCalledTimes(1);
945
+
946
+ // If B's getSnapshot function updates, but the snapshot it returns is the same,
947
+ // only B should re-render (to update its state).
948
+ updateGetSnapshot(() => s => defaultGetSnapshot(s));
949
+ expect(Scheduler).toFlushAndYield(['b:one']);
950
+ ReactNoop.flushPassiveEffects();
951
+ expect(onRenderA).toHaveBeenCalledTimes(1);
952
+ expect(onRenderB).toHaveBeenCalledTimes(2);
953
+ });
954
+ });
955
+
956
+ it('should not throw if getSnapshot changes but the source can be safely read from anyway', () => {
957
+ const source = createSource('one');
958
+ const mutableSource = createMutableSource(source, param => param.version);
959
+
960
+ const newGetSnapshot = s => 'new:' + defaultGetSnapshot(s);
961
+
962
+ let updateGetSnapshot;
963
+
964
+ function WrapperWithState() {
965
+ const tuple = React.useState(() => defaultGetSnapshot);
966
+ updateGetSnapshot = tuple[1];
967
+ return (
968
+ <Component
969
+ label="only"
970
+ getSnapshot={tuple[0]}
971
+ mutableSource={mutableSource}
972
+ subscribe={defaultSubscribe}
973
+ />
974
+ );
975
+ }
976
+
977
+ act(() => {
978
+ ReactNoop.render(<WrapperWithState />, () =>
979
+ Scheduler.unstable_yieldValue('Sync effect'),
980
+ );
981
+ expect(Scheduler).toFlushAndYield(['only:one', 'Sync effect']);
982
+ ReactNoop.flushPassiveEffects();
983
+
984
+ // Change the source (and schedule an update)
985
+ // but also change the snapshot function too.
986
+ ReactNoop.batchedUpdates(() => {
987
+ source.value = 'two';
988
+ updateGetSnapshot(() => newGetSnapshot);
989
+ });
990
+
991
+ expect(Scheduler).toFlushAndYield(['only:new:two']);
992
+ });
993
+ });
994
+
995
+ it('should still schedule an update if an eager selector throws after a mutation', () => {
996
+ const source = createSource({
997
+ friends: [
998
+ {id: 1, name: 'Foo'},
999
+ {id: 2, name: 'Bar'},
1000
+ ],
1001
+ });
1002
+ const mutableSource = createMutableSource(source, param => param.version);
1003
+
1004
+ function FriendsList() {
1005
+ const getSnapshot = React.useCallback(
1006
+ ({value}) => Array.from(value.friends),
1007
+ [],
1008
+ );
1009
+ const friends = useMutableSource(
1010
+ mutableSource,
1011
+ getSnapshot,
1012
+ defaultSubscribe,
1013
+ );
1014
+ return (
1015
+ <ul>
1016
+ {friends.map(friend => (
1017
+ <Friend key={friend.id} id={friend.id} />
1018
+ ))}
1019
+ </ul>
1020
+ );
1021
+ }
1022
+
1023
+ function Friend({id}) {
1024
+ const getSnapshot = React.useCallback(
1025
+ ({value}) => {
1026
+ // This selector is intentionally written in a way that will throw
1027
+ // if no matching friend exists in the store.
1028
+ return value.friends.find(friend => friend.id === id).name;
1029
+ },
1030
+ [id],
1031
+ );
1032
+ const name = useMutableSource(
1033
+ mutableSource,
1034
+ getSnapshot,
1035
+ defaultSubscribe,
1036
+ );
1037
+ Scheduler.unstable_yieldValue(`${id}:${name}`);
1038
+ return <li>{name}</li>;
1039
+ }
1040
+
1041
+ act(() => {
1042
+ ReactNoop.render(<FriendsList />, () =>
1043
+ Scheduler.unstable_yieldValue('Sync effect'),
1044
+ );
1045
+ expect(Scheduler).toFlushAndYield(['1:Foo', '2:Bar', 'Sync effect']);
1046
+
1047
+ // This mutation will cause the "Bar" component to throw,
1048
+ // since its value will no longer be a part of the store.
1049
+ // Mutable source should still schedule an update though,
1050
+ // which should unmount "Bar" and mount "Baz".
1051
+ source.value = {
1052
+ friends: [
1053
+ {id: 1, name: 'Foo'},
1054
+ {id: 3, name: 'Baz'},
1055
+ ],
1056
+ };
1057
+ expect(Scheduler).toFlushAndYield(['1:Foo', '3:Baz']);
1058
+ });
1059
+ });
1060
+
1061
+ it('should not warn about updates that fire between unmount and passive unsubscribe', () => {
1062
+ const source = createSource('one');
1063
+ const mutableSource = createMutableSource(source, param => param.version);
1064
+
1065
+ function Wrapper() {
1066
+ React.useLayoutEffect(() => () => {
1067
+ Scheduler.unstable_yieldValue('layout unmount');
1068
+ });
1069
+ return (
1070
+ <Component
1071
+ label="only"
1072
+ getSnapshot={defaultGetSnapshot}
1073
+ mutableSource={mutableSource}
1074
+ subscribe={defaultSubscribe}
1075
+ />
1076
+ );
1077
+ }
1078
+
1079
+ act(() => {
1080
+ ReactNoop.renderToRootWithID(<Wrapper />, 'root', () =>
1081
+ Scheduler.unstable_yieldValue('Sync effect'),
1082
+ );
1083
+ expect(Scheduler).toFlushAndYield(['only:one', 'Sync effect']);
1084
+ ReactNoop.flushPassiveEffects();
1085
+
1086
+ // Unmounting a root should remove the remaining event listeners in a passive effect
1087
+ ReactNoop.unmountRootWithID('root');
1088
+ expect(Scheduler).toFlushAndYieldThrough(['layout unmount']);
1089
+
1090
+ // Changes to source should not cause a warning,
1091
+ // even though the unsubscribe hasn't run yet (since it's a pending passive effect).
1092
+ source.value = 'two';
1093
+ expect(Scheduler).toFlushAndYield([]);
1094
+ });
1095
+ });
1096
+
1097
+ it('should support inline selectors and updates that are processed after selector change', async () => {
1098
+ const source = createSource({
1099
+ a: 'initial',
1100
+ b: 'initial',
1101
+ });
1102
+ const mutableSource = createMutableSource(source, param => param.version);
1103
+
1104
+ const getSnapshotA = () => source.value.a;
1105
+ const getSnapshotB = () => source.value.b;
1106
+
1107
+ function mutateB(newB) {
1108
+ source.value = {
1109
+ ...source.value,
1110
+ b: newB,
1111
+ };
1112
+ }
1113
+
1114
+ function App({getSnapshot}) {
1115
+ const state = useMutableSource(
1116
+ mutableSource,
1117
+ getSnapshot,
1118
+ defaultSubscribe,
1119
+ );
1120
+ return state;
1121
+ }
1122
+
1123
+ const root = ReactNoop.createRoot();
1124
+ await act(async () => {
1125
+ root.render(<App getSnapshot={getSnapshotA} />);
1126
+ });
1127
+ expect(root).toMatchRenderedOutput('initial');
1128
+
1129
+ await act(async () => {
1130
+ mutateB('Updated B');
1131
+ root.render(<App getSnapshot={getSnapshotB} />);
1132
+ });
1133
+ expect(root).toMatchRenderedOutput('Updated B');
1134
+
1135
+ await act(async () => {
1136
+ mutateB('Another update');
1137
+ });
1138
+ expect(root).toMatchRenderedOutput('Another update');
1139
+ });
1140
+
1141
+ it('should clear the update queue when getSnapshot changes with pending lower priority updates', async () => {
1142
+ const source = createSource({
1143
+ a: 'initial',
1144
+ b: 'initial',
1145
+ });
1146
+ const mutableSource = createMutableSource(source, param => param.version);
1147
+
1148
+ const getSnapshotA = () => source.value.a;
1149
+ const getSnapshotB = () => source.value.b;
1150
+
1151
+ function mutateA(newA) {
1152
+ source.value = {
1153
+ ...source.value,
1154
+ a: newA,
1155
+ };
1156
+ }
1157
+
1158
+ function mutateB(newB) {
1159
+ source.value = {
1160
+ ...source.value,
1161
+ b: newB,
1162
+ };
1163
+ }
1164
+
1165
+ function App({toggle}) {
1166
+ const state = useMutableSource(
1167
+ mutableSource,
1168
+ toggle ? getSnapshotB : getSnapshotA,
1169
+ defaultSubscribe,
1170
+ );
1171
+ const result = (toggle ? 'B: ' : 'A: ') + state;
1172
+ return result;
1173
+ }
1174
+
1175
+ const root = ReactNoop.createRoot();
1176
+ await act(async () => {
1177
+ root.render(<App toggle={false} />);
1178
+ });
1179
+ expect(root).toMatchRenderedOutput('A: initial');
1180
+
1181
+ await act(async () => {
1182
+ ReactNoop.discreteUpdates(() => {
1183
+ // Update both A and B to the same value
1184
+ mutateA('Update');
1185
+ mutateB('Update');
1186
+ // Toggle to B in the same batch
1187
+ root.render(<App toggle={true} />);
1188
+ });
1189
+ // Mutate A at lower priority. This should never be rendered, because
1190
+ // by the time we get to the lower priority, we've already switched
1191
+ // to B.
1192
+ mutateA('OOPS! This mutation should be ignored');
1193
+ });
1194
+ expect(root).toMatchRenderedOutput('B: Update');
1195
+ });
1196
+
1197
+ it('should clear the update queue when source changes with pending lower priority updates', async () => {
1198
+ const sourceA = createSource('initial');
1199
+ const sourceB = createSource('initial');
1200
+ const mutableSourceA = createMutableSource(
1201
+ sourceA,
1202
+ param => param.versionA,
1203
+ );
1204
+ const mutableSourceB = createMutableSource(
1205
+ sourceB,
1206
+ param => param.versionB,
1207
+ );
1208
+
1209
+ function App({toggle}) {
1210
+ const state = useMutableSource(
1211
+ toggle ? mutableSourceB : mutableSourceA,
1212
+ defaultGetSnapshot,
1213
+ defaultSubscribe,
1214
+ );
1215
+ const result = (toggle ? 'B: ' : 'A: ') + state;
1216
+ return result;
1217
+ }
1218
+
1219
+ const root = ReactNoop.createRoot();
1220
+ await act(async () => {
1221
+ root.render(<App toggle={false} />);
1222
+ });
1223
+ expect(root).toMatchRenderedOutput('A: initial');
1224
+
1225
+ await act(async () => {
1226
+ ReactNoop.discreteUpdates(() => {
1227
+ // Update both A and B to the same value
1228
+ sourceA.value = 'Update';
1229
+ sourceB.value = 'Update';
1230
+ // Toggle to B in the same batch
1231
+ root.render(<App toggle={true} />);
1232
+ });
1233
+ // Mutate A at lower priority. This should never be rendered, because
1234
+ // by the time we get to the lower priority, we've already switched
1235
+ // to B.
1236
+ sourceA.value = 'OOPS! This mutation should be ignored';
1237
+ });
1238
+ expect(root).toMatchRenderedOutput('B: Update');
1239
+ });
1240
+
1241
+ it('should always treat reading as potentially unsafe when getSnapshot changes between renders', async () => {
1242
+ const source = createSource({
1243
+ a: 'foo',
1244
+ b: 'bar',
1245
+ });
1246
+ const mutableSource = createMutableSource(source, param => param.version);
1247
+
1248
+ const getSnapshotA = () => source.value.a;
1249
+ const getSnapshotB = () => source.value.b;
1250
+
1251
+ function mutateA(newA) {
1252
+ source.value = {
1253
+ ...source.value,
1254
+ a: newA,
1255
+ };
1256
+ }
1257
+
1258
+ function App({getSnapshotFirst, getSnapshotSecond}) {
1259
+ const first = useMutableSource(
1260
+ mutableSource,
1261
+ getSnapshotFirst,
1262
+ defaultSubscribe,
1263
+ );
1264
+ const second = useMutableSource(
1265
+ mutableSource,
1266
+ getSnapshotSecond,
1267
+ defaultSubscribe,
1268
+ );
1269
+
1270
+ let result = `x: ${first}, y: ${second}`;
1271
+
1272
+ if (getSnapshotFirst === getSnapshotSecond) {
1273
+ // When both getSnapshot functions are equal,
1274
+ // the two values must be consistent.
1275
+ if (first !== second) {
1276
+ result = 'Oops, tearing!';
1277
+ }
1278
+ }
1279
+
1280
+ React.useEffect(() => {
1281
+ Scheduler.unstable_yieldValue(result);
1282
+ }, [result]);
1283
+
1284
+ return result;
1285
+ }
1286
+
1287
+ const root = ReactNoop.createRoot();
1288
+ await act(async () => {
1289
+ root.render(
1290
+ <App
1291
+ getSnapshotFirst={getSnapshotA}
1292
+ getSnapshotSecond={getSnapshotB}
1293
+ />,
1294
+ );
1295
+ });
1296
+ // x and y start out reading from different parts of the store.
1297
+ expect(Scheduler).toHaveYielded(['x: foo, y: bar']);
1298
+
1299
+ await act(async () => {
1300
+ ReactNoop.discreteUpdates(() => {
1301
+ // At high priority, toggle y so that it reads from A instead of B.
1302
+ // Simultaneously, mutate A.
1303
+ mutateA('baz');
1304
+ root.render(
1305
+ <App
1306
+ getSnapshotFirst={getSnapshotA}
1307
+ getSnapshotSecond={getSnapshotA}
1308
+ />,
1309
+ );
1310
+
1311
+ // If this update were processed before the next mutation,
1312
+ // it would be expected to yield "baz" and "baz".
1313
+ });
1314
+
1315
+ // At lower priority, mutate A again.
1316
+ // This happens to match the initial value of B.
1317
+ mutateA('bar');
1318
+
1319
+ // When this update is processed,
1320
+ // it is expected to yield "bar" and "bar".
1321
+ });
1322
+
1323
+ // Check that we didn't commit any inconsistent states.
1324
+ // The actual sequence of work will be:
1325
+ // 1. React renders the high-pri update, sees a new getSnapshot, detects the source has been further mutated, and throws
1326
+ // 2. React re-renders with all pending updates, including the second mutation, and renders "bar" and "bar".
1327
+ expect(Scheduler).toHaveYielded(['x: bar, y: bar']);
1328
+ });
1329
+
1330
+ it('getSnapshot changes and then source is mutated in between paint and passive effect phase', async () => {
1331
+ const source = createSource({
1332
+ a: 'foo',
1333
+ b: 'bar',
1334
+ });
1335
+ const mutableSource = createMutableSource(source, param => param.version);
1336
+
1337
+ function mutateB(newB) {
1338
+ source.value = {
1339
+ ...source.value,
1340
+ b: newB,
1341
+ };
1342
+ }
1343
+
1344
+ const getSnapshotA = () => source.value.a;
1345
+ const getSnapshotB = () => source.value.b;
1346
+
1347
+ function App({getSnapshot}) {
1348
+ const value = useMutableSource(
1349
+ mutableSource,
1350
+ getSnapshot,
1351
+ defaultSubscribe,
1352
+ );
1353
+
1354
+ Scheduler.unstable_yieldValue('Render: ' + value);
1355
+ React.useEffect(() => {
1356
+ Scheduler.unstable_yieldValue('Commit: ' + value);
1357
+ }, [value]);
1358
+
1359
+ return value;
1360
+ }
1361
+
1362
+ const root = ReactNoop.createRoot();
1363
+ await act(async () => {
1364
+ root.render(<App getSnapshot={getSnapshotA} />);
1365
+ });
1366
+ expect(Scheduler).toHaveYielded(['Render: foo', 'Commit: foo']);
1367
+
1368
+ await act(async () => {
1369
+ // Switch getSnapshot to read from B instead
1370
+ root.render(<App getSnapshot={getSnapshotB} />);
1371
+ // Render and finish the tree, but yield right after paint, before
1372
+ // the passive effects have fired.
1373
+ expect(Scheduler).toFlushUntilNextPaint(['Render: bar']);
1374
+ // Then mutate B.
1375
+ mutateB('baz');
1376
+ });
1377
+ expect(Scheduler).toHaveYielded([
1378
+ // Fires the effect from the previous render
1379
+ 'Commit: bar',
1380
+ // During that effect, it should detect that the snapshot has changed
1381
+ // and re-render.
1382
+ 'Render: baz',
1383
+ 'Commit: baz',
1384
+ ]);
1385
+ expect(root).toMatchRenderedOutput('baz');
1386
+ });
1387
+
1388
+ it('getSnapshot changes and then source is mutated in between paint and passive effect phase, case 2', async () => {
1389
+ const source = createSource({
1390
+ a: 'a0',
1391
+ b: 'b0',
1392
+ });
1393
+ const mutableSource = createMutableSource(source, param => param.version);
1394
+
1395
+ const getSnapshotA = () => source.value.a;
1396
+ const getSnapshotB = () => source.value.b;
1397
+
1398
+ function mutateA(newA) {
1399
+ source.value = {
1400
+ ...source.value,
1401
+ a: newA,
1402
+ };
1403
+ }
1404
+
1405
+ function App({getSnapshotFirst, getSnapshotSecond}) {
1406
+ const first = useMutableSource(
1407
+ mutableSource,
1408
+ getSnapshotFirst,
1409
+ defaultSubscribe,
1410
+ );
1411
+ const second = useMutableSource(
1412
+ mutableSource,
1413
+ getSnapshotSecond,
1414
+ defaultSubscribe,
1415
+ );
1416
+
1417
+ return `first: ${first}, second: ${second}`;
1418
+ }
1419
+
1420
+ const root = ReactNoop.createRoot();
1421
+ await act(async () => {
1422
+ root.render(
1423
+ <App
1424
+ getSnapshotFirst={getSnapshotA}
1425
+ getSnapshotSecond={getSnapshotB}
1426
+ />,
1427
+ );
1428
+ });
1429
+ expect(root.getChildrenAsJSX()).toEqual('first: a0, second: b0');
1430
+
1431
+ await act(async () => {
1432
+ // Switch the second getSnapshot to also read from A
1433
+ root.render(
1434
+ <App
1435
+ getSnapshotFirst={getSnapshotA}
1436
+ getSnapshotSecond={getSnapshotA}
1437
+ />,
1438
+ );
1439
+ // Render and finish the tree, but yield right after paint, before
1440
+ // the passive effects have fired.
1441
+ expect(Scheduler).toFlushUntilNextPaint([]);
1442
+
1443
+ // Now mutate A. Both hooks should update.
1444
+ // This is at high priority so that it doesn't get batched with default
1445
+ // priority updates that might fire during the passive effect
1446
+ await act(async () => {
1447
+ ReactNoop.discreteUpdates(() => {
1448
+ mutateA('a1');
1449
+ });
1450
+ });
1451
+
1452
+ expect(root).toMatchRenderedOutput('first: a1, second: a1');
1453
+ });
1454
+
1455
+ expect(root.getChildrenAsJSX()).toEqual('first: a1, second: a1');
1456
+ });
1457
+
1458
+ it(
1459
+ 'if source is mutated after initial read but before subscription is set ' +
1460
+ 'up, should still entangle all pending mutations even if snapshot of ' +
1461
+ 'new subscription happens to match',
1462
+ async () => {
1463
+ const source = createSource({
1464
+ a: 'a0',
1465
+ b: 'b0',
1466
+ });
1467
+ const mutableSource = createMutableSource(source, param => param.version);
1468
+
1469
+ const getSnapshotA = () => source.value.a;
1470
+ const getSnapshotB = () => source.value.b;
1471
+
1472
+ function mutateA(newA) {
1473
+ source.value = {
1474
+ ...source.value,
1475
+ a: newA,
1476
+ };
1477
+ }
1478
+
1479
+ function mutateB(newB) {
1480
+ source.value = {
1481
+ ...source.value,
1482
+ b: newB,
1483
+ };
1484
+ }
1485
+
1486
+ function Read({getSnapshot}) {
1487
+ const value = useMutableSource(
1488
+ mutableSource,
1489
+ getSnapshot,
1490
+ defaultSubscribe,
1491
+ );
1492
+ Scheduler.unstable_yieldValue(value);
1493
+ return value;
1494
+ }
1495
+
1496
+ function Text({text}) {
1497
+ Scheduler.unstable_yieldValue(text);
1498
+ return text;
1499
+ }
1500
+
1501
+ const root = ReactNoop.createRoot();
1502
+ await act(async () => {
1503
+ root.render(
1504
+ <>
1505
+ <Read getSnapshot={getSnapshotA} />
1506
+ </>,
1507
+ );
1508
+ });
1509
+ expect(Scheduler).toHaveYielded(['a0']);
1510
+ expect(root).toMatchRenderedOutput('a0');
1511
+
1512
+ await act(async () => {
1513
+ if (gate(flags => flags.enableSyncDefaultUpdates)) {
1514
+ React.startTransition(() => {
1515
+ root.render(
1516
+ <>
1517
+ <Read getSnapshot={getSnapshotA} />
1518
+ <Read getSnapshot={getSnapshotB} />
1519
+ <Text text="c" />
1520
+ </>,
1521
+ );
1522
+ });
1523
+ } else {
1524
+ root.render(
1525
+ <>
1526
+ <Read getSnapshot={getSnapshotA} />
1527
+ <Read getSnapshot={getSnapshotB} />
1528
+ <Text text="c" />
1529
+ </>,
1530
+ );
1531
+ }
1532
+
1533
+ expect(Scheduler).toFlushAndYieldThrough(['a0', 'b0']);
1534
+ // Mutate in an event. This schedules a subscription update on a, which
1535
+ // already mounted, but not b, which hasn't subscribed yet.
1536
+ mutateA('a1');
1537
+ mutateB('b1');
1538
+
1539
+ // Mutate again at lower priority. This will schedule another subscription
1540
+ // update on a, but not b. When b mounts and subscriptions, the value it
1541
+ // read during render will happen to match the latest value. But it should
1542
+ // still entangle the updates to prevent the previous update (a1) from
1543
+ // rendering by itself.
1544
+ React.startTransition(() => {
1545
+ mutateA('a0');
1546
+ mutateB('b0');
1547
+ });
1548
+ // Finish the current render
1549
+ expect(Scheduler).toFlushUntilNextPaint(['c']);
1550
+ // a0 will re-render because of the mutation update. But it should show
1551
+ // the latest value, not the intermediate one, to avoid tearing with b.
1552
+ expect(Scheduler).toFlushUntilNextPaint(['a0']);
1553
+
1554
+ expect(root).toMatchRenderedOutput('a0b0c');
1555
+ // We should be done.
1556
+ expect(Scheduler).toFlushAndYield([]);
1557
+ expect(root).toMatchRenderedOutput('a0b0c');
1558
+ });
1559
+ },
1560
+ );
1561
+
1562
+ it('warns about functions being used as snapshot values', async () => {
1563
+ const source = createSource(() => 'a');
1564
+ const mutableSource = createMutableSource(source, param => param.version);
1565
+
1566
+ const getSnapshot = () => source.value;
1567
+
1568
+ function Read() {
1569
+ const fn = useMutableSource(mutableSource, getSnapshot, defaultSubscribe);
1570
+ const value = fn();
1571
+ Scheduler.unstable_yieldValue(value);
1572
+ return value;
1573
+ }
1574
+
1575
+ const root = ReactNoop.createRoot();
1576
+ await act(async () => {
1577
+ root.render(
1578
+ <>
1579
+ <Read />
1580
+ </>,
1581
+ );
1582
+ expect(() => expect(Scheduler).toFlushAndYield(['a'])).toErrorDev(
1583
+ 'Mutable source should not return a function as the snapshot value.',
1584
+ );
1585
+ });
1586
+ expect(root).toMatchRenderedOutput('a');
1587
+ });
1588
+
1589
+ it('getSnapshot changes and then source is mutated during interleaved event', async () => {
1590
+ const {useEffect} = React;
1591
+
1592
+ const source = createComplexSource('1', '2');
1593
+ const mutableSource = createMutableSource(source, param => param.version);
1594
+
1595
+ // Subscribe to part of the store.
1596
+ const getSnapshotA = s => s.valueA;
1597
+ const subscribeA = (s, callback) => s.subscribeA(callback);
1598
+ const configA = [getSnapshotA, subscribeA];
1599
+
1600
+ const getSnapshotB = s => s.valueB;
1601
+ const subscribeB = (s, callback) => s.subscribeB(callback);
1602
+ const configB = [getSnapshotB, subscribeB];
1603
+
1604
+ function App({parentConfig, childConfig}) {
1605
+ const [getSnapshot, subscribe] = parentConfig;
1606
+ const parentValue = useMutableSource(
1607
+ mutableSource,
1608
+ getSnapshot,
1609
+ subscribe,
1610
+ );
1611
+
1612
+ Scheduler.unstable_yieldValue('Parent: ' + parentValue);
1613
+
1614
+ return (
1615
+ <Child
1616
+ parentConfig={parentConfig}
1617
+ childConfig={childConfig}
1618
+ parentValue={parentValue}
1619
+ />
1620
+ );
1621
+ }
1622
+
1623
+ function Child({parentConfig, childConfig, parentValue}) {
1624
+ const [getSnapshot, subscribe] = childConfig;
1625
+ const childValue = useMutableSource(
1626
+ mutableSource,
1627
+ getSnapshot,
1628
+ subscribe,
1629
+ );
1630
+
1631
+ Scheduler.unstable_yieldValue('Child: ' + childValue);
1632
+
1633
+ let result = `${parentValue}, ${childValue}`;
1634
+
1635
+ if (parentConfig === childConfig) {
1636
+ // When both components read using the same config, the two values
1637
+ // must be consistent.
1638
+ if (parentValue !== childValue) {
1639
+ result = 'Oops, tearing!';
1640
+ }
1641
+ }
1642
+
1643
+ useEffect(() => {
1644
+ Scheduler.unstable_yieldValue('Commit: ' + result);
1645
+ }, [result]);
1646
+
1647
+ return result;
1648
+ }
1649
+
1650
+ const root = ReactNoop.createRoot();
1651
+ await act(async () => {
1652
+ root.render(<App parentConfig={configA} childConfig={configB} />);
1653
+ });
1654
+ expect(Scheduler).toHaveYielded(['Parent: 1', 'Child: 2', 'Commit: 1, 2']);
1655
+
1656
+ await act(async () => {
1657
+ // Switch the parent and the child to read using the same config
1658
+ if (gate(flags => flags.enableSyncDefaultUpdates)) {
1659
+ React.startTransition(() => {
1660
+ root.render(<App parentConfig={configB} childConfig={configB} />);
1661
+ });
1662
+ } else {
1663
+ root.render(<App parentConfig={configB} childConfig={configB} />);
1664
+ }
1665
+ // Start rendering the parent, but yield before rendering the child
1666
+ expect(Scheduler).toFlushAndYieldThrough(['Parent: 2']);
1667
+
1668
+ // Mutate the config. This is at lower priority so that 1) to make sure
1669
+ // it doesn't happen to get batched with the in-progress render, and 2)
1670
+ // so it doesn't interrupt the in-progress render.
1671
+ React.startTransition(() => {
1672
+ source.valueB = '3';
1673
+ });
1674
+
1675
+ if (gate(flags => flags.enableSyncDefaultUpdates)) {
1676
+ // In default sync mode, all of the updates flush sync.
1677
+ expect(Scheduler).toFlushAndYieldThrough([
1678
+ // The partial render completes
1679
+ 'Child: 2',
1680
+ 'Commit: 2, 2',
1681
+ 'Parent: 3',
1682
+ 'Child: 3',
1683
+ ]);
1684
+
1685
+ expect(Scheduler).toFlushAndYield([
1686
+ // Now finish the rest of the update
1687
+ 'Commit: 3, 3',
1688
+ ]);
1689
+ } else {
1690
+ expect(Scheduler).toFlushAndYieldThrough([
1691
+ // The partial render completes
1692
+ 'Child: 2',
1693
+ 'Commit: 2, 2',
1694
+ ]);
1695
+
1696
+ // Now there are two pending mutations at different priorities. But they
1697
+ // both read the same version of the mutable source, so we must render
1698
+ // them simultaneously.
1699
+ //
1700
+ expect(Scheduler).toFlushAndYieldThrough([
1701
+ 'Parent: 3',
1702
+ // Demonstrates that we can yield here
1703
+ ]);
1704
+ expect(Scheduler).toFlushAndYield([
1705
+ // Now finish the rest of the update
1706
+ 'Child: 3',
1707
+ 'Commit: 3, 3',
1708
+ ]);
1709
+ }
1710
+ });
1711
+ });
1712
+
1713
+ it('should not tear with newly mounted component when updates were scheduled at a lower priority', async () => {
1714
+ const source = createSource('one');
1715
+ const mutableSource = createMutableSource(source, param => param.version);
1716
+
1717
+ let committedA = null;
1718
+ let committedB = null;
1719
+
1720
+ const onRender = () => {
1721
+ if (committedB !== null) {
1722
+ expect(committedA).toBe(committedB);
1723
+ }
1724
+ };
1725
+
1726
+ function ComponentA() {
1727
+ const snapshot = useMutableSource(
1728
+ mutableSource,
1729
+ defaultGetSnapshot,
1730
+ defaultSubscribe,
1731
+ );
1732
+ Scheduler.unstable_yieldValue(`a:${snapshot}`);
1733
+ React.useEffect(() => {
1734
+ committedA = snapshot;
1735
+ }, [snapshot]);
1736
+ return <div>{`a:${snapshot}`}</div>;
1737
+ }
1738
+ function ComponentB() {
1739
+ const snapshot = useMutableSource(
1740
+ mutableSource,
1741
+ defaultGetSnapshot,
1742
+ defaultSubscribe,
1743
+ );
1744
+ Scheduler.unstable_yieldValue(`b:${snapshot}`);
1745
+ React.useEffect(() => {
1746
+ committedB = snapshot;
1747
+ }, [snapshot]);
1748
+ return <div>{`b:${snapshot}`}</div>;
1749
+ }
1750
+
1751
+ // Mount ComponentA with data version 1
1752
+ act(() => {
1753
+ ReactNoop.render(
1754
+ <React.Profiler id="root" onRender={onRender}>
1755
+ <ComponentA />
1756
+ </React.Profiler>,
1757
+ () => Scheduler.unstable_yieldValue('Sync effect'),
1758
+ );
1759
+ });
1760
+ expect(Scheduler).toHaveYielded(['a:one', 'Sync effect']);
1761
+ expect(source.listenerCount).toBe(1);
1762
+
1763
+ // Mount ComponentB with version 1 (but don't commit it)
1764
+ act(() => {
1765
+ ReactNoop.render(
1766
+ <React.Profiler id="root" onRender={onRender}>
1767
+ <ComponentA />
1768
+ <ComponentB />
1769
+ </React.Profiler>,
1770
+ () => Scheduler.unstable_yieldValue('Sync effect'),
1771
+ );
1772
+ expect(Scheduler).toFlushAndYieldThrough([
1773
+ 'a:one',
1774
+ 'b:one',
1775
+ 'Sync effect',
1776
+ ]);
1777
+ expect(source.listenerCount).toBe(1);
1778
+
1779
+ // Mutate -> schedule update for ComponentA
1780
+ React.startTransition(() => {
1781
+ source.value = 'two';
1782
+ });
1783
+
1784
+ // Commit ComponentB -> notice the change and schedule an update for ComponentB
1785
+ expect(Scheduler).toFlushAndYield(['a:two', 'b:two']);
1786
+ expect(source.listenerCount).toBe(2);
1787
+ });
1788
+ });
1789
+
1790
+ if (__DEV__) {
1791
+ describe('dev warnings', () => {
1792
+ it('should warn if the subscribe function does not return an unsubscribe function', () => {
1793
+ const source = createSource('one');
1794
+ const mutableSource = createMutableSource(
1795
+ source,
1796
+ param => param.version,
1797
+ );
1798
+
1799
+ const brokenSubscribe = () => {};
1800
+
1801
+ expect(() => {
1802
+ act(() => {
1803
+ ReactNoop.render(
1804
+ <Component
1805
+ label="only"
1806
+ getSnapshot={defaultGetSnapshot}
1807
+ mutableSource={mutableSource}
1808
+ subscribe={brokenSubscribe}
1809
+ />,
1810
+ );
1811
+ });
1812
+ }).toErrorDev(
1813
+ 'Mutable source subscribe function must return an unsubscribe function.',
1814
+ );
1815
+ });
1816
+
1817
+ it('should error if multiple renderers of the same type use a mutable source at the same time', () => {
1818
+ const source = createSource('one');
1819
+ const mutableSource = createMutableSource(
1820
+ source,
1821
+ param => param.version,
1822
+ );
1823
+
1824
+ act(() => {
1825
+ // Start a render that uses the mutable source.
1826
+ if (gate(flags => flags.enableSyncDefaultUpdates)) {
1827
+ React.startTransition(() => {
1828
+ ReactNoop.render(
1829
+ <>
1830
+ <Component
1831
+ label="a"
1832
+ getSnapshot={defaultGetSnapshot}
1833
+ mutableSource={mutableSource}
1834
+ subscribe={defaultSubscribe}
1835
+ />
1836
+ <Component
1837
+ label="b"
1838
+ getSnapshot={defaultGetSnapshot}
1839
+ mutableSource={mutableSource}
1840
+ subscribe={defaultSubscribe}
1841
+ />
1842
+ </>,
1843
+ );
1844
+ });
1845
+ } else {
1846
+ ReactNoop.render(
1847
+ <>
1848
+ <Component
1849
+ label="a"
1850
+ getSnapshot={defaultGetSnapshot}
1851
+ mutableSource={mutableSource}
1852
+ subscribe={defaultSubscribe}
1853
+ />
1854
+ <Component
1855
+ label="b"
1856
+ getSnapshot={defaultGetSnapshot}
1857
+ mutableSource={mutableSource}
1858
+ subscribe={defaultSubscribe}
1859
+ />
1860
+ </>,
1861
+ );
1862
+ }
1863
+ expect(Scheduler).toFlushAndYieldThrough(['a:one']);
1864
+
1865
+ const PrevScheduler = Scheduler;
1866
+
1867
+ // Get a new copy of ReactNoop.
1868
+ loadModules();
1869
+
1870
+ spyOnDev(console, 'error');
1871
+
1872
+ // Use the mutablesource again but with a different renderer.
1873
+ ReactNoop.render(
1874
+ <Component
1875
+ label="c"
1876
+ getSnapshot={defaultGetSnapshot}
1877
+ mutableSource={mutableSource}
1878
+ subscribe={defaultSubscribe}
1879
+ />,
1880
+ );
1881
+ expect(Scheduler).toFlushAndYieldThrough(['c:one']);
1882
+
1883
+ expect(console.error.calls.argsFor(0)[0]).toContain(
1884
+ 'Detected multiple renderers concurrently rendering the ' +
1885
+ 'same mutable source. This is currently unsupported.',
1886
+ );
1887
+
1888
+ // TODO (useMutableSource) Act will automatically flush remaining work from render 1,
1889
+ // but at this point something in the hooks dispatcher has been broken by jest.resetModules()
1890
+ // Figure out what this is and remove this catch.
1891
+ expect(() =>
1892
+ PrevScheduler.unstable_flushAllWithoutAsserting(),
1893
+ ).toThrow('Invalid hook call');
1894
+ });
1895
+ });
1896
+
1897
+ it('should error if multiple renderers of the same type use a mutable source at the same time with mutation between', () => {
1898
+ const source = createSource('one');
1899
+ const mutableSource = createMutableSource(
1900
+ source,
1901
+ param => param.version,
1902
+ );
1903
+
1904
+ act(() => {
1905
+ // Start a render that uses the mutable source.
1906
+ if (gate(flags => flags.enableSyncDefaultUpdates)) {
1907
+ React.startTransition(() => {
1908
+ ReactNoop.render(
1909
+ <>
1910
+ <Component
1911
+ label="a"
1912
+ getSnapshot={defaultGetSnapshot}
1913
+ mutableSource={mutableSource}
1914
+ subscribe={defaultSubscribe}
1915
+ />
1916
+ <Component
1917
+ label="b"
1918
+ getSnapshot={defaultGetSnapshot}
1919
+ mutableSource={mutableSource}
1920
+ subscribe={defaultSubscribe}
1921
+ />
1922
+ </>,
1923
+ );
1924
+ });
1925
+ } else {
1926
+ ReactNoop.render(
1927
+ <>
1928
+ <Component
1929
+ label="a"
1930
+ getSnapshot={defaultGetSnapshot}
1931
+ mutableSource={mutableSource}
1932
+ subscribe={defaultSubscribe}
1933
+ />
1934
+ <Component
1935
+ label="b"
1936
+ getSnapshot={defaultGetSnapshot}
1937
+ mutableSource={mutableSource}
1938
+ subscribe={defaultSubscribe}
1939
+ />
1940
+ </>,
1941
+ );
1942
+ }
1943
+ expect(Scheduler).toFlushAndYieldThrough(['a:one']);
1944
+
1945
+ const PrevScheduler = Scheduler;
1946
+
1947
+ // Get a new copy of ReactNoop.
1948
+ loadModules();
1949
+
1950
+ spyOnDev(console, 'error');
1951
+
1952
+ // Mutate before the new render reads from the source.
1953
+ source.value = 'two';
1954
+
1955
+ // Use the mutablesource again but with a different renderer.
1956
+ ReactNoop.render(
1957
+ <Component
1958
+ label="c"
1959
+ getSnapshot={defaultGetSnapshot}
1960
+ mutableSource={mutableSource}
1961
+ subscribe={defaultSubscribe}
1962
+ />,
1963
+ );
1964
+ expect(Scheduler).toFlushAndYieldThrough(['c:two']);
1965
+
1966
+ expect(console.error.calls.argsFor(0)[0]).toContain(
1967
+ 'Detected multiple renderers concurrently rendering the ' +
1968
+ 'same mutable source. This is currently unsupported.',
1969
+ );
1970
+
1971
+ // TODO (useMutableSource) Act will automatically flush remaining work from render 1,
1972
+ // but at this point something in the hooks dispatcher has been broken by jest.resetModules()
1973
+ // Figure out what this is and remove this catch.
1974
+ expect(() =>
1975
+ PrevScheduler.unstable_flushAllWithoutAsserting(),
1976
+ ).toThrow('Invalid hook call');
1977
+ });
1978
+ });
1979
+ });
1980
+ }
1981
+});
packages/react-reconciler/src/__tests__/useMutableSourceHydration-test.js
new
+451
@@ -0,0 +1,451 @@
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
+ */
9
+
10
+'use strict';
11
+
12
+let React;
13
+let ReactDOM;
14
+let ReactDOMServer;
15
+let Scheduler;
16
+let act;
17
+let createMutableSource;
18
+let useMutableSource;
19
+
20
+describe('useMutableSourceHydration', () => {
21
+ beforeEach(() => {
22
+ jest.resetModules();
23
+
24
+ React = require('react');
25
+ ReactDOM = require('react-dom');
26
+ ReactDOMServer = require('react-dom/server');
27
+ Scheduler = require('scheduler');
28
+
29
+ act = require('jest-react').act;
30
+
31
+ // Stable entrypoints export with "unstable_" prefix.
32
+ createMutableSource =
33
+ React.createMutableSource || React.unstable_createMutableSource;
34
+ useMutableSource =
35
+ React.useMutableSource || React.unstable_useMutableSource;
36
+ });
37
+
38
+ function dispatchAndSetCurrentEvent(el, event) {
39
+ try {
40
+ window.event = event;
41
+ el.dispatchEvent(event);
42
+ } finally {
43
+ window.event = undefined;
44
+ }
45
+ }
46
+
47
+ const defaultGetSnapshot = source => source.value;
48
+ const defaultSubscribe = (source, callback) => source.subscribe(callback);
49
+
50
+ function createComplexSource(initialValueA, initialValueB) {
51
+ const callbacksA = [];
52
+ const callbacksB = [];
53
+ let revision = 0;
54
+ let valueA = initialValueA;
55
+ let valueB = initialValueB;
56
+
57
+ const subscribeHelper = (callbacks, callback) => {
58
+ if (callbacks.indexOf(callback) < 0) {
59
+ callbacks.push(callback);
60
+ }
61
+ return () => {
62
+ const index = callbacks.indexOf(callback);
63
+ if (index >= 0) {
64
+ callbacks.splice(index, 1);
65
+ }
66
+ };
67
+ };
68
+
69
+ return {
70
+ subscribeA(callback) {
71
+ return subscribeHelper(callbacksA, callback);
72
+ },
73
+ subscribeB(callback) {
74
+ return subscribeHelper(callbacksB, callback);
75
+ },
76
+
77
+ get listenerCountA() {
78
+ return callbacksA.length;
79
+ },
80
+ get listenerCountB() {
81
+ return callbacksB.length;
82
+ },
83
+
84
+ set valueA(newValue) {
85
+ revision++;
86
+ valueA = newValue;
87
+ callbacksA.forEach(callback => callback());
88
+ },
89
+ get valueA() {
90
+ return valueA;
91
+ },
92
+
93
+ set valueB(newValue) {
94
+ revision++;
95
+ valueB = newValue;
96
+ callbacksB.forEach(callback => callback());
97
+ },
98
+ get valueB() {
99
+ return valueB;
100
+ },
101
+
102
+ get version() {
103
+ return revision;
104
+ },
105
+ };
106
+ }
107
+
108
+ function createSource(initialValue) {
109
+ const callbacks = [];
110
+ let revision = 0;
111
+ let value = initialValue;
112
+ return {
113
+ subscribe(callback) {
114
+ if (callbacks.indexOf(callback) < 0) {
115
+ callbacks.push(callback);
116
+ }
117
+ return () => {
118
+ const index = callbacks.indexOf(callback);
119
+ if (index >= 0) {
120
+ callbacks.splice(index, 1);
121
+ }
122
+ };
123
+ },
124
+ get listenerCount() {
125
+ return callbacks.length;
126
+ },
127
+ set value(newValue) {
128
+ revision++;
129
+ value = newValue;
130
+ callbacks.forEach(callback => callback());
131
+ },
132
+ get value() {
133
+ return value;
134
+ },
135
+ get version() {
136
+ return revision;
137
+ },
138
+ };
139
+ }
140
+
141
+ function Component({getSnapshot, label, mutableSource, subscribe}) {
142
+ const snapshot = useMutableSource(mutableSource, getSnapshot, subscribe);
143
+ Scheduler.unstable_yieldValue(`${label}:${snapshot}`);
144
+ return <div>{`${label}:${snapshot}`}</div>;
145
+ }
146
+
147
+ it('should render and hydrate', () => {
148
+ const source = createSource('one');
149
+ const mutableSource = createMutableSource(source, param => param.version);
150
+
151
+ function TestComponent() {
152
+ return (
153
+ <Component
154
+ label="only"
155
+ getSnapshot={defaultGetSnapshot}
156
+ mutableSource={mutableSource}
157
+ subscribe={defaultSubscribe}
158
+ />
159
+ );
160
+ }
161
+
162
+ const container = document.createElement('div');
163
+ document.body.appendChild(container);
164
+
165
+ const htmlString = ReactDOMServer.renderToString(<TestComponent />);
166
+ container.innerHTML = htmlString;
167
+ expect(Scheduler).toHaveYielded(['only:one']);
168
+ expect(source.listenerCount).toBe(0);
169
+
170
+ const root = ReactDOM.createRoot(container, {
171
+ hydrate: true,
172
+ hydrationOptions: {
173
+ mutableSources: [mutableSource],
174
+ },
175
+ });
176
+ act(() => {
177
+ root.render(<TestComponent />);
178
+ });
179
+ expect(Scheduler).toHaveYielded(['only:one']);
180
+ expect(source.listenerCount).toBe(1);
181
+ });
182
+
183
+ it('should detect a tear before hydrating a component', () => {
184
+ const source = createSource('one');
185
+ const mutableSource = createMutableSource(source, param => param.version);
186
+
187
+ function TestComponent() {
188
+ return (
189
+ <Component
190
+ label="only"
191
+ getSnapshot={defaultGetSnapshot}
192
+ mutableSource={mutableSource}
193
+ subscribe={defaultSubscribe}
194
+ />
195
+ );
196
+ }
197
+
198
+ const container = document.createElement('div');
199
+ document.body.appendChild(container);
200
+
201
+ const htmlString = ReactDOMServer.renderToString(<TestComponent />);
202
+ container.innerHTML = htmlString;
203
+ expect(Scheduler).toHaveYielded(['only:one']);
204
+ expect(source.listenerCount).toBe(0);
205
+
206
+ const root = ReactDOM.createRoot(container, {
207
+ hydrate: true,
208
+ hydrationOptions: {
209
+ mutableSources: [mutableSource],
210
+ },
211
+ });
212
+ expect(() => {
213
+ act(() => {
214
+ root.render(<TestComponent />);
215
+
216
+ source.value = 'two';
217
+ });
218
+ }).toErrorDev(
219
+ 'Warning: An error occurred during hydration. ' +
220
+ 'The server HTML was replaced with client content in <div>.',
221
+ {withoutStack: true},
222
+ );
223
+ expect(Scheduler).toHaveYielded(['only:two']);
224
+ expect(source.listenerCount).toBe(1);
225
+ });
226
+
227
+ it('should detect a tear between hydrating components', () => {
228
+ const source = createSource('one');
229
+ const mutableSource = createMutableSource(source, param => param.version);
230
+
231
+ function TestComponent() {
232
+ return (
233
+ <>
234
+ <Component
235
+ label="a"
236
+ getSnapshot={defaultGetSnapshot}
237
+ mutableSource={mutableSource}
238
+ subscribe={defaultSubscribe}
239
+ />
240
+ <Component
241
+ label="b"
242
+ getSnapshot={defaultGetSnapshot}
243
+ mutableSource={mutableSource}
244
+ subscribe={defaultSubscribe}
245
+ />
246
+ </>
247
+ );
248
+ }
249
+
250
+ const container = document.createElement('div');
251
+ document.body.appendChild(container);
252
+
253
+ const htmlString = ReactDOMServer.renderToString(<TestComponent />);
254
+ container.innerHTML = htmlString;
255
+ expect(Scheduler).toHaveYielded(['a:one', 'b:one']);
256
+ expect(source.listenerCount).toBe(0);
257
+
258
+ const root = ReactDOM.createRoot(container, {
259
+ hydrate: true,
260
+ hydrationOptions: {
261
+ mutableSources: [mutableSource],
262
+ },
263
+ });
264
+ expect(() => {
265
+ act(() => {
266
+ if (gate(flags => flags.enableSyncDefaultUpdates)) {
267
+ React.startTransition(() => {
268
+ root.render(<TestComponent />);
269
+ });
270
+ } else {
271
+ root.render(<TestComponent />);
272
+ }
273
+ expect(Scheduler).toFlushAndYieldThrough(['a:one']);
274
+ source.value = 'two';
275
+ });
276
+ }).toErrorDev(
277
+ 'Warning: An error occurred during hydration. ' +
278
+ 'The server HTML was replaced with client content in <div>.',
279
+ {withoutStack: true},
280
+ );
281
+ expect(Scheduler).toHaveYielded(['a:two', 'b:two']);
282
+ expect(source.listenerCount).toBe(2);
283
+ });
284
+
285
+ it('should detect a tear between hydrating components reading from different parts of a source', () => {
286
+ const source = createComplexSource('a:one', 'b:one');
287
+ const mutableSource = createMutableSource(source, param => param.version);
288
+
289
+ // Subscribe to part of the store.
290
+ const getSnapshotA = s => s.valueA;
291
+ const subscribeA = (s, callback) => s.subscribeA(callback);
292
+ const getSnapshotB = s => s.valueB;
293
+ const subscribeB = (s, callback) => s.subscribeB(callback);
294
+
295
+ const container = document.createElement('div');
296
+ document.body.appendChild(container);
297
+
298
+ const htmlString = ReactDOMServer.renderToString(
299
+ <>
300
+ <Component
301
+ label="0"
302
+ getSnapshot={getSnapshotA}
303
+ mutableSource={mutableSource}
304
+ subscribe={subscribeA}
305
+ />
306
+ <Component
307
+ label="1"
308
+ getSnapshot={getSnapshotB}
309
+ mutableSource={mutableSource}
310
+ subscribe={subscribeB}
311
+ />
312
+ </>,
313
+ );
314
+ container.innerHTML = htmlString;
315
+ expect(Scheduler).toHaveYielded(['0:a:one', '1:b:one']);
316
+
317
+ const root = ReactDOM.createRoot(container, {
318
+ hydrate: true,
319
+ hydrationOptions: {
320
+ mutableSources: [mutableSource],
321
+ },
322
+ });
323
+ expect(() => {
324
+ act(() => {
325
+ if (gate(flags => flags.enableSyncDefaultUpdates)) {
326
+ React.startTransition(() => {
327
+ root.render(
328
+ <>
329
+ <Component
330
+ label="0"
331
+ getSnapshot={getSnapshotA}
332
+ mutableSource={mutableSource}
333
+ subscribe={subscribeA}
334
+ />
335
+ <Component
336
+ label="1"
337
+ getSnapshot={getSnapshotB}
338
+ mutableSource={mutableSource}
339
+ subscribe={subscribeB}
340
+ />
341
+ </>,
342
+ );
343
+ });
344
+ } else {
345
+ root.render(
346
+ <>
347
+ <Component
348
+ label="0"
349
+ getSnapshot={getSnapshotA}
350
+ mutableSource={mutableSource}
351
+ subscribe={subscribeA}
352
+ />
353
+ <Component
354
+ label="1"
355
+ getSnapshot={getSnapshotB}
356
+ mutableSource={mutableSource}
357
+ subscribe={subscribeB}
358
+ />
359
+ </>,
360
+ );
361
+ }
362
+ expect(Scheduler).toFlushAndYieldThrough(['0:a:one']);
363
+ source.valueB = 'b:two';
364
+ });
365
+ }).toErrorDev(
366
+ 'Warning: An error occurred during hydration. ' +
367
+ 'The server HTML was replaced with client content in <div>.',
368
+ {withoutStack: true},
369
+ );
370
+ expect(Scheduler).toHaveYielded(['0:a:one', '1:b:two']);
371
+ });
372
+
373
+ // @gate !enableSyncDefaultUpdates
374
+ it('should detect a tear during a higher priority interruption', () => {
375
+ const source = createSource('one');
376
+ const mutableSource = createMutableSource(source, param => param.version);
377
+
378
+ function Unrelated({flag}) {
379
+ Scheduler.unstable_yieldValue(flag);
380
+ return flag;
381
+ }
382
+
383
+ function TestComponent({flag}) {
384
+ return (
385
+ <>
386
+ <Unrelated flag={flag} />
387
+ <Component
388
+ label="a"
389
+ getSnapshot={defaultGetSnapshot}
390
+ mutableSource={mutableSource}
391
+ subscribe={defaultSubscribe}
392
+ />
393
+ </>
394
+ );
395
+ }
396
+
397
+ const container = document.createElement('div');
398
+ document.body.appendChild(container);
399
+
400
+ const htmlString = ReactDOMServer.renderToString(
401
+ <TestComponent flag={1} />,
402
+ );
403
+ container.innerHTML = htmlString;
404
+ expect(Scheduler).toHaveYielded([1, 'a:one']);
405
+ expect(source.listenerCount).toBe(0);
406
+
407
+ const root = ReactDOM.createRoot(container, {
408
+ hydrate: true,
409
+ hydrationOptions: {
410
+ mutableSources: [mutableSource],
411
+ },
412
+ });
413
+
414
+ expect(() => {
415
+ act(() => {
416
+ if (gate(flags => flags.enableSyncDefaultUpdates)) {
417
+ React.startTransition(() => {
418
+ root.render(<TestComponent flag={1} />);
419
+ });
420
+ } else {
421
+ root.render(<TestComponent flag={1} />);
422
+ }
423
+ expect(Scheduler).toFlushAndYieldThrough([1]);
424
+
425
+ // Render an update which will be higher priority than the hydration.
426
+ // We can do this by scheduling the update inside a mouseover event.
427
+ const arbitraryElement = document.createElement('div');
428
+ const mouseOverEvent = document.createEvent('MouseEvents');
429
+ mouseOverEvent.initEvent('mouseover', true, true);
430
+ arbitraryElement.addEventListener('mouseover', () => {
431
+ root.render(<TestComponent flag={2} />);
432
+ });
433
+ dispatchAndSetCurrentEvent(arbitraryElement, mouseOverEvent);
434
+
435
+ expect(Scheduler).toFlushAndYieldThrough([2]);
436
+
437
+ source.value = 'two';
438
+ });
439
+ }).toErrorDev(
440
+ [
441
+ 'Warning: An error occurred during hydration. ' +
442
+ 'The server HTML was replaced with client content in <div>.',
443
+
444
+ 'Warning: Text content did not match. Server: "1" Client: "2"',
445
+ ],
446
+ {withoutStack: 1},
447
+ );
448
+ expect(Scheduler).toHaveYielded([2, 'a:two']);
449
+ expect(source.listenerCount).toBe(1);
450
+ });
451
+});
packages/react-server/src/ReactFizzHooks.js
+20
-1
@@ -9,7 +9,12 @@
9
10
import type {Dispatcher as DispatcherType} from 'react-reconciler/src/ReactInternalTypes';
11
12
-import type {ReactContext} from 'shared/ReactTypes';
12
+import type {
13
+ MutableSource,
14
+ MutableSourceGetSnapshotFn,
15
+ MutableSourceSubscribeFn,
16
+ ReactContext,
17
+} from 'shared/ReactTypes';
18
19
import type {ResponseState, OpaqueIDType} from './ReactServerFormatConfig';
20
@@ -444,6 +449,18 @@ export function useCallback<T>(
449
return useMemo(() => callback, deps);
450
}
451
452
+// TODO Decide on how to implement this hook for server rendering.
453
+// If a mutation occurs during render, consider triggering a Suspense boundary
454
+// and falling back to client rendering.
455
+function useMutableSource<Source, Snapshot>(
456
+ source: MutableSource<Source>,
457
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
458
+ subscribe: MutableSourceSubscribeFn<Source, Snapshot>,
459
+): Snapshot {
460
+ resolveCurrentlyRenderingComponent();
461
+ return getSnapshot(source._source);
462
+}
463
+
464
function useSyncExternalStore<T>(
465
subscribe: (() => void) => () => void,
466
getSnapshot: () => T,
@@ -506,6 +523,8 @@ export const Dispatcher: DispatcherType = {
523
useDeferredValue,
524
useTransition,
525
useOpaqueIdentifier,
526
+ // Subscriptions are not setup in a server environment.
527
+ useMutableSource,
528
useSyncExternalStore,
529
};
530
packages/react-server/src/ReactFlightServer.js
+1
@@ -827,6 +827,7 @@ const Dispatcher: DispatcherType = {
827
useImperativeHandle: (unsupportedHook: any),
828
useEffect: (unsupportedHook: any),
829
useOpaqueIdentifier: (unsupportedHook: any),
830
+ useMutableSource: (unsupportedHook: any),
831
useSyncExternalStore: (unsupportedHook: any),
832
useCacheRefresh(): <T>(?() => T, ?T) => void {
833
return unsupportedRefresh;
packages/react-suspense-test-utils/src/ReactSuspenseTestUtils.js
+1
@@ -44,6 +44,7 @@ export function waitForSuspense<T>(fn: () => T): Promise<T> {
44
useDeferredValue: unsupported,
45
useTransition: unsupported,
46
useOpaqueIdentifier: unsupported,
47
+ useMutableSource: unsupported,
48
useSyncExternalStore: unsupported,
49
useCacheRefresh: unsupported,
50
};
packages/react/index.classic.fb.js
+4
@@ -23,6 +23,8 @@ export {
23
createContext,
24
createElement,
25
createFactory,
26
+ createMutableSource,
27
+ createMutableSource as unstable_createMutableSource,
28
createRef,
29
forwardRef,
30
isValidElement,
@@ -48,6 +50,8 @@ export {
50
useLayoutEffect,
51
unstable_useInsertionEffect,
52
useMemo,
53
+ useMutableSource,
54
+ useMutableSource as unstable_useMutableSource,
55
useSyncExternalStore,
56
useSyncExternalStore as unstable_useSyncExternalStore,
57
useReducer,
packages/react/index.experimental.js
+2
@@ -22,6 +22,7 @@ export {
22
createContext,
23
createElement,
24
createFactory,
25
+ createMutableSource as unstable_createMutableSource,
26
createRef,
27
forwardRef,
28
isValidElement,
@@ -44,6 +45,7 @@ export {
45
unstable_useInsertionEffect,
46
useLayoutEffect,
47
useMemo,
48
+ useMutableSource as unstable_useMutableSource,
49
useSyncExternalStore as unstable_useSyncExternalStore,
50
useReducer,
51
useRef,
packages/react/index.js
+2
@@ -46,6 +46,7 @@ export {
46
createContext,
47
createElement,
48
createFactory,
49
+ createMutableSource,
50
createRef,
51
forwardRef,
52
isValidElement,
@@ -69,6 +70,7 @@ export {
70
unstable_useInsertionEffect,
71
useLayoutEffect,
72
useMemo,
73
+ useMutableSource,
74
useSyncExternalStore,
75
useSyncExternalStore as unstable_useSyncExternalStore,
76
useReducer,
packages/react/index.modern.fb.js
+4
@@ -22,6 +22,8 @@ export {
22
cloneElement,
23
createContext,
24
createElement,
25
+ createMutableSource,
26
+ createMutableSource as unstable_createMutableSource,
27
createRef,
28
forwardRef,
29
isValidElement,
@@ -47,6 +49,8 @@ export {
49
unstable_useInsertionEffect,
50
useLayoutEffect,
51
useMemo,
52
+ useMutableSource,
53
+ useMutableSource as unstable_useMutableSource,
54
useSyncExternalStore,
55
useSyncExternalStore as unstable_useSyncExternalStore,
56
useReducer,
packages/react/index.stable.js
+2
@@ -22,6 +22,7 @@ export {
22
createContext,
23
createElement,
24
createFactory,
25
+ createMutableSource as unstable_createMutableSource,
26
createRef,
27
forwardRef,
28
isValidElement,
@@ -37,6 +38,7 @@ export {
38
useImperativeHandle,
39
useLayoutEffect,
40
useMemo,
41
+ useMutableSource as unstable_useMutableSource,
42
useReducer,
43
useRef,
44
useState,
packages/react/src/React.js
+4
@@ -44,6 +44,7 @@ import {
44
useInsertionEffect,
45
useLayoutEffect,
46
useMemo,
47
+ useMutableSource,
48
useSyncExternalStore,
49
useReducer,
50
useRef,
@@ -58,6 +59,7 @@ import {
59
createFactoryWithValidation,
60
cloneElementWithValidation,
61
} from './ReactElementValidator';
62
+import {createMutableSource} from './ReactMutableSource';
63
import ReactSharedInternals from './ReactSharedInternals';
64
import {startTransition} from './ReactStartTransition';
65
import {act} from './ReactAct';
@@ -77,6 +79,7 @@ const Children = {
79
80
export {
81
Children,
82
+ createMutableSource,
83
createRef,
84
Component,
85
PureComponent,
@@ -92,6 +95,7 @@ export {
95
useInsertionEffect as unstable_useInsertionEffect,
96
useLayoutEffect,
97
useMemo,
98
+ useMutableSource,
99
useSyncExternalStore,
100
useReducer,
101
useRef,
packages/react/src/ReactHooks.js
+15
-1
@@ -8,7 +8,12 @@
8
*/
9
10
import type {Dispatcher} from 'react-reconciler/src/ReactInternalTypes';
11
-import type {ReactContext} from 'shared/ReactTypes';
11
+import type {
12
+ MutableSource,
13
+ MutableSourceGetSnapshotFn,
14
+ MutableSourceSubscribeFn,
15
+ ReactContext,
16
+} from 'shared/ReactTypes';
17
import type {OpaqueIDType} from 'react-reconciler/src/ReactFiberHostConfig';
18
19
import ReactCurrentDispatcher from './ReactCurrentDispatcher';
@@ -163,6 +168,15 @@ export function useOpaqueIdentifier(): OpaqueIDType | void {
168
return dispatcher.useOpaqueIdentifier();
169
}
170
171
+export function useMutableSource<Source, Snapshot>(
172
+ source: MutableSource<Source>,
173
+ getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
174
+ subscribe: MutableSourceSubscribeFn<Source, Snapshot>,
175
+): Snapshot {
176
+ const dispatcher = resolveDispatcher();
177
+ return dispatcher.useMutableSource(source, getSnapshot, subscribe);
178
+}
179
+
180
export function useSyncExternalStore<T>(
181
subscribe: (() => void) => () => void,
182
getSnapshot: () => T,
packages/react/src/ReactMutableSource.js
new
+34
@@ -0,0 +1,34 @@
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 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
+ // Used to detect side effects that update a mutable source during render.
28
+ // See https://github.com/facebook/react/issues/19948
29
+ mutableSource._currentlyRenderingFiber = null;
30
+ mutableSource._initialVersionAsOfFirstRender = null;
31
+ }
32
+
33
+ return mutableSource;
34
+}
packages/react/unstable-shared-subset.experimental.js
+2
@@ -17,6 +17,7 @@ export {
17
SuspenseList,
18
cloneElement,
19
createElement,
20
+ createMutableSource as unstable_createMutableSource,
21
createRef,
22
forwardRef,
23
isValidElement,
@@ -32,6 +33,7 @@ export {
33
useDeferredValue,
34
useDeferredValue as unstable_useDeferredValue,
35
useMemo,
36
+ useMutableSource as unstable_useMutableSource,
37
useTransition,
38
version,
39
} from './src/React';
packages/shared/ReactTypes.js
+50
@@ -101,6 +101,56 @@ export type ReactScopeInstance = {|
101
getChildContextValues: <T>(context: ReactContext<T>) => Array<T>,
102
|};
103
104
+// Mutable source version can be anything (e.g. number, string, immutable data structure)
105
+// so long as it changes every time any part of the source changes.
106
+export type MutableSourceVersion = $NonMaybeType<mixed>;
107
+
108
+export type MutableSourceGetSnapshotFn<
109
+ Source: $NonMaybeType<mixed>,
110
+ Snapshot,
111
+> = (source: Source) => Snapshot;
112
+
113
+export type MutableSourceSubscribeFn<Source: $NonMaybeType<mixed>, Snapshot> = (
114
+ source: Source,
115
+ callback: (snapshot: Snapshot) => void,
116
+) => () => void;
117
+
118
+export type MutableSourceGetVersionFn = (
119
+ source: $NonMaybeType<mixed>,
120
+) => MutableSourceVersion;
121
+
122
+export type MutableSource<Source: $NonMaybeType<mixed>> = {|
123
+ _source: Source,
124
+
125
+ _getVersion: MutableSourceGetVersionFn,
126
+
127
+ // Tracks the version of this source at the time it was most recently read.
128
+ // Used to determine if a source is safe to read from before it has been subscribed to.
129
+ // Version number is only used during mount,
130
+ // since the mechanism for determining safety after subscription is expiration time.
131
+ //
132
+ // As a workaround to support multiple concurrent renderers,
133
+ // we categorize some renderers as primary and others as secondary.
134
+ // We only expect there to be two concurrent renderers at most:
135
+ // React Native (primary) and Fabric (secondary);
136
+ // React DOM (primary) and React ART (secondary).
137
+ // Secondary renderers store their context values on separate fields.
138
+ // We use the same approach for Context.
139
+ _workInProgressVersionPrimary: null | MutableSourceVersion,
140
+ _workInProgressVersionSecondary: null | MutableSourceVersion,
141
+
142
+ // DEV only
143
+ // Used to detect multiple renderers using the same mutable source.
144
+ _currentPrimaryRenderer?: Object | null,
145
+ _currentSecondaryRenderer?: Object | null,
146
+
147
+ // DEV only
148
+ // Used to detect side effects that update a mutable source during render.
149
+ // See https://github.com/facebook/react/issues/19948
150
+ _currentlyRenderingFiber?: Fiber | null,
151
+ _initialVersionAsOfFirstRender?: MutableSourceVersion | null,
152
+|};
153
+
154
// The subset of a Thenable required by things thrown by Suspense.
155
// This doesn't require a value to be passed to either handler.
156
export interface Wakeable {