From baad1910781a751d5008471d34f3f8ecb8d8a69f Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Fri, 7 Feb 2020 11:46:53 -0800 Subject: [PATCH] useMutableSource hook useMutableSource() enables React components to safely read from a mutable external source in Concurrent Mode. This API will detect mutations that occur during a render to avoid tearing. It will also automatically schedule updates when the source is mutated --- .../react-debug-tools/src/ReactDebugHooks.js | 24 ++ .../src/server/ReactPartialRendererHooks.js | 15 + .../react-reconciler/src/ReactFiberHooks.js | 269 ++++++++++++++++- .../react-reconciler/src/ReactFiberRoot.js | 5 + .../src/ReactFiberWorkLoop.js | 36 +++ ...eactHooksWithNoopRenderer-test.internal.js | 272 ++++++++++++++++++ .../src/ReactShallowRenderer.js | 15 + packages/react/src/React.js | 2 + packages/react/src/ReactHooks.js | 12 + packages/shared/ReactMutableSource.js | 84 ++++++ scripts/error-codes/codes.json | 4 +- 11 files changed, 736 insertions(+), 2 deletions(-) create mode 100644 packages/shared/ReactMutableSource.js diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index 99f7ae89056f7..1003bd1727d0d 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -13,6 +13,10 @@ import type { ReactEventResponder, ReactEventResponderListener, } from 'shared/ReactTypes'; +import type { + MutableSource, + MutableSourceHookConfig, +} from 'shared/ReactMutableSource'; import type {Fiber} from 'react-reconciler/src/ReactFiber'; import type {Hook, TimeoutConfig} from 'react-reconciler/src/ReactFiberHooks'; import type {Dispatcher as DispatcherType} from 'react-reconciler/src/ReactFiberHooks'; @@ -67,6 +71,14 @@ function getPrimitiveStackCache(): Map> { Dispatcher.useDebugValue(null); Dispatcher.useCallback(() => {}); Dispatcher.useMemo(() => null); + Dispatcher.useMutableSource( + {}, + { + getVersion: () => null, + getSnapshot: () => null, + subscribe: () => () => {}, + }, + ); } finally { readHookLog = hookLog; hookLog = []; @@ -224,6 +236,17 @@ function useMemo( return value; } +function useMutableSource( + source: MutableSource, + config: MutableSourceHookConfig, +): S { + const hook = nextHook(); + const getSnapshot = config.getSnapshot; + const value = hook !== null ? hook.memoizedState.snapshot : getSnapshot(); + hookLog.push({primitive: 'MutableSource', stackError: new Error(), value}); + return value; +} + function useResponder( responder: ReactEventResponder, listenerProps: Object, @@ -276,6 +299,7 @@ const Dispatcher: DispatcherType = { useState, useResponder, useTransition, + useMutableSource, useDeferredValue, }; diff --git a/packages/react-dom/src/server/ReactPartialRendererHooks.js b/packages/react-dom/src/server/ReactPartialRendererHooks.js index fddf54ac9c0d7..059ffea16c1a4 100644 --- a/packages/react-dom/src/server/ReactPartialRendererHooks.js +++ b/packages/react-dom/src/server/ReactPartialRendererHooks.js @@ -16,6 +16,10 @@ import type { ReactContext, ReactEventResponderListener, } from 'shared/ReactTypes'; +import type { + MutableSource, + MutableSourceHookConfig, +} from 'shared/ReactMutableSource'; import type {SuspenseConfig} from 'react-reconciler/src/ReactFiberSuspenseConfig'; import {validateContextBounds} from './ReactPartialRendererContext'; @@ -459,6 +463,15 @@ function useResponder(responder, props): ReactEventResponderListener { }; } +function useMutableSource( + source: MutableSource, + config: MutableSourceHookConfig, +): S { + resolveCurrentlyRenderingComponent(); + const getSnapshot = config.getSnapshot; + return getSnapshot(); +} + function useDeferredValue(value: T, config: TimeoutConfig | null | void): T { resolveCurrentlyRenderingComponent(); return value; @@ -500,4 +513,6 @@ export const Dispatcher: DispatcherType = { useResponder, useDeferredValue, useTransition, + // Subscriptions are not setup in a server environment. + useMutableSource, }; diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index fdb0875f8bb5c..3bf8aa0fb06ad 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -17,6 +17,10 @@ import type {ExpirationTime} from './ReactFiberExpirationTime'; import type {HookEffectTag} from './ReactHookEffectTags'; import type {SuspenseConfig} from './ReactFiberSuspenseConfig'; import type {ReactPriorityLevel} from './SchedulerWithReactIntegration'; +import type { + MutableSource, + MutableSourceHookConfig, +} from 'shared/ReactMutableSource'; import ReactSharedInternals from 'shared/ReactSharedInternals'; @@ -41,6 +45,7 @@ import { warnIfNotScopedWithMatchingAct, markRenderEventTimeAndConfig, markUnprocessedUpdateTime, + getMutableSourceMetadata, } from './ReactFiberWorkLoop'; import invariant from 'shared/invariant'; @@ -54,6 +59,10 @@ import { runWithPriority, getCurrentPriorityLevel, } from './SchedulerWithReactIntegration'; +import { + getWorkInProgressVersion, + setWorkInProgressVersion, +} from 'shared/ReactMutableSource'; const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals; @@ -97,6 +106,10 @@ export type Dispatcher = {| useTransition( config: SuspenseConfig | void | null, ): [(() => void) => void, boolean], + useMutableSource( + source: MutableSource, + config: MutableSourceHookConfig, + ): S, |}; type Update = {| @@ -129,7 +142,8 @@ export type HookType = | 'useDebugValue' | 'useResponder' | 'useDeferredValue' - | 'useTransition'; + | 'useTransition' + | 'useMutableSource'; let didWarnAboutMismatchedHooksForComponent; if (__DEV__) { @@ -837,6 +851,196 @@ function rerenderReducer( return [newState, dispatch]; } +type MutableSourceState = {| + source: MutableSource, + config: MutableSourceHookConfig, + snapshot: S, + destroy?: () => void, +|}; + +function useMutableSourceImpl( + hook: Hook, + source: MutableSource, + config: MutableSourceHookConfig, +): S { + if (__DEV__) { + if (typeof config !== 'object' || config === null) { + console.error( + 'Expected useMutableSource() second argument to be a config object. ' + + 'Instead received: %s.', + config === null ? 'null' : typeof config, + ); + } + } + + const {getSnapshot, getVersion, subscribe} = config; + if (__DEV__) { + if ( + typeof getSnapshot !== 'function' || + typeof getVersion !== 'function' || + typeof subscribe !== 'function' + ) { + console.error( + 'Invalid useMutableSource() config specified. ' + + 'Config object should define getSnapshot, getVersion, and subscribe methods. ' + + 'See https://fb.me/useMutableSource for more information.', + ); + } + } + + const version = getVersion(); + if (__DEV__) { + if (version == null) { + console.error( + 'useMutableSource() version should not be null or undefined.', + ); + } + } + + const metadata = getMutableSourceMetadata(source); + + // Is it safe to read from this source during the current render? + // If the source has not yet been subscribed to, we can use the version number to determine this. + // Else we can use the expiration time as an indicator of any future scheduled updates. + let isSafeToReadFromSource = false; + if (metadata.subscriptionCount === 0) { + const lastReadVersion = getWorkInProgressVersion(source); + if (lastReadVersion === null) { + // This is the only case where we need to actually update the version number. + // A changed version number for a new source will throw and restart the render, + // at which point the work-in-progress version Map will be reinitialized. + // Once a source has been subscribed to, we use expiration time to determine safety. + setWorkInProgressVersion(source, version); + + isSafeToReadFromSource = true; + } else { + isSafeToReadFromSource = lastReadVersion === version; + } + } else { + const currentTime = requestCurrentTimeForUpdate(); + const suspenseConfig = requestCurrentSuspenseConfig(); + const expirationTime = computeExpirationForFiber( + currentTime, + currentlyRenderingFiber, + suspenseConfig, + ); + + isSafeToReadFromSource = + metadata.expirationTime === NoWork || + metadata.expirationTime >= expirationTime; + } + + let prevMemoizedState = ((hook.memoizedState: any): ?MutableSourceState); + let snapshot = ((null: any): S); + + if (isSafeToReadFromSource) { + snapshot = getSnapshot(); + } else { + // Since we can't read safely, can we reuse a previous snapshot? + if ( + prevMemoizedState != null && + prevMemoizedState.config === config && + prevMemoizedState.source === source + ) { + snapshot = prevMemoizedState.snapshot; + } else { + // We can't read from the source and we can't reuse the snapshot, + // so the only option left is to throw and restart the render. + // This error should cause React to restart work on this root, + // and flush all pending udpates (including any pending source updates). + // This error won't be user-visible unless we throw again during re-render, + // but we should not do this since the retry-render will be synchronous. + // It would be a React error if this message was ever user visible. + throw Error( + 'Cannot read from mutable source during the current render without tearing. ' + + 'This is a bug in React. Please file an issue.', + ); + } + } + + const prevSource = + prevMemoizedState != null ? prevMemoizedState.source : null; + const destroy = + prevMemoizedState != null ? prevMemoizedState.destroy : undefined; + + const memoizedState: MutableSourceState = { + config, + destroy, + snapshot, + source, + }; + + hook.memoizedState = memoizedState; + + if (prevSource !== source) { + const fiber = currentlyRenderingFiber; + + const create = () => { + const scheduleUpdate = () => { + const currentTime = requestCurrentTimeForUpdate(); + const suspenseConfig = requestCurrentSuspenseConfig(); + const expirationTime = computeExpirationForFiber( + currentTime, + fiber, + suspenseConfig, + ); + + // Make sure reads during future renders will know there's a pending update. + // This will prevent a higher priority update from reading a newer version of the source, + // and causing a tear between that render and previous renders. + if (expirationTime > metadata.expirationTime) { + metadata.expirationTime = expirationTime; + } + + scheduleWork(fiber, expirationTime); + }; + + // Was the source mutated between when we rendered and when we're subscribing? + // If so, we also need to schedule an update. + const maybeNewSnapshot = getSnapshot(); + if (snapshot !== maybeNewSnapshot) { + scheduleUpdate(); + } + + const unsubscribe = subscribe(scheduleUpdate); + metadata.subscriptionCount++; + + memoizedState.destroy = () => { + metadata.subscriptionCount--; + + // TODO (useMutableSource) If count is 0, flag this source for possible cleanup. + + unsubscribe(); + }; + + return memoizedState.destroy; + }; + + // Schedule effect to attach event listeners to the new source, + // and remove them from the previous source + currentlyRenderingFiber.effectTag |= UpdateEffect | PassiveEffect; + pushEffect(HookHasEffect | HookPassive, create, destroy, null); + } + + return snapshot; +} + +function mountMutableSource( + source: MutableSource, + config: MutableSourceHookConfig, +): S { + const hook = mountWorkInProgressHook(); + return useMutableSourceImpl(hook, source, config); +} + +function updateMutableSource( + source: MutableSource, + config: MutableSourceHookConfig, +): S { + const hook = updateWorkInProgressHook(); + return useMutableSourceImpl(hook, source, config); +} + function mountState( initialState: (() => S) | S, ): [S, Dispatch>] { @@ -1385,6 +1589,7 @@ export const ContextOnlyDispatcher: Dispatcher = { useResponder: throwInvalidHookError, useDeferredValue: throwInvalidHookError, useTransition: throwInvalidHookError, + useMutableSource: throwInvalidHookError, }; const HooksDispatcherOnMount: Dispatcher = { @@ -1403,6 +1608,7 @@ const HooksDispatcherOnMount: Dispatcher = { useResponder: createDeprecatedResponderListener, useDeferredValue: mountDeferredValue, useTransition: mountTransition, + useMutableSource: mountMutableSource, }; const HooksDispatcherOnUpdate: Dispatcher = { @@ -1421,6 +1627,7 @@ const HooksDispatcherOnUpdate: Dispatcher = { useResponder: createDeprecatedResponderListener, useDeferredValue: updateDeferredValue, useTransition: updateTransition, + useMutableSource: updateMutableSource, }; const HooksDispatcherOnRerender: Dispatcher = { @@ -1439,6 +1646,7 @@ const HooksDispatcherOnRerender: Dispatcher = { useResponder: createDeprecatedResponderListener, useDeferredValue: rerenderDeferredValue, useTransition: rerenderTransition, + useMutableSource: updateMutableSource, }; let HooksDispatcherOnMountInDEV: Dispatcher | null = null; @@ -1588,6 +1796,14 @@ if (__DEV__) { mountHookTypesDev(); return mountTransition(config); }, + useMutableSource( + source: MutableSource, + config: MutableSourceHookConfig, + ): S { + currentHookNameInDev = 'useMutableSource'; + mountHookTypesDev(); + return mountMutableSource(source, config); + }, }; HooksDispatcherOnMountWithHookTypesInDEV = { @@ -1705,6 +1921,14 @@ if (__DEV__) { updateHookTypesDev(); return mountTransition(config); }, + useMutableSource( + source: MutableSource, + config: MutableSourceHookConfig, + ): S { + currentHookNameInDev = 'useMutableSource'; + updateHookTypesDev(); + return updateMutableSource(source, config); + }, }; HooksDispatcherOnUpdateInDEV = { @@ -1822,6 +2046,14 @@ if (__DEV__) { updateHookTypesDev(); return updateTransition(config); }, + useMutableSource( + source: MutableSource, + config: MutableSourceHookConfig, + ): S { + currentHookNameInDev = 'useMutableSource'; + updateHookTypesDev(); + return updateMutableSource(source, config); + }, }; HooksDispatcherOnRerenderInDEV = { @@ -1939,6 +2171,14 @@ if (__DEV__) { updateHookTypesDev(); return rerenderTransition(config); }, + useMutableSource( + source: MutableSource, + config: MutableSourceHookConfig, + ): S { + currentHookNameInDev = 'useMutableSource'; + updateHookTypesDev(); + return updateMutableSource(source, config); + }, }; InvalidNestedHooksDispatcherOnMountInDEV = { @@ -2070,6 +2310,15 @@ if (__DEV__) { mountHookTypesDev(); return mountTransition(config); }, + useMutableSource( + source: MutableSource, + config: MutableSourceHookConfig, + ): S { + currentHookNameInDev = 'useMutableSource'; + warnInvalidHookAccess(); + mountHookTypesDev(); + return mountMutableSource(source, config); + }, }; InvalidNestedHooksDispatcherOnUpdateInDEV = { @@ -2201,6 +2450,15 @@ if (__DEV__) { updateHookTypesDev(); return updateTransition(config); }, + useMutableSource( + source: MutableSource, + config: MutableSourceHookConfig, + ): S { + currentHookNameInDev = 'useMutableSource'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateMutableSource(source, config); + }, }; InvalidNestedHooksDispatcherOnRerenderInDEV = { @@ -2332,5 +2590,14 @@ if (__DEV__) { updateHookTypesDev(); return rerenderTransition(config); }, + useMutableSource( + source: MutableSource, + config: MutableSourceHookConfig, + ): S { + currentHookNameInDev = 'useMutableSource'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateMutableSource(source, config); + }, }; } diff --git a/packages/react-reconciler/src/ReactFiberRoot.js b/packages/react-reconciler/src/ReactFiberRoot.js index e0de5ecfd8970..4b491219e0378 100644 --- a/packages/react-reconciler/src/ReactFiberRoot.js +++ b/packages/react-reconciler/src/ReactFiberRoot.js @@ -15,6 +15,7 @@ import type {Thenable} from './ReactFiberWorkLoop'; import type {Interaction} from 'scheduler/src/Tracing'; import type {SuspenseHydrationCallbacks} from './ReactFiberSuspenseComponent'; import type {ReactPriorityLevel} from './SchedulerWithReactIntegration'; +import type {MutableSourceMetadataMap} from 'shared/ReactMutableSource'; import {noTimeout} from './ReactFiberHostConfig'; import {createHostRootFiber} from './ReactFiber'; @@ -74,6 +75,9 @@ type BaseFiberRootProperties = {| // render again lastPingedTime: ExpirationTime, lastExpiredTime: ExpirationTime, + // Used by useMutableSource hook to avoid tearing within this root + // when external, mutable sources are read from during render. + mutableSourceMetadata: MutableSourceMetadataMap, |}; // The following attributes are only used by interaction tracing builds. @@ -123,6 +127,7 @@ function FiberRootNode(containerInfo, tag, hydrate) { this.nextKnownPendingLevel = NoWork; this.lastPingedTime = NoWork; this.lastExpiredTime = NoWork; + this.mutableSourceMetadata = new Map(); if (enableSchedulerTracing) { this.interactionThreadID = unstable_getThreadID(); diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index efd4a788c0b81..ae35b1030169a 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -14,6 +14,15 @@ import type {ReactPriorityLevel} from './SchedulerWithReactIntegration'; import type {Interaction} from 'scheduler/src/Tracing'; import type {SuspenseConfig} from './ReactFiberSuspenseConfig'; import type {SuspenseState} from './ReactFiberSuspenseComponent'; +import type { + MutableSource, + MutableSourceMetadata, +} from 'shared/ReactMutableSource'; + +import { + initializeWorkInProgressVersionMap as initializeMutableSourceWorkInProgressVersionMap, + resetWorkInProgressVersionMap as resetMutableSourceWorkInProgressVersionMap, +} from 'shared/ReactMutableSource'; import { warnAboutDeprecatedLifecycles, @@ -290,6 +299,26 @@ let spawnedWorkDuringRender: null | Array = null; // receive the same expiration time. Otherwise we get tearing. let currentEventTime: ExpirationTime = NoWork; +export function getMutableSourceMetadata( + source: MutableSource, +): MutableSourceMetadata { + invariant(workInProgressRoot !== null, 'Expected a work-in-progress root.'); + + let metadata = workInProgressRoot.mutableSourceMetadata.get(source); + if (metadata !== undefined) { + return metadata; + } else { + metadata = { + expirationTime: NoWork, + subscriptionCount: 0, + }; + + workInProgressRoot.mutableSourceMetadata.set(source, metadata); + + return ((metadata: any): MutableSourceMetadata); + } +} + export function requestCurrentTimeForUpdate() { if ((executionContext & (RenderContext | CommitContext)) !== NoContext) { // We're inside React, so it's fine to read the actual time. @@ -1259,6 +1288,8 @@ function prepareFreshStack(root, expirationTime) { workInProgressRootNextUnprocessedUpdateTime = NoWork; workInProgressRootHasPendingPing = false; + initializeMutableSourceWorkInProgressVersionMap(); + if (enableSchedulerTracing) { spawnedWorkDuringRender = null; } @@ -1996,6 +2027,8 @@ function commitRootImpl(root, renderPriorityLevel) { nestedUpdateCount = 0; } + resetMutableSourceWorkInProgressVersionMap(); + onCommitRoot(finishedWork.stateNode, expirationTime); // Always call this before exiting `commitRoot`, to ensure that any @@ -2247,6 +2280,9 @@ function flushPassiveEffectsImpl() { finishPendingInteractions(root, expirationTime); } + // TODO (useMutableSource) Remove metadata for mutable sources that are no longer in use. + // This check comes after passive effects, because that's when sources are unsubscribed from. + executionContext = prevExecutionContext; flushSyncCallbackQueue(); diff --git a/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js b/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js index c98dbb1171273..007bc2ac39da7 100644 --- a/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js @@ -1830,6 +1830,278 @@ describe('ReactHooksWithNoopRenderer', () => { }); }); + describe('useMutableSource', () => { + function createConfig(source) { + return { + getVersion: () => source.version, + getSnapshot: () => source.value, + subscribe: callback => source.subscribe(callback), + }; + } + + function createMutableSource(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({config, label, source}) { + const snapshot = React.useMutableSource(source, config); + Scheduler.unstable_yieldValue(`${label}:${snapshot}`); + return
{`${label}:${snapshot}`}
; + } + + it('should subscribe to a source and schedule updates when it changes', () => { + const source = createMutableSource('one'); + const config = createConfig(source); + + ReactNoop.render( + + + + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'a:one', + 'b:one', + 'Sync effect', + ]); + + // Subscriptions should be passive + expect(source.listenerCount).toBe(0); + ReactNoop.flushPassiveEffects(); + expect(source.listenerCount).toBe(2); + + // Changing values should schedule an update with React + source.value = 'two'; + expect(Scheduler).toFlushAndYieldThrough(['a:two', 'b:two']); + + // Umounting should remove event listeners + ReactNoop.render(null); + expect(Scheduler).toFlushAndYield([]); + ReactNoop.flushPassiveEffects(); + expect(source.listenerCount).toBe(0); + source.value = 'three'; + expect(Scheduler).toFlushAndYield([]); + }); + + it('should restart work if a new source is mutated during render', () => { + const source = createMutableSource('one'); + const config = createConfig(source); + + 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).toFlushAndYieldThrough([ + 'a:two', + 'b:two', + 'Sync effect', + ]); + }); + + it('should schedule an update if a new source is mutated between render and commit (subscription)', () => { + const source = createMutableSource('one'); + const config = createConfig(source); + + 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 = createMutableSource('a-one'); + const configA = createConfig(sourceA); + + const sourceB = createMutableSource('b-one'); + const configB = createConfig(sourceB); + + ReactNoop.render( + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough(['only:a-one', 'Sync effect']); + ReactNoop.flushPassiveEffects(); + expect(sourceA.listenerCount).toBe(1); + + // Changing values should schedule an update with React + sourceA.value = 'a-two'; + expect(Scheduler).toFlushAndYield(['only:a-two']); + + // If we re-render with a new source, the old one should be unsubscribed. + ReactNoop.render( + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYield(['only:b-one', 'Sync effect']); + ReactNoop.flushPassiveEffects(); + expect(sourceA.listenerCount).toBe(0); + expect(sourceB.listenerCount).toBe(1); + + // Changing to original source should not schedule updates with React + sourceA.value = 'a-three'; + expect(Scheduler).toFlushAndYield([]); + + // Changing new source value should schedule an update with React + sourceB.value = 'b-two'; + expect(Scheduler).toFlushAndYield(['only:b-two']); + }); + + it('should re-use previously read snapshot value when reading is unsafe', () => { + const source = createMutableSource('one'); + const config = createConfig(source); + + 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. + source.value = 'two'; + expect(Scheduler).toFlushAndYieldThrough(['a:two']); + + // Re-renders that occur before the udpate is processed + // should reuse snapshot so long as the config has not changed + ReactNoop.flushSync(() => { + ReactNoop.render( + + + + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + }); + expect(Scheduler).toHaveYielded(['a:one', 'b:one', 'Sync effect']); + + // TODO (useMutableSource) Re-enable the assertion below; it fails now for reasons unknown. + // Once the update is processed, the new value should be used + // 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 = createMutableSource('one'); + const config = createConfig(source); + + 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']); + }); + + // TODO (useMutableSource) Re-enable the assertion below! + // The current failure is expected and unrelated to this hook. + xit('should through and restart render if source and snapshot are unavailable during an update', () => { + const source = createMutableSource('one'); + const configA = createConfig(source); + const configB = createConfig(source); + + 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. + source.value = 'two'; + expect(Scheduler).toFlushAndYieldThrough(['a:two']); + + // 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:two', 'b:two', 'Sync effect']); + }); + + // TODO (useMutableSource) Edge case: make sure we don't leak on root Map (how to test this without internals?) + }); + describe('useCallback', () => { it('memoizes callback by comparing inputs', () => { class IncrementButton extends React.PureComponent { diff --git a/packages/react-test-renderer/src/ReactShallowRenderer.js b/packages/react-test-renderer/src/ReactShallowRenderer.js index 6c20c5700d975..41eb2af9c97a9 100644 --- a/packages/react-test-renderer/src/ReactShallowRenderer.js +++ b/packages/react-test-renderer/src/ReactShallowRenderer.js @@ -17,6 +17,10 @@ import checkPropTypes from 'prop-types/checkPropTypes'; import ReactSharedInternals from 'shared/ReactSharedInternals'; import is from 'shared/objectIs'; +import type { + MutableSource, + MutableSourceHookConfig, +} from 'shared/ReactMutableSource'; import type {Dispatcher as DispatcherType} from 'react-reconciler/src/ReactFiberHooks'; import type { ReactContext, @@ -314,6 +318,16 @@ class ReactShallowRenderer { ); }; + // TODO: implement if we decide to keep the shallow renderer + const useMutableSource = ( + source: MutableSource, + config: MutableSourceHookConfig, + ): V => { + this._validateCurrentlyRenderingComponent(); + const getSnapshot = config.getSnapshot; + return getSnapshot(); + }; + const useMemo = ( nextCreate: () => T, deps: Array | void | null, @@ -414,6 +428,7 @@ class ReactShallowRenderer { useState, useResponder, useTransition, + useMutableSource, useDeferredValue, }; } diff --git a/packages/react/src/React.js b/packages/react/src/React.js index ad515bd321dfc..7190dd7cec4ea 100644 --- a/packages/react/src/React.js +++ b/packages/react/src/React.js @@ -43,6 +43,7 @@ import { useResponder, useTransition, useDeferredValue, + useMutableSource, } from './ReactHooks'; import {withSuspenseConfig} from './ReactBatchConfig'; import { @@ -94,6 +95,7 @@ const React = { useReducer, useRef, useState, + useMutableSource, Fragment: REACT_FRAGMENT_TYPE, Profiler: REACT_PROFILER_TYPE, diff --git a/packages/react/src/ReactHooks.js b/packages/react/src/ReactHooks.js index c725b0e7c54e0..7413a825136c3 100644 --- a/packages/react/src/ReactHooks.js +++ b/packages/react/src/ReactHooks.js @@ -12,6 +12,10 @@ import type { ReactEventResponder, ReactEventResponderListener, } from 'shared/ReactTypes'; +import type { + MutableSource, + MutableSourceHookConfig, +} from 'shared/ReactMutableSource'; import invariant from 'shared/invariant'; import {REACT_RESPONDER_TYPE} from 'shared/ReactSymbols'; @@ -177,3 +181,11 @@ export function useDeferredValue(value: T, config: ?Object): T { const dispatcher = resolveDispatcher(); return dispatcher.useDeferredValue(value, config); } + +export function useMutableSource( + source: MutableSource, + config: MutableSourceHookConfig, +): S { + const dispatcher = resolveDispatcher(); + return dispatcher.useMutableSource(source, config); +} diff --git a/packages/shared/ReactMutableSource.js b/packages/shared/ReactMutableSource.js new file mode 100644 index 0000000000000..02fd88bbbebcc --- /dev/null +++ b/packages/shared/ReactMutableSource.js @@ -0,0 +1,84 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {ExpirationTime} from 'react-reconciler/src/ReactFiberExpirationTime'; + +import invariant from 'shared/invariant'; + +export type MutableSource = $NonMaybeType; +export type Version = $NonMaybeType; + +export type MutableSourceHookConfig = {| + getSnapshot: () => S, + getVersion: () => $NonMaybeType, + subscribe: (callback: Function) => () => void, +|}; + +export type MutableSourceMetadata = {| + // Expiration time of most recently scheduled update. + // Used to determine if a source is safe to read during updates. + // If the render’s expiration time is ≤ this value, + // the source has not changed since the last render and is safe to read from. + expirationTime: ExpirationTime, + + // Number of hooks that are subscribed as of the most recently committed render. + // This value is used to determine when a source is no longer in use, + // and should be removed from the root map to avoid a memory leak. + subscriptionCount: number, +|}; + +export type MutableSourceMetadataMap = Map< + MutableSource, + MutableSourceMetadata, +>; + +// Tracks the version of each 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 for sources that have not yet been subscribed to, +// since the mechanism for determining safety after subscription is expiration time. +type MutableSourceWorkInProgressVersionMap = Map; + +let workInProgressVersionMap: null | MutableSourceWorkInProgressVersionMap = null; + +export function resetWorkInProgressVersionMap(): void { + workInProgressVersionMap = null; +} + +export function initializeWorkInProgressVersionMap(): void { + workInProgressVersionMap = new Map(); +} + +export function getWorkInProgressVersion( + source: MutableSource, +): null | Version { + invariant( + workInProgressVersionMap !== null, + 'Expected a work-in-progress version map.', + ); + + const version = ((workInProgressVersionMap: any): MutableSourceWorkInProgressVersionMap).get( + source, + ); + return version === undefined ? null : version; +} + +export function setWorkInProgressVersion( + source: MutableSource, + version: Version, +): void { + invariant( + workInProgressVersionMap !== null, + 'Expected a work-in-progress version map.', + ); + + ((workInProgressVersionMap: any): MutableSourceWorkInProgressVersionMap).set( + source, + version, + ); +} diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 654b283dc676e..7aa89986a7f99 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -345,5 +345,7 @@ "344": "Expected prepareToHydrateHostSuspenseInstance() to never be called. This error is likely caused by a bug in React. Please file an issue.", "345": "Root did not complete. This is a bug in React.", "346": "An event responder context was used outside of an event cycle.", - "347": "Maps are not valid as a React child (found: %s). Consider converting children to an array of keyed ReactElements instead." + "347": "Maps are not valid as a React child (found: %s). Consider converting children to an array of keyed ReactElements instead.", + "348": "Expected a work-in-progress root.", + "349": "Expected a work-in-progress version map." }