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.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;