@samitouri / QOS-React-2 / commits / 258b375a41

Move context comparison to consumer

In the lazy context implementation, not all context changes are propagated from the provider, so we can't rely on the propagation alone to mark the consumer as dirty. The consumer needs to compare to the previous value, like we do for state and context. I added a `memoizedValue` field to the context dependency type. Then in the consumer, we iterate over the current dependencies to see if something changed. We only do this iteration after props and state has already bailed out, so it's a relatively uncommon path, except at the root of a changed subtree. Alternatively, we could move these comparisons into `readContext`, but that's a much hotter path, so I think this is an appropriate trade off.

Andrew Clark committed Feb 25, 2021 at 16:42 UTC 258b375a419098a5b6c3568e8d35046d142ea912
22 files changed +507 -83
packages/react-dom/src/__tests__/ReactLegacyContextDisabled-test.internal.js
+9 -1
@@ -226,7 +226,15 @@ describe('ReactLegacyContextDisabled', () => {
226 container,
227 );
228 expect(container.textContent).toBe('bbb');
229 - expect(lifecycleContextLog).toEqual(['b', 'b']); // sCU skipped due to changed context value.
229 + if (gate(flags => flags.enableLazyContextPropagation)) {
230 + // In the lazy propagation implementation, we don't check if context
231 + // changed until after shouldComponentUpdate is run.
232 + expect(lifecycleContextLog).toEqual(['b', 'b', 'b']);
233 + } else {
234 + // In the eager implementation, a dirty flag was set when the parent
235 + // changed, so we skipped sCU.
236 + expect(lifecycleContextLog).toEqual(['b', 'b']);
237 + }
238 ReactDOM.unmountComponentAtNode(container);
239 });
240 });
packages/react-reconciler/src/ReactFiberBeginWork.new.js
+4
@@ -3074,6 +3074,10 @@ export function markWorkInProgressReceivedUpdate() {
3074 didReceiveUpdate = true;
3075 }
3076
3077 +export function checkIfWorkInProgressReceivedUpdate() {
3078 + return didReceiveUpdate;
3079 +}
3080 +
3081 function bailoutOnAlreadyFinishedWork(
3082 current: Fiber | null,
3083 workInProgress: Fiber,
packages/react-reconciler/src/ReactFiberBeginWork.old.js
+4
@@ -3074,6 +3074,10 @@ export function markWorkInProgressReceivedUpdate() {
3074 didReceiveUpdate = true;
3075 }
3076
3077 +export function checkIfWorkInProgressReceivedUpdate() {
3078 + return didReceiveUpdate;
3079 +}
3080 +
3081 function bailoutOnAlreadyFinishedWork(
3082 current: Fiber | null,
3083 workInProgress: Fiber,
packages/react-reconciler/src/ReactFiberClassComponent.new.js
+18 -3
@@ -20,6 +20,7 @@ import {
20 enableSchedulingProfiler,
21 warnAboutDeprecatedLifecycles,
22 enableStrictEffects,
23 + enableLazyContextPropagation,
24 } from 'shared/ReactFeatureFlags';
25 import ReactStrictModeWarnings from './ReactStrictModeWarnings.new';
26 import {isMounted} from './ReactFiberTreeReflection';
@@ -58,7 +59,7 @@ import {
59 hasContextChanged,
60 emptyContextObject,
61 } from './ReactFiberContext.new';
61 -import {readContext} from './ReactFiberNewContext.new';
62 +import {readContext, checkIfContextChanged} from './ReactFiberNewContext.new';
63 import {
64 requestEventTime,
65 requestUpdateLane,
@@ -1150,7 +1151,13 @@ function updateClassInstance(
1151 unresolvedOldProps === unresolvedNewProps &&
1152 oldState === newState &&
1153 !hasContextChanged() &&
1153 - !checkHasForceUpdateAfterProcessing()
1154 + !checkHasForceUpdateAfterProcessing() &&
1155 + !(
1156 + enableLazyContextPropagation &&
1157 + current !== null &&
1158 + current.dependencies !== null &&
1159 + checkIfContextChanged(current.dependencies)
1160 + )
1161 ) {
1162 // If an update was already in progress, we should schedule an Update
1163 // effect even though we're bailing out, so that cWU/cDU are called.
@@ -1193,7 +1200,15 @@ function updateClassInstance(
1200 oldState,
1201 newState,
1202 nextContext,
1196 - );
1203 + ) ||
1204 + // TODO: In some cases, we'll end up checking if context has changed twice,
1205 + // both before and after `shouldComponentUpdate` has been called. Not ideal,
1206 + // but I'm loath to refactor this function. This only happens for memoized
1207 + // components so it's not that common.
1208 + (enableLazyContextPropagation &&
1209 + current !== null &&
1210 + current.dependencies !== null &&
1211 + checkIfContextChanged(current.dependencies));
1212
1213 if (shouldUpdate) {
1214 // In order to support react-lifecycles-compat polyfilled components,
packages/react-reconciler/src/ReactFiberClassComponent.old.js
+18 -3
@@ -20,6 +20,7 @@ import {
20 enableSchedulingProfiler,
21 warnAboutDeprecatedLifecycles,
22 enableStrictEffects,
23 + enableLazyContextPropagation,
24 } from 'shared/ReactFeatureFlags';
25 import ReactStrictModeWarnings from './ReactStrictModeWarnings.old';
26 import {isMounted} from './ReactFiberTreeReflection';
@@ -58,7 +59,7 @@ import {
59 hasContextChanged,
60 emptyContextObject,
61 } from './ReactFiberContext.old';
61 -import {readContext} from './ReactFiberNewContext.old';
62 +import {readContext, checkIfContextChanged} from './ReactFiberNewContext.old';
63 import {
64 requestEventTime,
65 requestUpdateLane,
@@ -1150,7 +1151,13 @@ function updateClassInstance(
1151 unresolvedOldProps === unresolvedNewProps &&
1152 oldState === newState &&
1153 !hasContextChanged() &&
1153 - !checkHasForceUpdateAfterProcessing()
1154 + !checkHasForceUpdateAfterProcessing() &&
1155 + !(
1156 + enableLazyContextPropagation &&
1157 + current !== null &&
1158 + current.dependencies !== null &&
1159 + checkIfContextChanged(current.dependencies)
1160 + )
1161 ) {
1162 // If an update was already in progress, we should schedule an Update
1163 // effect even though we're bailing out, so that cWU/cDU are called.
@@ -1193,7 +1200,15 @@ function updateClassInstance(
1200 oldState,
1201 newState,
1202 nextContext,
1196 - );
1203 + ) ||
1204 + // TODO: In some cases, we'll end up checking if context has changed twice,
1205 + // both before and after `shouldComponentUpdate` has been called. Not ideal,
1206 + // but I'm loath to refactor this function. This only happens for memoized
1207 + // components so it's not that common.
1208 + (enableLazyContextPropagation &&
1209 + current !== null &&
1210 + current.dependencies !== null &&
1211 + checkIfContextChanged(current.dependencies));
1212
1213 if (shouldUpdate) {
1214 // In order to support react-lifecycles-compat polyfilled components,
packages/react-reconciler/src/ReactFiberHooks.new.js
+27 -2
@@ -30,6 +30,7 @@ import {
30 decoupleUpdatePriorityFromScheduler,
31 enableUseRefAccessWarning,
32 enableStrictEffects,
33 + enableLazyContextPropagation,
34 } from 'shared/ReactFeatureFlags';
35
36 import {
@@ -54,7 +55,7 @@ import {
55 higherLanePriority,
56 DefaultLanePriority,
57 } from './ReactFiberLane.new';
57 -import {readContext} from './ReactFiberNewContext.new';
58 +import {readContext, checkIfContextChanged} from './ReactFiberNewContext.new';
59 import {HostRoot, CacheComponent} from './ReactWorkTags';
60 import {
61 Update as UpdateEffect,
@@ -83,7 +84,10 @@ import {
84 import invariant from 'shared/invariant';
85 import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber';
86 import is from 'shared/objectIs';
86 -import {markWorkInProgressReceivedUpdate} from './ReactFiberBeginWork.new';
87 +import {
88 + markWorkInProgressReceivedUpdate,
89 + checkIfWorkInProgressReceivedUpdate,
90 +} from './ReactFiberBeginWork.new';
91 import {
92 UserBlockingPriority,
93 NormalPriority,
@@ -496,6 +500,27 @@ export function renderWithHooks<Props, SecondArg>(
500 'early return statement.',
501 );
502
503 + if (enableLazyContextPropagation) {
504 + if (current !== null) {
505 + if (!checkIfWorkInProgressReceivedUpdate()) {
506 + // If there were no changes to props or state, we need to check if there
507 + // was a context change. We didn't already do this because there's no
508 + // 1:1 correspondence between dependencies and hooks. Although, because
509 + // there almost always is in the common case (`readContext` is an
510 + // internal API), we could compare in there. OTOH, we only hit this case
511 + // if everything else bails out, so on the whole it might be better to
512 + // keep the comparison out of the common path.
513 + const currentDependencies = current.dependencies;
514 + if (
515 + currentDependencies !== null &&
516 + checkIfContextChanged(currentDependencies)
517 + ) {
518 + markWorkInProgressReceivedUpdate();
519 + }
520 + }
521 + }
522 + }
523 +
524 return children;
525 }
526
packages/react-reconciler/src/ReactFiberHooks.old.js
+27 -2
@@ -30,6 +30,7 @@ import {
30 decoupleUpdatePriorityFromScheduler,
31 enableUseRefAccessWarning,
32 enableStrictEffects,
33 + enableLazyContextPropagation,
34 } from 'shared/ReactFeatureFlags';
35
36 import {
@@ -54,7 +55,7 @@ import {
55 higherLanePriority,
56 DefaultLanePriority,
57 } from './ReactFiberLane.old';
57 -import {readContext} from './ReactFiberNewContext.old';
58 +import {readContext, checkIfContextChanged} from './ReactFiberNewContext.old';
59 import {HostRoot, CacheComponent} from './ReactWorkTags';
60 import {
61 Update as UpdateEffect,
@@ -83,7 +84,10 @@ import {
84 import invariant from 'shared/invariant';
85 import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber';
86 import is from 'shared/objectIs';
86 -import {markWorkInProgressReceivedUpdate} from './ReactFiberBeginWork.old';
87 +import {
88 + markWorkInProgressReceivedUpdate,
89 + checkIfWorkInProgressReceivedUpdate,
90 +} from './ReactFiberBeginWork.old';
91 import {
92 UserBlockingPriority,
93 NormalPriority,
@@ -496,6 +500,27 @@ export function renderWithHooks<Props, SecondArg>(
500 'early return statement.',
501 );
502
503 + if (enableLazyContextPropagation) {
504 + if (current !== null) {
505 + if (!checkIfWorkInProgressReceivedUpdate()) {
506 + // If there were no changes to props or state, we need to check if there
507 + // was a context change. We didn't already do this because there's no
508 + // 1:1 correspondence between dependencies and hooks. Although, because
509 + // there almost always is in the common case (`readContext` is an
510 + // internal API), we could compare in there. OTOH, we only hit this case
511 + // if everything else bails out, so on the whole it might be better to
512 + // keep the comparison out of the common path.
513 + const currentDependencies = current.dependencies;
514 + if (
515 + currentDependencies !== null &&
516 + checkIfContextChanged(currentDependencies)
517 + ) {
518 + markWorkInProgressReceivedUpdate();
519 + }
520 + }
521 + }
522 + }
523 +
524 return children;
525 }
526
packages/react-reconciler/src/ReactFiberNewContext.new.js
+88 -35
@@ -8,7 +8,11 @@
8 */
9
10 import type {ReactContext} from 'shared/ReactTypes';
11 -import type {Fiber, ContextDependency} from './ReactInternalTypes';
11 +import type {
12 + Fiber,
13 + ContextDependency,
14 + Dependencies,
15 +} from './ReactInternalTypes';
16 import type {StackCursor} from './ReactFiberStack.new';
17 import type {Lanes} from './ReactFiberLane.new';
18 import type {SharedQueue} from './ReactUpdateQueue.new';
@@ -34,7 +38,10 @@ import invariant from 'shared/invariant';
38 import is from 'shared/objectIs';
39 import {createUpdate, ForceUpdate} from './ReactUpdateQueue.new';
40 import {markWorkInProgressReceivedUpdate} from './ReactFiberBeginWork.new';
37 -import {enableSuspenseServerRenderer} from 'shared/ReactFeatureFlags';
41 +import {
42 + enableSuspenseServerRenderer,
43 + enableLazyContextPropagation,
44 +} from 'shared/ReactFeatureFlags';
45
46 const valueCursor: StackCursor<mixed> = createCursor(null);
47
@@ -210,33 +217,46 @@ export function propagateContextChange<T>(
217 ) {
218 // Match! Schedule an update on this fiber.
219
213 - if (fiber.tag === ClassComponent) {
214 - // Schedule a force update on the work-in-progress.
215 - const lane = pickArbitraryLane(renderLanes);
216 - const update = createUpdate(NoTimestamp, lane);
217 - update.tag = ForceUpdate;
218 - // TODO: Because we don't have a work-in-progress, this will add the
219 - // update to the current fiber, too, which means it will persist even if
220 - // this render is thrown away. Since it's a race condition, not sure it's
221 - // worth fixing.
222 -
223 - // Inlined `enqueueUpdate` to remove interleaved update check
224 - const updateQueue = fiber.updateQueue;
225 - if (updateQueue === null) {
226 - // Only occurs if the fiber has been unmounted.
227 - } else {
228 - const sharedQueue: SharedQueue<any> = (updateQueue: any).shared;
229 - const pending = sharedQueue.pending;
230 - if (pending === null) {
231 - // This is the first update. Create a circular list.
232 - update.next = update;
220 + if (enableLazyContextPropagation) {
221 + // In the lazy implemenation, don't mark a dirty flag on the
222 + // dependency itself. Not all changes are propagated, so we can't
223 + // rely on the propagation function alone to determine whether
224 + // something has changed; the consumer will check. In the future,
225 + // we could add back a dirty flag as an optimization to avoid
226 + // double checking, but until we have selectors it's not really
227 + // worth the trouble.
228 + } else {
229 + if (fiber.tag === ClassComponent) {
230 + // Schedule a force update on the work-in-progress.
231 + const lane = pickArbitraryLane(renderLanes);
232 + const update = createUpdate(NoTimestamp, lane);
233 + update.tag = ForceUpdate;
234 + // TODO: Because we don't have a work-in-progress, this will add the
235 + // update to the current fiber, too, which means it will persist even if
236 + // this render is thrown away. Since it's a race condition, not sure it's
237 + // worth fixing.
238 +
239 + // Inlined `enqueueUpdate` to remove interleaved update check
240 + const updateQueue = fiber.updateQueue;
241 + if (updateQueue === null) {
242 + // Only occurs if the fiber has been unmounted.
243 } else {
234 - update.next = pending.next;
235 - pending.next = update;
244 + const sharedQueue: SharedQueue<any> = (updateQueue: any).shared;
245 + const pending = sharedQueue.pending;
246 + if (pending === null) {
247 + // This is the first update. Create a circular list.
248 + update.next = update;
249 + } else {
250 + update.next = pending.next;
251 + pending.next = update;
252 + }
253 + sharedQueue.pending = update;
254 }
237 - sharedQueue.pending = update;
255 }
256 + // Mark the updated lanes on the list, too.
257 + list.lanes = mergeLanes(list.lanes, renderLanes);
258 }
259 +
260 fiber.lanes = mergeLanes(fiber.lanes, renderLanes);
261 const alternate = fiber.alternate;
262 if (alternate !== null) {
@@ -244,9 +264,6 @@ export function propagateContextChange<T>(
264 }
265 scheduleWorkOnParentPath(fiber.return, renderLanes);
266
247 - // Mark the updated lanes on the list, too.
248 - list.lanes = mergeLanes(list.lanes, renderLanes);
249 -
267 // Since we already found a match, we can stop traversing the
268 // dependency list.
269 break;
@@ -311,6 +328,30 @@ export function propagateContextChange<T>(
328 }
329 }
330
331 +export function checkIfContextChanged(currentDependencies: Dependencies) {
332 + if (!enableLazyContextPropagation) {
333 + return false;
334 + }
335 + // Iterate over the current dependencies to see if something changed. This
336 + // only gets called if props and state has already bailed out, so it's a
337 + // relatively uncommon path, except at the root of a changed subtree.
338 + // Alternatively, we could move these comparisons into `readContext`, but
339 + // that's a much hotter path, so I think this is an appropriate trade off.
340 + let dependency = currentDependencies.firstContext;
341 + while (dependency !== null) {
342 + const context = dependency.context;
343 + const newValue = isPrimaryRenderer
344 + ? context._currentValue
345 + : context._currentValue2;
346 + const oldValue = dependency.memoizedValue;
347 + if (!is(newValue, oldValue)) {
348 + return true;
349 + }
350 + dependency = dependency.next;
351 + }
352 + return false;
353 +}
354 +
355 export function prepareToReadContext(
356 workInProgress: Fiber,
357 renderLanes: Lanes,
@@ -321,14 +362,19 @@ export function prepareToReadContext(
362
363 const dependencies = workInProgress.dependencies;
364 if (dependencies !== null) {
324 - const firstContext = dependencies.firstContext;
325 - if (firstContext !== null) {
326 - if (includesSomeLane(dependencies.lanes, renderLanes)) {
327 - // Context list has a pending update. Mark that this fiber performed work.
328 - markWorkInProgressReceivedUpdate();
329 - }
365 + if (enableLazyContextPropagation) {
366 // Reset the work-in-progress list
367 dependencies.firstContext = null;
368 + } else {
369 + const firstContext = dependencies.firstContext;
370 + if (firstContext !== null) {
371 + if (includesSomeLane(dependencies.lanes, renderLanes)) {
372 + // Context list has a pending update. Mark that this fiber performed work.
373 + markWorkInProgressReceivedUpdate();
374 + }
375 + // Reset the work-in-progress list
376 + dependencies.firstContext = null;
377 + }
378 }
379 }
380 }
@@ -350,6 +396,10 @@ export function readContext<T>(
396 }
397 }
398
399 + const value = isPrimaryRenderer
400 + ? context._currentValue
401 + : context._currentValue2;
402 +
403 if (lastContextWithAllBitsObserved === context) {
404 // Nothing to do. We already observe everything in this context.
405 } else if (observedBits === false || observedBits === 0) {
@@ -370,6 +420,7 @@ export function readContext<T>(
420 const contextItem = {
421 context: ((context: any): ReactContext<mixed>),
422 observedBits: resolvedObservedBits,
423 + memoizedValue: value,
424 next: null,
425 };
426
@@ -387,6 +438,8 @@ export function readContext<T>(
438 currentlyRenderingFiber.dependencies = {
439 lanes: NoLanes,
440 firstContext: contextItem,
441 +
442 + // TODO: This is an old field. Delete it.
443 responders: null,
444 };
445 } else {
@@ -394,5 +447,5 @@ export function readContext<T>(
447 lastContextDependency = lastContextDependency.next = contextItem;
448 }
449 }
397 - return isPrimaryRenderer ? context._currentValue : context._currentValue2;
450 + return value;
451 }
packages/react-reconciler/src/ReactFiberNewContext.old.js
+88 -35
@@ -8,7 +8,11 @@
8 */
9
10 import type {ReactContext} from 'shared/ReactTypes';
11 -import type {Fiber, ContextDependency} from './ReactInternalTypes';
11 +import type {
12 + Fiber,
13 + ContextDependency,
14 + Dependencies,
15 +} from './ReactInternalTypes';
16 import type {StackCursor} from './ReactFiberStack.old';
17 import type {Lanes} from './ReactFiberLane.old';
18 import type {SharedQueue} from './ReactUpdateQueue.old';
@@ -34,7 +38,10 @@ import invariant from 'shared/invariant';
38 import is from 'shared/objectIs';
39 import {createUpdate, ForceUpdate} from './ReactUpdateQueue.old';
40 import {markWorkInProgressReceivedUpdate} from './ReactFiberBeginWork.old';
37 -import {enableSuspenseServerRenderer} from 'shared/ReactFeatureFlags';
41 +import {
42 + enableSuspenseServerRenderer,
43 + enableLazyContextPropagation,
44 +} from 'shared/ReactFeatureFlags';
45
46 const valueCursor: StackCursor<mixed> = createCursor(null);
47
@@ -210,33 +217,46 @@ export function propagateContextChange<T>(
217 ) {
218 // Match! Schedule an update on this fiber.
219
213 - if (fiber.tag === ClassComponent) {
214 - // Schedule a force update on the work-in-progress.
215 - const lane = pickArbitraryLane(renderLanes);
216 - const update = createUpdate(NoTimestamp, lane);
217 - update.tag = ForceUpdate;
218 - // TODO: Because we don't have a work-in-progress, this will add the
219 - // update to the current fiber, too, which means it will persist even if
220 - // this render is thrown away. Since it's a race condition, not sure it's
221 - // worth fixing.
222 -
223 - // Inlined `enqueueUpdate` to remove interleaved update check
224 - const updateQueue = fiber.updateQueue;
225 - if (updateQueue === null) {
226 - // Only occurs if the fiber has been unmounted.
227 - } else {
228 - const sharedQueue: SharedQueue<any> = (updateQueue: any).shared;
229 - const pending = sharedQueue.pending;
230 - if (pending === null) {
231 - // This is the first update. Create a circular list.
232 - update.next = update;
220 + if (enableLazyContextPropagation) {
221 + // In the lazy implemenation, don't mark a dirty flag on the
222 + // dependency itself. Not all changes are propagated, so we can't
223 + // rely on the propagation function alone to determine whether
224 + // something has changed; the consumer will check. In the future,
225 + // we could add back a dirty flag as an optimization to avoid
226 + // double checking, but until we have selectors it's not really
227 + // worth the trouble.
228 + } else {
229 + if (fiber.tag === ClassComponent) {
230 + // Schedule a force update on the work-in-progress.
231 + const lane = pickArbitraryLane(renderLanes);
232 + const update = createUpdate(NoTimestamp, lane);
233 + update.tag = ForceUpdate;
234 + // TODO: Because we don't have a work-in-progress, this will add the
235 + // update to the current fiber, too, which means it will persist even if
236 + // this render is thrown away. Since it's a race condition, not sure it's
237 + // worth fixing.
238 +
239 + // Inlined `enqueueUpdate` to remove interleaved update check
240 + const updateQueue = fiber.updateQueue;
241 + if (updateQueue === null) {
242 + // Only occurs if the fiber has been unmounted.
243 } else {
234 - update.next = pending.next;
235 - pending.next = update;
244 + const sharedQueue: SharedQueue<any> = (updateQueue: any).shared;
245 + const pending = sharedQueue.pending;
246 + if (pending === null) {
247 + // This is the first update. Create a circular list.
248 + update.next = update;
249 + } else {
250 + update.next = pending.next;
251 + pending.next = update;
252 + }
253 + sharedQueue.pending = update;
254 }
237 - sharedQueue.pending = update;
255 }
256 + // Mark the updated lanes on the list, too.
257 + list.lanes = mergeLanes(list.lanes, renderLanes);
258 }
259 +
260 fiber.lanes = mergeLanes(fiber.lanes, renderLanes);
261 const alternate = fiber.alternate;
262 if (alternate !== null) {
@@ -244,9 +264,6 @@ export function propagateContextChange<T>(
264 }
265 scheduleWorkOnParentPath(fiber.return, renderLanes);
266
247 - // Mark the updated lanes on the list, too.
248 - list.lanes = mergeLanes(list.lanes, renderLanes);
249 -
267 // Since we already found a match, we can stop traversing the
268 // dependency list.
269 break;
@@ -311,6 +328,30 @@ export function propagateContextChange<T>(
328 }
329 }
330
331 +export function checkIfContextChanged(currentDependencies: Dependencies) {
332 + if (!enableLazyContextPropagation) {
333 + return false;
334 + }
335 + // Iterate over the current dependencies to see if something changed. This
336 + // only gets called if props and state has already bailed out, so it's a
337 + // relatively uncommon path, except at the root of a changed subtree.
338 + // Alternatively, we could move these comparisons into `readContext`, but
339 + // that's a much hotter path, so I think this is an appropriate trade off.
340 + let dependency = currentDependencies.firstContext;
341 + while (dependency !== null) {
342 + const context = dependency.context;
343 + const newValue = isPrimaryRenderer
344 + ? context._currentValue
345 + : context._currentValue2;
346 + const oldValue = dependency.memoizedValue;
347 + if (!is(newValue, oldValue)) {
348 + return true;
349 + }
350 + dependency = dependency.next;
351 + }
352 + return false;
353 +}
354 +
355 export function prepareToReadContext(
356 workInProgress: Fiber,
357 renderLanes: Lanes,
@@ -321,14 +362,19 @@ export function prepareToReadContext(
362
363 const dependencies = workInProgress.dependencies;
364 if (dependencies !== null) {
324 - const firstContext = dependencies.firstContext;
325 - if (firstContext !== null) {
326 - if (includesSomeLane(dependencies.lanes, renderLanes)) {
327 - // Context list has a pending update. Mark that this fiber performed work.
328 - markWorkInProgressReceivedUpdate();
329 - }
365 + if (enableLazyContextPropagation) {
366 // Reset the work-in-progress list
367 dependencies.firstContext = null;
368 + } else {
369 + const firstContext = dependencies.firstContext;
370 + if (firstContext !== null) {
371 + if (includesSomeLane(dependencies.lanes, renderLanes)) {
372 + // Context list has a pending update. Mark that this fiber performed work.
373 + markWorkInProgressReceivedUpdate();
374 + }
375 + // Reset the work-in-progress list
376 + dependencies.firstContext = null;
377 + }
378 }
379 }
380 }
@@ -350,6 +396,10 @@ export function readContext<T>(
396 }
397 }
398
399 + const value = isPrimaryRenderer
400 + ? context._currentValue
401 + : context._currentValue2;
402 +
403 if (lastContextWithAllBitsObserved === context) {
404 // Nothing to do. We already observe everything in this context.
405 } else if (observedBits === false || observedBits === 0) {
@@ -370,6 +420,7 @@ export function readContext<T>(
420 const contextItem = {
421 context: ((context: any): ReactContext<mixed>),
422 observedBits: resolvedObservedBits,
423 + memoizedValue: value,
424 next: null,
425 };
426
@@ -387,6 +438,8 @@ export function readContext<T>(
438 currentlyRenderingFiber.dependencies = {
439 lanes: NoLanes,
440 firstContext: contextItem,
441 +
442 + // TODO: This is an old field. Delete it.
443 responders: null,
444 };
445 } else {
@@ -394,5 +447,5 @@ export function readContext<T>(
447 lastContextDependency = lastContextDependency.next = contextItem;
448 }
449 }
397 - return isPrimaryRenderer ? context._currentValue : context._currentValue2;
450 + return value;
451 }
packages/react-reconciler/src/ReactInternalTypes.js
+1
@@ -51,6 +51,7 @@ export type ContextDependency<T> = {
51 context: ReactContext<T>,
52 observedBits: number,
53 next: ContextDependency<mixed> | null,
54 + memoizedValue: T,
55 ...
56 };
57
packages/react-reconciler/src/__tests__/ReactContextPropagation-test.js new
+206
@@ -0,0 +1,206 @@
1 +let React;
2 +let ReactNoop;
3 +let Scheduler;
4 +let useState;
5 +let useContext;
6 +
7 +describe('ReactLazyContextPropagation', () => {
8 + beforeEach(() => {
9 + jest.resetModules();
10 +
11 + React = require('react');
12 + ReactNoop = require('react-noop-renderer');
13 + Scheduler = require('scheduler');
14 + useState = React.useState;
15 + useContext = React.useContext;
16 + });
17 +
18 + function Text({text}) {
19 + Scheduler.unstable_yieldValue(text);
20 + return text;
21 + }
22 +
23 + // NOTE: These tests are not specific to the lazy propagation (as opposed to
24 + // eager propagation). The behavior should be the same in both
25 + // implementations. These are tests that are more relevant to the lazy
26 + // propagation implementation, though.
27 +
28 + test(
29 + 'context change should prevent bailout of memoized component (useMemo -> ' +
30 + 'no intermediate fiber)',
31 + async () => {
32 + const root = ReactNoop.createRoot();
33 +
34 + const Context = React.createContext(0);
35 +
36 + let setValue;
37 + function App() {
38 + const [value, _setValue] = useState(0);
39 + setValue = _setValue;
40 +
41 + // NOTE: It's an important part of this test that we're memoizing the
42 + // props of the Consumer component, as opposed to wrapping in an
43 + // additional memoized fiber, because the implementation propagates
44 + // context changes whenever a fiber bails out.
45 + const consumer = React.useMemo(() => <Consumer />, []);
46 +
47 + return <Context.Provider value={value}>{consumer}</Context.Provider>;
48 + }
49 +
50 + function Consumer() {
51 + const value = useContext(Context);
52 + // Even though Consumer is memoized, Consumer should re-render
53 + // DeepChild whenever the context value changes. Otherwise DeepChild
54 + // won't receive the new value.
55 + return <DeepChild value={value} />;
56 + }
57 +
58 + function DeepChild({value}) {
59 + return <Text text={value} />;
60 + }
61 +
62 + await ReactNoop.act(async () => {
63 + root.render(<App />);
64 + });
65 + expect(Scheduler).toHaveYielded([0]);
66 + expect(root).toMatchRenderedOutput('0');
67 +
68 + await ReactNoop.act(async () => {
69 + setValue(1);
70 + });
71 + expect(Scheduler).toHaveYielded([1]);
72 + expect(root).toMatchRenderedOutput('1');
73 + },
74 + );
75 +
76 + test('context change should prevent bailout of memoized component (memo HOC)', async () => {
77 + const root = ReactNoop.createRoot();
78 +
79 + const Context = React.createContext(0);
80 +
81 + let setValue;
82 + function App() {
83 + const [value, _setValue] = useState(0);
84 + setValue = _setValue;
85 + return (
86 + <Context.Provider value={value}>
87 + <Consumer />
88 + </Context.Provider>
89 + );
90 + }
91 +
92 + const Consumer = React.memo(() => {
93 + const value = useContext(Context);
94 + // Even though Consumer is memoized, Consumer should re-render
95 + // DeepChild whenever the context value changes. Otherwise DeepChild
96 + // won't receive the new value.
97 + return <DeepChild value={value} />;
98 + });
99 +
100 + function DeepChild({value}) {
101 + return <Text text={value} />;
102 + }
103 +
104 + await ReactNoop.act(async () => {
105 + root.render(<App />);
106 + });
107 + expect(Scheduler).toHaveYielded([0]);
108 + expect(root).toMatchRenderedOutput('0');
109 +
110 + await ReactNoop.act(async () => {
111 + setValue(1);
112 + });
113 + expect(Scheduler).toHaveYielded([1]);
114 + expect(root).toMatchRenderedOutput('1');
115 + });
116 +
117 + test('context change should prevent bailout of memoized component (PureComponent)', async () => {
118 + const root = ReactNoop.createRoot();
119 +
120 + const Context = React.createContext(0);
121 +
122 + let setValue;
123 + function App() {
124 + const [value, _setValue] = useState(0);
125 + setValue = _setValue;
126 + return (
127 + <Context.Provider value={value}>
128 + <Consumer />
129 + </Context.Provider>
130 + );
131 + }
132 +
133 + class Consumer extends React.PureComponent {
134 + static contextType = Context;
135 + render() {
136 + // Even though Consumer is memoized, Consumer should re-render
137 + // DeepChild whenever the context value changes. Otherwise DeepChild
138 + // won't receive the new value.
139 + return <DeepChild value={this.context} />;
140 + }
141 + }
142 +
143 + function DeepChild({value}) {
144 + return <Text text={value} />;
145 + }
146 +
147 + await ReactNoop.act(async () => {
148 + root.render(<App />);
149 + });
150 + expect(Scheduler).toHaveYielded([0]);
151 + expect(root).toMatchRenderedOutput('0');
152 +
153 + await ReactNoop.act(async () => {
154 + setValue(1);
155 + });
156 + expect(Scheduler).toHaveYielded([1]);
157 + expect(root).toMatchRenderedOutput('1');
158 + });
159 +
160 + test("context consumer bails out if context hasn't changed", async () => {
161 + const root = ReactNoop.createRoot();
162 +
163 + const Context = React.createContext(0);
164 +
165 + function App() {
166 + return (
167 + <Context.Provider value={0}>
168 + <Consumer />
169 + </Context.Provider>
170 + );
171 + }
172 +
173 + let setOtherValue;
174 + const Consumer = React.memo(() => {
175 + const value = useContext(Context);
176 +
177 + const [, _setOtherValue] = useState(0);
178 + setOtherValue = _setOtherValue;
179 +
180 + Scheduler.unstable_yieldValue('Consumer');
181 +
182 + return <Text text={value} />;
183 + });
184 +
185 + await ReactNoop.act(async () => {
186 + root.render(<App />);
187 + });
188 + expect(Scheduler).toHaveYielded(['Consumer', 0]);
189 + expect(root).toMatchRenderedOutput('0');
190 +
191 + await ReactNoop.act(async () => {
192 + // Intentionally calling setState to some other arbitrary value before
193 + // setting it back to the current one. That way an update is scheduled,
194 + // but we'll bail out during render when nothing has changed.
195 + setOtherValue(1);
196 + setOtherValue(0);
197 + });
198 + // NOTE: If this didn't yield anything, that indicates that we never visited
199 + // the consumer during the render phase, which probably means the eager
200 + // bailout mechanism kicked in. Because we're testing the _lazy_ bailout
201 + // mechanism, update this test to foil the _eager_ bailout, somehow. Perhaps
202 + // by switching to useReducer.
203 + expect(Scheduler).toHaveYielded(['Consumer']);
204 + expect(root).toMatchRenderedOutput('0');
205 + });
206 +});
packages/react/src/__tests__/ReactContextValidator-test.js
+6 -2
@@ -431,8 +431,12 @@ describe('ReactContextValidator', () => {
431 expect(renderContext).toBe(secondContext);
432 expect(componentDidUpdateContext).toBe(secondContext);
433
434 - // sCU is not called in this case because React force updates when a provider re-renders
435 - expect(shouldComponentUpdateWasCalled).toBe(false);
434 + if (gate(flags => flags.enableLazyContextPropagation)) {
435 + expect(shouldComponentUpdateWasCalled).toBe(true);
436 + } else {
437 + // sCU is not called in this case because React force updates when a provider re-renders
438 + expect(shouldComponentUpdateWasCalled).toBe(false);
439 + }
440 });
441
442 it('should re-render PureComponents when context Provider updates', () => {
packages/shared/ReactFeatureFlags.js
+2
@@ -158,3 +158,5 @@ export const enableDiscreteEventMicroTasks = false;
158 export const enableSyncMicroTasks = false;
159
160 export const enableNativeEventPriorityInference = false;
161 +
162 +export const enableLazyContextPropagation = false;
packages/shared/forks/ReactFeatureFlags.native-fb.js
+1
@@ -61,6 +61,7 @@ export const disableSchedulerTimeoutInWorkLoop = false;
61 export const enableDiscreteEventMicroTasks = false;
62 export const enableSyncMicroTasks = false;
63 export const enableNativeEventPriorityInference = false;
64 +export const enableLazyContextPropagation = false;
65
66 // Flow magic to verify the exports of this file match the original version.
67 // eslint-disable-next-line no-unused-vars
packages/shared/forks/ReactFeatureFlags.native-oss.js
+1
@@ -60,6 +60,7 @@ export const disableSchedulerTimeoutInWorkLoop = false;
60 export const enableDiscreteEventMicroTasks = false;
61 export const enableSyncMicroTasks = false;
62 export const enableNativeEventPriorityInference = false;
63 +export const enableLazyContextPropagation = false;
64
65 // Flow magic to verify the exports of this file match the original version.
66 // eslint-disable-next-line no-unused-vars
packages/shared/forks/ReactFeatureFlags.test-renderer.js
+1
@@ -60,6 +60,7 @@ export const disableSchedulerTimeoutInWorkLoop = false;
60 export const enableDiscreteEventMicroTasks = false;
61 export const enableSyncMicroTasks = false;
62 export const enableNativeEventPriorityInference = false;
63 +export const enableLazyContextPropagation = false;
64
65 // Flow magic to verify the exports of this file match the original version.
66 // eslint-disable-next-line no-unused-vars
packages/shared/forks/ReactFeatureFlags.test-renderer.native.js
+1
@@ -60,6 +60,7 @@ export const disableSchedulerTimeoutInWorkLoop = false;
60 export const enableDiscreteEventMicroTasks = false;
61 export const enableSyncMicroTasks = false;
62 export const enableNativeEventPriorityInference = false;
63 +export const enableLazyContextPropagation = false;
64
65 // Flow magic to verify the exports of this file match the original version.
66 // eslint-disable-next-line no-unused-vars
packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
+1
@@ -60,6 +60,7 @@ export const disableSchedulerTimeoutInWorkLoop = false;
60 export const enableDiscreteEventMicroTasks = false;
61 export const enableSyncMicroTasks = false;
62 export const enableNativeEventPriorityInference = false;
63 +export const enableLazyContextPropagation = false;
64
65 // Flow magic to verify the exports of this file match the original version.
66 // eslint-disable-next-line no-unused-vars
packages/shared/forks/ReactFeatureFlags.testing.js
+1
@@ -60,6 +60,7 @@ export const disableSchedulerTimeoutInWorkLoop = false;
60 export const enableDiscreteEventMicroTasks = false;
61 export const enableSyncMicroTasks = false;
62 export const enableNativeEventPriorityInference = false;
63 +export const enableLazyContextPropagation = false;
64
65 // Flow magic to verify the exports of this file match the original version.
66 // eslint-disable-next-line no-unused-vars
packages/shared/forks/ReactFeatureFlags.testing.www.js
+1
@@ -60,6 +60,7 @@ export const disableSchedulerTimeoutInWorkLoop = false;
60 export const enableDiscreteEventMicroTasks = false;
61 export const enableSyncMicroTasks = false;
62 export const enableNativeEventPriorityInference = false;
63 +export const enableLazyContextPropagation = false;
64
65 // Flow magic to verify the exports of this file match the original version.
66 // eslint-disable-next-line no-unused-vars
packages/shared/forks/ReactFeatureFlags.www-dynamic.js
+1
@@ -59,3 +59,4 @@ export const disableSchedulerTimeoutInWorkLoop = __VARIANT__;
59 export const enableDiscreteEventMicroTasks = __VARIANT__;
60 export const enableSyncMicroTasks = __VARIANT__;
61 export const enableNativeEventPriorityInference = __VARIANT__;
62 +export const enableLazyContextPropagation = __VARIANT__;
packages/shared/forks/ReactFeatureFlags.www.js
+1
@@ -35,6 +35,7 @@ export const {
35 enableDiscreteEventMicroTasks,
36 enableSyncMicroTasks,
37 enableNativeEventPriorityInference,
38 + enableLazyContextPropagation,
39 } = dynamicFeatureFlags;
40
41 // On WWW, __EXPERIMENTAL__ is used for a new modern build.