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