Skip to content

Commit 9d67847

Browse files
authored
[Bugfix] Dropped updates inside a suspended tree (facebook#18384)
* Minor test refactor: `resolveText` Adds a `resolveText` method as an alternative to using timers. Also removes dependency on react-cache (for just this one test file; can do the others later). Timer option is still there if you provide a `ms` prop. * Bugfix: Dropped updates in suspended tree When there are multiple updates at different priority levels inside a suspended subtree, all but the highest priority one is dropped after the highest one suspends. We do have tests that cover this for updates that originate outside of the Suspense boundary, but not for updates that originate inside. I'm surprised it's taken us this long to find this issue, but it makes sense in that transition updates usually originate outside the boundary or "seam" of the part of the UI that is transitioning. * Bugfix: Suspense fragment skipped by setState Fixes a bug where updates inside a suspended tree are dropped because the fragment fiber we insert to wrap the hidden children is not part of the return path, so it doesn't get marked during setState. As a workaround, I recompute `childExpirationTime` right before deciding to bail out by bubbling it up from the next level of children. This is something we should consider addressing when we refactor the Fiber data structure. * Add back `lastPendingTime` field This reverts commit 9a54113. I want to use this so we can check if there might be any lower priority updates in a suspended tree. We can remove it again during the expiration times refactor. * Use `lastPendingTime` instead of Idle We don't currently have an mechanism to check if there are lower priority updates in a subtree, but we can check if there are any in the whole root. This still isn't perfect but it's better than using Idle, which frequently leads to redundant re-renders. When we refactor `expirationTime` to be a bitmask, this will no longer be necessary because we'll know exactly which "task bits" remain. * Add a test for updating the fallback
1 parent 5bd1bc2 commit 9d67847

File tree

4 files changed

+473
-53
lines changed

4 files changed

+473
-53
lines changed

Diff for: packages/react-reconciler/src/ReactFiberBeginWork.js

+89-3
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ import {
179179
scheduleUpdateOnFiber,
180180
renderDidSuspendDelayIfPossible,
181181
markUnprocessedUpdateTime,
182+
getWorkInProgressRoot,
182183
} from './ReactFiberWorkLoop';
183184

184185
const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner;
@@ -1590,6 +1591,35 @@ function shouldRemainOnFallback(
15901591
);
15911592
}
15921593

1594+
function getRemainingWorkInPrimaryTree(
1595+
workInProgress,
1596+
currentChildExpirationTime,
1597+
renderExpirationTime,
1598+
) {
1599+
if (currentChildExpirationTime < renderExpirationTime) {
1600+
// The highest priority remaining work is not part of this render. So the
1601+
// remaining work has not changed.
1602+
return currentChildExpirationTime;
1603+
}
1604+
if ((workInProgress.mode & BlockingMode) !== NoMode) {
1605+
// The highest priority remaining work is part of this render. Since we only
1606+
// keep track of the highest level, we don't know if there's a lower
1607+
// priority level scheduled. As a compromise, we'll render at the lowest
1608+
// known level in the entire tree, since that will include everything.
1609+
// TODO: If expirationTime were a bitmask where each bit represents a
1610+
// separate task thread, this would be: currentChildBits & ~renderBits
1611+
const root = getWorkInProgressRoot();
1612+
if (root !== null) {
1613+
const lastPendingTime = root.lastPendingTime;
1614+
if (lastPendingTime < renderExpirationTime) {
1615+
return lastPendingTime;
1616+
}
1617+
}
1618+
}
1619+
// In legacy mode, there's no work left.
1620+
return NoWork;
1621+
}
1622+
15931623
function updateSuspenseComponent(
15941624
current,
15951625
workInProgress,
@@ -1831,8 +1861,15 @@ function updateSuspenseComponent(
18311861
fallbackChildFragment.return = workInProgress;
18321862
primaryChildFragment.sibling = fallbackChildFragment;
18331863
fallbackChildFragment.effectTag |= Placement;
1834-
primaryChildFragment.childExpirationTime = NoWork;
1835-
1864+
primaryChildFragment.childExpirationTime = getRemainingWorkInPrimaryTree(
1865+
workInProgress,
1866+
// This argument represents the remaining work in the current
1867+
// primary tree. Since the current tree did not already time out
1868+
// the direct parent of the primary children is the Suspense
1869+
// fiber, not a fragment.
1870+
current.childExpirationTime,
1871+
renderExpirationTime,
1872+
);
18361873
workInProgress.memoizedState = SUSPENDED_MARKER;
18371874
workInProgress.child = primaryChildFragment;
18381875

@@ -1895,6 +1932,11 @@ function updateSuspenseComponent(
18951932
);
18961933
fallbackChildFragment.return = workInProgress;
18971934
primaryChildFragment.sibling = fallbackChildFragment;
1935+
primaryChildFragment.childExpirationTime = getRemainingWorkInPrimaryTree(
1936+
workInProgress,
1937+
currentPrimaryChildFragment.childExpirationTime,
1938+
renderExpirationTime,
1939+
);
18981940
primaryChildFragment.childExpirationTime = NoWork;
18991941
// Skip the primary children, and continue working on the
19001942
// fallback children.
@@ -1989,7 +2031,15 @@ function updateSuspenseComponent(
19892031
fallbackChildFragment.return = workInProgress;
19902032
primaryChildFragment.sibling = fallbackChildFragment;
19912033
fallbackChildFragment.effectTag |= Placement;
1992-
primaryChildFragment.childExpirationTime = NoWork;
2034+
primaryChildFragment.childExpirationTime = getRemainingWorkInPrimaryTree(
2035+
workInProgress,
2036+
// This argument represents the remaining work in the current
2037+
// primary tree. Since the current tree did not already time out
2038+
// the direct parent of the primary children is the Suspense
2039+
// fiber, not a fragment.
2040+
current.childExpirationTime,
2041+
renderExpirationTime,
2042+
);
19932043
// Skip the primary children, and continue working on the
19942044
// fallback children.
19952045
workInProgress.memoizedState = SUSPENDED_MARKER;
@@ -3006,6 +3056,42 @@ function beginWork(
30063056
renderExpirationTime,
30073057
);
30083058
} else {
3059+
// The primary child fragment does not have pending work marked
3060+
// on it...
3061+
3062+
// ...usually. There's an unfortunate edge case where the fragment
3063+
// fiber is not part of the return path of the children, so when
3064+
// an update happens, the fragment doesn't get marked during
3065+
// setState. This is something we should consider addressing when
3066+
// we refactor the Fiber data structure. (There's a test with more
3067+
// details; to find it, comment out the following block and see
3068+
// which one fails.)
3069+
//
3070+
// As a workaround, we need to recompute the `childExpirationTime`
3071+
// by bubbling it up from the next level of children. This is
3072+
// based on similar logic in `resetChildExpirationTime`.
3073+
let primaryChild = primaryChildFragment.child;
3074+
while (primaryChild !== null) {
3075+
const childUpdateExpirationTime = primaryChild.expirationTime;
3076+
const childChildExpirationTime =
3077+
primaryChild.childExpirationTime;
3078+
if (
3079+
(childUpdateExpirationTime !== NoWork &&
3080+
childUpdateExpirationTime >= renderExpirationTime) ||
3081+
(childChildExpirationTime !== NoWork &&
3082+
childChildExpirationTime >= renderExpirationTime)
3083+
) {
3084+
// Found a child with an update with sufficient priority.
3085+
// Use the normal path to render the primary children again.
3086+
return updateSuspenseComponent(
3087+
current,
3088+
workInProgress,
3089+
renderExpirationTime,
3090+
);
3091+
}
3092+
primaryChild = primaryChild.sibling;
3093+
}
3094+
30093095
pushSuspenseContext(
30103096
workInProgress,
30113097
setDefaultShallowSuspenseContext(suspenseStackCursor.current),

Diff for: packages/react-reconciler/src/ReactFiberRoot.js

+12
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ type BaseFiberRootProperties = {|
6565
callbackPriority: ReactPriorityLevel,
6666
// The earliest pending expiration time that exists in the tree
6767
firstPendingTime: ExpirationTime,
68+
// The latest pending expiration time that exists in the tree
69+
lastPendingTime: ExpirationTime,
6870
// The earliest suspended expiration time that exists in the tree
6971
firstSuspendedTime: ExpirationTime,
7072
// The latest suspended expiration time that exists in the tree
@@ -122,6 +124,7 @@ function FiberRootNode(containerInfo, tag, hydrate) {
122124
this.callbackNode = null;
123125
this.callbackPriority = NoPriority;
124126
this.firstPendingTime = NoWork;
127+
this.lastPendingTime = NoWork;
125128
this.firstSuspendedTime = NoWork;
126129
this.lastSuspendedTime = NoWork;
127130
this.nextKnownPendingLevel = NoWork;
@@ -205,6 +208,10 @@ export function markRootUpdatedAtTime(
205208
if (expirationTime > firstPendingTime) {
206209
root.firstPendingTime = expirationTime;
207210
}
211+
const lastPendingTime = root.lastPendingTime;
212+
if (lastPendingTime === NoWork || expirationTime < lastPendingTime) {
213+
root.lastPendingTime = expirationTime;
214+
}
208215

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

236248
// Update the range of suspended times. Treat everything higher priority or
237249
// equal to this update as unsuspended.

Diff for: packages/react-reconciler/src/__tests__/ReactSuspenseList-test.internal.js

+7-1
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,13 @@ describe('ReactSuspenseList', () => {
293293

294294
await C.resolve();
295295

296-
expect(Scheduler).toFlushAndYield(['C']);
296+
expect(Scheduler).toFlushAndYield([
297+
// TODO: Ideally we wouldn't have to retry B. This is an implementation
298+
// trade off.
299+
'Suspend! [B]',
300+
301+
'C',
302+
]);
297303

298304
expect(ReactNoop).toMatchRenderedOutput(
299305
<>

0 commit comments

Comments
 (0)