diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index cddbb0810f753..aa4d2d60fdf63 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -7,7 +7,13 @@ * @flow */ -import type {ReactContext, ReactProviderType} from 'shared/ReactTypes'; +import type { + MutableSource, + MutableSourceGetSnapshotFn, + MutableSourceSubscribeFn, + ReactContext, + ReactProviderType, +} from 'shared/ReactTypes'; import type { Fiber, Dispatcher as DispatcherType, @@ -255,6 +261,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 useSyncExternalStore( subscribe: (() => void) => () => void, getSnapshot: () => T, @@ -335,6 +358,7 @@ const Dispatcher: DispatcherType = { useRef, useState, useTransition, + useMutableSource, useSyncExternalStore, useDeferredValue, useOpaqueIdentifier, diff --git a/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js b/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js index 2e6896ef160fd..b13bec22d213c 100644 --- a/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js +++ b/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js @@ -1019,6 +1019,43 @@ describe('ReactHooksInspectionIntegration', () => { ]); }); + it('should support composite useMutableSource hook', () => { + const createMutableSource = + React.createMutableSource || React.unstable_createMutableSource; + const useMutableSource = + React.useMutableSource || React.unstable_useMutableSource; + + const mutableSource = createMutableSource({}, () => 1); + function Foo(props) { + useMutableSource( + mutableSource, + () => 'snapshot', + () => {}, + ); + React.useMemo(() => 'memo', []); + return
; + } + const renderer = ReactTestRenderer.create(); + const childFiber = renderer.root.findByType(Foo)._currentFiber(); + const 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: [], + }, + ]); + }); + // @gate experimental || www it('should support composite useSyncExternalStore hook', () => { const useSyncExternalStore = React.unstable_useSyncExternalStore; diff --git a/packages/react-dom/src/client/ReactDOMRoot.js b/packages/react-dom/src/client/ReactDOMRoot.js index 3bde63dc67a30..ba062fc3f71e9 100644 --- a/packages/react-dom/src/client/ReactDOMRoot.js +++ b/packages/react-dom/src/client/ReactDOMRoot.js @@ -8,7 +8,7 @@ */ import type {Container} from './ReactDOMHostConfig'; -import type {ReactNodeList} from 'shared/ReactTypes'; +import type {MutableSource, ReactNodeList} from 'shared/ReactTypes'; import type {FiberRoot} from 'react-reconciler/src/ReactInternalTypes'; export type RootType = { @@ -24,6 +24,7 @@ export type CreateRootOptions = { hydrationOptions?: { onHydrated?: (suspenseNode: Comment) => void, onDeleted?: (suspenseNode: Comment) => void, + mutableSources?: Array>, ... }, // END OF TODO @@ -34,6 +35,7 @@ export type CreateRootOptions = { export type HydrateRootOptions = { // Hydration options + hydratedSources?: Array>, onHydrated?: (suspenseNode: Comment) => void, onDeleted?: (suspenseNode: Comment) => void, // Options for all roots @@ -59,6 +61,7 @@ import { createContainer, updateContainer, findHostInstanceWithNoPortals, + registerMutableSourceForHydration, } from 'react-reconciler/src/ReactFiberReconciler'; import invariant from 'shared/invariant'; import {ConcurrentRoot} from 'react-reconciler/src/ReactRootTags'; @@ -126,6 +129,11 @@ export function createRoot( const hydrate = options != null && options.hydrate === true; const hydrationCallbacks = (options != null && options.hydrationOptions) || null; + const mutableSources = + (options != null && + options.hydrationOptions != null && + options.hydrationOptions.mutableSources) || + null; // END TODO const isStrictMode = options != null && options.unstable_strictMode === true; @@ -151,6 +159,15 @@ export function createRoot( container.nodeType === COMMENT_NODE ? container.parentNode : container; listenToAllSupportedEvents(rootContainerElement); + // TODO: Delete this path + if (mutableSources) { + for (let i = 0; i < mutableSources.length; i++) { + const mutableSource = mutableSources[i]; + registerMutableSourceForHydration(root, mutableSource); + } + } + // END TODO + return new ReactDOMRoot(root); } @@ -168,6 +185,7 @@ export function hydrateRoot( // For now we reuse the whole bag of options since they contain // the hydration callbacks. const hydrationCallbacks = options != null ? options : null; + const mutableSources = (options != null && options.hydratedSources) || null; const isStrictMode = options != null && options.unstable_strictMode === true; let concurrentUpdatesByDefaultOverride = null; @@ -190,6 +208,13 @@ export function hydrateRoot( // This can't be a comment node since hydration doesn't work on comment nodes anyway. listenToAllSupportedEvents(container); + if (mutableSources) { + for (let i = 0; i < mutableSources.length; i++) { + const mutableSource = mutableSources[i]; + registerMutableSourceForHydration(root, mutableSource); + } + } + // Render the initial children updateContainer(initialChildren, root, null, null); diff --git a/packages/react-dom/src/server/ReactPartialRendererHooks.js b/packages/react-dom/src/server/ReactPartialRendererHooks.js index a57e6952a3fd9..edc5df3dce4bd 100644 --- a/packages/react-dom/src/server/ReactPartialRendererHooks.js +++ b/packages/react-dom/src/server/ReactPartialRendererHooks.js @@ -9,7 +9,12 @@ import type {Dispatcher as DispatcherType} from 'react-reconciler/src/ReactInternalTypes'; -import type {ReactContext} from 'shared/ReactTypes'; +import type { + MutableSource, + MutableSourceGetSnapshotFn, + MutableSourceSubscribeFn, + ReactContext, +} from 'shared/ReactTypes'; import type PartialRenderer from './ReactPartialRenderer'; import {validateContextBounds} from './ReactPartialRendererContext'; @@ -461,6 +466,18 @@ export function useCallback( return useMemo(() => callback, deps); } +// 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 useSyncExternalStore( subscribe: (() => void) => () => void, getSnapshot: () => T, @@ -527,6 +544,8 @@ export const Dispatcher: DispatcherType = { useDeferredValue, useTransition, useOpaqueIdentifier, + // Subscriptions are not setup in a server environment. + useMutableSource, useSyncExternalStore, }; diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.new.js b/packages/react-reconciler/src/ReactFiberBeginWork.new.js index 8c89c369a5a66..9ad53ec00f27f 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.new.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.new.js @@ -12,6 +12,7 @@ import type {LazyComponent as LazyComponentType} from 'react/src/ReactLazy'; import type {Fiber, FiberRoot} from './ReactInternalTypes'; import type {TypeOfMode} from './ReactTypeOfMode'; import type {Lanes, Lane} from './ReactFiberLane.new'; +import type {MutableSource} from 'shared/ReactTypes'; import type { SuspenseState, SuspenseListRenderState, @@ -144,6 +145,7 @@ import { isSuspenseInstancePending, isSuspenseInstanceFallback, registerSuspenseInstanceRetry, + supportsHydration, isPrimaryRenderer, supportsPersistence, getOffscreenContainerProps, @@ -218,6 +220,7 @@ import { RetryAfterError, NoContext, } from './ReactFiberWorkLoop.new'; +import {setWorkInProgressVersion} from './ReactMutableSource.new'; import { requestCacheFromPool, pushCacheProvider, @@ -1297,6 +1300,21 @@ function updateHostRoot(current, workInProgress, renderLanes) { // We always try to hydrate. If this isn't a hydration pass there won't // be any children to hydrate which is effectively the same thing as // not hydrating. + + if (supportsHydration) { + const mutableSourceEagerHydrationData = + root.mutableSourceEagerHydrationData; + if (mutableSourceEagerHydrationData != null) { + for (let i = 0; i < mutableSourceEagerHydrationData.length; i += 2) { + const mutableSource = ((mutableSourceEagerHydrationData[ + i + ]: any): MutableSource); + const version = mutableSourceEagerHydrationData[i + 1]; + setWorkInProgressVersion(mutableSource, version); + } + } + } + const child = mountChildFibers( workInProgress, null, diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.old.js b/packages/react-reconciler/src/ReactFiberBeginWork.old.js index d56e2a2b5ff18..bef7863638ff0 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.old.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.old.js @@ -12,6 +12,7 @@ import type {LazyComponent as LazyComponentType} from 'react/src/ReactLazy'; import type {Fiber, FiberRoot} from './ReactInternalTypes'; import type {TypeOfMode} from './ReactTypeOfMode'; import type {Lanes, Lane} from './ReactFiberLane.old'; +import type {MutableSource} from 'shared/ReactTypes'; import type { SuspenseState, SuspenseListRenderState, @@ -144,6 +145,7 @@ import { isSuspenseInstancePending, isSuspenseInstanceFallback, registerSuspenseInstanceRetry, + supportsHydration, isPrimaryRenderer, supportsPersistence, getOffscreenContainerProps, @@ -218,6 +220,7 @@ import { RetryAfterError, NoContext, } from './ReactFiberWorkLoop.old'; +import {setWorkInProgressVersion} from './ReactMutableSource.old'; import { requestCacheFromPool, pushCacheProvider, @@ -1297,6 +1300,21 @@ function updateHostRoot(current, workInProgress, renderLanes) { // We always try to hydrate. If this isn't a hydration pass there won't // be any children to hydrate which is effectively the same thing as // not hydrating. + + if (supportsHydration) { + const mutableSourceEagerHydrationData = + root.mutableSourceEagerHydrationData; + if (mutableSourceEagerHydrationData != null) { + for (let i = 0; i < mutableSourceEagerHydrationData.length; i += 2) { + const mutableSource = ((mutableSourceEagerHydrationData[ + i + ]: any): MutableSource); + const version = mutableSourceEagerHydrationData[i + 1]; + setWorkInProgressVersion(mutableSource, version); + } + } + } + const child = mountChildFibers( workInProgress, null, diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js index e7d6f40e98bdb..1382439f1ee7a 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js @@ -30,6 +30,8 @@ import type {SuspenseContext} from './ReactFiberSuspenseContext.new'; import type {OffscreenState} from './ReactFiberOffscreenComponent'; import type {Cache, SpawnedCachePool} from './ReactFiberCacheComponent.new'; +import {resetWorkInProgressVersions as resetMutableSourceWorkInProgressVersions} from './ReactMutableSource.new'; + import {now} from './Scheduler'; import { @@ -852,6 +854,7 @@ function completeWork( } popHostContainer(workInProgress); popTopLevelLegacyContextObject(workInProgress); + resetMutableSourceWorkInProgressVersions(); if (fiberRoot.pendingContext) { fiberRoot.context = fiberRoot.pendingContext; fiberRoot.pendingContext = null; diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.old.js b/packages/react-reconciler/src/ReactFiberCompleteWork.old.js index 6e8406fc05dfd..746e3d4b572da 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.old.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.old.js @@ -30,6 +30,8 @@ import type {SuspenseContext} from './ReactFiberSuspenseContext.old'; import type {OffscreenState} from './ReactFiberOffscreenComponent'; import type {Cache, SpawnedCachePool} from './ReactFiberCacheComponent.old'; +import {resetWorkInProgressVersions as resetMutableSourceWorkInProgressVersions} from './ReactMutableSource.old'; + import {now} from './Scheduler'; import { @@ -852,6 +854,7 @@ function completeWork( } popHostContainer(workInProgress); popTopLevelLegacyContextObject(workInProgress); + resetMutableSourceWorkInProgressVersions(); if (fiberRoot.pendingContext) { fiberRoot.context = fiberRoot.pendingContext; fiberRoot.pendingContext = null; diff --git a/packages/react-reconciler/src/ReactFiberHooks.new.js b/packages/react-reconciler/src/ReactFiberHooks.new.js index f797d7d5812e6..76af5ddf50746 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.new.js +++ b/packages/react-reconciler/src/ReactFiberHooks.new.js @@ -7,7 +7,12 @@ * @flow */ -import type {ReactContext} from 'shared/ReactTypes'; +import type { + MutableSource, + MutableSourceGetSnapshotFn, + MutableSourceSubscribeFn, + ReactContext, +} from 'shared/ReactTypes'; import type {Fiber, Dispatcher, HookType} from './ReactInternalTypes'; import type {Lanes, Lane} from './ReactFiberLane.new'; import type {HookFlags} from './ReactHookEffectTags'; @@ -45,6 +50,7 @@ import { intersectLanes, isTransitionLane, markRootEntangled, + markRootMutableRead, NoTimestamp, } from './ReactFiberLane.new'; import { @@ -96,6 +102,12 @@ import { makeClientIdInDEV, makeOpaqueHydratingObject, } from './ReactFiberHostConfig'; +import { + getWorkInProgressVersion, + markSourceAsDirty, + setWorkInProgressVersion, + warnAboutMultipleRenderersDEV, +} from './ReactMutableSource.new'; import {getIsRendering} from './ReactCurrentFiber'; import {logStateUpdateScheduled} from './DebugTracing'; import {markStateUpdateScheduled} from './SchedulingProfiler'; @@ -935,6 +947,289 @@ function rerenderReducer( return [newState, dispatch]; } +type MutableSourceMemoizedState = {| + refs: { + getSnapshot: MutableSourceGetSnapshotFn, + setSnapshot: Snapshot => void, + }, + source: MutableSource, + subscribe: MutableSourceSubscribeFn, +|}; + +function readFromUnsubscribedMutableSource( + 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) { + // It's safe to read if the store hasn't been mutated since the last time + // we read something. + isSafeToReadFromSource = currentRenderVersion === version; + } else { + // If there's no version, then this is the first time we've read from the + // source during the current render pass, so we need to do a bit more work. + // What we need to determine is if there are any hooks that already + // subscribed to the source, and if so, whether there are any pending + // mutations that haven't been synchronized yet. + // + // If there are no pending mutations, then `root.mutableReadLanes` will be + // empty, and we know we can safely read. + // + // If there *are* pending mutations, we may still be able to safely read + // if the currently rendering lanes are inclusive of the pending mutation + // lanes, since that guarantees that the value we're about to read from + // the source is consistent with the values that we read during the most + // recent mutation. + isSafeToReadFromSource = isSubsetOfLanes( + renderLanes, + root.mutableReadLanes, + ); + + 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) { + const snapshot = getSnapshot(source._source); + if (__DEV__) { + if (typeof snapshot === 'function') { + console.error( + 'Mutable source should not return a function as the snapshot value. ' + + 'Functions may close over mutable values and cause tearing.', + ); + } + } + return snapshot; + } else { + // This handles the special case of a mutable source being shared between 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); + + // Intentioally throw an error to force React to retry synchronously. During + // the synchronous retry, it will block interleaved mutations, so we should + // get a consistent read. Therefore, the following error should never be + // visible to the user. + + // We expect this error not to be thrown during the synchronous retry, + // because we blocked interleaved mutations. + invariant( + false, + 'Cannot read from mutable source during the current render without tearing. This may be 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; + + // eslint-disable-next-line prefer-const + let [currentSnapshot, setSnapshot] = dispatcher.useState(() => + readFromUnsubscribedMutableSource(root, source, getSnapshot), + ); + let snapshot = currentSnapshot; + + // 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 subscription handler after each commit. + dispatcher.useEffect(() => { + refs.getSnapshot = getSnapshot; + + // Normally the dispatch function for a state hook never changes, + // but this hook recreates the queue in certain cases to avoid updates from stale sources. + // handleChange() below needs to reference the dispatch function without re-subscribing, + // so we use a ref to ensure that it always has the latest version. + refs.setSnapshot = setSnapshot; + + // Check for a possible change between when we last rendered now. + const maybeNewVersion = getVersion(source._source); + if (!is(version, maybeNewVersion)) { + const maybeNewSnapshot = getSnapshot(source._source); + if (__DEV__) { + if (typeof maybeNewSnapshot === 'function') { + console.error( + 'Mutable source should not return a function as the snapshot value. ' + + 'Functions may close over mutable values and cause tearing.', + ); + } + } + + if (!is(snapshot, maybeNewSnapshot)) { + setSnapshot(maybeNewSnapshot); + + const lane = requestUpdateLane(fiber); + markRootMutableRead(root, lane); + } + // If the source mutated between render and now, + // there may be state updates already scheduled from the old source. + // Entangle the updates so that they render in the same batch. + markRootEntangled(root, root.mutableReadLanes); + } + }, [getSnapshot, source, subscribe]); + + // If we got a new source or subscribe function, re-subscribe in a passive effect. + dispatcher.useEffect(() => { + const handleChange = () => { + const latestGetSnapshot = refs.getSnapshot; + const latestSetSnapshot = refs.setSnapshot; + + try { + latestSetSnapshot(latestGetSnapshot(source._source)); + + // Record a pending mutable source update with the same expiration time. + const lane = requestUpdateLane(fiber); + + markRootMutableRead(root, lane); + } 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. + latestSetSnapshot( + (() => { + throw error; + }: any), + ); + } + }; + + const unsubscribe = subscribe(source._source, handleChange); + if (__DEV__) { + if (typeof unsubscribe !== 'function') { + console.error( + 'Mutable source subscribe function must return an unsubscribe function.', + ); + } + } + + 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 updates (since they are no longer relevant) + // and treat reading from the source as we do in the mount case. + if ( + !is(prevGetSnapshot, getSnapshot) || + !is(prevSource, source) || + !is(prevSubscribe, subscribe) + ) { + // Create a new queue and setState method, + // So if there are interleaved updates, they get pushed to the older queue. + // When this becomes current, the previous queue and dispatch method will be discarded, + // including any interleaving updates that occur. + const newQueue: UpdateQueue> = { + pending: null, + interleaved: null, + lanes: NoLanes, + dispatch: null, + lastRenderedReducer: basicStateReducer, + lastRenderedState: snapshot, + }; + newQueue.dispatch = setSnapshot = (dispatchAction.bind( + null, + currentlyRenderingFiber, + newQueue, + ): any); + stateHook.queue = newQueue; + stateHook.baseQueue = null; + snapshot = readFromUnsubscribedMutableSource(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, + setSnapshot: (null: any), + }, + 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 mountSyncExternalStore( subscribe: (() => void) => () => void, getSnapshot: () => T, @@ -2035,6 +2330,7 @@ export const ContextOnlyDispatcher: Dispatcher = { useDebugValue: throwInvalidHookError, useDeferredValue: throwInvalidHookError, useTransition: throwInvalidHookError, + useMutableSource: throwInvalidHookError, useSyncExternalStore: throwInvalidHookError, useOpaqueIdentifier: throwInvalidHookError, @@ -2061,6 +2357,7 @@ const HooksDispatcherOnMount: Dispatcher = { useDebugValue: mountDebugValue, useDeferredValue: mountDeferredValue, useTransition: mountTransition, + useMutableSource: mountMutableSource, useSyncExternalStore: mountSyncExternalStore, useOpaqueIdentifier: mountOpaqueIdentifier, @@ -2087,6 +2384,7 @@ const HooksDispatcherOnUpdate: Dispatcher = { useDebugValue: updateDebugValue, useDeferredValue: updateDeferredValue, useTransition: updateTransition, + useMutableSource: updateMutableSource, useSyncExternalStore: updateSyncExternalStore, useOpaqueIdentifier: updateOpaqueIdentifier, @@ -2113,6 +2411,7 @@ const HooksDispatcherOnRerender: Dispatcher = { useDebugValue: updateDebugValue, useDeferredValue: rerenderDeferredValue, useTransition: rerenderTransition, + useMutableSource: updateMutableSource, useSyncExternalStore: mountSyncExternalStore, useOpaqueIdentifier: rerenderOpaqueIdentifier, @@ -2262,6 +2561,15 @@ if (__DEV__) { mountHookTypesDev(); return mountTransition(); }, + useMutableSource( + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn, + subscribe: MutableSourceSubscribeFn, + ): Snapshot { + currentHookNameInDev = 'useMutableSource'; + mountHookTypesDev(); + return mountMutableSource(source, getSnapshot, subscribe); + }, useSyncExternalStore( subscribe: (() => void) => () => void, getSnapshot: () => T, @@ -2394,6 +2702,15 @@ if (__DEV__) { updateHookTypesDev(); return mountTransition(); }, + useMutableSource( + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn, + subscribe: MutableSourceSubscribeFn, + ): Snapshot { + currentHookNameInDev = 'useMutableSource'; + updateHookTypesDev(); + return mountMutableSource(source, getSnapshot, subscribe); + }, useSyncExternalStore( subscribe: (() => void) => () => void, getSnapshot: () => T, @@ -2526,6 +2843,15 @@ if (__DEV__) { updateHookTypesDev(); return updateTransition(); }, + useMutableSource( + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn, + subscribe: MutableSourceSubscribeFn, + ): Snapshot { + currentHookNameInDev = 'useMutableSource'; + updateHookTypesDev(); + return updateMutableSource(source, getSnapshot, subscribe); + }, useSyncExternalStore( subscribe: (() => void) => () => void, getSnapshot: () => T, @@ -2659,6 +2985,15 @@ if (__DEV__) { updateHookTypesDev(); return rerenderTransition(); }, + useMutableSource( + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn, + subscribe: MutableSourceSubscribeFn, + ): Snapshot { + currentHookNameInDev = 'useMutableSource'; + updateHookTypesDev(); + return updateMutableSource(source, getSnapshot, subscribe); + }, useSyncExternalStore( subscribe: (() => void) => () => void, getSnapshot: () => T, @@ -2805,6 +3140,16 @@ if (__DEV__) { mountHookTypesDev(); return mountTransition(); }, + useMutableSource( + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn, + subscribe: MutableSourceSubscribeFn, + ): Snapshot { + currentHookNameInDev = 'useMutableSource'; + warnInvalidHookAccess(); + mountHookTypesDev(); + return mountMutableSource(source, getSnapshot, subscribe); + }, useSyncExternalStore( subscribe: (() => void) => () => void, getSnapshot: () => T, @@ -2953,6 +3298,16 @@ if (__DEV__) { updateHookTypesDev(); return updateTransition(); }, + useMutableSource( + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn, + subscribe: MutableSourceSubscribeFn, + ): Snapshot { + currentHookNameInDev = 'useMutableSource'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateMutableSource(source, getSnapshot, subscribe); + }, useSyncExternalStore( subscribe: (() => void) => () => void, getSnapshot: () => T, @@ -3102,6 +3457,16 @@ if (__DEV__) { updateHookTypesDev(); return rerenderTransition(); }, + useMutableSource( + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn, + subscribe: MutableSourceSubscribeFn, + ): Snapshot { + currentHookNameInDev = 'useMutableSource'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateMutableSource(source, getSnapshot, subscribe); + }, useSyncExternalStore( subscribe: (() => void) => () => void, getSnapshot: () => T, diff --git a/packages/react-reconciler/src/ReactFiberHooks.old.js b/packages/react-reconciler/src/ReactFiberHooks.old.js index 4d93786f6b798..b755852fe618d 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.old.js +++ b/packages/react-reconciler/src/ReactFiberHooks.old.js @@ -7,7 +7,12 @@ * @flow */ -import type {ReactContext} from 'shared/ReactTypes'; +import type { + MutableSource, + MutableSourceGetSnapshotFn, + MutableSourceSubscribeFn, + ReactContext, +} from 'shared/ReactTypes'; import type {Fiber, Dispatcher, HookType} from './ReactInternalTypes'; import type {Lanes, Lane} from './ReactFiberLane.old'; import type {HookFlags} from './ReactHookEffectTags'; @@ -45,6 +50,7 @@ import { intersectLanes, isTransitionLane, markRootEntangled, + markRootMutableRead, NoTimestamp, } from './ReactFiberLane.old'; import { @@ -96,6 +102,12 @@ import { makeClientIdInDEV, makeOpaqueHydratingObject, } from './ReactFiberHostConfig'; +import { + getWorkInProgressVersion, + markSourceAsDirty, + setWorkInProgressVersion, + warnAboutMultipleRenderersDEV, +} from './ReactMutableSource.old'; import {getIsRendering} from './ReactCurrentFiber'; import {logStateUpdateScheduled} from './DebugTracing'; import {markStateUpdateScheduled} from './SchedulingProfiler'; @@ -935,6 +947,289 @@ function rerenderReducer( return [newState, dispatch]; } +type MutableSourceMemoizedState = {| + refs: { + getSnapshot: MutableSourceGetSnapshotFn, + setSnapshot: Snapshot => void, + }, + source: MutableSource, + subscribe: MutableSourceSubscribeFn, +|}; + +function readFromUnsubscribedMutableSource( + 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) { + // It's safe to read if the store hasn't been mutated since the last time + // we read something. + isSafeToReadFromSource = currentRenderVersion === version; + } else { + // If there's no version, then this is the first time we've read from the + // source during the current render pass, so we need to do a bit more work. + // What we need to determine is if there are any hooks that already + // subscribed to the source, and if so, whether there are any pending + // mutations that haven't been synchronized yet. + // + // If there are no pending mutations, then `root.mutableReadLanes` will be + // empty, and we know we can safely read. + // + // If there *are* pending mutations, we may still be able to safely read + // if the currently rendering lanes are inclusive of the pending mutation + // lanes, since that guarantees that the value we're about to read from + // the source is consistent with the values that we read during the most + // recent mutation. + isSafeToReadFromSource = isSubsetOfLanes( + renderLanes, + root.mutableReadLanes, + ); + + 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) { + const snapshot = getSnapshot(source._source); + if (__DEV__) { + if (typeof snapshot === 'function') { + console.error( + 'Mutable source should not return a function as the snapshot value. ' + + 'Functions may close over mutable values and cause tearing.', + ); + } + } + return snapshot; + } else { + // This handles the special case of a mutable source being shared between 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); + + // Intentioally throw an error to force React to retry synchronously. During + // the synchronous retry, it will block interleaved mutations, so we should + // get a consistent read. Therefore, the following error should never be + // visible to the user. + + // We expect this error not to be thrown during the synchronous retry, + // because we blocked interleaved mutations. + invariant( + false, + 'Cannot read from mutable source during the current render without tearing. This may be 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; + + // eslint-disable-next-line prefer-const + let [currentSnapshot, setSnapshot] = dispatcher.useState(() => + readFromUnsubscribedMutableSource(root, source, getSnapshot), + ); + let snapshot = currentSnapshot; + + // 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 subscription handler after each commit. + dispatcher.useEffect(() => { + refs.getSnapshot = getSnapshot; + + // Normally the dispatch function for a state hook never changes, + // but this hook recreates the queue in certain cases to avoid updates from stale sources. + // handleChange() below needs to reference the dispatch function without re-subscribing, + // so we use a ref to ensure that it always has the latest version. + refs.setSnapshot = setSnapshot; + + // Check for a possible change between when we last rendered now. + const maybeNewVersion = getVersion(source._source); + if (!is(version, maybeNewVersion)) { + const maybeNewSnapshot = getSnapshot(source._source); + if (__DEV__) { + if (typeof maybeNewSnapshot === 'function') { + console.error( + 'Mutable source should not return a function as the snapshot value. ' + + 'Functions may close over mutable values and cause tearing.', + ); + } + } + + if (!is(snapshot, maybeNewSnapshot)) { + setSnapshot(maybeNewSnapshot); + + const lane = requestUpdateLane(fiber); + markRootMutableRead(root, lane); + } + // If the source mutated between render and now, + // there may be state updates already scheduled from the old source. + // Entangle the updates so that they render in the same batch. + markRootEntangled(root, root.mutableReadLanes); + } + }, [getSnapshot, source, subscribe]); + + // If we got a new source or subscribe function, re-subscribe in a passive effect. + dispatcher.useEffect(() => { + const handleChange = () => { + const latestGetSnapshot = refs.getSnapshot; + const latestSetSnapshot = refs.setSnapshot; + + try { + latestSetSnapshot(latestGetSnapshot(source._source)); + + // Record a pending mutable source update with the same expiration time. + const lane = requestUpdateLane(fiber); + + markRootMutableRead(root, lane); + } 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. + latestSetSnapshot( + (() => { + throw error; + }: any), + ); + } + }; + + const unsubscribe = subscribe(source._source, handleChange); + if (__DEV__) { + if (typeof unsubscribe !== 'function') { + console.error( + 'Mutable source subscribe function must return an unsubscribe function.', + ); + } + } + + 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 updates (since they are no longer relevant) + // and treat reading from the source as we do in the mount case. + if ( + !is(prevGetSnapshot, getSnapshot) || + !is(prevSource, source) || + !is(prevSubscribe, subscribe) + ) { + // Create a new queue and setState method, + // So if there are interleaved updates, they get pushed to the older queue. + // When this becomes current, the previous queue and dispatch method will be discarded, + // including any interleaving updates that occur. + const newQueue: UpdateQueue> = { + pending: null, + interleaved: null, + lanes: NoLanes, + dispatch: null, + lastRenderedReducer: basicStateReducer, + lastRenderedState: snapshot, + }; + newQueue.dispatch = setSnapshot = (dispatchAction.bind( + null, + currentlyRenderingFiber, + newQueue, + ): any); + stateHook.queue = newQueue; + stateHook.baseQueue = null; + snapshot = readFromUnsubscribedMutableSource(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, + setSnapshot: (null: any), + }, + 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 mountSyncExternalStore( subscribe: (() => void) => () => void, getSnapshot: () => T, @@ -2035,6 +2330,7 @@ export const ContextOnlyDispatcher: Dispatcher = { useDebugValue: throwInvalidHookError, useDeferredValue: throwInvalidHookError, useTransition: throwInvalidHookError, + useMutableSource: throwInvalidHookError, useSyncExternalStore: throwInvalidHookError, useOpaqueIdentifier: throwInvalidHookError, @@ -2061,6 +2357,7 @@ const HooksDispatcherOnMount: Dispatcher = { useDebugValue: mountDebugValue, useDeferredValue: mountDeferredValue, useTransition: mountTransition, + useMutableSource: mountMutableSource, useSyncExternalStore: mountSyncExternalStore, useOpaqueIdentifier: mountOpaqueIdentifier, @@ -2087,6 +2384,7 @@ const HooksDispatcherOnUpdate: Dispatcher = { useDebugValue: updateDebugValue, useDeferredValue: updateDeferredValue, useTransition: updateTransition, + useMutableSource: updateMutableSource, useSyncExternalStore: updateSyncExternalStore, useOpaqueIdentifier: updateOpaqueIdentifier, @@ -2113,6 +2411,7 @@ const HooksDispatcherOnRerender: Dispatcher = { useDebugValue: updateDebugValue, useDeferredValue: rerenderDeferredValue, useTransition: rerenderTransition, + useMutableSource: updateMutableSource, useSyncExternalStore: mountSyncExternalStore, useOpaqueIdentifier: rerenderOpaqueIdentifier, @@ -2262,6 +2561,15 @@ if (__DEV__) { mountHookTypesDev(); return mountTransition(); }, + useMutableSource( + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn, + subscribe: MutableSourceSubscribeFn, + ): Snapshot { + currentHookNameInDev = 'useMutableSource'; + mountHookTypesDev(); + return mountMutableSource(source, getSnapshot, subscribe); + }, useSyncExternalStore( subscribe: (() => void) => () => void, getSnapshot: () => T, @@ -2394,6 +2702,15 @@ if (__DEV__) { updateHookTypesDev(); return mountTransition(); }, + useMutableSource( + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn, + subscribe: MutableSourceSubscribeFn, + ): Snapshot { + currentHookNameInDev = 'useMutableSource'; + updateHookTypesDev(); + return mountMutableSource(source, getSnapshot, subscribe); + }, useSyncExternalStore( subscribe: (() => void) => () => void, getSnapshot: () => T, @@ -2526,6 +2843,15 @@ if (__DEV__) { updateHookTypesDev(); return updateTransition(); }, + useMutableSource( + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn, + subscribe: MutableSourceSubscribeFn, + ): Snapshot { + currentHookNameInDev = 'useMutableSource'; + updateHookTypesDev(); + return updateMutableSource(source, getSnapshot, subscribe); + }, useSyncExternalStore( subscribe: (() => void) => () => void, getSnapshot: () => T, @@ -2659,6 +2985,15 @@ if (__DEV__) { updateHookTypesDev(); return rerenderTransition(); }, + useMutableSource( + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn, + subscribe: MutableSourceSubscribeFn, + ): Snapshot { + currentHookNameInDev = 'useMutableSource'; + updateHookTypesDev(); + return updateMutableSource(source, getSnapshot, subscribe); + }, useSyncExternalStore( subscribe: (() => void) => () => void, getSnapshot: () => T, @@ -2805,6 +3140,16 @@ if (__DEV__) { mountHookTypesDev(); return mountTransition(); }, + useMutableSource( + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn, + subscribe: MutableSourceSubscribeFn, + ): Snapshot { + currentHookNameInDev = 'useMutableSource'; + warnInvalidHookAccess(); + mountHookTypesDev(); + return mountMutableSource(source, getSnapshot, subscribe); + }, useSyncExternalStore( subscribe: (() => void) => () => void, getSnapshot: () => T, @@ -2953,6 +3298,16 @@ if (__DEV__) { updateHookTypesDev(); return updateTransition(); }, + useMutableSource( + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn, + subscribe: MutableSourceSubscribeFn, + ): Snapshot { + currentHookNameInDev = 'useMutableSource'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateMutableSource(source, getSnapshot, subscribe); + }, useSyncExternalStore( subscribe: (() => void) => () => void, getSnapshot: () => T, @@ -3102,6 +3457,16 @@ if (__DEV__) { updateHookTypesDev(); return rerenderTransition(); }, + useMutableSource( + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn, + subscribe: MutableSourceSubscribeFn, + ): Snapshot { + currentHookNameInDev = 'useMutableSource'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateMutableSource(source, getSnapshot, subscribe); + }, useSyncExternalStore( subscribe: (() => void) => () => void, getSnapshot: () => T, diff --git a/packages/react-reconciler/src/ReactFiberLane.new.js b/packages/react-reconciler/src/ReactFiberLane.new.js index 15d8e7ff019d8..ad124a432a4a2 100644 --- a/packages/react-reconciler/src/ReactFiberLane.new.js +++ b/packages/react-reconciler/src/ReactFiberLane.new.js @@ -283,9 +283,9 @@ export function getNextLanes(root: FiberRoot, wipLanes: Lanes): Lanes { // time it takes to show the final state, which is what they are actually // waiting for. // - // For those exceptions where entanglement is semantically important, we - // should ensure that there is no partial work at the time we apply the - // entanglement. + // For those exceptions where entanglement is semantically important, like + // useMutableSource, we should ensure that there is no partial work at the + // time we apply the entanglement. const entangledLanes = root.entangledLanes; if (entangledLanes !== NoLanes) { const entanglements = root.entanglements; @@ -617,6 +617,10 @@ export function markRootPinged( root.pingedLanes |= root.suspendedLanes & pingedLanes; } +export function markRootMutableRead(root: FiberRoot, updateLane: Lane) { + root.mutableReadLanes |= updateLane & root.pendingLanes; +} + export function markRootFinished(root: FiberRoot, remainingLanes: Lanes) { const noLongerPendingLanes = root.pendingLanes & ~remainingLanes; @@ -627,6 +631,7 @@ export function markRootFinished(root: FiberRoot, remainingLanes: Lanes) { root.pingedLanes = 0; root.expiredLanes &= remainingLanes; + root.mutableReadLanes &= remainingLanes; root.entangledLanes &= remainingLanes; diff --git a/packages/react-reconciler/src/ReactFiberLane.old.js b/packages/react-reconciler/src/ReactFiberLane.old.js index 49927abb20bb7..4a064a3846515 100644 --- a/packages/react-reconciler/src/ReactFiberLane.old.js +++ b/packages/react-reconciler/src/ReactFiberLane.old.js @@ -283,9 +283,9 @@ export function getNextLanes(root: FiberRoot, wipLanes: Lanes): Lanes { // time it takes to show the final state, which is what they are actually // waiting for. // - // For those exceptions where entanglement is semantically important, we - // should ensure that there is no partial work at the time we apply the - // entanglement. + // For those exceptions where entanglement is semantically important, like + // useMutableSource, we should ensure that there is no partial work at the + // time we apply the entanglement. const entangledLanes = root.entangledLanes; if (entangledLanes !== NoLanes) { const entanglements = root.entanglements; @@ -617,6 +617,10 @@ export function markRootPinged( root.pingedLanes |= root.suspendedLanes & pingedLanes; } +export function markRootMutableRead(root: FiberRoot, updateLane: Lane) { + root.mutableReadLanes |= updateLane & root.pendingLanes; +} + export function markRootFinished(root: FiberRoot, remainingLanes: Lanes) { const noLongerPendingLanes = root.pendingLanes & ~remainingLanes; @@ -627,6 +631,7 @@ export function markRootFinished(root: FiberRoot, remainingLanes: Lanes) { root.pingedLanes = 0; root.expiredLanes &= remainingLanes; + root.mutableReadLanes &= remainingLanes; root.entangledLanes &= remainingLanes; diff --git a/packages/react-reconciler/src/ReactFiberReconciler.js b/packages/react-reconciler/src/ReactFiberReconciler.js index 1f92626fa8b2c..d25783164e984 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.js @@ -46,6 +46,7 @@ import { findBoundingRects as findBoundingRects_old, focusWithin as focusWithin_old, observeVisibleRects as observeVisibleRects_old, + registerMutableSourceForHydration as registerMutableSourceForHydration_old, runWithPriority as runWithPriority_old, getCurrentUpdatePriority as getCurrentUpdatePriority_old, } from './ReactFiberReconciler.old'; @@ -82,6 +83,7 @@ import { findBoundingRects as findBoundingRects_new, focusWithin as focusWithin_new, observeVisibleRects as observeVisibleRects_new, + registerMutableSourceForHydration as registerMutableSourceForHydration_new, runWithPriority as runWithPriority_new, getCurrentUpdatePriority as getCurrentUpdatePriority_new, } from './ReactFiberReconciler.new'; @@ -182,6 +184,9 @@ export const focusWithin = enableNewReconciler export const observeVisibleRects = enableNewReconciler ? observeVisibleRects_new : observeVisibleRects_old; +export const registerMutableSourceForHydration = enableNewReconciler + ? registerMutableSourceForHydration_new + : registerMutableSourceForHydration_old; export const runWithPriority = enableNewReconciler ? runWithPriority_new : runWithPriority_old; diff --git a/packages/react-reconciler/src/ReactFiberReconciler.new.js b/packages/react-reconciler/src/ReactFiberReconciler.new.js index c9b78964c1725..90503a183d943 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.new.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.new.js @@ -90,6 +90,7 @@ import { } from './ReactFiberHotReloading.new'; import {markRenderScheduled} from './SchedulingProfiler'; import ReactVersion from 'shared/ReactVersion'; +export {registerMutableSourceForHydration} from './ReactMutableSource.new'; export {createPortal} from './ReactPortal'; export { createComponentSelector, diff --git a/packages/react-reconciler/src/ReactFiberReconciler.old.js b/packages/react-reconciler/src/ReactFiberReconciler.old.js index 6ad7587eed230..2608bc260264f 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.old.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.old.js @@ -90,6 +90,7 @@ import { } from './ReactFiberHotReloading.old'; import {markRenderScheduled} from './SchedulingProfiler'; import ReactVersion from 'shared/ReactVersion'; +export {registerMutableSourceForHydration} from './ReactMutableSource.old'; export {createPortal} from './ReactPortal'; export { createComponentSelector, diff --git a/packages/react-reconciler/src/ReactFiberRoot.new.js b/packages/react-reconciler/src/ReactFiberRoot.new.js index 84e487c248755..e96b12344d093 100644 --- a/packages/react-reconciler/src/ReactFiberRoot.new.js +++ b/packages/react-reconciler/src/ReactFiberRoot.new.js @@ -10,7 +10,7 @@ import type {FiberRoot, SuspenseHydrationCallbacks} from './ReactInternalTypes'; import type {RootTag} from './ReactRootTags'; -import {noTimeout} from './ReactFiberHostConfig'; +import {noTimeout, supportsHydration} from './ReactFiberHostConfig'; import {createHostRootFiber} from './ReactFiber.new'; import { NoLane, @@ -49,6 +49,7 @@ function FiberRootNode(containerInfo, tag, hydrate) { this.suspendedLanes = NoLanes; this.pingedLanes = NoLanes; this.expiredLanes = NoLanes; + this.mutableReadLanes = NoLanes; this.finishedLanes = NoLanes; this.entangledLanes = NoLanes; @@ -59,6 +60,10 @@ function FiberRootNode(containerInfo, tag, hydrate) { this.pooledCacheLanes = NoLanes; } + if (supportsHydration) { + this.mutableSourceEagerHydrationData = null; + } + if (enableSuspenseCallback) { this.hydrationCallbacks = null; } diff --git a/packages/react-reconciler/src/ReactFiberRoot.old.js b/packages/react-reconciler/src/ReactFiberRoot.old.js index 5750e21ddecac..b6e198ba2e002 100644 --- a/packages/react-reconciler/src/ReactFiberRoot.old.js +++ b/packages/react-reconciler/src/ReactFiberRoot.old.js @@ -10,7 +10,7 @@ import type {FiberRoot, SuspenseHydrationCallbacks} from './ReactInternalTypes'; import type {RootTag} from './ReactRootTags'; -import {noTimeout} from './ReactFiberHostConfig'; +import {noTimeout, supportsHydration} from './ReactFiberHostConfig'; import {createHostRootFiber} from './ReactFiber.old'; import { NoLane, @@ -49,6 +49,7 @@ function FiberRootNode(containerInfo, tag, hydrate) { this.suspendedLanes = NoLanes; this.pingedLanes = NoLanes; this.expiredLanes = NoLanes; + this.mutableReadLanes = NoLanes; this.finishedLanes = NoLanes; this.entangledLanes = NoLanes; @@ -59,6 +60,10 @@ function FiberRootNode(containerInfo, tag, hydrate) { this.pooledCacheLanes = NoLanes; } + if (supportsHydration) { + this.mutableSourceEagerHydrationData = null; + } + if (enableSuspenseCallback) { this.hydrationCallbacks = null; } diff --git a/packages/react-reconciler/src/ReactFiberUnwindWork.new.js b/packages/react-reconciler/src/ReactFiberUnwindWork.new.js index cb0d90e1a7f5f..152837286f5d2 100644 --- a/packages/react-reconciler/src/ReactFiberUnwindWork.new.js +++ b/packages/react-reconciler/src/ReactFiberUnwindWork.new.js @@ -13,6 +13,7 @@ import type {Lanes} from './ReactFiberLane.new'; import type {SuspenseState} from './ReactFiberSuspenseComponent.new'; import type {Cache, SpawnedCachePool} from './ReactFiberCacheComponent.new'; +import {resetWorkInProgressVersions as resetMutableSourceWorkInProgressVersions} from './ReactMutableSource.new'; import { ClassComponent, HostRoot, @@ -82,6 +83,7 @@ function unwindWork(workInProgress: Fiber, renderLanes: Lanes) { } popHostContainer(workInProgress); popTopLevelLegacyContextObject(workInProgress); + resetMutableSourceWorkInProgressVersions(); const flags = workInProgress.flags; invariant( (flags & DidCapture) === NoFlags, @@ -177,6 +179,7 @@ function unwindInterruptedWork(interruptedWork: Fiber, renderLanes: Lanes) { } popHostContainer(interruptedWork); popTopLevelLegacyContextObject(interruptedWork); + resetMutableSourceWorkInProgressVersions(); break; } case HostComponent: { diff --git a/packages/react-reconciler/src/ReactFiberUnwindWork.old.js b/packages/react-reconciler/src/ReactFiberUnwindWork.old.js index 8fb63177c01ec..88861db778be3 100644 --- a/packages/react-reconciler/src/ReactFiberUnwindWork.old.js +++ b/packages/react-reconciler/src/ReactFiberUnwindWork.old.js @@ -13,6 +13,7 @@ import type {Lanes} from './ReactFiberLane.old'; import type {SuspenseState} from './ReactFiberSuspenseComponent.old'; import type {Cache, SpawnedCachePool} from './ReactFiberCacheComponent.old'; +import {resetWorkInProgressVersions as resetMutableSourceWorkInProgressVersions} from './ReactMutableSource.old'; import { ClassComponent, HostRoot, @@ -82,6 +83,7 @@ function unwindWork(workInProgress: Fiber, renderLanes: Lanes) { } popHostContainer(workInProgress); popTopLevelLegacyContextObject(workInProgress); + resetMutableSourceWorkInProgressVersions(); const flags = workInProgress.flags; invariant( (flags & DidCapture) === NoFlags, @@ -177,6 +179,7 @@ function unwindInterruptedWork(interruptedWork: Fiber, renderLanes: Lanes) { } popHostContainer(interruptedWork); popTopLevelLegacyContextObject(interruptedWork); + resetMutableSourceWorkInProgressVersions(); break; } case HostComponent: { diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index bf7ffb2b33d17..673298b8da3de 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -8,7 +8,14 @@ */ import type {Source} from 'shared/ReactElementType'; -import type {RefObject, ReactContext} from 'shared/ReactTypes'; +import type { + RefObject, + ReactContext, + MutableSourceSubscribeFn, + MutableSourceGetSnapshotFn, + MutableSourceVersion, + MutableSource, +} from 'shared/ReactTypes'; import type {SuspenseInstance} from './ReactFiberHostConfig'; import type {WorkTag} from './ReactWorkTags'; import type {TypeOfMode} from './ReactTypeOfMode'; @@ -34,6 +41,7 @@ export type HookType = | 'useDebugValue' | 'useDeferredValue' | 'useTransition' + | 'useMutableSource' | 'useSyncExternalStore' | 'useOpaqueIdentifier' | 'useCacheRefresh'; @@ -206,6 +214,11 @@ type BaseFiberRootProperties = {| // Determines if we should attempt to hydrate on the initial mount +hydrate: boolean, + // Used by useMutableSource hook to avoid tearing during hydration. + mutableSourceEagerHydrationData?: Array< + MutableSource | MutableSourceVersion, + > | null, + // Node returned by Scheduler.scheduleCallback. Represents the next rendering // task that the root will work on. callbackNode: *, @@ -217,6 +230,7 @@ type BaseFiberRootProperties = {| suspendedLanes: Lanes, pingedLanes: Lanes, expiredLanes: Lanes, + mutableReadLanes: Lanes, finishedLanes: Lanes, @@ -291,6 +305,11 @@ export type Dispatcher = {| useDebugValue(value: T, formatterFn: ?(value: T) => mixed): void, useDeferredValue(value: T): T, useTransition(): [boolean, (() => void) => void], + useMutableSource( + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn, + subscribe: MutableSourceSubscribeFn, + ): Snapshot, useSyncExternalStore( subscribe: (() => void) => () => void, getSnapshot: () => T, diff --git a/packages/react-reconciler/src/ReactMutableSource.new.js b/packages/react-reconciler/src/ReactMutableSource.new.js new file mode 100644 index 0000000000000..61809d33f800d --- /dev/null +++ b/packages/react-reconciler/src/ReactMutableSource.new.js @@ -0,0 +1,108 @@ +/** + * 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, MutableSourceVersion} from 'shared/ReactTypes'; +import type {FiberRoot} from './ReactInternalTypes'; + +import {isPrimaryRenderer} from './ReactFiberHostConfig'; + +// 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. +const workInProgressSources: Array> = []; + +let rendererSigil; +if (__DEV__) { + // Used to detect multiple renderers using the same mutable source. + rendererSigil = {}; +} + +export function markSourceAsDirty(mutableSource: MutableSource): void { + workInProgressSources.push(mutableSource); +} + +export function resetWorkInProgressVersions(): void { + for (let i = 0; i < workInProgressSources.length; i++) { + const mutableSource = workInProgressSources[i]; + if (isPrimaryRenderer) { + mutableSource._workInProgressVersionPrimary = null; + } else { + mutableSource._workInProgressVersionSecondary = null; + } + } + workInProgressSources.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; + } else { + mutableSource._workInProgressVersionSecondary = version; + } + workInProgressSources.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.', + ); + } + } + } +} + +// Eager reads the version of a mutable source and stores it on the root. +// This ensures that the version used for server rendering matches the one +// that is eventually read during hydration. +// If they don't match there's a potential tear and a full deopt render is required. +export function registerMutableSourceForHydration( + root: FiberRoot, + mutableSource: MutableSource, +): void { + const getVersion = mutableSource._getVersion; + const version = getVersion(mutableSource._source); + + // TODO Clear this data once all pending hydration work is finished. + // Retaining it forever may interfere with GC. + if (root.mutableSourceEagerHydrationData == null) { + root.mutableSourceEagerHydrationData = [mutableSource, version]; + } else { + root.mutableSourceEagerHydrationData.push(mutableSource, version); + } +} diff --git a/packages/react-reconciler/src/ReactMutableSource.old.js b/packages/react-reconciler/src/ReactMutableSource.old.js new file mode 100644 index 0000000000000..61809d33f800d --- /dev/null +++ b/packages/react-reconciler/src/ReactMutableSource.old.js @@ -0,0 +1,108 @@ +/** + * 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, MutableSourceVersion} from 'shared/ReactTypes'; +import type {FiberRoot} from './ReactInternalTypes'; + +import {isPrimaryRenderer} from './ReactFiberHostConfig'; + +// 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. +const workInProgressSources: Array> = []; + +let rendererSigil; +if (__DEV__) { + // Used to detect multiple renderers using the same mutable source. + rendererSigil = {}; +} + +export function markSourceAsDirty(mutableSource: MutableSource): void { + workInProgressSources.push(mutableSource); +} + +export function resetWorkInProgressVersions(): void { + for (let i = 0; i < workInProgressSources.length; i++) { + const mutableSource = workInProgressSources[i]; + if (isPrimaryRenderer) { + mutableSource._workInProgressVersionPrimary = null; + } else { + mutableSource._workInProgressVersionSecondary = null; + } + } + workInProgressSources.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; + } else { + mutableSource._workInProgressVersionSecondary = version; + } + workInProgressSources.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.', + ); + } + } + } +} + +// Eager reads the version of a mutable source and stores it on the root. +// This ensures that the version used for server rendering matches the one +// that is eventually read during hydration. +// If they don't match there's a potential tear and a full deopt render is required. +export function registerMutableSourceForHydration( + root: FiberRoot, + mutableSource: MutableSource, +): void { + const getVersion = mutableSource._getVersion; + const version = getVersion(mutableSource._source); + + // TODO Clear this data once all pending hydration work is finished. + // Retaining it forever may interfere with GC. + if (root.mutableSourceEagerHydrationData == null) { + root.mutableSourceEagerHydrationData = [mutableSource, version]; + } else { + root.mutableSourceEagerHydrationData.push(mutableSource, version); + } +} 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 0000000000000..a6d99f9802084 --- /dev/null +++ b/packages/react-reconciler/src/__tests__/useMutableSource-test.internal.js @@ -0,0 +1,1981 @@ +/** + * 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 act; +let createMutableSource; +let useMutableSource; + +function loadModules() { + jest.resetModules(); + jest.useFakeTimers(); + + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.enableProfilerTimer = true; + + React = require('react'); + ReactNoop = require('react-noop-renderer'); + Scheduler = require('scheduler'); + act = require('jest-react').act; + + // Stable entrypoints export with "unstable_" prefix. + createMutableSource = + React.createMutableSource || React.unstable_createMutableSource; + useMutableSource = React.useMutableSource || React.unstable_useMutableSource; +} + +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 = initialValueA; + let valueB = initialValueB; + + 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 Component({getSnapshot, label, mutableSource, subscribe}) { + const snapshot = useMutableSource(mutableSource, getSnapshot, subscribe); + Scheduler.unstable_yieldValue(`${label}:${snapshot}`); + return
{`${label}:${snapshot}`}
; + } + + beforeEach(loadModules); + + it('should subscribe to a source and schedule updates when it changes', () => { + const source = createSource('one'); + const mutableSource = createMutableSource(source, param => param.version); + + 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']); + + // Unmounting a component should remove its subscription. + ReactNoop.renderToRootWithID( + <> + + , + 'root', + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYield(['a:two', 'Sync effect']); + ReactNoop.flushPassiveEffects(); + expect(source.listenerCount).toBe(1); + + // Unmounting 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, param => param.version); + + act(() => { + if (gate(flags => flags.enableSyncDefaultUpdates)) { + React.startTransition(() => { + ReactNoop.render( + <> + + + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + }); + } else { + 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, param => param.version); + + 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, + param => param.versionA, + ); + + const sourceB = createSource('b-one'); + const mutableSourceB = createMutableSource( + sourceB, + param => param.versionB, + ); + + act(() => { + ReactNoop.render( + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYield(['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, param => param.version); + + 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 unsubscribe. + 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, param => param.version); + + 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. + if (gate(flags => flags.enableSyncDefaultUpdates)) { + React.startTransition(() => { + source.value = 'two'; + }); + } else { + source.value = 'two'; + } + expect(Scheduler).toFlushAndYieldThrough(['a:two']); + + // Re-renders that occur before the update 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, param => param.version); + + 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, param => param.version); + + 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. + ReactNoop.idleUpdates(() => { + 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 throw and restart render if source and snapshot are unavailable during a sync update', () => { + const source = createSource('one'); + const mutableSource = createMutableSource(source, param => param.version); + + 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. + ReactNoop.idleUpdates(() => { + 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('a:one', 'b:one'); + const mutableSource = createMutableSource(source, param => param.version); + + // 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('a:one', 'b:one'); + const mutableSource = createMutableSource(source, param => param.version); + + // 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 changed yet, there are no pending updates, + // so it is considered safe to read from when we start this render. + if (gate(flags => flags.enableSyncDefaultUpdates)) { + React.startTransition(() => { + ReactNoop.render( + <> + + + + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + }); + } else { + 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, param => param.version); + + 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, param => param.version); + + 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). + source.value = 'two'; + + // Schedule a higher priority update that changes getSnapshot. + ReactNoop.flushSync(() => { + updateGetSnapshot(() => newGetSnapshot); + }); + + expect(Scheduler).toHaveYielded(['only:new:two']); + }); + }); + + it('should recover from a mutation during yield when other work is scheduled', () => { + const source = createSource('one'); + const mutableSource = createMutableSource(source, param => param.version); + + act(() => { + // Start a render that uses the mutable source. + if (gate(flags => flags.enableSyncDefaultUpdates)) { + React.startTransition(() => { + ReactNoop.render( + <> + + + , + ); + }); + } else { + 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, param => param.version); + + 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, param => param.version); + + 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, param => param.version); + + 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 unsubscribe', () => { + const source = createSource('one'); + const mutableSource = createMutableSource(source, param => param.version); + + 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(); + + // Unmounting 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, param => param.version); + + const getSnapshotA = () => source.value.a; + const getSnapshotB = () => source.value.b; + + function mutateB(newB) { + source.value = { + ...source.value, + b: newB, + }; + } + + function App({getSnapshot}) { + const state = useMutableSource( + mutableSource, + getSnapshot, + defaultSubscribe, + ); + return state; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + root.render(); + }); + expect(root).toMatchRenderedOutput('initial'); + + await act(async () => { + mutateB('Updated B'); + root.render(); + }); + expect(root).toMatchRenderedOutput('Updated B'); + + await act(async () => { + mutateB('Another update'); + }); + expect(root).toMatchRenderedOutput('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, param => param.version); + + 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, + param => param.versionA, + ); + const mutableSourceB = createMutableSource( + sourceB, + param => param.versionB, + ); + + 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, param => param.version); + + 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('baz'); + root.render( + , + ); + + // If this update were processed before the next mutation, + // it would be expected to yield "baz" and "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']); + }); + + it('getSnapshot changes and then source is mutated in between paint and passive effect phase', async () => { + const source = createSource({ + a: 'foo', + b: 'bar', + }); + const mutableSource = createMutableSource(source, param => param.version); + + function mutateB(newB) { + source.value = { + ...source.value, + b: newB, + }; + } + + const getSnapshotA = () => source.value.a; + const getSnapshotB = () => source.value.b; + + function App({getSnapshot}) { + const value = useMutableSource( + mutableSource, + getSnapshot, + defaultSubscribe, + ); + + Scheduler.unstable_yieldValue('Render: ' + value); + React.useEffect(() => { + Scheduler.unstable_yieldValue('Commit: ' + value); + }, [value]); + + return value; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['Render: foo', 'Commit: foo']); + + await act(async () => { + // Switch getSnapshot to read from B instead + root.render(); + // Render and finish the tree, but yield right after paint, before + // the passive effects have fired. + expect(Scheduler).toFlushUntilNextPaint(['Render: bar']); + // Then mutate B. + mutateB('baz'); + }); + expect(Scheduler).toHaveYielded([ + // Fires the effect from the previous render + 'Commit: bar', + // During that effect, it should detect that the snapshot has changed + // and re-render. + 'Render: baz', + 'Commit: baz', + ]); + expect(root).toMatchRenderedOutput('baz'); + }); + + it('getSnapshot changes and then source is mutated in between paint and passive effect phase, case 2', async () => { + const source = createSource({ + a: 'a0', + b: 'b0', + }); + const mutableSource = createMutableSource(source, param => param.version); + + 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, + ); + + return `first: ${first}, second: ${second}`; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + root.render( + , + ); + }); + expect(root.getChildrenAsJSX()).toEqual('first: a0, second: b0'); + + await act(async () => { + // Switch the second getSnapshot to also read from A + root.render( + , + ); + // Render and finish the tree, but yield right after paint, before + // the passive effects have fired. + expect(Scheduler).toFlushUntilNextPaint([]); + + // Now mutate A. Both hooks should update. + // This is at high priority so that it doesn't get batched with default + // priority updates that might fire during the passive effect + await act(async () => { + ReactNoop.discreteUpdates(() => { + mutateA('a1'); + }); + }); + + expect(root).toMatchRenderedOutput('first: a1, second: a1'); + }); + + expect(root.getChildrenAsJSX()).toEqual('first: a1, second: a1'); + }); + + it( + 'if source is mutated after initial read but before subscription is set ' + + 'up, should still entangle all pending mutations even if snapshot of ' + + 'new subscription happens to match', + async () => { + const source = createSource({ + a: 'a0', + b: 'b0', + }); + const mutableSource = createMutableSource(source, param => param.version); + + 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 Read({getSnapshot}) { + const value = useMutableSource( + mutableSource, + getSnapshot, + defaultSubscribe, + ); + Scheduler.unstable_yieldValue(value); + return value; + } + + function Text({text}) { + Scheduler.unstable_yieldValue(text); + return text; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + root.render( + <> + + , + ); + }); + expect(Scheduler).toHaveYielded(['a0']); + expect(root).toMatchRenderedOutput('a0'); + + await act(async () => { + if (gate(flags => flags.enableSyncDefaultUpdates)) { + React.startTransition(() => { + root.render( + <> + + + + , + ); + }); + } else { + root.render( + <> + + + + , + ); + } + + expect(Scheduler).toFlushAndYieldThrough(['a0', 'b0']); + // Mutate in an event. This schedules a subscription update on a, which + // already mounted, but not b, which hasn't subscribed yet. + mutateA('a1'); + mutateB('b1'); + + // Mutate again at lower priority. This will schedule another subscription + // update on a, but not b. When b mounts and subscriptions, the value it + // read during render will happen to match the latest value. But it should + // still entangle the updates to prevent the previous update (a1) from + // rendering by itself. + React.startTransition(() => { + mutateA('a0'); + mutateB('b0'); + }); + // Finish the current render + expect(Scheduler).toFlushUntilNextPaint(['c']); + // a0 will re-render because of the mutation update. But it should show + // the latest value, not the intermediate one, to avoid tearing with b. + expect(Scheduler).toFlushUntilNextPaint(['a0']); + + expect(root).toMatchRenderedOutput('a0b0c'); + // We should be done. + expect(Scheduler).toFlushAndYield([]); + expect(root).toMatchRenderedOutput('a0b0c'); + }); + }, + ); + + it('warns about functions being used as snapshot values', async () => { + const source = createSource(() => 'a'); + const mutableSource = createMutableSource(source, param => param.version); + + const getSnapshot = () => source.value; + + function Read() { + const fn = useMutableSource(mutableSource, getSnapshot, defaultSubscribe); + const value = fn(); + Scheduler.unstable_yieldValue(value); + return value; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + root.render( + <> + + , + ); + expect(() => expect(Scheduler).toFlushAndYield(['a'])).toErrorDev( + 'Mutable source should not return a function as the snapshot value.', + ); + }); + expect(root).toMatchRenderedOutput('a'); + }); + + it('getSnapshot changes and then source is mutated during interleaved event', async () => { + const {useEffect} = React; + + const source = createComplexSource('1', '2'); + const mutableSource = createMutableSource(source, param => param.version); + + // Subscribe to part of the store. + const getSnapshotA = s => s.valueA; + const subscribeA = (s, callback) => s.subscribeA(callback); + const configA = [getSnapshotA, subscribeA]; + + const getSnapshotB = s => s.valueB; + const subscribeB = (s, callback) => s.subscribeB(callback); + const configB = [getSnapshotB, subscribeB]; + + function App({parentConfig, childConfig}) { + const [getSnapshot, subscribe] = parentConfig; + const parentValue = useMutableSource( + mutableSource, + getSnapshot, + subscribe, + ); + + Scheduler.unstable_yieldValue('Parent: ' + parentValue); + + return ( + + ); + } + + function Child({parentConfig, childConfig, parentValue}) { + const [getSnapshot, subscribe] = childConfig; + const childValue = useMutableSource( + mutableSource, + getSnapshot, + subscribe, + ); + + Scheduler.unstable_yieldValue('Child: ' + childValue); + + let result = `${parentValue}, ${childValue}`; + + if (parentConfig === childConfig) { + // When both components read using the same config, the two values + // must be consistent. + if (parentValue !== childValue) { + result = 'Oops, tearing!'; + } + } + + useEffect(() => { + Scheduler.unstable_yieldValue('Commit: ' + result); + }, [result]); + + return result; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['Parent: 1', 'Child: 2', 'Commit: 1, 2']); + + await act(async () => { + // Switch the parent and the child to read using the same config + if (gate(flags => flags.enableSyncDefaultUpdates)) { + React.startTransition(() => { + root.render(); + }); + } else { + root.render(); + } + // Start rendering the parent, but yield before rendering the child + expect(Scheduler).toFlushAndYieldThrough(['Parent: 2']); + + // Mutate the config. This is at lower priority so that 1) to make sure + // it doesn't happen to get batched with the in-progress render, and 2) + // so it doesn't interrupt the in-progress render. + React.startTransition(() => { + source.valueB = '3'; + }); + + if (gate(flags => flags.enableSyncDefaultUpdates)) { + // In default sync mode, all of the updates flush sync. + expect(Scheduler).toFlushAndYieldThrough([ + // The partial render completes + 'Child: 2', + 'Commit: 2, 2', + 'Parent: 3', + 'Child: 3', + ]); + + expect(Scheduler).toFlushAndYield([ + // Now finish the rest of the update + 'Commit: 3, 3', + ]); + } else { + expect(Scheduler).toFlushAndYieldThrough([ + // The partial render completes + 'Child: 2', + 'Commit: 2, 2', + ]); + + // Now there are two pending mutations at different priorities. But they + // both read the same version of the mutable source, so we must render + // them simultaneously. + // + expect(Scheduler).toFlushAndYieldThrough([ + 'Parent: 3', + // Demonstrates that we can yield here + ]); + expect(Scheduler).toFlushAndYield([ + // Now finish the rest of the update + 'Child: 3', + 'Commit: 3, 3', + ]); + } + }); + }); + + it('should not tear with newly mounted component when updates were scheduled at a lower priority', async () => { + const source = createSource('one'); + const mutableSource = createMutableSource(source, param => param.version); + + let committedA = null; + let committedB = null; + + const onRender = () => { + if (committedB !== null) { + expect(committedA).toBe(committedB); + } + }; + + function ComponentA() { + const snapshot = useMutableSource( + mutableSource, + defaultGetSnapshot, + defaultSubscribe, + ); + Scheduler.unstable_yieldValue(`a:${snapshot}`); + React.useEffect(() => { + committedA = snapshot; + }, [snapshot]); + return
    {`a:${snapshot}`}
    ; + } + function ComponentB() { + const snapshot = useMutableSource( + mutableSource, + defaultGetSnapshot, + defaultSubscribe, + ); + Scheduler.unstable_yieldValue(`b:${snapshot}`); + React.useEffect(() => { + committedB = snapshot; + }, [snapshot]); + return
    {`b:${snapshot}`}
    ; + } + + // Mount ComponentA with data version 1 + act(() => { + ReactNoop.render( + + + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + }); + expect(Scheduler).toHaveYielded(['a:one', 'Sync effect']); + expect(source.listenerCount).toBe(1); + + // Mount ComponentB with version 1 (but don't commit it) + act(() => { + ReactNoop.render( + + + + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'a:one', + 'b:one', + 'Sync effect', + ]); + expect(source.listenerCount).toBe(1); + + // Mutate -> schedule update for ComponentA + React.startTransition(() => { + source.value = 'two'; + }); + + // Commit ComponentB -> notice the change and schedule an update for ComponentB + expect(Scheduler).toFlushAndYield(['a:two', 'b:two']); + expect(source.listenerCount).toBe(2); + }); + }); + + 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, + param => param.version, + ); + + 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, + param => param.version, + ); + + act(() => { + // Start a render that uses the mutable source. + if (gate(flags => flags.enableSyncDefaultUpdates)) { + React.startTransition(() => { + ReactNoop.render( + <> + + + , + ); + }); + } else { + 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, + param => param.version, + ); + + act(() => { + // Start a render that uses the mutable source. + if (gate(flags => flags.enableSyncDefaultUpdates)) { + React.startTransition(() => { + ReactNoop.render( + <> + + + , + ); + }); + } else { + 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-reconciler/src/__tests__/useMutableSourceHydration-test.js b/packages/react-reconciler/src/__tests__/useMutableSourceHydration-test.js new file mode 100644 index 0000000000000..7f46d1cb00552 --- /dev/null +++ b/packages/react-reconciler/src/__tests__/useMutableSourceHydration-test.js @@ -0,0 +1,451 @@ +/** + * 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 + */ + +'use strict'; + +let React; +let ReactDOM; +let ReactDOMServer; +let Scheduler; +let act; +let createMutableSource; +let useMutableSource; + +describe('useMutableSourceHydration', () => { + beforeEach(() => { + jest.resetModules(); + + React = require('react'); + ReactDOM = require('react-dom'); + ReactDOMServer = require('react-dom/server'); + Scheduler = require('scheduler'); + + act = require('jest-react').act; + + // Stable entrypoints export with "unstable_" prefix. + createMutableSource = + React.createMutableSource || React.unstable_createMutableSource; + useMutableSource = + React.useMutableSource || React.unstable_useMutableSource; + }); + + function dispatchAndSetCurrentEvent(el, event) { + try { + window.event = event; + el.dispatchEvent(event); + } finally { + window.event = undefined; + } + } + + 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 = initialValueA; + let valueB = initialValueB; + + 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 Component({getSnapshot, label, mutableSource, subscribe}) { + const snapshot = useMutableSource(mutableSource, getSnapshot, subscribe); + Scheduler.unstable_yieldValue(`${label}:${snapshot}`); + return
    {`${label}:${snapshot}`}
    ; + } + + it('should render and hydrate', () => { + const source = createSource('one'); + const mutableSource = createMutableSource(source, param => param.version); + + function TestComponent() { + return ( + + ); + } + + const container = document.createElement('div'); + document.body.appendChild(container); + + const htmlString = ReactDOMServer.renderToString(); + container.innerHTML = htmlString; + expect(Scheduler).toHaveYielded(['only:one']); + expect(source.listenerCount).toBe(0); + + const root = ReactDOM.createRoot(container, { + hydrate: true, + hydrationOptions: { + mutableSources: [mutableSource], + }, + }); + act(() => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['only:one']); + expect(source.listenerCount).toBe(1); + }); + + it('should detect a tear before hydrating a component', () => { + const source = createSource('one'); + const mutableSource = createMutableSource(source, param => param.version); + + function TestComponent() { + return ( + + ); + } + + const container = document.createElement('div'); + document.body.appendChild(container); + + const htmlString = ReactDOMServer.renderToString(); + container.innerHTML = htmlString; + expect(Scheduler).toHaveYielded(['only:one']); + expect(source.listenerCount).toBe(0); + + const root = ReactDOM.createRoot(container, { + hydrate: true, + hydrationOptions: { + mutableSources: [mutableSource], + }, + }); + expect(() => { + act(() => { + root.render(); + + source.value = 'two'; + }); + }).toErrorDev( + 'Warning: An error occurred during hydration. ' + + 'The server HTML was replaced with client content in
    .', + {withoutStack: true}, + ); + expect(Scheduler).toHaveYielded(['only:two']); + expect(source.listenerCount).toBe(1); + }); + + it('should detect a tear between hydrating components', () => { + const source = createSource('one'); + const mutableSource = createMutableSource(source, param => param.version); + + function TestComponent() { + return ( + <> + + + + ); + } + + const container = document.createElement('div'); + document.body.appendChild(container); + + const htmlString = ReactDOMServer.renderToString(); + container.innerHTML = htmlString; + expect(Scheduler).toHaveYielded(['a:one', 'b:one']); + expect(source.listenerCount).toBe(0); + + const root = ReactDOM.createRoot(container, { + hydrate: true, + hydrationOptions: { + mutableSources: [mutableSource], + }, + }); + expect(() => { + act(() => { + if (gate(flags => flags.enableSyncDefaultUpdates)) { + React.startTransition(() => { + root.render(); + }); + } else { + root.render(); + } + expect(Scheduler).toFlushAndYieldThrough(['a:one']); + source.value = 'two'; + }); + }).toErrorDev( + 'Warning: An error occurred during hydration. ' + + 'The server HTML was replaced with client content in
    .', + {withoutStack: true}, + ); + expect(Scheduler).toHaveYielded(['a:two', 'b:two']); + expect(source.listenerCount).toBe(2); + }); + + it('should detect a tear between hydrating components reading from different parts of a source', () => { + const source = createComplexSource('a:one', 'b:one'); + const mutableSource = createMutableSource(source, param => param.version); + + // 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); + + const container = document.createElement('div'); + document.body.appendChild(container); + + const htmlString = ReactDOMServer.renderToString( + <> + + + , + ); + container.innerHTML = htmlString; + expect(Scheduler).toHaveYielded(['0:a:one', '1:b:one']); + + const root = ReactDOM.createRoot(container, { + hydrate: true, + hydrationOptions: { + mutableSources: [mutableSource], + }, + }); + expect(() => { + act(() => { + if (gate(flags => flags.enableSyncDefaultUpdates)) { + React.startTransition(() => { + root.render( + <> + + + , + ); + }); + } else { + root.render( + <> + + + , + ); + } + expect(Scheduler).toFlushAndYieldThrough(['0:a:one']); + source.valueB = 'b:two'; + }); + }).toErrorDev( + 'Warning: An error occurred during hydration. ' + + 'The server HTML was replaced with client content in
    .', + {withoutStack: true}, + ); + expect(Scheduler).toHaveYielded(['0:a:one', '1:b:two']); + }); + + // @gate !enableSyncDefaultUpdates + it('should detect a tear during a higher priority interruption', () => { + const source = createSource('one'); + const mutableSource = createMutableSource(source, param => param.version); + + function Unrelated({flag}) { + Scheduler.unstable_yieldValue(flag); + return flag; + } + + function TestComponent({flag}) { + return ( + <> + + + + ); + } + + const container = document.createElement('div'); + document.body.appendChild(container); + + const htmlString = ReactDOMServer.renderToString( + , + ); + container.innerHTML = htmlString; + expect(Scheduler).toHaveYielded([1, 'a:one']); + expect(source.listenerCount).toBe(0); + + const root = ReactDOM.createRoot(container, { + hydrate: true, + hydrationOptions: { + mutableSources: [mutableSource], + }, + }); + + expect(() => { + act(() => { + if (gate(flags => flags.enableSyncDefaultUpdates)) { + React.startTransition(() => { + root.render(); + }); + } else { + root.render(); + } + expect(Scheduler).toFlushAndYieldThrough([1]); + + // Render an update which will be higher priority than the hydration. + // We can do this by scheduling the update inside a mouseover event. + const arbitraryElement = document.createElement('div'); + const mouseOverEvent = document.createEvent('MouseEvents'); + mouseOverEvent.initEvent('mouseover', true, true); + arbitraryElement.addEventListener('mouseover', () => { + root.render(); + }); + dispatchAndSetCurrentEvent(arbitraryElement, mouseOverEvent); + + expect(Scheduler).toFlushAndYieldThrough([2]); + + source.value = 'two'; + }); + }).toErrorDev( + [ + 'Warning: An error occurred during hydration. ' + + 'The server HTML was replaced with client content in
    .', + + 'Warning: Text content did not match. Server: "1" Client: "2"', + ], + {withoutStack: 1}, + ); + expect(Scheduler).toHaveYielded([2, 'a:two']); + expect(source.listenerCount).toBe(1); + }); +}); diff --git a/packages/react-server/src/ReactFizzHooks.js b/packages/react-server/src/ReactFizzHooks.js index 9976b5be90c1a..860dc63962dab 100644 --- a/packages/react-server/src/ReactFizzHooks.js +++ b/packages/react-server/src/ReactFizzHooks.js @@ -9,7 +9,12 @@ import type {Dispatcher as DispatcherType} from 'react-reconciler/src/ReactInternalTypes'; -import type {ReactContext} from 'shared/ReactTypes'; +import type { + MutableSource, + MutableSourceGetSnapshotFn, + MutableSourceSubscribeFn, + ReactContext, +} from 'shared/ReactTypes'; import type {ResponseState, OpaqueIDType} from './ReactServerFormatConfig'; @@ -444,6 +449,18 @@ export function useCallback( return useMemo(() => callback, deps); } +// 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 useSyncExternalStore( subscribe: (() => void) => () => void, getSnapshot: () => T, @@ -506,6 +523,8 @@ export const Dispatcher: DispatcherType = { useDeferredValue, useTransition, useOpaqueIdentifier, + // Subscriptions are not setup in a server environment. + useMutableSource, useSyncExternalStore, }; diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index a555f7cac8c68..5429d3a3b9e60 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -827,6 +827,7 @@ const Dispatcher: DispatcherType = { useImperativeHandle: (unsupportedHook: any), useEffect: (unsupportedHook: any), useOpaqueIdentifier: (unsupportedHook: any), + useMutableSource: (unsupportedHook: any), useSyncExternalStore: (unsupportedHook: any), useCacheRefresh(): (?() => T, ?T) => void { return unsupportedRefresh; diff --git a/packages/react-suspense-test-utils/src/ReactSuspenseTestUtils.js b/packages/react-suspense-test-utils/src/ReactSuspenseTestUtils.js index e4c886dc85aa6..b4b57c68b5f56 100644 --- a/packages/react-suspense-test-utils/src/ReactSuspenseTestUtils.js +++ b/packages/react-suspense-test-utils/src/ReactSuspenseTestUtils.js @@ -44,6 +44,7 @@ export function waitForSuspense(fn: () => T): Promise { useDeferredValue: unsupported, useTransition: unsupported, useOpaqueIdentifier: unsupported, + useMutableSource: unsupported, useSyncExternalStore: unsupported, useCacheRefresh: unsupported, }; diff --git a/packages/react/index.classic.fb.js b/packages/react/index.classic.fb.js index d3ebdb5c02fb8..7f8abc863157a 100644 --- a/packages/react/index.classic.fb.js +++ b/packages/react/index.classic.fb.js @@ -23,6 +23,8 @@ export { createContext, createElement, createFactory, + createMutableSource, + createMutableSource as unstable_createMutableSource, createRef, forwardRef, isValidElement, @@ -48,6 +50,8 @@ export { useLayoutEffect, unstable_useInsertionEffect, useMemo, + useMutableSource, + useMutableSource as unstable_useMutableSource, useSyncExternalStore, useSyncExternalStore as unstable_useSyncExternalStore, useReducer, diff --git a/packages/react/index.experimental.js b/packages/react/index.experimental.js index 7ff629fd0af03..a0a9bbaca216e 100644 --- a/packages/react/index.experimental.js +++ b/packages/react/index.experimental.js @@ -22,6 +22,7 @@ export { createContext, createElement, createFactory, + createMutableSource as unstable_createMutableSource, createRef, forwardRef, isValidElement, @@ -44,6 +45,7 @@ export { unstable_useInsertionEffect, useLayoutEffect, useMemo, + useMutableSource as unstable_useMutableSource, useSyncExternalStore as unstable_useSyncExternalStore, useReducer, useRef, diff --git a/packages/react/index.js b/packages/react/index.js index 17916a322bfa9..1b4552a90f5d8 100644 --- a/packages/react/index.js +++ b/packages/react/index.js @@ -46,6 +46,7 @@ export { createContext, createElement, createFactory, + createMutableSource, createRef, forwardRef, isValidElement, @@ -69,6 +70,7 @@ export { unstable_useInsertionEffect, useLayoutEffect, useMemo, + useMutableSource, useSyncExternalStore, useSyncExternalStore as unstable_useSyncExternalStore, useReducer, diff --git a/packages/react/index.modern.fb.js b/packages/react/index.modern.fb.js index e2a55bbdab87f..819d5fe90afd3 100644 --- a/packages/react/index.modern.fb.js +++ b/packages/react/index.modern.fb.js @@ -22,6 +22,8 @@ export { cloneElement, createContext, createElement, + createMutableSource, + createMutableSource as unstable_createMutableSource, createRef, forwardRef, isValidElement, @@ -47,6 +49,8 @@ export { unstable_useInsertionEffect, useLayoutEffect, useMemo, + useMutableSource, + useMutableSource as unstable_useMutableSource, useSyncExternalStore, useSyncExternalStore as unstable_useSyncExternalStore, useReducer, diff --git a/packages/react/index.stable.js b/packages/react/index.stable.js index 70ede828c8a4e..008875e4577e5 100644 --- a/packages/react/index.stable.js +++ b/packages/react/index.stable.js @@ -22,6 +22,7 @@ export { createContext, createElement, createFactory, + createMutableSource as unstable_createMutableSource, createRef, forwardRef, isValidElement, @@ -37,6 +38,7 @@ export { useImperativeHandle, useLayoutEffect, useMemo, + useMutableSource as unstable_useMutableSource, useReducer, useRef, useState, diff --git a/packages/react/src/React.js b/packages/react/src/React.js index a171d16b8af80..541e7a35d3d3e 100644 --- a/packages/react/src/React.js +++ b/packages/react/src/React.js @@ -44,6 +44,7 @@ import { useInsertionEffect, useLayoutEffect, useMemo, + useMutableSource, useSyncExternalStore, useReducer, useRef, @@ -58,6 +59,7 @@ import { createFactoryWithValidation, cloneElementWithValidation, } from './ReactElementValidator'; +import {createMutableSource} from './ReactMutableSource'; import ReactSharedInternals from './ReactSharedInternals'; import {startTransition} from './ReactStartTransition'; import {act} from './ReactAct'; @@ -77,6 +79,7 @@ const Children = { export { Children, + createMutableSource, createRef, Component, PureComponent, @@ -92,6 +95,7 @@ export { useInsertionEffect as unstable_useInsertionEffect, useLayoutEffect, useMemo, + useMutableSource, useSyncExternalStore, useReducer, useRef, diff --git a/packages/react/src/ReactHooks.js b/packages/react/src/ReactHooks.js index e981907baed55..0108c545fae5f 100644 --- a/packages/react/src/ReactHooks.js +++ b/packages/react/src/ReactHooks.js @@ -8,7 +8,12 @@ */ import type {Dispatcher} from 'react-reconciler/src/ReactInternalTypes'; -import type {ReactContext} from 'shared/ReactTypes'; +import type { + MutableSource, + MutableSourceGetSnapshotFn, + MutableSourceSubscribeFn, + ReactContext, +} from 'shared/ReactTypes'; import type {OpaqueIDType} from 'react-reconciler/src/ReactFiberHostConfig'; import ReactCurrentDispatcher from './ReactCurrentDispatcher'; @@ -163,6 +168,15 @@ export function useOpaqueIdentifier(): OpaqueIDType | void { return dispatcher.useOpaqueIdentifier(); } +export function useMutableSource( + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn, + subscribe: MutableSourceSubscribeFn, +): Snapshot { + const dispatcher = resolveDispatcher(); + return dispatcher.useMutableSource(source, getSnapshot, subscribe); +} + export function useSyncExternalStore( subscribe: (() => void) => () => void, getSnapshot: () => T, diff --git a/packages/react/src/ReactMutableSource.js b/packages/react/src/ReactMutableSource.js new file mode 100644 index 0000000000000..f8e5d0b283037 --- /dev/null +++ b/packages/react/src/ReactMutableSource.js @@ -0,0 +1,34 @@ +/** + * 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 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; + + // Used to detect side effects that update a mutable source during render. + // See https://github.com/facebook/react/issues/19948 + mutableSource._currentlyRenderingFiber = null; + mutableSource._initialVersionAsOfFirstRender = null; + } + + return mutableSource; +} diff --git a/packages/react/unstable-shared-subset.experimental.js b/packages/react/unstable-shared-subset.experimental.js index c18138226ad3e..9381778b4435d 100644 --- a/packages/react/unstable-shared-subset.experimental.js +++ b/packages/react/unstable-shared-subset.experimental.js @@ -17,6 +17,7 @@ export { SuspenseList, cloneElement, createElement, + createMutableSource as unstable_createMutableSource, createRef, forwardRef, isValidElement, @@ -32,6 +33,7 @@ export { useDeferredValue, useDeferredValue as unstable_useDeferredValue, useMemo, + useMutableSource as unstable_useMutableSource, useTransition, version, } from './src/React'; diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index 277f633387d11..43f42bddb91d3 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -101,6 +101,56 @@ export type ReactScopeInstance = {| getChildContextValues: (context: ReactContext) => Array, |}; +// 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, + + // DEV only + // Used to detect side effects that update a mutable source during render. + // See https://github.com/facebook/react/issues/19948 + _currentlyRenderingFiber?: Fiber | null, + _initialVersionAsOfFirstRender?: MutableSourceVersion | null, +|}; + // The subset of a Thenable required by things thrown by Suspense. // This doesn't require a value to be passed to either handler. export interface Wakeable {