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.