@samitouri / QOS-React-2 / commits / 322cdcd3ab

useMutableSource hook (#18000)

useMutableSource hook useMutableSource() enables React components to safely and efficiently read from a mutable external source in Concurrent Mode. The API will detect mutations that occur during a render to avoid tearing and it will automatically schedule updates when the source is mutated. RFC: reactjs/rfcs#147

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