Bugfix: Do not unhide a suspended tree without finishing the suspended update (#18411)
* Bugfix: Suspended update must finish to unhide When we commit a fallback, we cannot unhide the content without including the level that originally suspended. That's because the work at level outside the boundary (i.e. everything that wasn't hidden during that render) already committed. * Test unblocking with a high-pri update
Andrew Clark committed
Mar 30, 2020 at 11:25 UTC
d7382b6c43b63ce15ce091cf13db8cd1f3c4b7ae
5 files changed
+363
-33
packages/react-debug-tools/src/__tests__/ReactDevToolsHooksIntegration-test.js
+1
@@ -265,6 +265,7 @@ describe('React hooks DevTools integration', () => {
265
// Release the lock
266
setSuspenseHandler(() => false);
267
scheduleUpdate(fiber); // Re-render
268
+ Scheduler.unstable_flushAll();
269
expect(renderer.toJSON().children).toEqual(['Done']);
270
scheduleUpdate(fiber); // Re-render
271
expect(renderer.toJSON().children).toEqual(['Done']);
packages/react-reconciler/src/ReactFiberBeginWork.js
+101
-30
@@ -1558,37 +1558,102 @@ function validateFunctionComponentInDev(workInProgress: Fiber, Component: any) {
1558
}
1559
}
1560
1561
-const SUSPENDED_MARKER: SuspenseState = {
1562
- dehydrated: null,
1563
- retryTime: NoWork,
1564
-};
1561
+function mountSuspenseState(
1562
+ renderExpirationTime: ExpirationTime,
1563
+): SuspenseState {
1564
+ return {
1565
+ dehydrated: null,
1566
+ baseTime: renderExpirationTime,
1567
+ retryTime: NoWork,
1568
+ };
1569
+}
1570
+
1571
+function updateSuspenseState(
1572
+ prevSuspenseState: SuspenseState,
1573
+ renderExpirationTime: ExpirationTime,
1574
+): SuspenseState {
1575
+ const prevSuspendedTime = prevSuspenseState.baseTime;
1576
+ return {
1577
+ dehydrated: null,
1578
+ baseTime:
1579
+ // Choose whichever time is inclusive of the other one. This represents
1580
+ // the union of all the levels that suspended.
1581
+ prevSuspendedTime !== NoWork && prevSuspendedTime < renderExpirationTime
1582
+ ? prevSuspendedTime
1583
+ : renderExpirationTime,
1584
+ retryTime: NoWork,
1585
+ };
1586
+}
1587
1588
function shouldRemainOnFallback(
1589
suspenseContext: SuspenseContext,
1590
current: null | Fiber,
1591
workInProgress: Fiber,
1592
+ renderExpirationTime: ExpirationTime,
1593
) {
1571
- // If the context is telling us that we should show a fallback, and we're not
1572
- // already showing content, then we should show the fallback instead.
1573
- return (
1574
- hasSuspenseContext(
1575
- suspenseContext,
1576
- (ForceSuspenseFallback: SuspenseContext),
1577
- ) &&
1578
- (current === null || current.memoizedState !== null)
1594
+ // If we're already showing a fallback, there are cases where we need to
1595
+ // remain on that fallback regardless of whether the content has resolved.
1596
+ // For example, SuspenseList coordinates when nested content appears.
1597
+ if (current !== null) {
1598
+ const suspenseState: SuspenseState = current.memoizedState;
1599
+ if (suspenseState !== null) {
1600
+ // Currently showing a fallback. If the current render includes
1601
+ // the level that triggered the fallback, we must continue showing it,
1602
+ // regardless of what the Suspense context says.
1603
+ const baseTime = suspenseState.baseTime;
1604
+ if (baseTime !== NoWork && baseTime < renderExpirationTime) {
1605
+ return true;
1606
+ }
1607
+ // Otherwise, fall through to check the Suspense context.
1608
+ } else {
1609
+ // Currently showing content. Don't hide it, even if ForceSuspenseFallack
1610
+ // is true. More precise name might be "ForceRemainSuspenseFallback".
1611
+ // Note: This is a factoring smell. Can't remain on a fallback if there's
1612
+ // no fallback to remain on.
1613
+ return false;
1614
+ }
1615
+ }
1616
+ // Not currently showing content. Consult the Suspense context.
1617
+ return hasSuspenseContext(
1618
+ suspenseContext,
1619
+ (ForceSuspenseFallback: SuspenseContext),
1620
);
1621
}
1622
1623
function getRemainingWorkInPrimaryTree(
1583
- workInProgress,
1584
- currentChildExpirationTime,
1624
+ current: Fiber,
1625
+ workInProgress: Fiber,
1626
+ currentPrimaryChildFragment: Fiber | null,
1627
renderExpirationTime,
1628
) {
1629
+ const currentParentOfPrimaryChildren =
1630
+ currentPrimaryChildFragment !== null
1631
+ ? currentPrimaryChildFragment
1632
+ : current;
1633
+ const currentChildExpirationTime =
1634
+ currentParentOfPrimaryChildren.childExpirationTime;
1635
+
1636
+ const currentSuspenseState: SuspenseState = current.memoizedState;
1637
+ if (currentSuspenseState !== null) {
1638
+ // This boundary already timed out. Check if this render includes the level
1639
+ // that previously suspended.
1640
+ const baseTime = currentSuspenseState.baseTime;
1641
+ if (
1642
+ baseTime !== NoWork &&
1643
+ baseTime < renderExpirationTime &&
1644
+ baseTime > currentChildExpirationTime
1645
+ ) {
1646
+ // There's pending work at a lower level that might now be unblocked.
1647
+ return baseTime;
1648
+ }
1649
+ }
1650
+
1651
if (currentChildExpirationTime < renderExpirationTime) {
1652
// The highest priority remaining work is not part of this render. So the
1653
// remaining work has not changed.
1654
return currentChildExpirationTime;
1655
}
1656
+
1657
if ((workInProgress.mode & BlockingMode) !== NoMode) {
1658
// The highest priority remaining work is part of this render. Since we only
1659
// keep track of the highest level, we don't know if there's a lower
@@ -1630,7 +1695,12 @@ function updateSuspenseComponent(
1695
1696
if (
1697
didSuspend ||
1633
- shouldRemainOnFallback(suspenseContext, current, workInProgress)
1698
+ shouldRemainOnFallback(
1699
+ suspenseContext,
1700
+ current,
1701
+ workInProgress,
1702
+ renderExpirationTime,
1703
+ )
1704
) {
1705
// Something in this boundary's subtree already suspended. Switch to
1706
// rendering the fallback children.
@@ -1746,7 +1816,7 @@ function updateSuspenseComponent(
1816
primaryChildFragment.sibling = fallbackChildFragment;
1817
// Skip the primary children, and continue working on the
1818
// fallback children.
1749
- workInProgress.memoizedState = SUSPENDED_MARKER;
1819
+ workInProgress.memoizedState = mountSuspenseState(renderExpirationTime);
1820
workInProgress.child = primaryChildFragment;
1821
return fallbackChildFragment;
1822
} else {
@@ -1850,15 +1920,15 @@ function updateSuspenseComponent(
1920
primaryChildFragment.sibling = fallbackChildFragment;
1921
fallbackChildFragment.effectTag |= Placement;
1922
primaryChildFragment.childExpirationTime = getRemainingWorkInPrimaryTree(
1923
+ current,
1924
workInProgress,
1854
- // This argument represents the remaining work in the current
1855
- // primary tree. Since the current tree did not already time out
1856
- // the direct parent of the primary children is the Suspense
1857
- // fiber, not a fragment.
1858
- current.childExpirationTime,
1925
+ null,
1926
+ renderExpirationTime,
1927
+ );
1928
+ workInProgress.memoizedState = updateSuspenseState(
1929
+ current.memoizedState,
1930
renderExpirationTime,
1931
);
1861
- workInProgress.memoizedState = SUSPENDED_MARKER;
1932
workInProgress.child = primaryChildFragment;
1933
1934
// Skip the primary children, and continue working on the
@@ -1921,13 +1991,17 @@ function updateSuspenseComponent(
1991
fallbackChildFragment.return = workInProgress;
1992
primaryChildFragment.sibling = fallbackChildFragment;
1993
primaryChildFragment.childExpirationTime = getRemainingWorkInPrimaryTree(
1994
+ current,
1995
workInProgress,
1925
- currentPrimaryChildFragment.childExpirationTime,
1996
+ currentPrimaryChildFragment,
1997
renderExpirationTime,
1998
);
1999
// Skip the primary children, and continue working on the
2000
// fallback children.
1930
- workInProgress.memoizedState = SUSPENDED_MARKER;
2001
+ workInProgress.memoizedState = updateSuspenseState(
2002
+ current.memoizedState,
2003
+ renderExpirationTime,
2004
+ );
2005
workInProgress.child = primaryChildFragment;
2006
return fallbackChildFragment;
2007
} else {
@@ -2019,17 +2093,14 @@ function updateSuspenseComponent(
2093
primaryChildFragment.sibling = fallbackChildFragment;
2094
fallbackChildFragment.effectTag |= Placement;
2095
primaryChildFragment.childExpirationTime = getRemainingWorkInPrimaryTree(
2096
+ current,
2097
workInProgress,
2023
- // This argument represents the remaining work in the current
2024
- // primary tree. Since the current tree did not already time out
2025
- // the direct parent of the primary children is the Suspense
2026
- // fiber, not a fragment.
2027
- current.childExpirationTime,
2098
+ null,
2099
renderExpirationTime,
2100
);
2101
// Skip the primary children, and continue working on the
2102
// fallback children.
2032
- workInProgress.memoizedState = SUSPENDED_MARKER;
2103
+ workInProgress.memoizedState = mountSuspenseState(renderExpirationTime);
2104
workInProgress.child = primaryChildFragment;
2105
return fallbackChildFragment;
2106
} else {
packages/react-reconciler/src/ReactFiberHydrationContext.js
+2
-1
@@ -55,7 +55,7 @@ import {
55
didNotFindHydratableSuspenseInstance,
56
} from './ReactFiberHostConfig';
57
import {enableSuspenseServerRenderer} from 'shared/ReactFeatureFlags';
58
-import {Never} from './ReactFiberExpirationTime';
58
+import {Never, NoWork} from './ReactFiberExpirationTime';
59
60
// The deepest Fiber on the stack involved in a hydration context.
61
// This may have been an insertion or a hydration.
@@ -231,6 +231,7 @@ function tryHydrate(fiber, nextInstance) {
231
if (suspenseInstance !== null) {
232
const suspenseState: SuspenseState = {
233
dehydrated: suspenseInstance,
234
+ baseTime: NoWork,
235
retryTime: Never,
236
};
237
fiber.memoizedState = suspenseState;
packages/react-reconciler/src/ReactFiberSuspenseComponent.js
+4
@@ -35,6 +35,10 @@ export type SuspenseState = {|
35
// here to indicate that it is dehydrated (flag) and for quick access
36
// to check things like isSuspenseInstancePending.
37
dehydrated: null | SuspenseInstance,
38
+ // Represents the work that was deprioritized when we committed the fallback.
39
+ // The work outside the boundary already committed at this level, so we cannot
40
+ // unhide the content without including it.
41
+ baseTime: ExpirationTime,
42
// Represents the earliest expiration time we should attempt to hydrate
43
// a dehydrated boundary at.
44
// Never is the default for dehydrated boundaries.
packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js
+255
-2
@@ -3168,9 +3168,14 @@ describe('ReactSuspenseWithNoopRenderer', () => {
3168
});
3169
3170
expect(Scheduler).toHaveYielded([
3171
- // First try to update the suspended tree. It's still suspended.
3172
- 'Suspend! [C]',
3171
+ // First try to render the high pri update. We won't try to re-render
3172
+ // the suspended tree during this pass, because it still has unfinished
3173
+ // updates at a lower priority.
3174
'Loading...',
3175
+
3176
+ // Now try the suspended update again. It's still suspended.
3177
+ 'Suspend! [C]',
3178
+
3179
// Then complete the update to the fallback.
3180
'Still loading...',
3181
]);
@@ -3260,4 +3265,252 @@ describe('ReactSuspenseWithNoopRenderer', () => {
3265
expect(root).toMatchRenderedOutput(<span prop="D" />);
3266
},
3267
);
3268
+
3269
+ it(
3270
+ 'after showing fallback, should not flip back to primary content until ' +
3271
+ 'the update that suspended finishes',
3272
+ async () => {
3273
+ const {useState, useEffect} = React;
3274
+ const root = ReactNoop.createRoot();
3275
+
3276
+ let setOuterText;
3277
+ function Parent({step}) {
3278
+ const [text, _setText] = useState('A');
3279
+ setOuterText = _setText;
3280
+ return (
3281
+ <>
3282
+ <Text text={'Outer text: ' + text} />
3283
+ <Text text={'Outer step: ' + step} />
3284
+ <Suspense fallback={<Text text="Loading..." />}>
3285
+ <Child step={step} outerText={text} />
3286
+ </Suspense>
3287
+ </>
3288
+ );
3289
+ }
3290
+
3291
+ let setInnerText;
3292
+ function Child({step, outerText}) {
3293
+ const [text, _setText] = useState('A');
3294
+ setInnerText = _setText;
3295
+
3296
+ // This will log if the component commits in an inconsistent state
3297
+ useEffect(() => {
3298
+ if (text === outerText) {
3299
+ Scheduler.unstable_yieldValue('Commit Child');
3300
+ } else {
3301
+ Scheduler.unstable_yieldValue(
3302
+ 'FIXME: Texts are inconsistent (tearing)',
3303
+ );
3304
+ }
3305
+ }, [text, outerText]);
3306
+
3307
+ return (
3308
+ <>
3309
+ <AsyncText text={'Inner text: ' + text} />
3310
+ <Text text={'Inner step: ' + step} />
3311
+ </>
3312
+ );
3313
+ }
3314
+
3315
+ // These always update simultaneously. They must be consistent.
3316
+ function setText(text) {
3317
+ setOuterText(text);
3318
+ setInnerText(text);
3319
+ }
3320
+
3321
+ // Mount an initial tree. Resolve A so that it doesn't suspend.
3322
+ await resolveText('Inner text: A');
3323
+ await ReactNoop.act(async () => {
3324
+ root.render(<Parent step={0} />);
3325
+ });
3326
+ expect(Scheduler).toHaveYielded([
3327
+ 'Outer text: A',
3328
+ 'Outer step: 0',
3329
+ 'Inner text: A',
3330
+ 'Inner step: 0',
3331
+ 'Commit Child',
3332
+ ]);
3333
+ expect(root).toMatchRenderedOutput(
3334
+ <>
3335
+ <span prop="Outer text: A" />
3336
+ <span prop="Outer step: 0" />
3337
+ <span prop="Inner text: A" />
3338
+ <span prop="Inner step: 0" />
3339
+ </>,
3340
+ );
3341
+
3342
+ // Update. This causes the inner component to suspend.
3343
+ await ReactNoop.act(async () => {
3344
+ setText('B');
3345
+ });
3346
+ expect(Scheduler).toHaveYielded([
3347
+ 'Outer text: B',
3348
+ 'Outer step: 0',
3349
+ 'Suspend! [Inner text: B]',
3350
+ 'Inner step: 0',
3351
+ 'Loading...',
3352
+ ]);
3353
+ // Commit the placeholder
3354
+ await advanceTimers(250);
3355
+ expect(root).toMatchRenderedOutput(
3356
+ <>
3357
+ <span prop="Outer text: B" />
3358
+ <span prop="Outer step: 0" />
3359
+ <span hidden={true} prop="Inner text: A" />
3360
+ <span hidden={true} prop="Inner step: 0" />
3361
+ <span prop="Loading..." />
3362
+ </>,
3363
+ );
3364
+
3365
+ // Schedule a high pri update on the parent.
3366
+ await ReactNoop.act(async () => {
3367
+ ReactNoop.discreteUpdates(() => {
3368
+ root.render(<Parent step={1} />);
3369
+ });
3370
+ });
3371
+ // Only the outer part can update. The inner part should still show a
3372
+ // fallback because we haven't finished loading B yet. Otherwise, the
3373
+ // inner text would be inconsistent with the outer text.
3374
+ expect(Scheduler).toHaveYielded([
3375
+ 'Outer text: B',
3376
+ 'Outer step: 1',
3377
+ 'Loading...',
3378
+
3379
+ 'Suspend! [Inner text: B]',
3380
+ 'Inner step: 1',
3381
+ ]);
3382
+ expect(root).toMatchRenderedOutput(
3383
+ <>
3384
+ <span prop="Outer text: B" />
3385
+ <span prop="Outer step: 1" />
3386
+ <span hidden={true} prop="Inner text: A" />
3387
+ <span hidden={true} prop="Inner step: 0" />
3388
+ <span prop="Loading..." />
3389
+ </>,
3390
+ );
3391
+
3392
+ // Now finish resolving the inner text
3393
+ await ReactNoop.act(async () => {
3394
+ await resolveText('Inner text: B');
3395
+ });
3396
+ expect(Scheduler).toHaveYielded([
3397
+ 'Promise resolved [Inner text: B]',
3398
+ 'Inner text: B',
3399
+ 'Inner step: 1',
3400
+ 'Commit Child',
3401
+ ]);
3402
+ expect(root).toMatchRenderedOutput(
3403
+ <>
3404
+ <span prop="Outer text: B" />
3405
+ <span prop="Outer step: 1" />
3406
+ <span prop="Inner text: B" />
3407
+ <span prop="Inner step: 1" />
3408
+ </>,
3409
+ );
3410
+ },
3411
+ );
3412
+
3413
+ it('a high pri update can unhide a boundary that suspended at a different level', async () => {
3414
+ const {useState, useEffect} = React;
3415
+ const root = ReactNoop.createRoot();
3416
+
3417
+ let setOuterText;
3418
+ function Parent({step}) {
3419
+ const [text, _setText] = useState('A');
3420
+ setOuterText = _setText;
3421
+ return (
3422
+ <>
3423
+ <Text text={'Outer: ' + text + step} />
3424
+ <Suspense fallback={<Text text="Loading..." />}>
3425
+ <Child step={step} outerText={text} />
3426
+ </Suspense>
3427
+ </>
3428
+ );
3429
+ }
3430
+
3431
+ let setInnerText;
3432
+ function Child({step, outerText}) {
3433
+ const [text, _setText] = useState('A');
3434
+ setInnerText = _setText;
3435
+
3436
+ // This will log if the component commits in an inconsistent state
3437
+ useEffect(() => {
3438
+ if (text === outerText) {
3439
+ Scheduler.unstable_yieldValue('Commit Child');
3440
+ } else {
3441
+ Scheduler.unstable_yieldValue(
3442
+ 'FIXME: Texts are inconsistent (tearing)',
3443
+ );
3444
+ }
3445
+ }, [text, outerText]);
3446
+
3447
+ return (
3448
+ <>
3449
+ <AsyncText text={'Inner: ' + text + step} />
3450
+ </>
3451
+ );
3452
+ }
3453
+
3454
+ // These always update simultaneously. They must be consistent.
3455
+ function setText(text) {
3456
+ setOuterText(text);
3457
+ setInnerText(text);
3458
+ }
3459
+
3460
+ // Mount an initial tree. Resolve A so that it doesn't suspend.
3461
+ await resolveText('Inner: A0');
3462
+ await ReactNoop.act(async () => {
3463
+ root.render(<Parent step={0} />);
3464
+ });
3465
+ expect(Scheduler).toHaveYielded(['Outer: A0', 'Inner: A0', 'Commit Child']);
3466
+ expect(root).toMatchRenderedOutput(
3467
+ <>
3468
+ <span prop="Outer: A0" />
3469
+ <span prop="Inner: A0" />
3470
+ </>,
3471
+ );
3472
+
3473
+ // Update. This causes the inner component to suspend.
3474
+ await ReactNoop.act(async () => {
3475
+ setText('B');
3476
+ });
3477
+ expect(Scheduler).toHaveYielded([
3478
+ 'Outer: B0',
3479
+ 'Suspend! [Inner: B0]',
3480
+ 'Loading...',
3481
+ ]);
3482
+ // Commit the placeholder
3483
+ await advanceTimers(250);
3484
+ expect(root).toMatchRenderedOutput(
3485
+ <>
3486
+ <span prop="Outer: B0" />
3487
+ <span hidden={true} prop="Inner: A0" />
3488
+ <span prop="Loading..." />
3489
+ </>,
3490
+ );
3491
+
3492
+ // Schedule a high pri update on the parent. This will unblock the content.
3493
+ await resolveText('Inner: B1');
3494
+ await ReactNoop.act(async () => {
3495
+ ReactNoop.discreteUpdates(() => {
3496
+ root.render(<Parent step={1} />);
3497
+ });
3498
+ });
3499
+
3500
+ expect(Scheduler).toHaveYielded([
3501
+ // First the outer part of the tree updates, at high pri.
3502
+ 'Outer: B1',
3503
+ 'Loading...',
3504
+
3505
+ // Then we retry the boundary.
3506
+ 'Inner: B1',
3507
+ 'Commit Child',
3508
+ ]);
3509
+ expect(root).toMatchRenderedOutput(
3510
+ <>
3511
+ <span prop="Outer: B1" />
3512
+ <span prop="Inner: B1" />
3513
+ </>,
3514
+ );
3515
+ });
3516
});