diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index 7d2679b319ce5..ec0a6c19041d9 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -123,6 +123,18 @@ function useContext(context: ReactContext): T { return context._currentValue; } +function useContextSelector( + context: ReactContext, + selector: C => S, +): C { + hookLog.push({ + primitive: 'ContextSelector', + stackError: new Error(), + value: context._currentValue, + }); + return context._currentValue; +} + function useState( initialState: (() => S) | S, ): [S, Dispatch>] { @@ -316,6 +328,7 @@ const Dispatcher: DispatcherType = { useCacheRefresh, useCallback, useContext, + useContextSelector, useEffect, useImperativeHandle, useDebugValue, diff --git a/packages/react-dom/src/server/ReactPartialRendererHooks.js b/packages/react-dom/src/server/ReactPartialRendererHooks.js index 433ab9ae078b2..94a6be755d69b 100644 --- a/packages/react-dom/src/server/ReactPartialRendererHooks.js +++ b/packages/react-dom/src/server/ReactPartialRendererHooks.js @@ -245,6 +245,19 @@ function useContext(context: ReactContext): T { return context[threadID]; } +function useContextSelector( + context: ReactContext, + selector: C => S, +): C { + if (__DEV__) { + currentHookNameInDev = 'useContextSelector'; + } + resolveCurrentlyRenderingComponent(); + const threadID = currentPartialRenderer.threadID; + validateContextBounds(context, threadID); + return context[threadID]; +} + function basicStateReducer(state: S, action: BasicStateAction): S { // $FlowFixMe: Flow doesn't like mixed types return typeof action === 'function' ? action(state) : action; @@ -497,6 +510,7 @@ export function setCurrentPartialRenderer(renderer: PartialRenderer) { export const Dispatcher: DispatcherType = { readContext, useContext, + useContextSelector, useMemo, useReducer, useRef, diff --git a/packages/react-reconciler/src/ReactFiberHooks.new.js b/packages/react-reconciler/src/ReactFiberHooks.new.js index b195a0cb57488..7103c07983f38 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.new.js +++ b/packages/react-reconciler/src/ReactFiberHooks.new.js @@ -56,7 +56,11 @@ import { setCurrentUpdatePriority, higherEventPriority, } from './ReactEventPriorities.new'; -import {readContext, checkIfContextChanged} from './ReactFiberNewContext.new'; +import { + readContext, + readContextWithSelector, + checkIfContextChanged, +} from './ReactFiberNewContext.new'; import {HostRoot, CacheComponent} from './ReactWorkTags'; import { LayoutStatic as LayoutStaticEffect, @@ -2067,6 +2071,7 @@ export const ContextOnlyDispatcher: Dispatcher = { useCallback: throwInvalidHookError, useContext: throwInvalidHookError, + useContextSelector: throwInvalidHookError, useEffect: throwInvalidHookError, useImperativeHandle: throwInvalidHookError, useLayoutEffect: throwInvalidHookError, @@ -2092,6 +2097,7 @@ const HooksDispatcherOnMount: Dispatcher = { useCallback: mountCallback, useContext: readContext, + useContextSelector: readContextWithSelector, useEffect: mountEffect, useImperativeHandle: mountImperativeHandle, useLayoutEffect: mountLayoutEffect, @@ -2117,6 +2123,7 @@ const HooksDispatcherOnUpdate: Dispatcher = { useCallback: updateCallback, useContext: readContext, + useContextSelector: readContextWithSelector, useEffect: updateEffect, useImperativeHandle: updateImperativeHandle, useLayoutEffect: updateLayoutEffect, @@ -2142,6 +2149,7 @@ const HooksDispatcherOnRerender: Dispatcher = { useCallback: updateCallback, useContext: readContext, + useContextSelector: readContextWithSelector, useEffect: updateEffect, useImperativeHandle: updateImperativeHandle, useLayoutEffect: updateLayoutEffect, @@ -2204,6 +2212,17 @@ if (__DEV__) { mountHookTypesDev(); return readContext(context); }, + useContextSelector(context: ReactContext, selector: C => S): C { + currentHookNameInDev = 'useContextSelector'; + mountHookTypesDev(); + const prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnMountInDEV; + try { + return readContextWithSelector(context, selector); + } finally { + ReactCurrentDispatcher.current = prevDispatcher; + } + }, useEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2332,6 +2351,17 @@ if (__DEV__) { updateHookTypesDev(); return readContext(context); }, + useContextSelector(context: ReactContext, selector: C => S): C { + currentHookNameInDev = 'useContextSelector'; + updateHookTypesDev(); + const prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnMountInDEV; + try { + return readContextWithSelector(context, selector); + } finally { + ReactCurrentDispatcher.current = prevDispatcher; + } + }, useEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2456,6 +2486,17 @@ if (__DEV__) { updateHookTypesDev(); return readContext(context); }, + useContextSelector(context: ReactContext, selector: C => S): C { + currentHookNameInDev = 'useContextSelector'; + updateHookTypesDev(); + const prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV; + try { + return readContextWithSelector(context, selector); + } finally { + ReactCurrentDispatcher.current = prevDispatcher; + } + }, useEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2581,6 +2622,17 @@ if (__DEV__) { updateHookTypesDev(); return readContext(context); }, + useContextSelector(context: ReactContext, selector: C => S): C { + currentHookNameInDev = 'useContextSelector'; + updateHookTypesDev(); + const prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnRerenderInDEV; + try { + return readContextWithSelector(context, selector); + } finally { + ReactCurrentDispatcher.current = prevDispatcher; + } + }, useEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2708,6 +2760,18 @@ if (__DEV__) { mountHookTypesDev(); return readContext(context); }, + useContextSelector(context: ReactContext, selector: C => S): C { + currentHookNameInDev = 'useContextSelector'; + warnInvalidHookAccess(); + mountHookTypesDev(); + const prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnMountInDEV; + try { + return readContextWithSelector(context, selector); + } finally { + ReactCurrentDispatcher.current = prevDispatcher; + } + }, useEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2847,6 +2911,18 @@ if (__DEV__) { updateHookTypesDev(); return readContext(context); }, + useContextSelector(context: ReactContext, selector: C => S): C { + currentHookNameInDev = 'useContextSelector'; + warnInvalidHookAccess(); + updateHookTypesDev(); + const prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV; + try { + return readContextWithSelector(context, selector); + } finally { + ReactCurrentDispatcher.current = prevDispatcher; + } + }, useEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2987,6 +3063,18 @@ if (__DEV__) { updateHookTypesDev(); return readContext(context); }, + useContextSelector(context: ReactContext, selector: C => S): C { + currentHookNameInDev = 'useContextSelector'; + warnInvalidHookAccess(); + updateHookTypesDev(); + const prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV; + try { + return readContextWithSelector(context, selector); + } 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 7c51c43ad2e7e..6a16002a6aca4 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.old.js +++ b/packages/react-reconciler/src/ReactFiberHooks.old.js @@ -56,7 +56,11 @@ import { setCurrentUpdatePriority, higherEventPriority, } from './ReactEventPriorities.old'; -import {readContext, checkIfContextChanged} from './ReactFiberNewContext.old'; +import { + readContext, + readContextWithSelector, + checkIfContextChanged, +} from './ReactFiberNewContext.old'; import {HostRoot, CacheComponent} from './ReactWorkTags'; import { LayoutStatic as LayoutStaticEffect, @@ -2067,6 +2071,7 @@ export const ContextOnlyDispatcher: Dispatcher = { useCallback: throwInvalidHookError, useContext: throwInvalidHookError, + useContextSelector: throwInvalidHookError, useEffect: throwInvalidHookError, useImperativeHandle: throwInvalidHookError, useLayoutEffect: throwInvalidHookError, @@ -2092,6 +2097,7 @@ const HooksDispatcherOnMount: Dispatcher = { useCallback: mountCallback, useContext: readContext, + useContextSelector: readContextWithSelector, useEffect: mountEffect, useImperativeHandle: mountImperativeHandle, useLayoutEffect: mountLayoutEffect, @@ -2117,6 +2123,7 @@ const HooksDispatcherOnUpdate: Dispatcher = { useCallback: updateCallback, useContext: readContext, + useContextSelector: readContextWithSelector, useEffect: updateEffect, useImperativeHandle: updateImperativeHandle, useLayoutEffect: updateLayoutEffect, @@ -2142,6 +2149,7 @@ const HooksDispatcherOnRerender: Dispatcher = { useCallback: updateCallback, useContext: readContext, + useContextSelector: readContextWithSelector, useEffect: updateEffect, useImperativeHandle: updateImperativeHandle, useLayoutEffect: updateLayoutEffect, @@ -2204,6 +2212,17 @@ if (__DEV__) { mountHookTypesDev(); return readContext(context); }, + useContextSelector(context: ReactContext, selector: C => S): C { + currentHookNameInDev = 'useContextSelector'; + mountHookTypesDev(); + const prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnMountInDEV; + try { + return readContextWithSelector(context, selector); + } finally { + ReactCurrentDispatcher.current = prevDispatcher; + } + }, useEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2332,6 +2351,17 @@ if (__DEV__) { updateHookTypesDev(); return readContext(context); }, + useContextSelector(context: ReactContext, selector: C => S): C { + currentHookNameInDev = 'useContextSelector'; + updateHookTypesDev(); + const prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnMountInDEV; + try { + return readContextWithSelector(context, selector); + } finally { + ReactCurrentDispatcher.current = prevDispatcher; + } + }, useEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2456,6 +2486,17 @@ if (__DEV__) { updateHookTypesDev(); return readContext(context); }, + useContextSelector(context: ReactContext, selector: C => S): C { + currentHookNameInDev = 'useContextSelector'; + updateHookTypesDev(); + const prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV; + try { + return readContextWithSelector(context, selector); + } finally { + ReactCurrentDispatcher.current = prevDispatcher; + } + }, useEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2581,6 +2622,17 @@ if (__DEV__) { updateHookTypesDev(); return readContext(context); }, + useContextSelector(context: ReactContext, selector: C => S): C { + currentHookNameInDev = 'useContextSelector'; + updateHookTypesDev(); + const prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnRerenderInDEV; + try { + return readContextWithSelector(context, selector); + } finally { + ReactCurrentDispatcher.current = prevDispatcher; + } + }, useEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2708,6 +2760,18 @@ if (__DEV__) { mountHookTypesDev(); return readContext(context); }, + useContextSelector(context: ReactContext, selector: C => S): C { + currentHookNameInDev = 'useContextSelector'; + warnInvalidHookAccess(); + mountHookTypesDev(); + const prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnMountInDEV; + try { + return readContextWithSelector(context, selector); + } finally { + ReactCurrentDispatcher.current = prevDispatcher; + } + }, useEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2847,6 +2911,18 @@ if (__DEV__) { updateHookTypesDev(); return readContext(context); }, + useContextSelector(context: ReactContext, selector: C => S): C { + currentHookNameInDev = 'useContextSelector'; + warnInvalidHookAccess(); + updateHookTypesDev(); + const prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV; + try { + return readContextWithSelector(context, selector); + } finally { + ReactCurrentDispatcher.current = prevDispatcher; + } + }, useEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2987,6 +3063,18 @@ if (__DEV__) { updateHookTypesDev(); return readContext(context); }, + useContextSelector(context: ReactContext, selector: C => S): C { + currentHookNameInDev = 'useContextSelector'; + warnInvalidHookAccess(); + updateHookTypesDev(); + const prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV; + try { + return readContextWithSelector(context, selector); + } 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 6195532d61277..d96980d62d403 100644 --- a/packages/react-reconciler/src/ReactFiberNewContext.new.js +++ b/packages/react-reconciler/src/ReactFiberNewContext.new.js @@ -56,7 +56,7 @@ if (__DEV__) { } let currentlyRenderingFiber: Fiber | null = null; -let lastContextDependency: ContextDependency | null = null; +let lastContextDependency: ContextDependency | null = null; let lastFullyObservedContext: ReactContext | null = null; let isDisallowedContextReadInDEV: boolean = false; @@ -212,6 +212,7 @@ function propagateContextChange_eager( let dependency = list.firstContext; while (dependency !== null) { // Check if the context matches. + // TODO: Compare selected values to bail out early. if (dependency.context === context) { // Match! Schedule an update on this fiber. if (fiber.tag === ClassComponent) { @@ -568,8 +569,18 @@ export function checkIfContextChanged(currentDependencies: Dependencies) { ? context._currentValue : context._currentValue2; const oldValue = dependency.memoizedValue; - if (!is(newValue, oldValue)) { - return true; + const selector = dependency.selector; + if (selector !== null) { + // TODO: Alternatively, we could store the selected value on the context. + // However, we expect selectors to do nothing except access a subfield, + // so this is probably fine, too. + if (!is(selector(newValue), selector(oldValue))) { + return true; + } + } else { + if (!is(newValue, oldValue)) { + return true; + } } dependency = dependency.next; } @@ -603,7 +614,28 @@ export function prepareToReadContext( } } -export function readContext(context: ReactContext): T { +export function readContextWithSelector( + context: ReactContext, + selector: C => S, +): C { + if (!enableLazyContextPropagation) { + return (null: any); + } + return readContextImpl(context, selector); +} + +export function readContext(context: ReactContext): C { + const value = readContextImpl(context, null); + lastFullyObservedContext = context; + return value; +} + +type ContextSelector = C => S; + +function readContextImpl( + context: ReactContext, + selector: (C => S) | null, +): C { if (__DEV__) { // This warning would fire if you read context inside a Hook like useMemo. // Unlike the class check below, it's not enforced in production for perf. @@ -626,6 +658,8 @@ export function readContext(context: ReactContext): T { } else { const contextItem = { context: ((context: any): ReactContext), + selector: ((selector: any): ContextSelector | null), + // TODO: Store selected value so we can compare to that during propagation memoizedValue: value, next: null, }; diff --git a/packages/react-reconciler/src/ReactFiberNewContext.old.js b/packages/react-reconciler/src/ReactFiberNewContext.old.js index 110e65d688d2f..e86ca55254346 100644 --- a/packages/react-reconciler/src/ReactFiberNewContext.old.js +++ b/packages/react-reconciler/src/ReactFiberNewContext.old.js @@ -56,7 +56,7 @@ if (__DEV__) { } let currentlyRenderingFiber: Fiber | null = null; -let lastContextDependency: ContextDependency | null = null; +let lastContextDependency: ContextDependency | null = null; let lastFullyObservedContext: ReactContext | null = null; let isDisallowedContextReadInDEV: boolean = false; @@ -212,6 +212,7 @@ function propagateContextChange_eager( let dependency = list.firstContext; while (dependency !== null) { // Check if the context matches. + // TODO: Compare selected values to bail out early. if (dependency.context === context) { // Match! Schedule an update on this fiber. if (fiber.tag === ClassComponent) { @@ -568,8 +569,18 @@ export function checkIfContextChanged(currentDependencies: Dependencies) { ? context._currentValue : context._currentValue2; const oldValue = dependency.memoizedValue; - if (!is(newValue, oldValue)) { - return true; + const selector = dependency.selector; + if (selector !== null) { + // TODO: Alternatively, we could store the selected value on the context. + // However, we expect selectors to do nothing except access a subfield, + // so this is probably fine, too. + if (!is(selector(newValue), selector(oldValue))) { + return true; + } + } else { + if (!is(newValue, oldValue)) { + return true; + } } dependency = dependency.next; } @@ -603,7 +614,28 @@ export function prepareToReadContext( } } -export function readContext(context: ReactContext): T { +export function readContextWithSelector( + context: ReactContext, + selector: C => S, +): C { + if (!enableLazyContextPropagation) { + return (null: any); + } + return readContextImpl(context, selector); +} + +export function readContext(context: ReactContext): C { + const value = readContextImpl(context, null); + lastFullyObservedContext = context; + return value; +} + +type ContextSelector = C => S; + +function readContextImpl( + context: ReactContext, + selector: (C => S) | null, +): C { if (__DEV__) { // This warning would fire if you read context inside a Hook like useMemo. // Unlike the class check below, it's not enforced in production for perf. @@ -626,6 +658,8 @@ export function readContext(context: ReactContext): T { } else { const contextItem = { context: ((context: any): ReactContext), + selector: ((selector: any): ContextSelector | null), + // TODO: Store selected value so we can compare to that during propagation memoizedValue: value, next: null, }; diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index 5a4bc62374250..1c0a083158377 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -31,6 +31,7 @@ export type HookType = | 'useState' | 'useReducer' | 'useContext' + | 'useContextSelector' | 'useRef' | 'useEffect' | 'useLayoutEffect' @@ -44,16 +45,19 @@ export type HookType = | 'useOpaqueIdentifier' | 'useCacheRefresh'; -export type ContextDependency = { - context: ReactContext, - next: ContextDependency | null, - memoizedValue: T, +export type ReactPriorityLevel = 99 | 98 | 97 | 96 | 95 | 90; + +export type ContextDependency = { + context: ReactContext, + selector: (C => S) | null, + next: ContextDependency | null, + memoizedValue: C, ... }; export type Dependencies = { lanes: Lanes, - firstContext: ContextDependency | null, + firstContext: ContextDependency | null, ... }; @@ -280,6 +284,7 @@ export type Dispatcher = {| init?: (I) => S, ): [S, Dispatch], useContext(context: ReactContext): T, + useContextSelector(context: ReactContext, selector: (C) => S): C, 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..66b90566cd5ee --- /dev/null +++ b/packages/react-reconciler/src/__tests__/ReactContextSelectors-test.js @@ -0,0 +1,184 @@ +let React; +let ReactNoop; +let Scheduler; +let act; +let useState; +let useContext; +let useContextSelector; + +describe('ReactContextSelectors', () => { + beforeEach(() => { + jest.resetModules(); + + React = require('react'); + ReactNoop = require('react-noop-renderer'); + Scheduler = require('scheduler'); + act = require('jest-react').act; + useState = React.useState; + useContext = React.useContext; + useContextSelector = React.unstable_useContextSelector; + }); + + function Text({text}) { + Scheduler.unstable_yieldValue(text); + return text; + } + + // @gate enableLazyContextPropagation + 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} = useContextSelector(Context, context => context.a); + return ; + } + + function B() { + const {b} = useContextSelector(Context, context => context.b); + return ; + } + + const root = ReactNoop.createRoot(); + await 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 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 act(async () => { + setContext({a: 1, b: 1}); + }); + expect(Scheduler).toHaveYielded([1]); + expect(root).toMatchRenderedOutput('A: 1, B: 1'); + }); + + // @gate enableLazyContextPropagation + test('useContextSelector 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} = useContextSelector(Context, context => context.a); + const context = useContext(Context); + return ; + } + + const root = ReactNoop.createRoot(); + await 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 act(async () => { + setContext({a: 0, b: 0, unrelated: 1}); + }); + expect(Scheduler).toHaveYielded(['A: 0, B: 0']); + expect(root).toMatchRenderedOutput('A: 0, B: 0'); + }); + + // @gate enableLazyContextPropagation + test('useContextSelector 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} = useContextSelector(ContextA, context => context.a); + const b = useContext(ContextB); + return ; + } + + const root = ReactNoop.createRoot(); + await 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 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 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/ReactFizzHooks.js b/packages/react-server/src/ReactFizzHooks.js index 23a35c1803f70..a89721665fd2a 100644 --- a/packages/react-server/src/ReactFizzHooks.js +++ b/packages/react-server/src/ReactFizzHooks.js @@ -244,6 +244,17 @@ function useContext(context: ReactContext): T { return readContextImpl(context); } +function useContextSelector( + context: ReactContext, + selector: C => S, +): C { + if (__DEV__) { + currentHookNameInDev = 'useContextSelector'; + } + resolveCurrentlyRenderingComponent(); + return readContextImpl(context); +} + function basicStateReducer(state: S, action: BasicStateAction): S { // $FlowFixMe: Flow doesn't like mixed types return typeof action === 'function' ? action(state) : action; @@ -492,6 +503,7 @@ function noop(): void {} export const Dispatcher: DispatcherType = { readContext, useContext, + useContextSelector, useMemo, useReducer, useRef, diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index dd5b43df2d35e..ac82359029e05 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -819,6 +819,7 @@ const Dispatcher: DispatcherType = { }, readContext: (unsupportedHook: any), useContext: (unsupportedHook: any), + useContextSelector: (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..95cee159fdf80 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, + useContextSelector: unsupported, useMemo: unsupported, useReducer: unsupported, useRef: unsupported, diff --git a/packages/react/index.classic.fb.js b/packages/react/index.classic.fb.js index 653013c7b0797..669111b614975 100644 --- a/packages/react/index.classic.fb.js +++ b/packages/react/index.classic.fb.js @@ -39,6 +39,7 @@ export { unstable_Scope, unstable_getCacheForType, unstable_useCacheRefresh, + unstable_useContextSelector, unstable_useOpaqueIdentifier, useCallback, useContext, diff --git a/packages/react/index.experimental.js b/packages/react/index.experimental.js index ab90ea66bc112..29c756357a256 100644 --- a/packages/react/index.experimental.js +++ b/packages/react/index.experimental.js @@ -35,6 +35,7 @@ export { unstable_Offscreen, unstable_getCacheForType, unstable_useCacheRefresh, + unstable_useContextSelector, unstable_useOpaqueIdentifier, useCallback, useContext, diff --git a/packages/react/index.js b/packages/react/index.js index 890b06084738a..9135fb0b69e45 100644 --- a/packages/react/index.js +++ b/packages/react/index.js @@ -60,6 +60,7 @@ export { unstable_Scope, unstable_getCacheForType, unstable_useCacheRefresh, + unstable_useContextSelector, unstable_useOpaqueIdentifier, useCallback, useContext, diff --git a/packages/react/index.modern.fb.js b/packages/react/index.modern.fb.js index 4f316eacad8b7..a87a3941a74a3 100644 --- a/packages/react/index.modern.fb.js +++ b/packages/react/index.modern.fb.js @@ -38,6 +38,7 @@ export { unstable_Scope, unstable_getCacheForType, unstable_useCacheRefresh, + unstable_useContextSelector, unstable_useOpaqueIdentifier, useCallback, useContext, diff --git a/packages/react/src/React.js b/packages/react/src/React.js index 2b87d18b6c81d..4688d3386c890 100644 --- a/packages/react/src/React.js +++ b/packages/react/src/React.js @@ -38,6 +38,7 @@ import { getCacheForType, useCallback, useContext, + useContextSelector, useEffect, useImperativeHandle, useDebugValue, @@ -87,6 +88,7 @@ export { memo, useCallback, useContext, + useContextSelector as unstable_useContextSelector, useEffect, useImperativeHandle, useDebugValue, diff --git a/packages/react/src/ReactHooks.js b/packages/react/src/ReactHooks.js index 82bd886d82455..21b3b55bc205f 100644 --- a/packages/react/src/ReactHooks.js +++ b/packages/react/src/ReactHooks.js @@ -71,6 +71,33 @@ export function useContext(Context: ReactContext): T { return dispatcher.useContext(Context); } +export function useContextSelector( + Context: ReactContext, + selector: C => S, +): C { + 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 useContextSelector(Context.Consumer) is not supported, may cause bugs, and will be ' + + 'removed in a future major release. Did you mean to call useContextSelector(Context) instead?', + ); + } else if (realContext.Provider === Context) { + console.error( + 'Calling useContextSelector(Context.Provider) is not supported. ' + + 'Did you mean to call useContextSelector(Context) instead?', + ); + } + } + } + return dispatcher.useContextSelector(Context, selector); +} + export function useState( initialState: (() => S) | S, ): [S, Dispatch>] {