Skip to content

Commit a836b04

Browse files
committed
Feature: Suspend commit without blocking render
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.
1 parent 27a255c commit a836b04

20 files changed

+805
-63
lines changed

packages/react-art/src/ReactARTHostConfig.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,17 @@ export function requestPostPaintCallback(callback: (time: number) => void) {
459459
// noop
460460
}
461461

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+
}
462473
// eslint-disable-next-line no-undef
463474
export function prepareRendererToRender(container: Container): void {
464475
// noop

packages/react-dom-bindings/src/client/ReactDOMHostConfig.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1608,6 +1608,19 @@ export function requestPostPaintCallback(callback: (time: number) => void) {
16081608
localRequestAnimationFrame(time => callback(time));
16091609
});
16101610
}
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+
16111624
// -------------------
16121625
// Resources
16131626
// -------------------

packages/react-native-renderer/src/ReactFabricHostConfig.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,18 @@ export function requestPostPaintCallback(callback: (time: number) => void) {
418418
// noop
419419
}
420420

421+
export function shouldSuspendCommit(type: Type, props: Props): boolean {
422+
return false;
423+
}
424+
425+
export function startSuspendingCommit(): void {}
426+
427+
export function suspendInstance(type: Type, props: Props): void {}
428+
429+
export function waitForCommitToBeReady(): null {
430+
return null;
431+
}
432+
421433
export function prepareRendererToRender(container: Container): void {
422434
// noop
423435
}

packages/react-native-renderer/src/ReactNativeHostConfig.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,18 @@ export function requestPostPaintCallback(callback: (time: number) => void) {
522522
// noop
523523
}
524524

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+
525537
export function prepareRendererToRender(container: Container): void {
526538
// noop
527539
}

packages/react-noop-renderer/src/ReactNoop.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ export const {
2828
createLegacyRoot,
2929
getChildrenAsJSX,
3030
getPendingChildrenAsJSX,
31+
getSuspenseyThingStatus,
32+
resolveSuspenseyThing,
33+
resetSuspenseyThingCache,
3134
createPortal,
3235
render,
3336
renderLegacySyncRoot,

packages/react-noop-renderer/src/ReactNoopPersistent.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ export const {
2828
createLegacyRoot,
2929
getChildrenAsJSX,
3030
getPendingChildrenAsJSX,
31+
getSuspenseyThingStatus,
32+
resolveSuspenseyThing,
33+
resetSuspenseyThingCache,
3134
createPortal,
3235
render,
3336
renderLegacySyncRoot,

packages/react-noop-renderer/src/createReactNoop.js

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ type Props = {
4747
left?: null | number,
4848
right?: null | number,
4949
top?: null | number,
50+
src?: string,
5051
...
5152
};
5253
type Instance = {
@@ -72,6 +73,11 @@ type CreateRootOptions = {
7273
...
7374
};
7475

76+
type SuspenseyCommitSubscription = {
77+
pendingCount: number,
78+
commit: null | (() => void),
79+
};
80+
7581
const NO_CONTEXT = {};
7682
const UPPERCASE_CONTEXT = {};
7783
const UPDATE_SIGNAL = {};
@@ -238,6 +244,11 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
238244
hidden: !!newProps.hidden,
239245
context: instance.context,
240246
};
247+
248+
if (type === 'suspensey-thing' && typeof newProps.src === 'string') {
249+
clone.src = newProps.src;
250+
}
251+
241252
Object.defineProperty(clone, 'id', {
242253
value: clone.id,
243254
enumerable: false,
@@ -271,6 +282,78 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
271282
return hostContext === UPPERCASE_CONTEXT ? rawText.toUpperCase() : rawText;
272283
}
273284

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+
274357
const sharedHostConfig = {
275358
supportsSingletons: false,
276359

@@ -322,6 +405,11 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
322405
hidden: !!props.hidden,
323406
context: hostContext,
324407
};
408+
409+
if (type === 'suspensey-thing' && typeof props.src === 'string') {
410+
inst.src = props.src;
411+
}
412+
325413
// Hide from unit tests
326414
Object.defineProperty(inst, 'id', {value: inst.id, enumerable: false});
327415
Object.defineProperty(inst, 'parent', {
@@ -480,6 +568,45 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
480568
const endTime = Scheduler.unstable_now();
481569
callback(endTime);
482570
},
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+
483610
prepareRendererToRender() {},
484611
resetRendererAfterRender() {},
485612
};
@@ -508,6 +635,11 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
508635
hostUpdateCounter++;
509636
instance.prop = newProps.prop;
510637
instance.hidden = !!newProps.hidden;
638+
639+
if (type === 'suspensey-thing' && typeof newProps.src === 'string') {
640+
instance.src = newProps.src;
641+
}
642+
511643
if (shouldSetTextContent(type, newProps)) {
512644
if (__DEV__) {
513645
checkPropStringCoercion(newProps.children, 'children');
@@ -689,6 +821,9 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
689821
if (instance.hidden) {
690822
props.hidden = true;
691823
}
824+
if (instance.src) {
825+
props.src = instance.src;
826+
}
692827
if (children !== null) {
693828
props.children = children;
694829
}
@@ -915,6 +1050,50 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
9151050
return getPendingChildrenAsJSX(container);
9161051
},
9171052

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+
9181097
createPortal(
9191098
children: ReactNodeList,
9201099
container: Container,

packages/react-reconciler/src/ReactFiberCommitWork.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ import {
9494
LayoutMask,
9595
PassiveMask,
9696
Visibility,
97+
SuspenseyCommit,
9798
} from './ReactFiberFlags';
9899
import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber';
99100
import {
@@ -158,6 +159,7 @@ import {
158159
mountHoistable,
159160
unmountHoistable,
160161
prepareToCommitHoistables,
162+
suspendInstance,
161163
} from './ReactFiberHostConfig';
162164
import {
163165
captureCommitPhaseError,
@@ -4062,6 +4064,27 @@ export function commitPassiveUnmountEffects(finishedWork: Fiber): void {
40624064
resetCurrentDebugFiberInDEV();
40634065
}
40644066

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+
40654088
function detachAlternateSiblings(parentFiber: Fiber) {
40664089
// A fiber was deleted from this parent fiber, but it's still part of the
40674090
// previous (alternate) parent fiber's list of children. Because children

0 commit comments

Comments
 (0)