Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Bugfix] Dropped updates inside a suspended tree #18384

Merged
merged 6 commits into from Mar 26, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
92 changes: 89 additions & 3 deletions packages/react-reconciler/src/ReactFiberBeginWork.js
Expand Up @@ -179,6 +179,7 @@ import {
scheduleUpdateOnFiber,
renderDidSuspendDelayIfPossible,
markUnprocessedUpdateTime,
getWorkInProgressRoot,
} from './ReactFiberWorkLoop';

const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner;
Expand Down Expand Up @@ -1590,6 +1591,35 @@ function shouldRemainOnFallback(
);
}

function getRemainingWorkInPrimaryTree(
workInProgress,
currentChildExpirationTime,
renderExpirationTime,
) {
if (currentChildExpirationTime < renderExpirationTime) {
// The highest priority remaining work is not part of this render. So the
// remaining work has not changed.
return currentChildExpirationTime;
}
if ((workInProgress.mode & BlockingMode) !== NoMode) {
// The highest priority remaining work is part of this render. Since we only
// keep track of the highest level, we don't know if there's a lower
// priority level scheduled. As a compromise, we'll render at the lowest
// known level in the entire tree, since that will include everything.
// TODO: If expirationTime were a bitmask where each bit represents a
// separate task thread, this would be: currentChildBits & ~renderBits
const root = getWorkInProgressRoot();
if (root !== null) {
const lastPendingTime = root.lastPendingTime;
if (lastPendingTime < renderExpirationTime) {
return lastPendingTime;
}
}
}
// In legacy mode, there's no work left.
return NoWork;
}

function updateSuspenseComponent(
current,
workInProgress,
Expand Down Expand Up @@ -1831,8 +1861,15 @@ function updateSuspenseComponent(
fallbackChildFragment.return = workInProgress;
primaryChildFragment.sibling = fallbackChildFragment;
fallbackChildFragment.effectTag |= Placement;
primaryChildFragment.childExpirationTime = NoWork;

primaryChildFragment.childExpirationTime = getRemainingWorkInPrimaryTree(
workInProgress,
// This argument represents the remaining work in the current
// primary tree. Since the current tree did not already time out
// the direct parent of the primary children is the Suspense
// fiber, not a fragment.
current.childExpirationTime,
renderExpirationTime,
);
workInProgress.memoizedState = SUSPENDED_MARKER;
workInProgress.child = primaryChildFragment;

Expand Down Expand Up @@ -1895,6 +1932,11 @@ function updateSuspenseComponent(
);
fallbackChildFragment.return = workInProgress;
primaryChildFragment.sibling = fallbackChildFragment;
primaryChildFragment.childExpirationTime = getRemainingWorkInPrimaryTree(
workInProgress,
currentPrimaryChildFragment.childExpirationTime,
renderExpirationTime,
);
primaryChildFragment.childExpirationTime = NoWork;
// Skip the primary children, and continue working on the
// fallback children.
Expand Down Expand Up @@ -1989,7 +2031,15 @@ function updateSuspenseComponent(
fallbackChildFragment.return = workInProgress;
primaryChildFragment.sibling = fallbackChildFragment;
fallbackChildFragment.effectTag |= Placement;
primaryChildFragment.childExpirationTime = NoWork;
primaryChildFragment.childExpirationTime = getRemainingWorkInPrimaryTree(
workInProgress,
// This argument represents the remaining work in the current
// primary tree. Since the current tree did not already time out
// the direct parent of the primary children is the Suspense
// fiber, not a fragment.
current.childExpirationTime,
renderExpirationTime,
);
// Skip the primary children, and continue working on the
// fallback children.
workInProgress.memoizedState = SUSPENDED_MARKER;
Expand Down Expand Up @@ -3006,6 +3056,42 @@ function beginWork(
renderExpirationTime,
);
} else {
// The primary child fragment does not have pending work marked
// on it...

// ...usually. There's an unfortunate edge case where the fragment
// fiber is not part of the return path of the children, so when
// an update happens, the fragment doesn't get marked during
// setState. This is something we should consider addressing when
// we refactor the Fiber data structure. (There's a test with more
// details; to find it, comment out the following block and see
// which one fails.)
//
// As a workaround, we need to recompute the `childExpirationTime`
// by bubbling it up from the next level of children. This is
// based on similar logic in `resetChildExpirationTime`.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An alternative fix would be to detect this during setState. I thought it might be better to localize the fix here since it's relatively rare but I'm having second thoughts.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is the right call to keep it in the rare path.

I think we should just bite the bullet have have Suspense boundaries always have these fragments even when not suspended. It's too much code to manage (buggy and byte size). Especially with enter/exit animations it's going to get even trickier.

It adds a bit more memory but there's not that many suspense boundaries and the ones that do exist fairly often transition through this state anyway.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would definitely simplify things a lot

let primaryChild = primaryChildFragment.child;
while (primaryChild !== null) {
const childUpdateExpirationTime = primaryChild.expirationTime;
const childChildExpirationTime =
primaryChild.childExpirationTime;
if (
(childUpdateExpirationTime !== NoWork &&
childUpdateExpirationTime >= renderExpirationTime) ||
(childChildExpirationTime !== NoWork &&
childChildExpirationTime >= renderExpirationTime)
) {
// Found a child with an update with sufficient priority.
// Use the normal path to render the primary children again.
return updateSuspenseComponent(
current,
workInProgress,
renderExpirationTime,
);
}
primaryChild = primaryChild.sibling;
}

pushSuspenseContext(
workInProgress,
setDefaultShallowSuspenseContext(suspenseStackCursor.current),
Expand Down
12 changes: 12 additions & 0 deletions packages/react-reconciler/src/ReactFiberRoot.js
Expand Up @@ -65,6 +65,8 @@ type BaseFiberRootProperties = {|
callbackPriority: ReactPriorityLevel,
// The earliest pending expiration time that exists in the tree
firstPendingTime: ExpirationTime,
// The latest pending expiration time that exists in the tree
lastPendingTime: ExpirationTime,
// The earliest suspended expiration time that exists in the tree
firstSuspendedTime: ExpirationTime,
// The latest suspended expiration time that exists in the tree
Expand Down Expand Up @@ -122,6 +124,7 @@ function FiberRootNode(containerInfo, tag, hydrate) {
this.callbackNode = null;
this.callbackPriority = NoPriority;
this.firstPendingTime = NoWork;
this.lastPendingTime = NoWork;
this.firstSuspendedTime = NoWork;
this.lastSuspendedTime = NoWork;
this.nextKnownPendingLevel = NoWork;
Expand Down Expand Up @@ -205,6 +208,10 @@ export function markRootUpdatedAtTime(
if (expirationTime > firstPendingTime) {
root.firstPendingTime = expirationTime;
}
const lastPendingTime = root.lastPendingTime;
if (lastPendingTime === NoWork || expirationTime < lastPendingTime) {
root.lastPendingTime = expirationTime;
}

// Update the range of suspended times. Treat everything lower priority or
// equal to this update as unsuspended.
Expand Down Expand Up @@ -232,6 +239,11 @@ export function markRootFinishedAtTime(
): void {
// Update the range of pending times
root.firstPendingTime = remainingExpirationTime;
if (remainingExpirationTime < root.lastPendingTime) {
// This usually means we've finished all the work, but it can also happen
// when something gets downprioritized during render, like a hidden tree.
root.lastPendingTime = remainingExpirationTime;
}

// Update the range of suspended times. Treat everything higher priority or
// equal to this update as unsuspended.
Expand Down
Expand Up @@ -293,7 +293,13 @@ describe('ReactSuspenseList', () => {

await C.resolve();

expect(Scheduler).toFlushAndYield(['C']);
expect(Scheduler).toFlushAndYield([
// TODO: Ideally we wouldn't have to retry B. This is an implementation
// trade off.
'Suspend! [B]',

'C',
]);

expect(ReactNoop).toMatchRenderedOutput(
<>
Expand Down