From fb8c5fe801be0e0e23c673f4be9ac3c2956f0594 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Tue, 21 Sep 2021 23:05:15 -0400 Subject: [PATCH] Add back useMutableSource temporarily Recoil uses useMutableSource behind a flag. I thought this was fine because Recoil isn't used in any concurrent roots, so the behavior would be the same, but it turns out that it is used by concurrent roots in a few places. I'm not expecting it to be hard to migrate to useSyncExternalStore, but to de-risk the change I'm going to roll it out gradually with a flag. In the meantime, I've added back the useMutableSource API. --- .../react-debug-tools/src/ReactDebugHooks.js | 26 +- .../ReactHooksInspectionIntegration-test.js | 37 + packages/react-dom/src/client/ReactDOMRoot.js | 27 +- .../src/server/ReactPartialRendererHooks.js | 21 +- .../src/ReactFiberBeginWork.new.js | 18 + .../src/ReactFiberBeginWork.old.js | 18 + .../src/ReactFiberCompleteWork.new.js | 3 + .../src/ReactFiberCompleteWork.old.js | 3 + .../src/ReactFiberHooks.new.js | 367 ++- .../src/ReactFiberHooks.old.js | 401 +++- .../src/ReactFiberLane.new.js | 11 +- .../src/ReactFiberLane.old.js | 11 +- .../src/ReactFiberReconciler.js | 5 + .../src/ReactFiberReconciler.new.js | 1 + .../src/ReactFiberReconciler.old.js | 1 + .../src/ReactFiberRoot.new.js | 7 +- .../src/ReactFiberRoot.old.js | 7 +- .../src/ReactFiberUnwindWork.new.js | 3 + .../src/ReactFiberUnwindWork.old.js | 3 + .../src/ReactInternalTypes.js | 21 +- .../src/ReactMutableSource.new.js | 108 + .../src/ReactMutableSource.old.js | 108 + .../useMutableSource-test.internal.js | 1981 +++++++++++++++++ .../useMutableSourceHydration-test.js | 451 ++++ packages/react-server/src/ReactFizzHooks.js | 21 +- .../react-server/src/ReactFlightServer.js | 1 + .../src/ReactSuspenseTestUtils.js | 1 + packages/react/index.classic.fb.js | 4 + packages/react/index.experimental.js | 2 + packages/react/index.js | 2 + packages/react/index.modern.fb.js | 4 + packages/react/index.stable.js | 2 + packages/react/src/React.js | 4 + packages/react/src/ReactHooks.js | 16 +- packages/react/src/ReactMutableSource.js | 34 + .../unstable-shared-subset.experimental.js | 2 + packages/shared/ReactTypes.js | 50 + 37 files changed, 3766 insertions(+), 16 deletions(-) create mode 100644 packages/react-reconciler/src/ReactMutableSource.new.js create mode 100644 packages/react-reconciler/src/ReactMutableSource.old.js create mode 100644 packages/react-reconciler/src/__tests__/useMutableSource-test.internal.js create mode 100644 packages/react-reconciler/src/__tests__/useMutableSourceHydration-test.js create mode 100644 packages/react/src/ReactMutableSource.js 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..cb94380ef0bc3 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'; @@ -106,6 +118,7 @@ import { entangleTransitions, } from './ReactUpdateQueue.old'; import {pushInterleavedQueue} from './ReactFiberInterleavedUpdates.old'; +import {getIsStrictModeForDevtools} from './ReactFiberReconciler.old'; import {warnOnSubscriptionInsideStartTransition} from 'shared/ReactFeatureFlags'; const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals; @@ -935,6 +948,322 @@ 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. + // + // If it were to become visible to the user, it suggests one of two things: + // a bug in React, or (more likely), a mutation during the render phase that + // caused the second re-render attempt to be different from the first. + // + // We know it's the second case if the logs are currently disabled. So in + // dev, we can present a more accurate error message. + if (__DEV__) { + // eslint-disable-next-line react-internal/no-production-logging + if (getIsStrictModeForDevtools()) { + // If getIsStrictModeForDevtools is true, this is the dev-only double render + // This is only reachable if there was a mutation during render. Show a helpful + // error message. + // + // Something interesting to note: because we only double render in + // development, this error will never happen during production. This is + // actually true of all errors that occur during a double render, + // because if the first render had thrown, we would have exited the + // begin phase without double rendering. We should consider suppressing + // any error from a double render (with a warning) to more closely match + // the production behavior. + const componentName = getComponentNameFromFiber( + currentlyRenderingFiber, + ); + invariant( + false, + 'A mutable source was mutated while the %s component was rendering. ' + + 'This is not supported. Move any mutations into event handlers ' + + 'or effects.', + componentName, + ); + } + } + + // 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 +2364,7 @@ export const ContextOnlyDispatcher: Dispatcher = { useDebugValue: throwInvalidHookError, useDeferredValue: throwInvalidHookError, useTransition: throwInvalidHookError, + useMutableSource: throwInvalidHookError, useSyncExternalStore: throwInvalidHookError, useOpaqueIdentifier: throwInvalidHookError, @@ -2061,6 +2391,7 @@ const HooksDispatcherOnMount: Dispatcher = { useDebugValue: mountDebugValue, useDeferredValue: mountDeferredValue, useTransition: mountTransition, + useMutableSource: mountMutableSource, useSyncExternalStore: mountSyncExternalStore, useOpaqueIdentifier: mountOpaqueIdentifier, @@ -2087,6 +2418,7 @@ const HooksDispatcherOnUpdate: Dispatcher = { useDebugValue: updateDebugValue, useDeferredValue: updateDeferredValue, useTransition: updateTransition, + useMutableSource: updateMutableSource, useSyncExternalStore: updateSyncExternalStore, useOpaqueIdentifier: updateOpaqueIdentifier, @@ -2113,6 +2445,7 @@ const HooksDispatcherOnRerender: Dispatcher = { useDebugValue: updateDebugValue, useDeferredValue: rerenderDeferredValue, useTransition: rerenderTransition, + useMutableSource: updateMutableSource, useSyncExternalStore: mountSyncExternalStore, useOpaqueIdentifier: rerenderOpaqueIdentifier, @@ -2262,6 +2595,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 +2736,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 +2877,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 +3019,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 +3174,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 +3332,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 +3491,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 {