@samitouri / QOS-React-2 / commits / 82c8fa90be

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 {