mirror of
https://github.com/facebook/react.git
synced 2026-02-22 20:01:52 +00:00
[FORKED] Bugfix: Revealing a hidden update
This fixes a bug I discovered related to revealing a hidden Offscreen tree. When this happens, we include in that render all the updates that had previously been deferred — that is, all the updates that would have already committed if the tree weren't hidden. This is necessary to avoid tearing with the surrounding contents. (This was the "flickering" Suspense bug we found a few years ago: #18411.) The way we do this is by tracking the lanes of the updates that were deferred by a hidden tree. These are the "base" lanes. Then, in order to reveal the hidden tree, we process any update that matches one of those base lanes. The bug I discovered is that some of these base lanes may include updates that were not present at the time the tree was hidden. We cannot flush those updates earlier that the surrounding contents — that, too, could cause tearing. The crux of the problem is that we sometimes reuse the same lane for base updates and for non-base updates. So the lane alone isn't sufficient to distinguish between these cases. We must track this in some other way. The solution I landed upon was to add an extra OffscreenLane bit to any update that is made to a hidden tree. Then later when we reveal the tree, we'll know not to treat them as base updates. The extra OffscreenLane bit is removed as soon as that lane is committed by the root (markRootFinished) — at that point, it gets "upgraded" to a base update. The trickiest part of this algorithm is reliably detecting when an update is made to a hidden tree. What makes this challenging is when the update is received during a concurrent event, while a render is already in progress — it's possible the work-in-progress render is about to flip the visibility of the tree that's being updated, leading to a race condition. To avoid a race condition, we will wait to read the visibility of the tree until the current render has finished. In other words, this makes it an atomic operation. Most of this logic was already implemented in #24663. Because this bugfix depends on a moderately risky refactor to the update queue (#24663), it only works in the "new" reconciler fork. We will roll it out gradually to www before landing in the main fork.
This commit is contained in:
@@ -90,8 +90,10 @@ import type {Lanes, Lane} from './ReactFiberLane.new';
|
||||
import {
|
||||
NoLane,
|
||||
NoLanes,
|
||||
OffscreenLane,
|
||||
isSubsetOfLanes,
|
||||
mergeLanes,
|
||||
removeLanes,
|
||||
isTransitionLane,
|
||||
intersectLanes,
|
||||
markRootEntangled,
|
||||
@@ -108,6 +110,7 @@ import {StrictLegacyMode} from './ReactTypeOfMode';
|
||||
import {
|
||||
markSkippedUpdateLanes,
|
||||
isUnsafeClassRenderPhaseUpdate,
|
||||
getWorkInProgressRootRenderLanes,
|
||||
} from './ReactFiberWorkLoop.new';
|
||||
import {
|
||||
enqueueConcurrentClassUpdate,
|
||||
@@ -523,9 +526,23 @@ export function processUpdateQueue<State>(
|
||||
|
||||
let update = firstBaseUpdate;
|
||||
do {
|
||||
const updateLane = update.lane;
|
||||
// TODO: Don't need this field anymore
|
||||
const updateEventTime = update.eventTime;
|
||||
if (!isSubsetOfLanes(renderLanes, updateLane)) {
|
||||
|
||||
// An extra OffscreenLane bit is added to updates that were made to
|
||||
// a hidden tree, so that we can distinguish them from updates that were
|
||||
// already there when the tree was hidden.
|
||||
const updateLane = removeLanes(update.lane, OffscreenLane);
|
||||
const isHiddenUpdate = updateLane !== update.lane;
|
||||
|
||||
// Check if this update was made while the tree was hidden. If so, then
|
||||
// it's not a "base" update and we should disregard the extra base lanes
|
||||
// that were added to renderLanes when we entered the Offscreen tree.
|
||||
const shouldSkipUpdate = isHiddenUpdate
|
||||
? !isSubsetOfLanes(getWorkInProgressRootRenderLanes(), updateLane)
|
||||
: !isSubsetOfLanes(renderLanes, updateLane);
|
||||
|
||||
if (shouldSkipUpdate) {
|
||||
// Priority is insufficient. Skip this update. If this is the first
|
||||
// skipped update, the previous update/state is the new base
|
||||
// update/state.
|
||||
|
||||
@@ -17,14 +17,21 @@ import type {
|
||||
Update as ClassUpdate,
|
||||
} from './ReactFiberClassUpdateQueue.new';
|
||||
import type {Lane, Lanes} from './ReactFiberLane.new';
|
||||
import type {OffscreenInstance} from './ReactFiberOffscreenComponent';
|
||||
|
||||
import {warnAboutUpdateOnNotYetMountedFiberInDEV} from './ReactFiberWorkLoop.new';
|
||||
import {NoLane, NoLanes, mergeLanes} from './ReactFiberLane.new';
|
||||
import {
|
||||
NoLane,
|
||||
NoLanes,
|
||||
mergeLanes,
|
||||
markHiddenUpdate,
|
||||
} from './ReactFiberLane.new';
|
||||
import {NoFlags, Placement, Hydrating} from './ReactFiberFlags';
|
||||
import {HostRoot} from './ReactWorkTags';
|
||||
import {HostRoot, OffscreenComponent} from './ReactWorkTags';
|
||||
|
||||
type ConcurrentUpdate = {
|
||||
export type ConcurrentUpdate = {
|
||||
next: ConcurrentUpdate,
|
||||
lane: Lane,
|
||||
};
|
||||
|
||||
type ConcurrentQueue = {
|
||||
@@ -38,11 +45,13 @@ type ConcurrentQueue = {
|
||||
const concurrentQueues: Array<any> = [];
|
||||
let concurrentQueuesIndex = 0;
|
||||
|
||||
export function finishQueueingConcurrentUpdates(): Lanes {
|
||||
let concurrentlyUpdatedLanes: Lanes = NoLanes;
|
||||
|
||||
export function finishQueueingConcurrentUpdates(): void {
|
||||
const endIndex = concurrentQueuesIndex;
|
||||
concurrentQueuesIndex = 0;
|
||||
|
||||
let lanes = NoLanes;
|
||||
concurrentlyUpdatedLanes = NoLanes;
|
||||
|
||||
let i = 0;
|
||||
while (i < endIndex) {
|
||||
@@ -68,12 +77,13 @@ export function finishQueueingConcurrentUpdates(): Lanes {
|
||||
}
|
||||
|
||||
if (lane !== NoLane) {
|
||||
lanes = mergeLanes(lanes, lane);
|
||||
markUpdateLaneFromFiberToRoot(fiber, lane);
|
||||
markUpdateLaneFromFiberToRoot(fiber, update, lane);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lanes;
|
||||
export function getConcurrentlyUpdatedLanes(): Lanes {
|
||||
return concurrentlyUpdatedLanes;
|
||||
}
|
||||
|
||||
function enqueueUpdate(
|
||||
@@ -89,6 +99,8 @@ function enqueueUpdate(
|
||||
concurrentQueues[concurrentQueuesIndex++] = update;
|
||||
concurrentQueues[concurrentQueuesIndex++] = lane;
|
||||
|
||||
concurrentlyUpdatedLanes = mergeLanes(concurrentlyUpdatedLanes, lane);
|
||||
|
||||
// The fiber's `lane` field is used in some places to check if any work is
|
||||
// scheduled, to perform an eager bailout, so we need to update it immediately.
|
||||
// TODO: We should probably move this to the "shared" queue instead.
|
||||
@@ -151,11 +163,15 @@ export function unsafe_markUpdateLaneFromFiberToRoot(
|
||||
sourceFiber: Fiber,
|
||||
lane: Lane,
|
||||
): FiberRoot | null {
|
||||
markUpdateLaneFromFiberToRoot(sourceFiber, lane);
|
||||
markUpdateLaneFromFiberToRoot(sourceFiber, null, lane);
|
||||
return getRootForUpdatedFiber(sourceFiber);
|
||||
}
|
||||
|
||||
function markUpdateLaneFromFiberToRoot(sourceFiber: Fiber, lane: Lane): void {
|
||||
function markUpdateLaneFromFiberToRoot(
|
||||
sourceFiber: Fiber,
|
||||
update: ConcurrentUpdate | null,
|
||||
lane: Lane,
|
||||
): void {
|
||||
// Update the source fiber's lanes
|
||||
sourceFiber.lanes = mergeLanes(sourceFiber.lanes, lane);
|
||||
let alternate = sourceFiber.alternate;
|
||||
@@ -163,15 +179,31 @@ function markUpdateLaneFromFiberToRoot(sourceFiber: Fiber, lane: Lane): void {
|
||||
alternate.lanes = mergeLanes(alternate.lanes, lane);
|
||||
}
|
||||
// Walk the parent path to the root and update the child lanes.
|
||||
let isHidden = false;
|
||||
let parent = sourceFiber.return;
|
||||
let node = sourceFiber;
|
||||
while (parent !== null) {
|
||||
parent.childLanes = mergeLanes(parent.childLanes, lane);
|
||||
alternate = parent.alternate;
|
||||
if (alternate !== null) {
|
||||
alternate.childLanes = mergeLanes(alternate.childLanes, lane);
|
||||
}
|
||||
|
||||
if (parent.tag === OffscreenComponent) {
|
||||
const offscreenInstance: OffscreenInstance = parent.stateNode;
|
||||
if (offscreenInstance.isHidden) {
|
||||
isHidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
node = parent;
|
||||
parent = parent.return;
|
||||
}
|
||||
|
||||
if (isHidden && update !== null && node.tag === HostRoot) {
|
||||
const root: FiberRoot = node.stateNode;
|
||||
markHiddenUpdate(root, update, lane);
|
||||
}
|
||||
}
|
||||
|
||||
function getRootForUpdatedFiber(sourceFiber: Fiber): FiberRoot | null {
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
import {
|
||||
NoLane,
|
||||
SyncLane,
|
||||
OffscreenLane,
|
||||
NoLanes,
|
||||
isSubsetOfLanes,
|
||||
includesBlockingLane,
|
||||
@@ -83,6 +84,7 @@ import {
|
||||
} from './ReactHookEffectTags';
|
||||
import {
|
||||
getWorkInProgressRoot,
|
||||
getWorkInProgressRootRenderLanes,
|
||||
scheduleUpdateOnFiber,
|
||||
requestUpdateLane,
|
||||
requestEventTime,
|
||||
@@ -811,8 +813,20 @@ function updateReducer<S, I, A>(
|
||||
let newBaseQueueLast = null;
|
||||
let update = first;
|
||||
do {
|
||||
const updateLane = update.lane;
|
||||
if (!isSubsetOfLanes(renderLanes, updateLane)) {
|
||||
// An extra OffscreenLane bit is added to updates that were made to
|
||||
// a hidden tree, so that we can distinguish them from updates that were
|
||||
// already there when the tree was hidden.
|
||||
const updateLane = removeLanes(update.lane, OffscreenLane);
|
||||
const isHiddenUpdate = updateLane !== update.lane;
|
||||
|
||||
// Check if this update was made while the tree was hidden. If so, then
|
||||
// it's not a "base" update and we should disregard the extra base lanes
|
||||
// that were added to renderLanes when we entered the Offscreen tree.
|
||||
const shouldSkipUpdate = isHiddenUpdate
|
||||
? !isSubsetOfLanes(getWorkInProgressRootRenderLanes(), updateLane)
|
||||
: !isSubsetOfLanes(renderLanes, updateLane);
|
||||
|
||||
if (shouldSkipUpdate) {
|
||||
// Priority is insufficient. Skip this update. If this is the first
|
||||
// skipped update, the previous update/state is the new base
|
||||
// update/state.
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import type {FiberRoot} from './ReactInternalTypes';
|
||||
import type {Transition} from './ReactFiberTracingMarkerComponent.new';
|
||||
import type {ConcurrentUpdate} from './ReactFiberConcurrentUpdates.new';
|
||||
|
||||
// TODO: Ideally these types would be opaque but that doesn't work well with
|
||||
// our reconciler fork infra, since these leak into non-reconciler packages.
|
||||
@@ -648,6 +649,7 @@ export function markRootFinished(root: FiberRoot, remainingLanes: Lanes) {
|
||||
const entanglements = root.entanglements;
|
||||
const eventTimes = root.eventTimes;
|
||||
const expirationTimes = root.expirationTimes;
|
||||
const hiddenUpdates = root.hiddenUpdates;
|
||||
|
||||
// Clear the lanes that no longer have pending work
|
||||
let lanes = noLongerPendingLanes;
|
||||
@@ -659,6 +661,21 @@ export function markRootFinished(root: FiberRoot, remainingLanes: Lanes) {
|
||||
eventTimes[index] = NoTimestamp;
|
||||
expirationTimes[index] = NoTimestamp;
|
||||
|
||||
const hiddenUpdatesForLane = hiddenUpdates[index];
|
||||
if (hiddenUpdatesForLane !== null) {
|
||||
hiddenUpdates[index] = null;
|
||||
// "Hidden" updates are updates that were made to a hidden component. They
|
||||
// have special logic associated with them because they may be entangled
|
||||
// with updates that occur outside that tree. But once the outer tree
|
||||
// commits, they behave like regular updates.
|
||||
for (let i = 0; i < hiddenUpdatesForLane.length; i++) {
|
||||
const update = hiddenUpdatesForLane[i];
|
||||
if (update !== null) {
|
||||
update.lane &= ~OffscreenLane;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lanes &= ~lane;
|
||||
}
|
||||
}
|
||||
@@ -694,6 +711,22 @@ export function markRootEntangled(root: FiberRoot, entangledLanes: Lanes) {
|
||||
}
|
||||
}
|
||||
|
||||
export function markHiddenUpdate(
|
||||
root: FiberRoot,
|
||||
update: ConcurrentUpdate,
|
||||
lane: Lane,
|
||||
) {
|
||||
const index = laneToIndex(lane);
|
||||
const hiddenUpdates = root.hiddenUpdates;
|
||||
const hiddenUpdatesForLane = hiddenUpdates[index];
|
||||
if (hiddenUpdatesForLane === null) {
|
||||
hiddenUpdates[index] = [update];
|
||||
} else {
|
||||
hiddenUpdatesForLane.push(update);
|
||||
}
|
||||
update.lane = lane | OffscreenLane;
|
||||
}
|
||||
|
||||
export function getBumpedLaneForHydration(
|
||||
root: FiberRoot,
|
||||
renderLanes: Lanes,
|
||||
|
||||
@@ -80,6 +80,8 @@ function FiberRootNode(
|
||||
this.entangledLanes = NoLanes;
|
||||
this.entanglements = createLaneMap(NoLanes);
|
||||
|
||||
this.hiddenUpdates = createLaneMap(null);
|
||||
|
||||
this.identifierPrefix = identifierPrefix;
|
||||
this.onRecoverableError = onRecoverableError;
|
||||
|
||||
|
||||
@@ -199,6 +199,7 @@ import {
|
||||
import {
|
||||
enqueueConcurrentRenderForLane,
|
||||
finishQueueingConcurrentUpdates,
|
||||
getConcurrentlyUpdatedLanes,
|
||||
} from './ReactFiberConcurrentUpdates.new';
|
||||
|
||||
import {
|
||||
@@ -425,6 +426,10 @@ export function getWorkInProgressRoot(): FiberRoot | null {
|
||||
return workInProgressRoot;
|
||||
}
|
||||
|
||||
export function getWorkInProgressRootRenderLanes(): Lanes {
|
||||
return workInProgressRootRenderLanes;
|
||||
}
|
||||
|
||||
export function requestEventTime() {
|
||||
if ((executionContext & (RenderContext | CommitContext)) !== NoContext) {
|
||||
// We're inside React, so it's fine to read the actual time.
|
||||
@@ -2059,7 +2064,7 @@ function commitRootImpl(
|
||||
|
||||
// Make sure to account for lanes that were updated by a concurrent event
|
||||
// during the render phase; don't mark them as finished.
|
||||
const concurrentlyUpdatedLanes = finishQueueingConcurrentUpdates();
|
||||
const concurrentlyUpdatedLanes = getConcurrentlyUpdatedLanes();
|
||||
remainingLanes = mergeLanes(remainingLanes, concurrentlyUpdatedLanes);
|
||||
|
||||
markRootFinished(root, remainingLanes);
|
||||
|
||||
@@ -27,6 +27,7 @@ import type {RootTag} from './ReactRootTags';
|
||||
import type {TimeoutHandle, NoTimeout} from './ReactFiberHostConfig';
|
||||
import type {Cache} from './ReactFiberCacheComponent.old';
|
||||
import type {Transition} from './ReactFiberTracingMarkerComponent.new';
|
||||
import type {ConcurrentUpdate} from './ReactFiberConcurrentUpdates.new';
|
||||
|
||||
// Unwind Circular: moved from ReactFiberHooks.old
|
||||
export type HookType =
|
||||
@@ -225,6 +226,7 @@ type BaseFiberRootProperties = {|
|
||||
callbackPriority: Lane,
|
||||
eventTimes: LaneMap<number>,
|
||||
expirationTimes: LaneMap<number>,
|
||||
hiddenUpdates: LaneMap<Array<ConcurrentUpdate> | null>,
|
||||
|
||||
pendingLanes: Lanes,
|
||||
suspendedLanes: Lanes,
|
||||
|
||||
@@ -6,6 +6,7 @@ let LegacyHidden;
|
||||
let Offscreen;
|
||||
let useState;
|
||||
let useLayoutEffect;
|
||||
let useEffect;
|
||||
|
||||
describe('ReactOffscreen', () => {
|
||||
beforeEach(() => {
|
||||
@@ -19,6 +20,7 @@ describe('ReactOffscreen', () => {
|
||||
Offscreen = React.unstable_Offscreen;
|
||||
useState = React.useState;
|
||||
useLayoutEffect = React.useLayoutEffect;
|
||||
useEffect = React.useEffect;
|
||||
});
|
||||
|
||||
function Text(props) {
|
||||
@@ -473,4 +475,124 @@ describe('ReactOffscreen', () => {
|
||||
// Now it's visible
|
||||
expect(root).toMatchRenderedOutput(<span>Hi</span>);
|
||||
});
|
||||
|
||||
// Only works in new reconciler
|
||||
// @gate variant
|
||||
it('revealing a hidden tree at high priority does not cause tearing', async () => {
|
||||
// When revealing an offscreen tree, we need to include updates that were
|
||||
// previously deferred because the tree was hidden, even if they are lower
|
||||
// priority than the current render. However, we should *not* include low
|
||||
// priority updates that are entangled with updates outside of the hidden
|
||||
// tree, because that can cause tearing.
|
||||
//
|
||||
// This test covers a scenario where an update multiple updates inside a
|
||||
// hidden tree share the same lane, but are processed at different times
|
||||
// because of the timing of when they were scheduled.
|
||||
|
||||
let setInner;
|
||||
function Child({outer}) {
|
||||
const [inner, _setInner] = useState(0);
|
||||
setInner = _setInner;
|
||||
|
||||
useEffect(() => {
|
||||
// Inner and outer values are always updated simultaneously, so they
|
||||
// should always be consistent.
|
||||
if (inner !== outer) {
|
||||
Scheduler.unstable_yieldValue(
|
||||
'Tearing! Inner and outer are inconsistent!',
|
||||
);
|
||||
} else {
|
||||
Scheduler.unstable_yieldValue('Inner and outer are consistent');
|
||||
}
|
||||
}, [inner, outer]);
|
||||
|
||||
return <Text text={'Inner: ' + inner} />;
|
||||
}
|
||||
|
||||
let setOuter;
|
||||
function App({show}) {
|
||||
const [outer, _setOuter] = useState(0);
|
||||
setOuter = _setOuter;
|
||||
return (
|
||||
<>
|
||||
<Text text={'Outer: ' + outer} />
|
||||
<Offscreen mode={show ? 'visible' : 'hidden'}>
|
||||
<Child outer={outer} />
|
||||
</Offscreen>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Render a hidden tree
|
||||
const root = ReactNoop.createRoot();
|
||||
await act(async () => {
|
||||
root.render(<App show={false} />);
|
||||
});
|
||||
expect(Scheduler).toHaveYielded([
|
||||
'Outer: 0',
|
||||
'Inner: 0',
|
||||
'Inner and outer are consistent',
|
||||
]);
|
||||
expect(root).toMatchRenderedOutput(
|
||||
<>
|
||||
<span prop="Outer: 0" />
|
||||
<span hidden={true} prop="Inner: 0" />
|
||||
</>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
// Update a value both inside and outside the hidden tree. These values
|
||||
// must always be consistent.
|
||||
setOuter(1);
|
||||
setInner(1);
|
||||
// Only the outer updates finishes because the inner update is inside a
|
||||
// hidden tree. The outer update is deferred to a later render.
|
||||
expect(Scheduler).toFlushUntilNextPaint(['Outer: 1']);
|
||||
expect(root).toMatchRenderedOutput(
|
||||
<>
|
||||
<span prop="Outer: 1" />
|
||||
<span hidden={true} prop="Inner: 0" />
|
||||
</>,
|
||||
);
|
||||
|
||||
// Before the inner update can finish, we receive another pair of updates.
|
||||
setOuter(2);
|
||||
setInner(2);
|
||||
|
||||
// Also, before either of these new updates are processed, the hidden
|
||||
// tree is revealed at high priority.
|
||||
ReactNoop.flushSync(() => {
|
||||
root.render(<App show={true} />);
|
||||
});
|
||||
|
||||
expect(Scheduler).toHaveYielded([
|
||||
'Outer: 1',
|
||||
|
||||
// There are two pending updates on Inner, but only the first one
|
||||
// is processed, even though they share the same lane. If the second
|
||||
// update were erroneously processed, then Inner would be inconsistent
|
||||
// with Outer.
|
||||
'Inner: 1',
|
||||
|
||||
'Inner and outer are consistent',
|
||||
]);
|
||||
expect(root).toMatchRenderedOutput(
|
||||
<>
|
||||
<span prop="Outer: 1" />
|
||||
<span prop="Inner: 1" />
|
||||
</>,
|
||||
);
|
||||
});
|
||||
expect(Scheduler).toHaveYielded([
|
||||
'Outer: 2',
|
||||
'Inner: 2',
|
||||
'Inner and outer are consistent',
|
||||
]);
|
||||
expect(root).toMatchRenderedOutput(
|
||||
<>
|
||||
<span prop="Outer: 2" />
|
||||
<span prop="Inner: 2" />
|
||||
</>,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
246
packages/react-reconciler/src/__tests__/ReactOffscreenSuspense-test.js
vendored
Normal file
246
packages/react-reconciler/src/__tests__/ReactOffscreenSuspense-test.js
vendored
Normal file
@@ -0,0 +1,246 @@
|
||||
let React;
|
||||
let ReactNoop;
|
||||
let Scheduler;
|
||||
let act;
|
||||
let Offscreen;
|
||||
let Suspense;
|
||||
let useState;
|
||||
let useEffect;
|
||||
let textCache;
|
||||
|
||||
describe('ReactOffscreen', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
|
||||
React = require('react');
|
||||
ReactNoop = require('react-noop-renderer');
|
||||
Scheduler = require('scheduler');
|
||||
act = require('jest-react').act;
|
||||
Offscreen = React.unstable_Offscreen;
|
||||
Suspense = React.Suspense;
|
||||
useState = React.useState;
|
||||
useEffect = React.useEffect;
|
||||
|
||||
textCache = new Map();
|
||||
});
|
||||
|
||||
function resolveText(text) {
|
||||
const record = textCache.get(text);
|
||||
if (record === undefined) {
|
||||
const newRecord = {
|
||||
status: 'resolved',
|
||||
value: text,
|
||||
};
|
||||
textCache.set(text, newRecord);
|
||||
} else if (record.status === 'pending') {
|
||||
const thenable = record.value;
|
||||
record.status = 'resolved';
|
||||
record.value = text;
|
||||
thenable.pings.forEach(t => t());
|
||||
}
|
||||
}
|
||||
|
||||
function readText(text) {
|
||||
const record = textCache.get(text);
|
||||
if (record !== undefined) {
|
||||
switch (record.status) {
|
||||
case 'pending':
|
||||
Scheduler.unstable_yieldValue(`Suspend! [${text}]`);
|
||||
throw record.value;
|
||||
case 'rejected':
|
||||
throw record.value;
|
||||
case 'resolved':
|
||||
return record.value;
|
||||
}
|
||||
} else {
|
||||
Scheduler.unstable_yieldValue(`Suspend! [${text}]`);
|
||||
const thenable = {
|
||||
pings: [],
|
||||
then(resolve) {
|
||||
if (newRecord.status === 'pending') {
|
||||
thenable.pings.push(resolve);
|
||||
} else {
|
||||
Promise.resolve().then(() => resolve(newRecord.value));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const newRecord = {
|
||||
status: 'pending',
|
||||
value: thenable,
|
||||
};
|
||||
textCache.set(text, newRecord);
|
||||
|
||||
throw thenable;
|
||||
}
|
||||
}
|
||||
|
||||
function Text({text}) {
|
||||
Scheduler.unstable_yieldValue(text);
|
||||
return text;
|
||||
}
|
||||
|
||||
function AsyncText({text}) {
|
||||
readText(text);
|
||||
Scheduler.unstable_yieldValue(text);
|
||||
return text;
|
||||
}
|
||||
|
||||
// Only works in new reconciler
|
||||
// @gate variant
|
||||
test('detect updates to a hidden tree during a concurrent event', async () => {
|
||||
// This is a pretty complex test case. It relates to how we detect if an
|
||||
// update is made to a hidden tree: when scheduling the update, we walk up
|
||||
// the fiber return path to see if any of the parents is a hidden Offscreen
|
||||
// component. This doesn't work if there's already a render in progress,
|
||||
// because the tree might be about to flip to hidden. To avoid a data race,
|
||||
// queue updates atomically: wait to queue the update until after the
|
||||
// current render has finished.
|
||||
|
||||
let setInner;
|
||||
function Child({outer}) {
|
||||
const [inner, _setInner] = useState(0);
|
||||
setInner = _setInner;
|
||||
|
||||
useEffect(() => {
|
||||
// Inner and outer values are always updated simultaneously, so they
|
||||
// should always be consistent.
|
||||
if (inner !== outer) {
|
||||
Scheduler.unstable_yieldValue(
|
||||
'Tearing! Inner and outer are inconsistent!',
|
||||
);
|
||||
} else {
|
||||
Scheduler.unstable_yieldValue('Inner and outer are consistent');
|
||||
}
|
||||
}, [inner, outer]);
|
||||
|
||||
return <Text text={'Inner: ' + inner} />;
|
||||
}
|
||||
|
||||
let setOuter;
|
||||
function App({show}) {
|
||||
const [outer, _setOuter] = useState(0);
|
||||
setOuter = _setOuter;
|
||||
return (
|
||||
<>
|
||||
<span>
|
||||
<Text text={'Outer: ' + outer} />
|
||||
</span>
|
||||
<Offscreen mode={show ? 'visible' : 'hidden'}>
|
||||
<span>
|
||||
<Child outer={outer} />
|
||||
</span>
|
||||
</Offscreen>
|
||||
<Suspense fallback={<Text text="Loading..." />}>
|
||||
<span>
|
||||
<AsyncText text={'Async: ' + outer} />
|
||||
</span>
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Render a hidden tree
|
||||
const root = ReactNoop.createRoot();
|
||||
resolveText('Async: 0');
|
||||
await act(async () => {
|
||||
root.render(<App show={true} />);
|
||||
});
|
||||
expect(Scheduler).toHaveYielded([
|
||||
'Outer: 0',
|
||||
'Inner: 0',
|
||||
'Async: 0',
|
||||
'Inner and outer are consistent',
|
||||
]);
|
||||
expect(root).toMatchRenderedOutput(
|
||||
<>
|
||||
<span>Outer: 0</span>
|
||||
<span>Inner: 0</span>
|
||||
<span>Async: 0</span>
|
||||
</>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
// Update a value both inside and outside the hidden tree. These values
|
||||
// must always be consistent.
|
||||
setOuter(1);
|
||||
setInner(1);
|
||||
// In the same render, also hide the offscreen tree.
|
||||
root.render(<App show={false} />);
|
||||
|
||||
expect(Scheduler).toFlushAndYieldThrough([
|
||||
// The outer update will commit, but the inner update is deferred until
|
||||
// a later render.
|
||||
'Outer: 1',
|
||||
|
||||
// Something suspended. This means we won't commit immediately; there
|
||||
// will be an async gap between render and commit. In this test, we will
|
||||
// use this property to schedule a concurrent update. The fact that
|
||||
// we're using Suspense to schedule a concurrent update is not directly
|
||||
// relevant to the test — we could also use time slicing, but I've
|
||||
// chosen to use Suspense the because implementation details of time
|
||||
// slicing are more volatile.
|
||||
'Suspend! [Async: 1]',
|
||||
|
||||
'Loading...',
|
||||
]);
|
||||
// Assert that we haven't committed quite yet
|
||||
expect(root).toMatchRenderedOutput(
|
||||
<>
|
||||
<span>Outer: 0</span>
|
||||
<span>Inner: 0</span>
|
||||
<span>Async: 0</span>
|
||||
</>,
|
||||
);
|
||||
|
||||
// Before the tree commits, schedule a concurrent event. The inner update
|
||||
// is to a tree that's just about to be hidden.
|
||||
setOuter(2);
|
||||
setInner(2);
|
||||
|
||||
// Commit the previous render.
|
||||
jest.runAllTimers();
|
||||
expect(root).toMatchRenderedOutput(
|
||||
<>
|
||||
<span>Outer: 1</span>
|
||||
<span hidden={true}>Inner: 0</span>
|
||||
<span hidden={true}>Async: 0</span>
|
||||
Loading...
|
||||
</>,
|
||||
);
|
||||
|
||||
// Now reveal the hidden tree at high priority.
|
||||
ReactNoop.flushSync(() => {
|
||||
root.render(<App show={true} />);
|
||||
});
|
||||
expect(Scheduler).toHaveYielded([
|
||||
'Outer: 1',
|
||||
|
||||
// There are two pending updates on Inner, but only the first one
|
||||
// is processed, even though they share the same lane. If the second
|
||||
// update were erroneously processed, then Inner would be inconsistent
|
||||
// with Outer.
|
||||
'Inner: 1',
|
||||
|
||||
'Suspend! [Async: 1]',
|
||||
'Loading...',
|
||||
'Inner and outer are consistent',
|
||||
]);
|
||||
});
|
||||
expect(Scheduler).toHaveYielded([
|
||||
'Outer: 2',
|
||||
'Inner: 2',
|
||||
'Suspend! [Async: 2]',
|
||||
'Loading...',
|
||||
'Inner and outer are consistent',
|
||||
]);
|
||||
expect(root).toMatchRenderedOutput(
|
||||
<>
|
||||
<span>Outer: 2</span>
|
||||
<span>Inner: 2</span>
|
||||
<span hidden={true}>Async: 0</span>
|
||||
Loading...
|
||||
</>,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user