@samitouri / QOS-React / commits / 1350a85980

Add unstable context bailout for profiling (#30407)

**This API is not intended to ship. This is a temporary unstable hook for internal performance profiling.** This PR exposes `unstable_useContextWithBailout`, which takes a compare function in addition to Context. The comparison function is run to determine if Context propagation and render should bail out earlier. `unstable_useContextWithBailout` returns the full Context value, same as `useContext`. We can profile this API against `useContext` to better measure the cost of Context value updates and gather more data around propagation and render performance. The bailout logic and test cases are based on https://github.com/facebook/react/pull/20646 Additionally, this implementation allows multiple values to be compared in one hook by returning a tuple to avoid requiring additional Context consumer hooks.

Jack Pope committed Jul 26, 2024 at 14:38 UTC 1350a85980d1bf5e63c16a4a889861246f4cc107
16 files changed +524 -17
packages/react-debug-tools/src/ReactDebugHooks.js
+5 -1
@@ -37,6 +37,7 @@ import {
37 REACT_CONTEXT_TYPE,
38 } from 'shared/ReactSymbols';
39 import hasOwnProperty from 'shared/hasOwnProperty';
40 +import type {ContextDependencyWithSelect} from '../../react-reconciler/src/ReactInternalTypes';
41
42 type CurrentDispatcherRef = typeof ReactSharedInternals;
43
@@ -155,7 +156,10 @@ function getPrimitiveStackCache(): Map<string, Array<any>> {
156
157 let currentFiber: null | Fiber = null;
158 let currentHook: null | Hook = null;
158 -let currentContextDependency: null | ContextDependency<mixed> = null;
159 +let currentContextDependency:
160 + | null
161 + | ContextDependency<mixed>
162 + | ContextDependencyWithSelect<mixed> = null;
163
164 function nextHook(): null | Hook {
165 const hook = currentHook;
packages/react-reconciler/src/ReactFiberHooks.js
+112 -1
@@ -47,6 +47,7 @@ import {
47 enableUseDeferredValueInitialArg,
48 disableLegacyMode,
49 enableNoCloningMemoCache,
50 + enableContextProfiling,
51 } from 'shared/ReactFeatureFlags';
52 import {
53 REACT_CONTEXT_TYPE,
@@ -81,7 +82,11 @@ import {
82 ContinuousEventPriority,
83 higherEventPriority,
84 } from './ReactEventPriorities';
84 -import {readContext, checkIfContextChanged} from './ReactFiberNewContext';
85 +import {
86 + readContext,
87 + readContextAndCompare,
88 + checkIfContextChanged,
89 +} from './ReactFiberNewContext';
90 import {HostRoot, CacheComponent, HostComponent} from './ReactWorkTags';
91 import {
92 LayoutStatic as LayoutStaticEffect,
@@ -1053,6 +1058,16 @@ function updateWorkInProgressHook(): Hook {
1058 return workInProgressHook;
1059 }
1060
1061 +function unstable_useContextWithBailout<T>(
1062 + context: ReactContext<T>,
1063 + select: (T => Array<mixed>) | null,
1064 +): T {
1065 + if (select === null) {
1066 + return readContext(context);
1067 + }
1068 + return readContextAndCompare(context, select);
1069 +}
1070 +
1071 // NOTE: defining two versions of this function to avoid size impact when this feature is disabled.
1072 // Previously this function was inlined, the additional `memoCache` property makes it not inlined.
1073 let createFunctionComponentUpdateQueue: () => FunctionComponentUpdateQueue;
@@ -3689,6 +3704,10 @@ if (enableAsyncActions) {
3704 if (enableAsyncActions) {
3705 (ContextOnlyDispatcher: Dispatcher).useOptimistic = throwInvalidHookError;
3706 }
3707 +if (enableContextProfiling) {
3708 + (ContextOnlyDispatcher: Dispatcher).unstable_useContextWithBailout =
3709 + throwInvalidHookError;
3710 +}
3711
3712 const HooksDispatcherOnMount: Dispatcher = {
3713 readContext,
@@ -3728,6 +3747,10 @@ if (enableAsyncActions) {
3747 if (enableAsyncActions) {
3748 (HooksDispatcherOnMount: Dispatcher).useOptimistic = mountOptimistic;
3749 }
3750 +if (enableContextProfiling) {
3751 + (HooksDispatcherOnMount: Dispatcher).unstable_useContextWithBailout =
3752 + unstable_useContextWithBailout;
3753 +}
3754
3755 const HooksDispatcherOnUpdate: Dispatcher = {
3756 readContext,
@@ -3767,6 +3790,10 @@ if (enableAsyncActions) {
3790 if (enableAsyncActions) {
3791 (HooksDispatcherOnUpdate: Dispatcher).useOptimistic = updateOptimistic;
3792 }
3793 +if (enableContextProfiling) {
3794 + (HooksDispatcherOnUpdate: Dispatcher).unstable_useContextWithBailout =
3795 + unstable_useContextWithBailout;
3796 +}
3797
3798 const HooksDispatcherOnRerender: Dispatcher = {
3799 readContext,
@@ -3806,6 +3833,10 @@ if (enableAsyncActions) {
3833 if (enableAsyncActions) {
3834 (HooksDispatcherOnRerender: Dispatcher).useOptimistic = rerenderOptimistic;
3835 }
3836 +if (enableContextProfiling) {
3837 + (HooksDispatcherOnRerender: Dispatcher).unstable_useContextWithBailout =
3838 + unstable_useContextWithBailout;
3839 +}
3840
3841 let HooksDispatcherOnMountInDEV: Dispatcher | null = null;
3842 let HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher | null = null;
@@ -4019,6 +4050,17 @@ if (__DEV__) {
4050 return mountOptimistic(passthrough, reducer);
4051 };
4052 }
4053 + if (enableContextProfiling) {
4054 + (HooksDispatcherOnMountInDEV: Dispatcher).unstable_useContextWithBailout =
4055 + function <T>(
4056 + context: ReactContext<T>,
4057 + select: (T => Array<mixed>) | null,
4058 + ): T {
4059 + currentHookNameInDev = 'useContext';
4060 + mountHookTypesDev();
4061 + return unstable_useContextWithBailout(context, select);
4062 + };
4063 + }
4064
4065 HooksDispatcherOnMountWithHookTypesInDEV = {
4066 readContext<T>(context: ReactContext<T>): T {
@@ -4200,6 +4242,17 @@ if (__DEV__) {
4242 return mountOptimistic(passthrough, reducer);
4243 };
4244 }
4245 + if (enableContextProfiling) {
4246 + (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).unstable_useContextWithBailout =
4247 + function <T>(
4248 + context: ReactContext<T>,
4249 + select: (T => Array<mixed>) | null,
4250 + ): T {
4251 + currentHookNameInDev = 'useContext';
4252 + updateHookTypesDev();
4253 + return unstable_useContextWithBailout(context, select);
4254 + };
4255 + }
4256
4257 HooksDispatcherOnUpdateInDEV = {
4258 readContext<T>(context: ReactContext<T>): T {
@@ -4380,6 +4433,17 @@ if (__DEV__) {
4433 return updateOptimistic(passthrough, reducer);
4434 };
4435 }
4436 + if (enableContextProfiling) {
4437 + (HooksDispatcherOnUpdateInDEV: Dispatcher).unstable_useContextWithBailout =
4438 + function <T>(
4439 + context: ReactContext<T>,
4440 + select: (T => Array<mixed>) | null,
4441 + ): T {
4442 + currentHookNameInDev = 'useContext';
4443 + updateHookTypesDev();
4444 + return unstable_useContextWithBailout(context, select);
4445 + };
4446 + }
4447
4448 HooksDispatcherOnRerenderInDEV = {
4449 readContext<T>(context: ReactContext<T>): T {
@@ -4560,6 +4624,17 @@ if (__DEV__) {
4624 return rerenderOptimistic(passthrough, reducer);
4625 };
4626 }
4627 + if (enableContextProfiling) {
4628 + (HooksDispatcherOnUpdateInDEV: Dispatcher).unstable_useContextWithBailout =
4629 + function <T>(
4630 + context: ReactContext<T>,
4631 + select: (T => Array<mixed>) | null,
4632 + ): T {
4633 + currentHookNameInDev = 'useContext';
4634 + updateHookTypesDev();
4635 + return unstable_useContextWithBailout(context, select);
4636 + };
4637 + }
4638
4639 InvalidNestedHooksDispatcherOnMountInDEV = {
4640 readContext<T>(context: ReactContext<T>): T {
@@ -4766,6 +4841,18 @@ if (__DEV__) {
4841 return mountOptimistic(passthrough, reducer);
4842 };
4843 }
4844 + if (enableContextProfiling) {
4845 + (HooksDispatcherOnUpdateInDEV: Dispatcher).unstable_useContextWithBailout =
4846 + function <T>(
4847 + context: ReactContext<T>,
4848 + select: (T => Array<mixed>) | null,
4849 + ): T {
4850 + currentHookNameInDev = 'useContext';
4851 + warnInvalidHookAccess();
4852 + mountHookTypesDev();
4853 + return unstable_useContextWithBailout(context, select);
4854 + };
4855 + }
4856
4857 InvalidNestedHooksDispatcherOnUpdateInDEV = {
4858 readContext<T>(context: ReactContext<T>): T {
@@ -4972,6 +5059,18 @@ if (__DEV__) {
5059 return updateOptimistic(passthrough, reducer);
5060 };
5061 }
5062 + if (enableContextProfiling) {
5063 + (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).unstable_useContextWithBailout =
5064 + function <T>(
5065 + context: ReactContext<T>,
5066 + select: (T => Array<mixed>) | null,
5067 + ): T {
5068 + currentHookNameInDev = 'useContext';
5069 + warnInvalidHookAccess();
5070 + updateHookTypesDev();
5071 + return unstable_useContextWithBailout(context, select);
5072 + };
5073 + }
5074
5075 InvalidNestedHooksDispatcherOnRerenderInDEV = {
5076 readContext<T>(context: ReactContext<T>): T {
@@ -5178,4 +5277,16 @@ if (__DEV__) {
5277 return rerenderOptimistic(passthrough, reducer);
5278 };
5279 }
5280 + if (enableContextProfiling) {
5281 + (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).unstable_useContextWithBailout =
5282 + function <T>(
5283 + context: ReactContext<T>,
5284 + select: (T => Array<mixed>) | null,
5285 + ): T {
5286 + currentHookNameInDev = 'useContext';
5287 + warnInvalidHookAccess();
5288 + updateHookTypesDev();
5289 + return unstable_useContextWithBailout(context, select);
5290 + };
5291 + }
5292 }
packages/react-reconciler/src/ReactFiberNewContext.js
+129 -7
@@ -12,6 +12,7 @@ import type {
12 Fiber,
13 ContextDependency,
14 Dependencies,
15 + ContextDependencyWithSelect,
16 } from './ReactInternalTypes';
17 import type {StackCursor} from './ReactFiberStack';
18 import type {Lanes} from './ReactFiberLane';
@@ -51,6 +52,8 @@ import {
52 getHostTransitionProvider,
53 HostTransitionContext,
54 } from './ReactFiberHostContext';
55 +import isArray from '../../shared/isArray';
56 +import {enableContextProfiling} from '../../shared/ReactFeatureFlags';
57
58 const valueCursor: StackCursor<mixed> = createCursor(null);
59
@@ -70,7 +73,10 @@ if (__DEV__) {
73 }
74
75 let currentlyRenderingFiber: Fiber | null = null;
73 -let lastContextDependency: ContextDependency<mixed> | null = null;
76 +let lastContextDependency:
77 + | ContextDependency<mixed>
78 + | ContextDependencyWithSelect<mixed>
79 + | null = null;
80 let lastFullyObservedContext: ReactContext<any> | null = null;
81
82 let isDisallowedContextReadInDEV: boolean = false;
@@ -400,8 +406,24 @@ function propagateContextChanges<T>(
406 findContext: for (let i = 0; i < contexts.length; i++) {
407 const context: ReactContext<T> = contexts[i];
408 // Check if the context matches.
403 - // TODO: Compare selected values to bail out early.
409 if (dependency.context === context) {
410 + if (enableContextProfiling) {
411 + const select = dependency.select;
412 + if (select != null && dependency.lastSelectedValue != null) {
413 + const newValue = isPrimaryRenderer
414 + ? dependency.context._currentValue
415 + : dependency.context._currentValue2;
416 + if (
417 + !checkIfSelectedContextValuesChanged(
418 + dependency.lastSelectedValue,
419 + select(newValue),
420 + )
421 + ) {
422 + // Compared value hasn't changed. Bail out early.
423 + continue findContext;
424 + }
425 + }
426 + }
427 // Match! Schedule an update on this fiber.
428
429 // In the lazy implementation, don't mark a dirty flag on the
@@ -641,6 +663,29 @@ function propagateParentContextChanges(
663 workInProgress.flags |= DidPropagateContext;
664 }
665
666 +function checkIfSelectedContextValuesChanged(
667 + oldComparedValue: Array<mixed>,
668 + newComparedValue: Array<mixed>,
669 +): boolean {
670 + // We have an implicit contract that compare functions must return arrays.
671 + // This allows us to compare multiple values in the same context access
672 + // since compiling to additional hook calls regresses perf.
673 + if (isArray(oldComparedValue) && isArray(newComparedValue)) {
674 + if (oldComparedValue.length !== newComparedValue.length) {
675 + return true;
676 + }
677 +
678 + for (let i = 0; i < oldComparedValue.length; i++) {
679 + if (!is(newComparedValue[i], oldComparedValue[i])) {
680 + return true;
681 + }
682 + }
683 + } else {
684 + throw new Error('Compared context values must be arrays');
685 + }
686 + return false;
687 +}
688 +
689 export function checkIfContextChanged(
690 currentDependencies: Dependencies,
691 ): boolean {
@@ -659,8 +704,23 @@ export function checkIfContextChanged(
704 ? context._currentValue
705 : context._currentValue2;
706 const oldValue = dependency.memoizedValue;
662 - if (!is(newValue, oldValue)) {
663 - return true;
707 + if (
708 + enableContextProfiling &&
709 + dependency.select != null &&
710 + dependency.lastSelectedValue != null
711 + ) {
712 + if (
713 + checkIfSelectedContextValuesChanged(
714 + dependency.lastSelectedValue,
715 + dependency.select(newValue),
716 + )
717 + ) {
718 + return true;
719 + }
720 + } else {
721 + if (!is(newValue, oldValue)) {
722 + return true;
723 + }
724 }
725 dependency = dependency.next;
726 }
@@ -694,6 +754,21 @@ export function prepareToReadContext(
754 }
755 }
756
757 +export function readContextAndCompare<C>(
758 + context: ReactContext<C>,
759 + select: C => Array<mixed>,
760 +): C {
761 + if (!(enableLazyContextPropagation && enableContextProfiling)) {
762 + throw new Error('Not implemented.');
763 + }
764 +
765 + return readContextForConsumer_withSelect(
766 + currentlyRenderingFiber,
767 + context,
768 + select,
769 + );
770 +}
771 +
772 export function readContext<T>(context: ReactContext<T>): T {
773 if (__DEV__) {
774 // This warning would fire if you read context inside a Hook like useMemo.
@@ -721,10 +796,57 @@ export function readContextDuringReconciliation<T>(
796 return readContextForConsumer(consumer, context);
797 }
798
724 -function readContextForConsumer<T>(
799 +function readContextForConsumer_withSelect<C>(
800 consumer: Fiber | null,
726 - context: ReactContext<T>,
727 -): T {
801 + context: ReactContext<C>,
802 + select: C => Array<mixed>,
803 +): C {
804 + const value = isPrimaryRenderer
805 + ? context._currentValue
806 + : context._currentValue2;
807 +
808 + if (lastFullyObservedContext === context) {
809 + // Nothing to do. We already observe everything in this context.
810 + } else {
811 + const contextItem = {
812 + context: ((context: any): ReactContext<mixed>),
813 + memoizedValue: value,
814 + next: null,
815 + select: ((select: any): (context: mixed) => Array<mixed>),
816 + lastSelectedValue: select(value),
817 + };
818 +
819 + if (lastContextDependency === null) {
820 + if (consumer === null) {
821 + throw new Error(
822 + 'Context can only be read while React is rendering. ' +
823 + 'In classes, you can read it in the render method or getDerivedStateFromProps. ' +
824 + 'In function components, you can read it directly in the function body, but not ' +
825 + 'inside Hooks like useReducer() or useMemo().',
826 + );
827 + }
828 +
829 + // This is the first dependency for this component. Create a new list.
830 + lastContextDependency = contextItem;
831 + consumer.dependencies = {
832 + lanes: NoLanes,
833 + firstContext: contextItem,
834 + };
835 + if (enableLazyContextPropagation) {
836 + consumer.flags |= NeedsPropagation;
837 + }
838 + } else {
839 + // Append a new context item.
840 + lastContextDependency = lastContextDependency.next = contextItem;
841 + }
842 + }
843 + return value;
844 +}
845 +
846 +function readContextForConsumer<C>(
847 + consumer: Fiber | null,
848 + context: ReactContext<C>,
849 +): C {
850 const value = isPrimaryRenderer
851 ? context._currentValue
852 : context._currentValue2;
packages/react-reconciler/src/ReactInternalTypes.js
+20 -6
@@ -61,16 +61,26 @@ export type HookType =
61 | 'useFormState'
62 | 'useActionState';
63
64 -export type ContextDependency<T> = {
65 - context: ReactContext<T>,
66 - next: ContextDependency<mixed> | null,
67 - memoizedValue: T,
68 - ...
64 +export type ContextDependency<C> = {
65 + context: ReactContext<C>,
66 + next: ContextDependency<mixed> | ContextDependencyWithSelect<mixed> | null,
67 + memoizedValue: C,
68 +};
69 +
70 +export type ContextDependencyWithSelect<C> = {
71 + context: ReactContext<C>,
72 + next: ContextDependency<mixed> | ContextDependencyWithSelect<mixed> | null,
73 + memoizedValue: C,
74 + select: C => Array<mixed>,
75 + lastSelectedValue: ?Array<mixed>,
76 };
77
78 export type Dependencies = {
79 lanes: Lanes,
73 - firstContext: ContextDependency<mixed> | null,
80 + firstContext:
81 + | ContextDependency<mixed>
82 + | ContextDependencyWithSelect<mixed>
83 + | null,
84 ...
85 };
86
@@ -384,6 +394,10 @@ export type Dispatcher = {
394 initialArg: I,
395 init?: (I) => S,
396 ): [S, Dispatch<A>],
397 + unstable_useContextWithBailout?: <T>(
398 + context: ReactContext<T>,
399 + select: (T => Array<mixed>) | null,
400 + ) => T,
401 useContext<T>(context: ReactContext<T>): T,
402 useRef<T>(initialValue: T): {current: T},
403 useEffect(
packages/react-reconciler/src/__tests__/ReactContextWithBailout-test.js new
+217
@@ -0,0 +1,217 @@
1 +let React;
2 +let ReactNoop;
3 +let Scheduler;
4 +let act;
5 +let assertLog;
6 +let useState;
7 +let useContext;
8 +let unstable_useContextWithBailout;
9 +
10 +describe('ReactContextWithBailout', () => {
11 + beforeEach(() => {
12 + jest.resetModules();
13 +
14 + React = require('react');
15 + ReactNoop = require('react-noop-renderer');
16 + Scheduler = require('scheduler');
17 + const testUtils = require('internal-test-utils');
18 + act = testUtils.act;
19 + assertLog = testUtils.assertLog;
20 + useState = React.useState;
21 + useContext = React.useContext;
22 + unstable_useContextWithBailout = React.unstable_useContextWithBailout;
23 + });
24 +
25 + function Text({text}) {
26 + Scheduler.log(text);
27 + return text;
28 + }
29 +
30 + // @gate enableLazyContextPropagation && enableContextProfiling
31 + test('unstable_useContextWithBailout basic usage', async () => {
32 + const Context = React.createContext();
33 +
34 + let setContext;
35 + function App() {
36 + const [context, _setContext] = useState({a: 'A0', b: 'B0', c: 'C0'});
37 + setContext = _setContext;
38 + return (
39 + <Context.Provider value={context}>
40 + <Indirection />
41 + </Context.Provider>
42 + );
43 + }
44 +
45 + // Intermediate parent that bails out. Children will only re-render when the
46 + // context changes.
47 + const Indirection = React.memo(() => {
48 + return (
49 + <>
50 + A: <A />, B: <B />, C: <C />, AB: <AB />
51 + </>
52 + );
53 + });
54 +
55 + function A() {
56 + const {a} = unstable_useContextWithBailout(Context, context => [
57 + context.a,
58 + ]);
59 + return <Text text={a} />;
60 + }
61 +
62 + function B() {
63 + const {b} = unstable_useContextWithBailout(Context, context => [
64 + context.b,
65 + ]);
66 + return <Text text={b} />;
67 + }
68 +
69 + function C() {
70 + const {c} = unstable_useContextWithBailout(Context, context => [
71 + context.c,
72 + ]);
73 + return <Text text={c} />;
74 + }
75 +
76 + function AB() {
77 + const {a, b} = unstable_useContextWithBailout(Context, context => [
78 + context.a,
79 + context.b,
80 + ]);
81 + return <Text text={a + b} />;
82 + }
83 +
84 + const root = ReactNoop.createRoot();
85 + await act(async () => {
86 + root.render(<App />);
87 + });
88 + assertLog(['A0', 'B0', 'C0', 'A0B0']);
89 + expect(root).toMatchRenderedOutput('A: A0, B: B0, C: C0, AB: A0B0');
90 +
91 + // Update a. Only the A and AB consumer should re-render.
92 + await act(async () => {
93 + setContext({a: 'A1', c: 'C0', b: 'B0'});
94 + });
95 + assertLog(['A1', 'A1B0']);
96 + expect(root).toMatchRenderedOutput('A: A1, B: B0, C: C0, AB: A1B0');
97 +
98 + // Update b. Only the B and AB consumer should re-render.
99 + await act(async () => {
100 + setContext({a: 'A1', b: 'B1', c: 'C0'});
101 + });
102 + assertLog(['B1', 'A1B1']);
103 + expect(root).toMatchRenderedOutput('A: A1, B: B1, C: C0, AB: A1B1');
104 +
105 + // Update c. Only the C consumer should re-render.
106 + await act(async () => {
107 + setContext({a: 'A1', b: 'B1', c: 'C1'});
108 + });
109 + assertLog(['C1']);
110 + expect(root).toMatchRenderedOutput('A: A1, B: B1, C: C1, AB: A1B1');
111 + });
112 +
113 + // @gate enableLazyContextPropagation && enableContextProfiling
114 + test('unstable_useContextWithBailout and useContext subscribing to same context in same component', async () => {
115 + const Context = React.createContext();
116 +
117 + let setContext;
118 + function App() {
119 + const [context, _setContext] = useState({a: 0, b: 0, unrelated: 0});
120 + setContext = _setContext;
121 + return (
122 + <Context.Provider value={context}>
123 + <Indirection />
124 + </Context.Provider>
125 + );
126 + }
127 +
128 + // Intermediate parent that bails out. Children will only re-render when the
129 + // context changes.
130 + const Indirection = React.memo(() => {
131 + return <Child />;
132 + });
133 +
134 + function Child() {
135 + const {a} = unstable_useContextWithBailout(Context, context => [
136 + context.a,
137 + ]);
138 + const context = useContext(Context);
139 + return <Text text={`A: ${a}, B: ${context.b}`} />;
140 + }
141 +
142 + const root = ReactNoop.createRoot();
143 + await act(async () => {
144 + root.render(<App />);
145 + });
146 + assertLog(['A: 0, B: 0']);
147 + expect(root).toMatchRenderedOutput('A: 0, B: 0');
148 +
149 + // Update an unrelated field that isn't used by the component. The context
150 + // attempts to bail out, but the normal context forces an update.
151 + await act(async () => {
152 + setContext({a: 0, b: 0, unrelated: 1});
153 + });
154 + assertLog(['A: 0, B: 0']);
155 + expect(root).toMatchRenderedOutput('A: 0, B: 0');
156 + });
157 +
158 + // @gate enableLazyContextPropagation && enableContextProfiling
159 + test('unstable_useContextWithBailout and useContext subscribing to different contexts in same component', async () => {
160 + const ContextA = React.createContext();
161 + const ContextB = React.createContext();
162 +
163 + let setContextA;
164 + let setContextB;
165 + function App() {
166 + const [a, _setContextA] = useState({a: 0, unrelated: 0});
167 + const [b, _setContextB] = useState(0);
168 + setContextA = _setContextA;
169 + setContextB = _setContextB;
170 + return (
171 + <ContextA.Provider value={a}>
172 + <ContextB.Provider value={b}>
173 + <Indirection />
174 + </ContextB.Provider>
175 + </ContextA.Provider>
176 + );
177 + }
178 +
179 + // Intermediate parent that bails out. Children will only re-render when the
180 + // context changes.
181 + const Indirection = React.memo(() => {
182 + return <Child />;
183 + });
184 +
185 + function Child() {
186 + const {a} = unstable_useContextWithBailout(ContextA, context => [
187 + context.a,
188 + ]);
189 + const b = useContext(ContextB);
190 + return <Text text={`A: ${a}, B: ${b}`} />;
191 + }
192 +
193 + const root = ReactNoop.createRoot();
194 + await act(async () => {
195 + root.render(<App />);
196 + });
197 + assertLog(['A: 0, B: 0']);
198 + expect(root).toMatchRenderedOutput('A: 0, B: 0');
199 +
200 + // Update a field in A that isn't part of the compared context. It should
201 + // bail out.
202 + await act(async () => {
203 + setContextA({a: 0, unrelated: 1});
204 + });
205 + assertLog([]);
206 + expect(root).toMatchRenderedOutput('A: 0, B: 0');
207 +
208 + // Now update the same a field again, but this time, also update a different
209 + // context in the same batch. The other context prevents a bail out.
210 + await act(async () => {
211 + setContextA({a: 0, unrelated: 1});
212 + setContextB(1);
213 + });
214 + assertLog(['A: 0, B: 1']);
215 + expect(root).toMatchRenderedOutput('A: 0, B: 1');
216 + });
217 +});
packages/react/index.fb.js
+1
@@ -39,6 +39,7 @@ export {
39 use,
40 useActionState,
41 useCallback,
42 + unstable_useContextWithBailout,
43 useContext,
44 useDebugValue,
45 useDeferredValue,
packages/react/src/ReactClient.js
+2
@@ -38,6 +38,7 @@ import {postpone} from './ReactPostpone';
38 import {
39 getCacheForType,
40 useCallback,
41 + unstable_useContextWithBailout,
42 useContext,
43 useEffect,
44 useEffectEvent,
@@ -83,6 +84,7 @@ export {
84 cache,
85 postpone as unstable_postpone,
86 useCallback,
87 + unstable_useContextWithBailout,
88 useContext,
89 useEffect,
90 useEffectEvent as experimental_useEffectEvent,
packages/react/src/ReactHooks.js
+25
@@ -19,6 +19,10 @@ import {REACT_CONSUMER_TYPE} from 'shared/ReactSymbols';
19 import ReactSharedInternals from 'shared/ReactSharedInternals';
20
21 import {enableAsyncActions} from 'shared/ReactFeatureFlags';
22 +import {
23 + enableContextProfiling,
24 + enableLazyContextPropagation,
25 +} from '../../shared/ReactFeatureFlags';
26
27 type BasicStateAction<S> = (S => S) | S;
28 type Dispatch<A> = A => void;
@@ -65,6 +69,27 @@ export function useContext<T>(Context: ReactContext<T>): T {
69 return dispatcher.useContext(Context);
70 }
71
72 +export function unstable_useContextWithBailout<T>(
73 + context: ReactContext<T>,
74 + select: (T => Array<mixed>) | null,
75 +): T {
76 + if (!(enableLazyContextPropagation && enableContextProfiling)) {
77 + throw new Error('Not implemented.');
78 + }
79 +
80 + const dispatcher = resolveDispatcher();
81 + if (__DEV__) {
82 + if (context.$$typeof === REACT_CONSUMER_TYPE) {
83 + console.error(
84 + 'Calling useContext(Context.Consumer) is not supported and will cause bugs. ' +
85 + 'Did you mean to call useContext(Context) instead?',
86 + );
87 + }
88 + }
89 + // $FlowFixMe[not-a-function] This is unstable, thus optional
90 + return dispatcher.unstable_useContextWithBailout(context, select);
91 +}
92 +
93 export function useState<S>(
94 initialState: (() => S) | S,
95 ): [S, Dispatch<BasicStateAction<S>>] {
packages/shared/ReactFeatureFlags.js
+3
@@ -97,6 +97,9 @@ export const enableTransitionTracing = false;
97 // No known bugs, but needs performance testing
98 export const enableLazyContextPropagation = false;
99
100 +// Expose unstable useContext for performance testing
101 +export const enableContextProfiling = false;
102 +
103 // FB-only usage. The new API has different semantics.
104 export const enableLegacyHidden = false;
105
packages/shared/forks/ReactFeatureFlags.native-fb.js
+1
@@ -57,6 +57,7 @@ export const enableFlightReadableStream = true;
57 export const enableGetInspectorDataForInstanceInProduction = true;
58 export const enableInfiniteRenderLoopDetection = true;
59 export const enableLazyContextPropagation = false;
60 +export const enableContextProfiling = false;
61 export const enableLegacyCache = false;
62 export const enableLegacyFBSupport = false;
63 export const enableLegacyHidden = false;
packages/shared/forks/ReactFeatureFlags.native-oss.js
+1
@@ -50,6 +50,7 @@ export const enableFlightReadableStream = true;
50 export const enableGetInspectorDataForInstanceInProduction = false;
51 export const enableInfiniteRenderLoopDetection = true;
52 export const enableLazyContextPropagation = false;
53 +export const enableContextProfiling = false;
54 export const enableLegacyCache = false;
55 export const enableLegacyFBSupport = false;
56 export const enableLegacyHidden = false;
packages/shared/forks/ReactFeatureFlags.test-renderer.js
+1
@@ -52,6 +52,7 @@ export const transitionLaneExpirationMs = 5000;
52
53 export const disableSchedulerTimeoutInWorkLoop = false;
54 export const enableLazyContextPropagation = false;
55 +export const enableContextProfiling = false;
56 export const enableLegacyHidden = false;
57
58 export const consoleManagedByDevToolsDuringStrictMode = false;
packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js
+1
@@ -42,6 +42,7 @@ export const enableFlightReadableStream = true;
42 export const enableGetInspectorDataForInstanceInProduction = false;
43 export const enableInfiniteRenderLoopDetection = true;
44 export const enableLazyContextPropagation = false;
45 +export const enableContextProfiling = false;
46 export const enableLegacyCache = false;
47 export const enableLegacyFBSupport = false;
48 export const enableLegacyHidden = false;
packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
+1
@@ -55,6 +55,7 @@ export const transitionLaneExpirationMs = 5000;
55
56 export const disableSchedulerTimeoutInWorkLoop = false;
57 export const enableLazyContextPropagation = false;
58 +export const enableContextProfiling = false;
59 export const enableLegacyHidden = false;
60
61 export const consoleManagedByDevToolsDuringStrictMode = false;
packages/shared/forks/ReactFeatureFlags.www.js
+2
@@ -78,6 +78,8 @@ export const enableTaint = false;
78
79 export const enablePostpone = false;
80
81 +export const enableContextProfiling = true;
82 +
83 // TODO: www currently relies on this feature. It's disabled in open source.
84 // Need to remove it.
85 export const disableCommentsAsDOMContainers = false;
scripts/error-codes/codes.json
+3 -2
@@ -525,5 +525,6 @@
525 "537": "Cannot pass event handlers (%s) in renderToMarkup because the HTML will never be hydrated so they can never get called.",
526 "538": "Cannot use state or effect Hooks in renderToMarkup because this component will never be hydrated.",
527 "539": "Binary RSC chunks cannot be encoded as strings. This is a bug in the wiring of the React streams.",
528 - "540": "String chunks need to be passed in their original shape. Not split into smaller string chunks. This is a bug in the wiring of the React streams."
529 -}
528 + "540": "String chunks need to be passed in their original shape. Not split into smaller string chunks. This is a bug in the wiring of the React streams.",
529 + "541": "Compared context values must be arrays"
530 +}
\ No newline at end of file