@samitouri / QOS-React-2 / commits / 768f965de2

Suspensily committing a prerendered tree (#26434)

Prerendering a tree (i.e. with Offscreen) should not suspend the commit phase, because the content is not yet visible. However, when revealing a prerendered tree, we should suspend the commit phase if resources in the prerendered tree haven't finished loading yet. To do this properly, we need to visit all the visible nodes in the tree that might possibly suspend. This includes nodes in the current tree, because even though they were already "mounted", the resources might not have loaded yet, because we didn't suspend when it was prerendered. We will need to add this capability to the Offscreen component's "manual" mode, too. Something like a `ready()` method that returns a promise that resolves when the tree has fully loaded. Also includes some fixes to #26450. See PR for details.

Andrew Clark committed Mar 26, 2023 at 23:48 UTC 768f965de2d4c6be7f688562ef02382478c82e5b
9 files changed +336 -85
packages/react-dom-bindings/src/client/ReactDOMHostConfig.js
+31 -2
@@ -1788,7 +1788,7 @@ type StyleTagResource = TResource<'style', null>;
1788 type StyleResource = StyleTagResource | StylesheetResource;
1789 type ScriptResource = TResource<'script', null>;
1790 type VoidResource = TResource<'void', null>;
1791 -type Resource = StyleResource | ScriptResource | VoidResource;
1791 +export type Resource = StyleResource | ScriptResource | VoidResource;
1792
1793 type LoadingState = number;
1794 const NotLoaded = /* */ 0b000;
@@ -2170,6 +2170,7 @@ function preinit(href: string, options: PreinitOptions) {
2170 state.loading |= Errored;
2171 });
2172
2173 + state.loading |= Inserted;
2174 insertStylesheet(instance, precedence, resourceRoot);
2175 }
2176
@@ -2518,6 +2519,11 @@ export function acquireResource(
2519
2520 markNodeAsHoistable(instance);
2521 setInitialProperties(instance, 'style', styleProps);
2522 +
2523 + // TODO: `style` does not have loading state for tracking insertions. I
2524 + // guess because these aren't suspensey? Not sure whether this is a
2525 + // factoring smell.
2526 + // resource.state.loading |= Inserted;
2527 insertStylesheet(instance, qualifiedProps.precedence, hoistableRoot);
2528 resource.instance = instance;
2529
@@ -2556,6 +2562,7 @@ export function acquireResource(
2562 linkInstance.onerror = reject;
2563 });
2564 setInitialProperties(instance, 'link', stylesheetProps);
2565 + resource.state.loading |= Inserted;
2566 insertStylesheet(instance, qualifiedProps.precedence, hoistableRoot);
2567 resource.instance = instance;
2568
@@ -2604,6 +2611,28 @@ export function acquireResource(
2611 );
2612 }
2613 }
2614 + } else {
2615 + // In the case of stylesheets, they might have already been assigned an
2616 + // instance during `suspendResource`. But that doesn't mean they were
2617 + // inserted, because the commit might have been interrupted. So we need to
2618 + // check now.
2619 + //
2620 + // The other resource types are unaffected because they are not
2621 + // yet suspensey.
2622 + //
2623 + // TODO: This is a bit of a code smell. Consider refactoring how
2624 + // `suspendResource` and `acquireResource` work together. The idea is that
2625 + // `suspendResource` does all the same stuff as `acquireResource` except
2626 + // for the insertion.
2627 + if (
2628 + resource.type === 'stylesheet' &&
2629 + (resource.state.loading & Inserted) === NotLoaded
2630 + ) {
2631 + const qualifiedProps: StylesheetQualifyingProps = props;
2632 + const instance: Instance = resource.instance;
2633 + resource.state.loading |= Inserted;
2634 + insertStylesheet(instance, qualifiedProps.precedence, hoistableRoot);
2635 + }
2636 }
2637 return resource.instance;
2638 }
@@ -2613,7 +2642,7 @@ export function releaseResource(resource: Resource): void {
2642 }
2643
2644 function insertStylesheet(
2616 - instance: HTMLElement,
2645 + instance: Element,
2646 precedence: string,
2647 root: HoistableRoot,
2648 ): void {
packages/react-dom/src/__tests__/ReactDOMFloat-test.js
+18 -5
@@ -2939,7 +2939,6 @@ body {
2939 );
2940 });
2941
2942 - // @gate TODO
2942 it('can interrupt a suspended commit with a new update', async () => {
2943 function App({children}) {
2944 return (
@@ -2949,9 +2948,13 @@ body {
2948 );
2949 }
2950 const root = ReactDOMClient.createRoot(document);
2951 +
2952 + // Do an initial render. This means subsequent insertions will suspend,
2953 + // unless they are wrapped inside a fresh Suspense boundary.
2954 root.render(<App />);
2955 await waitForAll([]);
2956
2957 + // Insert a stylesheet. This will suspend because it's a transition.
2958 React.startTransition(() => {
2959 root.render(
2960 <App>
@@ -2961,6 +2964,7 @@ body {
2964 );
2965 });
2966 await waitForAll([]);
2967 + // Although the commit suspended, a preload was inserted.
2968 expect(getMeaningfulChildren(document)).toEqual(
2969 <html>
2970 <head>
@@ -2970,6 +2974,9 @@ body {
2974 </html>,
2975 );
2976
2977 + // Before the stylesheet has loaded, do an urgent update. This will insert a
2978 + // different stylesheet, and cancel the first one. This stylesheet will not
2979 + // suspend, even though it hasn't loaded, because it's an urgent update.
2980 root.render(
2981 <App>
2982 hello2
@@ -2978,6 +2985,9 @@ body {
2985 </App>,
2986 );
2987 await waitForAll([]);
2988 +
2989 + // The bar stylesheet was inserted. There's still a "foo" preload, even
2990 + // though that update was superseded.
2991 expect(getMeaningfulChildren(document)).toEqual(
2992 <html>
2993 <head>
@@ -2989,9 +2999,10 @@ body {
2999 </html>,
3000 );
3001
2992 - // Even though foo was preloaded we don't see the stylesheet insert because the commit was cancelled.
2993 - // If we do a followup render that tries to recommit that resource it will insert right away because
2994 - // the preload is already loaded
3002 + // When "foo" finishes loading, nothing happens, because "foo" was not
3003 + // included in the last root update. However, if we insert "foo" again
3004 + // later, it should immediately commit without suspending, because it's
3005 + // been preloaded.
3006 loadPreloads(['foo']);
3007 assertLog(['load preload: foo']);
3008 expect(getMeaningfulChildren(document)).toEqual(
@@ -3005,6 +3016,7 @@ body {
3016 </html>,
3017 );
3018
3019 + // Now insert "foo" again.
3020 React.startTransition(() => {
3021 root.render(
3022 <App>
@@ -3015,6 +3027,7 @@ body {
3027 );
3028 });
3029 await waitForAll([]);
3030 + // Commits without suspending because "foo" was preloaded.
3031 expect(getMeaningfulChildren(document)).toEqual(
3032 <html>
3033 <head>
@@ -3023,7 +3036,7 @@ body {
3036 <link rel="preload" href="foo" as="style" />
3037 <link rel="preload" href="bar" as="style" />
3038 </head>
3026 - <body>hello2</body>
3039 + <body>hello3</body>
3040 </html>,
3041 );
3042
packages/react-reconciler/src/ReactFiberCommitWork.js
+41 -6
@@ -94,7 +94,8 @@ import {
94 LayoutMask,
95 PassiveMask,
96 Visibility,
97 - SuspenseyCommit,
97 + ShouldSuspendCommit,
98 + MaySuspendCommit,
99 } from './ReactFiberFlags';
100 import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber';
101 import {
@@ -4065,12 +4066,23 @@ export function commitPassiveUnmountEffects(finishedWork: Fiber): void {
4066 resetCurrentDebugFiberInDEV();
4067 }
4068
4069 +// If we're inside a brand new tree, or a tree that was already visible, then we
4070 +// should only suspend host components that have a ShouldSuspendCommit flag.
4071 +// Components without it haven't changed since the last commit, so we can skip
4072 +// over those.
4073 +//
4074 +// When we enter a tree that is being revealed (going from hidden -> visible),
4075 +// we need to suspend _any_ component that _may_ suspend. Even if they're
4076 +// already in the "current" tree. Because their visibility has changed, the
4077 +// browser may not have prerendered them yet. So we check the MaySuspendCommit
4078 +// flag instead.
4079 +let suspenseyCommitFlag = ShouldSuspendCommit;
4080 export function accumulateSuspenseyCommit(finishedWork: Fiber): void {
4081 accumulateSuspenseyCommitOnFiber(finishedWork);
4082 }
4083
4084 function recursivelyAccumulateSuspenseyCommit(parentFiber: Fiber): void {
4073 - if (parentFiber.subtreeFlags & SuspenseyCommit) {
4085 + if (parentFiber.subtreeFlags & suspenseyCommitFlag) {
4086 let child = parentFiber.child;
4087 while (child !== null) {
4088 accumulateSuspenseyCommitOnFiber(child);
@@ -4083,7 +4095,7 @@ function accumulateSuspenseyCommitOnFiber(fiber: Fiber) {
4095 switch (fiber.tag) {
4096 case HostHoistable: {
4097 recursivelyAccumulateSuspenseyCommit(fiber);
4086 - if (fiber.flags & SuspenseyCommit) {
4098 + if (fiber.flags & suspenseyCommitFlag) {
4099 if (fiber.memoizedState !== null) {
4100 suspendResource(
4101 // This should always be set by visiting HostRoot first
@@ -4101,7 +4113,7 @@ function accumulateSuspenseyCommitOnFiber(fiber: Fiber) {
4113 }
4114 case HostComponent: {
4115 recursivelyAccumulateSuspenseyCommit(fiber);
4104 - if (fiber.flags & SuspenseyCommit) {
4116 + if (fiber.flags & suspenseyCommitFlag) {
4117 const type = fiber.type;
4118 const props = fiber.memoizedProps;
4119 suspendInstance(type, props);
@@ -4117,10 +4129,33 @@ function accumulateSuspenseyCommitOnFiber(fiber: Fiber) {
4129
4130 recursivelyAccumulateSuspenseyCommit(fiber);
4131 currentHoistableRoot = previousHoistableRoot;
4120 - break;
4132 + } else {
4133 + recursivelyAccumulateSuspenseyCommit(fiber);
4134 }
4135 + break;
4136 + }
4137 + case OffscreenComponent: {
4138 + const isHidden = (fiber.memoizedState: OffscreenState | null) !== null;
4139 + if (isHidden) {
4140 + // Don't suspend in hidden trees
4141 + } else {
4142 + const current = fiber.alternate;
4143 + const wasHidden =
4144 + current !== null &&
4145 + (current.memoizedState: OffscreenState | null) !== null;
4146 + if (wasHidden) {
4147 + // This tree is being revealed. Visit all newly visible suspensey
4148 + // instances, even if they're in the current tree.
4149 + const prevFlags = suspenseyCommitFlag;
4150 + suspenseyCommitFlag = MaySuspendCommit;
4151 + recursivelyAccumulateSuspenseyCommit(fiber);
4152 + suspenseyCommitFlag = prevFlags;
4153 + } else {
4154 + recursivelyAccumulateSuspenseyCommit(fiber);
4155 + }
4156 + }
4157 + break;
4158 }
4123 - // eslint-disable-next-line-no-fallthrough
4159 default: {
4160 recursivelyAccumulateSuspenseyCommit(fiber);
4161 }
packages/react-reconciler/src/ReactFiberCompleteWork.js
+159 -66
@@ -17,6 +17,7 @@ import type {
17 Props,
18 Container,
19 ChildSet,
20 + Resource,
21 } from './ReactFiberHostConfig';
22 import type {
23 SuspenseState,
@@ -86,8 +87,9 @@ import {
87 MutationMask,
88 Passive,
89 ForceClientRender,
89 - SuspenseyCommit,
90 + MaySuspendCommit,
91 ScheduleRetry,
92 + ShouldSuspendCommit,
93 } from './ReactFiberFlags';
94
95 import {
@@ -112,6 +114,7 @@ import {
114 maySuspendCommit,
115 mayResourceSuspendCommit,
116 preloadInstance,
117 + preloadResource,
118 } from './ReactFiberHostConfig';
119 import {
120 getRootHostContainer,
@@ -152,6 +155,7 @@ import {
155 getRenderTargetTime,
156 getWorkInProgressTransitions,
157 shouldRemainOnPreviousScreen,
158 + getWorkInProgressRootRenderLanes,
159 } from './ReactFiberWorkLoop';
160 import {
161 OffscreenLane,
@@ -511,6 +515,10 @@ function updateHostComponent(
515 }
516 }
517
518 +// This function must be called at the very end of the complete phase, because
519 +// it might throw to suspend, and if the resource immediately loads, the work
520 +// loop will resume rendering as if the work-in-progress completed. So it must
521 +// fully complete.
522 // TODO: This should ideally move to begin phase, but currently the instance is
523 // not created until the complete phase. For our existing use cases, host nodes
524 // that suspend don't have children, so it doesn't matter. But that might not
@@ -521,16 +529,39 @@ function preloadInstanceAndSuspendIfNeeded(
529 props: Props,
530 renderLanes: Lanes,
531 ) {
524 - workInProgress.flags |= SuspenseyCommit;
532 + if (!maySuspendCommit(type, props)) {
533 + // If this flag was set previously, we can remove it. The flag
534 + // represents whether this particular set of props might ever need to
535 + // suspend. The safest thing to do is for maySuspendCommit to always
536 + // return true, but if the renderer is reasonably confident that the
537 + // underlying resource won't be evicted, it can return false as a
538 + // performance optimization.
539 + workInProgress.flags &= ~MaySuspendCommit;
540 + return;
541 + }
542 +
543 + // Mark this fiber with a flag. This gets set on all host instances
544 + // that might possibly suspend, even if they don't need to suspend
545 + // currently. We use this when revealing a prerendered tree, because
546 + // even though the tree has "mounted", its resources might not have
547 + // loaded yet.
548 + workInProgress.flags |= MaySuspendCommit;
549 +
550 // Check if we're rendering at a "non-urgent" priority. This is the same
551 // check that `useDeferredValue` does to determine whether it needs to
552 // defer. This is partly for gradual adoption purposes (i.e. shouldn't start
553 // suspending until you opt in with startTransition or Suspense) but it
554 // also happens to be the desired behavior for the concrete use cases we've
555 // thought of so far, like CSS loading, fonts, images, etc.
556 + //
557 + // We check the "root" render lanes here rather than the "subtree" render
558 + // because during a retry or offscreen prerender, the "subtree" render
559 + // lanes may include additional "base" lanes that were deferred during
560 + // a previous render.
561 // TODO: We may decide to expose a way to force a fallback even during a
562 // sync update.
533 - if (!includesOnlyNonUrgentLanes(renderLanes)) {
563 + const rootRenderLanes = getWorkInProgressRootRenderLanes();
564 + if (!includesOnlyNonUrgentLanes(rootRenderLanes)) {
565 // This is an urgent render. Don't suspend or show a fallback. Also,
566 // there's no need to preload, because we're going to commit this
567 // synchronously anyway.
@@ -544,7 +575,9 @@ function preloadInstanceAndSuspendIfNeeded(
575 const isReady = preloadInstance(type, props);
576 if (!isReady) {
577 if (shouldRemainOnPreviousScreen()) {
547 - // It's OK to suspend. Continue rendering.
578 + // It's OK to suspend. Mark the fiber so we know to suspend before the
579 + // commit phase. Then continue rendering.
580 + workInProgress.flags |= ShouldSuspendCommit;
581 } else {
582 // Trigger a fallback rather than block the render.
583 suspendCommit();
@@ -553,6 +586,36 @@ function preloadInstanceAndSuspendIfNeeded(
586 }
587 }
588
589 +function preloadResourceAndSuspendIfNeeded(
590 + workInProgress: Fiber,
591 + resource: Resource,
592 + type: Type,
593 + props: Props,
594 + renderLanes: Lanes,
595 +) {
596 + // This is a fork of preloadInstanceAndSuspendIfNeeded, but for resources.
597 + if (!mayResourceSuspendCommit(resource)) {
598 + workInProgress.flags &= ~MaySuspendCommit;
599 + return;
600 + }
601 +
602 + workInProgress.flags |= MaySuspendCommit;
603 +
604 + const rootRenderLanes = getWorkInProgressRootRenderLanes();
605 + if (!includesOnlyNonUrgentLanes(rootRenderLanes)) {
606 + // This is an urgent render. Don't suspend or show a fallback.
607 + } else {
608 + const isReady = preloadResource(resource);
609 + if (!isReady) {
610 + if (shouldRemainOnPreviousScreen()) {
611 + workInProgress.flags |= ShouldSuspendCommit;
612 + } else {
613 + suspendCommit();
614 + }
615 + }
616 + }
617 +}
618 +
619 function scheduleRetryEffect(
620 workInProgress: Fiber,
621 retryQueue: RetryQueue | null,
@@ -1015,64 +1078,99 @@ function completeWork(
1078 }
1079 case HostHoistable: {
1080 if (enableFloat && supportsResources) {
1018 - const currentRef = current ? current.ref : null;
1019 - if (currentRef !== workInProgress.ref) {
1020 - markRef(workInProgress);
1021 - }
1022 -
1023 - let maySuspend = false;
1081 + // The branching here is more complicated than you might expect because
1082 + // a HostHoistable sometimes corresponds to a Resource and sometimes
1083 + // corresponds to an Instance. It can also switch during an update.
1084
1025 - // @TODO refactor this block to create the instance here in complete phase if we
1026 - // are not hydrating.
1027 - if (
1085 + const type = workInProgress.type;
1086 + const nextResource: Resource | null = workInProgress.memoizedState;
1087 + if (current === null) {
1088 // We are mounting and must Update this Hoistable in this commit
1029 - current === null ||
1030 - // We are transitioning to, from, or between Hoistable Resources
1031 - // and require an update
1032 - current.memoizedState !== workInProgress.memoizedState
1033 - ) {
1034 - if (workInProgress.memoizedState !== null) {
1035 - maySuspend = mayResourceSuspendCommit(workInProgress.memoizedState);
1089 + // @TODO refactor this block to create the instance here in complete
1090 + // phase if we are not hydrating.
1091 + markUpdate(workInProgress);
1092 + if (workInProgress.ref !== null) {
1093 + markRef(workInProgress);
1094 + }
1095 + if (nextResource !== null) {
1096 + // This is a Hoistable Resource
1097 +
1098 + // This must come at the very end of the complete phase.
1099 + bubbleProperties(workInProgress);
1100 + preloadResourceAndSuspendIfNeeded(
1101 + workInProgress,
1102 + nextResource,
1103 + type,
1104 + newProps,
1105 + renderLanes,
1106 + );
1107 + return null;
1108 } else {
1037 - maySuspend = maySuspendCommit(
1038 - workInProgress.type,
1039 - workInProgress.pendingProps,
1109 + // This is a Hoistable Instance
1110 +
1111 + // This must come at the very end of the complete phase.
1112 + bubbleProperties(workInProgress);
1113 + preloadInstanceAndSuspendIfNeeded(
1114 + workInProgress,
1115 + type,
1116 + newProps,
1117 + renderLanes,
1118 );
1119 + return null;
1120 }
1042 - markUpdate(workInProgress);
1043 - } else if (workInProgress.memoizedState === null) {
1044 - maySuspend = maySuspendCommit(
1045 - workInProgress.type,
1046 - workInProgress.pendingProps,
1047 - );
1048 - // We may have props to update on the Hoistable instance. We use the
1049 - // updateHostComponent path becuase it produces the update queue
1050 - // we need for Hoistables
1051 - updateHostComponent(
1052 - current,
1053 - workInProgress,
1054 - workInProgress.type,
1055 - workInProgress.pendingProps,
1056 - renderLanes,
1057 - );
1058 - }
1059 - bubbleProperties(workInProgress);
1060 -
1061 - // This must come at the very end of the complete phase, because it might
1062 - // throw to suspend, and if the resource immediately loads, the work loop
1063 - // will resume rendering as if the work-in-progress completed. So it must
1064 - // fully complete.
1065 - if (maySuspend) {
1066 - preloadInstanceAndSuspendIfNeeded(
1067 - workInProgress,
1068 - workInProgress.type,
1069 - workInProgress.pendingProps,
1070 - renderLanes,
1071 - );
1121 } else {
1073 - workInProgress.flags &= ~SuspenseyCommit;
1122 + // We are updating.
1123 + const currentResource = current.memoizedState;
1124 + if (nextResource !== currentResource) {
1125 + // We are transitioning to, from, or between Hoistable Resources
1126 + // and require an update
1127 + markUpdate(workInProgress);
1128 + }
1129 + if (current.ref !== workInProgress.ref) {
1130 + markRef(workInProgress);
1131 + }
1132 + if (nextResource !== null) {
1133 + // This is a Hoistable Resource
1134 + // This must come at the very end of the complete phase.
1135 +
1136 + bubbleProperties(workInProgress);
1137 + if (nextResource === currentResource) {
1138 + workInProgress.flags &= ~MaySuspendCommit;
1139 + } else {
1140 + preloadResourceAndSuspendIfNeeded(
1141 + workInProgress,
1142 + nextResource,
1143 + type,
1144 + newProps,
1145 + renderLanes,
1146 + );
1147 + }
1148 + return null;
1149 + } else {
1150 + // This is a Hoistable Instance
1151 + //
1152 + // We may have props to update on the Hoistable instance. We use the
1153 + // updateHostComponent path becuase it produces the update queue
1154 + // we need for Hoistables.
1155 + updateHostComponent(
1156 + current,
1157 + workInProgress,
1158 + type,
1159 + newProps,
1160 + renderLanes,
1161 + );
1162 +
1163 + // This must come at the very end of the complete phase.
1164 + bubbleProperties(workInProgress);
1165 + preloadInstanceAndSuspendIfNeeded(
1166 + workInProgress,
1167 + type,
1168 + newProps,
1169 + renderLanes,
1170 + );
1171 + return null;
1172 + }
1173 }
1075 - return null;
1174 }
1175 }
1176 // eslint-disable-next-line-no-fallthrough
@@ -1141,7 +1239,6 @@ function completeWork(
1239 case HostComponent: {
1240 popHostContext(workInProgress);
1241 const type = workInProgress.type;
1144 - const maySuspend = maySuspendCommit(type, newProps);
1242 if (current !== null && workInProgress.stateNode != null) {
1243 updateHostComponent(
1244 current,
@@ -1222,16 +1319,12 @@ function completeWork(
1319 // throw to suspend, and if the resource immediately loads, the work loop
1320 // will resume rendering as if the work-in-progress completed. So it must
1321 // fully complete.
1225 - if (maySuspend) {
1226 - preloadInstanceAndSuspendIfNeeded(
1227 - workInProgress,
1228 - type,
1229 - newProps,
1230 - renderLanes,
1231 - );
1232 - } else {
1233 - workInProgress.flags &= ~SuspenseyCommit;
1234 - }
1322 + preloadInstanceAndSuspendIfNeeded(
1323 + workInProgress,
1324 + workInProgress.type,
1325 + workInProgress.pendingProps,
1326 + renderLanes,
1327 + );
1328 return null;
1329 }
1330 case HostText: {
packages/react-reconciler/src/ReactFiberFlags.js
+4 -3
@@ -36,10 +36,11 @@ export const Passive = /* */ 0b0000000000000000100000000000
36 export const Visibility = /* */ 0b0000000000000010000000000000;
37 export const StoreConsistency = /* */ 0b0000000000000100000000000000;
38
39 -// It's OK to reuse this bit because these flags are mutually exclusive for
39 +// It's OK to reuse these bits because these flags are mutually exclusive for
40 // different fiber types. We should really be doing this for as many flags as
41 // possible, because we're about to run out of bits.
42 export const ScheduleRetry = StoreConsistency;
43 +export const ShouldSuspendCommit = Visibility;
44
45 export const LifecycleEffectMask =
46 Passive | Update | Callback | Ref | Snapshot | StoreConsistency;
@@ -63,7 +64,7 @@ export const Forked = /* */ 0b0000000100000000000000000000
64 export const RefStatic = /* */ 0b0000001000000000000000000000;
65 export const LayoutStatic = /* */ 0b0000010000000000000000000000;
66 export const PassiveStatic = /* */ 0b0000100000000000000000000000;
66 -export const SuspenseyCommit = /* */ 0b0001000000000000000000000000;
67 +export const MaySuspendCommit = /* */ 0b0001000000000000000000000000;
68
69 // Flag used to identify newly inserted fibers. It isn't reset after commit unlike `Placement`.
70 export const PlacementDEV = /* */ 0b0010000000000000000000000000;
@@ -103,4 +104,4 @@ export const PassiveMask = Passive | Visibility | ChildDeletion;
104 // This allows certain concepts to persist without recalculating them,
105 // e.g. whether a subtree contains passive effects or portals.
106 export const StaticMask =
106 - LayoutStatic | PassiveStatic | RefStatic | SuspenseyCommit;
107 + LayoutStatic | PassiveStatic | RefStatic | MaySuspendCommit;
packages/react-reconciler/src/ReactFiberHostConfigWithNoResources.js
+1
@@ -19,6 +19,7 @@ function shim(...args: any): empty {
19 }
20
21 export type HoistableRoot = mixed;
22 +export type Resource = mixed;
23
24 // Resources (when unsupported)
25 export const supportsResources = false;
packages/react-reconciler/src/ReactFiberWorkLoop.js
+14 -2
@@ -169,6 +169,8 @@ import {
169 addTransitionToLanesMap,
170 getTransitionsForLanes,
171 includesOnlyNonUrgentLanes,
172 + includesSomeLane,
173 + OffscreenLane,
174 } from './ReactFiberLane';
175 import {
176 DiscreteEventPriority,
@@ -1997,17 +1999,27 @@ export function shouldRemainOnPreviousScreen(): boolean {
1999 // parent Suspense boundary, even outside a transition. Somehow. Otherwise,
2000 // an uncached promise can fall into an infinite loop.
2001 } else {
2000 - if (includesOnlyRetries(workInProgressRootRenderLanes)) {
2002 + if (
2003 + includesOnlyRetries(workInProgressRootRenderLanes) ||
2004 + // In this context, an OffscreenLane counts as a Retry
2005 + // TODO: It's become increasingly clear that Retries and Offscreen are
2006 + // deeply connected. They probably can be unified further.
2007 + includesSomeLane(workInProgressRootRenderLanes, OffscreenLane)
2008 + ) {
2009 // During a retry, we can suspend rendering if the nearest Suspense boundary
2010 // is the boundary of the "shell", because we're guaranteed not to block
2011 // any new content from appearing.
2012 + //
2013 + // The reason we must check if this is a retry is because it guarantees
2014 + // that suspending the work loop won't block an actual update, because
2015 + // retries don't "update" anything; they fill in fallbacks that were left
2016 + // behind by a previous transition.
2017 return handler === getShellBoundary();
2018 }
2019 }
2020
2021 // For all other Lanes besides Transitions and Retries, we should not wait
2022 // for the data to load.
2010 - // TODO: We should wait during Offscreen prerendering, too.
2023 return false;
2024 }
2025
packages/react-reconciler/src/__tests__/ReactSuspenseyCommitPhase-test.js
+67 -1
@@ -4,7 +4,9 @@ let ReactNoop;
4 let resolveSuspenseyThing;
5 let getSuspenseyThingStatus;
6 let Suspense;
7 +let Offscreen;
8 let SuspenseList;
9 +let useMemo;
10 let Scheduler;
11 let act;
12 let assertLog;
@@ -18,10 +20,11 @@ describe('ReactSuspenseyCommitPhase', () => {
20 ReactNoop = require('react-noop-renderer');
21 Scheduler = require('scheduler');
22 Suspense = React.Suspense;
21 - SuspenseList = React.SuspenseList;
23 if (gate(flags => flags.enableSuspenseList)) {
24 SuspenseList = React.SuspenseList;
25 }
26 + Offscreen = React.unstable_Offscreen;
27 + useMemo = React.useMemo;
28 startTransition = React.startTransition;
29 resolveSuspenseyThing = ReactNoop.resolveSuspenseyThing;
30 getSuspenseyThingStatus = ReactNoop.getSuspenseyThingStatus;
@@ -279,4 +282,67 @@ describe('ReactSuspenseyCommitPhase', () => {
282 </>,
283 );
284 });
285 +
286 + // @gate enableOffscreen
287 + test("host instances don't suspend during prerendering, but do suspend when they are revealed", async () => {
288 + function More() {
289 + Scheduler.log('More');
290 + return <SuspenseyImage src="More" />;
291 + }
292 +
293 + function Details({showMore}) {
294 + Scheduler.log('Details');
295 + const more = useMemo(() => <More />, []);
296 + return (
297 + <>
298 + <div>Main Content</div>
299 + <Offscreen mode={showMore ? 'visible' : 'hidden'}>{more}</Offscreen>
300 + </>
301 + );
302 + }
303 +
304 + const root = ReactNoop.createRoot();
305 + await act(async () => {
306 + root.render(<Details showMore={false} />);
307 + // First render the outer component, without the hidden content
308 + await waitForPaint(['Details']);
309 + expect(root).toMatchRenderedOutput(<div>Main Content</div>);
310 + });
311 + // Then prerender the hidden content.
312 + assertLog(['More', 'Image requested [More]']);
313 + // The prerender should commit even though the image is still loading,
314 + // because it's hidden.
315 + expect(root).toMatchRenderedOutput(
316 + <>
317 + <div>Main Content</div>
318 + <suspensey-thing hidden={true} src="More" />
319 + </>,
320 + );
321 +
322 + // Reveal the prerendered content. This update should suspend, because the
323 + // image that is being revealed still hasn't loaded.
324 + await act(() => {
325 + startTransition(() => {
326 + root.render(<Details showMore={true} />);
327 + });
328 + });
329 + // The More component should not render again, because it was memoized,
330 + // and it already prerendered.
331 + assertLog(['Details']);
332 + expect(root).toMatchRenderedOutput(
333 + <>
334 + <div>Main Content</div>
335 + <suspensey-thing hidden={true} src="More" />
336 + </>,
337 + );
338 +
339 + // Now resolve the image. The transition should complete.
340 + resolveSuspenseyThing('More');
341 + expect(root).toMatchRenderedOutput(
342 + <>
343 + <div>Main Content</div>
344 + <suspensey-thing src="More" />
345 + </>,
346 + );
347 + });
348 });
packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js
+1
@@ -208,6 +208,7 @@ export const errorHydratingContainer = $$$hostConfig.errorHydratingContainer;
208 // (optional)
209 // -------------------
210 export type HoistableRoot = mixed;
211 +export type Resource = mixed; // eslint-disable-line no-undef
212 export const supportsResources = $$$hostConfig.supportsResources;
213 export const isHostHoistableType = $$$hostConfig.isHostHoistableType;
214 export const getHoistableRoot = $$$hostConfig.getHoistableRoot;