Skip to content

Commit 33589f7

Browse files
authored
useMutableSource: "Entangle" instead of expiring (#18889)
* useMutableSource: "Entangle" instead of expiring A lane is said to be entangled with another when it's not allowed to render in a batch that does not also include the other lane. This commit implements entanglement for `useMutableSource`. If a source is mutated in between when it's read in the render phase, but before it's subscribed to in the commit phase, we must account for whether the same source has pending mutations elsewhere. The old subscriptions must not be allowed to re-render without also including the new subscription (and vice versa), to prevent tearing. In the old reconciler, we did this by synchronously flushing all the pending subscription updates. This works, but isn't ideal. The new reconciler can entangle the updates without de-opting to sync. In the future, we plan to use this same mechanism for other features, like skipping over intermediate useTransition states. * Use clz instead of ctrz to pick an arbitrary lane Should be slightly faster since most engines have built-in support.
1 parent 43063fd commit 33589f7

File tree

6 files changed

+107
-40
lines changed

6 files changed

+107
-40
lines changed

packages/react-reconciler/src/ReactFiberHooks.new.js

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import {
3232
isSubsetOfLanes,
3333
mergeLanes,
3434
removeLanes,
35-
markRootExpired,
35+
markRootEntangled,
3636
markRootMutableRead,
3737
} from './ReactFiberLane';
3838
import {readContext} from './ReactFiberNewContext.new';
@@ -999,12 +999,11 @@ function useMutableSource<Source, Snapshot>(
999999
markRootMutableRead(root, lane);
10001000

10011001
// If the source mutated between render and now,
1002-
// there may be state updates already scheduled from the old getSnapshot.
1003-
// Those updates should not commit without this value.
1004-
// There is no mechanism currently to associate these updates though,
1005-
// so for now we fall back to synchronously flushing all pending updates.
1006-
// TODO: This should entangle the lanes instead of expiring everything.
1007-
markRootExpired(root, root.mutableReadLanes);
1002+
// there may be state updates already scheduled from the old source.
1003+
// Entangle the updates so that they render in the same batch.
1004+
// TODO: I think we need to entangle even if the snapshot matches,
1005+
// because there could have been an update to a different hook.
1006+
markRootEntangled(root, root.mutableReadLanes);
10081007
}
10091008
}
10101009
}, [getSnapshot, source, subscribe]);

packages/react-reconciler/src/ReactFiberLane.js

Lines changed: 55 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,37 @@ export function getNextLanes(root: FiberRoot, wipLanes: Lanes): Lanes {
365365
}
366366
}
367367

368+
// Check for entangled lanes and add them to the batch.
369+
//
370+
// A lane is said to be entangled with another when it's not allowed to render
371+
// in a batch that does not also include the other lane. Typically we do this
372+
// when multiple updates have the same source, and we only want to respond to
373+
// the most recent event from that source.
374+
//
375+
// Note that we apply entanglements *after* checking for partial work above.
376+
// This means that if a lane is entangled during an interleaved event while
377+
// it's already rendering, we won't interrupt it. This is intentional, since
378+
// entanglement is usually "best effort": we'll try our best to render the
379+
// lanes in the same batch, but it's not worth throwing out partially
380+
// completed work in order to do it.
381+
//
382+
// For those exceptions where entanglement is semantically important, like
383+
// useMutableSource, we should ensure that there is no partial work at the
384+
// time we apply the entanglement.
385+
const entangledLanes = root.entangledLanes;
386+
if (entangledLanes !== NoLanes) {
387+
const entanglements = root.entanglements;
388+
let lanes = nextLanes & entangledLanes;
389+
while (lanes > 0) {
390+
const index = pickArbitraryLaneIndex(lanes);
391+
const lane = 1 << index;
392+
393+
nextLanes |= entanglements[index];
394+
395+
lanes &= ~lane;
396+
}
397+
}
398+
368399
return nextLanes;
369400
}
370401

@@ -401,7 +432,7 @@ export function markStarvedLanesAsExpired(
401432
// it as expired to force it to finish.
402433
let lanes = pendingLanes;
403434
while (lanes > 0) {
404-
const index = ctrz(lanes);
435+
const index = pickArbitraryLaneIndex(lanes);
405436
const lane = 1 << index;
406437

407438
const expirationTime = expirationTimes[index];
@@ -588,6 +619,10 @@ export function pickArbitraryLane(lanes: Lanes): Lane {
588619
return getLowestPriorityLane(lanes);
589620
}
590621

622+
function pickArbitraryLaneIndex(lanes: Lane | Lanes) {
623+
return 31 - clz32(lanes);
624+
}
625+
591626
export function includesSomeLane(a: Lanes | Lane, b: Lanes | Lane) {
592627
return (a & b) !== NoLanes;
593628
}
@@ -647,7 +682,7 @@ export function markRootSuspended(root: FiberRoot, suspendedLanes: Lanes) {
647682
const expirationTimes = root.expirationTimes;
648683
let lanes = suspendedLanes;
649684
while (lanes > 0) {
650-
const index = ctrz(lanes);
685+
const index = pickArbitraryLaneIndex(lanes);
651686
const lane = 1 << index;
652687

653688
expirationTimes[index] = NoTimestamp;
@@ -692,10 +727,12 @@ export function markRootFinished(root: FiberRoot, remainingLanes: Lanes) {
692727
root.expiredLanes &= remainingLanes;
693728
root.mutableReadLanes &= remainingLanes;
694729

730+
root.entangledLanes &= remainingLanes;
731+
695732
const expirationTimes = root.expirationTimes;
696733
let lanes = noLongerPendingLanes;
697734
while (lanes > 0) {
698-
const index = ctrz(lanes);
735+
const index = pickArbitraryLaneIndex(lanes);
699736
const lane = 1 << index;
700737

701738
// Clear the expiration time
@@ -705,6 +742,21 @@ export function markRootFinished(root: FiberRoot, remainingLanes: Lanes) {
705742
}
706743
}
707744

745+
export function markRootEntangled(root: FiberRoot, entangledLanes: Lanes) {
746+
root.entangledLanes |= entangledLanes;
747+
748+
const entanglements = root.entanglements;
749+
let lanes = entangledLanes;
750+
while (lanes > 0) {
751+
const index = pickArbitraryLaneIndex(lanes);
752+
const lane = 1 << index;
753+
754+
entanglements[index] |= entangledLanes;
755+
756+
lanes &= ~lane;
757+
}
758+
}
759+
708760
export function getBumpedLaneForHydration(
709761
root: FiberRoot,
710762
renderLanes: Lanes,
@@ -777,14 +829,3 @@ function clz32Fallback(lanes: Lanes | Lane) {
777829
}
778830
return (31 - ((log(lanes) / LN2) | 0)) | 0;
779831
}
780-
781-
// Count trailing zeros. Only used on lanes, so assume input is an integer.
782-
function ctrz(lanes: Lanes | Lane) {
783-
let bits = lanes;
784-
bits |= bits << 16;
785-
bits |= bits << 8;
786-
bits |= bits << 4;
787-
bits |= bits << 2;
788-
bits |= bits << 1;
789-
return 32 - clz32(~bits);
790-
}

packages/react-reconciler/src/ReactFiberRoot.new.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ function FiberRootNode(containerInfo, tag, hydrate) {
4949

5050
this.finishedLanes = NoLanes;
5151

52+
this.entangledLanes = NoLanes;
53+
this.entanglements = createLaneMap(NoLanes);
54+
5255
if (enableSchedulerTracing) {
5356
this.interactionThreadID = unstable_getThreadID();
5457
this.memoizedInteractions = new Set();

packages/react-reconciler/src/ReactFiberWorkLoop.new.js

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -673,6 +673,15 @@ function performConcurrentWorkOnRoot(root, didTimeout) {
673673
currentEventWipLanes = NoLanes;
674674
currentEventPendingLanes = NoLanes;
675675

676+
invariant(
677+
(executionContext & (RenderContext | CommitContext)) === NoContext,
678+
'Should not already be working.',
679+
);
680+
681+
// Flush any pending passive effects before deciding which lanes to work on,
682+
// in case they schedule additional work.
683+
flushPassiveEffects();
684+
676685
// Determine the next expiration time to work on, using the fields stored
677686
// on the root.
678687
let lanes = getNextLanes(
@@ -697,12 +706,6 @@ function performConcurrentWorkOnRoot(root, didTimeout) {
697706
}
698707

699708
const originalCallbackNode = root.callbackNode;
700-
invariant(
701-
(executionContext & (RenderContext | CommitContext)) === NoContext,
702-
'Should not already be working.',
703-
);
704-
705-
flushPassiveEffects();
706709

707710
let exitStatus = renderRootConcurrent(root, lanes);
708711

packages/react-reconciler/src/ReactInternalTypes.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,9 @@ type BaseFiberRootProperties = {|
262262
mutableReadLanes: Lanes,
263263

264264
finishedLanes: Lanes,
265+
266+
entangledLanes: Lanes,
267+
entanglements: LaneMap<Lanes>,
265268
|};
266269

267270
// The following attributes are only used by interaction tracing builds.

packages/react-reconciler/src/__tests__/useMutableSource-test.internal.js

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1481,21 +1481,39 @@ describe('useMutableSource', () => {
14811481
source.valueB = '3';
14821482
},
14831483
);
1484-
});
1485-
1486-
expect(Scheduler).toHaveYielded([
1487-
// The partial render completes
1488-
'Child: 2',
1489-
'Commit: 2, 2',
14901484

1491-
// Then we start rendering the low priority mutation
1492-
'Parent: 3',
1485+
expect(Scheduler).toFlushAndYieldThrough([
1486+
// The partial render completes
1487+
'Child: 2',
1488+
'Commit: 2, 2',
1489+
]);
14931490

1494-
// Eventually the child corrects itself, because of the check that
1495-
// occurs when re-subscribing.
1496-
'Child: 3',
1497-
'Commit: 3, 3',
1498-
]);
1491+
// Now there are two pending mutations at different priorities. But they
1492+
// both read the same verion of the mutable source, so we must render
1493+
// them simultaneously.
1494+
//
1495+
if (gate(flags => flags.new)) {
1496+
// In the new reconciler, we can do this with entanglement: when the
1497+
// high priority render starts, we'll also include the low pri work.
1498+
expect(Scheduler).toFlushAndYieldThrough([
1499+
'Parent: 3',
1500+
// Demonstrates that we can yield here
1501+
]);
1502+
expect(Scheduler).toFlushAndYield([
1503+
// Now finish the rest of the update
1504+
'Child: 3',
1505+
'Commit: 3, 3',
1506+
]);
1507+
} else {
1508+
// In the old reconciler, we don't have an entanglement mechanism. The
1509+
// best we can do is synchronously flush both updates.
1510+
expect(Scheduler).toFlushAndYield([
1511+
'Parent: 3',
1512+
'Child: 3',
1513+
'Commit: 3, 3',
1514+
]);
1515+
}
1516+
});
14991517
});
15001518

15011519
// @gate experimental

0 commit comments

Comments
 (0)