Skip to content

Commit 602a821

Browse files
committed
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.
1 parent f8eb8de commit 602a821

File tree

4 files changed

+220
-6
lines changed

4 files changed

+220
-6
lines changed

packages/react-reconciler/src/ReactFiberBeginWork.js

+61-3
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ import {
9898
} from './ReactUpdateQueue';
9999
import {
100100
NoWork,
101+
Idle,
102+
ContinuousHydration,
101103
Never,
102104
Sync,
103105
computeAsyncExpiration,
@@ -1590,6 +1592,42 @@ function shouldRemainOnFallback(
15901592
);
15911593
}
15921594

1595+
function getRemainingWorkInPrimaryTree(
1596+
workInProgress,
1597+
currentChildExpirationTime,
1598+
renderExpirationTime,
1599+
) {
1600+
if (currentChildExpirationTime < renderExpirationTime) {
1601+
// The highest priority remaining work is not part of this render. So the
1602+
// remaining work has not changed.
1603+
return currentChildExpirationTime;
1604+
}
1605+
if ((workInProgress.mode & BlockingMode) !== NoMode) {
1606+
// The highest priority remaining work is part of this render. Since we only
1607+
// keep track of the highest level, we don't know if there's a lower
1608+
// priority level scheduled. As a compromise, we'll render at a really low
1609+
// priority that includes all the updates.
1610+
// TODO: If expirationTime were a bitmask where each bit represents a
1611+
// separate task thread, this would be: currentChildBits & ~renderBits
1612+
// TODO: We used to track the lowest pending level for the whole root, but
1613+
// we removed it to simplify the implementation. It might be worth adding
1614+
// it back for this use case, to avoid redundant passes.
1615+
if (renderExpirationTime > ContinuousHydration) {
1616+
// First we try ContinuousHydration, so that we don't get grouped
1617+
// with Idle.
1618+
return ContinuousHydration;
1619+
} else if (renderExpirationTime > Idle) {
1620+
// Then we'll try Idle.
1621+
return Idle;
1622+
} else {
1623+
// Already at lowest possible update level.
1624+
return NoWork;
1625+
}
1626+
}
1627+
// In legacy mode, there's no work left.
1628+
return NoWork;
1629+
}
1630+
15931631
function updateSuspenseComponent(
15941632
current,
15951633
workInProgress,
@@ -1831,8 +1869,15 @@ function updateSuspenseComponent(
18311869
fallbackChildFragment.return = workInProgress;
18321870
primaryChildFragment.sibling = fallbackChildFragment;
18331871
fallbackChildFragment.effectTag |= Placement;
1834-
primaryChildFragment.childExpirationTime = NoWork;
1835-
1872+
primaryChildFragment.childExpirationTime = getRemainingWorkInPrimaryTree(
1873+
workInProgress,
1874+
// This argument represents the remaining work in the current
1875+
// primary tree. Since the current tree did not already time out
1876+
// the direct parent of the primary children is the Suspense
1877+
// fiber, not a fragment.
1878+
current.childExpirationTime,
1879+
renderExpirationTime,
1880+
);
18361881
workInProgress.memoizedState = SUSPENDED_MARKER;
18371882
workInProgress.child = primaryChildFragment;
18381883

@@ -1895,6 +1940,11 @@ function updateSuspenseComponent(
18951940
);
18961941
fallbackChildFragment.return = workInProgress;
18971942
primaryChildFragment.sibling = fallbackChildFragment;
1943+
primaryChildFragment.childExpirationTime = getRemainingWorkInPrimaryTree(
1944+
workInProgress,
1945+
currentPrimaryChildFragment.childExpirationTime,
1946+
renderExpirationTime,
1947+
);
18981948
primaryChildFragment.childExpirationTime = NoWork;
18991949
// Skip the primary children, and continue working on the
19001950
// fallback children.
@@ -1989,7 +2039,15 @@ function updateSuspenseComponent(
19892039
fallbackChildFragment.return = workInProgress;
19902040
primaryChildFragment.sibling = fallbackChildFragment;
19912041
fallbackChildFragment.effectTag |= Placement;
1992-
primaryChildFragment.childExpirationTime = NoWork;
2042+
primaryChildFragment.childExpirationTime = getRemainingWorkInPrimaryTree(
2043+
workInProgress,
2044+
// This argument represents the remaining work in the current
2045+
// primary tree. Since the current tree did not already time out
2046+
// the direct parent of the primary children is the Suspense
2047+
// fiber, not a fragment.
2048+
current.childExpirationTime,
2049+
renderExpirationTime,
2050+
);
19932051
// Skip the primary children, and continue working on the
19942052
// fallback children.
19952053
workInProgress.memoizedState = SUSPENDED_MARKER;

packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js

+12-2
Original file line numberDiff line numberDiff line change
@@ -734,12 +734,22 @@ describe('ReactHooksWithNoopRenderer', () => {
734734
root.render(<Foo signal={false} />);
735735
setLabel('B');
736736
});
737-
expect(Scheduler).toHaveYielded(['Suspend!']);
737+
expect(Scheduler).toHaveYielded([
738+
'Suspend!',
739+
// TODO: This second attempt is because React isn't sure if there's
740+
// another update at a lower priority. Ideally we wouldn't need it.
741+
'Suspend!',
742+
]);
738743
expect(root).toMatchRenderedOutput(<span prop="A:0" />);
739744

740745
// Rendering again should suspend again.
741746
root.render(<Foo signal={false} />);
742-
expect(Scheduler).toFlushAndYield(['Suspend!']);
747+
expect(Scheduler).toFlushAndYield([
748+
'Suspend!',
749+
// TODO: This second attempt is because React isn't sure if there's
750+
// another update at a lower priority. Ideally we wouldn't need it.
751+
'Suspend!',
752+
]);
743753

744754
// Flip the signal back to "cancel" the update. However, the update to
745755
// label should still proceed. It shouldn't have been dropped.

packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js

+8-1
Original file line numberDiff line numberDiff line change
@@ -956,7 +956,14 @@ describe('ReactSuspense', () => {
956956

957957
// Update that suspends
958958
instance.setState({step: 2});
959-
expect(Scheduler).toFlushAndYield(['Suspend! [Step: 2]', 'Loading...']);
959+
expect(Scheduler).toFlushAndYield([
960+
'Suspend! [Step: 2]',
961+
'Loading...',
962+
// TODO: This second attempt is because React isn't sure if there's
963+
// another update at a lower priority. Ideally we wouldn't need it.
964+
'Suspend! [Step: 2]',
965+
'Loading...',
966+
]);
960967
jest.advanceTimersByTime(500);
961968
expect(root).toMatchRenderedOutput('Loading...');
962969

packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js

+139
Original file line numberDiff line numberDiff line change
@@ -2842,6 +2842,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
28422842
foo.setState({suspend: false});
28432843
});
28442844

2845+
expect(Scheduler).toHaveYielded(['Foo']);
28452846
expect(root).toMatchRenderedOutput(<span prop="Foo" />);
28462847
});
28472848

@@ -2957,4 +2958,142 @@ describe('ReactSuspenseWithNoopRenderer', () => {
29572958
</>,
29582959
);
29592960
});
2961+
2962+
it(
2963+
'multiple updates originating inside a Suspense boundary at different ' +
2964+
'priority levels are not dropped',
2965+
async () => {
2966+
const {useState} = React;
2967+
const root = ReactNoop.createRoot();
2968+
2969+
function Parent() {
2970+
return (
2971+
<>
2972+
<Suspense fallback={<Text text="Loading..." />}>
2973+
<Child />
2974+
</Suspense>
2975+
</>
2976+
);
2977+
}
2978+
2979+
let setText;
2980+
let setShouldHide;
2981+
function Child() {
2982+
const [text, _setText] = useState('A');
2983+
const [shouldHide, _setShouldHide] = useState(false);
2984+
setText = _setText;
2985+
setShouldHide = _setShouldHide;
2986+
return shouldHide ? <Text text="(empty)" /> : <AsyncText text={text} />;
2987+
}
2988+
2989+
await ReactNoop.act(async () => {
2990+
root.render(<Parent />);
2991+
});
2992+
expect(Scheduler).toHaveYielded(['Suspend! [A]', 'Loading...']);
2993+
expect(root).toMatchRenderedOutput(<span prop="Loading..." />);
2994+
2995+
await resolveText('A');
2996+
expect(Scheduler).toHaveYielded(['Promise resolved [A]']);
2997+
expect(Scheduler).toFlushAndYield(['A']);
2998+
expect(root).toMatchRenderedOutput(<span prop="A" />);
2999+
3000+
await ReactNoop.act(async () => {
3001+
// Schedule two updates that originate inside the Suspense boundary.
3002+
// The first one causes the boundary to suspend. The second one is at
3003+
// lower priority and unsuspends it by hiding the async component.
3004+
ReactNoop.discreteUpdates(() => {
3005+
setText('B');
3006+
});
3007+
setShouldHide(true);
3008+
});
3009+
expect(Scheduler).toHaveYielded([
3010+
// First we attempt the high pri update. It suspends.
3011+
'Suspend! [B]',
3012+
'Loading...',
3013+
// Then we attempt the low pri update, which finishes successfully.
3014+
'(empty)',
3015+
]);
3016+
expect(root).toMatchRenderedOutput(<span prop="(empty)" />);
3017+
},
3018+
);
3019+
3020+
it(
3021+
'multiple updates originating inside a Suspense boundary at different ' +
3022+
'priority levels are not dropped, including Idle updates',
3023+
async () => {
3024+
const {useState} = React;
3025+
const root = ReactNoop.createRoot();
3026+
3027+
function Parent() {
3028+
return (
3029+
<>
3030+
<Suspense fallback={<Text text="Loading..." />}>
3031+
<Child />
3032+
</Suspense>
3033+
</>
3034+
);
3035+
}
3036+
3037+
let setText;
3038+
let setShouldHide;
3039+
function Child() {
3040+
const [text, _setText] = useState('A');
3041+
const [shouldHide, _setShouldHide] = useState(false);
3042+
setText = _setText;
3043+
setShouldHide = _setShouldHide;
3044+
return shouldHide ? <Text text="(empty)" /> : <AsyncText text={text} />;
3045+
}
3046+
3047+
await ReactNoop.act(async () => {
3048+
root.render(<Parent />);
3049+
});
3050+
expect(Scheduler).toHaveYielded(['Suspend! [A]', 'Loading...']);
3051+
expect(root).toMatchRenderedOutput(<span prop="Loading..." />);
3052+
3053+
await resolveText('A');
3054+
expect(Scheduler).toHaveYielded(['Promise resolved [A]']);
3055+
expect(Scheduler).toFlushAndYield(['A']);
3056+
expect(root).toMatchRenderedOutput(<span prop="A" />);
3057+
3058+
await ReactNoop.act(async () => {
3059+
// Schedule two updates that originate inside the Suspense boundary.
3060+
// The first one causes the boundary to suspend. The second one is at
3061+
// lower priority and unsuspends it by hiding the async component.
3062+
setText('B');
3063+
Scheduler.unstable_runWithPriority(
3064+
Scheduler.unstable_IdlePriority,
3065+
() => {
3066+
setShouldHide(true);
3067+
},
3068+
);
3069+
});
3070+
expect(Scheduler).toHaveYielded([
3071+
// First we attempt the high pri update. It suspends.
3072+
'Suspend! [B]',
3073+
'Loading...',
3074+
3075+
// Then retry at a lower priority level.
3076+
// NOTE: The current implementation doesn't know which level to attempt,
3077+
// so it picks ContinuousHydration, which is one level higher than Idle.
3078+
// Since it doesn't include the Idle update, it will suspend again and
3079+
// reschedule to try at Idle. If we refactor expiration time to be a
3080+
// bitmask, we shouldn't need this heuristic.
3081+
'Suspend! [B]',
3082+
'Loading...',
3083+
]);
3084+
3085+
// Commit the placeholder to unblock the Idle update.
3086+
await advanceTimers(250);
3087+
expect(root).toMatchRenderedOutput(
3088+
<>
3089+
<span hidden={true} prop="A" />
3090+
<span prop="Loading..." />
3091+
</>,
3092+
);
3093+
3094+
// Now flush the remaining work. The Idle update successfully finishes.
3095+
expect(Scheduler).toFlushAndYield(['(empty)']);
3096+
expect(root).toMatchRenderedOutput(<span prop="(empty)" />);
3097+
},
3098+
);
29603099
});

0 commit comments

Comments
 (0)