Resume immediately pinged fiber without unwinding (#25074)

* Yield to main thread if continuation is returned

Instead of using an imperative method `requestYield` to ask Scheduler to
yield to the main thread, we can assume that any time a Scheduler task
returns a continuation callback, it's because it wants to yield to the
main thread. We can assume the task already checked some condition that
caused it to return a continuation, so we don't need to do any
additional checks — we can immediately yield and schedule a new task
for the continuation.

The replaces the `requestYield` API that I added in ca990e9.

* Move unwind after error into main work loop

I need to be able to yield to the main thread in between when an error
is thrown and when the stack is unwound. (This is the motivation behind
the refactor, but it isn't implemented in this commit.) Currently the
unwind is inlined directly into `handleError`.

Instead, I've moved the unwind logic into the main work loop. At the
very beginning of the function, we check to see if the work-in-progress
is in a "suspended" state — that is, whether it needs to be unwound. If
it is, we will enter the unwind phase instead of the begin phase.

We only need to perform this check when we first enter the work loop:
at the beginning of a Scheduler chunk, or after something throws. We
don't need to perform it after every unit of work.

* Yield to main thread whenever a fiber suspends

When a fiber suspends, we should yield to the main thread in case the
data is already cached, to unblock a potential ping event.

By itself, this commit isn't useful because we don't do anything special
in the case where to do receive an immediate ping event. I've split this
out only to demonstrate that it doesn't break any existing behavior.

See the next commit for full context and motivation.

* Resume immediately pinged fiber without unwinding

If a fiber suspends, and is pinged immediately in a microtask (or a
regular task that fires before React resumes rendering), try rendering
the same fiber again without unwinding the stack. This can be super
helpful when working with promises and async-await, because even if the
outermost promise hasn't been cached before, the underlying data may
have been preloaded. In many cases, we can continue rendering
immediately without having to show a fallback.

This optimization should work during any concurrent (time-sliced)
render. It doesn't work during discrete updates because those are
semantically required to finish synchronously — those get the current
behavior.
This commit is contained in:
Andrew Clark 2022-08-11 22:01:56 -04:00 committed by GitHub
parent 4e60dbd905
commit 8ef3a7c08c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 538 additions and 199 deletions

View File

@ -357,7 +357,7 @@ function throwException(
sourceFiber: Fiber,
value: mixed,
rootRenderLanes: Lanes,
) {
): Wakeable | null {
// The source fiber did not complete.
sourceFiber.flags |= Incomplete;
@ -459,7 +459,7 @@ function throwException(
if (suspenseBoundary.mode & ConcurrentMode) {
attachPingListener(root, wakeable, rootRenderLanes);
}
return;
return wakeable;
} else {
// No boundary was found. Unless this is a sync update, this is OK.
// We can suspend and wait for more data to arrive.
@ -474,7 +474,7 @@ function throwException(
// This case also applies to initial hydration.
attachPingListener(root, wakeable, rootRenderLanes);
renderDidSuspendDelayIfPossible();
return;
return wakeable;
}
// This is a sync/discrete update. We treat this case like an error
@ -517,7 +517,7 @@ function throwException(
// Even though the user may not be affected by this error, we should
// still log it so it can be fixed.
queueHydrationError(createCapturedValueAtFiber(value, sourceFiber));
return;
return null;
}
} else {
// Otherwise, fall through to the error path.
@ -540,7 +540,7 @@ function throwException(
workInProgress.lanes = mergeLanes(workInProgress.lanes, lane);
const update = createRootErrorUpdate(workInProgress, errorInfo, lane);
enqueueCapturedUpdate(workInProgress, update);
return;
return null;
}
case ClassComponent:
// Capture and retry
@ -564,7 +564,7 @@ function throwException(
lane,
);
enqueueCapturedUpdate(workInProgress, update);
return;
return null;
}
break;
default:
@ -572,6 +572,7 @@ function throwException(
}
workInProgress = workInProgress.return;
} while (workInProgress !== null);
return null;
}
export {throwException, createRootErrorUpdate, createClassErrorUpdate};

View File

@ -357,7 +357,7 @@ function throwException(
sourceFiber: Fiber,
value: mixed,
rootRenderLanes: Lanes,
) {
): Wakeable | null {
// The source fiber did not complete.
sourceFiber.flags |= Incomplete;
@ -459,7 +459,7 @@ function throwException(
if (suspenseBoundary.mode & ConcurrentMode) {
attachPingListener(root, wakeable, rootRenderLanes);
}
return;
return wakeable;
} else {
// No boundary was found. Unless this is a sync update, this is OK.
// We can suspend and wait for more data to arrive.
@ -474,7 +474,7 @@ function throwException(
// This case also applies to initial hydration.
attachPingListener(root, wakeable, rootRenderLanes);
renderDidSuspendDelayIfPossible();
return;
return wakeable;
}
// This is a sync/discrete update. We treat this case like an error
@ -517,7 +517,7 @@ function throwException(
// Even though the user may not be affected by this error, we should
// still log it so it can be fixed.
queueHydrationError(createCapturedValueAtFiber(value, sourceFiber));
return;
return null;
}
} else {
// Otherwise, fall through to the error path.
@ -540,7 +540,7 @@ function throwException(
workInProgress.lanes = mergeLanes(workInProgress.lanes, lane);
const update = createRootErrorUpdate(workInProgress, errorInfo, lane);
enqueueCapturedUpdate(workInProgress, update);
return;
return null;
}
case ClassComponent:
// Capture and retry
@ -564,7 +564,7 @@ function throwException(
lane,
);
enqueueCapturedUpdate(workInProgress, update);
return;
return null;
}
break;
default:
@ -572,6 +572,7 @@ function throwException(
}
workInProgress = workInProgress.return;
} while (workInProgress !== null);
return null;
}
export {throwException, createRootErrorUpdate, createClassErrorUpdate};

View File

@ -0,0 +1,50 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import type {Wakeable} from 'shared/ReactTypes';
let suspendedWakeable: Wakeable | null = null;
let wasPinged = false;
let adHocSuspendCount: number = 0;
const MAX_AD_HOC_SUSPEND_COUNT = 50;
export function suspendedWakeableWasPinged() {
return wasPinged;
}
export function trackSuspendedWakeable(wakeable: Wakeable) {
adHocSuspendCount++;
suspendedWakeable = wakeable;
}
export function attemptToPingSuspendedWakeable(wakeable: Wakeable) {
if (wakeable === suspendedWakeable) {
// This ping is from the wakeable that just suspended. Mark it as pinged.
// When the work loop resumes, we'll immediately try rendering the fiber
// again instead of unwinding the stack.
wasPinged = true;
return true;
}
return false;
}
export function resetWakeableState() {
suspendedWakeable = null;
wasPinged = false;
adHocSuspendCount = 0;
}
export function throwIfInfinitePingLoopDetected() {
if (adHocSuspendCount > MAX_AD_HOC_SUSPEND_COUNT) {
// TODO: Guard against an infinite loop by throwing an error if the same
// component suspends too many times in a row. This should be thrown from
// the render phase so that it gets the component stack.
}
}

View File

@ -0,0 +1,50 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import type {Wakeable} from 'shared/ReactTypes';
let suspendedWakeable: Wakeable | null = null;
let wasPinged = false;
let adHocSuspendCount: number = 0;
const MAX_AD_HOC_SUSPEND_COUNT = 50;
export function suspendedWakeableWasPinged() {
return wasPinged;
}
export function trackSuspendedWakeable(wakeable: Wakeable) {
adHocSuspendCount++;
suspendedWakeable = wakeable;
}
export function attemptToPingSuspendedWakeable(wakeable: Wakeable) {
if (wakeable === suspendedWakeable) {
// This ping is from the wakeable that just suspended. Mark it as pinged.
// When the work loop resumes, we'll immediately try rendering the fiber
// again instead of unwinding the stack.
wasPinged = true;
return true;
}
return false;
}
export function resetWakeableState() {
suspendedWakeable = null;
wasPinged = false;
adHocSuspendCount = 0;
}
export function throwIfInfinitePingLoopDetected() {
if (adHocSuspendCount > MAX_AD_HOC_SUSPEND_COUNT) {
// TODO: Guard against an infinite loop by throwing an error if the same
// component suspends too many times in a row. This should be thrown from
// the render phase so that it gets the component stack.
}
}

View File

@ -86,6 +86,7 @@ import {
import {
createWorkInProgress,
assignFiberPropertiesInDEV,
resetWorkInProgress,
} from './ReactFiber.new';
import {isRootDehydrated} from './ReactFiberShellHydration';
import {didSuspendOrErrorWhileHydratingDEV} from './ReactFiberHydrationContext.new';
@ -245,6 +246,12 @@ import {
isConcurrentActEnvironment,
} from './ReactFiberAct.new';
import {processTransitionCallbacks} from './ReactFiberTracingMarkerComponent.new';
import {
resetWakeableState,
trackSuspendedWakeable,
suspendedWakeableWasPinged,
attemptToPingSuspendedWakeable,
} from './ReactFiberWakeable.new';
const ceil = Math.ceil;
@ -280,6 +287,12 @@ let workInProgress: Fiber | null = null;
// The lanes we're rendering
let workInProgressRootRenderLanes: Lanes = NoLanes;
// When this is true, the work-in-progress fiber just suspended (or errored) and
// we've yet to unwind the stack. In some cases, we may yield to the main thread
// after this happens. If the fiber is pinged before we resume, we can retry
// immediately instead of unwinding the stack.
let workInProgressIsSuspended: boolean = false;
// A contextual version of workInProgressRootRenderLanes. It is a superset of
// the lanes that we started working on at the root. When we enter a subtree
// that is currently hidden, we add the lanes that would have committed if
@ -1543,11 +1556,13 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
);
interruptedWork = interruptedWork.return;
}
resetWakeableState();
}
workInProgressRoot = root;
const rootWorkInProgress = createWorkInProgress(root.current, null);
workInProgress = rootWorkInProgress;
workInProgressRootRenderLanes = renderLanes = lanes;
workInProgressIsSuspended = false;
workInProgressRootExitStatus = RootInProgress;
workInProgressRootFatalError = null;
workInProgressRootSkippedLanes = NoLanes;
@ -1566,7 +1581,7 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
return rootWorkInProgress;
}
function handleError(root, thrownValue): void {
function handleError(root, thrownValue): Wakeable | null {
do {
let erroredWork = workInProgress;
try {
@ -1592,7 +1607,7 @@ function handleError(root, thrownValue): void {
// intentionally not calling those, we need set it here.
// TODO: Consider calling `unwindWork` to pop the contexts.
workInProgress = null;
return;
return null;
}
if (enableProfilerTimer && erroredWork.mode & ProfileMode) {
@ -1625,14 +1640,21 @@ function handleError(root, thrownValue): void {
}
}
throwException(
const maybeWakeable = throwException(
root,
erroredWork.return,
erroredWork,
thrownValue,
workInProgressRootRenderLanes,
);
completeUnitOfWork(erroredWork);
// Setting this to `true` tells the work loop to unwind the stack instead
// of entering the begin phase. It's called "suspended" because it usually
// happens because of Suspense, but it also applies to errors. Think of it
// as suspending the execution of the work loop.
workInProgressIsSuspended = true;
// Return to the normal work loop.
return maybeWakeable;
} catch (yetAnotherThrownValue) {
// Something in the return path also threw.
thrownValue = yetAnotherThrownValue;
@ -1646,8 +1668,6 @@ function handleError(root, thrownValue): void {
}
continue;
}
// Return to the normal work loop.
return;
} while (true);
}
@ -1810,7 +1830,14 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) {
// The work loop is an extremely hot path. Tell Closure not to inline it.
/** @noinline */
function workLoopSync() {
// Already timed out, so perform work without checking if we need to yield.
// Perform work without checking if we need to yield between fiber.
if (workInProgressIsSuspended && workInProgress !== null) {
// The current work-in-progress was already attempted. We need to unwind
// it before we continue the normal work loop.
resumeSuspendedUnitOfWork(workInProgress);
}
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
@ -1860,7 +1887,14 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
workLoopConcurrent();
break;
} catch (thrownValue) {
handleError(root, thrownValue);
const maybeWakeable = handleError(root, thrownValue);
if (maybeWakeable !== null) {
// If this fiber just suspended, it's possible the data is already
// cached. Yield to the the main thread to give it a chance to ping. If
// it does, we can retry immediately without unwinding the stack.
trackSuspendedWakeable(maybeWakeable);
break;
}
}
} while (true);
resetContextDependencies();
@ -1899,6 +1933,13 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
/** @noinline */
function workLoopConcurrent() {
// Perform work until Scheduler asks us to yield
if (workInProgressIsSuspended && workInProgress !== null) {
// The current work-in-progress was already attempted. We need to unwind
// it before we continue the normal work loop.
resumeSuspendedUnitOfWork(workInProgress);
}
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
@ -1932,6 +1973,56 @@ function performUnitOfWork(unitOfWork: Fiber): void {
ReactCurrentOwner.current = null;
}
function resumeSuspendedUnitOfWork(unitOfWork: Fiber): void {
// This is a fork of performUnitOfWork specifcally for resuming a fiber that
// just suspended. In some cases, we may choose to retry the fiber immediately
// instead of unwinding the stack. It's a separate function to keep the
// additional logic out of the work loop's hot path.
if (!suspendedWakeableWasPinged()) {
// The wakeable wasn't pinged. Return to the normal work loop. This will
// unwind the stack, and potentially result in showing a fallback.
workInProgressIsSuspended = false;
resetWakeableState();
completeUnitOfWork(unitOfWork);
return;
}
// The work-in-progress was immediately pinged. Instead of unwinding the
// stack and potentially showing a fallback, reset the fiber and try rendering
// it again.
unitOfWork = workInProgress = resetWorkInProgress(unitOfWork, renderLanes);
const current = unitOfWork.alternate;
setCurrentDebugFiberInDEV(unitOfWork);
let next;
if (enableProfilerTimer && (unitOfWork.mode & ProfileMode) !== NoMode) {
startProfilerTimer(unitOfWork);
next = beginWork(current, unitOfWork, renderLanes);
stopProfilerTimerIfRunningAndRecordDelta(unitOfWork, true);
} else {
next = beginWork(current, unitOfWork, renderLanes);
}
// The begin phase finished successfully without suspending. Reset the state
// used to track the fiber while it was suspended. Then return to the normal
// work loop.
workInProgressIsSuspended = false;
resetWakeableState();
resetCurrentDebugFiberInDEV();
unitOfWork.memoizedProps = unitOfWork.pendingProps;
if (next === null) {
// If this doesn't spawn new work, complete the current work.
completeUnitOfWork(unitOfWork);
} else {
workInProgress = next;
}
ReactCurrentOwner.current = null;
}
function completeUnitOfWork(unitOfWork: Fiber): void {
// Attempt to complete the current unit of work, then move to the next
// sibling. If there are no more siblings, return to the parent fiber.
@ -2743,27 +2834,31 @@ export function pingSuspendedRoot(
// Received a ping at the same priority level at which we're currently
// rendering. We might want to restart this render. This should mirror
// the logic of whether or not a root suspends once it completes.
// TODO: If we're rendering sync either due to Sync, Batched or expired,
// we should probably never restart.
// If we're suspended with delay, or if it's a retry, we'll always suspend
// so we can always restart.
if (
workInProgressRootExitStatus === RootSuspendedWithDelay ||
(workInProgressRootExitStatus === RootSuspended &&
includesOnlyRetries(workInProgressRootRenderLanes) &&
now() - globalMostRecentFallbackTime < FALLBACK_THROTTLE_MS)
) {
// Restart from the root.
prepareFreshStack(root, NoLanes);
const didPingSuspendedWakeable = attemptToPingSuspendedWakeable(wakeable);
if (didPingSuspendedWakeable) {
// Successfully pinged the in-progress fiber. Don't unwind the stack.
} else {
// Even though we can't restart right now, we might get an
// opportunity later. So we mark this render as having a ping.
workInProgressRootPingedLanes = mergeLanes(
workInProgressRootPingedLanes,
pingedLanes,
);
// TODO: If we're rendering sync either due to Sync, Batched or expired,
// we should probably never restart.
// If we're suspended with delay, or if it's a retry, we'll always suspend
// so we can always restart.
if (
workInProgressRootExitStatus === RootSuspendedWithDelay ||
(workInProgressRootExitStatus === RootSuspended &&
includesOnlyRetries(workInProgressRootRenderLanes) &&
now() - globalMostRecentFallbackTime < FALLBACK_THROTTLE_MS)
) {
// Restart from the root.
prepareFreshStack(root, NoLanes);
} else {
// Even though we can't restart right now, we might get an
// opportunity later. So we mark this render as having a ping.
workInProgressRootPingedLanes = mergeLanes(
workInProgressRootPingedLanes,
pingedLanes,
);
}
}
}

View File

@ -86,6 +86,7 @@ import {
import {
createWorkInProgress,
assignFiberPropertiesInDEV,
resetWorkInProgress,
} from './ReactFiber.old';
import {isRootDehydrated} from './ReactFiberShellHydration';
import {didSuspendOrErrorWhileHydratingDEV} from './ReactFiberHydrationContext.old';
@ -245,6 +246,12 @@ import {
isConcurrentActEnvironment,
} from './ReactFiberAct.old';
import {processTransitionCallbacks} from './ReactFiberTracingMarkerComponent.old';
import {
resetWakeableState,
trackSuspendedWakeable,
suspendedWakeableWasPinged,
attemptToPingSuspendedWakeable,
} from './ReactFiberWakeable.old';
const ceil = Math.ceil;
@ -280,6 +287,12 @@ let workInProgress: Fiber | null = null;
// The lanes we're rendering
let workInProgressRootRenderLanes: Lanes = NoLanes;
// When this is true, the work-in-progress fiber just suspended (or errored) and
// we've yet to unwind the stack. In some cases, we may yield to the main thread
// after this happens. If the fiber is pinged before we resume, we can retry
// immediately instead of unwinding the stack.
let workInProgressIsSuspended: boolean = false;
// A contextual version of workInProgressRootRenderLanes. It is a superset of
// the lanes that we started working on at the root. When we enter a subtree
// that is currently hidden, we add the lanes that would have committed if
@ -1543,11 +1556,13 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
);
interruptedWork = interruptedWork.return;
}
resetWakeableState();
}
workInProgressRoot = root;
const rootWorkInProgress = createWorkInProgress(root.current, null);
workInProgress = rootWorkInProgress;
workInProgressRootRenderLanes = renderLanes = lanes;
workInProgressIsSuspended = false;
workInProgressRootExitStatus = RootInProgress;
workInProgressRootFatalError = null;
workInProgressRootSkippedLanes = NoLanes;
@ -1566,7 +1581,7 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
return rootWorkInProgress;
}
function handleError(root, thrownValue): void {
function handleError(root, thrownValue): Wakeable | null {
do {
let erroredWork = workInProgress;
try {
@ -1592,7 +1607,7 @@ function handleError(root, thrownValue): void {
// intentionally not calling those, we need set it here.
// TODO: Consider calling `unwindWork` to pop the contexts.
workInProgress = null;
return;
return null;
}
if (enableProfilerTimer && erroredWork.mode & ProfileMode) {
@ -1625,14 +1640,21 @@ function handleError(root, thrownValue): void {
}
}
throwException(
const maybeWakeable = throwException(
root,
erroredWork.return,
erroredWork,
thrownValue,
workInProgressRootRenderLanes,
);
completeUnitOfWork(erroredWork);
// Setting this to `true` tells the work loop to unwind the stack instead
// of entering the begin phase. It's called "suspended" because it usually
// happens because of Suspense, but it also applies to errors. Think of it
// as suspending the execution of the work loop.
workInProgressIsSuspended = true;
// Return to the normal work loop.
return maybeWakeable;
} catch (yetAnotherThrownValue) {
// Something in the return path also threw.
thrownValue = yetAnotherThrownValue;
@ -1646,8 +1668,6 @@ function handleError(root, thrownValue): void {
}
continue;
}
// Return to the normal work loop.
return;
} while (true);
}
@ -1810,7 +1830,14 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) {
// The work loop is an extremely hot path. Tell Closure not to inline it.
/** @noinline */
function workLoopSync() {
// Already timed out, so perform work without checking if we need to yield.
// Perform work without checking if we need to yield between fiber.
if (workInProgressIsSuspended && workInProgress !== null) {
// The current work-in-progress was already attempted. We need to unwind
// it before we continue the normal work loop.
resumeSuspendedUnitOfWork(workInProgress);
}
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
@ -1860,7 +1887,14 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
workLoopConcurrent();
break;
} catch (thrownValue) {
handleError(root, thrownValue);
const maybeWakeable = handleError(root, thrownValue);
if (maybeWakeable !== null) {
// If this fiber just suspended, it's possible the data is already
// cached. Yield to the the main thread to give it a chance to ping. If
// it does, we can retry immediately without unwinding the stack.
trackSuspendedWakeable(maybeWakeable);
break;
}
}
} while (true);
resetContextDependencies();
@ -1899,6 +1933,13 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
/** @noinline */
function workLoopConcurrent() {
// Perform work until Scheduler asks us to yield
if (workInProgressIsSuspended && workInProgress !== null) {
// The current work-in-progress was already attempted. We need to unwind
// it before we continue the normal work loop.
resumeSuspendedUnitOfWork(workInProgress);
}
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
@ -1932,6 +1973,56 @@ function performUnitOfWork(unitOfWork: Fiber): void {
ReactCurrentOwner.current = null;
}
function resumeSuspendedUnitOfWork(unitOfWork: Fiber): void {
// This is a fork of performUnitOfWork specifcally for resuming a fiber that
// just suspended. In some cases, we may choose to retry the fiber immediately
// instead of unwinding the stack. It's a separate function to keep the
// additional logic out of the work loop's hot path.
if (!suspendedWakeableWasPinged()) {
// The wakeable wasn't pinged. Return to the normal work loop. This will
// unwind the stack, and potentially result in showing a fallback.
workInProgressIsSuspended = false;
resetWakeableState();
completeUnitOfWork(unitOfWork);
return;
}
// The work-in-progress was immediately pinged. Instead of unwinding the
// stack and potentially showing a fallback, reset the fiber and try rendering
// it again.
unitOfWork = workInProgress = resetWorkInProgress(unitOfWork, renderLanes);
const current = unitOfWork.alternate;
setCurrentDebugFiberInDEV(unitOfWork);
let next;
if (enableProfilerTimer && (unitOfWork.mode & ProfileMode) !== NoMode) {
startProfilerTimer(unitOfWork);
next = beginWork(current, unitOfWork, renderLanes);
stopProfilerTimerIfRunningAndRecordDelta(unitOfWork, true);
} else {
next = beginWork(current, unitOfWork, renderLanes);
}
// The begin phase finished successfully without suspending. Reset the state
// used to track the fiber while it was suspended. Then return to the normal
// work loop.
workInProgressIsSuspended = false;
resetWakeableState();
resetCurrentDebugFiberInDEV();
unitOfWork.memoizedProps = unitOfWork.pendingProps;
if (next === null) {
// If this doesn't spawn new work, complete the current work.
completeUnitOfWork(unitOfWork);
} else {
workInProgress = next;
}
ReactCurrentOwner.current = null;
}
function completeUnitOfWork(unitOfWork: Fiber): void {
// Attempt to complete the current unit of work, then move to the next
// sibling. If there are no more siblings, return to the parent fiber.
@ -2743,27 +2834,31 @@ export function pingSuspendedRoot(
// Received a ping at the same priority level at which we're currently
// rendering. We might want to restart this render. This should mirror
// the logic of whether or not a root suspends once it completes.
// TODO: If we're rendering sync either due to Sync, Batched or expired,
// we should probably never restart.
// If we're suspended with delay, or if it's a retry, we'll always suspend
// so we can always restart.
if (
workInProgressRootExitStatus === RootSuspendedWithDelay ||
(workInProgressRootExitStatus === RootSuspended &&
includesOnlyRetries(workInProgressRootRenderLanes) &&
now() - globalMostRecentFallbackTime < FALLBACK_THROTTLE_MS)
) {
// Restart from the root.
prepareFreshStack(root, NoLanes);
const didPingSuspendedWakeable = attemptToPingSuspendedWakeable(wakeable);
if (didPingSuspendedWakeable) {
// Successfully pinged the in-progress fiber. Don't unwind the stack.
} else {
// Even though we can't restart right now, we might get an
// opportunity later. So we mark this render as having a ping.
workInProgressRootPingedLanes = mergeLanes(
workInProgressRootPingedLanes,
pingedLanes,
);
// TODO: If we're rendering sync either due to Sync, Batched or expired,
// we should probably never restart.
// If we're suspended with delay, or if it's a retry, we'll always suspend
// so we can always restart.
if (
workInProgressRootExitStatus === RootSuspendedWithDelay ||
(workInProgressRootExitStatus === RootSuspended &&
includesOnlyRetries(workInProgressRootRenderLanes) &&
now() - globalMostRecentFallbackTime < FALLBACK_THROTTLE_MS)
) {
// Restart from the root.
prepareFreshStack(root, NoLanes);
} else {
// Even though we can't restart right now, we might get an
// opportunity later. So we mark this render as having a ping.
workInProgressRootPingedLanes = mergeLanes(
workInProgressRootPingedLanes,
pingedLanes,
);
}
}
}

View File

@ -485,22 +485,33 @@ describe('ReactOffscreen', () => {
// In the same render, also hide the offscreen tree.
root.render(<App show={false} />);
expect(Scheduler).toFlushUntilNextPaint([
// The outer update will commit, but the inner update is deferred until
// a later render.
'Outer: 1',
if (gate(flags => flags.enableSyncDefaultUpdates)) {
expect(Scheduler).toFlushUntilNextPaint([
// 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]',
// 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...',
]);
} else {
// When default updates are time sliced, React yields before preparing
// the fallback.
expect(Scheduler).toFlushUntilNextPaint([
'Outer: 1',
'Suspend! [Async: 1]',
]);
expect(Scheduler).toFlushUntilNextPaint(['Loading...']);
}
'Loading...',
]);
// Assert that we haven't committed quite yet
expect(root).toMatchRenderedOutput(
<>

View File

@ -963,33 +963,36 @@ describe('ReactSuspenseWithNoopRenderer', () => {
// @gate enableCache
it('resolves successfully even if fallback render is pending', async () => {
ReactNoop.render(
const root = ReactNoop.createRoot();
root.render(
<>
<Suspense fallback={<Text text="Loading..." />} />
</>,
);
expect(Scheduler).toFlushAndYield([]);
expect(ReactNoop.getChildren()).toEqual([]);
expect(root).toMatchRenderedOutput(null);
if (gate(flags => flags.enableSyncDefaultUpdates)) {
React.startTransition(() => {
ReactNoop.render(
root.render(
<>
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text="Async" />
<Text text="Sibling" />
</Suspense>
</>,
);
});
} else {
ReactNoop.render(
root.render(
<>
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text="Async" />
<Text text="Sibling" />
</Suspense>
</>,
);
}
expect(ReactNoop.flushNextYield()).toEqual(['Suspend! [Async]']);
expect(Scheduler).toFlushAndYieldThrough(['Suspend! [Async]', 'Sibling']);
await resolveText('Async');
expect(Scheduler).toFlushAndYield([
@ -998,8 +1001,14 @@ describe('ReactSuspenseWithNoopRenderer', () => {
'Loading...',
// Once we've completed the boundary we restarted.
'Async',
'Sibling',
]);
expect(ReactNoop.getChildren()).toEqual([span('Async')]);
expect(root).toMatchRenderedOutput(
<>
<span prop="Async" />
<span prop="Sibling" />
</>,
);
});
// @gate enableCache
@ -3859,7 +3868,6 @@ describe('ReactSuspenseWithNoopRenderer', () => {
'Suspend! [A2]',
'Loading...',
'Suspend! [B2]',
'Loading...',
]);
expect(root).toMatchRenderedOutput(
<>

View File

@ -0,0 +1,67 @@
'use strict';
let React;
let ReactNoop;
let Scheduler;
let act;
let Suspense;
let startTransition;
describe('ReactWakeable', () => {
beforeEach(() => {
jest.resetModules();
React = require('react');
ReactNoop = require('react-noop-renderer');
Scheduler = require('scheduler');
act = require('jest-react').act;
Suspense = React.Suspense;
startTransition = React.startTransition;
});
function Text(props) {
Scheduler.unstable_yieldValue(props.text);
return props.text;
}
test('if suspended fiber is pinged in a microtask, retry immediately without unwinding the stack', async () => {
let resolved = false;
function Async() {
if (resolved) {
return <Text text="Async" />;
}
Scheduler.unstable_yieldValue('Suspend!');
throw Promise.resolve().then(() => {
Scheduler.unstable_yieldValue('Resolve in microtask');
resolved = true;
});
}
function App() {
return (
<Suspense fallback={<Text text="Loading..." />}>
<Async />
</Suspense>
);
}
await act(async () => {
startTransition(() => {
ReactNoop.render(<App />);
});
// React will yield when the async component suspends.
expect(Scheduler).toFlushUntilNextPaint(['Suspend!']);
// Wait for microtasks to resolve
// TODO: The async form of `act` should automatically yield to microtasks
// when a continuation is returned, the way Scheduler does.
await null;
expect(Scheduler).toHaveYielded(['Resolve in microtask']);
});
// Finished rendering without unwinding the stack.
expect(Scheduler).toHaveYielded(['Async']);
});
});

View File

@ -54,13 +54,6 @@
);
}
function unstable_requestYield() {
return global.React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.Scheduler.unstable_requestYield.apply(
this,
arguments
);
}
function unstable_runWithPriority() {
return global.React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.Scheduler.unstable_runWithPriority.apply(
this,
@ -123,7 +116,6 @@
unstable_cancelCallback: unstable_cancelCallback,
unstable_shouldYield: unstable_shouldYield,
unstable_requestPaint: unstable_requestPaint,
unstable_requestYield: unstable_requestYield,
unstable_runWithPriority: unstable_runWithPriority,
unstable_next: unstable_next,
unstable_wrapCallback: unstable_wrapCallback,

View File

@ -54,13 +54,6 @@
);
}
function unstable_requestYield() {
return global.React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.Scheduler.unstable_requestYield.apply(
this,
arguments
);
}
function unstable_runWithPriority() {
return global.React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.Scheduler.unstable_runWithPriority.apply(
this,
@ -117,7 +110,6 @@
unstable_cancelCallback: unstable_cancelCallback,
unstable_shouldYield: unstable_shouldYield,
unstable_requestPaint: unstable_requestPaint,
unstable_requestYield: unstable_requestYield,
unstable_runWithPriority: unstable_runWithPriority,
unstable_next: unstable_next,
unstable_wrapCallback: unstable_wrapCallback,

View File

@ -54,13 +54,6 @@
);
}
function unstable_requestYield() {
return global.React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.Scheduler.unstable_requestYield.apply(
this,
arguments
);
}
function unstable_runWithPriority() {
return global.React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.Scheduler.unstable_runWithPriority.apply(
this,
@ -117,7 +110,6 @@
unstable_cancelCallback: unstable_cancelCallback,
unstable_shouldYield: unstable_shouldYield,
unstable_requestPaint: unstable_requestPaint,
unstable_requestYield: unstable_requestYield,
unstable_runWithPriority: unstable_runWithPriority,
unstable_next: unstable_next,
unstable_wrapCallback: unstable_wrapCallback,

View File

@ -18,7 +18,6 @@ let performance;
let cancelCallback;
let scheduleCallback;
let requestPaint;
let requestYield;
let shouldYield;
let NormalPriority;
@ -44,7 +43,6 @@ describe('SchedulerBrowser', () => {
scheduleCallback = Scheduler.unstable_scheduleCallback;
NormalPriority = Scheduler.unstable_NormalPriority;
requestPaint = Scheduler.unstable_requestPaint;
requestYield = Scheduler.unstable_requestYield;
shouldYield = Scheduler.unstable_shouldYield;
});
@ -480,19 +478,13 @@ describe('SchedulerBrowser', () => {
]);
});
it('requestYield forces a yield immediately', () => {
it('yielding continues in a new task regardless of how much time is remaining', () => {
scheduleCallback(NormalPriority, () => {
runtime.log('Original Task');
runtime.log('shouldYield: ' + shouldYield());
runtime.log('requestYield');
requestYield();
runtime.log('shouldYield: ' + shouldYield());
runtime.log('Return a continuation');
return () => {
runtime.log('Continuation Task');
runtime.log('shouldYield: ' + shouldYield());
runtime.log('Advance time past frame deadline');
runtime.advanceTime(10000);
runtime.log('shouldYield: ' + shouldYield());
};
});
runtime.assertLog(['Post Message']);
@ -501,27 +493,20 @@ describe('SchedulerBrowser', () => {
runtime.assertLog([
'Message Event',
'Original Task',
// Immediately before returning a continuation, `shouldYield` returns
// false, which means there must be time remaining in the frame.
'shouldYield: false',
'requestYield',
// Immediately after calling requestYield, shouldYield starts
// returning true, even though no time has elapsed in the frame
'shouldYield: true',
'Return a continuation',
// The continuation should be scheduled in a separate macrotask.
// The continuation should be scheduled in a separate macrotask even
// though there's time remaining.
'Post Message',
]);
// No time has elapsed
expect(performance.now()).toBe(0);
// Subsequent tasks work as normal
runtime.fireMessageEvent();
runtime.assertLog([
'Message Event',
'Continuation Task',
'shouldYield: false',
'Advance time past frame deadline',
'shouldYield: true',
]);
runtime.assertLog(['Message Event', 'Continuation Task']);
});
});

View File

@ -726,37 +726,53 @@ describe('Scheduler', () => {
expect(Scheduler).toFlushWithoutYielding();
});
it('requestYield forces a yield immediately', () => {
it('toFlushUntilNextPaint stops if a continuation is returned', () => {
scheduleCallback(NormalPriority, () => {
Scheduler.unstable_yieldValue('Original Task');
Scheduler.unstable_yieldValue(
'shouldYield: ' + Scheduler.unstable_shouldYield(),
);
Scheduler.unstable_yieldValue('requestYield');
Scheduler.unstable_requestYield();
Scheduler.unstable_yieldValue(
'shouldYield: ' + Scheduler.unstable_shouldYield(),
);
Scheduler.unstable_yieldValue('shouldYield: ' + shouldYield());
Scheduler.unstable_yieldValue('Return a continuation');
return () => {
Scheduler.unstable_yieldValue('Continuation Task');
Scheduler.unstable_yieldValue(
'shouldYield: ' + Scheduler.unstable_shouldYield(),
);
Scheduler.unstable_yieldValue('Advance time past frame deadline');
Scheduler.unstable_yieldValue(
'shouldYield: ' + Scheduler.unstable_shouldYield(),
);
};
});
// The continuation should be scheduled in a separate macrotask.
expect(Scheduler).toFlushUntilNextPaint([
'Original Task',
// Immediately before returning a continuation, `shouldYield` returns
// false, which means there must be time remaining in the frame.
'shouldYield: false',
'requestYield',
// Immediately after calling requestYield, shouldYield starts
// returning true
'shouldYield: true',
'Return a continuation',
// The continuation should not flush yet.
]);
// No time has elapsed
expect(Scheduler.unstable_now()).toBe(0);
// Continue the task
expect(Scheduler).toFlushAndYield(['Continuation Task']);
});
it("toFlushAndYield keeps flushing even if there's a continuation", () => {
scheduleCallback(NormalPriority, () => {
Scheduler.unstable_yieldValue('Original Task');
Scheduler.unstable_yieldValue('shouldYield: ' + shouldYield());
Scheduler.unstable_yieldValue('Return a continuation');
return () => {
Scheduler.unstable_yieldValue('Continuation Task');
};
});
expect(Scheduler).toFlushAndYield([
'Original Task',
// Immediately before returning a continuation, `shouldYield` returns
// false, which means there must be time remaining in the frame.
'shouldYield: false',
'Return a continuation',
// The continuation should flush immediately, even though the task
// yielded a continuation.
'Continuation Task',
]);
});
});

View File

@ -23,7 +23,6 @@ let UserBlockingPriority;
let LowPriority;
let IdlePriority;
let shouldYield;
let requestYield;
// The Scheduler postTask implementation uses a new postTask browser API to
// schedule work on the main thread. This test suite mocks all browser methods
@ -47,7 +46,6 @@ describe('SchedulerPostTask', () => {
LowPriority = Scheduler.unstable_LowPriority;
IdlePriority = Scheduler.unstable_IdlePriority;
shouldYield = Scheduler.unstable_shouldYield;
requestYield = Scheduler.unstable_requestYield;
});
afterEach(() => {
@ -301,19 +299,13 @@ describe('SchedulerPostTask', () => {
]);
});
it('requestYield forces a yield immediately', () => {
it('yielding continues in a new task regardless of how much time is remaining', () => {
scheduleCallback(NormalPriority, () => {
runtime.log('Original Task');
runtime.log('shouldYield: ' + shouldYield());
runtime.log('requestYield');
requestYield();
runtime.log('shouldYield: ' + shouldYield());
runtime.log('Return a continuation');
return () => {
runtime.log('Continuation Task');
runtime.log('shouldYield: ' + shouldYield());
runtime.log('Advance time past frame deadline');
runtime.advanceTime(10000);
runtime.log('shouldYield: ' + shouldYield());
};
});
runtime.assertLog(['Post Task 0 [user-visible]']);
@ -322,27 +314,20 @@ describe('SchedulerPostTask', () => {
runtime.assertLog([
'Task 0 Fired',
'Original Task',
// Immediately before returning a continuation, `shouldYield` returns
// false, which means there must be time remaining in the frame.
'shouldYield: false',
'requestYield',
// Immediately after calling requestYield, shouldYield starts
// returning true, even though no time has elapsed in the frame
'shouldYield: true',
'Return a continuation',
// The continuation should be scheduled in a separate macrotask.
// The continuation should be scheduled in a separate macrotask even
// though there's time remaining.
'Post Task 1 [user-visible]',
]);
// No time has elapsed
expect(performance.now()).toBe(0);
// Subsequent tasks work as normal
runtime.flushTasks();
runtime.assertLog([
'Task 1 Fired',
'Continuation Task',
'shouldYield: false',
'Advance time past frame deadline',
'shouldYield: true',
]);
runtime.assertLog(['Task 1 Fired', 'Continuation Task']);
});
});

View File

@ -212,10 +212,14 @@ function workLoop(hasTimeRemaining, initialTime) {
const continuationCallback = callback(didUserCallbackTimeout);
currentTime = getCurrentTime();
if (typeof continuationCallback === 'function') {
// If a continuation is returned, immediately yield to the main thread
// regardless of how much time is left in the current time slice.
currentTask.callback = continuationCallback;
if (enableProfiling) {
markTaskYield(currentTask, currentTime);
}
advanceTimers(currentTime);
return true;
} else {
if (enableProfiling) {
markTaskCompleted(currentTask, currentTime);
@ -224,8 +228,8 @@ function workLoop(hasTimeRemaining, initialTime) {
if (currentTask === peek(taskQueue)) {
pop(taskQueue);
}
advanceTimers(currentTime);
}
advanceTimers(currentTime);
} else {
pop(taskQueue);
}
@ -495,11 +499,6 @@ function requestPaint() {
// Since we yield every frame regardless, `requestPaint` has no effect.
}
function requestYield() {
// Force a yield at the next opportunity.
startTime = -99999;
}
function forceFrameRate(fps) {
if (fps < 0 || fps > 125) {
// Using console['error'] to evade Babel and ESLint
@ -617,7 +616,6 @@ export {
unstable_getCurrentPriorityLevel,
shouldYieldToHost as unstable_shouldYield,
requestPaint as unstable_requestPaint,
requestYield as unstable_requestYield,
unstable_continueExecution,
unstable_pauseExecution,
unstable_getFirstCallbackNode,

View File

@ -195,10 +195,22 @@ function workLoop(hasTimeRemaining, initialTime) {
const continuationCallback = callback(didUserCallbackTimeout);
currentTime = getCurrentTime();
if (typeof continuationCallback === 'function') {
// If a continuation is returned, immediately yield to the main thread
// regardless of how much time is left in the current time slice.
currentTask.callback = continuationCallback;
if (enableProfiling) {
markTaskYield(currentTask, currentTime);
}
advanceTimers(currentTime);
if (shouldYieldForPaint) {
needsPaint = true;
return true;
} else {
// If `shouldYieldForPaint` is false, we keep flushing synchronously
// without yielding to the main thread. This is the behavior of the
// `toFlushAndYield` and `toFlushAndYieldThrough` testing helpers .
}
} else {
if (enableProfiling) {
markTaskCompleted(currentTask, currentTime);
@ -207,8 +219,8 @@ function workLoop(hasTimeRemaining, initialTime) {
if (currentTask === peek(taskQueue)) {
pop(taskQueue);
}
advanceTimers(currentTime);
}
advanceTimers(currentTime);
} else {
pop(taskQueue);
}
@ -608,11 +620,6 @@ function requestPaint() {
needsPaint = true;
}
function requestYield() {
// Force a yield at the next opportunity.
shouldYieldForPaint = needsPaint = true;
}
export {
ImmediatePriority as unstable_ImmediatePriority,
UserBlockingPriority as unstable_UserBlockingPriority,
@ -627,7 +634,6 @@ export {
unstable_getCurrentPriorityLevel,
shouldYieldToHost as unstable_shouldYield,
requestPaint as unstable_requestPaint,
requestYield as unstable_requestYield,
unstable_continueExecution,
unstable_pauseExecution,
unstable_getFirstCallbackNode,

View File

@ -67,11 +67,6 @@ export function unstable_requestPaint() {
// Since we yield every frame regardless, `requestPaint` has no effect.
}
export function unstable_requestYield() {
// Force a yield at the next opportunity.
deadline = -99999;
}
type SchedulerCallback<T> = (
didTimeout_DEPRECATED: boolean,
) =>