mirror of
https://github.com/zebrajr/react.git
synced 2026-01-15 12:15:22 +00:00
Expiration: Do nothing except disable time slicing (#21345)
We have a feature called "expiration" whose purpose is to prevent a concurrent update from being starved by higher priority events. If a lane is CPU-bound for too long, we finish the rest of the work synchronously without allowing further interruptions. In the current implementation, we do this in sort of a roundabout way: once a lane is determined to have expired, we entangle it with SyncLane and switch to the synchronous work loop. There are a few flaws with the approach. One is that SyncLane has a particular semantic meaning besides its non-yieldiness. For example, `flushSync` will force remaining Sync work to finish; currently, that also includes expired work, which isn't an intended behavior, but rather an artifact of the implementation. An event worse example is that passive effects triggered by a Sync update are flushed synchronously, before paint, so that its result is guaranteed to be observed by the next discrete event. But expired work has no such requirement: we're flushing expired effects before paint unnecessarily. Aside from the behaviorial implications, the current implementation has proven to be fragile: more than once, we've accidentally regressed performance due to a subtle change in how expiration is handled. This PR aims to radically simplify how we model starvation protection by scaling back the implementation as much as possible. In this new model, if a lane is expired, we disable time slicing. That's it. We don't entangle it with SyncLane. The only thing we do is skip the call to `shouldYield` in between each time slice. This is identical to how we model synchronous-by-default updates in React 18.
This commit is contained in:
@@ -401,7 +401,6 @@ export function markStarvedLanesAsExpired(
|
||||
// expiration time. If so, we'll assume the update is being starved and mark
|
||||
// it as expired to force it to finish.
|
||||
let lanes = pendingLanes;
|
||||
let expiredLanes = 0;
|
||||
while (lanes > 0) {
|
||||
const index = pickArbitraryLaneIndex(lanes);
|
||||
const lane = 1 << index;
|
||||
@@ -420,15 +419,11 @@ export function markStarvedLanesAsExpired(
|
||||
}
|
||||
} else if (expirationTime <= currentTime) {
|
||||
// This lane expired
|
||||
expiredLanes |= lane;
|
||||
root.expiredLanes |= lane;
|
||||
}
|
||||
|
||||
lanes &= ~lane;
|
||||
}
|
||||
|
||||
if (expiredLanes !== 0) {
|
||||
markRootExpired(root, expiredLanes);
|
||||
}
|
||||
}
|
||||
|
||||
// This returns the highest priority pending lanes regardless of whether they
|
||||
@@ -459,16 +454,22 @@ export function includesOnlyTransitions(lanes: Lanes) {
|
||||
}
|
||||
|
||||
export function shouldTimeSlice(root: FiberRoot, lanes: Lanes) {
|
||||
if (!enableSyncDefaultUpdates) {
|
||||
if ((lanes & root.expiredLanes) !== NoLanes) {
|
||||
// At least one of these lanes expired. To prevent additional starvation,
|
||||
// finish rendering without yielding execution.
|
||||
return false;
|
||||
}
|
||||
if (enableSyncDefaultUpdates) {
|
||||
const SyncDefaultLanes =
|
||||
InputContinuousHydrationLane |
|
||||
InputContinuousLane |
|
||||
DefaultHydrationLane |
|
||||
DefaultLane;
|
||||
// TODO: Check for root override, once that lands
|
||||
return (lanes & SyncDefaultLanes) === NoLanes;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
const SyncDefaultLanes =
|
||||
InputContinuousHydrationLane |
|
||||
InputContinuousLane |
|
||||
DefaultHydrationLane |
|
||||
DefaultLane;
|
||||
// TODO: Check for root override, once that lands
|
||||
return (lanes & SyncDefaultLanes) === NoLanes;
|
||||
}
|
||||
|
||||
export function isTransitionLane(lane: Lane) {
|
||||
@@ -613,14 +614,6 @@ export function markRootPinged(
|
||||
root.pingedLanes |= root.suspendedLanes & pingedLanes;
|
||||
}
|
||||
|
||||
export function markRootExpired(root: FiberRoot, expiredLanes: Lanes) {
|
||||
const entanglements = root.entanglements;
|
||||
const SyncLaneIndex = 0;
|
||||
entanglements[SyncLaneIndex] |= expiredLanes;
|
||||
root.entangledLanes |= SyncLane;
|
||||
root.pendingLanes |= SyncLane;
|
||||
}
|
||||
|
||||
export function markRootMutableRead(root: FiberRoot, updateLane: Lane) {
|
||||
root.mutableReadLanes |= updateLane & root.pendingLanes;
|
||||
}
|
||||
@@ -634,6 +627,7 @@ export function markRootFinished(root: FiberRoot, remainingLanes: Lanes) {
|
||||
root.suspendedLanes = 0;
|
||||
root.pingedLanes = 0;
|
||||
|
||||
root.expiredLanes &= remainingLanes;
|
||||
root.mutableReadLanes &= remainingLanes;
|
||||
|
||||
root.entangledLanes &= remainingLanes;
|
||||
|
||||
@@ -401,7 +401,6 @@ export function markStarvedLanesAsExpired(
|
||||
// expiration time. If so, we'll assume the update is being starved and mark
|
||||
// it as expired to force it to finish.
|
||||
let lanes = pendingLanes;
|
||||
let expiredLanes = 0;
|
||||
while (lanes > 0) {
|
||||
const index = pickArbitraryLaneIndex(lanes);
|
||||
const lane = 1 << index;
|
||||
@@ -420,15 +419,11 @@ export function markStarvedLanesAsExpired(
|
||||
}
|
||||
} else if (expirationTime <= currentTime) {
|
||||
// This lane expired
|
||||
expiredLanes |= lane;
|
||||
root.expiredLanes |= lane;
|
||||
}
|
||||
|
||||
lanes &= ~lane;
|
||||
}
|
||||
|
||||
if (expiredLanes !== 0) {
|
||||
markRootExpired(root, expiredLanes);
|
||||
}
|
||||
}
|
||||
|
||||
// This returns the highest priority pending lanes regardless of whether they
|
||||
@@ -459,16 +454,22 @@ export function includesOnlyTransitions(lanes: Lanes) {
|
||||
}
|
||||
|
||||
export function shouldTimeSlice(root: FiberRoot, lanes: Lanes) {
|
||||
if (!enableSyncDefaultUpdates) {
|
||||
if ((lanes & root.expiredLanes) !== NoLanes) {
|
||||
// At least one of these lanes expired. To prevent additional starvation,
|
||||
// finish rendering without yielding execution.
|
||||
return false;
|
||||
}
|
||||
if (enableSyncDefaultUpdates) {
|
||||
const SyncDefaultLanes =
|
||||
InputContinuousHydrationLane |
|
||||
InputContinuousLane |
|
||||
DefaultHydrationLane |
|
||||
DefaultLane;
|
||||
// TODO: Check for root override, once that lands
|
||||
return (lanes & SyncDefaultLanes) === NoLanes;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
const SyncDefaultLanes =
|
||||
InputContinuousHydrationLane |
|
||||
InputContinuousLane |
|
||||
DefaultHydrationLane |
|
||||
DefaultLane;
|
||||
// TODO: Check for root override, once that lands
|
||||
return (lanes & SyncDefaultLanes) === NoLanes;
|
||||
}
|
||||
|
||||
export function isTransitionLane(lane: Lane) {
|
||||
@@ -613,14 +614,6 @@ export function markRootPinged(
|
||||
root.pingedLanes |= root.suspendedLanes & pingedLanes;
|
||||
}
|
||||
|
||||
export function markRootExpired(root: FiberRoot, expiredLanes: Lanes) {
|
||||
const entanglements = root.entanglements;
|
||||
const SyncLaneIndex = 0;
|
||||
entanglements[SyncLaneIndex] |= expiredLanes;
|
||||
root.entangledLanes |= SyncLane;
|
||||
root.pendingLanes |= SyncLane;
|
||||
}
|
||||
|
||||
export function markRootMutableRead(root: FiberRoot, updateLane: Lane) {
|
||||
root.mutableReadLanes |= updateLane & root.pendingLanes;
|
||||
}
|
||||
@@ -634,6 +627,7 @@ export function markRootFinished(root: FiberRoot, remainingLanes: Lanes) {
|
||||
root.suspendedLanes = 0;
|
||||
root.pingedLanes = 0;
|
||||
|
||||
root.expiredLanes &= remainingLanes;
|
||||
root.mutableReadLanes &= remainingLanes;
|
||||
|
||||
root.entangledLanes &= remainingLanes;
|
||||
|
||||
@@ -50,6 +50,7 @@ function FiberRootNode(containerInfo, tag, hydrate) {
|
||||
this.pendingLanes = NoLanes;
|
||||
this.suspendedLanes = NoLanes;
|
||||
this.pingedLanes = NoLanes;
|
||||
this.expiredLanes = NoLanes;
|
||||
this.mutableReadLanes = NoLanes;
|
||||
this.finishedLanes = NoLanes;
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ function FiberRootNode(containerInfo, tag, hydrate) {
|
||||
this.pendingLanes = NoLanes;
|
||||
this.suspendedLanes = NoLanes;
|
||||
this.pingedLanes = NoLanes;
|
||||
this.expiredLanes = NoLanes;
|
||||
this.mutableReadLanes = NoLanes;
|
||||
this.finishedLanes = NoLanes;
|
||||
|
||||
|
||||
@@ -159,7 +159,7 @@ import {
|
||||
markRootUpdated,
|
||||
markRootSuspended as markRootSuspended_dontCallThisOneDirectly,
|
||||
markRootPinged,
|
||||
markRootExpired,
|
||||
markRootEntangled,
|
||||
markRootFinished,
|
||||
getHighestPriorityLane,
|
||||
addFiberToLanesMap,
|
||||
@@ -787,22 +787,17 @@ function performConcurrentWorkOnRoot(root, didTimeout) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// We disable time-slicing in some cases: if the work has been CPU-bound
|
||||
// for too long ("expired" work, to prevent starvation), or we're in
|
||||
// sync-updates-by-default mode.
|
||||
// TODO: We only check `didTimeout` defensively, to account for a Scheduler
|
||||
// bug we're still investigating. Once the bug in Scheduler is fixed,
|
||||
// we can remove this, since we track expiration ourselves.
|
||||
if (!disableSchedulerTimeoutInWorkLoop && didTimeout) {
|
||||
// Something expired. Flush synchronously until there's no expired
|
||||
// work left.
|
||||
markRootExpired(root, lanes);
|
||||
// This will schedule a synchronous callback.
|
||||
ensureRootIsScheduled(root, now());
|
||||
return null;
|
||||
}
|
||||
|
||||
let exitStatus = shouldTimeSlice(root, lanes)
|
||||
? renderRootConcurrent(root, lanes)
|
||||
: // Time slicing is disabled for default updates in this root.
|
||||
renderRootSync(root, lanes);
|
||||
let exitStatus =
|
||||
shouldTimeSlice(root, lanes) &&
|
||||
(disableSchedulerTimeoutInWorkLoop || !didTimeout)
|
||||
? renderRootConcurrent(root, lanes)
|
||||
: renderRootSync(root, lanes);
|
||||
if (exitStatus !== RootIncomplete) {
|
||||
if (exitStatus === RootErrored) {
|
||||
executionContext |= RetryAfterError;
|
||||
@@ -990,16 +985,7 @@ function performSyncWorkOnRoot(root) {
|
||||
flushPassiveEffects();
|
||||
|
||||
let lanes = getNextLanes(root, NoLanes);
|
||||
if (includesSomeLane(lanes, SyncLane)) {
|
||||
if (
|
||||
root === workInProgressRoot &&
|
||||
includesSomeLane(lanes, workInProgressRootRenderLanes)
|
||||
) {
|
||||
// There's a partial tree, and at least one of its lanes has expired. Finish
|
||||
// rendering it before rendering the rest of the expired work.
|
||||
lanes = workInProgressRootRenderLanes;
|
||||
}
|
||||
} else {
|
||||
if (!includesSomeLane(lanes, SyncLane)) {
|
||||
// There's no remaining sync work left.
|
||||
ensureRootIsScheduled(root, now());
|
||||
return null;
|
||||
@@ -1052,11 +1038,9 @@ function performSyncWorkOnRoot(root) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// TODO: Do we still need this API? I think we can delete it. Was only used
|
||||
// internally.
|
||||
export function flushRoot(root: FiberRoot, lanes: Lanes) {
|
||||
if (lanes !== NoLanes) {
|
||||
markRootExpired(root, lanes);
|
||||
markRootEntangled(root, mergeLanes(lanes, SyncLane));
|
||||
ensureRootIsScheduled(root, now());
|
||||
if ((executionContext & (RenderContext | CommitContext)) === NoContext) {
|
||||
resetRenderTimer();
|
||||
|
||||
@@ -159,7 +159,7 @@ import {
|
||||
markRootUpdated,
|
||||
markRootSuspended as markRootSuspended_dontCallThisOneDirectly,
|
||||
markRootPinged,
|
||||
markRootExpired,
|
||||
markRootEntangled,
|
||||
markRootFinished,
|
||||
getHighestPriorityLane,
|
||||
addFiberToLanesMap,
|
||||
@@ -787,22 +787,17 @@ function performConcurrentWorkOnRoot(root, didTimeout) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// We disable time-slicing in some cases: if the work has been CPU-bound
|
||||
// for too long ("expired" work, to prevent starvation), or we're in
|
||||
// sync-updates-by-default mode.
|
||||
// TODO: We only check `didTimeout` defensively, to account for a Scheduler
|
||||
// bug we're still investigating. Once the bug in Scheduler is fixed,
|
||||
// we can remove this, since we track expiration ourselves.
|
||||
if (!disableSchedulerTimeoutInWorkLoop && didTimeout) {
|
||||
// Something expired. Flush synchronously until there's no expired
|
||||
// work left.
|
||||
markRootExpired(root, lanes);
|
||||
// This will schedule a synchronous callback.
|
||||
ensureRootIsScheduled(root, now());
|
||||
return null;
|
||||
}
|
||||
|
||||
let exitStatus = shouldTimeSlice(root, lanes)
|
||||
? renderRootConcurrent(root, lanes)
|
||||
: // Time slicing is disabled for default updates in this root.
|
||||
renderRootSync(root, lanes);
|
||||
let exitStatus =
|
||||
shouldTimeSlice(root, lanes) &&
|
||||
(disableSchedulerTimeoutInWorkLoop || !didTimeout)
|
||||
? renderRootConcurrent(root, lanes)
|
||||
: renderRootSync(root, lanes);
|
||||
if (exitStatus !== RootIncomplete) {
|
||||
if (exitStatus === RootErrored) {
|
||||
executionContext |= RetryAfterError;
|
||||
@@ -990,16 +985,7 @@ function performSyncWorkOnRoot(root) {
|
||||
flushPassiveEffects();
|
||||
|
||||
let lanes = getNextLanes(root, NoLanes);
|
||||
if (includesSomeLane(lanes, SyncLane)) {
|
||||
if (
|
||||
root === workInProgressRoot &&
|
||||
includesSomeLane(lanes, workInProgressRootRenderLanes)
|
||||
) {
|
||||
// There's a partial tree, and at least one of its lanes has expired. Finish
|
||||
// rendering it before rendering the rest of the expired work.
|
||||
lanes = workInProgressRootRenderLanes;
|
||||
}
|
||||
} else {
|
||||
if (!includesSomeLane(lanes, SyncLane)) {
|
||||
// There's no remaining sync work left.
|
||||
ensureRootIsScheduled(root, now());
|
||||
return null;
|
||||
@@ -1052,11 +1038,9 @@ function performSyncWorkOnRoot(root) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// TODO: Do we still need this API? I think we can delete it. Was only used
|
||||
// internally.
|
||||
export function flushRoot(root: FiberRoot, lanes: Lanes) {
|
||||
if (lanes !== NoLanes) {
|
||||
markRootExpired(root, lanes);
|
||||
markRootEntangled(root, mergeLanes(lanes, SyncLane));
|
||||
ensureRootIsScheduled(root, now());
|
||||
if ((executionContext & (RenderContext | CommitContext)) === NoContext) {
|
||||
resetRenderTimer();
|
||||
|
||||
@@ -228,6 +228,7 @@ type BaseFiberRootProperties = {|
|
||||
pendingLanes: Lanes,
|
||||
suspendedLanes: Lanes,
|
||||
pingedLanes: Lanes,
|
||||
expiredLanes: Lanes,
|
||||
mutableReadLanes: Lanes,
|
||||
|
||||
finishedLanes: Lanes,
|
||||
|
||||
@@ -15,6 +15,8 @@ let Scheduler;
|
||||
let readText;
|
||||
let resolveText;
|
||||
let startTransition;
|
||||
let useState;
|
||||
let useEffect;
|
||||
|
||||
describe('ReactExpiration', () => {
|
||||
beforeEach(() => {
|
||||
@@ -24,6 +26,8 @@ describe('ReactExpiration', () => {
|
||||
ReactNoop = require('react-noop-renderer');
|
||||
Scheduler = require('scheduler');
|
||||
startTransition = React.unstable_startTransition;
|
||||
useState = React.useState;
|
||||
useEffect = React.useEffect;
|
||||
|
||||
const textCache = new Map();
|
||||
|
||||
@@ -478,9 +482,7 @@ describe('ReactExpiration', () => {
|
||||
});
|
||||
|
||||
// @gate experimental || !enableSyncDefaultUpdates
|
||||
it('prevents starvation by sync updates', async () => {
|
||||
const {useState} = React;
|
||||
|
||||
it('prevents starvation by sync updates by disabling time slicing if too much time has elapsed', async () => {
|
||||
let updateSyncPri;
|
||||
let updateNormalPri;
|
||||
function App() {
|
||||
@@ -519,15 +521,17 @@ describe('ReactExpiration', () => {
|
||||
}
|
||||
expect(Scheduler).toFlushAndYieldThrough(['Sync pri: 0']);
|
||||
updateSyncPri();
|
||||
expect(Scheduler).toHaveYielded(['Sync pri: 1', 'Normal pri: 0']);
|
||||
|
||||
// The remaining work hasn't expired, so the render phase is time sliced.
|
||||
// In other words, we can flush just the first child without flushing
|
||||
// the rest.
|
||||
Scheduler.unstable_flushNumberOfYields(1);
|
||||
// Yield right after first child.
|
||||
expect(Scheduler).toHaveYielded(['Sync pri: 1']);
|
||||
// Now do the rest.
|
||||
expect(Scheduler).toFlushAndYield(['Normal pri: 1']);
|
||||
});
|
||||
expect(Scheduler).toHaveYielded([
|
||||
// Interrupt high pri update to render sync update
|
||||
'Sync pri: 1',
|
||||
'Normal pri: 0',
|
||||
// Now render normal pri
|
||||
'Sync pri: 1',
|
||||
'Normal pri: 1',
|
||||
]);
|
||||
expect(root).toMatchRenderedOutput('Sync pri: 1, Normal pri: 1');
|
||||
|
||||
// Do the same thing, but starve the first update
|
||||
@@ -547,22 +551,18 @@ describe('ReactExpiration', () => {
|
||||
// starvation of normal priority updates.)
|
||||
Scheduler.unstable_advanceTime(10000);
|
||||
|
||||
// So when we get a high pri update, we shouldn't interrupt
|
||||
updateSyncPri();
|
||||
expect(Scheduler).toHaveYielded(['Sync pri: 2', 'Normal pri: 1']);
|
||||
|
||||
// The remaining work _has_ expired, so the render phase is _not_ time
|
||||
// sliced. Attempting to flush just the first child also flushes the rest.
|
||||
Scheduler.unstable_flushNumberOfYields(1);
|
||||
expect(Scheduler).toHaveYielded(['Sync pri: 2', 'Normal pri: 2']);
|
||||
});
|
||||
expect(Scheduler).toHaveYielded([
|
||||
// Finish normal pri update
|
||||
'Normal pri: 2',
|
||||
// Then do high pri update
|
||||
'Sync pri: 2',
|
||||
'Normal pri: 2',
|
||||
]);
|
||||
expect(root).toMatchRenderedOutput('Sync pri: 2, Normal pri: 2');
|
||||
});
|
||||
|
||||
it('idle work never expires', async () => {
|
||||
const {useState} = React;
|
||||
|
||||
let updateSyncPri;
|
||||
let updateIdlePri;
|
||||
function App() {
|
||||
@@ -629,23 +629,19 @@ describe('ReactExpiration', () => {
|
||||
});
|
||||
|
||||
// @gate experimental
|
||||
it('a single update can expire without forcing all other updates to expire', async () => {
|
||||
const {useState} = React;
|
||||
|
||||
let updateHighPri;
|
||||
let updateNormalPri;
|
||||
it('when multiple lanes expire, we can finish the in-progress one without including the others', async () => {
|
||||
let setA;
|
||||
let setB;
|
||||
function App() {
|
||||
const [highPri, setHighPri] = useState(0);
|
||||
const [normalPri, setNormalPri] = useState(0);
|
||||
updateHighPri = () => ReactNoop.flushSync(() => setHighPri(n => n + 1));
|
||||
updateNormalPri = () => setNormalPri(n => n + 1);
|
||||
const [a, _setA] = useState(0);
|
||||
const [b, _setB] = useState(0);
|
||||
setA = _setA;
|
||||
setB = _setB;
|
||||
return (
|
||||
<>
|
||||
<Text text={'High pri: ' + highPri} />
|
||||
{', '}
|
||||
<Text text={'Normal pri: ' + normalPri} />
|
||||
{', '}
|
||||
<Text text="Sibling" />
|
||||
<Text text={'A' + a} />
|
||||
<Text text={'B' + b} />
|
||||
<Text text="C" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -654,121 +650,30 @@ describe('ReactExpiration', () => {
|
||||
await ReactNoop.act(async () => {
|
||||
root.render(<App />);
|
||||
});
|
||||
expect(Scheduler).toHaveYielded([
|
||||
'High pri: 0',
|
||||
'Normal pri: 0',
|
||||
'Sibling',
|
||||
]);
|
||||
expect(root).toMatchRenderedOutput('High pri: 0, Normal pri: 0, Sibling');
|
||||
expect(Scheduler).toHaveYielded(['A0', 'B0', 'C']);
|
||||
expect(root).toMatchRenderedOutput('A0B0C');
|
||||
|
||||
await ReactNoop.act(async () => {
|
||||
// Partially render an update
|
||||
startTransition(() => {
|
||||
updateNormalPri();
|
||||
setA(1);
|
||||
});
|
||||
expect(Scheduler).toFlushAndYieldThrough(['High pri: 0']);
|
||||
|
||||
// Some time goes by. Schedule another update.
|
||||
// This will be placed into a separate batch.
|
||||
Scheduler.unstable_advanceTime(4000);
|
||||
|
||||
expect(Scheduler).toFlushAndYieldThrough(['A1']);
|
||||
startTransition(() => {
|
||||
updateNormalPri();
|
||||
setB(1);
|
||||
});
|
||||
// Keep rendering the first update
|
||||
expect(Scheduler).toFlushAndYieldThrough(['Normal pri: 1']);
|
||||
// More time goes by. Enough to expire the first batch, but not the
|
||||
// second one.
|
||||
Scheduler.unstable_advanceTime(1000);
|
||||
// Attempt to interrupt with a high pri update.
|
||||
await ReactNoop.act(async () => {
|
||||
updateHighPri();
|
||||
});
|
||||
|
||||
expect(Scheduler).toHaveYielded([
|
||||
// The first update expired
|
||||
'Sibling',
|
||||
// Then render the high pri update
|
||||
'High pri: 1',
|
||||
'Normal pri: 1',
|
||||
'Sibling',
|
||||
// Then the second normal pri update
|
||||
'High pri: 1',
|
||||
'Normal pri: 2',
|
||||
'Sibling',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// @gate experimental || !enableSyncDefaultUpdates
|
||||
it('detects starvation in multiple batches', async () => {
|
||||
const {useState} = React;
|
||||
|
||||
let updateHighPri;
|
||||
let updateNormalPri;
|
||||
function App() {
|
||||
const [highPri, setHighPri] = useState(0);
|
||||
const [normalPri, setNormalPri] = useState(0);
|
||||
updateHighPri = () => {
|
||||
ReactNoop.flushSync(() => {
|
||||
setHighPri(n => n + 1);
|
||||
});
|
||||
};
|
||||
updateNormalPri = () => setNormalPri(n => n + 1);
|
||||
return (
|
||||
<>
|
||||
<Text text={'High pri: ' + highPri} />
|
||||
{', '}
|
||||
<Text text={'Normal pri: ' + normalPri} />
|
||||
{', '}
|
||||
<Text text="Sibling" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const root = ReactNoop.createRoot();
|
||||
await ReactNoop.act(async () => {
|
||||
root.render(<App />);
|
||||
});
|
||||
expect(Scheduler).toHaveYielded([
|
||||
'High pri: 0',
|
||||
'Normal pri: 0',
|
||||
'Sibling',
|
||||
]);
|
||||
expect(root).toMatchRenderedOutput('High pri: 0, Normal pri: 0, Sibling');
|
||||
|
||||
await ReactNoop.act(async () => {
|
||||
// Partially render an update
|
||||
if (gate(flags => flags.enableSyncDefaultUpdates)) {
|
||||
React.unstable_startTransition(() => {
|
||||
updateNormalPri();
|
||||
});
|
||||
} else {
|
||||
updateNormalPri();
|
||||
}
|
||||
expect(Scheduler).toFlushAndYieldThrough(['High pri: 0']);
|
||||
// Some time goes by. In an interleaved event, schedule another update.
|
||||
// This will be placed into a separate batch.
|
||||
Scheduler.unstable_advanceTime(4000);
|
||||
updateNormalPri();
|
||||
// Keep rendering the first update
|
||||
expect(Scheduler).toFlushAndYieldThrough(['Normal pri: 1']);
|
||||
// More time goes by. This expires both of the updates just scheduled.
|
||||
// Expire both the transitions
|
||||
Scheduler.unstable_advanceTime(10000);
|
||||
expect(Scheduler).toHaveYielded([]);
|
||||
// Both transitions have expired, but since they aren't related
|
||||
// (entangled), we should be able to finish the in-progress transition
|
||||
// without also including the next one.
|
||||
Scheduler.unstable_flushNumberOfYields(1);
|
||||
expect(Scheduler).toHaveYielded(['B0', 'C']);
|
||||
expect(root).toMatchRenderedOutput('A1B0C');
|
||||
|
||||
// Attempt to interrupt with a high pri update.
|
||||
updateHighPri();
|
||||
|
||||
// Both normal pri updates should have expired.
|
||||
// The sync update and the expired normal pri updates render in a
|
||||
// single batch.
|
||||
expect(Scheduler).toHaveYielded([
|
||||
'Sibling',
|
||||
'High pri: 1',
|
||||
'Normal pri: 2',
|
||||
'Sibling',
|
||||
]);
|
||||
// The next transition also finishes without yielding.
|
||||
Scheduler.unstable_flushNumberOfYields(1);
|
||||
expect(Scheduler).toHaveYielded(['A1', 'B1', 'C']);
|
||||
expect(root).toMatchRenderedOutput('A1B1C');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -776,62 +681,137 @@ describe('ReactExpiration', () => {
|
||||
it('updates do not expire while they are IO-bound', async () => {
|
||||
const {Suspense} = React;
|
||||
|
||||
function App({text}) {
|
||||
function App({step}) {
|
||||
return (
|
||||
<Suspense fallback={<Text text="Loading..." />}>
|
||||
<AsyncText text={text} />
|
||||
{', '}
|
||||
<Text text="Sibling" />
|
||||
<AsyncText text={'A' + step} />
|
||||
<Text text="B" />
|
||||
<Text text="C" />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
const root = ReactNoop.createRoot();
|
||||
await ReactNoop.act(async () => {
|
||||
await resolveText('A');
|
||||
root.render(<App text="A" />);
|
||||
await resolveText('A0');
|
||||
root.render(<App step={0} />);
|
||||
});
|
||||
expect(Scheduler).toHaveYielded(['A', 'Sibling']);
|
||||
expect(root).toMatchRenderedOutput('A, Sibling');
|
||||
expect(Scheduler).toHaveYielded(['A0', 'B', 'C']);
|
||||
expect(root).toMatchRenderedOutput('A0BC');
|
||||
|
||||
await ReactNoop.act(async () => {
|
||||
if (gate(flags => flags.enableSyncDefaultUpdates)) {
|
||||
React.unstable_startTransition(() => {
|
||||
root.render(<App text="B" />);
|
||||
root.render(<App step={1} />);
|
||||
});
|
||||
} else {
|
||||
root.render(<App text="B" />);
|
||||
root.render(<App step={1} />);
|
||||
}
|
||||
expect(Scheduler).toFlushAndYield([
|
||||
'Suspend! [B]',
|
||||
'Sibling',
|
||||
'Suspend! [A1]',
|
||||
'B',
|
||||
'C',
|
||||
'Loading...',
|
||||
]);
|
||||
|
||||
// Lots of time elapses before the promise resolves
|
||||
Scheduler.unstable_advanceTime(10000);
|
||||
await resolveText('B');
|
||||
expect(Scheduler).toHaveYielded(['Promise resolved [B]']);
|
||||
await resolveText('A1');
|
||||
expect(Scheduler).toHaveYielded(['Promise resolved [A1]']);
|
||||
|
||||
// But the update doesn't expire, because it was IO bound. So we can
|
||||
// partially rendering without finishing.
|
||||
expect(Scheduler).toFlushAndYieldThrough(['B']);
|
||||
expect(root).toMatchRenderedOutput('A, Sibling');
|
||||
expect(Scheduler).toFlushAndYieldThrough(['A1']);
|
||||
expect(root).toMatchRenderedOutput('A0BC');
|
||||
|
||||
// Lots more time elapses. We're CPU-bound now, so we should treat this
|
||||
// as starvation.
|
||||
Scheduler.unstable_advanceTime(10000);
|
||||
|
||||
// Attempt to interrupt with a sync update.
|
||||
ReactNoop.flushSync(() => root.render(<App text="A" />));
|
||||
expect(Scheduler).toHaveYielded([
|
||||
// Because the previous update had already expired, we don't interrupt
|
||||
// it. Finish rendering it first.
|
||||
'Sibling',
|
||||
// Then do the sync update.
|
||||
'A',
|
||||
'Sibling',
|
||||
]);
|
||||
// The rest of the update finishes without yielding.
|
||||
Scheduler.unstable_flushNumberOfYields(1);
|
||||
expect(Scheduler).toHaveYielded(['B', 'C']);
|
||||
});
|
||||
});
|
||||
|
||||
// @gate experimental
|
||||
it('flushSync should not affect expired work', async () => {
|
||||
let setA;
|
||||
let setB;
|
||||
function App() {
|
||||
const [a, _setA] = useState(0);
|
||||
const [b, _setB] = useState(0);
|
||||
setA = _setA;
|
||||
setB = _setB;
|
||||
return (
|
||||
<>
|
||||
<Text text={'A' + a} />
|
||||
<Text text={'B' + b} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const root = ReactNoop.createRoot();
|
||||
await ReactNoop.act(async () => {
|
||||
root.render(<App />);
|
||||
});
|
||||
expect(Scheduler).toHaveYielded(['A0', 'B0']);
|
||||
|
||||
await ReactNoop.act(async () => {
|
||||
startTransition(() => {
|
||||
setA(1);
|
||||
});
|
||||
expect(Scheduler).toFlushAndYieldThrough(['A1']);
|
||||
|
||||
// Expire the in-progress update
|
||||
Scheduler.unstable_advanceTime(10000);
|
||||
|
||||
ReactNoop.flushSync(() => {
|
||||
setB(1);
|
||||
});
|
||||
expect(Scheduler).toHaveYielded(['A0', 'B1']);
|
||||
|
||||
// Now flush the original update. Because it expired, it should finish
|
||||
// without yielding.
|
||||
Scheduler.unstable_flushNumberOfYields(1);
|
||||
expect(Scheduler).toHaveYielded(['A1', 'B1']);
|
||||
});
|
||||
});
|
||||
|
||||
// @gate experimental
|
||||
it('passive effects of expired update flush after paint', async () => {
|
||||
function App({step}) {
|
||||
useEffect(() => {
|
||||
Scheduler.unstable_yieldValue('Effect: ' + step);
|
||||
}, [step]);
|
||||
return (
|
||||
<>
|
||||
<Text text={'A' + step} />
|
||||
<Text text={'B' + step} />
|
||||
<Text text={'C' + step} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const root = ReactNoop.createRoot();
|
||||
await ReactNoop.act(async () => {
|
||||
root.render(<App step={0} />);
|
||||
});
|
||||
expect(Scheduler).toHaveYielded(['A0', 'B0', 'C0', 'Effect: 0']);
|
||||
expect(root).toMatchRenderedOutput('A0B0C0');
|
||||
|
||||
await ReactNoop.act(async () => {
|
||||
startTransition(() => {
|
||||
root.render(<App step={1} />);
|
||||
});
|
||||
// Expire the update
|
||||
Scheduler.unstable_advanceTime(10000);
|
||||
|
||||
// The update finishes without yielding. But it does not flush the effect.
|
||||
Scheduler.unstable_flushNumberOfYields(1);
|
||||
expect(Scheduler).toHaveYielded(['A1', 'B1', 'C1']);
|
||||
});
|
||||
// The effect flushes after paint.
|
||||
expect(Scheduler).toHaveYielded(['Effect: 1']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -300,10 +300,9 @@ describe(
|
||||
ReactNoop.render(<App />);
|
||||
});
|
||||
|
||||
ReactNoop.flushSync();
|
||||
|
||||
// Because the render expired, React should finish the tree without
|
||||
// consulting `shouldYield` again
|
||||
Scheduler.unstable_flushNumberOfYields(1);
|
||||
expect(Scheduler).toHaveYielded(['B', 'C']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1958,32 +1958,13 @@ describe('ReactSuspenseWithNoopRenderer', () => {
|
||||
await advanceTimers(5000);
|
||||
|
||||
// Retry with the new content.
|
||||
if (gate(flags => flags.disableSchedulerTimeoutInWorkLoop)) {
|
||||
expect(Scheduler).toFlushAndYield([
|
||||
'A',
|
||||
// B still suspends
|
||||
'Suspend! [B]',
|
||||
'Loading more...',
|
||||
]);
|
||||
} else {
|
||||
// In this branch, right as we start rendering, we detect that the work
|
||||
// has expired (via Scheduler's didTimeout argument) and re-schedule the
|
||||
// work as synchronous. Since sync work does not flow through Scheduler,
|
||||
// we need to use `flushSync`.
|
||||
//
|
||||
// Usually we would use `act`, which fluses both sync work and Scheduler
|
||||
// work, but that would also force the fallback to display, and this test
|
||||
// is specifically about whether we delay or show the fallback.
|
||||
expect(Scheduler).toFlushAndYield([]);
|
||||
// This will flush the synchronous callback we just scheduled.
|
||||
ReactNoop.flushSync();
|
||||
expect(Scheduler).toHaveYielded([
|
||||
'A',
|
||||
// B still suspends
|
||||
'Suspend! [B]',
|
||||
'Loading more...',
|
||||
]);
|
||||
}
|
||||
expect(Scheduler).toFlushAndYield([
|
||||
'A',
|
||||
// B still suspends
|
||||
'Suspend! [B]',
|
||||
'Loading more...',
|
||||
]);
|
||||
|
||||
// Because we've already been waiting for so long we've exceeded
|
||||
// our threshold and we show the next level immediately.
|
||||
expect(ReactNoop.getChildren()).toEqual([
|
||||
|
||||
Reference in New Issue
Block a user