diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index eb7a8f6d4ef14..4ad8dadc66673 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -129,6 +129,18 @@ function useContext( 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>] { @@ -322,6 +334,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 50edb72c2844a..a6e5d45d04e78 100644 --- a/packages/react-dom/src/server/ReactPartialRendererHooks.js +++ b/packages/react-dom/src/server/ReactPartialRendererHooks.js @@ -251,6 +251,19 @@ function useContext( 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; @@ -503,6 +516,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 0b903cefa9eb2..78d09d5e5913a 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.new.js +++ b/packages/react-reconciler/src/ReactFiberHooks.new.js @@ -55,7 +55,11 @@ import { higherLanePriority, DefaultLanePriority, } from './ReactFiberLane.new'; -import {readContext, checkIfContextChanged} from './ReactFiberNewContext.new'; +import { + readContext, + readContextWithSelector, + checkIfContextChanged, +} from './ReactFiberNewContext.new'; import {HostRoot, CacheComponent} from './ReactWorkTags'; import { Update as UpdateEffect, @@ -2113,6 +2117,7 @@ export const ContextOnlyDispatcher: Dispatcher = { useCallback: throwInvalidHookError, useContext: throwInvalidHookError, + useContextSelector: throwInvalidHookError, useEffect: throwInvalidHookError, useImperativeHandle: throwInvalidHookError, useLayoutEffect: throwInvalidHookError, @@ -2138,6 +2143,7 @@ const HooksDispatcherOnMount: Dispatcher = { useCallback: mountCallback, useContext: readContext, + useContextSelector: readContextWithSelector, useEffect: mountEffect, useImperativeHandle: mountImperativeHandle, useLayoutEffect: mountLayoutEffect, @@ -2163,6 +2169,7 @@ const HooksDispatcherOnUpdate: Dispatcher = { useCallback: updateCallback, useContext: readContext, + useContextSelector: readContextWithSelector, useEffect: updateEffect, useImperativeHandle: updateImperativeHandle, useLayoutEffect: updateLayoutEffect, @@ -2188,6 +2195,7 @@ const HooksDispatcherOnRerender: Dispatcher = { useCallback: updateCallback, useContext: readContext, + useContextSelector: readContextWithSelector, useEffect: updateEffect, useImperativeHandle: updateImperativeHandle, useLayoutEffect: updateLayoutEffect, @@ -2256,6 +2264,17 @@ if (__DEV__) { mountHookTypesDev(); return readContext(context, observedBits); }, + 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, @@ -2390,6 +2409,17 @@ if (__DEV__) { updateHookTypesDev(); return readContext(context, observedBits); }, + 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, @@ -2520,6 +2550,17 @@ if (__DEV__) { updateHookTypesDev(); return readContext(context, observedBits); }, + 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, @@ -2651,6 +2692,17 @@ if (__DEV__) { updateHookTypesDev(); return readContext(context, observedBits); }, + 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, @@ -2784,6 +2836,18 @@ if (__DEV__) { mountHookTypesDev(); return readContext(context, observedBits); }, + 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, @@ -2929,6 +2993,18 @@ if (__DEV__) { updateHookTypesDev(); return readContext(context, observedBits); }, + 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, @@ -3075,6 +3151,18 @@ if (__DEV__) { updateHookTypesDev(); return readContext(context, observedBits); }, + 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 c98ff375b575a..9615dbb617c9b 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.old.js +++ b/packages/react-reconciler/src/ReactFiberHooks.old.js @@ -55,7 +55,11 @@ import { higherLanePriority, DefaultLanePriority, } from './ReactFiberLane.old'; -import {readContext, checkIfContextChanged} from './ReactFiberNewContext.old'; +import { + readContext, + readContextWithSelector, + checkIfContextChanged, +} from './ReactFiberNewContext.old'; import {HostRoot, CacheComponent} from './ReactWorkTags'; import { Update as UpdateEffect, @@ -2113,6 +2117,7 @@ export const ContextOnlyDispatcher: Dispatcher = { useCallback: throwInvalidHookError, useContext: throwInvalidHookError, + useContextSelector: throwInvalidHookError, useEffect: throwInvalidHookError, useImperativeHandle: throwInvalidHookError, useLayoutEffect: throwInvalidHookError, @@ -2138,6 +2143,7 @@ const HooksDispatcherOnMount: Dispatcher = { useCallback: mountCallback, useContext: readContext, + useContextSelector: readContextWithSelector, useEffect: mountEffect, useImperativeHandle: mountImperativeHandle, useLayoutEffect: mountLayoutEffect, @@ -2163,6 +2169,7 @@ const HooksDispatcherOnUpdate: Dispatcher = { useCallback: updateCallback, useContext: readContext, + useContextSelector: readContextWithSelector, useEffect: updateEffect, useImperativeHandle: updateImperativeHandle, useLayoutEffect: updateLayoutEffect, @@ -2188,6 +2195,7 @@ const HooksDispatcherOnRerender: Dispatcher = { useCallback: updateCallback, useContext: readContext, + useContextSelector: readContextWithSelector, useEffect: updateEffect, useImperativeHandle: updateImperativeHandle, useLayoutEffect: updateLayoutEffect, @@ -2256,6 +2264,17 @@ if (__DEV__) { mountHookTypesDev(); return readContext(context, observedBits); }, + 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, @@ -2390,6 +2409,17 @@ if (__DEV__) { updateHookTypesDev(); return readContext(context, observedBits); }, + 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, @@ -2520,6 +2550,17 @@ if (__DEV__) { updateHookTypesDev(); return readContext(context, observedBits); }, + 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, @@ -2651,6 +2692,17 @@ if (__DEV__) { updateHookTypesDev(); return readContext(context, observedBits); }, + 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, @@ -2784,6 +2836,18 @@ if (__DEV__) { mountHookTypesDev(); return readContext(context, observedBits); }, + 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, @@ -2929,6 +2993,18 @@ if (__DEV__) { updateHookTypesDev(); return readContext(context, observedBits); }, + 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, @@ -3075,6 +3151,18 @@ if (__DEV__) { updateHookTypesDev(); return readContext(context, observedBits); }, + 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 55d35510e2299..7ac8f0db4382d 100644 --- a/packages/react-reconciler/src/ReactFiberNewContext.new.js +++ b/packages/react-reconciler/src/ReactFiberNewContext.new.js @@ -53,7 +53,7 @@ if (__DEV__) { } let currentlyRenderingFiber: Fiber | null = null; -let lastContextDependency: ContextDependency | null = null; +let lastContextDependency: ContextDependency | null = null; let lastContextWithAllBitsObserved: ReactContext | null = null; let isDisallowedContextReadInDEV: boolean = false; @@ -212,6 +212,7 @@ export function propagateContextChange( let dependency = list.firstContext; while (dependency !== null) { // Check if the context matches. + // TODO: Compare selected values to bail out early. if ( dependency.context === context && (dependency.observedBits & changedBits) !== 0 @@ -416,8 +417,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; } @@ -451,10 +462,27 @@ export function prepareToReadContext( } } -export function readContext( - context: ReactContext, +export function readContextWithSelector( + context: ReactContext, + selector: C => S, +): C { + return readContextImpl(context, selector); +} + +export function readContext( + context: ReactContext, observedBits: void | number | boolean, -): T { +): C { + return readContextImpl(context, null, observedBits); +} + +type ContextSelector = C => S; + +function readContextImpl( + context: ReactContext, + selector: (C => S) | null, + observedBits: void | number | boolean, +): 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. @@ -483,7 +511,9 @@ export function readContext( observedBits === MAX_SIGNED_31_BIT_INT ) { // Observe all updates. - lastContextWithAllBitsObserved = ((context: any): ReactContext); + if (!enableLazyContextPropagation || selector === null) { + lastContextWithAllBitsObserved = ((context: any): ReactContext); + } resolvedObservedBits = MAX_SIGNED_31_BIT_INT; } else { resolvedObservedBits = observedBits; @@ -491,7 +521,10 @@ export function readContext( const contextItem = { context: ((context: any): ReactContext), + // TODO: Remove `observedBits` in favor of selector observedBits: resolvedObservedBits, + 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 f6025075bdb87..6e313084b94cc 100644 --- a/packages/react-reconciler/src/ReactFiberNewContext.old.js +++ b/packages/react-reconciler/src/ReactFiberNewContext.old.js @@ -53,7 +53,7 @@ if (__DEV__) { } let currentlyRenderingFiber: Fiber | null = null; -let lastContextDependency: ContextDependency | null = null; +let lastContextDependency: ContextDependency | null = null; let lastContextWithAllBitsObserved: ReactContext | null = null; let isDisallowedContextReadInDEV: boolean = false; @@ -212,6 +212,7 @@ export function propagateContextChange( let dependency = list.firstContext; while (dependency !== null) { // Check if the context matches. + // TODO: Compare selected values to bail out early. if ( dependency.context === context && (dependency.observedBits & changedBits) !== 0 @@ -416,8 +417,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; } @@ -451,10 +462,27 @@ export function prepareToReadContext( } } -export function readContext( - context: ReactContext, +export function readContextWithSelector( + context: ReactContext, + selector: C => S, +): C { + return readContextImpl(context, selector); +} + +export function readContext( + context: ReactContext, observedBits: void | number | boolean, -): T { +): C { + return readContextImpl(context, null, observedBits); +} + +type ContextSelector = C => S; + +function readContextImpl( + context: ReactContext, + selector: (C => S) | null, + observedBits: void | number | boolean, +): 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. @@ -483,7 +511,9 @@ export function readContext( observedBits === MAX_SIGNED_31_BIT_INT ) { // Observe all updates. - lastContextWithAllBitsObserved = ((context: any): ReactContext); + if (!enableLazyContextPropagation || selector === null) { + lastContextWithAllBitsObserved = ((context: any): ReactContext); + } resolvedObservedBits = MAX_SIGNED_31_BIT_INT; } else { resolvedObservedBits = observedBits; @@ -491,7 +521,10 @@ export function readContext( const contextItem = { context: ((context: any): ReactContext), + // TODO: Remove `observedBits` in favor of selector observedBits: resolvedObservedBits, + 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 b1764bed5ac34..a1d7c02f8afd4 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' + | 'useContextSelector' | 'useRef' | 'useEffect' | 'useLayoutEffect' @@ -47,17 +48,19 @@ export type HookType = export type ReactPriorityLevel = 99 | 98 | 97 | 96 | 95 | 90; -export type ContextDependency = { - context: ReactContext, +export type ContextDependency = { + context: ReactContext, + // TODO: Remove `observedBits` in favor of selector observedBits: number, - next: ContextDependency | null, - memoizedValue: T, + selector: (C => S) | null, + next: ContextDependency | null, + memoizedValue: C, ... }; export type Dependencies = { lanes: Lanes, - firstContext: ContextDependency | null, + firstContext: ContextDependency | null, ... }; @@ -295,6 +298,7 @@ export type Dispatcher = {| context: ReactContext, observedBits: void | number | boolean, ): 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..1d925d56f2a49 --- /dev/null +++ b/packages/react-reconciler/src/__tests__/ReactContextSelectors-test.js @@ -0,0 +1,185 @@ +let React; +let ReactNoop; +let Scheduler; +let useState; +let useContext; +let useContextSelector; + +describe('ReactContextSelectors', () => { + beforeEach(() => { + jest.resetModules(); + + React = require('react'); + ReactNoop = require('react-noop-renderer'); + Scheduler = require('scheduler'); + useState = React.useState; + useContext = React.useContext; + useContextSelector = React.unstable_useContextSelector; + }); + + function Text({text}) { + Scheduler.unstable_yieldValue(text); + return text; + } + + // @gate enableLazyContextPropagation + // @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} = useContextSelector(Context, context => context.a); + return ; + } + + function B() { + const {b} = useContextSelector(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 enableLazyContextPropagation + // @gate experimental + 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 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 enableLazyContextPropagation + // @gate experimental + 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 ReactNoop.act(async () => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['A: 0, B: 0']); + expect(root).toMatchRenderedOutput('A: 0, B: 0'); + + // Update a field in A that isn't part of the selected context. It should + // bail out. + await ReactNoop.act(async () => { + setContextA({a: 0, unrelated: 1}); + }); + expect(Scheduler).toHaveYielded([]); + expect(root).toMatchRenderedOutput('A: 0, B: 0'); + + // Now update the same a field again, but this time, also update a different + // context in the same batch. The other context prevents a bail out. + await ReactNoop.act(async () => { + setContextA({a: 0, unrelated: 1}); + setContextB(1); + }); + expect(Scheduler).toHaveYielded(['A: 0, B: 1']); + expect(root).toMatchRenderedOutput('A: 0, B: 1'); + }); +}); diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index b80798cd66f4b..fd3f0c5c0128b 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -790,6 +790,7 @@ const Dispatcher: DispatcherType = { }, readContext: (unsupportedHook: any), useContext: (unsupportedHook: any), + 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 366e86626fd15..0d3f9df6bba7d 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_useContextSelector, // enableScopeAPI unstable_Scope, unstable_useOpaqueIdentifier, diff --git a/packages/react/index.experimental.js b/packages/react/index.experimental.js index ba0d205f81297..d03c8aa56daa2 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_useContextSelector, // enableDebugTracing unstable_DebugTracingMode, } from './src/React'; diff --git a/packages/react/index.js b/packages/react/index.js index 5319bb80be756..cb3ab97a19097 100644 --- a/packages/react/index.js +++ b/packages/react/index.js @@ -84,4 +84,5 @@ export { unstable_getCacheForType, unstable_Cache, unstable_useCacheRefresh, + unstable_useContextSelector, } from './src/React'; diff --git a/packages/react/index.modern.fb.js b/packages/react/index.modern.fb.js index cf459c0bfb442..c778f2e474228 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_useContextSelector, // enableScopeAPI unstable_Scope, unstable_useOpaqueIdentifier, diff --git a/packages/react/src/React.js b/packages/react/src/React.js index 70c92ff86820e..2a1369832950b 100644 --- a/packages/react/src/React.js +++ b/packages/react/src/React.js @@ -37,6 +37,7 @@ import { getCacheForType, useCallback, useContext, + useContextSelector, useEffect, useImperativeHandle, useDebugValue, @@ -118,4 +119,6 @@ export { // enableScopeAPI REACT_SCOPE_TYPE as unstable_Scope, useOpaqueIdentifier as unstable_useOpaqueIdentifier, + // enableContextSelectors + useContextSelector as unstable_useContextSelector, }; diff --git a/packages/react/src/ReactHooks.js b/packages/react/src/ReactHooks.js index d397d8f789f0a..2ae893cae4046 100644 --- a/packages/react/src/ReactHooks.js +++ b/packages/react/src/ReactHooks.js @@ -88,6 +88,33 @@ export function useContext( return dispatcher.useContext(Context, unstable_observedBits); } +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>] {