@samitouri / QOS-React-2 / commits / d7382b6c43

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 });