@samitouri / QOS-React-2 / commits / 914b57be27

Move legacy hidden API to new internal Fiber type (#18782)

* Unhide Suspense trees without entanglement When a Suspense boundary is in its fallback state, you cannot switch back to the main content without also finishing any updates inside the tree that might have been skipped. That would be a form of tearing. Before we fixed this in #18411, the way this bug manifested was that a boundary was suspended by an update that originated from a child component (as opposed to props from a parent). While the fallback was showing, it received another update, this time at high priority. React would render the high priority update without also including the original update. That would cause the fallback to switch back to the main content, since the update that caused the tree to suspend was no longer part of the render. But then, React would immediately try to render the original update, which would again suspend and show the fallback, leading to a momentary flicker in the UI. The approach added in #18411 is, when receiving a high priority update to a Suspense tree that's in its fallback state is to bail out, keep showing the fallback and finish the update in the rest of the tree. After that commits, render again at the original priority. Because low priority expiration times are inclusive of higher priority expiration times, this ensures that all the updates are committed together. The new approach in this commit is to turn `renderExpirationTime` into a context-like value that lives on the stack. Then, when unhiding the Suspense boundary, we can push a new `renderExpirationTime` that is inclusive of both the high pri update and the original update that suspended. Then the boundary can be unblocked in a single render pass. An advantage of the old approach is that by deferring the work of unhiding, there's less work to do in the high priority update. The key advantage of the new approach is that it solves the consistency problem without having to entangle the entire root. * Create internal LegacyHidden type This only exists so we can clean up the internal implementation of `<div hidden={isHidden} />`, which is not a stable feature. The goal is to move everything to the new Offscreen type instead. However, Offscreen has different semantics, so before we can remove the legacy API, we have to migrate our internal usage at Facebook. So we'll need to maintain both temporarily. In this initial commit, I've only added the type. It's not used anywhere. The next step is to use it to implement `hidden`. * Use LegacyHidden to implement old hidden API If a host component receives a `hidden` prop, we wrap its children in an Offscreen fiber. This is similar to what we do for Suspense children. The LegacyHidden type happens to share the same implementation as the new Offscreen type, for now, but using separate types allows us to fork the behavior later when we implement our planned changes to the Offscreen API. There are two subtle semantic changes here. One is that the children of the host component will have their visibility toggled using the same mechanism we use for Offscreen and Suspense: find the nearest host node children and give them a style of `display: none`. We didn't used to do this in the old API, because the `hidden` DOM attribute on the parent already hides them. So with this change, we're actually "overhiding" the children. I considered addressing this, but I figure I'll leave it as-is in case we want to expose the LegacyHidden component type temporarily to ease migration of Facebook's internal callers to the Offscreen type. The other subtle semantic change is that, because of the extra fiber that wraps around the children, this pattern will cause the children to lose state: ```js return isHidden ? <div hidden={true} /> : <div />; ``` The reason is that I didn't want to wrap every single host component in an extra fiber. So I only wrap them if a `hidden` prop exists. In the above example, that means the children are conditionally wrapped in an extra fiber, so they don't line up during reconciliation, so they get remounted every time `isHidden` changes. The fix is to rewrite to: ```js return <div hidden={isHidden} />; ``` I don't anticipate this will be a problem at Facebook, especially since we're only supposed to use `hidden` via a userspace wrapper component. (And since the bad pattern isn't very React-y, anyway.) Again, the eventual goal is to delete this completely and replace it with Offscreen.

Andrew Clark committed Apr 30, 2020 at 19:37 UTC 914b57be27a8697d8ed83266466b30378af379f0
11 files changed +328 -145
packages/react-reconciler/src/ReactFiber.new.js
+27
@@ -56,6 +56,7 @@ import {
56 ScopeComponent,
57 Block,
58 OffscreenComponent,
59 + LegacyHiddenComponent,
60 } from './ReactWorkTags';
61 import getComponentName from 'shared/getComponentName';
62
@@ -90,6 +91,7 @@ import {
91 REACT_SCOPE_TYPE,
92 REACT_BLOCK_TYPE,
93 REACT_OFFSCREEN_TYPE,
94 + REACT_LEGACY_HIDDEN_TYPE,
95 } from 'shared/ReactSymbols';
96
97 export type {Fiber};
@@ -521,6 +523,13 @@ export function createFiberFromTypeAndProps(
523 expirationTime,
524 key,
525 );
526 + case REACT_LEGACY_HIDDEN_TYPE:
527 + return createFiberFromLegacyHidden(
528 + pendingProps,
529 + mode,
530 + expirationTime,
531 + key,
532 + );
533 default: {
534 if (typeof type === 'object' && type !== null) {
535 switch (type.$$typeof) {
@@ -756,6 +765,24 @@ export function createFiberFromOffscreen(
765 return fiber;
766 }
767
768 +export function createFiberFromLegacyHidden(
769 + pendingProps: OffscreenProps,
770 + mode: TypeOfMode,
771 + expirationTime: ExpirationTimeOpaque,
772 + key: null | string,
773 +) {
774 + const fiber = createFiber(LegacyHiddenComponent, pendingProps, key, mode);
775 + // TODO: The LegacyHidden fiber shouldn't have a type. It has a tag.
776 + // This needs to be fixed in getComponentName so that it relies on the tag
777 + // instead.
778 + if (__DEV__) {
779 + fiber.type = REACT_LEGACY_HIDDEN_TYPE;
780 + }
781 + fiber.elementType = REACT_LEGACY_HIDDEN_TYPE;
782 + fiber.expirationTime_opaque = expirationTime;
783 + return fiber;
784 +}
785 +
786 export function createFiberFromText(
787 content: string,
788 mode: TypeOfMode,
packages/react-reconciler/src/ReactFiberBeginWork.new.js
+161 -105
@@ -50,6 +50,7 @@ import {
50 ScopeComponent,
51 Block,
52 OffscreenComponent,
53 + LegacyHiddenComponent,
54 } from './ReactWorkTags';
55 import {
56 NoEffect,
@@ -79,7 +80,12 @@ import invariant from 'shared/invariant';
80 import shallowEqual from 'shared/shallowEqual';
81 import getComponentName from 'shared/getComponentName';
82 import ReactStrictModeWarnings from './ReactStrictModeWarnings.new';
82 -import {REACT_LAZY_TYPE, getIteratorFn} from 'shared/ReactSymbols';
83 +import {
84 + REACT_ELEMENT_TYPE,
85 + REACT_LAZY_TYPE,
86 + REACT_LEGACY_HIDDEN_TYPE,
87 + getIteratorFn,
88 +} from 'shared/ReactSymbols';
89 import {
90 getCurrentFiberOwnerNameInDevOrNull,
91 setIsRendering,
@@ -187,6 +193,7 @@ import {
193 renderDidSuspendDelayIfPossible,
194 markUnprocessedUpdateTime,
195 getWorkInProgressRoot,
196 + pushRenderExpirationTime,
197 } from './ReactFiberWorkLoop.new';
198
199 import {disableLogs, reenableLogs} from 'shared/ConsolePatchingDev';
@@ -569,15 +576,67 @@ function updateOffscreenComponent(
576 const nextProps: OffscreenProps = workInProgress.pendingProps;
577 const nextChildren = nextProps.children;
578
572 - if (current !== null) {
573 - if (nextProps.mode === 'hidden') {
574 - // TODO: Should currently be unreachable because Offscreen is only used as
575 - // an implementation detail of Suspense. Once this is a public API, it
576 - // will need to create an OffscreenState.
579 + const prevState: OffscreenState | null =
580 + current !== null ? current.memoizedState : null;
581 +
582 + if (nextProps.mode === 'hidden') {
583 + if (
584 + !isSameExpirationTime(renderExpirationTime, (Never: ExpirationTimeOpaque))
585 + ) {
586 + let nextBaseTime;
587 + if (prevState !== null) {
588 + const prevBaseTime = prevState.baseTime;
589 + nextBaseTime = !isSameOrHigherPriority(
590 + prevBaseTime,
591 + renderExpirationTime,
592 + )
593 + ? prevBaseTime
594 + : renderExpirationTime;
595 + } else {
596 + nextBaseTime = renderExpirationTime;
597 + }
598 +
599 + // Schedule this fiber to re-render at offscreen priority. Then bailout.
600 + if (enableSchedulerTracing) {
601 + markSpawnedWork((Never: ExpirationTimeOpaque));
602 + }
603 + workInProgress.expirationTime_opaque = workInProgress.childExpirationTime_opaque = Never;
604 + const nextState: OffscreenState = {
605 + baseTime: nextBaseTime,
606 + };
607 + workInProgress.memoizedState = nextState;
608 + // We're about to bail out, but we need to push this to the stack anyway
609 + // to avoid a push/pop misalignment.
610 + pushRenderExpirationTime(workInProgress, nextBaseTime);
611 + return null;
612 } else {
578 - // Clear the offscreen state.
613 + // Rendering at offscreen, so we can clear the base time.
614 + const nextState: OffscreenState = {
615 + baseTime: NoWork,
616 + };
617 + workInProgress.memoizedState = nextState;
618 + pushRenderExpirationTime(workInProgress, renderExpirationTime);
619 + }
620 + } else {
621 + let subtreeRenderTime;
622 + if (prevState !== null) {
623 + const baseTime = prevState.baseTime;
624 + subtreeRenderTime = !isSameOrHigherPriority(
625 + baseTime,
626 + renderExpirationTime,
627 + )
628 + ? baseTime
629 + : renderExpirationTime;
630 +
631 + // Since we're not hidden anymore, reset the state
632 workInProgress.memoizedState = null;
633 + } else {
634 + // We weren't previously hidden, and we still aren't, so there's nothing
635 + // special to do. Need to push to the stack regardless, though, to avoid
636 + // a push/pop misalignment.
637 + subtreeRenderTime = renderExpirationTime;
638 }
639 + pushRenderExpirationTime(workInProgress, subtreeRenderTime);
640 }
641
642 reconcileChildren(
@@ -589,6 +648,11 @@ function updateOffscreenComponent(
648 return workInProgress.child;
649 }
650
651 +// Note: These happen to have identical begin phases, for now. We shouldn't hold
652 +// ourselves to this constraint, though. If the behavior diverges, we should
653 +// fork the function.
654 +const updateLegacyHiddenComponent = updateOffscreenComponent;
655 +
656 function updateFragment(
657 current: Fiber | null,
658 workInProgress: Fiber,
@@ -1138,21 +1202,23 @@ function updateHostComponent(
1202
1203 markRef(current, workInProgress);
1204
1141 - // Check the host config to see if the children are offscreen/hidden.
1205 if (
1143 - workInProgress.mode & ConcurrentMode &&
1144 - !isSameExpirationTime(
1145 - renderExpirationTime,
1146 - (Never: ExpirationTimeOpaque),
1147 - ) &&
1148 - shouldDeprioritizeSubtree(type, nextProps)
1206 + (workInProgress.mode & ConcurrentMode) !== NoMode &&
1207 + nextProps.hasOwnProperty('hidden')
1208 ) {
1150 - if (enableSchedulerTracing) {
1151 - markSpawnedWork((Never: ExpirationTimeOpaque));
1152 - }
1153 - // Schedule this fiber to re-render at offscreen priority. Then bailout.
1154 - workInProgress.expirationTime_opaque = workInProgress.childExpirationTime_opaque = Never;
1155 - return null;
1209 + const wrappedChildren = {
1210 + $$typeof: REACT_ELEMENT_TYPE,
1211 + type: REACT_LEGACY_HIDDEN_TYPE,
1212 + key: null,
1213 + ref: null,
1214 + props: {
1215 + children: nextChildren,
1216 + // Check the host config to see if the children are offscreen/hidden.
1217 + mode: shouldDeprioritizeSubtree(type, nextProps) ? 'hidden' : 'visible',
1218 + },
1219 + _owner: __DEV__ ? {} : null,
1220 + };
1221 + nextChildren = wrappedChildren;
1222 }
1223
1224 reconcileChildren(
@@ -1651,33 +1717,32 @@ function validateFunctionComponentInDev(workInProgress: Fiber, Component: any) {
1717 }
1718 }
1719
1654 -function mountSuspenseState(
1720 +const SUSPENDED_MARKER: SuspenseState = {
1721 + dehydrated: null,
1722 + retryTime: NoWork,
1723 +};
1724 +
1725 +function mountSuspenseOffscreenState(
1726 renderExpirationTime: ExpirationTimeOpaque,
1656 -): SuspenseState {
1727 +): OffscreenState {
1728 return {
1658 - dehydrated: null,
1729 baseTime: renderExpirationTime,
1660 - retryTime: NoWork,
1730 };
1731 }
1732
1664 -function updateSuspenseState(
1665 - prevSuspenseState: SuspenseState,
1733 +function updateSuspenseOffscreenState(
1734 + prevOffscreenState: OffscreenState,
1735 renderExpirationTime: ExpirationTimeOpaque,
1667 -): SuspenseState {
1668 - const prevSuspendedTime = prevSuspenseState.baseTime;
1736 +): OffscreenState {
1737 + const prevBaseTime = prevOffscreenState.baseTime;
1738 return {
1670 - dehydrated: null,
1739 + // Choose whichever time is inclusive of the other one. This represents
1740 + // the union of all the levels that suspended.
1741 baseTime:
1672 - // Choose whichever time is inclusive of the other one. This represents
1673 - // the union of all the levels that suspended.
1674 - !isSameExpirationTime(
1675 - prevSuspendedTime,
1676 - (NoWork: ExpirationTimeOpaque),
1677 - ) && !isSameOrHigherPriority(prevSuspendedTime, renderExpirationTime)
1678 - ? prevSuspendedTime
1742 + !isSameExpirationTime(prevBaseTime, (NoWork: ExpirationTimeOpaque)) &&
1743 + !isSameOrHigherPriority(prevBaseTime, renderExpirationTime)
1744 + ? prevBaseTime
1745 : renderExpirationTime,
1680 - retryTime: NoWork,
1746 };
1747 }
1748
@@ -1692,19 +1757,7 @@ function shouldRemainOnFallback(
1757 // For example, SuspenseList coordinates when nested content appears.
1758 if (current !== null) {
1759 const suspenseState: SuspenseState = current.memoizedState;
1695 - if (suspenseState !== null) {
1696 - // Currently showing a fallback. If the current render includes
1697 - // the level that triggered the fallback, we must continue showing it,
1698 - // regardless of what the Suspense context says.
1699 - const baseTime = suspenseState.baseTime;
1700 - if (
1701 - !isSameExpirationTime(baseTime, (NoWork: ExpirationTimeOpaque)) &&
1702 - !isSameOrHigherPriority(baseTime, renderExpirationTime)
1703 - ) {
1704 - return true;
1705 - }
1706 - // Otherwise, fall through to check the Suspense context.
1707 - } else {
1760 + if (suspenseState === null) {
1761 // Currently showing content. Don't hide it, even if ForceSuspenseFallack
1762 // is true. More precise name might be "ForceRemainSuspenseFallback".
1763 // Note: This is a factoring smell. Can't remain on a fallback if there's
@@ -1712,6 +1765,7 @@ function shouldRemainOnFallback(
1765 return false;
1766 }
1767 }
1768 +
1769 // Not currently showing content. Consult the Suspense context.
1770 return hasSuspenseContext(
1771 suspenseContext,
@@ -1725,20 +1779,6 @@ function getRemainingWorkInPrimaryTree(
1779 renderExpirationTime,
1780 ) {
1781 const currentChildExpirationTime = current.childExpirationTime_opaque;
1728 - const currentSuspenseState: SuspenseState = current.memoizedState;
1729 - if (currentSuspenseState !== null) {
1730 - // This boundary already timed out. Check if this render includes the level
1731 - // that previously suspended.
1732 - const baseTime = currentSuspenseState.baseTime;
1733 - if (
1734 - !isSameExpirationTime(baseTime, (NoWork: ExpirationTimeOpaque)) &&
1735 - !isSameOrHigherPriority(baseTime, renderExpirationTime)
1736 - ) {
1737 - // There's pending work at a lower level that might now be unblocked.
1738 - return baseTime;
1739 - }
1740 - }
1741 -
1782 if (
1783 !isSameOrHigherPriority(currentChildExpirationTime, renderExpirationTime)
1784 ) {
@@ -1880,8 +1920,10 @@ function updateSuspenseComponent(
1920 renderExpirationTime,
1921 );
1922 const primaryChildFragment: Fiber = (workInProgress.child: any);
1883 - primaryChildFragment.memoizedState = ({baseTime: NoWork}: OffscreenState);
1884 - workInProgress.memoizedState = mountSuspenseState(renderExpirationTime);
1923 + primaryChildFragment.memoizedState = mountSuspenseOffscreenState(
1924 + renderExpirationTime,
1925 + );
1926 + workInProgress.memoizedState = SUSPENDED_MARKER;
1927 return fallbackFragment;
1928 } else {
1929 const nextPrimaryChildren = nextProps.children;
@@ -1935,14 +1977,10 @@ function updateSuspenseComponent(
1977 renderExpirationTime,
1978 );
1979 const primaryChildFragment: Fiber = (workInProgress.child: any);
1938 - primaryChildFragment.memoizedState = ({
1939 - baseTime: NoWork,
1940 - }: OffscreenState);
1941 - workInProgress.memoizedState = updateSuspenseState(
1942 - current.memoizedState,
1980 + primaryChildFragment.memoizedState = mountSuspenseOffscreenState(
1981 renderExpirationTime,
1982 );
1945 -
1983 + workInProgress.memoizedState = SUSPENDED_MARKER;
1984 return fallbackChildFragment;
1985 }
1986 }
@@ -1959,18 +1997,21 @@ function updateSuspenseComponent(
1997 renderExpirationTime,
1998 );
1999 const primaryChildFragment: Fiber = (workInProgress.child: any);
1962 - primaryChildFragment.memoizedState = ({
1963 - baseTime: NoWork,
1964 - }: OffscreenState);
2000 + const prevOffscreenState: OffscreenState | null = (current.child: any)
2001 + .memoizedState;
2002 + primaryChildFragment.memoizedState =
2003 + prevOffscreenState === null
2004 + ? mountSuspenseOffscreenState(renderExpirationTime)
2005 + : updateSuspenseOffscreenState(
2006 + prevOffscreenState,
2007 + renderExpirationTime,
2008 + );
2009 primaryChildFragment.childExpirationTime_opaque = getRemainingWorkInPrimaryTree(
2010 current,
2011 workInProgress,
2012 renderExpirationTime,
2013 );
1970 - workInProgress.memoizedState = updateSuspenseState(
1971 - current.memoizedState,
1972 - renderExpirationTime,
1973 - );
2014 + workInProgress.memoizedState = SUSPENDED_MARKER;
2015 return fallbackChildFragment;
2016 } else {
2017 const nextPrimaryChildren = nextProps.children;
@@ -1997,9 +2038,15 @@ function updateSuspenseComponent(
2038 renderExpirationTime,
2039 );
2040 const primaryChildFragment: Fiber = (workInProgress.child: any);
2000 - primaryChildFragment.memoizedState = ({
2001 - baseTime: NoWork,
2002 - }: OffscreenState);
2041 + const prevOffscreenState: OffscreenState | null = (current.child: any)
2042 + .memoizedState;
2043 + primaryChildFragment.memoizedState =
2044 + prevOffscreenState === null
2045 + ? mountSuspenseOffscreenState(renderExpirationTime)
2046 + : updateSuspenseOffscreenState(
2047 + prevOffscreenState,
2048 + renderExpirationTime,
2049 + );
2050 primaryChildFragment.childExpirationTime_opaque = getRemainingWorkInPrimaryTree(
2051 current,
2052 workInProgress,
@@ -2007,7 +2054,7 @@ function updateSuspenseComponent(
2054 );
2055 // Skip the primary children, and continue working on the
2056 // fallback children.
2010 - workInProgress.memoizedState = mountSuspenseState(renderExpirationTime);
2057 + workInProgress.memoizedState = SUSPENDED_MARKER;
2058 return fallbackChildFragment;
2059 } else {
2060 // Still haven't timed out. Continue rendering the children, like we
@@ -3215,21 +3262,6 @@ function beginWork(
3262 break;
3263 case HostComponent:
3264 pushHostContext(workInProgress);
3218 - if (
3219 - workInProgress.mode & ConcurrentMode &&
3220 - !isSameExpirationTime(
3221 - renderExpirationTime,
3222 - (Never: ExpirationTimeOpaque),
3223 - ) &&
3224 - shouldDeprioritizeSubtree(workInProgress.type, newProps)
3225 - ) {
3226 - if (enableSchedulerTracing) {
3227 - markSpawnedWork((Never: ExpirationTimeOpaque));
3228 - }
3229 - // Schedule this fiber to re-render at offscreen priority. Then bailout.
3230 - workInProgress.expirationTime_opaque = workInProgress.childExpirationTime_opaque = Never;
3231 - return null;
3232 - }
3265 break;
3266 case ClassComponent: {
3267 const Component = workInProgress.type;
@@ -3384,6 +3416,23 @@ function beginWork(
3416 return null;
3417 }
3418 }
3419 + case OffscreenComponent:
3420 + case LegacyHiddenComponent: {
3421 + // Need to check if the tree still needs to be deferred. This is
3422 + // almost identical to the logic used in the normal update path,
3423 + // so we'll just enter that. The only difference is we'll bail out
3424 + // at the next level instead of this one, because the child props
3425 + // have not changed. Which is fine.
3426 + // TODO: Probably should refactor `beginWork` to split the bailout
3427 + // path from the normal path. I'm tempted to do a labeled break here
3428 + // but I won't :)
3429 + workInProgress.expirationTime_opaque = NoWork;
3430 + return updateOffscreenComponent(
3431 + current,
3432 + workInProgress,
3433 + renderExpirationTime,
3434 + );
3435 + }
3436 }
3437 return bailoutOnAlreadyFinishedWork(
3438 current,
@@ -3536,13 +3585,6 @@ function beginWork(
3585 renderExpirationTime,
3586 );
3587 }
3539 - case OffscreenComponent: {
3540 - return updateOffscreenComponent(
3541 - current,
3542 - workInProgress,
3543 - renderExpirationTime,
3544 - );
3545 - }
3588 case SimpleMemoComponent: {
3589 return updateSimpleMemoComponent(
3590 current,
@@ -3609,6 +3651,20 @@ function beginWork(
3651 }
3652 break;
3653 }
3654 + case OffscreenComponent: {
3655 + return updateOffscreenComponent(
3656 + current,
3657 + workInProgress,
3658 + renderExpirationTime,
3659 + );
3660 + }
3661 + case LegacyHiddenComponent: {
3662 + return updateLegacyHiddenComponent(
3663 + current,
3664 + workInProgress,
3665 + renderExpirationTime,
3666 + );
3667 + }
3668 }
3669 invariant(
3670 false,
packages/react-reconciler/src/ReactFiberCommitWork.new.js
+8 -3
@@ -57,6 +57,7 @@ import {
57 ScopeComponent,
58 Block,
59 OffscreenComponent,
60 + LegacyHiddenComponent,
61 } from './ReactWorkTags';
62 import {
63 invokeGuardedCallback,
@@ -817,6 +818,7 @@ function commitLifeCycles(
818 case FundamentalComponent:
819 case ScopeComponent:
820 case OffscreenComponent:
821 + case LegacyHiddenComponent:
822 return;
823 }
824 invariant(
@@ -847,7 +849,8 @@ function hideOrUnhideAllChildren(finishedWork, isHidden) {
849 unhideTextInstance(instance, node.memoizedProps);
850 }
851 } else if (
850 - node.tag === OffscreenComponent &&
852 + (node.tag === OffscreenComponent ||
853 + node.tag === LegacyHiddenComponent) &&
854 (node.memoizedState: OffscreenState) !== null &&
855 node !== finishedWork
856 ) {
@@ -1592,7 +1595,8 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void {
1595 }
1596 break;
1597 }
1595 - case OffscreenComponent: {
1598 + case OffscreenComponent:
1599 + case LegacyHiddenComponent: {
1600 return;
1601 }
1602 }
@@ -1731,7 +1735,8 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void {
1735 }
1736 break;
1737 }
1734 - case OffscreenComponent: {
1738 + case OffscreenComponent:
1739 + case LegacyHiddenComponent: {
1740 const newState: OffscreenState | null = finishedWork.memoizedState;
1741 const isHidden = newState !== null;
1742 hideOrUnhideAllChildren(finishedWork, isHidden);
packages/react-reconciler/src/ReactFiberCompleteWork.new.js
+5 -1
@@ -56,6 +56,7 @@ import {
56 ScopeComponent,
57 Block,
58 OffscreenComponent,
59 + LegacyHiddenComponent,
60 } from './ReactWorkTags';
61 import {NoMode, BlockingMode} from './ReactTypeOfMode';
62 import {
@@ -131,6 +132,7 @@ import {
132 renderDidSuspend,
133 renderDidSuspendDelayIfPossible,
134 renderHasNotSuspendedYet,
135 + popRenderExpirationTime,
136 } from './ReactFiberWorkLoop.new';
137 import {createFundamentalStateInstance} from './ReactFiberFundamental.new';
138 import {Never, isSameOrHigherPriority} from './ReactFiberExpirationTime.new';
@@ -1311,7 +1313,9 @@ function completeWork(
1313 return null;
1314 }
1315 break;
1314 - case OffscreenComponent: {
1316 + case OffscreenComponent:
1317 + case LegacyHiddenComponent: {
1318 + popRenderExpirationTime(workInProgress);
1319 if (current !== null) {
1320 const nextState: OffscreenState | null = workInProgress.memoizedState;
1321 const prevState: OffscreenState | null = current.memoizedState;
packages/react-reconciler/src/ReactFiberHydrationContext.new.js
+1 -2
@@ -55,7 +55,7 @@ import {
55 didNotFindHydratableSuspenseInstance,
56 } from './ReactFiberHostConfig';
57 import {enableSuspenseServerRenderer} from 'shared/ReactFeatureFlags';
58 -import {Never, NoWork} from './ReactFiberExpirationTime.new';
58 +import {Never} from './ReactFiberExpirationTime.new';
59
60 // The deepest Fiber on the stack involved in a hydration context.
61 // This may have been an insertion or a hydration.
@@ -231,7 +231,6 @@ function tryHydrate(fiber, nextInstance) {
231 if (suspenseInstance !== null) {
232 const suspenseState: SuspenseState = {
233 dehydrated: suspenseInstance,
234 - baseTime: NoWork,
234 retryTime: Never,
235 };
236 fiber.memoizedState = suspenseState;
packages/react-reconciler/src/ReactFiberSuspenseComponent.new.js
-4
@@ -29,10 +29,6 @@ export type SuspenseState = {|
29 // here to indicate that it is dehydrated (flag) and for quick access
30 // to check things like isSuspenseInstancePending.
31 dehydrated: null | SuspenseInstance,
32 - // Represents the work that was deprioritized when we committed the fallback.
33 - // The work outside the boundary already committed at this level, so we cannot
34 - // unhide the content without including it.
35 - baseTime: ExpirationTimeOpaque,
32 // Represents the earliest expiration time we should attempt to hydrate
33 // a dehydrated boundary at.
34 // Never is the default for dehydrated boundaries.
packages/react-reconciler/src/ReactFiberUnwindWork.new.js
+11
@@ -20,6 +20,8 @@ import {
20 ContextProvider,
21 SuspenseComponent,
22 SuspenseListComponent,
23 + OffscreenComponent,
24 + LegacyHiddenComponent,
25 } from './ReactWorkTags';
26 import {DidCapture, NoEffect, ShouldCapture} from './ReactSideEffectTags';
27 import {enableSuspenseServerRenderer} from 'shared/ReactFeatureFlags';
@@ -33,6 +35,7 @@ import {
35 popTopLevelContextObject as popTopLevelLegacyContextObject,
36 } from './ReactFiberContext.new';
37 import {popProvider} from './ReactFiberNewContext.new';
38 +import {popRenderExpirationTime} from './ReactFiberWorkLoop.new';
39
40 import invariant from 'shared/invariant';
41
@@ -105,6 +108,10 @@ function unwindWork(
108 case ContextProvider:
109 popProvider(workInProgress);
110 return null;
111 + case OffscreenComponent:
112 + case LegacyHiddenComponent:
113 + popRenderExpirationTime(workInProgress);
114 + return null;
115 default:
116 return null;
117 }
@@ -141,6 +148,10 @@ function unwindInterruptedWork(interruptedWork: Fiber) {
148 case ContextProvider:
149 popProvider(interruptedWork);
150 break;
151 + case OffscreenComponent:
152 + case LegacyHiddenComponent:
153 + popRenderExpirationTime(interruptedWork);
154 + break;
155 default:
156 break;
157 }
packages/react-reconciler/src/ReactFiberWorkLoop.new.js
+25
@@ -15,6 +15,7 @@ import type {Interaction} from 'scheduler/src/Tracing';
15 import type {SuspenseConfig} from './ReactFiberSuspenseConfig';
16 import type {SuspenseState} from './ReactFiberSuspenseComponent.new';
17 import type {Effect as HookEffect} from './ReactFiberHooks.new';
18 +import type {StackCursor} from './ReactFiberStack.new';
19
20 import {
21 warnAboutDeprecatedLifecycles,
@@ -168,6 +169,11 @@ import {
169 getIsUpdatingOpaqueValueInRenderPhaseInDEV,
170 } from './ReactFiberHooks.new';
171 import {createCapturedValue} from './ReactCapturedValue';
172 +import {
173 + push as pushToStack,
174 + pop as popFromStack,
175 + createCursor,
176 +} from './ReactFiberStack.new';
177
178 import {
179 recordCommitTime,
@@ -231,6 +237,12 @@ let workInProgressRoot: FiberRoot | null = null;
237 let workInProgress: Fiber | null = null;
238 // The expiration time we're rendering
239 let renderExpirationTime: ExpirationTimeOpaque = NoWork;
240 +
241 +// Stack that allows components to channge renderExpirationTime for its subtree
242 +const renderExpirationTimeCursor: StackCursor<ExpirationTimeOpaque> = createCursor(
243 + NoWork,
244 +);
245 +
246 // Whether to root completed, errored, suspended, etc.
247 let workInProgressRootExitStatus: RootExitStatus = RootIncomplete;
248 // A fatal error, if one is thrown
@@ -1265,6 +1277,19 @@ export function flushControlled(fn: () => mixed): void {
1277 }
1278 }
1279
1280 +export function pushRenderExpirationTime(
1281 + fiber: Fiber,
1282 + subtreeRenderTime: ExpirationTimeOpaque,
1283 +) {
1284 + pushToStack(renderExpirationTimeCursor, renderExpirationTime, fiber);
1285 + renderExpirationTime = subtreeRenderTime;
1286 +}
1287 +
1288 +export function popRenderExpirationTime(fiber: Fiber) {
1289 + renderExpirationTime = renderExpirationTimeCursor.current;
1290 + popFromStack(renderExpirationTimeCursor, fiber);
1291 +}
1292 +
1293 function prepareFreshStack(root, expirationTime) {
1294 root.finishedWork = null;
1295 root.finishedExpirationTime_opaque = NoWork;
packages/react-reconciler/src/ReactWorkTags.js
+3 -1
@@ -31,7 +31,8 @@ export type WorkTag =
31 | 20
32 | 21
33 | 22
34 - | 23;
34 + | 23
35 + | 24;
36
37 export const FunctionComponent = 0;
38 export const ClassComponent = 1;
@@ -57,3 +58,4 @@ export const FundamentalComponent = 20;
58 export const ScopeComponent = 21;
59 export const Block = 22;
60 export const OffscreenComponent = 23;
61 +export const LegacyHiddenComponent = 24;
packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js
+85 -29
@@ -3180,18 +3180,49 @@ describe('ReactSuspenseWithNoopRenderer', () => {
3180 });
3181 setFallbackText('Still loading...');
3182
3183 - expect(Scheduler).toFlushAndYield([
3184 - // First try to render the high pri update. We won't try to re-render
3185 - // the suspended tree during this pass, because it still has unfinished
3186 - // updates at a lower priority.
3187 - 'Loading...',
3188 -
3189 - // Now try the suspended update again. It's still suspended.
3190 - 'Suspend! [C]',
3191 -
3192 - // Then complete the update to the fallback.
3193 - 'Still loading...',
3194 - ]);
3183 + expect(Scheduler).toFlushAndYield(
3184 + gate(flags =>
3185 + flags.new
3186 + ? [
3187 + // First try to render the high pri update. Still suspended.
3188 + 'Suspend! [C]',
3189 + 'Loading...',
3190 +
3191 + // In the expiration times model, once the high pri update
3192 + // suspends, we can't be sure if there's additional work at a
3193 + // lower priority that might unblock the tree. We do know that
3194 + // there's a lower priority update *somehwere* in the entire
3195 + // root, though (the update to the fallback). So we try
3196 + // rendering one more time, just in case.
3197 + // TODO: We shouldn't need to do this with lanes, because we
3198 + // always know exactly which lanes have pending work in
3199 + // each tree.
3200 + 'Suspend! [C]',
3201 +
3202 + // Then complete the update to the fallback.
3203 + 'Still loading...',
3204 + ]
3205 + : [
3206 + // In the old reconciler, we don't attempt to unhdie the
3207 + // Suspense boundary at high priority. Instead, we bailout,
3208 + // then try again at the original priority that the component
3209 + // suspended. This is mostly an implementation compromise,
3210 + // though there are some advantages to this behavior, because
3211 + // attempt to unhide could slow down the rest of the update.
3212 + //
3213 + // Render that only includes the fallback, since we bailed
3214 + // out on the primary tree.
3215 + 'Loading...',
3216 +
3217 + // Now try the suspended update again at the original
3218 + // priority. It's still suspended.
3219 + 'Suspend! [C]',
3220 +
3221 + // Then complete the update to the fallback.
3222 + 'Still loading...',
3223 + ],
3224 + ),
3225 + );
3226 expect(root).toMatchRenderedOutput(
3227 <>
3228 <span hidden={true} prop="A" />
@@ -3466,17 +3497,34 @@ describe('ReactSuspenseWithNoopRenderer', () => {
3497 root.render(<Parent step={1} />);
3498 });
3499 });
3500 +
3501 // Only the outer part can update. The inner part should still show a
3502 // fallback because we haven't finished loading B yet. Otherwise, the
3503 // inner text would be inconsistent with the outer text.
3472 - expect(Scheduler).toHaveYielded([
3473 - 'Outer text: B',
3474 - 'Outer step: 1',
3475 - 'Loading...',
3476 -
3477 - 'Suspend! [Inner text: B]',
3478 - 'Inner step: 1',
3479 - ]);
3504 + expect(Scheduler).toHaveYielded(
3505 + gate(flags =>
3506 + flags.new
3507 + ? [
3508 + 'Outer text: B',
3509 + 'Outer step: 1',
3510 + 'Suspend! [Inner text: B]',
3511 + 'Inner step: 1',
3512 + 'Loading...',
3513 + ]
3514 + : [
3515 + // In the old reconciler, we first complete the outside of the
3516 + // Suspense boundary, then attempt to unhide it in a separate
3517 + // render at the original priority at which it suspended.
3518 + // First render:
3519 + 'Outer text: B',
3520 + 'Outer step: 1',
3521 + 'Loading...',
3522 + // Second render:
3523 + 'Suspend! [Inner text: B]',
3524 + 'Inner step: 1',
3525 + ],
3526 + ),
3527 + );
3528 expect(root).toMatchRenderedOutput(
3529 <>
3530 <span prop="Outer text: B" />
@@ -3595,15 +3643,23 @@ describe('ReactSuspenseWithNoopRenderer', () => {
3643 });
3644 });
3645
3598 - expect(Scheduler).toHaveYielded([
3599 - // First the outer part of the tree updates, at high pri.
3600 - 'Outer: B1',
3601 - 'Loading...',
3602 -
3603 - // Then we retry the boundary.
3604 - 'Inner: B1',
3605 - 'Commit Child',
3606 - ]);
3646 + expect(Scheduler).toHaveYielded(
3647 + gate(flags =>
3648 + flags.new
3649 + ? ['Outer: B1', 'Inner: B1', 'Commit Child']
3650 + : [
3651 + // In the old reconciler, we first complete the outside of the
3652 + // Suspense boundary, then attempt to unhide it in a separate
3653 + // render at the original priority at which it suspended.
3654 + // First render:
3655 + 'Outer: B1',
3656 + 'Loading...',
3657 + // Second render:
3658 + 'Inner: B1',
3659 + 'Commit Child',
3660 + ],
3661 + ),
3662 + );
3663 expect(root).toMatchRenderedOutput(
3664 <>
3665 <span prop="Outer: B1" />
packages/shared/ReactSymbols.js
+2
@@ -33,6 +33,7 @@ export let REACT_SCOPE_TYPE = 0xead7;
33 export let REACT_OPAQUE_ID_TYPE = 0xeae0;
34 export let REACT_DEBUG_TRACING_MODE_TYPE = 0xeae1;
35 export let REACT_OFFSCREEN_TYPE = 0xeae2;
36 +export let REACT_LEGACY_HIDDEN_TYPE = 0xeae3;
37
38 if (typeof Symbol === 'function' && Symbol.for) {
39 const symbolFor = Symbol.for;
@@ -56,6 +57,7 @@ if (typeof Symbol === 'function' && Symbol.for) {
57 REACT_OPAQUE_ID_TYPE = symbolFor('react.opaque.id');
58 REACT_DEBUG_TRACING_MODE_TYPE = symbolFor('react.debug_trace_mode');
59 REACT_OFFSCREEN_TYPE = symbolFor('react.offscreen');
60 + REACT_LEGACY_HIDDEN_TYPE = symbolFor('react.legacy_hidden');
61 }
62
63 const MAYBE_ITERATOR_SYMBOL = typeof Symbol === 'function' && Symbol.iterator;