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

Feature: Suspend commit without blocking render (#26398)

This adds a new capability for renderers (React DOM, React Native): prevent a tree from being displayed until it is ready, showing a fallback if necessary, but without blocking the React components from being evaluated in the meantime. A concrete example is CSS loading: React DOM can block a commit from being applied until the stylesheet has loaded. This allows us to load the CSS asynchronously, while also preventing a flash of unstyled content. Images and fonts are some of the other use cases. You can think of this as "Suspense for the commit phase". Traditional Suspense, i.e. with `use`, blocking during the render phase: React cannot proceed with rendering until the data is available. But in the case of things like stylesheets, you don't need the CSS in order to evaluate the component. It just needs to be loaded before the tree is committed. Because React buffers its side effects and mutations, it can do work in parallel while the stylesheets load in the background. Like regular Suspense, a "suspensey" stylesheet or image will trigger the nearest Suspense fallback if it hasn't loaded yet. For now, though, we only do this for non-urgent updates, like with startTransition. If you render a suspensey resource during an urgent update, it will revert to today's behavior. (We may or may not add a way to suspend the commit during an urgent update in the future.) In this PR, I have implemented this capability in the reconciler via new methods added to the host config. I've used our internal React "no-op" renderer to write tests that demonstrate the feature. I have not yet implemented Suspensey CSS, images, etc in React DOM. @gnoff and I will work on that in subsequent PRs.

Andrew Clark committed Mar 17, 2023 at 18:05 UTC db281b3d9cd033cdc3d63e00fc9f3153c03aa70c
23 files changed +894 -130
packages/react-art/src/ReactARTHostConfig.js
+11
@@ -459,6 +459,17 @@ export function requestPostPaintCallback(callback: (time: number) => void) {
459 // noop
460 }
461
462 +export function shouldSuspendCommit(type, props) {
463 + return false;
464 +}
465 +
466 +export function startSuspendingCommit() {}
467 +
468 +export function suspendInstance(type, props) {}
469 +
470 +export function waitForCommitToBeReady() {
471 + return null;
472 +}
473 // eslint-disable-next-line no-undef
474 export function prepareRendererToRender(container: Container): void {
475 // noop
packages/react-dom-bindings/src/client/ReactDOMHostConfig.js
+13
@@ -1608,6 +1608,19 @@ export function requestPostPaintCallback(callback: (time: number) => void) {
1608 localRequestAnimationFrame(time => callback(time));
1609 });
1610 }
1611 +
1612 +export function shouldSuspendCommit(type: Type, props: Props): boolean {
1613 + return false;
1614 +}
1615 +
1616 +export function startSuspendingCommit(): void {}
1617 +
1618 +export function suspendInstance(type: Type, props: Props): void {}
1619 +
1620 +export function waitForCommitToBeReady(): null {
1621 + return null;
1622 +}
1623 +
1624 // -------------------
1625 // Resources
1626 // -------------------
packages/react-native-renderer/src/ReactFabricHostConfig.js
+12
@@ -414,6 +414,18 @@ export function requestPostPaintCallback(callback: (time: number) => void) {
414 // noop
415 }
416
417 +export function shouldSuspendCommit(type: Type, props: Props): boolean {
418 + return false;
419 +}
420 +
421 +export function startSuspendingCommit(): void {}
422 +
423 +export function suspendInstance(type: Type, props: Props): void {}
424 +
425 +export function waitForCommitToBeReady(): null {
426 + return null;
427 +}
428 +
429 export function prepareRendererToRender(container: Container): void {
430 // noop
431 }
packages/react-native-renderer/src/ReactNativeHostConfig.js
+12
@@ -522,6 +522,18 @@ export function requestPostPaintCallback(callback: (time: number) => void) {
522 // noop
523 }
524
525 +export function shouldSuspendCommit(type: Type, props: Props): boolean {
526 + return false;
527 +}
528 +
529 +export function startSuspendingCommit(): void {}
530 +
531 +export function suspendInstance(type: Type, props: Props): void {}
532 +
533 +export function waitForCommitToBeReady(): null {
534 + return null;
535 +}
536 +
537 export function prepareRendererToRender(container: Container): void {
538 // noop
539 }
packages/react-noop-renderer/src/ReactNoop.js
+3
@@ -28,6 +28,9 @@ export const {
28 createLegacyRoot,
29 getChildrenAsJSX,
30 getPendingChildrenAsJSX,
31 + getSuspenseyThingStatus,
32 + resolveSuspenseyThing,
33 + resetSuspenseyThingCache,
34 createPortal,
35 render,
36 renderLegacySyncRoot,
packages/react-noop-renderer/src/ReactNoopPersistent.js
+3
@@ -28,6 +28,9 @@ export const {
28 createLegacyRoot,
29 getChildrenAsJSX,
30 getPendingChildrenAsJSX,
31 + getSuspenseyThingStatus,
32 + resolveSuspenseyThing,
33 + resetSuspenseyThingCache,
34 createPortal,
35 render,
36 renderLegacySyncRoot,
packages/react-noop-renderer/src/createReactNoop.js
+179
@@ -47,6 +47,7 @@ type Props = {
47 left?: null | number,
48 right?: null | number,
49 top?: null | number,
50 + src?: string,
51 ...
52 };
53 type Instance = {
@@ -72,6 +73,11 @@ type CreateRootOptions = {
73 ...
74 };
75
76 +type SuspenseyCommitSubscription = {
77 + pendingCount: number,
78 + commit: null | (() => void),
79 +};
80 +
81 const NO_CONTEXT = {};
82 const UPPERCASE_CONTEXT = {};
83 const UPDATE_SIGNAL = {};
@@ -238,6 +244,11 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
244 hidden: !!newProps.hidden,
245 context: instance.context,
246 };
247 +
248 + if (type === 'suspensey-thing' && typeof newProps.src === 'string') {
249 + clone.src = newProps.src;
250 + }
251 +
252 Object.defineProperty(clone, 'id', {
253 value: clone.id,
254 enumerable: false,
@@ -271,6 +282,78 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
282 return hostContext === UPPERCASE_CONTEXT ? rawText.toUpperCase() : rawText;
283 }
284
285 + type SuspenseyThingRecord = {
286 + status: 'pending' | 'fulfilled',
287 + subscriptions: Array<SuspenseyCommitSubscription> | null,
288 + };
289 +
290 + let suspenseyThingCache: Map<
291 + SuspenseyThingRecord,
292 + 'pending' | 'fulfilled',
293 + > | null = null;
294 +
295 + // Represents a subscription for all the suspensey things that block a
296 + // particular commit. Once they've all loaded, the commit phase can proceed.
297 + let suspenseyCommitSubscription: SuspenseyCommitSubscription | null = null;
298 +
299 + function startSuspendingCommit(): void {
300 + // This is where we might suspend on things that aren't associated with a
301 + // particular node, like document.fonts.ready.
302 + suspenseyCommitSubscription = null;
303 + }
304 +
305 + function suspendInstance(type: string, props: Props): void {
306 + const src = props.src;
307 + if (type === 'suspensey-thing' && typeof src === 'string') {
308 + // Attach a listener to the suspensey thing and create a subscription
309 + // object that uses reference counting to track when all the suspensey
310 + // things have loaded.
311 + const record = suspenseyThingCache.get(src);
312 + if (record === undefined) {
313 + throw new Error('Could not find record for key.');
314 + }
315 + if (record.status === 'pending') {
316 + if (suspenseyCommitSubscription === null) {
317 + suspenseyCommitSubscription = {
318 + pendingCount: 1,
319 + commit: null,
320 + };
321 + } else {
322 + suspenseyCommitSubscription.pendingCount++;
323 + }
324 + }
325 + // Stash the subscription on the record. In `resolveSuspenseyThing`,
326 + // we'll use this fire the commit once all the things have loaded.
327 + if (record.subscriptions === null) {
328 + record.subscriptions = [];
329 + }
330 + record.subscriptions.push(suspenseyCommitSubscription);
331 + } else {
332 + throw new Error(
333 + 'Did not expect this host component to be visited when suspending ' +
334 + 'the commit. Did you check the SuspendCommit flag?',
335 + );
336 + }
337 + return suspenseyCommitSubscription;
338 + }
339 +
340 + function waitForCommitToBeReady():
341 + | ((commit: () => mixed) => () => void)
342 + | null {
343 + const subscription = suspenseyCommitSubscription;
344 + if (subscription !== null) {
345 + suspenseyCommitSubscription = null;
346 + return (commit: () => void) => {
347 + subscription.commit = commit;
348 + const cancelCommit = () => {
349 + subscription.commit = null;
350 + };
351 + return cancelCommit;
352 + };
353 + }
354 + return null;
355 + }
356 +
357 const sharedHostConfig = {
358 supportsSingletons: false,
359
@@ -322,6 +405,11 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
405 hidden: !!props.hidden,
406 context: hostContext,
407 };
408 +
409 + if (type === 'suspensey-thing' && typeof props.src === 'string') {
410 + inst.src = props.src;
411 + }
412 +
413 // Hide from unit tests
414 Object.defineProperty(inst, 'id', {value: inst.id, enumerable: false});
415 Object.defineProperty(inst, 'parent', {
@@ -480,6 +568,45 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
568 const endTime = Scheduler.unstable_now();
569 callback(endTime);
570 },
571 +
572 + shouldSuspendCommit(type: string, props: Props): boolean {
573 + if (type === 'suspensey-thing' && typeof props.src === 'string') {
574 + if (suspenseyThingCache === null) {
575 + suspenseyThingCache = new Map();
576 + }
577 + const record = suspenseyThingCache.get(props.src);
578 + if (record === undefined) {
579 + const newRecord: SuspenseyThingRecord = {
580 + status: 'pending',
581 + subscriptions: null,
582 + };
583 + suspenseyThingCache.set(props.src, newRecord);
584 + const onLoadStart = props.onLoadStart;
585 + if (typeof onLoadStart === 'function') {
586 + onLoadStart();
587 + }
588 + return props.src;
589 + } else {
590 + if (record.status === 'pending') {
591 + // The resource was already requested, but it hasn't finished
592 + // loading yet.
593 + return true;
594 + } else {
595 + // The resource has already loaded. If the renderer is confident that
596 + // the resource will still be cached by the time the render commits,
597 + // then it can return false, like we do here.
598 + return false;
599 + }
600 + }
601 + }
602 + // Don't need to suspend.
603 + return false;
604 + },
605 +
606 + startSuspendingCommit,
607 + suspendInstance,
608 + waitForCommitToBeReady,
609 +
610 prepareRendererToRender() {},
611 resetRendererAfterRender() {},
612 };
@@ -508,6 +635,11 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
635 hostUpdateCounter++;
636 instance.prop = newProps.prop;
637 instance.hidden = !!newProps.hidden;
638 +
639 + if (type === 'suspensey-thing' && typeof newProps.src === 'string') {
640 + instance.src = newProps.src;
641 + }
642 +
643 if (shouldSetTextContent(type, newProps)) {
644 if (__DEV__) {
645 checkPropStringCoercion(newProps.children, 'children');
@@ -689,6 +821,9 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
821 if (instance.hidden) {
822 props.hidden = true;
823 }
824 + if (instance.src) {
825 + props.src = instance.src;
826 + }
827 if (children !== null) {
828 props.children = children;
829 }
@@ -915,6 +1050,50 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
1050 return getPendingChildrenAsJSX(container);
1051 },
1052
1053 + getSuspenseyThingStatus(src): string | null {
1054 + if (suspenseyThingCache === null) {
1055 + return null;
1056 + } else {
1057 + const record = suspenseyThingCache.get(src);
1058 + return record === undefined ? null : record.status;
1059 + }
1060 + },
1061 +
1062 + resolveSuspenseyThing(key: string): void {
1063 + if (suspenseyThingCache === null) {
1064 + suspenseyThingCache = new Map();
1065 + }
1066 + const record = suspenseyThingCache.get(key);
1067 + if (record === undefined) {
1068 + const newRecord: SuspenseyThingRecord = {
1069 + status: 'fulfilled',
1070 + subscriptions: null,
1071 + };
1072 + suspenseyThingCache.set(key, newRecord);
1073 + } else {
1074 + if (record.status === 'pending') {
1075 + record.status = 'fulfilled';
1076 + const subscriptions = record.subscriptions;
1077 + if (subscriptions !== null) {
1078 + record.subscriptions = null;
1079 + for (let i = 0; i < subscriptions.length; i++) {
1080 + const subscription = subscriptions[i];
1081 + subscription.pendingCount--;
1082 + if (subscription.pendingCount === 0) {
1083 + const commit = subscription.commit;
1084 + subscription.commit = null;
1085 + commit();
1086 + }
1087 + }
1088 + }
1089 + }
1090 + }
1091 + },
1092 +
1093 + resetSuspenseyThingCache() {
1094 + suspenseyThingCache = null;
1095 + },
1096 +
1097 createPortal(
1098 children: ReactNodeList,
1099 container: Container,
packages/react-reconciler/src/ReactFiberBeginWork.js
+4 -4
@@ -2298,7 +2298,7 @@ function updateSuspenseComponent(
2298 const newOffscreenQueue: OffscreenQueue = {
2299 transitions: currentTransitions,
2300 markerInstances: parentMarkerInstances,
2301 - wakeables: null,
2301 + retryQueue: null,
2302 };
2303 primaryChildFragment.updateQueue = newOffscreenQueue;
2304 } else {
@@ -2399,7 +2399,7 @@ function updateSuspenseComponent(
2399 const newOffscreenQueue: OffscreenQueue = {
2400 transitions: currentTransitions,
2401 markerInstances: parentMarkerInstances,
2402 - wakeables: null,
2402 + retryQueue: null,
2403 };
2404 primaryChildFragment.updateQueue = newOffscreenQueue;
2405 } else if (offscreenQueue === currentOffscreenQueue) {
@@ -2408,9 +2408,9 @@ function updateSuspenseComponent(
2408 const newOffscreenQueue: OffscreenQueue = {
2409 transitions: currentTransitions,
2410 markerInstances: parentMarkerInstances,
2411 - wakeables:
2411 + retryQueue:
2412 currentOffscreenQueue !== null
2413 - ? currentOffscreenQueue.wakeables
2413 + ? currentOffscreenQueue.retryQueue
2414 : null,
2415 };
2416 primaryChildFragment.updateQueue = newOffscreenQueue;
packages/react-reconciler/src/ReactFiberCommitWork.js
+39 -15
@@ -19,7 +19,7 @@ import type {
19 import type {Fiber, FiberRoot} from './ReactInternalTypes';
20 import type {Lanes} from './ReactFiberLane';
21 import {NoTimestamp, SyncLane} from './ReactFiberLane';
22 -import type {SuspenseState} from './ReactFiberSuspenseComponent';
22 +import type {SuspenseState, RetryQueue} from './ReactFiberSuspenseComponent';
23 import type {UpdateQueue} from './ReactFiberClassUpdateQueue';
24 import type {FunctionComponentUpdateQueue} from './ReactFiberHooks';
25 import type {Wakeable} from 'shared/ReactTypes';
@@ -94,6 +94,7 @@ import {
94 LayoutMask,
95 PassiveMask,
96 Visibility,
97 + SuspenseyCommit,
98 } from './ReactFiberFlags';
99 import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber';
100 import {
@@ -158,6 +159,7 @@ import {
159 mountHoistable,
160 unmountHoistable,
161 prepareToCommitHoistables,
162 + suspendInstance,
163 } from './ReactFiberHostConfig';
164 import {
165 captureCommitPhaseError,
@@ -2310,9 +2312,9 @@ function commitSuspenseCallback(finishedWork: Fiber) {
2312 if (enableSuspenseCallback && newState !== null) {
2313 const suspenseCallback = finishedWork.memoizedProps.suspenseCallback;
2314 if (typeof suspenseCallback === 'function') {
2313 - const wakeables: Set<Wakeable> | null = (finishedWork.updateQueue: any);
2314 - if (wakeables !== null) {
2315 - suspenseCallback(new Set(wakeables));
2315 + const retryQueue: RetryQueue | null = (finishedWork.updateQueue: any);
2316 + if (retryQueue !== null) {
2317 + suspenseCallback(new Set(retryQueue));
2318 }
2319 } else if (__DEV__) {
2320 if (suspenseCallback !== undefined) {
@@ -2431,7 +2433,7 @@ export function attachOffscreenInstance(instance: OffscreenInstance): void {
2433
2434 function attachSuspenseRetryListeners(
2435 finishedWork: Fiber,
2434 - wakeables: Set<Wakeable>,
2436 + wakeables: RetryQueue,
2437 ) {
2438 // If this boundary just timed out, then it will have a set of wakeables.
2439 // For each wakeable, attach a listener so that when it resolves, React
@@ -2917,10 +2919,10 @@ function commitMutationEffectsOnFiber(
2919 } catch (error) {
2920 captureCommitPhaseError(finishedWork, finishedWork.return, error);
2921 }
2920 - const wakeables: Set<Wakeable> | null = (finishedWork.updateQueue: any);
2921 - if (wakeables !== null) {
2922 + const retryQueue: RetryQueue | null = (finishedWork.updateQueue: any);
2923 + if (retryQueue !== null) {
2924 finishedWork.updateQueue = null;
2923 - attachSuspenseRetryListeners(finishedWork, wakeables);
2925 + attachSuspenseRetryListeners(finishedWork, retryQueue);
2926 }
2927 }
2928 return;
@@ -3006,10 +3008,10 @@ function commitMutationEffectsOnFiber(
3008 const offscreenQueue: OffscreenQueue | null =
3009 (finishedWork.updateQueue: any);
3010 if (offscreenQueue !== null) {
3009 - const wakeables = offscreenQueue.wakeables;
3010 - if (wakeables !== null) {
3011 - offscreenQueue.wakeables = null;
3012 - attachSuspenseRetryListeners(finishedWork, wakeables);
3011 + const retryQueue = offscreenQueue.retryQueue;
3012 + if (retryQueue !== null) {
3013 + offscreenQueue.retryQueue = null;
3014 + attachSuspenseRetryListeners(finishedWork, retryQueue);
3015 }
3016 }
3017 }
@@ -3020,10 +3022,11 @@ function commitMutationEffectsOnFiber(
3022 commitReconciliationEffects(finishedWork);
3023
3024 if (flags & Update) {
3023 - const wakeables: Set<Wakeable> | null = (finishedWork.updateQueue: any);
3024 - if (wakeables !== null) {
3025 + const retryQueue: Set<Wakeable> | null =
3026 + (finishedWork.updateQueue: any);
3027 + if (retryQueue !== null) {
3028 finishedWork.updateQueue = null;
3026 - attachSuspenseRetryListeners(finishedWork, wakeables);
3029 + attachSuspenseRetryListeners(finishedWork, retryQueue);
3030 }
3031 }
3032 return;
@@ -4061,6 +4064,27 @@ export function commitPassiveUnmountEffects(finishedWork: Fiber): void {
4064 resetCurrentDebugFiberInDEV();
4065 }
4066
4067 +export function recursivelyAccumulateSuspenseyCommit(parentFiber: Fiber): void {
4068 + if (parentFiber.subtreeFlags & SuspenseyCommit) {
4069 + let child = parentFiber.child;
4070 + while (child !== null) {
4071 + recursivelyAccumulateSuspenseyCommit(child);
4072 + switch (child.tag) {
4073 + case HostComponent:
4074 + case HostHoistable: {
4075 + if (child.flags & SuspenseyCommit) {
4076 + const type = child.type;
4077 + const props = child.memoizedProps;
4078 + suspendInstance(type, props);
4079 + }
4080 + break;
4081 + }
4082 + }
4083 + child = child.sibling;
4084 + }
4085 + }
4086 +}
4087 +
4088 function detachAlternateSiblings(parentFiber: Fiber) {
4089 // A fiber was deleted from this parent fiber, but it's still part of the
4090 // previous (alternate) parent fiber's list of children. Because children
packages/react-reconciler/src/ReactFiberCompleteWork.js
+135 -28
@@ -10,11 +10,7 @@
10 import type {Fiber, FiberRoot} from './ReactInternalTypes';
11 import type {RootState} from './ReactFiberRoot';
12 import type {Lanes, Lane} from './ReactFiberLane';
13 -import type {
14 - ReactScopeInstance,
15 - ReactContext,
16 - Wakeable,
17 -} from 'shared/ReactTypes';
13 +import type {ReactScopeInstance, ReactContext} from 'shared/ReactTypes';
14 import type {
15 Instance,
16 Type,
@@ -25,7 +21,9 @@ import type {
21 import type {
22 SuspenseState,
23 SuspenseListRenderState,
24 + RetryQueue,
25 } from './ReactFiberSuspenseComponent';
26 +import type {OffscreenQueue} from './ReactFiberOffscreenComponent';
27 import {isOffscreenManual} from './ReactFiberOffscreenComponent';
28 import type {OffscreenState} from './ReactFiberOffscreenComponent';
29 import type {TracingMarkerInstance} from './ReactFiberTracingMarkerComponent';
@@ -90,6 +88,8 @@ import {
88 Incomplete,
89 ShouldCapture,
90 ForceClientRender,
91 + SuspenseyCommit,
92 + ScheduleRetry,
93 } from './ReactFiberFlags';
94
95 import {
@@ -111,6 +111,7 @@ import {
111 finalizeContainerChildren,
112 preparePortalMount,
113 prepareScopeUpdate,
114 + shouldSuspendCommit,
115 } from './ReactFiberHostConfig';
116 import {
117 getRootHostContainer,
@@ -150,6 +151,7 @@ import {
151 renderHasNotSuspendedYet,
152 getRenderTargetTime,
153 getWorkInProgressTransitions,
154 + shouldRemainOnPreviousScreen,
155 } from './ReactFiberWorkLoop';
156 import {
157 OffscreenLane,
@@ -157,6 +159,8 @@ import {
159 NoLanes,
160 includesSomeLane,
161 mergeLanes,
162 + claimNextRetryLane,
163 + includesOnlyNonUrgentLanes,
164 } from './ReactFiberLane';
165 import {resetChildFibers} from './ReactChildFiber';
166 import {createScopeInstance} from './ReactFiberScope';
@@ -168,6 +172,7 @@ import {
172 popMarkerInstance,
173 popRootMarkerInstance,
174 } from './ReactFiberTracingMarkerComponent';
175 +import {suspendCommit} from './ReactFiberThenable';
176
177 function markUpdate(workInProgress: Fiber) {
178 // Tag the fiber with an update effect. This turns a Placement into
@@ -411,6 +416,7 @@ function updateHostComponent(
416 workInProgress: Fiber,
417 type: Type,
418 newProps: Props,
419 + renderLanes: Lanes,
420 ) {
421 if (supportsMutation) {
422 // If we have an alternate, that means this is an update and we need to
@@ -427,6 +433,9 @@ function updateHostComponent(
433 // TODO: Split the update API as separate for the props vs. children.
434 // Even better would be if children weren't special cased at all tho.
435 const instance: Instance = workInProgress.stateNode;
436 +
437 + suspendHostCommitIfNeeded(workInProgress, type, newProps, renderLanes);
438 +
439 const currentHostContext = getHostContext();
440 // TODO: Experiencing an error where oldProps is null. Suggests a host
441 // component is hitting the resume path. Figure out why. Possibly
@@ -485,6 +494,9 @@ function updateHostComponent(
494 childrenUnchanged,
495 recyclableInstance,
496 );
497 +
498 + suspendHostCommitIfNeeded(workInProgress, type, newProps, renderLanes);
499 +
500 if (
501 finalizeInitialChildren(newInstance, type, newProps, currentHostContext)
502 ) {
@@ -502,6 +514,89 @@ function updateHostComponent(
514 }
515 }
516 }
517 +
518 +// TODO: This should ideally move to begin phase, but currently the instance is
519 +// not created until the complete phase. For our existing use cases, host nodes
520 +// that suspend don't have children, so it doesn't matter. But that might not
521 +// always be true in the future.
522 +function suspendHostCommitIfNeeded(
523 + workInProgress: Fiber,
524 + type: Type,
525 + props: Props,
526 + renderLanes: Lanes,
527 +) {
528 + // Ask the renderer if this instance should suspend the commit.
529 + if (!shouldSuspendCommit(type, props)) {
530 + // If this flag was set previously, we can remove it. The flag represents
531 + // whether this particular set of props might ever need to suspend. The
532 + // safest thing to do is for shouldSuspendCommit to always return true, but
533 + // if the renderer is reasonably confident that the underlying resource
534 + // won't be evicted, it can return false as a performance optimization.
535 + workInProgress.flags &= ~SuspenseyCommit;
536 + return;
537 + }
538 +
539 + // Mark this fiber with a flag. We use this right before the commit phase to
540 + // find all the fibers that might need to suspend the commit. In the future
541 + // we'll also use it when revealing a hidden tree. It gets set even if we
542 + // don't end up suspending this particular commit, because if this tree ever
543 + // becomes hidden, we might want to suspend before revealing it again.
544 + workInProgress.flags |= SuspenseyCommit;
545 +
546 + // Check if we're rendering at a "non-urgent" priority. This is the same
547 + // check that `useDeferredValue` does to determine whether it needs to
548 + // defer. This is partly for gradual adoption purposes (i.e. shouldn't start
549 + // suspending until you opt in with startTransition or Suspense) but it
550 + // also happens to be the desired behavior for the concrete use cases we've
551 + // thought of so far, like CSS loading, fonts, images, etc.
552 + // TODO: We may decide to expose a way to force a fallback even during a
553 + // sync update.
554 + if (!includesOnlyNonUrgentLanes(renderLanes)) {
555 + // This is an urgent render. Never suspend or trigger a fallback.
556 + } else {
557 + // Need to decide whether to activate the nearest fallback or to continue
558 + // rendering and suspend right before the commit phase.
559 + if (shouldRemainOnPreviousScreen()) {
560 + // It's OK to block the commit. Don't show a fallback.
561 + } else {
562 + // We shouldn't block the commit. Activate a fallback at the nearest
563 + // Suspense boundary.
564 + suspendCommit();
565 + }
566 + }
567 +}
568 +
569 +function scheduleRetryEffect(
570 + workInProgress: Fiber,
571 + retryQueue: RetryQueue | null,
572 +) {
573 + const wakeables = retryQueue;
574 + if (wakeables !== null) {
575 + // Schedule an effect to attach a retry listener to the promise.
576 + // TODO: Move to passive phase
577 + workInProgress.flags |= Update;
578 + } else {
579 + // This boundary suspended, but no wakeables were added to the retry
580 + // queue. Check if the renderer suspended commit. If so, this means
581 + // that once the fallback is committed, we can immediately retry
582 + // rendering again, because rendering wasn't actually blocked. Only
583 + // the commit phase.
584 + // TODO: Consider a model where we always schedule an immediate retry, even
585 + // for normal Suspense. That way the retry can partially render up to the
586 + // first thing that suspends.
587 + if (workInProgress.flags & ScheduleRetry) {
588 + const retryLane =
589 + // TODO: This check should probably be moved into claimNextRetryLane
590 + // I also suspect that we need some further consolidation of offscreen
591 + // and retry lanes.
592 + workInProgress.tag !== OffscreenComponent
593 + ? claimNextRetryLane()
594 + : OffscreenLane;
595 + workInProgress.lanes = mergeLanes(workInProgress.lanes, retryLane);
596 + }
597 + }
598 +}
599 +
600 function updateHostText(
601 current: Fiber,
602 workInProgress: Fiber,
@@ -955,6 +1050,7 @@ function completeWork(
1050 workInProgress,
1051 workInProgress.type,
1052 workInProgress.pendingProps,
1053 + renderLanes,
1054 );
1055 }
1056 bubbleProperties(workInProgress);
@@ -968,7 +1064,13 @@ function completeWork(
1064 const rootContainerInstance = getRootHostContainer();
1065 const type = workInProgress.type;
1066 if (current !== null && workInProgress.stateNode != null) {
971 - updateHostComponent(current, workInProgress, type, newProps);
1067 + updateHostComponent(
1068 + current,
1069 + workInProgress,
1070 + type,
1071 + newProps,
1072 + renderLanes,
1073 + );
1074
1075 if (current.ref !== workInProgress.ref) {
1076 markRef(workInProgress);
@@ -989,19 +1091,22 @@ function completeWork(
1091
1092 const currentHostContext = getHostContext();
1093 const wasHydrated = popHydrationState(workInProgress);
1094 + let instance: Instance;
1095 if (wasHydrated) {
1096 // We ignore the boolean indicating there is an updateQueue because
1097 // it is used only to set text children and HostSingletons do not
1098 // use them.
1099 prepareToHydrateHostInstance(workInProgress, currentHostContext);
1100 + instance = workInProgress.stateNode;
1101 } else {
998 - workInProgress.stateNode = resolveSingletonInstance(
1102 + instance = resolveSingletonInstance(
1103 type,
1104 newProps,
1105 rootContainerInstance,
1106 currentHostContext,
1107 true,
1108 );
1109 + workInProgress.stateNode = instance;
1110 markUpdate(workInProgress);
1111 }
1112
@@ -1019,7 +1124,13 @@ function completeWork(
1124 popHostContext(workInProgress);
1125 const type = workInProgress.type;
1126 if (current !== null && workInProgress.stateNode != null) {
1022 - updateHostComponent(current, workInProgress, type, newProps);
1127 + updateHostComponent(
1128 + current,
1129 + workInProgress,
1130 + type,
1131 + newProps,
1132 + renderLanes,
1133 + );
1134
1135 if (current.ref !== workInProgress.ref) {
1136 markRef(workInProgress);
@@ -1081,6 +1192,8 @@ function completeWork(
1192 }
1193 }
1194
1195 + suspendHostCommitIfNeeded(workInProgress, type, newProps, renderLanes);
1196 +
1197 if (workInProgress.ref !== null) {
1198 // If there is a ref on a host node we need to schedule a callback
1199 markRef(workInProgress);
@@ -1227,12 +1340,8 @@ function completeWork(
1340 }
1341 }
1342
1230 - const wakeables: Set<Wakeable> | null = (workInProgress.updateQueue: any);
1231 - if (wakeables !== null) {
1232 - // Schedule an effect to attach a retry listener to the promise.
1233 - // TODO: Move to passive phase
1234 - workInProgress.flags |= Update;
1235 - }
1343 + const retryQueue: RetryQueue | null = (workInProgress.updateQueue: any);
1344 + scheduleRetryEffect(workInProgress, retryQueue);
1345
1346 if (
1347 enableSuspenseCallback &&
@@ -1337,11 +1446,10 @@ function completeWork(
1446 // We might bail out of the loop before finding any but that
1447 // doesn't matter since that means that the other boundaries that
1448 // we did find already has their listeners attached.
1340 - const newThenables = suspended.updateQueue;
1341 - if (newThenables !== null) {
1342 - workInProgress.updateQueue = newThenables;
1343 - workInProgress.flags |= Update;
1344 - }
1449 + const retryQueue: RetryQueue | null =
1450 + (suspended.updateQueue: any);
1451 + workInProgress.updateQueue = retryQueue;
1452 + scheduleRetryEffect(workInProgress, retryQueue);
1453
1454 // Rerender the whole list, but this time, we'll force fallbacks
1455 // to stay in place.
@@ -1399,11 +1507,9 @@ function completeWork(
1507
1508 // Ensure we transfer the update queue to the parent so that it doesn't
1509 // get lost if this row ends up dropped during a second pass.
1402 - const newThenables = suspended.updateQueue;
1403 - if (newThenables !== null) {
1404 - workInProgress.updateQueue = newThenables;
1405 - workInProgress.flags |= Update;
1406 - }
1510 + const retryQueue: RetryQueue | null = (suspended.updateQueue: any);
1511 + workInProgress.updateQueue = retryQueue;
1512 + scheduleRetryEffect(workInProgress, retryQueue);
1513
1514 cutOffTailIfNeeded(renderState, true);
1515 // This might have been modified.
@@ -1566,10 +1672,11 @@ function completeWork(
1672 }
1673 }
1674
1569 - if (workInProgress.updateQueue !== null) {
1570 - // Schedule an effect to attach Suspense retry listeners
1571 - // TODO: Move to passive phase
1572 - workInProgress.flags |= Update;
1675 + const offscreenQueue: OffscreenQueue | null =
1676 + (workInProgress.updateQueue: any);
1677 + if (offscreenQueue !== null) {
1678 + const retryQueue = offscreenQueue.retryQueue;
1679 + scheduleRetryEffect(workInProgress, retryQueue);
1680 }
1681
1682 if (enableCache) {
packages/react-reconciler/src/ReactFiberFlags.js
+39 -32
@@ -12,57 +12,63 @@ import {enableCreateEventHandleAPI} from 'shared/ReactFeatureFlags';
12 export type Flags = number;
13
14 // Don't change these values. They're used by React Dev Tools.
15 -export const NoFlags = /* */ 0b000000000000000000000000000;
16 -export const PerformedWork = /* */ 0b000000000000000000000000001;
17 -export const Placement = /* */ 0b000000000000000000000000010;
18 -export const DidCapture = /* */ 0b000000000000000000010000000;
19 -export const Hydrating = /* */ 0b000000000000001000000000000;
15 +export const NoFlags = /* */ 0b0000000000000000000000000000;
16 +export const PerformedWork = /* */ 0b0000000000000000000000000001;
17 +export const Placement = /* */ 0b0000000000000000000000000010;
18 +export const DidCapture = /* */ 0b0000000000000000000010000000;
19 +export const Hydrating = /* */ 0b0000000000000001000000000000;
20
21 // You can change the rest (and add more).
22 -export const Update = /* */ 0b000000000000000000000000100;
23 -/* Skipped value: 0b000000000000000000000001000; */
22 +export const Update = /* */ 0b0000000000000000000000000100;
23 +/* Skipped value: 0b0000000000000000000000001000; */
24
25 -export const ChildDeletion = /* */ 0b000000000000000000000010000;
26 -export const ContentReset = /* */ 0b000000000000000000000100000;
27 -export const Callback = /* */ 0b000000000000000000001000000;
28 -/* Used by DidCapture: 0b000000000000000000010000000; */
25 +export const ChildDeletion = /* */ 0b0000000000000000000000010000;
26 +export const ContentReset = /* */ 0b0000000000000000000000100000;
27 +export const Callback = /* */ 0b0000000000000000000001000000;
28 +/* Used by DidCapture: 0b0000000000000000000010000000; */
29
30 -export const ForceClientRender = /* */ 0b000000000000000000100000000;
31 -export const Ref = /* */ 0b000000000000000001000000000;
32 -export const Snapshot = /* */ 0b000000000000000010000000000;
33 -export const Passive = /* */ 0b000000000000000100000000000;
34 -/* Used by Hydrating: 0b000000000000001000000000000; */
30 +export const ForceClientRender = /* */ 0b0000000000000000000100000000;
31 +export const Ref = /* */ 0b0000000000000000001000000000;
32 +export const Snapshot = /* */ 0b0000000000000000010000000000;
33 +export const Passive = /* */ 0b0000000000000000100000000000;
34 +/* Used by Hydrating: 0b0000000000000001000000000000; */
35
36 -export const Visibility = /* */ 0b000000000000010000000000000;
37 -export const StoreConsistency = /* */ 0b000000000000100000000000000;
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
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
44 export const LifecycleEffectMask =
45 Passive | Update | Callback | Ref | Snapshot | StoreConsistency;
46
47 // Union of all commit flags (flags with the lifetime of a particular commit)
43 -export const HostEffectMask = /* */ 0b00000000000011111111111111;
48 +export const HostEffectMask = /* */ 0b0000000000000111111111111111;
49
50 // These are not really side effects, but we still reuse this field.
46 -export const Incomplete = /* */ 0b000000000001000000000000000;
47 -export const ShouldCapture = /* */ 0b000000000010000000000000000;
48 -export const ForceUpdateForLegacySuspense = /* */ 0b000000000100000000000000000;
49 -export const DidPropagateContext = /* */ 0b000000001000000000000000000;
50 -export const NeedsPropagation = /* */ 0b000000010000000000000000000;
51 -export const Forked = /* */ 0b000000100000000000000000000;
51 +export const Incomplete = /* */ 0b0000000000001000000000000000;
52 +export const ShouldCapture = /* */ 0b0000000000010000000000000000;
53 +export const ForceUpdateForLegacySuspense = /* */ 0b0000000000100000000000000000;
54 +export const DidPropagateContext = /* */ 0b0000000001000000000000000000;
55 +export const NeedsPropagation = /* */ 0b0000000010000000000000000000;
56 +export const Forked = /* */ 0b0000000100000000000000000000;
57
58 // Static tags describe aspects of a fiber that are not specific to a render,
59 // e.g. a fiber uses a passive effect (even if there are no updates on this particular render).
60 // This enables us to defer more work in the unmount case,
61 // since we can defer traversing the tree during layout to look for Passive effects,
62 // and instead rely on the static flag as a signal that there may be cleanup work.
58 -export const RefStatic = /* */ 0b000001000000000000000000000;
59 -export const LayoutStatic = /* */ 0b000010000000000000000000000;
60 -export const PassiveStatic = /* */ 0b000100000000000000000000000;
63 +export const RefStatic = /* */ 0b0000001000000000000000000000;
64 +export const LayoutStatic = /* */ 0b0000010000000000000000000000;
65 +export const PassiveStatic = /* */ 0b0000100000000000000000000000;
66 +export const SuspenseyCommit = /* */ 0b0001000000000000000000000000;
67
68 // Flag used to identify newly inserted fibers. It isn't reset after commit unlike `Placement`.
63 -export const PlacementDEV = /* */ 0b001000000000000000000000000;
64 -export const MountLayoutDev = /* */ 0b010000000000000000000000000;
65 -export const MountPassiveDev = /* */ 0b100000000000000000000000000;
69 +export const PlacementDEV = /* */ 0b0010000000000000000000000000;
70 +export const MountLayoutDev = /* */ 0b0100000000000000000000000000;
71 +export const MountPassiveDev = /* */ 0b1000000000000000000000000000;
72
73 // Groups of flags that are used in the commit phase to skip over trees that
74 // don't contain effects, by checking subtreeFlags.
@@ -96,4 +102,5 @@ export const PassiveMask = Passive | Visibility | ChildDeletion;
102 // Union of tags that don't get reset on clones.
103 // This allows certain concepts to persist without recalculating them,
104 // e.g. whether a subtree contains passive effects or portals.
99 -export const StaticMask = LayoutStatic | PassiveStatic | RefStatic;
105 +export const StaticMask =
106 + LayoutStatic | PassiveStatic | RefStatic | SuspenseyCommit;
packages/react-reconciler/src/ReactFiberHostConfigWithNoSingletons.js
+1 -1
@@ -10,7 +10,7 @@
10 // Renderers that don't support mutation
11 // can re-export everything from this module.
12
13 -function shim(...args: any) {
13 +function shim(...args: any): any {
14 throw new Error(
15 'The current renderer does not support Singletons. ' +
16 'This error is likely caused by a bug in React. ' +
packages/react-reconciler/src/ReactFiberOffscreenComponent.js
+2 -1
@@ -15,6 +15,7 @@ import type {
15 Transition,
16 TracingMarkerInstance,
17 } from './ReactFiberTracingMarkerComponent';
18 +import type {RetryQueue} from './ReactFiberSuspenseComponent';
19
20 export type OffscreenProps = {
21 // TODO: Pick an API before exposing the Offscreen type. I've chosen an enum
@@ -40,7 +41,7 @@ export type OffscreenState = {
41 export type OffscreenQueue = {
42 transitions: Array<Transition> | null,
43 markerInstances: Array<TracingMarkerInstance> | null,
43 - wakeables: Set<Wakeable> | null,
44 + retryQueue: RetryQueue | null,
45 };
46
47 type OffscreenVisibility = number;
packages/react-reconciler/src/ReactFiberRoot.js
+1
@@ -60,6 +60,7 @@ function FiberRootNode(
60 this.pingCache = null;
61 this.finishedWork = null;
62 this.timeoutHandle = noTimeout;
63 + this.cancelPendingCommit = null;
64 this.context = null;
65 this.pendingContext = null;
66 this.callbackNode = null;
packages/react-reconciler/src/ReactFiberSuspenseComponent.js
+2
@@ -67,6 +67,8 @@ export type SuspenseListRenderState = {
67 tailMode: SuspenseListTailMode,
68 };
69
70 +export type RetryQueue = Set<Wakeable>;
71 +
72 export function findFirstSuspended(row: Fiber): null | Fiber {
73 let node = row;
74 while (node !== null) {
packages/react-reconciler/src/ReactFiberThenable.js
+14
@@ -31,6 +31,12 @@ export const SuspenseException: mixed = new Error(
31 "call the promise's `.catch` method and pass the result to `use`",
32 );
33
34 +// This is a noop thenable that we use to trigger a fallback in throwException.
35 +// TODO: It would be better to refactor throwException into multiple functions
36 +// so we can trigger a fallback directly without having to check the type. But
37 +// for now this will do.
38 +export const noopSuspenseyCommitThenable = {then() {}};
39 +
40 export function createThenableState(): ThenableState {
41 // The ThenableState is created the first time a component suspends. If it
42 // suspends again, we'll reuse the same state.
@@ -140,6 +146,14 @@ export function trackUsedThenable<T>(
146 }
147 }
148
149 +export function suspendCommit(): void {
150 + // This extra indirection only exists so it can handle passing
151 + // noopSuspenseyCommitThenable through to throwException.
152 + // TODO: Factor the thenable check out of throwException
153 + suspendedThenable = noopSuspenseyCommitThenable;
154 + throw SuspenseException;
155 +}
156 +
157 // This is used to track the actual thenable that suspended so it can be
158 // passed to the rest of the Suspense implementation — which, for historical
159 // reasons, expects to receive a thenable.
packages/react-reconciler/src/ReactFiberThrow.js
+40 -18
@@ -13,6 +13,7 @@ import type {CapturedValue} from './ReactCapturedValue';
13 import type {Update} from './ReactFiberClassUpdateQueue';
14 import type {Wakeable} from 'shared/ReactTypes';
15 import type {OffscreenQueue} from './ReactFiberOffscreenComponent';
16 +import type {RetryQueue} from './ReactFiberSuspenseComponent';
17
18 import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber';
19 import {
@@ -33,6 +34,7 @@ import {
34 LifecycleEffectMask,
35 ForceUpdateForLegacySuspense,
36 ForceClientRender,
37 + ScheduleRetry,
38 } from './ReactFiberFlags';
39 import {NoMode, ConcurrentMode, DebugTracingMode} from './ReactTypeOfMode';
40 import {
@@ -79,6 +81,7 @@ import {
81 queueHydrationError,
82 } from './ReactFiberHydrationContext';
83 import {ConcurrentRoot} from './ReactRootTags';
84 +import {noopSuspenseyCommitThenable} from './ReactFiberThenable';
85
86 function createRootErrorUpdate(
87 fiber: Fiber,
@@ -412,33 +415,52 @@ function throwException(
415 //
416 // When the wakeable resolves, we'll attempt to render the boundary
417 // again ("retry").
415 - const wakeables: Set<Wakeable> | null =
416 - (suspenseBoundary.updateQueue: any);
417 - if (wakeables === null) {
418 - suspenseBoundary.updateQueue = new Set([wakeable]);
418 +
419 + // Check if this is a Suspensey resource. We do not attach retry
420 + // listeners to these, because we don't actually need them for
421 + // rendering. Only for committing. Instead, if a fallback commits
422 + // and the only thing that suspended was a Suspensey resource, we
423 + // retry immediately.
424 + // TODO: Refactor throwException so that we don't have to do this type
425 + // check. The caller already knows what the cause was.
426 + const isSuspenseyResource = wakeable === noopSuspenseyCommitThenable;
427 + if (isSuspenseyResource) {
428 + suspenseBoundary.flags |= ScheduleRetry;
429 } else {
420 - wakeables.add(wakeable);
430 + const retryQueue: RetryQueue | null =
431 + (suspenseBoundary.updateQueue: any);
432 + if (retryQueue === null) {
433 + suspenseBoundary.updateQueue = new Set([wakeable]);
434 + } else {
435 + retryQueue.add(wakeable);
436 + }
437 }
438 break;
439 }
440 case OffscreenComponent: {
441 if (suspenseBoundary.mode & ConcurrentMode) {
442 suspenseBoundary.flags |= ShouldCapture;
427 - const offscreenQueue: OffscreenQueue | null =
428 - (suspenseBoundary.updateQueue: any);
429 - if (offscreenQueue === null) {
430 - const newOffscreenQueue: OffscreenQueue = {
431 - transitions: null,
432 - markerInstances: null,
433 - wakeables: new Set([wakeable]),
434 - };
435 - suspenseBoundary.updateQueue = newOffscreenQueue;
443 + const isSuspenseyResource =
444 + wakeable === noopSuspenseyCommitThenable;
445 + if (isSuspenseyResource) {
446 + suspenseBoundary.flags |= ScheduleRetry;
447 } else {
437 - const wakeables = offscreenQueue.wakeables;
438 - if (wakeables === null) {
439 - offscreenQueue.wakeables = new Set([wakeable]);
448 + const offscreenQueue: OffscreenQueue | null =
449 + (suspenseBoundary.updateQueue: any);
450 + if (offscreenQueue === null) {
451 + const newOffscreenQueue: OffscreenQueue = {
452 + transitions: null,
453 + markerInstances: null,
454 + retryQueue: new Set([wakeable]),
455 + };
456 + suspenseBoundary.updateQueue = newOffscreenQueue;
457 } else {
441 - wakeables.add(wakeable);
458 + const retryQueue = offscreenQueue.retryQueue;
459 + if (retryQueue === null) {
460 + offscreenQueue.retryQueue = new Set([wakeable]);
461 + } else {
462 + retryQueue.add(wakeable);
463 + }
464 }
465 }
466 break;
packages/react-reconciler/src/ReactFiberWorkLoop.js
+125 -31
@@ -84,6 +84,8 @@ import {
84 scheduleMicrotask,
85 prepareRendererToRender,
86 resetRendererAfterRender,
87 + startSuspendingCommit,
88 + waitForCommitToBeReady,
89 } from './ReactFiberHostConfig';
90
91 import {
@@ -161,6 +163,7 @@ import {
163 movePendingFibersToMemoized,
164 addTransitionToLanesMap,
165 getTransitionsForLanes,
166 + includesOnlyNonUrgentLanes,
167 } from './ReactFiberLane';
168 import {
169 DiscreteEventPriority,
@@ -201,6 +204,7 @@ import {
204 invokePassiveEffectMountInDEV,
205 invokeLayoutEffectUnmountInDEV,
206 invokePassiveEffectUnmountInDEV,
207 + recursivelyAccumulateSuspenseyCommit,
208 } from './ReactFiberCommitWork';
209 import {enqueueUpdate} from './ReactFiberClassUpdateQueue';
210 import {resetContextDependencies} from './ReactFiberNewContext';
@@ -905,6 +909,18 @@ function ensureRootIsScheduled(root: FiberRoot, currentTime: number) {
909 return;
910 }
911
912 + const cancelPendingCommit = root.cancelPendingCommit;
913 + if (cancelPendingCommit !== null) {
914 + // We should only interrupt a pending commit if the new update
915 + // is urgent.
916 + if (includesOnlyNonUrgentLanes(nextLanes)) {
917 + // The new update is not urgent. Don't interrupt the pending commit.
918 + root.callbackPriority = NoLane;
919 + root.callbackNode = null;
920 + return;
921 + }
922 + }
923 +
924 // We use the highest priority lane to represent the priority of the callback.
925 const newCallbackPriority = getHighestPriorityLane(nextLanes);
926
@@ -1158,7 +1174,7 @@ function performConcurrentWorkOnRoot(
1174 // or, if something suspended, wait to commit it after a timeout.
1175 root.finishedWork = finishedWork;
1176 root.finishedLanes = lanes;
1161 - finishConcurrentRender(root, exitStatus, lanes);
1177 + finishConcurrentRender(root, exitStatus, finishedWork, lanes);
1178 }
1179 }
1180
@@ -1263,6 +1279,7 @@ export function queueRecoverableErrors(errors: Array<CapturedValue<mixed>>) {
1279 function finishConcurrentRender(
1280 root: FiberRoot,
1281 exitStatus: RootExitStatus,
1282 + finishedWork: Fiber,
1283 lanes: Lanes,
1284 ) {
1285 switch (exitStatus) {
@@ -1276,10 +1293,12 @@ function finishConcurrentRender(
1293 case RootErrored: {
1294 // We should have already attempted to retry this tree. If we reached
1295 // this point, it errored again. Commit it.
1279 - commitRoot(
1296 + commitRootWhenReady(
1297 root,
1298 + finishedWork,
1299 workInProgressRootRecoverableErrors,
1300 workInProgressTransitions,
1301 + lanes,
1302 );
1303 break;
1304 }
@@ -1310,11 +1329,13 @@ function finishConcurrentRender(
1329 // lower priority work to do. Instead of committing the fallback
1330 // immediately, wait for more data to arrive.
1331 root.timeoutHandle = scheduleTimeout(
1313 - commitRoot.bind(
1332 + commitRootWhenReady.bind(
1333 null,
1334 root,
1335 + finishedWork,
1336 workInProgressRootRecoverableErrors,
1337 workInProgressTransitions,
1338 + lanes,
1339 ),
1340 msUntilTimeout,
1341 );
@@ -1322,10 +1343,12 @@ function finishConcurrentRender(
1343 }
1344 }
1345 // The work expired. Commit immediately.
1325 - commitRoot(
1346 + commitRootWhenReady(
1347 root,
1348 + finishedWork,
1349 workInProgressRootRecoverableErrors,
1350 workInProgressTransitions,
1351 + lanes,
1352 );
1353 break;
1354 }
@@ -1357,11 +1380,13 @@ function finishConcurrentRender(
1380 // Instead of committing the fallback immediately, wait for more data
1381 // to arrive.
1382 root.timeoutHandle = scheduleTimeout(
1360 - commitRoot.bind(
1383 + commitRootWhenReady.bind(
1384 null,
1385 root,
1386 + finishedWork,
1387 workInProgressRootRecoverableErrors,
1388 workInProgressTransitions,
1389 + lanes,
1390 ),
1391 msUntilTimeout,
1392 );
@@ -1370,19 +1395,23 @@ function finishConcurrentRender(
1395 }
1396
1397 // Commit the placeholder.
1373 - commitRoot(
1398 + commitRootWhenReady(
1399 root,
1400 + finishedWork,
1401 workInProgressRootRecoverableErrors,
1402 workInProgressTransitions,
1403 + lanes,
1404 );
1405 break;
1406 }
1407 case RootCompleted: {
1381 - // The work completed. Ready to commit.
1382 - commitRoot(
1408 + // The work completed.
1409 + commitRootWhenReady(
1410 root,
1411 + finishedWork,
1412 workInProgressRootRecoverableErrors,
1413 workInProgressTransitions,
1414 + lanes,
1415 );
1416 break;
1417 }
@@ -1392,6 +1421,53 @@ function finishConcurrentRender(
1421 }
1422 }
1423
1424 +function commitRootWhenReady(
1425 + root: FiberRoot,
1426 + finishedWork: Fiber,
1427 + recoverableErrors: Array<CapturedValue<mixed>> | null,
1428 + transitions: Array<Transition> | null,
1429 + lanes: Lanes,
1430 +) {
1431 + if (includesOnlyNonUrgentLanes(lanes)) {
1432 + // Before committing, ask the renderer whether the host tree is ready.
1433 + // If it's not, we'll wait until it notifies us.
1434 + startSuspendingCommit();
1435 + // This will walk the completed fiber tree and attach listeners to all
1436 + // the suspensey resources. The renderer is responsible for accumulating
1437 + // all the load events. This all happens in a single synchronous
1438 + // transaction, so it track state in its own module scope.
1439 + recursivelyAccumulateSuspenseyCommit(finishedWork);
1440 + // At the end, ask the renderer if it's ready to commit, or if we should
1441 + // suspend. If it's not ready, it will return a callback to subscribe to
1442 + // a ready event.
1443 + const schedulePendingCommit = waitForCommitToBeReady();
1444 + if (schedulePendingCommit !== null) {
1445 + // NOTE: waitForCommitToBeReady returns a subscribe function so that we
1446 + // only allocate a function if the commit isn't ready yet. The other
1447 + // pattern would be to always pass a callback to waitForCommitToBeReady.
1448 +
1449 + // Not yet ready to commit. Delay the commit until the renderer notifies
1450 + // us that it's ready. This will be canceled if we start work on the
1451 + // root again.
1452 + root.cancelPendingCommit = schedulePendingCommit(
1453 + commitRoot.bind(
1454 + null,
1455 + root,
1456 + workInProgressRootRecoverableErrors,
1457 + workInProgressTransitions,
1458 + ),
1459 + );
1460 + return;
1461 + }
1462 + }
1463 + // Otherwise, commit immediately.
1464 + commitRoot(
1465 + root,
1466 + workInProgressRootRecoverableErrors,
1467 + workInProgressTransitions,
1468 + );
1469 +}
1470 +
1471 function isRenderConsistentWithExternalStores(finishedWork: Fiber): boolean {
1472 // Search the rendered tree for external store reads, and check whether the
1473 // stores were mutated in a concurrent event. Intentionally using an iterative
@@ -1714,6 +1790,11 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
1790 // $FlowFixMe Complains noTimeout is not a TimeoutID, despite the check above
1791 cancelTimeout(timeoutHandle);
1792 }
1793 + const cancelPendingCommit = root.cancelPendingCommit;
1794 + if (cancelPendingCommit !== null) {
1795 + root.cancelPendingCommit = null;
1796 + cancelPendingCommit();
1797 + }
1798
1799 resetWorkInProgressStack();
1800 workInProgressRoot = root;
@@ -1775,9 +1856,21 @@ function handleThrow(root: FiberRoot, thrownValue: any): void {
1856 // API for suspending. This implementation detail can change later, once we
1857 // deprecate the old API in favor of `use`.
1858 thrownValue = getSuspendedThenable();
1778 - workInProgressSuspendedReason = shouldAttemptToSuspendUntilDataResolves()
1779 - ? SuspendedOnData
1780 - : SuspendedOnImmediate;
1859 + workInProgressSuspendedReason =
1860 + shouldRemainOnPreviousScreen() &&
1861 + // Check if there are other pending updates that might possibly unblock this
1862 + // component from suspending. This mirrors the check in
1863 + // renderDidSuspendDelayIfPossible. We should attempt to unify them somehow.
1864 + // TODO: Consider unwinding immediately, using the
1865 + // SuspendedOnHydration mechanism.
1866 + !includesNonIdleWork(workInProgressRootSkippedLanes) &&
1867 + !includesNonIdleWork(workInProgressRootInterleavedUpdatedLanes)
1868 + ? // Suspend work loop until data resolves
1869 + SuspendedOnData
1870 + : // Don't suspend work loop, except to check if the data has
1871 + // immediately resolved (i.e. in a microtask). Otherwise, trigger the
1872 + // nearest Suspense fallback.
1873 + SuspendedOnImmediate;
1874 } else if (thrownValue === SelectiveHydrationException) {
1875 // An update flowed into a dehydrated boundary. Before we can apply the
1876 // update, we need to finish hydrating. Interrupt the work-in-progress
@@ -1856,28 +1949,28 @@ function handleThrow(root: FiberRoot, thrownValue: any): void {
1949 }
1950 }
1951
1859 -function shouldAttemptToSuspendUntilDataResolves() {
1860 - // Check if there are other pending updates that might possibly unblock this
1861 - // component from suspending. This mirrors the check in
1862 - // renderDidSuspendDelayIfPossible. We should attempt to unify them somehow.
1863 - // TODO: Consider unwinding immediately, using the
1864 - // SuspendedOnHydration mechanism.
1865 - if (
1866 - includesNonIdleWork(workInProgressRootSkippedLanes) ||
1867 - includesNonIdleWork(workInProgressRootInterleavedUpdatedLanes)
1868 - ) {
1869 - // Suspend normally. renderDidSuspendDelayIfPossible will handle
1870 - // interrupting the work loop.
1871 - return false;
1872 - }
1952 +export function shouldRemainOnPreviousScreen(): boolean {
1953 + // This is asking whether it's better to suspend the transition and remain
1954 + // on the previous screen, versus showing a fallback as soon as possible. It
1955 + // takes into account both the priority of render and also whether showing a
1956 + // fallback would produce a desirable user experience.
1957
1874 - // TODO: We should be able to remove the equivalent check in
1875 - // finishConcurrentRender, and rely just on this one.
1958 + // TODO: Once `use` has fully replaced the `throw promise` pattern, we should
1959 + // be able to remove the equivalent check in finishConcurrentRender, and rely
1960 + // just on this one.
1961 if (includesOnlyTransitions(workInProgressRootRenderLanes)) {
1877 - // If we're rendering inside the "shell" of the app, it's better to suspend
1878 - // rendering and wait for the data to resolve. Otherwise, we should switch
1879 - // to a fallback and continue rendering.
1880 - return getShellBoundary() === null;
1962 + if (getShellBoundary() === null) {
1963 + // We're rendering inside the "shell" of the app. Activating the nearest
1964 + // fallback would cause visible content to disappear. It's better to
1965 + // suspend the transition and remain on the previous screen.
1966 + return true;
1967 + } else {
1968 + // We're rendering content that wasn't part of the previous screen.
1969 + // Rather than block the transition, it's better to show a fallback as
1970 + // soon as possible. The appearance of any nested fallbacks will be
1971 + // throttled to avoid jank.
1972 + return false;
1973 + }
1974 }
1975
1976 const handler = getSuspenseHandler();
@@ -2686,6 +2779,7 @@ function commitRootImpl(
2779 // So we can clear these now to allow a new callback to be scheduled.
2780 root.callbackNode = null;
2781 root.callbackPriority = NoLane;
2782 + root.cancelPendingCommit = null;
2783
2784 // Check which lanes no longer have any work scheduled on them, and mark
2785 // those as finished.
packages/react-reconciler/src/ReactInternalTypes.js
+4
@@ -227,6 +227,10 @@ type BaseFiberRootProperties = {
227 // Timeout handle returned by setTimeout. Used to cancel a pending timeout, if
228 // it's superseded by a new one.
229 timeoutHandle: TimeoutHandle | NoTimeout,
230 + // When a root has a pending commit scheduled, calling this function will
231 + // cancel it.
232 + // TODO: Can this be consolidated with timeoutHandle?
233 + cancelPendingCommit: null | (() => void),
234 // Top context object, used by renderSubtreeIntoContainer
235 context: Object | null,
236 pendingContext: Object | null,
packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js
+8
@@ -71,6 +71,14 @@ describe('ReactFiberHostContext', () => {
71 return DefaultEventPriority;
72 },
73 requestPostPaintCallback: function () {},
74 + shouldSuspendCommit(type, props) {
75 + return false;
76 + },
77 + startSuspendingCommit() {},
78 + suspendInstance(type, props) {},
79 + waitForCommitToBeReady() {
80 + return null;
81 + },
82 prepareRendererToRender: function () {},
83 resetRendererAfterRender: function () {},
84 supportsMutation: true,
packages/react-reconciler/src/__tests__/ReactSuspenseyCommitPhase-test.js new
+231
@@ -0,0 +1,231 @@
1 +let React;
2 +let startTransition;
3 +let ReactNoop;
4 +let resolveSuspenseyThing;
5 +let getSuspenseyThingStatus;
6 +let Suspense;
7 +let SuspenseList;
8 +let Scheduler;
9 +let act;
10 +let assertLog;
11 +
12 +describe('ReactSuspenseyCommitPhase', () => {
13 + beforeEach(() => {
14 + jest.resetModules();
15 +
16 + React = require('react');
17 + ReactNoop = require('react-noop-renderer');
18 + Scheduler = require('scheduler');
19 + Suspense = React.Suspense;
20 + SuspenseList = React.SuspenseList;
21 + if (gate(flags => flags.enableSuspenseList)) {
22 + SuspenseList = React.SuspenseList;
23 + }
24 + startTransition = React.startTransition;
25 + resolveSuspenseyThing = ReactNoop.resolveSuspenseyThing;
26 + getSuspenseyThingStatus = ReactNoop.getSuspenseyThingStatus;
27 +
28 + const InternalTestUtils = require('internal-test-utils');
29 + act = InternalTestUtils.act;
30 + assertLog = InternalTestUtils.assertLog;
31 + });
32 +
33 + function Text({text}) {
34 + Scheduler.log(text);
35 + return text;
36 + }
37 +
38 + function SuspenseyImage({src}) {
39 + return (
40 + <suspensey-thing
41 + src={src}
42 + onLoadStart={() => Scheduler.log(`Image requested [${src}]`)}
43 + />
44 + );
45 + }
46 +
47 + test('suspend commit during initial mount', async () => {
48 + const root = ReactNoop.createRoot();
49 + await act(async () => {
50 + startTransition(() => {
51 + root.render(
52 + <Suspense fallback={<Text text="Loading..." />}>
53 + <SuspenseyImage src="A" />
54 + </Suspense>,
55 + );
56 + });
57 + });
58 + assertLog(['Image requested [A]', 'Loading...']);
59 + expect(getSuspenseyThingStatus('A')).toBe('pending');
60 + expect(root).toMatchRenderedOutput('Loading...');
61 +
62 + // This should synchronously commit
63 + resolveSuspenseyThing('A');
64 + expect(root).toMatchRenderedOutput(<suspensey-thing src="A" />);
65 + });
66 +
67 + test('suspend commit during update', async () => {
68 + const root = ReactNoop.createRoot();
69 + await act(() => resolveSuspenseyThing('A'));
70 + await act(async () => {
71 + startTransition(() => {
72 + root.render(
73 + <Suspense fallback={<Text text="Loading..." />}>
74 + <SuspenseyImage src="A" />
75 + </Suspense>,
76 + );
77 + });
78 + });
79 + expect(root).toMatchRenderedOutput(<suspensey-thing src="A" />);
80 +
81 + // Update to a new image src. The transition should suspend because
82 + // the src hasn't loaded yet, and the image is in an already-visible tree.
83 + await act(async () => {
84 + startTransition(() => {
85 + root.render(
86 + <Suspense fallback={<Text text="Loading..." />}>
87 + <SuspenseyImage src="B" />
88 + </Suspense>,
89 + );
90 + });
91 + });
92 + assertLog(['Image requested [B]']);
93 + expect(getSuspenseyThingStatus('B')).toBe('pending');
94 + // Should remain on previous screen
95 + expect(root).toMatchRenderedOutput(<suspensey-thing src="A" />);
96 +
97 + // This should synchronously commit
98 + resolveSuspenseyThing('B');
99 + expect(root).toMatchRenderedOutput(<suspensey-thing src="B" />);
100 + });
101 +
102 + test('does not suspend commit during urgent update', async () => {
103 + const root = ReactNoop.createRoot();
104 + await act(async () => {
105 + root.render(
106 + <Suspense fallback={<Text text="Loading..." />}>
107 + <SuspenseyImage src="A" />
108 + </Suspense>,
109 + );
110 + });
111 + // NOTE: `shouldSuspendCommit` is called even during synchronous renders
112 + // because if this node is ever hidden, then revealed again, we want to know
113 + // whether it's capable of suspending the commit. We track this using a
114 + // fiber flag.
115 + assertLog(['Image requested [A]']);
116 + expect(getSuspenseyThingStatus('A')).toBe('pending');
117 + expect(root).toMatchRenderedOutput(<suspensey-thing src="A" />);
118 + });
119 +
120 + test('an urgent update interrupts a suspended commit', async () => {
121 + const root = ReactNoop.createRoot();
122 +
123 + // Mount an image. This transition will suspend because it's not inside a
124 + // Suspense boundary.
125 + await act(() => {
126 + startTransition(() => {
127 + root.render(<SuspenseyImage src="A" />);
128 + });
129 + });
130 + assertLog(['Image requested [A]']);
131 + // Nothing showing yet.
132 + expect(root).toMatchRenderedOutput(null);
133 +
134 + // If there's an urgent update, it should interrupt the suspended commit.
135 + await act(() => {
136 + root.render(<Text text="Something else" />);
137 + });
138 + assertLog(['Something else']);
139 + expect(root).toMatchRenderedOutput('Something else');
140 + });
141 +
142 + test('a non-urgent update does not interrupt a suspended commit', async () => {
143 + const root = ReactNoop.createRoot();
144 +
145 + // Mount an image. This transition will suspend because it's not inside a
146 + // Suspense boundary.
147 + await act(() => {
148 + startTransition(() => {
149 + root.render(<SuspenseyImage src="A" />);
150 + });
151 + });
152 + assertLog(['Image requested [A]']);
153 + // Nothing showing yet.
154 + expect(root).toMatchRenderedOutput(null);
155 +
156 + // If there's another transition update, it should not interrupt the
157 + // suspended commit.
158 + await act(() => {
159 + startTransition(() => {
160 + root.render(<Text text="Something else" />);
161 + });
162 + });
163 + // Still suspended.
164 + expect(root).toMatchRenderedOutput(null);
165 +
166 + await act(() => {
167 + // Resolving the image should result in an immediate, synchronous commit.
168 + resolveSuspenseyThing('A');
169 + expect(root).toMatchRenderedOutput(<suspensey-thing src="A" />);
170 + });
171 + // Then the second transition is unblocked.
172 + // TODO: Right now the only way to unsuspend a commit early is to proceed
173 + // with the commit even if everything isn't ready. Maybe there should also
174 + // be a way to abort a commit so that it can be interrupted by
175 + // another transition.
176 + assertLog(['Something else']);
177 + expect(root).toMatchRenderedOutput('Something else');
178 + });
179 +
180 + // @gate enableSuspenseList
181 + test('demonstrate current behavior when used with SuspenseList (not ideal)', async () => {
182 + function App() {
183 + return (
184 + <SuspenseList revealOrder="forwards">
185 + <Suspense fallback={<Text text="Loading A" />}>
186 + <SuspenseyImage src="A" />
187 + </Suspense>
188 + <Suspense fallback={<Text text="Loading B" />}>
189 + <SuspenseyImage src="B" />
190 + </Suspense>
191 + <Suspense fallback={<Text text="Loading C" />}>
192 + <SuspenseyImage src="C" />
193 + </Suspense>
194 + </SuspenseList>
195 + );
196 + }
197 +
198 + const root = ReactNoop.createRoot();
199 + await act(() => {
200 + startTransition(() => {
201 + root.render(<App />);
202 + });
203 + });
204 + assertLog([
205 + 'Image requested [A]',
206 + 'Loading A',
207 + 'Loading B',
208 + 'Loading C',
209 + 'Image requested [B]',
210 + 'Image requested [C]',
211 + ]);
212 + expect(root).toMatchRenderedOutput('Loading ALoading BLoading C');
213 +
214 + // TODO: Notice that none of these items appear until they've all loaded.
215 + // That's not ideal; we should commit each row as it becomes ready to
216 + // commit. That means we need to prepare both the fallback and the primary
217 + // tree during the render phase. Related to Offscreen, too.
218 + resolveSuspenseyThing('A');
219 + expect(root).toMatchRenderedOutput('Loading ALoading BLoading C');
220 + resolveSuspenseyThing('B');
221 + expect(root).toMatchRenderedOutput('Loading ALoading BLoading C');
222 + resolveSuspenseyThing('C');
223 + expect(root).toMatchRenderedOutput(
224 + <>
225 + <suspensey-thing src="A" />
226 + <suspensey-thing src="B" />
227 + <suspensey-thing src="C" />
228 + </>,
229 + );
230 + });
231 +});
packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js
+4
@@ -68,6 +68,10 @@ export const getInstanceFromScope = $$$hostConfig.getInstanceFromScope;
68 export const getCurrentEventPriority = $$$hostConfig.getCurrentEventPriority;
69 export const detachDeletedInstance = $$$hostConfig.detachDeletedInstance;
70 export const requestPostPaintCallback = $$$hostConfig.requestPostPaintCallback;
71 +export const shouldSuspendCommit = $$$hostConfig.shouldSuspendCommit;
72 +export const startSuspendingCommit = $$$hostConfig.startSuspendingCommit;
73 +export const suspendInstance = $$$hostConfig.suspendInstance;
74 +export const waitForCommitToBeReady = $$$hostConfig.waitForCommitToBeReady;
75 export const prepareRendererToRender = $$$hostConfig.prepareRendererToRender;
76 export const resetRendererAfterRender = $$$hostConfig.resetRendererAfterRender;
77
packages/react-test-renderer/src/ReactTestHostConfig.js
+12
@@ -324,6 +324,18 @@ export function requestPostPaintCallback(callback: (time: number) => void) {
324 // noop
325 }
326
327 +export function shouldSuspendCommit(type: Type, props: Props): boolean {
328 + return false;
329 +}
330 +
331 +export function startSuspendingCommit(): void {}
332 +
333 +export function suspendInstance(type: Type, props: Props): void {}
334 +
335 +export function waitForCommitToBeReady(): null {
336 + return null;
337 +}
338 +
339 export function prepareRendererToRender(container: Container): void {
340 // noop
341 }