@samitouri / QOS-React-2 / commits / 82e9e99098

Suspending inside a hidden tree should not cause fallbacks to appear (#24699)

* [FORKED] Hidden trees should capture Suspense If something suspends inside a hidden tree, it should not affect anything in the visible part of the UI. This means that Offscreen acts like a Suspense boundary whenever it's in its hidden state. * Add previous commit to forked revisions

Andrew Clark committed Jul 5, 2022 at 17:51 UTC 82e9e9909876591dbe808cdac7110610e63c3896
15 files changed +672 -122
packages/react-reconciler/src/ReactFiber.new.js
+2
@@ -719,6 +719,7 @@ export function createFiberFromOffscreen(
719 const primaryChildInstance: OffscreenInstance = {
720 isHidden: false,
721 pendingMarkers: null,
722 + retryCache: null,
723 transitions: null,
724 };
725 fiber.stateNode = primaryChildInstance;
@@ -740,6 +741,7 @@ export function createFiberFromLegacyHidden(
741 isHidden: false,
742 pendingMarkers: null,
743 transitions: null,
744 + retryCache: null,
745 };
746 fiber.stateNode = instance;
747 return fiber;
packages/react-reconciler/src/ReactFiber.old.js
+2
@@ -719,6 +719,7 @@ export function createFiberFromOffscreen(
719 const primaryChildInstance: OffscreenInstance = {
720 isHidden: false,
721 pendingMarkers: null,
722 + retryCache: null,
723 transitions: null,
724 };
725 fiber.stateNode = primaryChildInstance;
@@ -740,6 +741,7 @@ export function createFiberFromLegacyHidden(
741 isHidden: false,
742 pendingMarkers: null,
743 transitions: null,
744 + retryCache: null,
745 };
746 fiber.stateNode = instance;
747 return fiber;
packages/react-reconciler/src/ReactFiberBeginWork.new.js
+143 -46
@@ -174,6 +174,8 @@ import {
174 setShallowSuspenseListContext,
175 pushPrimaryTreeSuspenseHandler,
176 pushFallbackTreeSuspenseHandler,
177 + pushOffscreenSuspenseHandler,
178 + reuseSuspenseHandlerOnStack,
179 popSuspenseHandler,
180 } from './ReactFiberSuspenseContext.new';
181 import {
@@ -678,6 +680,52 @@ function updateOffscreenComponent(
680 (enableLegacyHidden && nextProps.mode === 'unstable-defer-without-hiding')
681 ) {
682 // Rendering a hidden tree.
683 +
684 + const didSuspend = (workInProgress.flags & DidCapture) !== NoFlags;
685 + if (didSuspend) {
686 + // Something suspended inside a hidden tree
687 +
688 + // Include the base lanes from the last render
689 + const nextBaseLanes =
690 + prevState !== null
691 + ? mergeLanes(prevState.baseLanes, renderLanes)
692 + : renderLanes;
693 +
694 + if (current !== null) {
695 + // Reset to the current children
696 + let currentChild = (workInProgress.child = current.child);
697 +
698 + // The current render suspended, but there may be other lanes with
699 + // pending work. We can't read `childLanes` from the current Offscreen
700 + // fiber because we reset it when it was deferred; however, we can read
701 + // the pending lanes from the child fibers.
702 + let currentChildLanes = NoLanes;
703 + while (currentChild !== null) {
704 + currentChildLanes = mergeLanes(
705 + mergeLanes(currentChildLanes, currentChild.lanes),
706 + currentChild.childLanes,
707 + );
708 + currentChild = currentChild.sibling;
709 + }
710 + const lanesWeJustAttempted = nextBaseLanes;
711 + const remainingChildLanes = removeLanes(
712 + currentChildLanes,
713 + lanesWeJustAttempted,
714 + );
715 + workInProgress.childLanes = remainingChildLanes;
716 + } else {
717 + workInProgress.childLanes = NoLanes;
718 + workInProgress.child = null;
719 + }
720 +
721 + return deferHiddenOffscreenComponent(
722 + current,
723 + workInProgress,
724 + nextBaseLanes,
725 + renderLanes,
726 + );
727 + }
728 +
729 if ((workInProgress.mode & ConcurrentMode) === NoMode) {
730 // In legacy sync mode, don't defer the subtree. Render it now.
731 // TODO: Consider how Offscreen should work with transitions in the future
@@ -694,50 +742,28 @@ function updateOffscreenComponent(
742 }
743 }
744 reuseHiddenContextOnStack(workInProgress);
745 + pushOffscreenSuspenseHandler(workInProgress);
746 } else if (!includesSomeLane(renderLanes, (OffscreenLane: Lane))) {
747 // We're hidden, and we're not rendering at Offscreen. We will bail out
748 // and resume this tree later.
700 - let nextBaseLanes = renderLanes;
701 - if (prevState !== null) {
702 - // Include the base lanes from the last render
703 - nextBaseLanes = mergeLanes(nextBaseLanes, prevState.baseLanes);
704 - }
749
706 - // Schedule this fiber to re-render at offscreen priority. Then bailout.
750 + // Schedule this fiber to re-render at Offscreen priority
751 workInProgress.lanes = workInProgress.childLanes = laneToLanes(
752 OffscreenLane,
753 );
710 - const nextState: OffscreenState = {
711 - baseLanes: nextBaseLanes,
712 - // Save the cache pool so we can resume later.
713 - cachePool: enableCache ? getOffscreenDeferredCache() : null,
714 - };
715 - workInProgress.memoizedState = nextState;
716 - workInProgress.updateQueue = null;
717 - if (enableCache) {
718 - // push the cache pool even though we're going to bail out
719 - // because otherwise there'd be a context mismatch
720 - if (current !== null) {
721 - pushTransition(workInProgress, null, null);
722 - }
723 - }
724 -
725 - // We're about to bail out, but we need to push this to the stack anyway
726 - // to avoid a push/pop misalignment.
727 - reuseHiddenContextOnStack(workInProgress);
754
729 - if (enableLazyContextPropagation && current !== null) {
730 - // Since this tree will resume rendering in a separate render, we need
731 - // to propagate parent contexts now so we don't lose track of which
732 - // ones changed.
733 - propagateParentContextChangesToDeferredTree(
734 - current,
735 - workInProgress,
736 - renderLanes,
737 - );
738 - }
755 + // Include the base lanes from the last render
756 + const nextBaseLanes =
757 + prevState !== null
758 + ? mergeLanes(prevState.baseLanes, renderLanes)
759 + : renderLanes;
760
740 - return null;
761 + return deferHiddenOffscreenComponent(
762 + current,
763 + workInProgress,
764 + nextBaseLanes,
765 + renderLanes,
766 + );
767 } else {
768 // This is the second render. The surrounding visible content has already
769 // committed. Now we resume rendering the hidden tree.
@@ -764,6 +790,7 @@ function updateOffscreenComponent(
790 } else {
791 reuseHiddenContextOnStack(workInProgress);
792 }
793 + pushOffscreenSuspenseHandler(workInProgress);
794 }
795 } else {
796 // Rendering a visible tree.
@@ -791,6 +818,7 @@ function updateOffscreenComponent(
818
819 // Push the lanes that were skipped when we bailed out.
820 pushHiddenContext(workInProgress, prevState);
821 + reuseSuspenseHandlerOnStack(workInProgress);
822
823 // Since we're not hidden anymore, reset the state
824 workInProgress.memoizedState = null;
@@ -811,6 +839,7 @@ function updateOffscreenComponent(
839 // We're about to bail out, but we need to push this to the stack anyway
840 // to avoid a push/pop misalignment.
841 reuseHiddenContextOnStack(workInProgress);
842 + reuseSuspenseHandlerOnStack(workInProgress);
843 }
844 }
845
@@ -818,6 +847,46 @@ function updateOffscreenComponent(
847 return workInProgress.child;
848 }
849
850 +function deferHiddenOffscreenComponent(
851 + current: Fiber | null,
852 + workInProgress: Fiber,
853 + nextBaseLanes: Lanes,
854 + renderLanes: Lanes,
855 +) {
856 + const nextState: OffscreenState = {
857 + baseLanes: nextBaseLanes,
858 + // Save the cache pool so we can resume later.
859 + cachePool: enableCache ? getOffscreenDeferredCache() : null,
860 + };
861 + workInProgress.memoizedState = nextState;
862 + if (enableCache) {
863 + // push the cache pool even though we're going to bail out
864 + // because otherwise there'd be a context mismatch
865 + if (current !== null) {
866 + pushTransition(workInProgress, null, null);
867 + }
868 + }
869 +
870 + // We're about to bail out, but we need to push this to the stack anyway
871 + // to avoid a push/pop misalignment.
872 + reuseHiddenContextOnStack(workInProgress);
873 +
874 + pushOffscreenSuspenseHandler(workInProgress);
875 +
876 + if (enableLazyContextPropagation && current !== null) {
877 + // Since this tree will resume rendering in a separate render, we need
878 + // to propagate parent contexts now so we don't lose track of which
879 + // ones changed.
880 + propagateParentContextChangesToDeferredTree(
881 + current,
882 + workInProgress,
883 + renderLanes,
884 + );
885 + }
886 +
887 + return null;
888 +}
889 +
890 // Note: These happen to have identical begin phases, for now. We shouldn't hold
891 // ourselves to this constraint, though. If the behavior diverges, we should
892 // fork the function.
@@ -2109,13 +2178,19 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) {
2178 if (enableTransitionTracing) {
2179 const currentTransitions = getPendingTransitions();
2180 if (currentTransitions !== null) {
2112 - // If there are no transitions, we don't need to keep track of tracing markers
2181 const parentMarkerInstances = getMarkerInstances();
2114 - const primaryChildUpdateQueue: OffscreenQueue = {
2115 - transitions: currentTransitions,
2116 - markerInstances: parentMarkerInstances,
2117 - };
2118 - primaryChildFragment.updateQueue = primaryChildUpdateQueue;
2182 + const offscreenQueue: OffscreenQueue | null = (primaryChildFragment.updateQueue: any);
2183 + if (offscreenQueue === null) {
2184 + const newOffscreenQueue: OffscreenQueue = {
2185 + transitions: currentTransitions,
2186 + markerInstances: parentMarkerInstances,
2187 + wakeables: null,
2188 + };
2189 + primaryChildFragment.updateQueue = newOffscreenQueue;
2190 + } else {
2191 + offscreenQueue.transitions = currentTransitions;
2192 + offscreenQueue.markerInstances = parentMarkerInstances;
2193 + }
2194 }
2195 }
2196
@@ -2140,6 +2215,8 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) {
2215 );
2216 workInProgress.memoizedState = SUSPENDED_MARKER;
2217
2218 + // TODO: Transition Tracing is not yet implemented for CPU Suspense.
2219 +
2220 // Since nothing actually suspended, there will nothing to ping this to
2221 // get it started back up to attempt the next item. While in terms of
2222 // priority this work has the same priority as this current render, it's
@@ -2201,11 +2278,31 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) {
2278 const currentTransitions = getPendingTransitions();
2279 if (currentTransitions !== null) {
2280 const parentMarkerInstances = getMarkerInstances();
2204 - const primaryChildUpdateQueue: OffscreenQueue = {
2205 - transitions: currentTransitions,
2206 - markerInstances: parentMarkerInstances,
2207 - };
2208 - primaryChildFragment.updateQueue = primaryChildUpdateQueue;
2281 + const offscreenQueue: OffscreenQueue | null = (primaryChildFragment.updateQueue: any);
2282 + const currentOffscreenQueue: OffscreenQueue | null = (current.updateQueue: any);
2283 + if (offscreenQueue === null) {
2284 + const newOffscreenQueue: OffscreenQueue = {
2285 + transitions: currentTransitions,
2286 + markerInstances: parentMarkerInstances,
2287 + wakeables: null,
2288 + };
2289 + primaryChildFragment.updateQueue = newOffscreenQueue;
2290 + } else if (offscreenQueue === currentOffscreenQueue) {
2291 + // If the work-in-progress queue is the same object as current, we
2292 + // can't modify it without cloning it first.
2293 + const newOffscreenQueue: OffscreenQueue = {
2294 + transitions: currentTransitions,
2295 + markerInstances: parentMarkerInstances,
2296 + wakeables:
2297 + currentOffscreenQueue !== null
2298 + ? currentOffscreenQueue.wakeables
2299 + : null,
2300 + };
2301 + primaryChildFragment.updateQueue = newOffscreenQueue;
2302 + } else {
2303 + offscreenQueue.transitions = currentTransitions;
2304 + offscreenQueue.markerInstances = parentMarkerInstances;
2305 + }
2306 }
2307 }
2308 primaryChildFragment.childLanes = getRemainingWorkInPrimaryTree(
packages/react-reconciler/src/ReactFiberBeginWork.old.js
+2
@@ -2130,6 +2130,7 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) {
2130 const primaryChildUpdateQueue: OffscreenQueue = {
2131 transitions: currentTransitions,
2132 markerInstances: parentMarkerInstances,
2133 + wakeables: null,
2134 };
2135 primaryChildFragment.updateQueue = primaryChildUpdateQueue;
2136 }
@@ -2216,6 +2217,7 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) {
2217 const primaryChildUpdateQueue: OffscreenQueue = {
2218 transitions: currentTransitions,
2219 markerInstances: parentMarkerInstances,
2220 + wakeables: null,
2221 };
2222 primaryChildFragment.updateQueue = primaryChildUpdateQueue;
2223 }
packages/react-reconciler/src/ReactFiberCommitWork.new.js
+76 -31
@@ -1953,40 +1953,65 @@ function commitSuspenseHydrationCallbacks(
1953 }
1954 }
1955
1956 -function attachSuspenseRetryListeners(finishedWork: Fiber) {
1956 +function getRetryCache(finishedWork) {
1957 + // TODO: Unify the interface for the retry cache so we don't have to switch
1958 + // on the tag like this.
1959 + switch (finishedWork.tag) {
1960 + case SuspenseComponent:
1961 + case SuspenseListComponent: {
1962 + let retryCache = finishedWork.stateNode;
1963 + if (retryCache === null) {
1964 + retryCache = finishedWork.stateNode = new PossiblyWeakSet();
1965 + }
1966 + return retryCache;
1967 + }
1968 + case OffscreenComponent: {
1969 + const instance: OffscreenInstance = finishedWork.stateNode;
1970 + let retryCache = instance.retryCache;
1971 + if (retryCache === null) {
1972 + retryCache = instance.retryCache = new PossiblyWeakSet();
1973 + }
1974 + return retryCache;
1975 + }
1976 + default: {
1977 + throw new Error(
1978 + `Unexpected Suspense handler tag (${finishedWork.tag}). This is a ` +
1979 + 'bug in React.',
1980 + );
1981 + }
1982 + }
1983 +}
1984 +
1985 +function attachSuspenseRetryListeners(
1986 + finishedWork: Fiber,
1987 + wakeables: Set<Wakeable>,
1988 +) {
1989 // If this boundary just timed out, then it will have a set of wakeables.
1990 // For each wakeable, attach a listener so that when it resolves, React
1991 // attempts to re-render the boundary in the primary (pre-timeout) state.
1960 - const wakeables: Set<Wakeable> | null = (finishedWork.updateQueue: any);
1961 - if (wakeables !== null) {
1962 - finishedWork.updateQueue = null;
1963 - let retryCache = finishedWork.stateNode;
1964 - if (retryCache === null) {
1965 - retryCache = finishedWork.stateNode = new PossiblyWeakSet();
1966 - }
1967 - wakeables.forEach(wakeable => {
1968 - // Memoize using the boundary fiber to prevent redundant listeners.
1969 - const retry = resolveRetryWakeable.bind(null, finishedWork, wakeable);
1970 - if (!retryCache.has(wakeable)) {
1971 - retryCache.add(wakeable);
1972 -
1973 - if (enableUpdaterTracking) {
1974 - if (isDevToolsPresent) {
1975 - if (inProgressLanes !== null && inProgressRoot !== null) {
1976 - // If we have pending work still, associate the original updaters with it.
1977 - restorePendingUpdaters(inProgressRoot, inProgressLanes);
1978 - } else {
1979 - throw Error(
1980 - 'Expected finished root and lanes to be set. This is a bug in React.',
1981 - );
1982 - }
1992 + const retryCache = getRetryCache(finishedWork);
1993 + wakeables.forEach(wakeable => {
1994 + // Memoize using the boundary fiber to prevent redundant listeners.
1995 + const retry = resolveRetryWakeable.bind(null, finishedWork, wakeable);
1996 + if (!retryCache.has(wakeable)) {
1997 + retryCache.add(wakeable);
1998 +
1999 + if (enableUpdaterTracking) {
2000 + if (isDevToolsPresent) {
2001 + if (inProgressLanes !== null && inProgressRoot !== null) {
2002 + // If we have pending work still, associate the original updaters with it.
2003 + restorePendingUpdaters(inProgressRoot, inProgressLanes);
2004 + } else {
2005 + throw Error(
2006 + 'Expected finished root and lanes to be set. This is a bug in React.',
2007 + );
2008 }
2009 }
1985 -
1986 - wakeable.then(retry, retry);
2010 }
1988 - });
1989 - }
2011 +
2012 + wakeable.then(retry, retry);
2013 + }
2014 + });
2015 }
2016
2017 // This function detects when a Suspense boundary goes from visible to hidden.
@@ -2307,7 +2332,11 @@ function commitMutationEffectsOnFiber(
2332 } catch (error) {
2333 captureCommitPhaseError(finishedWork, finishedWork.return, error);
2334 }
2310 - attachSuspenseRetryListeners(finishedWork);
2335 + const wakeables: Set<Wakeable> | null = (finishedWork.updateQueue: any);
2336 + if (wakeables !== null) {
2337 + finishedWork.updateQueue = null;
2338 + attachSuspenseRetryListeners(finishedWork, wakeables);
2339 + }
2340 }
2341 return;
2342 }
@@ -2362,6 +2391,18 @@ function commitMutationEffectsOnFiber(
2391 hideOrUnhideAllChildren(offscreenBoundary, isHidden);
2392 }
2393 }
2394 +
2395 + // TODO: Move to passive phase
2396 + if (flags & Update) {
2397 + const offscreenQueue: OffscreenQueue | null = (finishedWork.updateQueue: any);
2398 + if (offscreenQueue !== null) {
2399 + const wakeables = offscreenQueue.wakeables;
2400 + if (wakeables !== null) {
2401 + offscreenQueue.wakeables = null;
2402 + attachSuspenseRetryListeners(finishedWork, wakeables);
2403 + }
2404 + }
2405 + }
2406 return;
2407 }
2408 case SuspenseListComponent: {
@@ -2369,7 +2410,11 @@ function commitMutationEffectsOnFiber(
2410 commitReconciliationEffects(finishedWork);
2411
2412 if (flags & Update) {
2372 - attachSuspenseRetryListeners(finishedWork);
2413 + const wakeables: Set<Wakeable> | null = (finishedWork.updateQueue: any);
2414 + if (wakeables !== null) {
2415 + finishedWork.updateQueue = null;
2416 + attachSuspenseRetryListeners(finishedWork, wakeables);
2417 + }
2418 }
2419 return;
2420 }
@@ -2878,7 +2923,7 @@ function commitPassiveMountOnFiber(
2923
2924 if (enableTransitionTracing) {
2925 const isFallback = finishedWork.memoizedState;
2881 - const queue: OffscreenQueue = (finishedWork.updateQueue: any);
2926 + const queue: OffscreenQueue | null = (finishedWork.updateQueue: any);
2927 const instance: OffscreenInstance = finishedWork.stateNode;
2928
2929 if (queue !== null) {
packages/react-reconciler/src/ReactFiberCommitWork.old.js
+1 -1
@@ -2878,7 +2878,7 @@ function commitPassiveMountOnFiber(
2878
2879 if (enableTransitionTracing) {
2880 const isFallback = finishedWork.memoizedState;
2881 - const queue: OffscreenQueue = (finishedWork.updateQueue: any);
2881 + const queue: OffscreenQueue | null = (finishedWork.updateQueue: any);
2882 const instance: OffscreenInstance = finishedWork.stateNode;
2883
2884 if (queue !== null) {
packages/react-reconciler/src/ReactFiberCompleteWork.new.js
+12 -1
@@ -1508,6 +1508,7 @@ function completeWork(
1508 }
1509 case OffscreenComponent:
1510 case LegacyHiddenComponent: {
1511 + popSuspenseHandler(workInProgress);
1512 popHiddenContext(workInProgress);
1513 const nextState: OffscreenState | null = workInProgress.memoizedState;
1514 const nextIsHidden = nextState !== null;
@@ -1529,7 +1530,11 @@ function completeWork(
1530 } else {
1531 // Don't bubble properties for hidden children unless we're rendering
1532 // at offscreen priority.
1532 - if (includesSomeLane(renderLanes, (OffscreenLane: Lane))) {
1533 + if (
1534 + includesSomeLane(renderLanes, (OffscreenLane: Lane)) &&
1535 + // Also don't bubble if the tree suspended
1536 + (workInProgress.flags & DidCapture) === NoLanes
1537 + ) {
1538 bubbleProperties(workInProgress);
1539 // Check if there was an insertion or update in the hidden subtree.
1540 // If so, we need to hide those nodes in the commit phase, so
@@ -1544,6 +1549,12 @@ function completeWork(
1549 }
1550 }
1551
1552 + if (workInProgress.updateQueue !== null) {
1553 + // Schedule an effect to attach Suspense retry listeners
1554 + // TODO: Move to passive phase
1555 + workInProgress.flags |= Update;
1556 + }
1557 +
1558 if (enableCache) {
1559 let previousCache: Cache | null = null;
1560 if (
packages/react-reconciler/src/ReactFiberOffscreenComponent.js
+4 -2
@@ -7,7 +7,7 @@
7 * @flow
8 */
9
10 -import type {ReactNodeList, OffscreenMode} from 'shared/ReactTypes';
10 +import type {ReactNodeList, OffscreenMode, Wakeable} from 'shared/ReactTypes';
11 import type {Lanes} from './ReactFiberLane.old';
12 import type {SpawnedCachePool} from './ReactFiberCacheComponent.new';
13 import type {
@@ -40,10 +40,12 @@ export type OffscreenState = {|
40 export type OffscreenQueue = {|
41 transitions: Array<Transition> | null,
42 markerInstances: Array<TracingMarkerInstance> | null,
43 -|} | null;
43 + wakeables: Set<Wakeable> | null,
44 +|};
45
46 export type OffscreenInstance = {|
47 isHidden: boolean,
48 pendingMarkers: Set<PendingSuspenseBoundaries> | null,
49 transitions: Set<Transition> | null,
50 + retryCache: WeakSet<Wakeable> | Set<Wakeable> | null,
51 |};
packages/react-reconciler/src/ReactFiberSuspenseContext.new.js
+14 -1
@@ -14,7 +14,7 @@ import type {SuspenseState} from './ReactFiberSuspenseComponent.new';
14 import {enableSuspenseAvoidThisFallback} from 'shared/ReactFeatureFlags';
15 import {createCursor, push, pop} from './ReactFiberStack.new';
16 import {isCurrentTreeHidden} from './ReactFiberHiddenContext.new';
17 -import {SuspenseComponent} from './ReactWorkTags';
17 +import {SuspenseComponent, OffscreenComponent} from './ReactWorkTags';
18
19 // The Suspense handler is the boundary that should capture if something
20 // suspends, i.e. it's the nearest `catch` block on the stack.
@@ -77,6 +77,19 @@ export function pushFallbackTreeSuspenseHandler(fiber: Fiber): void {
77 // We're about to render the fallback. If something in the fallback suspends,
78 // it's akin to throwing inside of a `catch` block. This boundary should not
79 // capture. Reuse the existing handler on the stack.
80 + reuseSuspenseHandlerOnStack(fiber);
81 +}
82 +
83 +export function pushOffscreenSuspenseHandler(fiber: Fiber): void {
84 + if (fiber.tag === OffscreenComponent) {
85 + push(suspenseHandlerStackCursor, fiber, fiber);
86 + } else {
87 + // This is a LegacyHidden component.
88 + reuseSuspenseHandlerOnStack(fiber);
89 + }
90 +}
91 +
92 +export function reuseSuspenseHandlerOnStack(fiber: Fiber) {
93 push(suspenseHandlerStackCursor, getSuspenseHandler(), fiber);
94 }
95
packages/react-reconciler/src/ReactFiberThrow.new.js
+62 -36
@@ -13,6 +13,7 @@ import type {Lane, Lanes} from './ReactFiberLane.new';
13 import type {CapturedValue} from './ReactCapturedValue';
14 import type {Update} from './ReactFiberClassUpdateQueue.new';
15 import type {Wakeable} from 'shared/ReactTypes';
16 +import type {OffscreenQueue} from './ReactFiberOffscreenComponent';
17
18 import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber';
19 import {
@@ -22,6 +23,8 @@ import {
23 FunctionComponent,
24 ForwardRef,
25 SimpleMemoComponent,
26 + SuspenseComponent,
27 + OffscreenComponent,
28 } from './ReactWorkTags';
29 import {
30 DidCapture,
@@ -196,33 +199,6 @@ function attachPingListener(root: FiberRoot, wakeable: Wakeable, lanes: Lanes) {
199 }
200 }
201
199 -function attachRetryListener(
200 - suspenseBoundary: Fiber,
201 - root: FiberRoot,
202 - wakeable: Wakeable,
203 - lanes: Lanes,
204 -) {
205 - // Retry listener
206 - //
207 - // If the fallback does commit, we need to attach a different type of
208 - // listener. This one schedules an update on the Suspense boundary to turn
209 - // the fallback state off.
210 - //
211 - // Stash the wakeable on the boundary fiber so we can access it in the
212 - // commit phase.
213 - //
214 - // When the wakeable resolves, we'll attempt to render the boundary
215 - // again ("retry").
216 - const wakeables: Set<Wakeable> | null = (suspenseBoundary.updateQueue: any);
217 - if (wakeables === null) {
218 - const updateQueue = (new Set(): any);
219 - updateQueue.add(wakeable);
220 - suspenseBoundary.updateQueue = updateQueue;
221 - } else {
222 - wakeables.add(wakeable);
223 - }
224 -}
225 -
202 function resetSuspendedComponent(sourceFiber: Fiber, rootRenderLanes: Lanes) {
203 if (enableLazyContextPropagation) {
204 const currentSourceFiber = sourceFiber.alternate;
@@ -419,20 +395,70 @@ function throwException(
395 // Schedule the nearest Suspense to re-render the timed out view.
396 const suspenseBoundary = getSuspenseHandler();
397 if (suspenseBoundary !== null) {
422 - suspenseBoundary.flags &= ~ForceClientRender;
423 - markSuspenseBoundaryShouldCapture(
424 - suspenseBoundary,
425 - returnFiber,
426 - sourceFiber,
427 - root,
428 - rootRenderLanes,
429 - );
398 + switch (suspenseBoundary.tag) {
399 + case SuspenseComponent: {
400 + suspenseBoundary.flags &= ~ForceClientRender;
401 + markSuspenseBoundaryShouldCapture(
402 + suspenseBoundary,
403 + returnFiber,
404 + sourceFiber,
405 + root,
406 + rootRenderLanes,
407 + );
408 + // Retry listener
409 + //
410 + // If the fallback does commit, we need to attach a different type of
411 + // listener. This one schedules an update on the Suspense boundary to
412 + // turn the fallback state off.
413 + //
414 + // Stash the wakeable on the boundary fiber so we can access it in the
415 + // commit phase.
416 + //
417 + // When the wakeable resolves, we'll attempt to render the boundary
418 + // again ("retry").
419 + const wakeables: Set<Wakeable> | null = (suspenseBoundary.updateQueue: any);
420 + if (wakeables === null) {
421 + suspenseBoundary.updateQueue = new Set([wakeable]);
422 + } else {
423 + wakeables.add(wakeable);
424 + }
425 + break;
426 + }
427 + case OffscreenComponent: {
428 + if (suspenseBoundary.mode & ConcurrentMode) {
429 + suspenseBoundary.flags |= ShouldCapture;
430 + const offscreenQueue: OffscreenQueue | null = (suspenseBoundary.updateQueue: any);
431 + if (offscreenQueue === null) {
432 + const newOffscreenQueue: OffscreenQueue = {
433 + transitions: null,
434 + markerInstances: null,
435 + wakeables: new Set([wakeable]),
436 + };
437 + suspenseBoundary.updateQueue = newOffscreenQueue;
438 + } else {
439 + const wakeables = offscreenQueue.wakeables;
440 + if (wakeables === null) {
441 + offscreenQueue.wakeables = new Set([wakeable]);
442 + } else {
443 + wakeables.add(wakeable);
444 + }
445 + }
446 + break;
447 + }
448 + }
449 + // eslint-disable-next-line no-fallthrough
450 + default: {
451 + throw new Error(
452 + `Unexpected Suspense handler tag (${suspenseBoundary.tag}). This ` +
453 + 'is a bug in React.',
454 + );
455 + }
456 + }
457 // We only attach ping listeners in concurrent mode. Legacy Suspense always
458 // commits fallbacks synchronously, so there are no pings.
459 if (suspenseBoundary.mode & ConcurrentMode) {
460 attachPingListener(root, wakeable, rootRenderLanes);
461 }
435 - attachRetryListener(suspenseBoundary, root, wakeable, rootRenderLanes);
462 return;
463 } else {
464 // No boundary was found. Unless this is a sync update, this is OK.
packages/react-reconciler/src/ReactFiberUnwindWork.new.js
+16 -1
@@ -162,10 +162,24 @@ function unwindWork(
162 popProvider(context, workInProgress);
163 return null;
164 case OffscreenComponent:
165 - case LegacyHiddenComponent:
165 + case LegacyHiddenComponent: {
166 + popSuspenseHandler(workInProgress);
167 popHiddenContext(workInProgress);
168 popTransition(workInProgress, current);
169 + const flags = workInProgress.flags;
170 + if (flags & ShouldCapture) {
171 + workInProgress.flags = (flags & ~ShouldCapture) | DidCapture;
172 + // Captured a suspense effect. Re-render the boundary.
173 + if (
174 + enableProfilerTimer &&
175 + (workInProgress.mode & ProfileMode) !== NoMode
176 + ) {
177 + transferActualDuration(workInProgress);
178 + }
179 + return workInProgress;
180 + }
181 return null;
182 + }
183 case CacheComponent:
184 if (enableCache) {
185 const cache: Cache = workInProgress.memoizedState.cache;
@@ -238,6 +252,7 @@ function unwindInterruptedWork(
252 break;
253 case OffscreenComponent:
254 case LegacyHiddenComponent:
255 + popSuspenseHandler(interruptedWork);
256 popHiddenContext(interruptedWork);
257 popTransition(interruptedWork, current);
258 break;
packages/react-reconciler/src/ReactFiberWorkLoop.new.js
+7
@@ -20,6 +20,7 @@ import type {
20 MarkerTransitionObject,
21 Transition,
22 } from './ReactFiberTracingMarkerComponent.new';
23 +import type {OffscreenInstance} from './ReactFiberOffscreenComponent';
24
25 import {
26 warnAboutDeprecatedLifecycles,
@@ -96,6 +97,7 @@ import {
97 ClassComponent,
98 SuspenseComponent,
99 SuspenseListComponent,
100 + OffscreenComponent,
101 FunctionComponent,
102 ForwardRef,
103 MemoComponent,
@@ -2748,6 +2750,11 @@ export function resolveRetryWakeable(boundaryFiber: Fiber, wakeable: Wakeable) {
2750 case SuspenseListComponent:
2751 retryCache = boundaryFiber.stateNode;
2752 break;
2753 + case OffscreenComponent: {
2754 + const instance: OffscreenInstance = boundaryFiber.stateNode;
2755 + retryCache = instance.retryCache;
2756 + break;
2757 + }
2758 default:
2759 throw new Error(
2760 'Pinged unknown suspense boundary type. ' +
packages/react-reconciler/src/__tests__/ReactOffscreenSuspense-test.js
+326
@@ -2,10 +2,12 @@ let React;
2 let ReactNoop;
3 let Scheduler;
4 let act;
5 +let LegacyHidden;
6 let Offscreen;
7 let Suspense;
8 let useState;
9 let useEffect;
10 +let startTransition;
11 let textCache;
12
13 describe('ReactOffscreen', () => {
@@ -16,10 +18,12 @@ describe('ReactOffscreen', () => {
18 ReactNoop = require('react-noop-renderer');
19 Scheduler = require('scheduler');
20 act = require('jest-react').act;
21 + LegacyHidden = React.unstable_LegacyHidden;
22 Offscreen = React.unstable_Offscreen;
23 Suspense = React.Suspense;
24 useState = React.useState;
25 useEffect = React.useEffect;
26 + startTransition = React.startTransition;
27
28 textCache = new Map();
29 });
@@ -86,6 +90,328 @@ describe('ReactOffscreen', () => {
90 return text;
91 }
92
93 + // Only works in new reconciler
94 + // @gate variant
95 + // @gate enableOffscreen
96 + test('basic example of suspending inside hidden tree', async () => {
97 + const root = ReactNoop.createRoot();
98 +
99 + function App() {
100 + return (
101 + <Suspense fallback={<Text text="Loading..." />}>
102 + <span>
103 + <Text text="Visible" />
104 + </span>
105 + <Offscreen mode="hidden">
106 + <span>
107 + <AsyncText text="Hidden" />
108 + </span>
109 + </Offscreen>
110 + </Suspense>
111 + );
112 + }
113 +
114 + // The hidden tree hasn't finished loading, but we should still be able to
115 + // show the surrounding contents. The outer Suspense boundary
116 + // isn't affected.
117 + await act(async () => {
118 + root.render(<App />);
119 + });
120 + expect(Scheduler).toHaveYielded(['Visible', 'Suspend! [Hidden]']);
121 + expect(root).toMatchRenderedOutput(<span>Visible</span>);
122 +
123 + // When the data resolves, we should be able to finish prerendering
124 + // the hidden tree.
125 + await act(async () => {
126 + await resolveText('Hidden');
127 + });
128 + expect(Scheduler).toHaveYielded(['Hidden']);
129 + expect(root).toMatchRenderedOutput(
130 + <>
131 + <span>Visible</span>
132 + <span hidden={true}>Hidden</span>
133 + </>,
134 + );
135 + });
136 +
137 + // @gate www
138 + test('LegacyHidden does not handle suspense', async () => {
139 + const root = ReactNoop.createRoot();
140 +
141 + function App() {
142 + return (
143 + <Suspense fallback={<Text text="Loading..." />}>
144 + <span>
145 + <Text text="Visible" />
146 + </span>
147 + <LegacyHidden mode="hidden">
148 + <span>
149 + <AsyncText text="Hidden" />
150 + </span>
151 + </LegacyHidden>
152 + </Suspense>
153 + );
154 + }
155 +
156 + // Unlike Offscreen, LegacyHidden never captures if something suspends
157 + await act(async () => {
158 + root.render(<App />);
159 + });
160 + expect(Scheduler).toHaveYielded([
161 + 'Visible',
162 + 'Suspend! [Hidden]',
163 + 'Loading...',
164 + ]);
165 + // Nearest Suspense boundary switches to a fallback even though the
166 + // suspended content is hidden.
167 + expect(root).toMatchRenderedOutput(
168 + <>
169 + <span hidden={true}>Visible</span>
170 + Loading...
171 + </>,
172 + );
173 + });
174 +
175 + // Only works in new reconciler
176 + // @gate variant
177 + // @gate experimental || www
178 + test("suspending inside currently hidden tree that's switching to visible", async () => {
179 + const root = ReactNoop.createRoot();
180 +
181 + function Details({open, children}) {
182 + return (
183 + <Suspense fallback={<Text text="Loading..." />}>
184 + <span>
185 + <Text text={open ? 'Open' : 'Closed'} />
186 + </span>
187 + <Offscreen mode={open ? 'visible' : 'hidden'}>
188 + <span>{children}</span>
189 + </Offscreen>
190 + </Suspense>
191 + );
192 + }
193 +
194 + // The hidden tree hasn't finished loading, but we should still be able to
195 + // show the surrounding contents. It doesn't matter that there's no
196 + // Suspense boundary because the unfinished content isn't visible.
197 + await act(async () => {
198 + root.render(
199 + <Details open={false}>
200 + <AsyncText text="Async" />
201 + </Details>,
202 + );
203 + });
204 + expect(Scheduler).toHaveYielded(['Closed', 'Suspend! [Async]']);
205 + expect(root).toMatchRenderedOutput(<span>Closed</span>);
206 +
207 + // But when we switch the boundary from hidden to visible, it should
208 + // now bubble to the nearest Suspense boundary.
209 + await act(async () => {
210 + startTransition(() => {
211 + root.render(
212 + <Details open={true}>
213 + <AsyncText text="Async" />
214 + </Details>,
215 + );
216 + });
217 + });
218 + expect(Scheduler).toHaveYielded(['Open', 'Suspend! [Async]', 'Loading...']);
219 + // It should suspend with delay to prevent the already-visible Suspense
220 + // boundary from switching to a fallback
221 + expect(root).toMatchRenderedOutput(<span>Closed</span>);
222 +
223 + // Resolve the data and finish rendering
224 + await act(async () => {
225 + await resolveText('Async');
226 + });
227 + expect(Scheduler).toHaveYielded(['Open', 'Async']);
228 + expect(root).toMatchRenderedOutput(
229 + <>
230 + <span>Open</span>
231 + <span>Async</span>
232 + </>,
233 + );
234 + });
235 +
236 + // Only works in new reconciler
237 + // @gate variant
238 + // @gate enableOffscreen
239 + test("suspending inside currently visible tree that's switching to hidden", async () => {
240 + const root = ReactNoop.createRoot();
241 +
242 + function Details({open, children}) {
243 + return (
244 + <Suspense fallback={<Text text="Loading..." />}>
245 + <span>
246 + <Text text={open ? 'Open' : 'Closed'} />
247 + </span>
248 + <Offscreen mode={open ? 'visible' : 'hidden'}>
249 + <span>{children}</span>
250 + </Offscreen>
251 + </Suspense>
252 + );
253 + }
254 +
255 + // Initial mount. Nothing suspends
256 + await act(async () => {
257 + root.render(
258 + <Details open={true}>
259 + <Text text="(empty)" />
260 + </Details>,
261 + );
262 + });
263 + expect(Scheduler).toHaveYielded(['Open', '(empty)']);
264 + expect(root).toMatchRenderedOutput(
265 + <>
266 + <span>Open</span>
267 + <span>(empty)</span>
268 + </>,
269 + );
270 +
271 + // Update that suspends inside the currently visible tree
272 + await act(async () => {
273 + startTransition(() => {
274 + root.render(
275 + <Details open={true}>
276 + <AsyncText text="Async" />
277 + </Details>,
278 + );
279 + });
280 + });
281 + expect(Scheduler).toHaveYielded(['Open', 'Suspend! [Async]', 'Loading...']);
282 + // It should suspend with delay to prevent the already-visible Suspense
283 + // boundary from switching to a fallback
284 + expect(root).toMatchRenderedOutput(
285 + <>
286 + <span>Open</span>
287 + <span>(empty)</span>
288 + </>,
289 + );
290 +
291 + // Update that hides the suspended tree
292 + await act(async () => {
293 + startTransition(() => {
294 + root.render(
295 + <Details open={false}>
296 + <AsyncText text="Async" />
297 + </Details>,
298 + );
299 + });
300 + });
301 + // Now the visible part of the tree can commit without being blocked
302 + // by the suspended content, which is hidden.
303 + expect(Scheduler).toHaveYielded(['Closed', 'Suspend! [Async]']);
304 + expect(root).toMatchRenderedOutput(
305 + <>
306 + <span>Closed</span>
307 + <span hidden={true}>(empty)</span>
308 + </>,
309 + );
310 +
311 + // Resolve the data and finish rendering
312 + await act(async () => {
313 + await resolveText('Async');
314 + });
315 + expect(Scheduler).toHaveYielded(['Async']);
316 + expect(root).toMatchRenderedOutput(
317 + <>
318 + <span>Closed</span>
319 + <span hidden={true}>Async</span>
320 + </>,
321 + );
322 + });
323 +
324 + // @gate experimental || www
325 + test('update that suspends inside hidden tree', async () => {
326 + let setText;
327 + function Child() {
328 + const [text, _setText] = useState('A');
329 + setText = _setText;
330 + return <AsyncText text={text} />;
331 + }
332 +
333 + function App({show}) {
334 + return (
335 + <Offscreen mode={show ? 'visible' : 'hidden'}>
336 + <span>
337 + <Child />
338 + </span>
339 + </Offscreen>
340 + );
341 + }
342 +
343 + const root = ReactNoop.createRoot();
344 + resolveText('A');
345 + await act(async () => {
346 + root.render(<App show={false} />);
347 + });
348 + expect(Scheduler).toHaveYielded(['A']);
349 +
350 + await act(async () => {
351 + startTransition(() => {
352 + setText('B');
353 + });
354 + });
355 + });
356 +
357 + // Only works in new reconciler
358 + // @gate variant
359 + // @gate experimental || www
360 + test('updates at multiple priorities that suspend inside hidden tree', async () => {
361 + let setText;
362 + let setStep;
363 + function Child() {
364 + const [text, _setText] = useState('A');
365 + setText = _setText;
366 +
367 + const [step, _setStep] = useState(0);
368 + setStep = _setStep;
369 +
370 + return <AsyncText text={text + step} />;
371 + }
372 +
373 + function App({show}) {
374 + return (
375 + <Offscreen mode={show ? 'visible' : 'hidden'}>
376 + <span>
377 + <Child />
378 + </span>
379 + </Offscreen>
380 + );
381 + }
382 +
383 + const root = ReactNoop.createRoot();
384 + resolveText('A0');
385 + await act(async () => {
386 + root.render(<App show={false} />);
387 + });
388 + expect(Scheduler).toHaveYielded(['A0']);
389 + expect(root).toMatchRenderedOutput(<span hidden={true}>A0</span>);
390 +
391 + await act(async () => {
392 + setStep(1);
393 + ReactNoop.flushSync(() => {
394 + setText('B');
395 + });
396 + });
397 + expect(Scheduler).toHaveYielded([
398 + // The high priority render suspends again
399 + 'Suspend! [B0]',
400 + // There's still pending work in another lane, so we should attempt
401 + // that, too.
402 + 'Suspend! [B1]',
403 + ]);
404 + expect(root).toMatchRenderedOutput(<span hidden={true}>A0</span>);
405 +
406 + // Resolve the data and finish rendering
407 + await act(async () => {
408 + resolveText('B1');
409 + });
410 + expect(Scheduler).toHaveYielded(['B1']);
411 + expect(root).toMatchRenderedOutput(<span hidden={true}>B1</span>);
412 + });
413 +
414 + // Only works in new reconciler
415 // @gate enableOffscreen
416 test('detect updates to a hidden tree during a concurrent event', async () => {
417 // This is a pretty complex test case. It relates to how we detect if an
scripts/error-codes/codes.json
+3 -2
@@ -419,5 +419,6 @@
419 "431": "React elements are not allowed in ServerContext",
420 "432": "The render was aborted by the server without a reason.",
421 "433": "useId can only be used while React is rendering",
422 - "434": "`dangerouslySetInnerHTML` does not make sense on <title>."
423 -}
\ No newline at end of file
422 + "434": "`dangerouslySetInnerHTML` does not make sense on <title>.",
423 + "435": "Unexpected Suspense handler tag (%s). This is a bug in React."
424 +}
scripts/merge-fork/forked-revisions
+2 -1
@@ -1,2 +1,3 @@
1 +67de5e3fb09eecfab91321246246095058a708a9 [FORKED] Hidden trees should capture Suspense
2 6ab05ee2e9c5b1f4c8dc1f7ae8906bf613788ba7 [FORKED] Track nearest Suspense handler on stack
2 -051ac55cb75f426b81f8f75b143f34255476b9bc [FORKED] Add HiddenContext to track if subtree is hidden
\ No newline at end of file
3 +051ac55cb75f426b81f8f75b143f34255476b9bc [FORKED] Add HiddenContext to track if subtree is hidden