From eb46705daadf92cc397267e65bcb6675faca3835 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Fri, 22 Jan 2021 23:49:52 -0600 Subject: [PATCH] Implement naive version of context selectors For internal experimentation only. This implements `unstable_useSelectedContext` behind a feature flag. It's based on [RFC 119](https://github.com/reactjs/rfcs/pull/119) and [RFC 118](https://github.com/reactjs/rfcs/pull/118) by @gnoff. Usage: ```js const selection = useSelectedContext(Context, c => select(c)); ``` The key feature is that if the selected value does not change between renders, the component will bail out of rendering its children, a la `memo`, `PureComponent`, or the `useState` bailout mechanism. (Unless some other state, props, or context was updated in the same render.) However, I have not implemented the RFC's proposed optimizations to context propagation. We would like to land those eventually, but doing so will require a refactor that we don't currently have the bandwidth to complete. It will need to wait until after React 18. In the meantime though, we believe there may be value in landing this more naive implementation. It's designed to be API-compatible with the full proposal, so we have the option to make those optimizations in a non-breaking release. However, since it's still behind a flag, this currently has no impact on the stable release channel. We reserve the right to change or remove it, as we conduct internal testing. I also added an optional third argument, `isSelectionEqual`. If defined, it will override the default comparison function used to check if the selected value has changed (`Object.is`). --- .../react-debug-tools/src/ReactDebugHooks.js | 16 ++ .../src/server/ReactPartialRendererHooks.js | 17 ++ .../src/ReactFiberHooks.new.js | 165 +++++++++++- .../src/ReactFiberHooks.old.js | 165 +++++++++++- .../src/ReactFiberNewContext.new.js | 67 ++++- .../src/ReactFiberNewContext.old.js | 67 ++++- .../src/ReactInternalTypes.js | 10 + .../__tests__/ReactContextSelectors-test.js | 245 ++++++++++++++++++ .../react-server/src/ReactFlightServer.js | 1 + .../src/ReactSuspenseTestUtils.js | 1 + packages/react/index.classic.fb.js | 1 + packages/react/index.experimental.js | 1 + packages/react/index.js | 1 + packages/react/index.modern.fb.js | 1 + packages/react/src/React.js | 3 + packages/react/src/ReactHooks.js | 28 ++ packages/shared/ReactFeatureFlags.js | 3 + .../forks/ReactFeatureFlags.native-fb.js | 1 + .../forks/ReactFeatureFlags.native-oss.js | 1 + .../forks/ReactFeatureFlags.test-renderer.js | 1 + .../ReactFeatureFlags.test-renderer.native.js | 1 + .../ReactFeatureFlags.test-renderer.www.js | 1 + .../shared/forks/ReactFeatureFlags.testing.js | 1 + .../forks/ReactFeatureFlags.testing.www.js | 1 + .../shared/forks/ReactFeatureFlags.www.js | 1 + 25 files changed, 784 insertions(+), 16 deletions(-) create mode 100644 packages/react-reconciler/src/__tests__/ReactContextSelectors-test.js diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index eb7a8f6d4ef14..72e30c72fe325 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -129,6 +129,21 @@ function useContext( return context._currentValue; } +function useSelectedContext( + Context: ReactContext, + selector: C => S, + isEqual: ((S, S) => boolean) | void, +): S { + const context = Context._currentValue; + const selection = selector(context); + hookLog.push({ + primitive: 'SelectedContext', + stackError: new Error(), + value: selection, + }); + return selection; +} + function useState( initialState: (() => S) | S, ): [S, Dispatch>] { @@ -322,6 +337,7 @@ const Dispatcher: DispatcherType = { useCacheRefresh, useCallback, useContext, + useSelectedContext, useEffect, useImperativeHandle, useDebugValue, diff --git a/packages/react-dom/src/server/ReactPartialRendererHooks.js b/packages/react-dom/src/server/ReactPartialRendererHooks.js index 50edb72c2844a..e67c159d29fc0 100644 --- a/packages/react-dom/src/server/ReactPartialRendererHooks.js +++ b/packages/react-dom/src/server/ReactPartialRendererHooks.js @@ -251,6 +251,22 @@ function useContext( return context[threadID]; } +function useSelectedContext( + Context: ReactContext, + selector: C => S, + isEqual: ((S, S) => boolean) | void, +): S { + if (__DEV__) { + currentHookNameInDev = 'useSelectedContext'; + } + resolveCurrentlyRenderingComponent(); + const threadID = currentPartialRenderer.threadID; + validateContextBounds(Context, threadID); + const context = Context[threadID]; + const selection = selector(context); + return selection; +} + function basicStateReducer(state: S, action: BasicStateAction): S { // $FlowFixMe: Flow doesn't like mixed types return typeof action === 'function' ? action(state) : action; @@ -503,6 +519,7 @@ export function setCurrentPartialRenderer(renderer: PartialRenderer) { export const Dispatcher: DispatcherType = { readContext, useContext, + useSelectedContext, useMemo, useReducer, useRef, diff --git a/packages/react-reconciler/src/ReactFiberHooks.new.js b/packages/react-reconciler/src/ReactFiberHooks.new.js index 46c6b5af0e96f..48011782f622c 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.new.js +++ b/packages/react-reconciler/src/ReactFiberHooks.new.js @@ -30,6 +30,7 @@ import { decoupleUpdatePriorityFromScheduler, enableUseRefAccessWarning, enableDoubleInvokingEffects, + enableContextSelectors, } from 'shared/ReactFeatureFlags'; import { @@ -54,7 +55,7 @@ import { higherLanePriority, DefaultLanePriority, } from './ReactFiberLane.new'; -import {readContext} from './ReactFiberNewContext.new'; +import {readContext, readContextInsideHook} from './ReactFiberNewContext.new'; import {HostRoot, CacheComponent} from './ReactWorkTags'; import { Update as UpdateEffect, @@ -634,6 +635,56 @@ function updateWorkInProgressHook(): Hook { return workInProgressHook; } +function mountSelectedContext( + Context: ReactContext, + selector: C => S, + isEqual: ((S, S) => boolean) | void, +): S { + if (!enableContextSelectors) { + return (undefined: any); + } + + const hook = mountWorkInProgressHook(); + const context = readContextInsideHook(Context); + const selection = selector(context); + hook.memoizedState = selection; + return selection; +} + +function updateSelectedContext( + Context: ReactContext, + selector: C => S, + isEqual: ((S, S) => boolean) | void, +): S { + if (!enableContextSelectors) { + return (undefined: any); + } + + const hook = updateWorkInProgressHook(); + const context = readContextInsideHook(Context); + const newSelection = selector(context); + const oldSelection: S = hook.memoizedState; + if (isEqual !== undefined) { + if (__DEV__) { + if (typeof isEqual !== 'function') { + console.error( + 'The optional third argument to useSelectedContext must be a ' + + 'function. Instead got: %s', + isEqual, + ); + } + } + if (isEqual(newSelection, oldSelection)) { + return oldSelection; + } + } else if (is(newSelection, oldSelection)) { + return oldSelection; + } + markWorkInProgressReceivedUpdate(); + hook.memoizedState = newSelection; + return newSelection; +} + function createFunctionComponentUpdateQueue(): FunctionComponentUpdateQueue { return { lastEffect: null, @@ -2068,6 +2119,7 @@ export const ContextOnlyDispatcher: Dispatcher = { useCallback: throwInvalidHookError, useContext: throwInvalidHookError, + useSelectedContext: throwInvalidHookError, useEffect: throwInvalidHookError, useImperativeHandle: throwInvalidHookError, useLayoutEffect: throwInvalidHookError, @@ -2093,6 +2145,7 @@ const HooksDispatcherOnMount: Dispatcher = { useCallback: mountCallback, useContext: readContext, + useSelectedContext: mountSelectedContext, useEffect: mountEffect, useImperativeHandle: mountImperativeHandle, useLayoutEffect: mountLayoutEffect, @@ -2118,6 +2171,7 @@ const HooksDispatcherOnUpdate: Dispatcher = { useCallback: updateCallback, useContext: readContext, + useSelectedContext: updateSelectedContext, useEffect: updateEffect, useImperativeHandle: updateImperativeHandle, useLayoutEffect: updateLayoutEffect, @@ -2143,6 +2197,7 @@ const HooksDispatcherOnRerender: Dispatcher = { useCallback: updateCallback, useContext: readContext, + useSelectedContext: updateSelectedContext, useEffect: updateEffect, useImperativeHandle: updateImperativeHandle, useLayoutEffect: updateLayoutEffect, @@ -2211,6 +2266,21 @@ if (__DEV__) { mountHookTypesDev(); return readContext(context, observedBits); }, + useSelectedContext( + context: ReactContext, + selector: C => S, + isEqual: ((S, S) => boolean) | void, + ): S { + currentHookNameInDev = 'useSelectedContext'; + mountHookTypesDev(); + const prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnMountInDEV; + try { + return mountSelectedContext(context, selector, isEqual); + } finally { + ReactCurrentDispatcher.current = prevDispatcher; + } + }, useEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2345,6 +2415,21 @@ if (__DEV__) { updateHookTypesDev(); return readContext(context, observedBits); }, + useSelectedContext( + context: ReactContext, + selector: C => S, + isEqual: ((S, S) => boolean) | void, + ): S { + currentHookNameInDev = 'useSelectedContext'; + updateHookTypesDev(); + const prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnMountInDEV; + try { + return mountSelectedContext(context, selector, isEqual); + } finally { + ReactCurrentDispatcher.current = prevDispatcher; + } + }, useEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2475,6 +2560,21 @@ if (__DEV__) { updateHookTypesDev(); return readContext(context, observedBits); }, + useSelectedContext( + context: ReactContext, + selector: C => S, + isEqual: ((S, S) => boolean) | void, + ): S { + currentHookNameInDev = 'useSelectedContext'; + updateHookTypesDev(); + const prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV; + try { + return updateSelectedContext(context, selector, isEqual); + } finally { + ReactCurrentDispatcher.current = prevDispatcher; + } + }, useEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2606,6 +2706,21 @@ if (__DEV__) { updateHookTypesDev(); return readContext(context, observedBits); }, + useSelectedContext( + context: ReactContext, + selector: C => S, + isEqual: ((S, S) => boolean) | void, + ): S { + currentHookNameInDev = 'useSelectedContext'; + updateHookTypesDev(); + const prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnRerenderInDEV; + try { + return updateSelectedContext(context, selector, isEqual); + } finally { + ReactCurrentDispatcher.current = prevDispatcher; + } + }, useEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2739,6 +2854,22 @@ if (__DEV__) { mountHookTypesDev(); return readContext(context, observedBits); }, + useSelectedContext( + context: ReactContext, + selector: C => S, + isEqual: ((S, S) => boolean) | void, + ): S { + currentHookNameInDev = 'useSelectedContext'; + warnInvalidHookAccess(); + mountHookTypesDev(); + const prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnMountInDEV; + try { + return mountSelectedContext(context, selector, isEqual); + } finally { + ReactCurrentDispatcher.current = prevDispatcher; + } + }, useEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2884,6 +3015,22 @@ if (__DEV__) { updateHookTypesDev(); return readContext(context, observedBits); }, + useSelectedContext( + context: ReactContext, + selector: C => S, + isEqual: ((S, S) => boolean) | void, + ): S { + currentHookNameInDev = 'useSelectedContext'; + warnInvalidHookAccess(); + updateHookTypesDev(); + const prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV; + try { + return updateSelectedContext(context, selector, isEqual); + } finally { + ReactCurrentDispatcher.current = prevDispatcher; + } + }, useEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -3030,6 +3177,22 @@ if (__DEV__) { updateHookTypesDev(); return readContext(context, observedBits); }, + useSelectedContext( + context: ReactContext, + selector: C => S, + isEqual: ((S, S) => boolean) | void, + ): S { + currentHookNameInDev = 'useSelectedContext'; + warnInvalidHookAccess(); + updateHookTypesDev(); + const prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV; + try { + return updateSelectedContext(context, selector, isEqual); + } finally { + ReactCurrentDispatcher.current = prevDispatcher; + } + }, useEffect( create: () => (() => void) | void, deps: Array | void | null, diff --git a/packages/react-reconciler/src/ReactFiberHooks.old.js b/packages/react-reconciler/src/ReactFiberHooks.old.js index 17038f14b0904..6a7488b6a1a42 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.old.js +++ b/packages/react-reconciler/src/ReactFiberHooks.old.js @@ -30,6 +30,7 @@ import { decoupleUpdatePriorityFromScheduler, enableUseRefAccessWarning, enableDoubleInvokingEffects, + enableContextSelectors, } from 'shared/ReactFeatureFlags'; import { @@ -54,7 +55,7 @@ import { higherLanePriority, DefaultLanePriority, } from './ReactFiberLane.old'; -import {readContext} from './ReactFiberNewContext.old'; +import {readContext, readContextInsideHook} from './ReactFiberNewContext.old'; import {HostRoot, CacheComponent} from './ReactWorkTags'; import { Update as UpdateEffect, @@ -634,6 +635,56 @@ function updateWorkInProgressHook(): Hook { return workInProgressHook; } +function mountSelectedContext( + Context: ReactContext, + selector: C => S, + isEqual: ((S, S) => boolean) | void, +): S { + if (!enableContextSelectors) { + return (undefined: any); + } + + const hook = mountWorkInProgressHook(); + const context = readContextInsideHook(Context); + const selection = selector(context); + hook.memoizedState = selection; + return selection; +} + +function updateSelectedContext( + Context: ReactContext, + selector: C => S, + isEqual: ((S, S) => boolean) | void, +): S { + if (!enableContextSelectors) { + return (undefined: any); + } + + const hook = updateWorkInProgressHook(); + const context = readContextInsideHook(Context); + const newSelection = selector(context); + const oldSelection: S = hook.memoizedState; + if (isEqual !== undefined) { + if (__DEV__) { + if (typeof isEqual !== 'function') { + console.error( + 'The optional third argument to useSelectedContext must be a ' + + 'function. Instead got: %s', + isEqual, + ); + } + } + if (isEqual(newSelection, oldSelection)) { + return oldSelection; + } + } else if (is(newSelection, oldSelection)) { + return oldSelection; + } + markWorkInProgressReceivedUpdate(); + hook.memoizedState = newSelection; + return newSelection; +} + function createFunctionComponentUpdateQueue(): FunctionComponentUpdateQueue { return { lastEffect: null, @@ -2068,6 +2119,7 @@ export const ContextOnlyDispatcher: Dispatcher = { useCallback: throwInvalidHookError, useContext: throwInvalidHookError, + useSelectedContext: throwInvalidHookError, useEffect: throwInvalidHookError, useImperativeHandle: throwInvalidHookError, useLayoutEffect: throwInvalidHookError, @@ -2093,6 +2145,7 @@ const HooksDispatcherOnMount: Dispatcher = { useCallback: mountCallback, useContext: readContext, + useSelectedContext: mountSelectedContext, useEffect: mountEffect, useImperativeHandle: mountImperativeHandle, useLayoutEffect: mountLayoutEffect, @@ -2118,6 +2171,7 @@ const HooksDispatcherOnUpdate: Dispatcher = { useCallback: updateCallback, useContext: readContext, + useSelectedContext: updateSelectedContext, useEffect: updateEffect, useImperativeHandle: updateImperativeHandle, useLayoutEffect: updateLayoutEffect, @@ -2143,6 +2197,7 @@ const HooksDispatcherOnRerender: Dispatcher = { useCallback: updateCallback, useContext: readContext, + useSelectedContext: updateSelectedContext, useEffect: updateEffect, useImperativeHandle: updateImperativeHandle, useLayoutEffect: updateLayoutEffect, @@ -2211,6 +2266,21 @@ if (__DEV__) { mountHookTypesDev(); return readContext(context, observedBits); }, + useSelectedContext( + context: ReactContext, + selector: C => S, + isEqual: ((S, S) => boolean) | void, + ): S { + currentHookNameInDev = 'useSelectedContext'; + mountHookTypesDev(); + const prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnMountInDEV; + try { + return mountSelectedContext(context, selector, isEqual); + } finally { + ReactCurrentDispatcher.current = prevDispatcher; + } + }, useEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2345,6 +2415,21 @@ if (__DEV__) { updateHookTypesDev(); return readContext(context, observedBits); }, + useSelectedContext( + context: ReactContext, + selector: C => S, + isEqual: ((S, S) => boolean) | void, + ): S { + currentHookNameInDev = 'useSelectedContext'; + updateHookTypesDev(); + const prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnMountInDEV; + try { + return mountSelectedContext(context, selector, isEqual); + } finally { + ReactCurrentDispatcher.current = prevDispatcher; + } + }, useEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2475,6 +2560,21 @@ if (__DEV__) { updateHookTypesDev(); return readContext(context, observedBits); }, + useSelectedContext( + context: ReactContext, + selector: C => S, + isEqual: ((S, S) => boolean) | void, + ): S { + currentHookNameInDev = 'useSelectedContext'; + updateHookTypesDev(); + const prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV; + try { + return updateSelectedContext(context, selector, isEqual); + } finally { + ReactCurrentDispatcher.current = prevDispatcher; + } + }, useEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2606,6 +2706,21 @@ if (__DEV__) { updateHookTypesDev(); return readContext(context, observedBits); }, + useSelectedContext( + context: ReactContext, + selector: C => S, + isEqual: ((S, S) => boolean) | void, + ): S { + currentHookNameInDev = 'useSelectedContext'; + updateHookTypesDev(); + const prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnRerenderInDEV; + try { + return updateSelectedContext(context, selector, isEqual); + } finally { + ReactCurrentDispatcher.current = prevDispatcher; + } + }, useEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2739,6 +2854,22 @@ if (__DEV__) { mountHookTypesDev(); return readContext(context, observedBits); }, + useSelectedContext( + context: ReactContext, + selector: C => S, + isEqual: ((S, S) => boolean) | void, + ): S { + currentHookNameInDev = 'useSelectedContext'; + warnInvalidHookAccess(); + mountHookTypesDev(); + const prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnMountInDEV; + try { + return mountSelectedContext(context, selector, isEqual); + } finally { + ReactCurrentDispatcher.current = prevDispatcher; + } + }, useEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2884,6 +3015,22 @@ if (__DEV__) { updateHookTypesDev(); return readContext(context, observedBits); }, + useSelectedContext( + context: ReactContext, + selector: C => S, + isEqual: ((S, S) => boolean) | void, + ): S { + currentHookNameInDev = 'useSelectedContext'; + warnInvalidHookAccess(); + updateHookTypesDev(); + const prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV; + try { + return updateSelectedContext(context, selector, isEqual); + } finally { + ReactCurrentDispatcher.current = prevDispatcher; + } + }, useEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -3030,6 +3177,22 @@ if (__DEV__) { updateHookTypesDev(); return readContext(context, observedBits); }, + useSelectedContext( + context: ReactContext, + selector: C => S, + isEqual: ((S, S) => boolean) | void, + ): S { + currentHookNameInDev = 'useSelectedContext'; + warnInvalidHookAccess(); + updateHookTypesDev(); + const prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV; + try { + return updateSelectedContext(context, selector, isEqual); + } finally { + ReactCurrentDispatcher.current = prevDispatcher; + } + }, useEffect( create: () => (() => void) | void, deps: Array | void | null, diff --git a/packages/react-reconciler/src/ReactFiberNewContext.new.js b/packages/react-reconciler/src/ReactFiberNewContext.new.js index ad524da736d0c..16002bf41b26e 100644 --- a/packages/react-reconciler/src/ReactFiberNewContext.new.js +++ b/packages/react-reconciler/src/ReactFiberNewContext.new.js @@ -34,7 +34,10 @@ import invariant from 'shared/invariant'; import is from 'shared/objectIs'; import {createUpdate, ForceUpdate} from './ReactUpdateQueue.new'; import {markWorkInProgressReceivedUpdate} from './ReactFiberBeginWork.new'; -import {enableSuspenseServerRenderer} from 'shared/ReactFeatureFlags'; +import { + enableSuspenseServerRenderer, + enableContextSelectors, +} from 'shared/ReactFeatureFlags'; const valueCursor: StackCursor = createCursor(null); @@ -244,12 +247,20 @@ export function propagateContextChange( } scheduleWorkOnParentPath(fiber.return, renderLanes); - // Mark the updated lanes on the list, too. - list.lanes = mergeLanes(list.lanes, renderLanes); - - // Since we already found a match, we can stop traversing the - // dependency list. - break; + // Mark the updated lanes on the list, too, so that the consumer + // knows it received an update. Unless this dependency is associated + // with a hook, in which case we'll let the hook decide whether to + // bail out when we visit it. + // TODO: We could call the selector right here, during propagation. + // That would give us the opportunity to bail out early, without + // even visiting the fiber. + const hasHook = dependency.hook; + if (!hasHook) { + list.lanes = mergeLanes(list.lanes, renderLanes); + // Since we already scheduled an update on this fiber, we can stop + // traversing the dependency list. + break; + } } dependency = dependency.next; } @@ -370,6 +381,7 @@ export function readContext( const contextItem = { context: ((context: any): ReactContext), observedBits: resolvedObservedBits, + hook: false, next: null, }; @@ -396,3 +408,44 @@ export function readContext( } return isPrimaryRenderer ? context._currentValue : context._currentValue2; } + +// Special, internal version of readContext meant to be used inside another +// hook's implementation. Creates a context dependency, but shifts +// responsibility to the hook to track whether the context has changed. In other +// words, whereas the normal readContext function will override the fiber +// bailout mechanism, dependencies created by this function will not mark a +// render as "dirty", preserving the option to bailout. +export function readContextInsideHook(context: ReactContext): T { + if (!enableContextSelectors) { + return (undefined: any); + } + + const contextItem = { + context: ((context: any): ReactContext), + observedBits: MAX_SIGNED_31_BIT_INT, + hook: true, + next: null, + }; + + if (lastContextDependency === null) { + invariant( + currentlyRenderingFiber !== null, + 'Context can only be read while React is rendering. ' + + 'In classes, you can read it in the render method or getDerivedStateFromProps. ' + + 'In function components, you can read it directly in the function body, but not ' + + 'inside Hooks like useReducer() or useMemo().', + ); + + // This is the first dependency for this component. Create a new list. + lastContextDependency = contextItem; + currentlyRenderingFiber.dependencies = { + lanes: NoLanes, + firstContext: contextItem, + responders: null, + }; + } else { + // Append a new context item. + lastContextDependency = lastContextDependency.next = contextItem; + } + return isPrimaryRenderer ? context._currentValue : context._currentValue2; +} diff --git a/packages/react-reconciler/src/ReactFiberNewContext.old.js b/packages/react-reconciler/src/ReactFiberNewContext.old.js index c1102b89f93f2..f2a07b45bd223 100644 --- a/packages/react-reconciler/src/ReactFiberNewContext.old.js +++ b/packages/react-reconciler/src/ReactFiberNewContext.old.js @@ -34,7 +34,10 @@ import invariant from 'shared/invariant'; import is from 'shared/objectIs'; import {createUpdate, ForceUpdate} from './ReactUpdateQueue.old'; import {markWorkInProgressReceivedUpdate} from './ReactFiberBeginWork.old'; -import {enableSuspenseServerRenderer} from 'shared/ReactFeatureFlags'; +import { + enableSuspenseServerRenderer, + enableContextSelectors, +} from 'shared/ReactFeatureFlags'; const valueCursor: StackCursor = createCursor(null); @@ -244,12 +247,20 @@ export function propagateContextChange( } scheduleWorkOnParentPath(fiber.return, renderLanes); - // Mark the updated lanes on the list, too. - list.lanes = mergeLanes(list.lanes, renderLanes); - - // Since we already found a match, we can stop traversing the - // dependency list. - break; + // Mark the updated lanes on the list, too, so that the consumer + // knows it received an update. Unless this dependency is associated + // with a hook, in which case we'll let the hook decide whether to + // bail out when we visit it. + // TODO: We could call the selector right here, during propagation. + // That would give us the opportunity to bail out early, without + // even visiting the fiber. + const hasHook = dependency.hook; + if (!hasHook) { + list.lanes = mergeLanes(list.lanes, renderLanes); + // Since we already scheduled an update on this fiber, we can stop + // traversing the dependency list. + break; + } } dependency = dependency.next; } @@ -370,6 +381,7 @@ export function readContext( const contextItem = { context: ((context: any): ReactContext), observedBits: resolvedObservedBits, + hook: false, next: null, }; @@ -396,3 +408,44 @@ export function readContext( } return isPrimaryRenderer ? context._currentValue : context._currentValue2; } + +// Special, internal version of readContext meant to be used inside another +// hook's implementation. Creates a context dependency, but shifts +// responsibility to the hook to track whether the context has changed. In other +// words, whereas the normal readContext function will override the fiber +// bailout mechanism, dependencies created by this function will not mark a +// render as "dirty", preserving the option to bailout. +export function readContextInsideHook(context: ReactContext): T { + if (!enableContextSelectors) { + return (undefined: any); + } + + const contextItem = { + context: ((context: any): ReactContext), + observedBits: MAX_SIGNED_31_BIT_INT, + hook: true, + next: null, + }; + + if (lastContextDependency === null) { + invariant( + currentlyRenderingFiber !== null, + 'Context can only be read while React is rendering. ' + + 'In classes, you can read it in the render method or getDerivedStateFromProps. ' + + 'In function components, you can read it directly in the function body, but not ' + + 'inside Hooks like useReducer() or useMemo().', + ); + + // This is the first dependency for this component. Create a new list. + lastContextDependency = contextItem; + currentlyRenderingFiber.dependencies = { + lanes: NoLanes, + firstContext: contextItem, + responders: null, + }; + } else { + // Append a new context item. + lastContextDependency = lastContextDependency.next = contextItem; + } + return isPrimaryRenderer ? context._currentValue : context._currentValue2; +} diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index cb34ac74948e0..9e09a852ce3e2 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -32,6 +32,7 @@ export type HookType = | 'useState' | 'useReducer' | 'useContext' + | 'useSelectedContext' | 'useRef' | 'useEffect' | 'useLayoutEffect' @@ -50,6 +51,10 @@ export type ReactPriorityLevel = 99 | 98 | 97 | 96 | 95 | 90; export type ContextDependency = { context: ReactContext, observedBits: number, + // True if this dependency is associated with a hook object. Eventually this + // could point to the actual hook object, so that the propagation function + // could read information about its current state. + hook: boolean, next: ContextDependency | null, ... }; @@ -294,6 +299,11 @@ export type Dispatcher = {| context: ReactContext, observedBits: void | number | boolean, ): T, + useSelectedContext( + context: ReactContext, + selector: (C) => S, + isEqual: ((S, S) => boolean) | void, + ): S, useRef(initialValue: T): {|current: T|}, useEffect( create: () => (() => void) | void, diff --git a/packages/react-reconciler/src/__tests__/ReactContextSelectors-test.js b/packages/react-reconciler/src/__tests__/ReactContextSelectors-test.js new file mode 100644 index 0000000000000..7d1ac5e2495f9 --- /dev/null +++ b/packages/react-reconciler/src/__tests__/ReactContextSelectors-test.js @@ -0,0 +1,245 @@ +let React; +let ReactNoop; +let Scheduler; +let useState; +let useContext; +let useSelectedContext; + +describe('ReactContextSelectors', () => { + beforeEach(() => { + jest.resetModules(); + + React = require('react'); + ReactNoop = require('react-noop-renderer'); + Scheduler = require('scheduler'); + useState = React.useState; + useContext = React.useContext; + useSelectedContext = React.unstable_useSelectedContext; + }); + + function Text({text}) { + Scheduler.unstable_yieldValue(text); + return text; + } + + // @gate experimental + test('basic context selector', async () => { + const Context = React.createContext(); + + let setContext; + function App() { + const [context, _setContext] = useState({a: 0, b: 0}); + setContext = _setContext; + return ( + + + + ); + } + + // Intermediate parent that bails out. Children will only re-render when the + // context changes. + const Indirection = React.memo(() => { + return ( + <> + A: , B: + + ); + }); + + function A() { + const a = useSelectedContext(Context, context => context.a); + return ; + } + + function B() { + const a = useSelectedContext(Context, context => context.b); + return ; + } + + const root = ReactNoop.createRoot(); + await ReactNoop.act(async () => { + root.render(); + }); + expect(Scheduler).toHaveYielded([0, 0]); + expect(root).toMatchRenderedOutput('A: 0, B: 0'); + + // Update a. Only the A consumer should re-render. + await ReactNoop.act(async () => { + setContext({a: 1, b: 0}); + }); + expect(Scheduler).toHaveYielded([1]); + expect(root).toMatchRenderedOutput('A: 1, B: 0'); + + // Update b. Only the B consumer should re-render. + await ReactNoop.act(async () => { + setContext({a: 1, b: 1}); + }); + expect(Scheduler).toHaveYielded([1]); + expect(root).toMatchRenderedOutput('A: 1, B: 1'); + }); + + // @gate experimental + test('custom comparison function', async () => { + const Context = React.createContext(); + + let setContext; + function App() { + const [context, _setContext] = useState({a: 0, b: 0, c: 0}); + setContext = _setContext; + return ( + + + + ); + } + + // Intermediate parent that bails out. Children will only re-render when the + // context changes. + const Indirection = React.memo(() => { + return ; + }); + + function Child() { + const [a, b] = useSelectedContext( + Context, + // Select only the values we care about (a and b, but not c). + context => [context.a, context.b], + // Compare the selected values + ([a1, b1], [a2, b2]) => a1 === a2 && b1 === b2, + ); + return ; + } + + const root = ReactNoop.createRoot(); + await ReactNoop.act(async () => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['A: 0, B: 0']); + expect(root).toMatchRenderedOutput('A: 0, B: 0'); + + // Update a. Should re-render, since it's part of the selection. + await ReactNoop.act(async () => { + setContext({a: 1, b: 0, c: 0}); + }); + expect(Scheduler).toHaveYielded(['A: 1, B: 0']); + expect(root).toMatchRenderedOutput('A: 1, B: 0'); + + // Same with b. + await ReactNoop.act(async () => { + setContext({a: 1, b: 1, c: 0}); + }); + expect(Scheduler).toHaveYielded(['A: 1, B: 1']); + expect(root).toMatchRenderedOutput('A: 1, B: 1'); + + // But not c. Should bail out, because it's not part of the selection. + await ReactNoop.act(async () => { + setContext({a: 1, b: 1, c: 1}); + }); + expect(Scheduler).toHaveYielded([ + // Child did not re-render + ]); + expect(root).toMatchRenderedOutput('A: 1, B: 1'); + }); + + // @gate experimental + test('useSelectedContext and useContext subscribing to same context in same component', async () => { + const Context = React.createContext(); + + let setContext; + function App() { + const [context, _setContext] = useState({a: 0, b: 0, unrelated: 0}); + setContext = _setContext; + return ( + + + + ); + } + + // Intermediate parent that bails out. Children will only re-render when the + // context changes. + const Indirection = React.memo(() => { + return ; + }); + + function Child() { + const a = useSelectedContext(Context, context => context.a); + const context = useContext(Context); + return ; + } + + const root = ReactNoop.createRoot(); + await ReactNoop.act(async () => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['A: 0, B: 0']); + expect(root).toMatchRenderedOutput('A: 0, B: 0'); + + // Update an unrelated field that isn't used by the component. The selected + // context attempts to bail out, but the normal context forces an update. + await ReactNoop.act(async () => { + setContext({a: 0, b: 0, unrelated: 1}); + }); + expect(Scheduler).toHaveYielded(['A: 0, B: 0']); + expect(root).toMatchRenderedOutput('A: 0, B: 0'); + }); + + // @gate experimental + test('useSelectedContext and useContext subscribing to different contexts in same component', async () => { + const ContextA = React.createContext(); + const ContextB = React.createContext(); + + let setContextA; + let setContextB; + function App() { + const [a, _setContextA] = useState({a: 0, unrelated: 0}); + const [b, _setContextB] = useState(0); + setContextA = _setContextA; + setContextB = _setContextB; + return ( + + + + + + ); + } + + // Intermediate parent that bails out. Children will only re-render when the + // context changes. + const Indirection = React.memo(() => { + return ; + }); + + function Child() { + const a = useSelectedContext(ContextA, context => context.a); + const b = useContext(ContextB); + return ; + } + + const root = ReactNoop.createRoot(); + await ReactNoop.act(async () => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['A: 0, B: 0']); + expect(root).toMatchRenderedOutput('A: 0, B: 0'); + + // Update a field in A that isn't part of the selected context. It should + // bail out. + await ReactNoop.act(async () => { + setContextA({a: 0, unrelated: 1}); + }); + expect(Scheduler).toHaveYielded([]); + expect(root).toMatchRenderedOutput('A: 0, B: 0'); + + // Now update the same a field again, but this time, also update a different + // context in the same batch. The other context prevents a bail out. + await ReactNoop.act(async () => { + setContextA({a: 0, unrelated: 1}); + setContextB(1); + }); + expect(Scheduler).toHaveYielded(['A: 0, B: 1']); + expect(root).toMatchRenderedOutput('A: 0, B: 1'); + }); +}); diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index b80798cd66f4b..c0c7f6a5661b7 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -790,6 +790,7 @@ const Dispatcher: DispatcherType = { }, readContext: (unsupportedHook: any), useContext: (unsupportedHook: any), + useSelectedContext: (unsupportedHook: any), useReducer: (unsupportedHook: any), useRef: (unsupportedHook: any), useState: (unsupportedHook: any), diff --git a/packages/react-suspense-test-utils/src/ReactSuspenseTestUtils.js b/packages/react-suspense-test-utils/src/ReactSuspenseTestUtils.js index 43c6c5184d2b9..93ccb5672dbdf 100644 --- a/packages/react-suspense-test-utils/src/ReactSuspenseTestUtils.js +++ b/packages/react-suspense-test-utils/src/ReactSuspenseTestUtils.js @@ -31,6 +31,7 @@ export function waitForSuspense(fn: () => T): Promise { }, readContext: unsupported, useContext: unsupported, + useSelectedContext: unsupported, useMemo: unsupported, useReducer: unsupported, useRef: unsupported, diff --git a/packages/react/index.classic.fb.js b/packages/react/index.classic.fb.js index 366e86626fd15..6ec066b348507 100644 --- a/packages/react/index.classic.fb.js +++ b/packages/react/index.classic.fb.js @@ -53,6 +53,7 @@ export { unstable_getCacheForType, unstable_Cache, unstable_useCacheRefresh, + unstable_useSelectedContext, // enableScopeAPI unstable_Scope, unstable_useOpaqueIdentifier, diff --git a/packages/react/index.experimental.js b/packages/react/index.experimental.js index ba0d205f81297..892de5afb7bda 100644 --- a/packages/react/index.experimental.js +++ b/packages/react/index.experimental.js @@ -48,6 +48,7 @@ export { unstable_getCacheForType, unstable_Cache, unstable_useCacheRefresh, + unstable_useSelectedContext, // enableDebugTracing unstable_DebugTracingMode, } from './src/React'; diff --git a/packages/react/index.js b/packages/react/index.js index 5319bb80be756..491d1ce94c2d2 100644 --- a/packages/react/index.js +++ b/packages/react/index.js @@ -84,4 +84,5 @@ export { unstable_getCacheForType, unstable_Cache, unstable_useCacheRefresh, + unstable_useSelectedContext, } from './src/React'; diff --git a/packages/react/index.modern.fb.js b/packages/react/index.modern.fb.js index cf459c0bfb442..0bc2b44800964 100644 --- a/packages/react/index.modern.fb.js +++ b/packages/react/index.modern.fb.js @@ -52,6 +52,7 @@ export { unstable_getCacheForType, unstable_Cache, unstable_useCacheRefresh, + unstable_useSelectedContext, // enableScopeAPI unstable_Scope, unstable_useOpaqueIdentifier, diff --git a/packages/react/src/React.js b/packages/react/src/React.js index 70c92ff86820e..23514718559e4 100644 --- a/packages/react/src/React.js +++ b/packages/react/src/React.js @@ -37,6 +37,7 @@ import { getCacheForType, useCallback, useContext, + useSelectedContext, useEffect, useImperativeHandle, useDebugValue, @@ -118,4 +119,6 @@ export { // enableScopeAPI REACT_SCOPE_TYPE as unstable_Scope, useOpaqueIdentifier as unstable_useOpaqueIdentifier, + // enableContextSelectors + useSelectedContext as unstable_useSelectedContext, }; diff --git a/packages/react/src/ReactHooks.js b/packages/react/src/ReactHooks.js index d397d8f789f0a..b4b9a2901d44f 100644 --- a/packages/react/src/ReactHooks.js +++ b/packages/react/src/ReactHooks.js @@ -88,6 +88,34 @@ export function useContext( return dispatcher.useContext(Context, unstable_observedBits); } +export function useSelectedContext( + Context: ReactContext, + selector: C => S, + isEqual: ((S, S) => boolean) | void, +): S { + const dispatcher = resolveDispatcher(); + if (__DEV__) { + // TODO: add a more generic warning for invalid values. + if ((Context: any)._context !== undefined) { + const realContext = (Context: any)._context; + // Don't deduplicate because this legitimately causes bugs + // and nobody should be using this in existing code. + if (realContext.Consumer === Context) { + console.error( + 'Calling useSelectedContext(Context.Consumer) is not supported, may cause bugs, and will be ' + + 'removed in a future major release. Did you mean to call useSelectedContext(Context) instead?', + ); + } else if (realContext.Provider === Context) { + console.error( + 'Calling useSelectedContext(Context.Provider) is not supported. ' + + 'Did you mean to call useSelectedContext(Context) instead?', + ); + } + } + } + return dispatcher.useSelectedContext(Context, selector, isEqual); +} + export function useState( initialState: (() => S) | S, ): [S, Dispatch>] { diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index 842bbe4dfa616..20f35f7747a53 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -54,6 +54,9 @@ export const enableSelectiveHydration = __EXPERIMENTAL__; export const enableLazyElements = __EXPERIMENTAL__; export const enableCache = __EXPERIMENTAL__; +// Experimental Context API +export const enableContextSelectors = __EXPERIMENTAL__; + // Only used in www builds. export const enableSchedulerDebugging = false; diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index 1200892cee3f5..23f0e30d09fbd 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -22,6 +22,7 @@ export const enableSuspenseServerRenderer = false; export const enableSelectiveHydration = false; export const enableLazyElements = false; export const enableCache = false; +export const enableContextSelectors = false; export const enableSchedulerDebugging = false; export const debugRenderPhaseSideEffectsForStrictMode = true; export const disableJavaScriptURLs = false; diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index 3eb2d5e718f9a..3322f6d5169d8 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -24,6 +24,7 @@ export const enableSuspenseServerRenderer = false; export const enableSelectiveHydration = false; export const enableLazyElements = false; export const enableCache = false; +export const enableContextSelectors = false; export const disableJavaScriptURLs = false; export const disableInputAttributeSyncing = false; export const enableSchedulerDebugging = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index 0e95ccae41092..4e5c598c8ce40 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -24,6 +24,7 @@ export const enableSuspenseServerRenderer = false; export const enableSelectiveHydration = false; export const enableLazyElements = false; export const enableCache = __EXPERIMENTAL__; +export const enableContextSelectors = __EXPERIMENTAL__; export const disableJavaScriptURLs = false; export const disableInputAttributeSyncing = false; export const enableSchedulerDebugging = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js index ef7e948b2e189..677449a1a919c 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js @@ -24,6 +24,7 @@ export const enableSuspenseServerRenderer = false; export const enableSelectiveHydration = false; export const enableLazyElements = false; export const enableCache = false; +export const enableContextSelectors = false; export const disableJavaScriptURLs = false; export const disableInputAttributeSyncing = false; export const enableSchedulerDebugging = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index 756c38694ec7f..5914d2fd5814c 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -24,6 +24,7 @@ export const enableSuspenseServerRenderer = false; export const enableSelectiveHydration = false; export const enableLazyElements = false; export const enableCache = false; +export const enableContextSelectors = false; export const enableSchedulerDebugging = false; export const disableJavaScriptURLs = false; export const disableInputAttributeSyncing = false; diff --git a/packages/shared/forks/ReactFeatureFlags.testing.js b/packages/shared/forks/ReactFeatureFlags.testing.js index d2240049e0a4d..0693a72c784d7 100644 --- a/packages/shared/forks/ReactFeatureFlags.testing.js +++ b/packages/shared/forks/ReactFeatureFlags.testing.js @@ -24,6 +24,7 @@ export const enableSuspenseServerRenderer = false; export const enableSelectiveHydration = false; export const enableLazyElements = false; export const enableCache = false; +export const enableContextSelectors = false; export const disableJavaScriptURLs = false; export const disableInputAttributeSyncing = false; export const enableSchedulerDebugging = false; diff --git a/packages/shared/forks/ReactFeatureFlags.testing.www.js b/packages/shared/forks/ReactFeatureFlags.testing.www.js index e80dfde724d28..cdbe5b98aefae 100644 --- a/packages/shared/forks/ReactFeatureFlags.testing.www.js +++ b/packages/shared/forks/ReactFeatureFlags.testing.www.js @@ -24,6 +24,7 @@ export const enableSuspenseServerRenderer = true; export const enableSelectiveHydration = true; export const enableLazyElements = false; export const enableCache = false; +export const enableContextSelectors = true; export const disableJavaScriptURLs = true; export const disableInputAttributeSyncing = false; export const enableSchedulerDebugging = false; diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index 640b0d6f3e303..22f9de60632e2 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -64,6 +64,7 @@ export const enableSelectiveHydration = true; export const enableLazyElements = true; export const enableCache = true; +export const enableContextSelectors = true; export const disableJavaScriptURLs = true;