Skip to content

Commit 58afba0

Browse files
authored
Fix: Don't read primaryChild.childExpirationTime (#18457)
This is a variant of the fix in 5a0f1d. We can't rely on the primary fiber's `childExpirationTime` field to be correct. In this case, we can read from the Suspense boundary fiber instead. This will include updates that exist in the fallback fiber, but that's not a big deal; the important thing is that we don't drop updates.
1 parent 2ea7c60 commit 58afba0

2 files changed

Lines changed: 85 additions & 11 deletions

File tree

packages/react-reconciler/src/ReactFiberBeginWork.js

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1646,16 +1646,9 @@ function shouldRemainOnFallback(
16461646
function getRemainingWorkInPrimaryTree(
16471647
current: Fiber,
16481648
workInProgress: Fiber,
1649-
currentPrimaryChildFragment: Fiber | null,
16501649
renderExpirationTime,
16511650
) {
1652-
const currentParentOfPrimaryChildren =
1653-
currentPrimaryChildFragment !== null
1654-
? currentPrimaryChildFragment
1655-
: current;
1656-
const currentChildExpirationTime =
1657-
currentParentOfPrimaryChildren.childExpirationTime;
1658-
1651+
const currentChildExpirationTime = current.childExpirationTime;
16591652
const currentSuspenseState: SuspenseState = current.memoizedState;
16601653
if (currentSuspenseState !== null) {
16611654
// This boundary already timed out. Check if this render includes the level
@@ -1945,7 +1938,6 @@ function updateSuspenseComponent(
19451938
primaryChildFragment.childExpirationTime = getRemainingWorkInPrimaryTree(
19461939
current,
19471940
workInProgress,
1948-
null,
19491941
renderExpirationTime,
19501942
);
19511943
workInProgress.memoizedState = updateSuspenseState(
@@ -2016,7 +2008,6 @@ function updateSuspenseComponent(
20162008
primaryChildFragment.childExpirationTime = getRemainingWorkInPrimaryTree(
20172009
current,
20182010
workInProgress,
2019-
currentPrimaryChildFragment,
20202011
renderExpirationTime,
20212012
);
20222013
// Skip the primary children, and continue working on the
@@ -2118,7 +2109,6 @@ function updateSuspenseComponent(
21182109
primaryChildFragment.childExpirationTime = getRemainingWorkInPrimaryTree(
21192110
current,
21202111
workInProgress,
2121-
null,
21222112
renderExpirationTime,
21232113
);
21242114
// Skip the primary children, and continue working on the

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

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3266,6 +3266,90 @@ describe('ReactSuspenseWithNoopRenderer', () => {
32663266
},
32673267
);
32683268

3269+
it(
3270+
'regression: primary fragment fiber is not always part of setState ' +
3271+
'return path (another case)',
3272+
async () => {
3273+
// Reproduces a bug where updates inside a suspended tree are dropped
3274+
// because the fragment fiber we insert to wrap the hidden children is not
3275+
// part of the return path, so it doesn't get marked during setState.
3276+
const {useState} = React;
3277+
const root = ReactNoop.createRoot();
3278+
3279+
function Parent() {
3280+
return (
3281+
<Suspense fallback={<Text text="Loading..." />}>
3282+
<Child />
3283+
</Suspense>
3284+
);
3285+
}
3286+
3287+
let setText;
3288+
function Child() {
3289+
const [text, _setText] = useState('A');
3290+
setText = _setText;
3291+
return <AsyncText text={text} />;
3292+
}
3293+
3294+
// Mount an initial tree. Resolve A so that it doesn't suspend.
3295+
await resolveText('A');
3296+
await ReactNoop.act(async () => {
3297+
root.render(<Parent />);
3298+
});
3299+
expect(Scheduler).toHaveYielded(['A']);
3300+
// At this point, the setState return path follows current fiber.
3301+
expect(root).toMatchRenderedOutput(<span prop="A" />);
3302+
3303+
// Schedule another update. This will "flip" the alternate pairs.
3304+
await resolveText('B');
3305+
await ReactNoop.act(async () => {
3306+
setText('B');
3307+
});
3308+
expect(Scheduler).toHaveYielded(['B']);
3309+
// Now the setState return path follows the *alternate* fiber.
3310+
expect(root).toMatchRenderedOutput(<span prop="B" />);
3311+
3312+
// Schedule another update. This time, we'll suspend.
3313+
await ReactNoop.act(async () => {
3314+
setText('C');
3315+
});
3316+
expect(Scheduler).toHaveYielded(['Suspend! [C]', 'Loading...']);
3317+
3318+
// Commit. This will insert a fragment fiber to wrap around the component
3319+
// that triggered the update.
3320+
await ReactNoop.act(async () => {
3321+
await advanceTimers(250);
3322+
});
3323+
// The fragment fiber is part of the current tree, but the setState return
3324+
// path still follows the alternate path. That means the fragment fiber is
3325+
// not part of the return path.
3326+
expect(root).toMatchRenderedOutput(
3327+
<>
3328+
<span hidden={true} prop="B" />
3329+
<span prop="Loading..." />
3330+
</>,
3331+
);
3332+
3333+
await ReactNoop.act(async () => {
3334+
// Schedule a normal pri update. This will suspend again.
3335+
setText('D');
3336+
3337+
// And another update at lower priority. This will unblock.
3338+
await resolveText('E');
3339+
Scheduler.unstable_runWithPriority(
3340+
Scheduler.unstable_IdlePriority,
3341+
() => {
3342+
setText('E');
3343+
},
3344+
);
3345+
});
3346+
// Even though the fragment fiber is not part of the return path, we should
3347+
// be able to finish rendering.
3348+
expect(Scheduler).toHaveYielded(['Suspend! [D]', 'E']);
3349+
expect(root).toMatchRenderedOutput(<span prop="E" />);
3350+
},
3351+
);
3352+
32693353
it(
32703354
'after showing fallback, should not flip back to primary content until ' +
32713355
'the update that suspended finishes',

0 commit comments

Comments
 (0)