From 26d596d3cdd8226f160f801c3386ffe20e657286 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 374d383030cdb..b3a950a64fa87 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 { @@ -52,7 +53,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, @@ -627,6 +628,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, @@ -1995,6 +2046,7 @@ export const ContextOnlyDispatcher: Dispatcher = { useCallback: throwInvalidHookError, useContext: throwInvalidHookError, + useSelectedContext: throwInvalidHookError, useEffect: throwInvalidHookError, useImperativeHandle: throwInvalidHookError, useLayoutEffect: throwInvalidHookError, @@ -2020,6 +2072,7 @@ const HooksDispatcherOnMount: Dispatcher = { useCallback: mountCallback, useContext: readContext, + useSelectedContext: mountSelectedContext, useEffect: mountEffect, useImperativeHandle: mountImperativeHandle, useLayoutEffect: mountLayoutEffect, @@ -2045,6 +2098,7 @@ const HooksDispatcherOnUpdate: Dispatcher = { useCallback: updateCallback, useContext: readContext, + useSelectedContext: updateSelectedContext, useEffect: updateEffect, useImperativeHandle: updateImperativeHandle, useLayoutEffect: updateLayoutEffect, @@ -2070,6 +2124,7 @@ const HooksDispatcherOnRerender: Dispatcher = { useCallback: updateCallback, useContext: readContext, + useSelectedContext: updateSelectedContext, useEffect: updateEffect, useImperativeHandle: updateImperativeHandle, useLayoutEffect: updateLayoutEffect, @@ -2138,6 +2193,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, @@ -2272,6 +2342,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, @@ -2402,6 +2487,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, @@ -2533,6 +2633,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, @@ -2666,6 +2781,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, @@ -2811,6 +2942,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, @@ -2957,6 +3104,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 26721dcd11264..f736d6c2af348 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 { @@ -52,7 +53,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, @@ -624,6 +625,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, @@ -1949,6 +2000,7 @@ export const ContextOnlyDispatcher: Dispatcher = { useCallback: throwInvalidHookError, useContext: throwInvalidHookError, + useSelectedContext: throwInvalidHookError, useEffect: throwInvalidHookError, useImperativeHandle: throwInvalidHookError, useLayoutEffect: throwInvalidHookError, @@ -1974,6 +2026,7 @@ const HooksDispatcherOnMount: Dispatcher = { useCallback: mountCallback, useContext: readContext, + useSelectedContext: mountSelectedContext, useEffect: mountEffect, useImperativeHandle: mountImperativeHandle, useLayoutEffect: mountLayoutEffect, @@ -1999,6 +2052,7 @@ const HooksDispatcherOnUpdate: Dispatcher = { useCallback: updateCallback, useContext: readContext, + useSelectedContext: updateSelectedContext, useEffect: updateEffect, useImperativeHandle: updateImperativeHandle, useLayoutEffect: updateLayoutEffect, @@ -2024,6 +2078,7 @@ const HooksDispatcherOnRerender: Dispatcher = { useCallback: updateCallback, useContext: readContext, + useSelectedContext: updateSelectedContext, useEffect: updateEffect, useImperativeHandle: updateImperativeHandle, useLayoutEffect: updateLayoutEffect, @@ -2092,6 +2147,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, @@ -2226,6 +2296,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, @@ -2356,6 +2441,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, @@ -2487,6 +2587,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, @@ -2620,6 +2735,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, @@ -2765,6 +2896,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, @@ -2911,6 +3058,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 934bc6b3fcd5c..bb5d07e3595ae 100644 --- a/packages/react-reconciler/src/ReactFiberNewContext.old.js +++ b/packages/react-reconciler/src/ReactFiberNewContext.old.js @@ -33,7 +33,10 @@ import invariant from 'shared/invariant'; import is from 'shared/objectIs'; import {createUpdate, enqueueUpdate, 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); @@ -229,12 +232,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; } @@ -355,6 +366,7 @@ export function readContext( const contextItem = { context: ((context: any): ReactContext), observedBits: resolvedObservedBits, + hook: false, next: null, }; @@ -381,3 +393,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 ffbc13edaf60f..54f329e3d5f02 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -796,6 +796,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 80e6591171b5c..8e92551d06179 100644 --- a/packages/react/index.js +++ b/packages/react/index.js @@ -85,4 +85,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 84490ef902c97..0e0355c6db81f 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, @@ -121,4 +122,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 c3ffb65e96ff2..e6b0736e11355 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 23c926c65a3fb..0d479d1449526 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 3ed2d7701c93f..53079127121c8 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 c6ffacb27bab2..4daed40634319 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 84ea8902f60c2..e5d054cc08903 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 eb1630fc49bd7..4edcd9057711d 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 87133385439f3..d9e0a50436de1 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 671f44be02952..b436ebfc39e08 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 8b2bda551339e..4fee56d22e4e0 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -61,6 +61,7 @@ export const enableSelectiveHydration = true; export const enableLazyElements = true; export const enableCache = true; +export const enableContextSelectors = true; export const disableJavaScriptURLs = true;