From 887ee39b943f3a00cc94e70bed2e246efb6cae24 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Fri, 26 Feb 2021 15:37:01 -0600 Subject: [PATCH] Experiment: Context Selectors For internal experimentation only. This implements `unstable_useContextSelector` behind a feature flag. It's based on [RFC 119](https://github.com/reactjs/rfcs/pull/119) and [RFC 118](https://github.com/reactjs/rfcs/pull/118) by @gnoff. Usage: ```js const context = useSelectedContext(Context, c => select(c)); ``` The key feature is that if the selected value does not change between renders, the component will bail out of rendering its children, a la `memo`, `PureComponent`, or the `useState` bailout mechanism. (Unless some other state, props, or context was updated in the same render.) One difference from the RFC is that it does not return the selected value. It returns the full context object. This serves a few purposes: it discourages you from creating any new objects or derived values inside the selector, because it'll get thrown out regardless. Instead, all the selector will do is return a subfield. Then you can compute the derived value inside the component, and if needed, you memoize that derived value with `useMemo`. If all the selectors do is access a subfield, they're (theoretically) fast enough that we can call them during the propagation scan and bail out really early, without having to visit the component during the render phase. Another benefit is that it's API compatible with `useContext`. So we can put it behind a flag that falls back to regular `useContext`. The longer term vision is that these optimizations (in addition to other memoization checks, like `useMemo` and `useCallback`) are inserted automatically by a compiler. So you would write code like this: ```js const {a, b} = useContext(Context); const derived = computeDerived(a, b); ``` and it would get converted to something like this: ```js const {a} = useContextSelector(Context, context => context.a); const {b} = useContextSelector(Context, context => context.b); const derived = useMemo(() => computeDerived(a, b), [a, b]); ``` (Though not this exactly. Some lower level compiler output target.) --- .../react-debug-tools/src/ReactDebugHooks.js | 13 ++ .../src/server/ReactPartialRendererHooks.js | 14 ++ .../src/ReactFiberHooks.new.js | 90 ++++++++- .../src/ReactFiberHooks.old.js | 90 ++++++++- .../src/ReactFiberNewContext.new.js | 47 ++++- .../src/ReactFiberNewContext.old.js | 47 ++++- .../src/ReactInternalTypes.js | 14 +- .../__tests__/ReactContextSelectors-test.js | 185 ++++++++++++++++++ .../react-server/src/ReactFlightServer.js | 1 + .../src/ReactSuspenseTestUtils.js | 1 + packages/react/index.classic.fb.js | 1 + packages/react/index.experimental.js | 1 + packages/react/index.js | 1 + packages/react/index.modern.fb.js | 1 + packages/react/src/React.js | 3 + packages/react/src/ReactHooks.js | 27 +++ 16 files changed, 515 insertions(+), 21 deletions(-) create mode 100644 packages/react-reconciler/src/__tests__/ReactContextSelectors-test.js diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index eb7a8f6d4ef14..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>] {