From e99f863ef2084ac52217b013a19c21d0f330f1c0 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 = useContextSelector(Context, c => c.selectedField); ``` 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 | 40 +++- .../src/ReactFiberNewContext.old.js | 40 +++- .../src/ReactInternalTypes.js | 15 +- .../__tests__/ReactContextSelectors-test.js | 184 ++++++++++++++++++ packages/react-server/src/ReactFizzHooks.js | 12 ++ .../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 | 2 + packages/react/src/ReactHooks.js | 27 +++ 17 files changed, 518 insertions(+), 15 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 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..f208942b8fbf0 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,26 @@ 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 { + return readContextImpl(context, null); +} + +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 +656,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..6dfb0732fd2f7 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,26 @@ 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 { + return readContextImpl(context, null); +} + +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 +656,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>] {