Skip to content

Commit 1b3bc39

Browse files
committed
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.
1 parent 78e8b64 commit 1b3bc39

File tree

3 files changed

+129
-1
lines changed

3 files changed

+129
-1
lines changed

packages/react-reconciler/src/ReactFiberBeginWork.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3064,6 +3064,42 @@ function beginWork(
30643064
renderExpirationTime,
30653065
);
30663066
} else {
3067+
// The primary child fragment does not have pending work marked
3068+
// on it...
3069+
3070+
// ...usually. There's an unfortunate edge case where the fragment
3071+
// fiber is not part of the return path of the children, so when
3072+
// an update happens, the fragment doesn't get marked during
3073+
// setState. This is something we should consider addressing when
3074+
// we refactor the Fiber data structure. (There's a test with more
3075+
// details; to find it, comment out the following block and see
3076+
// which one fails.)
3077+
//
3078+
// As a workaround, we need to recompute the `childExpirationTime`
3079+
// by bubbling it up from the next level of children. This is
3080+
// based on similar logic in `resetChildExpirationTime`.
3081+
let primaryChild = primaryChildFragment.child;
3082+
while (primaryChild !== null) {
3083+
const childUpdateExpirationTime = primaryChild.expirationTime;
3084+
const childChildExpirationTime =
3085+
primaryChild.childExpirationTime;
3086+
if (
3087+
(childUpdateExpirationTime !== NoWork &&
3088+
childUpdateExpirationTime >= renderExpirationTime) ||
3089+
(childChildExpirationTime !== NoWork &&
3090+
childChildExpirationTime >= renderExpirationTime)
3091+
) {
3092+
// Found a child with an update with sufficient priority.
3093+
// Use the normal path to render the primary children again.
3094+
return updateSuspenseComponent(
3095+
current,
3096+
workInProgress,
3097+
renderExpirationTime,
3098+
);
3099+
}
3100+
primaryChild = primaryChild.sibling;
3101+
}
3102+
30673103
pushSuspenseContext(
30683104
workInProgress,
30693105
setDefaultShallowSuspenseContext(suspenseStackCursor.current),

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

Lines changed: 7 additions & 1 deletion
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
<>

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

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1335,6 +1335,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
13351335
'Suspend! [Hi]',
13361336
'Loading...',
13371337
// Re-render due to lifecycle update
1338+
'Suspend! [Hi]',
13381339
'Loading...',
13391340
]);
13401341
expect(ReactNoop.getChildren()).toEqual([span('Loading...')]);
@@ -3115,4 +3116,89 @@ describe('ReactSuspenseWithNoopRenderer', () => {
31153116
expect(root).toMatchRenderedOutput(<span prop="C" />);
31163117
},
31173118
);
3119+
3120+
it(
3121+
'regression: primary fragment fiber is not always part of setState ' +
3122+
'return path',
3123+
async () => {
3124+
// Reproduces a bug where updates inside a suspended tree are dropped
3125+
// because the fragment fiber we insert to wrap the hidden children is not
3126+
// part of the return path, so it doesn't get marked during setState.
3127+
const {useState} = React;
3128+
const root = ReactNoop.createRoot();
3129+
3130+
function Parent() {
3131+
return (
3132+
<>
3133+
<Suspense fallback={<Text text="Loading..." />}>
3134+
<Child />
3135+
</Suspense>
3136+
</>
3137+
);
3138+
}
3139+
3140+
let setText;
3141+
function Child() {
3142+
const [text, _setText] = useState('A');
3143+
setText = _setText;
3144+
return <AsyncText text={text} />;
3145+
}
3146+
3147+
// Mount an initial tree. Resolve A so that it doesn't suspend.
3148+
await resolveText('A');
3149+
await ReactNoop.act(async () => {
3150+
root.render(<Parent />);
3151+
});
3152+
expect(Scheduler).toHaveYielded(['A']);
3153+
// At this point, the setState return path follows current fiber.
3154+
expect(root).toMatchRenderedOutput(<span prop="A" />);
3155+
3156+
// Schedule another update. This will "flip" the alternate pairs.
3157+
await resolveText('B');
3158+
await ReactNoop.act(async () => {
3159+
setText('B');
3160+
});
3161+
expect(Scheduler).toHaveYielded(['B']);
3162+
// Now the setState return path follows the *alternate* fiber.
3163+
expect(root).toMatchRenderedOutput(<span prop="B" />);
3164+
3165+
// Schedule another update. This time, we'll suspend.
3166+
await ReactNoop.act(async () => {
3167+
setText('C');
3168+
});
3169+
expect(Scheduler).toHaveYielded([
3170+
'Suspend! [C]',
3171+
'Loading...',
3172+
3173+
'Suspend! [C]',
3174+
'Loading...',
3175+
]);
3176+
3177+
// Commit. This will insert a fragment fiber to wrap around the component
3178+
// that triggered the update.
3179+
await ReactNoop.act(async () => {
3180+
await advanceTimers(250);
3181+
});
3182+
expect(Scheduler).toHaveYielded(['Suspend! [C]']);
3183+
// The fragment fiber is part of the current tree, but the setState return
3184+
// path still follows the alternate path. That means the fragment fiber is
3185+
// not part of the return path.
3186+
expect(root).toMatchRenderedOutput(
3187+
<>
3188+
<span hidden={true} prop="B" />
3189+
<span prop="Loading..." />
3190+
</>,
3191+
);
3192+
3193+
// Update again. This should unsuspend the tree.
3194+
await resolveText('D');
3195+
await ReactNoop.act(async () => {
3196+
setText('D');
3197+
});
3198+
// Even though the fragment fiber is not part of the return path, we should
3199+
// be able to finish rendering.
3200+
expect(Scheduler).toHaveYielded(['D']);
3201+
expect(root).toMatchRenderedOutput(<span prop="D" />);
3202+
},
3203+
);
31183204
});

0 commit comments

Comments
 (0)