From 322cdcd3abfaca985a001a12247f02c5d31d311e Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Wed, 11 Mar 2020 12:34:39 -0700 Subject: [PATCH] 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 --- .../react-debug-tools/src/ReactDebugHooks.js | 31 + .../ReactHooksInspectionIntegration-test.js | 34 + .../src/server/ReactPartialRendererHooks.js | 17 + .../src/ReactFiberBeginWork.js | 4 +- .../src/ReactFiberClassComponent.js | 8 +- .../src/ReactFiberCompleteWork.js | 2 + .../react-reconciler/src/ReactFiberHooks.js | 312 +++- .../src/ReactFiberHotReloading.js | 4 +- .../src/ReactFiberReconciler.js | 18 +- .../react-reconciler/src/ReactFiberRoot.js | 8 + .../src/ReactFiberUnwindWork.js | 3 + .../src/ReactFiberWorkLoop.js | 5 +- .../src/ReactMutableSource.js | 120 ++ ...eactHooksWithNoopRenderer-test.internal.js | 13 +- .../useMutableSource-test.internal.js | 1409 +++++++++++++++++ packages/react/index.classic.fb.js | 2 + packages/react/index.experimental.js | 2 + packages/react/index.js | 2 + packages/react/index.modern.fb.js | 2 + packages/react/src/React.js | 4 + packages/react/src/ReactHooks.js | 12 + packages/react/src/createMutableSource.js | 29 + packages/shared/ReactTypes.js | 44 + scripts/error-codes/codes.json | 4 +- 24 files changed, 2062 insertions(+), 27 deletions(-) create mode 100644 packages/react-reconciler/src/ReactMutableSource.js create mode 100644 packages/react-reconciler/src/__tests__/useMutableSource-test.internal.js create mode 100644 packages/react/src/createMutableSource.js diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index db93a50e6f..ae7c0b87d3 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -8,6 +8,9 @@ */ import type { + MutableSource, + MutableSourceGetSnapshotFn, + MutableSourceSubscribeFn, ReactContext, ReactProviderType, ReactEventResponder, @@ -72,6 +75,16 @@ function getPrimitiveStackCache(): Map> { Dispatcher.useDebugValue(null); Dispatcher.useCallback(() => {}); Dispatcher.useMemo(() => null); + Dispatcher.useMutableSource( + { + _source: {}, + _getVersion: () => 1, + _workInProgressVersionPrimary: null, + _workInProgressVersionSecondary: null, + }, + () => null, + () => () => {}, + ); } finally { readHookLog = hookLog; hookLog = []; @@ -229,6 +242,23 @@ function useMemo( return value; } +function useMutableSource( + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn, + subscribe: MutableSourceSubscribeFn, +): Snapshot { + // useMutableSource() composes multiple hooks internally. + // Advance the current hook index the same number of times + // so that subsequent hooks have the right memoized state. + nextHook(); // MutableSource + nextHook(); // State + nextHook(); // Effect + nextHook(); // Effect + const value = getSnapshot(source._source); + hookLog.push({primitive: 'MutableSource', stackError: new Error(), value}); + return value; +} + function useResponder( responder: ReactEventResponder, listenerProps: Object, @@ -299,6 +329,7 @@ const Dispatcher: DispatcherType = { useState, useResponder, useTransition, + useMutableSource, useDeferredValue, useEvent, }; diff --git a/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js b/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js index f92191beaf..8bece8f1ca 100644 --- a/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js +++ b/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js @@ -785,4 +785,38 @@ describe('ReactHooksInspectionIntegration', () => { }, ]); }); + + if (__EXPERIMENTAL__) { + it('should support composite useMutableSource hook', () => { + const mutableSource = React.createMutableSource({}, () => 1); + function Foo(props) { + React.useMutableSource( + mutableSource, + () => 'snapshot', + () => {}, + ); + React.useMemo(() => 'memo', []); + return
; + } + let renderer = ReactTestRenderer.create(); + let childFiber = renderer.root.findByType(Foo)._currentFiber(); + let tree = ReactDebugTools.inspectHooksOfFiber(childFiber); + expect(tree).toEqual([ + { + id: 0, + isStateEditable: false, + name: 'MutableSource', + value: 'snapshot', + subHooks: [], + }, + { + id: 1, + isStateEditable: false, + name: 'Memo', + value: 'memo', + subHooks: [], + }, + ]); + }); + } }); diff --git a/packages/react-dom/src/server/ReactPartialRendererHooks.js b/packages/react-dom/src/server/ReactPartialRendererHooks.js index 932ea3cef3..ca480f8ed5 100644 --- a/packages/react-dom/src/server/ReactPartialRendererHooks.js +++ b/packages/react-dom/src/server/ReactPartialRendererHooks.js @@ -13,6 +13,9 @@ import type { } from 'react-reconciler/src/ReactFiberHooks'; import type {ThreadID} from './ReactThreadIDAllocator'; import type { + MutableSource, + MutableSourceGetSnapshotFn, + MutableSourceSubscribeFn, ReactContext, ReactEventResponderListener, } from 'shared/ReactTypes'; @@ -461,6 +464,18 @@ function useResponder(responder, props): ReactEventResponderListener { }; } +// TODO Decide on how to implement this hook for server rendering. +// If a mutation occurs during render, consider triggering a Suspense boundary +// and falling back to client rendering. +function useMutableSource( + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn, + subscribe: MutableSourceSubscribeFn, +): Snapshot { + resolveCurrentlyRenderingComponent(); + return getSnapshot(source._source); +} + function useDeferredValue(value: T, config: TimeoutConfig | null | void): T { resolveCurrentlyRenderingComponent(); return value; @@ -510,4 +525,6 @@ export const Dispatcher: DispatcherType = { useDeferredValue, useTransition, useEvent, + // Subscriptions are not setup in a server environment. + useMutableSource, }; diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 8a02eaaf61..99dc79eb2f 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -180,7 +180,7 @@ import { markSpawnedWork, requestCurrentTimeForUpdate, retryDehydratedSuspenseBoundary, - scheduleWork, + scheduleUpdateOnFiber, renderDidSuspendDelayIfPossible, markUnprocessedUpdateTime, } from './ReactFiberWorkLoop'; @@ -2121,7 +2121,7 @@ function updateDehydratedSuspenseComponent( // at even higher pri. let attemptHydrationAtExpirationTime = renderExpirationTime + 1; suspenseState.retryTime = attemptHydrationAtExpirationTime; - scheduleWork(current, attemptHydrationAtExpirationTime); + scheduleUpdateOnFiber(current, attemptHydrationAtExpirationTime); // TODO: Early abort this render. } else { // We have already tried to ping at a higher priority than we're rendering with diff --git a/packages/react-reconciler/src/ReactFiberClassComponent.js b/packages/react-reconciler/src/ReactFiberClassComponent.js index ffe007e437..6073f7aa2c 100644 --- a/packages/react-reconciler/src/ReactFiberClassComponent.js +++ b/packages/react-reconciler/src/ReactFiberClassComponent.js @@ -53,7 +53,7 @@ import {readContext} from './ReactFiberNewContext'; import { requestCurrentTimeForUpdate, computeExpirationForFiber, - scheduleWork, + scheduleUpdateOnFiber, } from './ReactFiberWorkLoop'; import {requestCurrentSuspenseConfig} from './ReactFiberSuspenseConfig'; @@ -200,7 +200,7 @@ const classComponentUpdater = { } enqueueUpdate(fiber, update); - scheduleWork(fiber, expirationTime); + scheduleUpdateOnFiber(fiber, expirationTime); }, enqueueReplaceState(inst, payload, callback) { const fiber = getInstance(inst); @@ -224,7 +224,7 @@ const classComponentUpdater = { } enqueueUpdate(fiber, update); - scheduleWork(fiber, expirationTime); + scheduleUpdateOnFiber(fiber, expirationTime); }, enqueueForceUpdate(inst, callback) { const fiber = getInstance(inst); @@ -247,7 +247,7 @@ const classComponentUpdater = { } enqueueUpdate(fiber, update); - scheduleWork(fiber, expirationTime); + scheduleUpdateOnFiber(fiber, expirationTime); }, }; diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.js b/packages/react-reconciler/src/ReactFiberCompleteWork.js index e7ecaf3343..7c04efe58f 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.js @@ -26,6 +26,7 @@ import type { SuspenseListRenderState, } from './ReactFiberSuspenseComponent'; import type {SuspenseContext} from './ReactFiberSuspenseContext'; +import {resetWorkInProgressVersions as resetMutableSourceWorkInProgressVersions} from './ReactMutableSource'; import {now} from './SchedulerWithReactIntegration'; @@ -662,6 +663,7 @@ function completeWork( case HostRoot: { popHostContainer(workInProgress); popTopLevelLegacyContextObject(workInProgress); + resetMutableSourceWorkInProgressVersions(); const fiberRoot = (workInProgress.stateNode: FiberRoot); if (fiberRoot.pendingContext) { fiberRoot.context = fiberRoot.pendingContext; diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index dc98402f64..a9a7b5a9b5 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -8,6 +8,9 @@ */ import type { + MutableSource, + MutableSourceGetSnapshotFn, + MutableSourceSubscribeFn, ReactEventResponder, ReactContext, ReactEventResponderListener, @@ -17,6 +20,7 @@ import type {ExpirationTime} from './ReactFiberExpirationTime'; import type {HookEffectTag} from './ReactHookEffectTags'; import type {SuspenseConfig} from './ReactFiberSuspenseConfig'; import type {ReactPriorityLevel} from './SchedulerWithReactIntegration'; +import type {FiberRoot} from './ReactFiberRoot'; import type { ReactListenerEvent, ReactListenerMap, @@ -39,7 +43,8 @@ import { Passive as HookPassive, } from './ReactHookEffectTags'; import { - scheduleWork, + getWorkInProgressRoot, + scheduleUpdateOnFiber, computeExpirationForFiber, requestCurrentTimeForUpdate, warnIfNotCurrentlyActingEffectsInDEV, @@ -60,6 +65,14 @@ import { runWithPriority, getCurrentPriorityLevel, } from './SchedulerWithReactIntegration'; +import { + getPendingExpirationTime, + getWorkInProgressVersion, + markSourceAsDirty, + setPendingExpirationTime, + setWorkInProgressVersion, + warnAboutMultipleRenderersDEV, +} from './ReactMutableSource'; const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals; @@ -103,6 +116,11 @@ export type Dispatcher = {| useTransition( config: SuspenseConfig | void | null, ): [(() => void) => void, boolean], + useMutableSource( + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn, + subscribe: MutableSourceSubscribeFn, + ): Snapshot, useEvent(event: ReactListenerEvent): ReactListenerMap, |}; @@ -137,6 +155,7 @@ export type HookType = | 'useResponder' | 'useDeferredValue' | 'useTransition' + | 'useMutableSource' | 'useEvent'; let didWarnAboutMismatchedHooksForComponent; @@ -855,6 +874,225 @@ function rerenderReducer( return [newState, dispatch]; } +type MutableSourceMemoizedState = {| + refs: { + getSnapshot: MutableSourceGetSnapshotFn, + }, + source: MutableSource, + subscribe: MutableSourceSubscribeFn, +|}; + +function readFromUnsubcribedMutableSource( + root: FiberRoot, + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn, +): Snapshot { + if (__DEV__) { + warnAboutMultipleRenderersDEV(source); + } + + const getVersion = source._getVersion; + const version = getVersion(source._source); + + // Is it safe for this component to read from this source during the current render? + let isSafeToReadFromSource = false; + + // Check the version first. + // If this render has already been started with a specific version, + // we can use it alone to determine if we can safely read from the source. + const currentRenderVersion = getWorkInProgressVersion(source); + if (currentRenderVersion !== null) { + isSafeToReadFromSource = currentRenderVersion === version; + } else { + // If there's no version, then we should fallback to checking the update time. + const pendingExpirationTime = getPendingExpirationTime(root); + + if (pendingExpirationTime === NoWork) { + isSafeToReadFromSource = true; + } else { + // If the source has pending updates, we can use the current render's expiration + // time to determine if it's safe to read again from the source. + isSafeToReadFromSource = + pendingExpirationTime === NoWork || + pendingExpirationTime >= renderExpirationTime; + } + + if (isSafeToReadFromSource) { + // If it's safe to read from this source during the current render, + // store the version in case other components read from it. + // A changed version number will let those components know to throw and restart the render. + setWorkInProgressVersion(source, version); + } + } + + if (isSafeToReadFromSource) { + return getSnapshot(source._source); + } else { + // This handles the special case of a mutable source being shared beween renderers. + // In that case, if the source is mutated between the first and second renderer, + // The second renderer don't know that it needs to reset the WIP version during unwind, + // (because the hook only marks sources as dirty if it's written to their WIP version). + // That would cause this tear check to throw again and eventually be visible to the user. + // We can avoid this infinite loop by explicitly marking the source as dirty. + // + // This can lead to tearing in the first renderer when it resumes, + // but there's nothing we can do about that (short of throwing here and refusing to continue the render). + markSourceAsDirty(source); + + invariant( + false, + 'Cannot read from mutable source during the current render without tearing. This is a bug in React. Please file an issue.', + ); + } +} + +function useMutableSource( + hook: Hook, + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn, + subscribe: MutableSourceSubscribeFn, +): Snapshot { + const root = ((getWorkInProgressRoot(): any): FiberRoot); + invariant( + root !== null, + 'Expected a work-in-progress root. This is a bug in React. Please file an issue.', + ); + + const getVersion = source._getVersion; + const version = getVersion(source._source); + + const dispatcher = ReactCurrentDispatcher.current; + + let [snapshot, setSnapshot] = dispatcher.useState(() => + readFromUnsubcribedMutableSource(root, source, getSnapshot), + ); + + // Grab a handle to the state hook as well. + // We use it to clear the pending update queue if we have a new source. + const stateHook = ((workInProgressHook: any): Hook); + + const memoizedState = ((hook.memoizedState: any): MutableSourceMemoizedState< + Source, + Snapshot, + >); + const refs = memoizedState.refs; + const prevGetSnapshot = refs.getSnapshot; + const prevSource = memoizedState.source; + const prevSubscribe = memoizedState.subscribe; + + const fiber = currentlyRenderingFiber; + + hook.memoizedState = ({ + refs, + source, + subscribe, + }: MutableSourceMemoizedState); + + // Sync the values needed by our subscribe function after each commit. + dispatcher.useEffect(() => { + refs.getSnapshot = getSnapshot; + }, [getSnapshot]); + + // If we got a new source or subscribe function, + // we'll need to subscribe in a passive effect, + // and also check for any changes that fire between render and subscribe. + dispatcher.useEffect(() => { + const handleChange = () => { + const latestGetSnapshot = refs.getSnapshot; + try { + setSnapshot(latestGetSnapshot(source._source)); + + // Record a pending mutable source update with the same expiration time. + const currentTime = requestCurrentTimeForUpdate(); + const suspenseConfig = requestCurrentSuspenseConfig(); + const expirationTime = computeExpirationForFiber( + currentTime, + fiber, + suspenseConfig, + ); + + setPendingExpirationTime(root, expirationTime); + } catch (error) { + // A selector might throw after a source mutation. + // e.g. it might try to read from a part of the store that no longer exists. + // In this case we should still schedule an update with React. + // Worst case the selector will throw again and then an error boundary will handle it. + setSnapshot(() => { + throw error; + }); + } + }; + + const unsubscribe = subscribe(source._source, handleChange); + if (__DEV__) { + if (typeof unsubscribe !== 'function') { + console.error( + 'Mutable source subscribe function must return an unsubscribe function.', + ); + } + } + + // Check for a possible change between when we last rendered and when we just subscribed. + const maybeNewVersion = getVersion(source._source); + if (!is(version, maybeNewVersion)) { + const maybeNewSnapshot = getSnapshot(source._source); + if (!is(snapshot, maybeNewSnapshot)) { + setSnapshot(maybeNewSnapshot); + } + } + + return unsubscribe; + }, [source, subscribe]); + + // If any of the inputs to useMutableSource change, reading is potentially unsafe. + // + // If either the source or the subscription have changed we can't can't trust the update queue. + // Maybe the source changed in a way that the old subscription ignored but the new one depends on. + // + // If the getSnapshot function changed, we also shouldn't rely on the update queue. + // It's possible that the underlying source was mutated between the when the last "change" event fired, + // and when the current render (with the new getSnapshot function) is processed. + // + // In both cases, we need to throw away pending udpates (since they are no longer relevant) + // and treat reading from the source as we do in the mount case. + if ( + !is(prevSource, source) || + !is(prevSubscribe, subscribe) || + !is(prevGetSnapshot, getSnapshot) + ) { + stateHook.baseQueue = null; + snapshot = readFromUnsubcribedMutableSource(root, source, getSnapshot); + stateHook.memoizedState = stateHook.baseState = snapshot; + } + + return snapshot; +} + +function mountMutableSource( + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn, + subscribe: MutableSourceSubscribeFn, +): Snapshot { + const hook = mountWorkInProgressHook(); + hook.memoizedState = ({ + refs: { + getSnapshot, + }, + source, + subscribe, + }: MutableSourceMemoizedState); + return useMutableSource(hook, source, getSnapshot, subscribe); +} + +function updateMutableSource( + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn, + subscribe: MutableSourceSubscribeFn, +): Snapshot { + const hook = updateWorkInProgressHook(); + return useMutableSource(hook, source, getSnapshot, subscribe); +} + function mountState( initialState: (() => S) | S, ): [S, Dispatch>] { @@ -1383,7 +1621,7 @@ function dispatchAction( warnIfNotCurrentlyActingUpdatesInDev(fiber); } } - scheduleWork(fiber, expirationTime); + scheduleUpdateOnFiber(fiber, expirationTime); } } @@ -1474,6 +1712,7 @@ export const ContextOnlyDispatcher: Dispatcher = { useResponder: throwInvalidHookError, useDeferredValue: throwInvalidHookError, useTransition: throwInvalidHookError, + useMutableSource: throwInvalidHookError, useEvent: throwInvalidHookError, }; @@ -1493,6 +1732,7 @@ const HooksDispatcherOnMount: Dispatcher = { useResponder: createDeprecatedResponderListener, useDeferredValue: mountDeferredValue, useTransition: mountTransition, + useMutableSource: mountMutableSource, useEvent: mountEventListener, }; @@ -1512,6 +1752,7 @@ const HooksDispatcherOnUpdate: Dispatcher = { useResponder: createDeprecatedResponderListener, useDeferredValue: updateDeferredValue, useTransition: updateTransition, + useMutableSource: updateMutableSource, useEvent: updateEventListener, }; @@ -1531,6 +1772,7 @@ const HooksDispatcherOnRerender: Dispatcher = { useResponder: createDeprecatedResponderListener, useDeferredValue: rerenderDeferredValue, useTransition: rerenderTransition, + useMutableSource: updateMutableSource, useEvent: updateEventListener, }; @@ -1681,6 +1923,15 @@ if (__DEV__) { mountHookTypesDev(); return mountTransition(config); }, + useMutableSource( + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn, + subscribe: MutableSourceSubscribeFn, + ): Snapshot { + currentHookNameInDev = 'useMutableSource'; + mountHookTypesDev(); + return mountMutableSource(source, getSnapshot, subscribe); + }, useEvent(event: ReactListenerEvent): ReactListenerMap { currentHookNameInDev = 'useEvent'; mountHookTypesDev(); @@ -1803,6 +2054,15 @@ if (__DEV__) { updateHookTypesDev(); return mountTransition(config); }, + useMutableSource( + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn, + subscribe: MutableSourceSubscribeFn, + ): Snapshot { + currentHookNameInDev = 'useMutableSource'; + updateHookTypesDev(); + return mountMutableSource(source, getSnapshot, subscribe); + }, useEvent(event: ReactListenerEvent): ReactListenerMap { currentHookNameInDev = 'useEvent'; updateHookTypesDev(); @@ -1925,6 +2185,15 @@ if (__DEV__) { updateHookTypesDev(); return updateTransition(config); }, + useMutableSource( + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn, + subscribe: MutableSourceSubscribeFn, + ): Snapshot { + currentHookNameInDev = 'useMutableSource'; + updateHookTypesDev(); + return updateMutableSource(source, getSnapshot, subscribe); + }, useEvent(event: ReactListenerEvent): ReactListenerMap { currentHookNameInDev = 'useEvent'; updateHookTypesDev(); @@ -2047,6 +2316,15 @@ if (__DEV__) { updateHookTypesDev(); return rerenderTransition(config); }, + useMutableSource( + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn, + subscribe: MutableSourceSubscribeFn, + ): Snapshot { + currentHookNameInDev = 'useMutableSource'; + updateHookTypesDev(); + return updateMutableSource(source, getSnapshot, subscribe); + }, useEvent(event: ReactListenerEvent): ReactListenerMap { currentHookNameInDev = 'useEvent'; updateHookTypesDev(); @@ -2183,6 +2461,16 @@ if (__DEV__) { mountHookTypesDev(); return mountTransition(config); }, + useMutableSource( + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn, + subscribe: MutableSourceSubscribeFn, + ): Snapshot { + currentHookNameInDev = 'useMutableSource'; + warnInvalidHookAccess(); + mountHookTypesDev(); + return mountMutableSource(source, getSnapshot, subscribe); + }, useEvent(event: ReactListenerEvent): ReactListenerMap { currentHookNameInDev = 'useEvent'; warnInvalidHookAccess(); @@ -2320,6 +2608,16 @@ if (__DEV__) { updateHookTypesDev(); return updateTransition(config); }, + useMutableSource( + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn, + subscribe: MutableSourceSubscribeFn, + ): Snapshot { + currentHookNameInDev = 'useMutableSource'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateMutableSource(source, getSnapshot, subscribe); + }, useEvent(event: ReactListenerEvent): ReactListenerMap { currentHookNameInDev = 'useEvent'; warnInvalidHookAccess(); @@ -2457,6 +2755,16 @@ if (__DEV__) { updateHookTypesDev(); return rerenderTransition(config); }, + useMutableSource( + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn, + subscribe: MutableSourceSubscribeFn, + ): Snapshot { + currentHookNameInDev = 'useMutableSource'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateMutableSource(source, getSnapshot, subscribe); + }, useEvent(event: ReactListenerEvent): ReactListenerMap { currentHookNameInDev = 'useEvent'; warnInvalidHookAccess(); diff --git a/packages/react-reconciler/src/ReactFiberHotReloading.js b/packages/react-reconciler/src/ReactFiberHotReloading.js index ec280e9656..7fc670813b 100644 --- a/packages/react-reconciler/src/ReactFiberHotReloading.js +++ b/packages/react-reconciler/src/ReactFiberHotReloading.js @@ -15,7 +15,7 @@ import type {ReactNodeList} from 'shared/ReactTypes'; import { flushSync, - scheduleWork, + scheduleUpdateOnFiber, flushPassiveEffects, } from './ReactFiberWorkLoop'; import {updateContainer, syncUpdates} from './ReactFiberReconciler'; @@ -319,7 +319,7 @@ function scheduleFibersWithFamiliesRecursively( fiber._debugNeedsRemount = true; } if (needsRemount || needsRender) { - scheduleWork(fiber, Sync); + scheduleUpdateOnFiber(fiber, Sync); } if (child !== null && !needsRemount) { scheduleFibersWithFamiliesRecursively( diff --git a/packages/react-reconciler/src/ReactFiberReconciler.js b/packages/react-reconciler/src/ReactFiberReconciler.js index 96ddef2f3c..48a57fdb75 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.js @@ -51,7 +51,7 @@ import {injectInternals, onScheduleRoot} from './ReactFiberDevToolsHook'; import { requestCurrentTimeForUpdate, computeExpirationForFiber, - scheduleWork, + scheduleUpdateOnFiber, flushRoot, batchedEventUpdates, batchedUpdates, @@ -294,7 +294,7 @@ export function updateContainer( } enqueueUpdate(current, update); - scheduleWork(current, expirationTime); + scheduleUpdateOnFiber(current, expirationTime); return expirationTime; } @@ -338,7 +338,7 @@ export function attemptSynchronousHydration(fiber: Fiber): void { } break; case SuspenseComponent: - flushSync(() => scheduleWork(fiber, Sync)); + flushSync(() => scheduleUpdateOnFiber(fiber, Sync)); // If we're still blocked after this, we need to increase // the priority of any promises resolving within this // boundary so that they next attempt also has higher pri. @@ -377,7 +377,7 @@ export function attemptUserBlockingHydration(fiber: Fiber): void { return; } let expTime = computeInteractiveExpiration(requestCurrentTimeForUpdate()); - scheduleWork(fiber, expTime); + scheduleUpdateOnFiber(fiber, expTime); markRetryTimeIfNotHydrated(fiber, expTime); } @@ -389,7 +389,7 @@ export function attemptContinuousHydration(fiber: Fiber): void { // Suspense. return; } - scheduleWork(fiber, ContinuousHydration); + scheduleUpdateOnFiber(fiber, ContinuousHydration); markRetryTimeIfNotHydrated(fiber, ContinuousHydration); } @@ -401,7 +401,7 @@ export function attemptHydrationAtCurrentPriority(fiber: Fiber): void { } const currentTime = requestCurrentTimeForUpdate(); const expTime = computeExpirationForFiber(currentTime, fiber, null); - scheduleWork(fiber, expTime); + scheduleUpdateOnFiber(fiber, expTime); markRetryTimeIfNotHydrated(fiber, expTime); } @@ -484,7 +484,7 @@ if (__DEV__) { // Shallow cloning props works as a workaround for now to bypass the bailout check. fiber.memoizedProps = {...fiber.memoizedProps}; - scheduleWork(fiber, Sync); + scheduleUpdateOnFiber(fiber, Sync); } }; @@ -494,11 +494,11 @@ if (__DEV__) { if (fiber.alternate) { fiber.alternate.pendingProps = fiber.pendingProps; } - scheduleWork(fiber, Sync); + scheduleUpdateOnFiber(fiber, Sync); }; scheduleUpdate = (fiber: Fiber) => { - scheduleWork(fiber, Sync); + scheduleUpdateOnFiber(fiber, Sync); }; setSuspenseHandler = (newShouldSuspendImpl: Fiber => boolean) => { diff --git a/packages/react-reconciler/src/ReactFiberRoot.js b/packages/react-reconciler/src/ReactFiberRoot.js index e0de5ecfd8..2201bbb998 100644 --- a/packages/react-reconciler/src/ReactFiberRoot.js +++ b/packages/react-reconciler/src/ReactFiberRoot.js @@ -26,6 +26,7 @@ import { import {unstable_getThreadID} from 'scheduler/tracing'; import {NoPriority} from './SchedulerWithReactIntegration'; import {initializeUpdateQueue} from './ReactUpdateQueue'; +import {clearPendingUpdates as clearPendingMutableSourceUpdates} from './ReactMutableSource'; export type PendingInteractionMap = Map>; @@ -74,6 +75,9 @@ type BaseFiberRootProperties = {| // render again lastPingedTime: ExpirationTime, lastExpiredTime: ExpirationTime, + // Used by useMutableSource hook to avoid tearing within this root + // when external, mutable sources are read from during render. + mutableSourcePendingUpdateTime: ExpirationTime, |}; // The following attributes are only used by interaction tracing builds. @@ -123,6 +127,7 @@ function FiberRootNode(containerInfo, tag, hydrate) { this.nextKnownPendingLevel = NoWork; this.lastPingedTime = NoWork; this.lastExpiredTime = NoWork; + this.mutableSourcePendingUpdateTime = NoWork; if (enableSchedulerTracing) { this.interactionThreadID = unstable_getThreadID(); @@ -249,6 +254,9 @@ export function markRootFinishedAtTime( // Clear the expired time root.lastExpiredTime = NoWork; } + + // Clear any pending updates that were just processed. + clearPendingMutableSourceUpdates(root, finishedExpirationTime); } export function markRootExpiredAtTime( diff --git a/packages/react-reconciler/src/ReactFiberUnwindWork.js b/packages/react-reconciler/src/ReactFiberUnwindWork.js index 8ce3870468..2bd46b9831 100644 --- a/packages/react-reconciler/src/ReactFiberUnwindWork.js +++ b/packages/react-reconciler/src/ReactFiberUnwindWork.js @@ -11,6 +11,7 @@ import type {Fiber} from './ReactFiber'; import type {ExpirationTime} from './ReactFiberExpirationTime'; import type {SuspenseState} from './ReactFiberSuspenseComponent'; +import {resetWorkInProgressVersions as resetMutableSourceWorkInProgressVersions} from './ReactMutableSource'; import { ClassComponent, HostRoot, @@ -55,6 +56,7 @@ function unwindWork( case HostRoot: { popHostContainer(workInProgress); popTopLevelLegacyContextObject(workInProgress); + resetMutableSourceWorkInProgressVersions(); const effectTag = workInProgress.effectTag; invariant( (effectTag & DidCapture) === NoEffect, @@ -120,6 +122,7 @@ function unwindInterruptedWork(interruptedWork: Fiber) { case HostRoot: { popHostContainer(interruptedWork); popTopLevelLegacyContextObject(interruptedWork); + resetMutableSourceWorkInProgressVersions(); break; } case HostComponent: { diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index a9a5354544..e092de3440 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -298,6 +298,10 @@ let spawnedWorkDuringRender: null | Array = null; // receive the same expiration time. Otherwise we get tearing. let currentEventTime: ExpirationTime = NoWork; +export function getWorkInProgressRoot(): FiberRoot | null { + return workInProgressRoot; +} + export function requestCurrentTimeForUpdate() { if ((executionContext & (RenderContext | CommitContext)) !== NoContext) { // We're inside React, so it's fine to read the actual time. @@ -451,7 +455,6 @@ export function scheduleUpdateOnFiber( } } } -export const scheduleWork = scheduleUpdateOnFiber; // This is split into a separate function so we can mark a fiber with pending // work without treating it as a typical update that originates from an event; diff --git a/packages/react-reconciler/src/ReactMutableSource.js b/packages/react-reconciler/src/ReactMutableSource.js new file mode 100644 index 0000000000..e0cf6857d8 --- /dev/null +++ b/packages/react-reconciler/src/ReactMutableSource.js @@ -0,0 +1,120 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {ExpirationTime} from 'react-reconciler/src/ReactFiberExpirationTime'; +import type {FiberRoot} from 'react-reconciler/src/ReactFiberRoot'; +import type {MutableSource, MutableSourceVersion} from 'shared/ReactTypes'; + +import {isPrimaryRenderer} from './ReactFiberHostConfig'; +import {NoWork} from './ReactFiberExpirationTime'; + +// Work in progress version numbers only apply to a single render, +// and should be reset before starting a new render. +// This tracks which mutable sources need to be reset after a render. +let workInProgressPrimarySources: Array> = []; +let workInProgressSecondarySources: Array> = []; + +let rendererSigil; +if (__DEV__) { + // Used to detect multiple renderers using the same mutable source. + rendererSigil = {}; +} + +export function clearPendingUpdates( + root: FiberRoot, + expirationTime: ExpirationTime, +): void { + if (root.mutableSourcePendingUpdateTime <= expirationTime) { + root.mutableSourcePendingUpdateTime = NoWork; + } +} + +export function getPendingExpirationTime(root: FiberRoot): ExpirationTime { + return root.mutableSourcePendingUpdateTime; +} + +export function setPendingExpirationTime( + root: FiberRoot, + expirationTime: ExpirationTime, +): void { + root.mutableSourcePendingUpdateTime = expirationTime; +} + +export function markSourceAsDirty(mutableSource: MutableSource): void { + if (isPrimaryRenderer) { + workInProgressPrimarySources.push(mutableSource); + } else { + workInProgressSecondarySources.push(mutableSource); + } +} + +export function resetWorkInProgressVersions(): void { + if (isPrimaryRenderer) { + for (let i = 0; i < workInProgressPrimarySources.length; i++) { + const mutableSource = workInProgressPrimarySources[i]; + mutableSource._workInProgressVersionPrimary = null; + } + workInProgressPrimarySources.length = 0; + } else { + for (let i = 0; i < workInProgressSecondarySources.length; i++) { + const mutableSource = workInProgressSecondarySources[i]; + mutableSource._workInProgressVersionSecondary = null; + } + workInProgressSecondarySources.length = 0; + } +} + +export function getWorkInProgressVersion( + mutableSource: MutableSource, +): null | MutableSourceVersion { + if (isPrimaryRenderer) { + return mutableSource._workInProgressVersionPrimary; + } else { + return mutableSource._workInProgressVersionSecondary; + } +} + +export function setWorkInProgressVersion( + mutableSource: MutableSource, + version: MutableSourceVersion, +): void { + if (isPrimaryRenderer) { + mutableSource._workInProgressVersionPrimary = version; + workInProgressPrimarySources.push(mutableSource); + } else { + mutableSource._workInProgressVersionSecondary = version; + workInProgressSecondarySources.push(mutableSource); + } +} + +export function warnAboutMultipleRenderersDEV( + mutableSource: MutableSource, +): void { + if (__DEV__) { + if (isPrimaryRenderer) { + if (mutableSource._currentPrimaryRenderer == null) { + mutableSource._currentPrimaryRenderer = rendererSigil; + } else if (mutableSource._currentPrimaryRenderer !== rendererSigil) { + console.error( + 'Detected multiple renderers concurrently rendering the ' + + 'same mutable source. This is currently unsupported.', + ); + } + } else { + if (mutableSource._currentSecondaryRenderer == null) { + mutableSource._currentSecondaryRenderer = rendererSigil; + } else if (mutableSource._currentSecondaryRenderer !== rendererSigil) { + console.error( + 'Detected multiple renderers concurrently rendering the ' + + 'same mutable source. This is currently unsupported.', + ); + } + } + } +} diff --git a/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js b/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js index 81b774bd0a..7682ebc1fa 100644 --- a/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js @@ -44,6 +44,7 @@ function loadModules({ ReactFeatureFlags.flushSuspenseFallbacksInTests = false; ReactFeatureFlags.deferPassiveEffectCleanupDuringUnmount = deferPassiveEffectCleanupDuringUnmount; ReactFeatureFlags.runAllPassiveEffectDestroysBeforeCreates = runAllPassiveEffectDestroysBeforeCreates; + ReactFeatureFlags.enableProfilerTimer = true; React = require('react'); ReactNoop = require('react-noop-renderer'); Scheduler = require('scheduler'); @@ -1901,10 +1902,10 @@ function loadModules({ } act(() => { ReactNoop.render( - + <> - , + , () => Scheduler.unstable_yieldValue('Sync effect'), ); expect(Scheduler).toFlushAndYieldThrough([ @@ -1922,10 +1923,10 @@ function loadModules({ act(() => { ReactNoop.render( - + <> - , + , () => Scheduler.unstable_yieldValue('Sync effect'), ); expect(Scheduler).toFlushAndYieldThrough([ @@ -1947,10 +1948,10 @@ function loadModules({ act(() => { ReactNoop.render( - + <> - , + , () => Scheduler.unstable_yieldValue('Sync effect'), ); expect(Scheduler).toFlushAndYieldThrough([ diff --git a/packages/react-reconciler/src/__tests__/useMutableSource-test.internal.js b/packages/react-reconciler/src/__tests__/useMutableSource-test.internal.js new file mode 100644 index 0000000000..3d875ffc89 --- /dev/null +++ b/packages/react-reconciler/src/__tests__/useMutableSource-test.internal.js @@ -0,0 +1,1409 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + * @jest-environment node + */ + +/* eslint-disable no-func-assign */ + +'use strict'; + +let React; +let ReactFeatureFlags; +let ReactNoop; +let Scheduler; +let useMutableSource; +let act; + +function loadModules() { + jest.resetModules(); + jest.useFakeTimers(); + + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false; + ReactFeatureFlags.enableSchedulerTracing = true; + ReactFeatureFlags.flushSuspenseFallbacksInTests = false; + ReactFeatureFlags.enableProfilerTimer = true; + React = require('react'); + ReactNoop = require('react-noop-renderer'); + Scheduler = require('scheduler'); + useMutableSource = React.useMutableSource; + act = ReactNoop.act; +} + +describe('useMutableSource', () => { + const defaultGetSnapshot = source => source.value; + const defaultSubscribe = (source, callback) => source.subscribe(callback); + + function createComplexSource(initialValueA, initialValueB) { + const callbacksA = []; + const callbacksB = []; + let revision = 0; + let valueA = 'a:one'; + let valueB = 'b:one'; + + const subscribeHelper = (callbacks, callback) => { + if (callbacks.indexOf(callback) < 0) { + callbacks.push(callback); + } + return () => { + const index = callbacks.indexOf(callback); + if (index >= 0) { + callbacks.splice(index, 1); + } + }; + }; + + return { + subscribeA(callback) { + return subscribeHelper(callbacksA, callback); + }, + subscribeB(callback) { + return subscribeHelper(callbacksB, callback); + }, + + get listenerCountA() { + return callbacksA.length; + }, + get listenerCountB() { + return callbacksB.length; + }, + + set valueA(newValue) { + revision++; + valueA = newValue; + callbacksA.forEach(callback => callback()); + }, + get valueA() { + return valueA; + }, + + set valueB(newValue) { + revision++; + valueB = newValue; + callbacksB.forEach(callback => callback()); + }, + get valueB() { + return valueB; + }, + + get version() { + return revision; + }, + }; + } + + function createSource(initialValue) { + const callbacks = []; + let revision = 0; + let value = initialValue; + return { + subscribe(callback) { + if (callbacks.indexOf(callback) < 0) { + callbacks.push(callback); + } + return () => { + const index = callbacks.indexOf(callback); + if (index >= 0) { + callbacks.splice(index, 1); + } + }; + }, + get listenerCount() { + return callbacks.length; + }, + set value(newValue) { + revision++; + value = newValue; + callbacks.forEach(callback => callback()); + }, + get value() { + return value; + }, + get version() { + return revision; + }, + }; + } + + function createMutableSource(source) { + return React.createMutableSource(source, param => param.version); + } + + function Component({getSnapshot, label, mutableSource, subscribe}) { + const snapshot = useMutableSource(mutableSource, getSnapshot, subscribe); + Scheduler.unstable_yieldValue(`${label}:${snapshot}`); + return
{`${label}:${snapshot}`}
; + } + + if (!__EXPERIMENTAL__) { + it("empty test so Jest doesn't complain", () => {}); + } else { + beforeEach(loadModules); + + it('should subscribe to a source and schedule updates when it changes', () => { + const source = createSource('one'); + const mutableSource = createMutableSource(source); + + act(() => { + ReactNoop.renderToRootWithID( + <> + + + , + 'root', + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'a:one', + 'b:one', + 'Sync effect', + ]); + + // Subscriptions should be passive + expect(source.listenerCount).toBe(0); + ReactNoop.flushPassiveEffects(); + expect(source.listenerCount).toBe(2); + + // Changing values should schedule an update with React + source.value = 'two'; + expect(Scheduler).toFlushAndYieldThrough(['a:two', 'b:two']); + + // Umounting a component should remove its subscriptino. + ReactNoop.renderToRootWithID( + <> + + , + 'root', + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYield(['a:two', 'Sync effect']); + ReactNoop.flushPassiveEffects(); + expect(source.listenerCount).toBe(1); + + // Umounting a root should remove the remaining event listeners + ReactNoop.unmountRootWithID('root'); + expect(Scheduler).toFlushAndYield([]); + ReactNoop.flushPassiveEffects(); + expect(source.listenerCount).toBe(0); + + // Changes to source should not trigger an updates or warnings. + source.value = 'three'; + expect(Scheduler).toFlushAndYield([]); + }); + }); + + it('should restart work if a new source is mutated during render', () => { + const source = createSource('one'); + const mutableSource = createMutableSource(source); + + act(() => { + ReactNoop.render( + <> + + + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + + // Do enough work to read from one component + expect(Scheduler).toFlushAndYieldThrough(['a:one']); + + // Mutate source before continuing work + source.value = 'two'; + + // Render work should restart and the updated value should be used + expect(Scheduler).toFlushAndYield(['a:two', 'b:two', 'Sync effect']); + }); + }); + + it('should schedule an update if a new source is mutated between render and commit (subscription)', () => { + const source = createSource('one'); + const mutableSource = createMutableSource(source); + + act(() => { + ReactNoop.render( + <> + + + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + + // Finish rendering + expect(Scheduler).toFlushAndYieldThrough([ + 'a:one', + 'b:one', + 'Sync effect', + ]); + + // Mutate source before subscriptions are attached + expect(source.listenerCount).toBe(0); + source.value = 'two'; + + // Mutation should be detected, and a new render should be scheduled + expect(Scheduler).toFlushAndYield(['a:two', 'b:two']); + }); + }); + + it('should unsubscribe and resubscribe if a new source is used', () => { + const sourceA = createSource('a-one'); + const mutableSourceA = createMutableSource(sourceA); + + const sourceB = createSource('b-one'); + const mutableSourceB = createMutableSource(sourceB); + + act(() => { + ReactNoop.render( + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough(['only:a-one', 'Sync effect']); + ReactNoop.flushPassiveEffects(); + expect(sourceA.listenerCount).toBe(1); + + // Changing values should schedule an update with React + sourceA.value = 'a-two'; + expect(Scheduler).toFlushAndYield(['only:a-two']); + + // If we re-render with a new source, the old one should be unsubscribed. + ReactNoop.render( + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYield(['only:b-one', 'Sync effect']); + ReactNoop.flushPassiveEffects(); + expect(sourceA.listenerCount).toBe(0); + expect(sourceB.listenerCount).toBe(1); + + // Changing to original source should not schedule updates with React + sourceA.value = 'a-three'; + expect(Scheduler).toFlushAndYield([]); + + // Changing new source value should schedule an update with React + sourceB.value = 'b-two'; + expect(Scheduler).toFlushAndYield(['only:b-two']); + }); + }); + + it('should unsubscribe and resubscribe if a new subscribe function is provided', () => { + const source = createSource('a-one'); + const mutableSource = createMutableSource(source); + + const unsubscribeA = jest.fn(); + const subscribeA = jest.fn(s => { + const unsubscribe = defaultSubscribe(s); + return () => { + unsubscribe(); + unsubscribeA(); + }; + }); + const unsubscribeB = jest.fn(); + const subscribeB = jest.fn(s => { + const unsubscribe = defaultSubscribe(s); + return () => { + unsubscribe(); + unsubscribeB(); + }; + }); + + act(() => { + ReactNoop.renderToRootWithID( + , + 'root', + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYield(['only:a-one', 'Sync effect']); + ReactNoop.flushPassiveEffects(); + expect(source.listenerCount).toBe(1); + expect(subscribeA).toHaveBeenCalledTimes(1); + + // If we re-render with a new subscription function, + // the old unsubscribe function should be called. + ReactNoop.renderToRootWithID( + , + 'root', + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYield(['only:a-one', 'Sync effect']); + ReactNoop.flushPassiveEffects(); + expect(source.listenerCount).toBe(1); + expect(unsubscribeA).toHaveBeenCalledTimes(1); + expect(subscribeB).toHaveBeenCalledTimes(1); + + // Unmounting should call the newer unsunscribe. + ReactNoop.unmountRootWithID('root'); + expect(Scheduler).toFlushAndYield([]); + ReactNoop.flushPassiveEffects(); + expect(source.listenerCount).toBe(0); + expect(unsubscribeB).toHaveBeenCalledTimes(1); + }); + }); + + it('should re-use previously read snapshot value when reading is unsafe', () => { + const source = createSource('one'); + const mutableSource = createMutableSource(source); + + act(() => { + ReactNoop.render( + <> + + + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYield(['a:one', 'b:one', 'Sync effect']); + + // Changing values should schedule an update with React. + // Start working on this update but don't finish it. + source.value = 'two'; + expect(Scheduler).toFlushAndYieldThrough(['a:two']); + + // Re-renders that occur before the udpate is processed + // should reuse snapshot so long as the config has not changed + ReactNoop.flushSync(() => { + ReactNoop.render( + <> + + + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + }); + expect(Scheduler).toHaveYielded(['a:one', 'b:one', 'Sync effect']); + + expect(Scheduler).toFlushAndYield(['a:two', 'b:two']); + }); + }); + + it('should read from source on newly mounted subtree if no pending updates are scheduled for source', () => { + const source = createSource('one'); + const mutableSource = createMutableSource(source); + + act(() => { + ReactNoop.render( + <> + + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYield(['a:one', 'Sync effect']); + + ReactNoop.render( + <> + + + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYield(['a:one', 'b:one', 'Sync effect']); + }); + }); + + it('should throw and restart render if source and snapshot are unavailable during an update', () => { + const source = createSource('one'); + const mutableSource = createMutableSource(source); + + act(() => { + ReactNoop.render( + <> + + + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYield(['a:one', 'b:one', 'Sync effect']); + ReactNoop.flushPassiveEffects(); + + // Changing values should schedule an update with React. + // Start working on this update but don't finish it. + Scheduler.unstable_runWithPriority( + Scheduler.unstable_LowPriority, + () => { + source.value = 'two'; + expect(Scheduler).toFlushAndYieldThrough(['a:two']); + }, + ); + + const newGetSnapshot = s => 'new:' + defaultGetSnapshot(s); + + // Force a higher priority render with a new config. + // This should signal that the snapshot is not safe and trigger a full re-render. + Scheduler.unstable_runWithPriority( + Scheduler.unstable_UserBlockingPriority, + () => { + ReactNoop.render( + <> + + + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + }, + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'a:new:two', + 'b:new:two', + 'Sync effect', + ]); + }); + }); + + it('should throw and restart render if source and snapshot are unavailable during a sync update', () => { + const source = createSource('one'); + const mutableSource = createMutableSource(source); + + act(() => { + ReactNoop.render( + <> + + + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYield(['a:one', 'b:one', 'Sync effect']); + ReactNoop.flushPassiveEffects(); + + // Changing values should schedule an update with React. + // Start working on this update but don't finish it. + Scheduler.unstable_runWithPriority( + Scheduler.unstable_LowPriority, + () => { + source.value = 'two'; + expect(Scheduler).toFlushAndYieldThrough(['a:two']); + }, + ); + + const newGetSnapshot = s => 'new:' + defaultGetSnapshot(s); + + // Force a higher priority render with a new config. + // This should signal that the snapshot is not safe and trigger a full re-render. + ReactNoop.flushSync(() => { + ReactNoop.render( + <> + + + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + }); + expect(Scheduler).toHaveYielded([ + 'a:new:two', + 'b:new:two', + 'Sync effect', + ]); + }); + }); + + it('should only update components whose subscriptions fire', () => { + const source = createComplexSource('one', 'one'); + const mutableSource = createMutableSource(source); + + // Subscribe to part of the store. + const getSnapshotA = s => s.valueA; + const subscribeA = (s, callback) => s.subscribeA(callback); + const getSnapshotB = s => s.valueB; + const subscribeB = (s, callback) => s.subscribeB(callback); + + act(() => { + ReactNoop.render( + <> + + + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYield([ + 'a:a:one', + 'b:b:one', + 'Sync effect', + ]); + + // Changes to part of the store (e.g. A) should not render other parts. + source.valueA = 'a:two'; + expect(Scheduler).toFlushAndYield(['a:a:two']); + source.valueB = 'b:two'; + expect(Scheduler).toFlushAndYield(['b:b:two']); + }); + }); + + it('should detect tearing in part of the store not yet subscribed to', () => { + const source = createComplexSource('one', 'one'); + const mutableSource = createMutableSource(source); + + // Subscribe to part of the store. + const getSnapshotA = s => s.valueA; + const subscribeA = (s, callback) => s.subscribeA(callback); + const getSnapshotB = s => s.valueB; + const subscribeB = (s, callback) => s.subscribeB(callback); + + act(() => { + ReactNoop.render( + <> + + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYield(['a:a:one', 'Sync effect']); + + // Because the store has not chagned yet, there are no pending updates, + // so it is considered safe to read from when we start this render. + ReactNoop.render( + <> + + + + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough(['a:a:one', 'b:b:one']); + + // Mutating the source should trigger a tear detection on the next read, + // which should throw and re-render the entire tree. + source.valueB = 'b:two'; + + expect(Scheduler).toFlushAndYield([ + 'a:a:one', + 'b:b:two', + 'c:b:two', + 'Sync effect', + ]); + }); + }); + + it('does not schedule an update for subscriptions that fire with an unchanged snapshot', () => { + const MockComponent = jest.fn(Component); + + const source = createSource('one'); + const mutableSource = createMutableSource(source); + + act(() => { + ReactNoop.render( + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough(['only:one', 'Sync effect']); + ReactNoop.flushPassiveEffects(); + expect(source.listenerCount).toBe(1); + + // Notify subscribe function but don't change the value + source.value = 'one'; + expect(Scheduler).toFlushWithoutYielding(); + }); + }); + + it('should throw and restart if getSnapshot changes between scheduled update and re-render', () => { + const source = createSource('one'); + const mutableSource = createMutableSource(source); + + const newGetSnapshot = s => 'new:' + defaultGetSnapshot(s); + + let updateGetSnapshot; + + function WrapperWithState() { + const tuple = React.useState(() => defaultGetSnapshot); + updateGetSnapshot = tuple[1]; + return ( + + ); + } + + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYield(['only:one', 'Sync effect']); + ReactNoop.flushPassiveEffects(); + + // Change the source (and schedule an update). + Scheduler.unstable_runWithPriority( + Scheduler.unstable_LowPriority, + () => { + source.value = 'two'; + }, + ); + + // Schedule a higher priority update that changes getSnapshot. + Scheduler.unstable_runWithPriority( + Scheduler.unstable_UserBlockingPriority, + () => { + updateGetSnapshot(() => newGetSnapshot); + }, + ); + + expect(Scheduler).toFlushAndYield(['only:new:two']); + }); + }); + + it('should recover from a mutation during yield when other work is scheduled', () => { + const source = createSource('one'); + const mutableSource = createMutableSource(source); + + act(() => { + // Start a render that uses the mutable source. + ReactNoop.render( + <> + + + , + ); + expect(Scheduler).toFlushAndYieldThrough(['a:one']); + + // Mutate source + source.value = 'two'; + + // Now render something different. + ReactNoop.render(
); + expect(Scheduler).toFlushAndYield([]); + }); + }); + + it('should not throw if the new getSnapshot returns the same snapshot value', () => { + const source = createSource('one'); + const mutableSource = createMutableSource(source); + + const onRenderA = jest.fn(); + const onRenderB = jest.fn(); + + let updateGetSnapshot; + + function WrapperWithState() { + const tuple = React.useState(() => defaultGetSnapshot); + updateGetSnapshot = tuple[1]; + return ( + + ); + } + + act(() => { + ReactNoop.render( + <> + + + + + + + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYield(['a:one', 'b:one', 'Sync effect']); + ReactNoop.flushPassiveEffects(); + expect(onRenderA).toHaveBeenCalledTimes(1); + expect(onRenderB).toHaveBeenCalledTimes(1); + + // If B's getSnapshot function updates, but the snapshot it returns is the same, + // only B should re-render (to update its state). + updateGetSnapshot(() => s => defaultGetSnapshot(s)); + expect(Scheduler).toFlushAndYield(['b:one']); + ReactNoop.flushPassiveEffects(); + expect(onRenderA).toHaveBeenCalledTimes(1); + expect(onRenderB).toHaveBeenCalledTimes(2); + }); + }); + + it('should not throw if getSnapshot changes but the source can be safely read from anyway', () => { + const source = createSource('one'); + const mutableSource = createMutableSource(source); + + const newGetSnapshot = s => 'new:' + defaultGetSnapshot(s); + + let updateGetSnapshot; + + function WrapperWithState() { + const tuple = React.useState(() => defaultGetSnapshot); + updateGetSnapshot = tuple[1]; + return ( + + ); + } + + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYield(['only:one', 'Sync effect']); + ReactNoop.flushPassiveEffects(); + + // Change the source (and schedule an update) + // but also change the snapshot function too. + ReactNoop.batchedUpdates(() => { + source.value = 'two'; + updateGetSnapshot(() => newGetSnapshot); + }); + + expect(Scheduler).toFlushAndYield(['only:new:two']); + }); + }); + + it('should still schedule an update if an eager selector throws after a mutation', () => { + const source = createSource({ + friends: [ + {id: 1, name: 'Foo'}, + {id: 2, name: 'Bar'}, + ], + }); + const mutableSource = createMutableSource(source); + + function FriendsList() { + const getSnapshot = React.useCallback( + ({value}) => Array.from(value.friends), + [], + ); + const friends = useMutableSource( + mutableSource, + getSnapshot, + defaultSubscribe, + ); + return ( +
    + {friends.map(friend => ( + + ))} +
+ ); + } + + function Friend({id}) { + const getSnapshot = React.useCallback( + ({value}) => { + // This selector is intentionally written in a way that will throw + // if no matching friend exists in the store. + return value.friends.find(friend => friend.id === id).name; + }, + [id], + ); + const name = useMutableSource( + mutableSource, + getSnapshot, + defaultSubscribe, + ); + Scheduler.unstable_yieldValue(`${id}:${name}`); + return
  • {name}
  • ; + } + + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYield(['1:Foo', '2:Bar', 'Sync effect']); + + // This mutation will cause the "Bar" component to throw, + // since its value will no longer be a part of the store. + // Mutable source should still schedule an update though, + // which should unmount "Bar" and mount "Baz". + source.value = { + friends: [ + {id: 1, name: 'Foo'}, + {id: 3, name: 'Baz'}, + ], + }; + expect(Scheduler).toFlushAndYield(['1:Foo', '3:Baz']); + }); + }); + + it('should not warn about updates that fire between unmount and passive unsubcribe', () => { + const source = createSource('one'); + const mutableSource = createMutableSource(source); + + function Wrapper() { + React.useLayoutEffect(() => () => { + Scheduler.unstable_yieldValue('layout unmount'); + }); + return ( + + ); + } + + act(() => { + ReactNoop.renderToRootWithID(, 'root', () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYield(['only:one', 'Sync effect']); + ReactNoop.flushPassiveEffects(); + + // Umounting a root should remove the remaining event listeners in a passive effect + ReactNoop.unmountRootWithID('root'); + expect(Scheduler).toFlushAndYieldThrough(['layout unmount']); + + // Changes to source should not cause a warning, + // even though the unsubscribe hasn't run yet (since it's a pending passive effect). + source.value = 'two'; + expect(Scheduler).toFlushAndYield([]); + }); + }); + + it('should support inline selectors and updates that are processed after selector change', async () => { + const source = createSource({ + a: 'initial', + b: 'initial', + }); + const mutableSource = createMutableSource(source); + + const getSnapshotA = () => source.value.a; + const getSnapshotB = () => source.value.b; + + function mutateB(newB) { + source.value = { + ...source.value, + b: newB, + }; + } + + function App({toggle}) { + const state = useMutableSource( + mutableSource, + toggle ? getSnapshotB : getSnapshotA, + defaultSubscribe, + ); + const result = (toggle ? 'on: ' : 'off: ') + state; + return result; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + root.render(); + }); + expect(root).toMatchRenderedOutput('off: initial'); + + await act(async () => { + mutateB('Updated B'); + root.render(); + }); + expect(root).toMatchRenderedOutput('on: Updated B'); + + await act(async () => { + mutateB('Another update'); + }); + expect(root).toMatchRenderedOutput('on: Another update'); + }); + + it('should clear the update queue when getSnapshot changes with pending lower priority updates', async () => { + const source = createSource({ + a: 'initial', + b: 'initial', + }); + const mutableSource = createMutableSource(source); + + const getSnapshotA = () => source.value.a; + const getSnapshotB = () => source.value.b; + + function mutateA(newA) { + source.value = { + ...source.value, + a: newA, + }; + } + + function mutateB(newB) { + source.value = { + ...source.value, + b: newB, + }; + } + + function App({toggle}) { + const state = useMutableSource( + mutableSource, + toggle ? getSnapshotB : getSnapshotA, + defaultSubscribe, + ); + const result = (toggle ? 'B: ' : 'A: ') + state; + return result; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + root.render(); + }); + expect(root).toMatchRenderedOutput('A: initial'); + + await act(async () => { + ReactNoop.discreteUpdates(() => { + // Update both A and B to the same value + mutateA('Update'); + mutateB('Update'); + // Toggle to B in the same batch + root.render(); + }); + // Mutate A at lower priority. This should never be rendered, because + // by the time we get to the lower priority, we've already switched + // to B. + mutateA('OOPS! This mutation should be ignored'); + }); + expect(root).toMatchRenderedOutput('B: Update'); + }); + + it('should clear the update queue when source changes with pending lower priority updates', async () => { + const sourceA = createSource('initial'); + const sourceB = createSource('initial'); + const mutableSourceA = createMutableSource(sourceA); + const mutableSourceB = createMutableSource(sourceB); + + function App({toggle}) { + const state = useMutableSource( + toggle ? mutableSourceB : mutableSourceA, + defaultGetSnapshot, + defaultSubscribe, + ); + const result = (toggle ? 'B: ' : 'A: ') + state; + return result; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + root.render(); + }); + expect(root).toMatchRenderedOutput('A: initial'); + + await act(async () => { + ReactNoop.discreteUpdates(() => { + // Update both A and B to the same value + sourceA.value = 'Update'; + sourceB.value = 'Update'; + // Toggle to B in the same batch + root.render(); + }); + // Mutate A at lower priority. This should never be rendered, because + // by the time we get to the lower priority, we've already switched + // to B. + sourceA.value = 'OOPS! This mutation should be ignored'; + }); + expect(root).toMatchRenderedOutput('B: Update'); + }); + + it('should always treat reading as potentially unsafe when getSnapshot changes between renders', async () => { + const source = createSource({ + a: 'foo', + b: 'bar', + }); + const mutableSource = createMutableSource(source); + + const getSnapshotA = () => source.value.a; + const getSnapshotB = () => source.value.b; + + function mutateA(newA) { + source.value = { + ...source.value, + a: newA, + }; + } + + function App({getSnapshotFirst, getSnapshotSecond}) { + const first = useMutableSource( + mutableSource, + getSnapshotFirst, + defaultSubscribe, + ); + const second = useMutableSource( + mutableSource, + getSnapshotSecond, + defaultSubscribe, + ); + + let result = `x: ${first}, y: ${second}`; + + if (getSnapshotFirst === getSnapshotSecond) { + // When both getSnapshot functions are equal, + // the two values must be consistent. + if (first !== second) { + result = 'Oops, tearing!'; + } + } + + React.useEffect(() => { + Scheduler.unstable_yieldValue(result); + }, [result]); + + return result; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + root.render( + , + ); + }); + // x and y start out reading from different parts of the store. + expect(Scheduler).toHaveYielded(['x: foo, y: bar']); + + await act(async () => { + ReactNoop.discreteUpdates(() => { + // At high priority, toggle y so that it reads from A instead of B. + // Simultaneously, mutate A. + mutateA('high-pri baz'); + root.render( + , + ); + + // If this update were processed before the next mutation, + // it would be expected to yield "high-pri baz" and "high-pri baz". + }); + + // At lower priority, mutate A again. + // This happens to match the initial value of B. + mutateA('bar'); + + // When this update is processed, + // it is expected to yield "bar" and "bar". + }); + + // Check that we didn't commit any inconsistent states. + // The actual sequence of work will be: + // 1. React renders the high-pri update, sees a new getSnapshot, detects the source has been further mutated, and throws + // 2. React re-renders with all pending updates, including the second mutation, and renders "bar" and "bar". + expect(Scheduler).toHaveYielded(['x: bar, y: bar']); + }); + + if (__DEV__) { + describe('dev warnings', () => { + it('should warn if the subscribe function does not return an unsubscribe function', () => { + const source = createSource('one'); + const mutableSource = createMutableSource(source); + + const brokenSubscribe = () => {}; + + expect(() => { + act(() => { + ReactNoop.render( + , + ); + }); + }).toErrorDev( + 'Mutable source subscribe function must return an unsubscribe function.', + ); + }); + + it('should error if multiple renderers of the same type use a mutable source at the same time', () => { + const source = createSource('one'); + const mutableSource = createMutableSource(source); + + act(() => { + // Start a render that uses the mutable source. + ReactNoop.render( + <> + + + , + ); + expect(Scheduler).toFlushAndYieldThrough(['a:one']); + + const PrevScheduler = Scheduler; + + // Get a new copy of ReactNoop. + loadModules(); + + spyOnDev(console, 'error'); + + // Use the mutablesource again but with a different renderer. + ReactNoop.render( + , + ); + expect(Scheduler).toFlushAndYieldThrough(['c:one']); + + expect(console.error.calls.argsFor(0)[0]).toContain( + 'Detected multiple renderers concurrently rendering the ' + + 'same mutable source. This is currently unsupported.', + ); + + // TODO (useMutableSource) Act will automatically flush remaining work from render 1, + // but at this point something in the hooks dispatcher has been broken by jest.resetModules() + // Figure out what this is and remove this catch. + expect(() => + PrevScheduler.unstable_flushAllWithoutAsserting(), + ).toThrow('Invalid hook call'); + }); + }); + + it('should error if multiple renderers of the same type use a mutable source at the same time with mutation between', () => { + const source = createSource('one'); + const mutableSource = createMutableSource(source); + + act(() => { + // Start a render that uses the mutable source. + ReactNoop.render( + <> + + + , + ); + expect(Scheduler).toFlushAndYieldThrough(['a:one']); + + const PrevScheduler = Scheduler; + + // Get a new copy of ReactNoop. + loadModules(); + + spyOnDev(console, 'error'); + + // Mutate before the new render reads from the source. + source.value = 'two'; + + // Use the mutablesource again but with a different renderer. + ReactNoop.render( + , + ); + expect(Scheduler).toFlushAndYieldThrough(['c:two']); + + expect(console.error.calls.argsFor(0)[0]).toContain( + 'Detected multiple renderers concurrently rendering the ' + + 'same mutable source. This is currently unsupported.', + ); + + // TODO (useMutableSource) Act will automatically flush remaining work from render 1, + // but at this point something in the hooks dispatcher has been broken by jest.resetModules() + // Figure out what this is and remove this catch. + expect(() => + PrevScheduler.unstable_flushAllWithoutAsserting(), + ).toThrow('Invalid hook call'); + }); + }); + }); + } + } +}); diff --git a/packages/react/index.classic.fb.js b/packages/react/index.classic.fb.js index e0d720efe8..b8e7612469 100644 --- a/packages/react/index.classic.fb.js +++ b/packages/react/index.classic.fb.js @@ -26,6 +26,8 @@ export { useReducer, useRef, useState, + useMutableSource, + createMutableSource, Fragment, Profiler, StrictMode, diff --git a/packages/react/index.experimental.js b/packages/react/index.experimental.js index a1e7b9629a..67f222b9dc 100644 --- a/packages/react/index.experimental.js +++ b/packages/react/index.experimental.js @@ -26,6 +26,8 @@ export { useReducer, useRef, useState, + useMutableSource, + createMutableSource, Fragment, Profiler, StrictMode, diff --git a/packages/react/index.js b/packages/react/index.js index c0dd6bbdb3..d139aa877d 100644 --- a/packages/react/index.js +++ b/packages/react/index.js @@ -55,6 +55,8 @@ export { useReducer, useRef, useState, + useMutableSource, + createMutableSource, Fragment, Profiler, StrictMode, diff --git a/packages/react/index.modern.fb.js b/packages/react/index.modern.fb.js index 0c6ba986e2..0164f74b8e 100644 --- a/packages/react/index.modern.fb.js +++ b/packages/react/index.modern.fb.js @@ -23,6 +23,8 @@ export { useDebugValue, useLayoutEffect, useMemo, + useMutableSource, + createMutableSource, useReducer, useRef, useState, diff --git a/packages/react/src/React.js b/packages/react/src/React.js index 7524e77959..eef2517973 100644 --- a/packages/react/src/React.js +++ b/packages/react/src/React.js @@ -39,6 +39,7 @@ import { useDebugValue, useLayoutEffect, useMemo, + useMutableSource, useReducer, useRef, useState, @@ -55,6 +56,7 @@ import { jsxWithValidationStatic, jsxWithValidationDynamic, } from './ReactElementValidator'; +import createMutableSource from './createMutableSource'; import ReactSharedInternals from './ReactSharedInternals'; import createFundamental from 'shared/createFundamentalComponent'; import createResponder from 'shared/createEventResponder'; @@ -81,6 +83,7 @@ const Children = { export { Children, + createMutableSource, createRef, Component, PureComponent, @@ -95,6 +98,7 @@ export { useDebugValue, useLayoutEffect, useMemo, + useMutableSource, useReducer, useRef, useState, diff --git a/packages/react/src/ReactHooks.js b/packages/react/src/ReactHooks.js index 8df2eeb326..38a6a36348 100644 --- a/packages/react/src/ReactHooks.js +++ b/packages/react/src/ReactHooks.js @@ -8,6 +8,9 @@ */ import type { + MutableSource, + MutableSourceGetSnapshotFn, + MutableSourceSubscribeFn, ReactContext, ReactEventResponder, ReactEventResponderListener, @@ -177,3 +180,12 @@ export function useDeferredValue(value: T, config: ?Object): T { const dispatcher = resolveDispatcher(); return dispatcher.useDeferredValue(value, config); } + +export function useMutableSource( + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn, + subscribe: MutableSourceSubscribeFn, +): Snapshot { + const dispatcher = resolveDispatcher(); + return dispatcher.useMutableSource(source, getSnapshot, subscribe); +} diff --git a/packages/react/src/createMutableSource.js b/packages/react/src/createMutableSource.js new file mode 100644 index 0000000000..915c5c7240 --- /dev/null +++ b/packages/react/src/createMutableSource.js @@ -0,0 +1,29 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {MutableSource, MutableSourceGetVersionFn} from 'shared/ReactTypes'; + +export default function createMutableSource>( + source: Source, + getVersion: MutableSourceGetVersionFn, +): MutableSource { + const mutableSource: MutableSource = { + _getVersion: getVersion, + _source: source, + _workInProgressVersionPrimary: null, + _workInProgressVersionSecondary: null, + }; + + if (__DEV__) { + mutableSource._currentPrimaryRenderer = null; + mutableSource._currentSecondaryRenderer = null; + } + + return mutableSource; +} diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index 2b87427cba..0789c286ed 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -190,3 +190,47 @@ export type ReactScopeInstance = {| fiber: Object, methods: null | ReactScopeMethods, |}; + +// Mutable source version can be anything (e.g. number, string, immutable data structure) +// so long as it changes every time any part of the source changes. +export type MutableSourceVersion = $NonMaybeType; + +export type MutableSourceGetSnapshotFn< + Source: $NonMaybeType, + Snapshot, +> = (source: Source) => Snapshot; + +export type MutableSourceSubscribeFn, Snapshot> = ( + source: Source, + callback: (snapshot: Snapshot) => void, +) => () => void; + +export type MutableSourceGetVersionFn = ( + source: $NonMaybeType, +) => MutableSourceVersion; + +export type MutableSource> = {| + _source: Source, + + _getVersion: MutableSourceGetVersionFn, + + // Tracks the version of this source at the time it was most recently read. + // Used to determine if a source is safe to read from before it has been subscribed to. + // Version number is only used during mount, + // since the mechanism for determining safety after subscription is expiration time. + // + // As a workaround to support multiple concurrent renderers, + // we categorize some renderers as primary and others as secondary. + // We only expect there to be two concurrent renderers at most: + // React Native (primary) and Fabric (secondary); + // React DOM (primary) and React ART (secondary). + // Secondary renderers store their context values on separate fields. + // We use the same approach for Context. + _workInProgressVersionPrimary: null | MutableSourceVersion, + _workInProgressVersionSecondary: null | MutableSourceVersion, + + // DEV only + // Used to detect multiple renderers using the same mutable source. + _currentPrimaryRenderer?: Object | null, + _currentSecondaryRenderer?: Object | null, +|}; diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 9e85caa7c7..a2f0a1f9da 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -346,5 +346,7 @@ "345": "Root did not complete. This is a bug in React.", "346": "An event responder context was used outside of an event cycle.", "347": "Maps are not valid as a React child (found: %s). Consider converting children to an array of keyed ReactElements instead.", - "348": "ensureListeningTo(): received a container that was not an element node. This is likely a bug in React." + "348": "ensureListeningTo(): received a container that was not an element node. This is likely a bug in React.", + "349": "Expected a work-in-progress root. This is a bug in React. Please file an issue.", + "350": "Cannot read from mutable source during the current render without tearing. This is a bug in React. Please file an issue." }