Skip to content

Commit f22640e

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 493a786 commit f22640e

20 files changed

+854
-63
lines changed

packages/react-art/src/ReactARTHostConfig.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,21 @@ 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+
return null;
468+
}
469+
470+
export function accumulateSuspenseyCommitPayload(type, props, payload) {
471+
return null;
472+
}
473+
474+
export function waitForCommitToBeReady(payload) {
475+
return null;
476+
}
462477
// eslint-disable-next-line no-undef
463478
export function prepareRendererToRender(container: Container): void {
464479
// noop

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ export type ChildSet = void; // Unused
156156
export type TimeoutHandle = TimeoutID;
157157
export type NoTimeout = -1;
158158
export type RendererInspectionConfig = $ReadOnly<{}>;
159+
export type SuspenseyCommitPayload = null;
159160

160161
type SelectionInformation = {
161162
focusedElem: null | HTMLElement,
@@ -1608,6 +1609,27 @@ export function requestPostPaintCallback(callback: (time: number) => void) {
16081609
localRequestAnimationFrame(time => callback(time));
16091610
});
16101611
}
1612+
1613+
export function shouldSuspendCommit(type: Type, props: Props): boolean {
1614+
return false;
1615+
}
1616+
1617+
export function startSuspendingCommit(): SuspenseyCommitPayload {
1618+
return null;
1619+
}
1620+
1621+
export function accumulateSuspenseyCommitPayload(
1622+
type: Type,
1623+
props: Props,
1624+
payload: SuspenseyCommitPayload,
1625+
): SuspenseyCommitPayload {
1626+
return null;
1627+
}
1628+
1629+
export function waitForCommitToBeReady(payload: SuspenseyCommitPayload): null {
1630+
return null;
1631+
}
1632+
16111633
// -------------------
16121634
// Resources
16131635
// -------------------

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ export type RendererInspectionConfig = $ReadOnly<{
8787
) => void,
8888
}>;
8989

90+
export type SuspenseyCommitPayload = null;
91+
9092
// TODO: Remove this conditional once all changes have propagated.
9193
if (registerEventHandler) {
9294
/**
@@ -418,6 +420,25 @@ export function requestPostPaintCallback(callback: (time: number) => void) {
418420
// noop
419421
}
420422

423+
export function shouldSuspendCommit(type: Type, props: Props): boolean {
424+
return false;
425+
}
426+
427+
export function startSuspendingCommit(): SuspenseyCommitPayload {
428+
return null;
429+
}
430+
431+
export function accumulateSuspenseyCommitPayload(
432+
type: Type,
433+
props: Props,
434+
payload: SuspenseyCommitPayload,
435+
): SuspenseyCommitPayload {
436+
return null;
437+
}
438+
439+
export function waitForCommitToBeReady(payload: SuspenseyCommitPayload): null {
440+
return null;
441+
}
421442
export function prepareRendererToRender(container: Container): void {
422443
// noop
423444
}

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ export type RendererInspectionConfig = $ReadOnly<{
5555
) => void,
5656
}>;
5757

58+
export type SuspenseyCommitPayload = null;
59+
5860
const UPDATE_SIGNAL = {};
5961
if (__DEV__) {
6062
Object.freeze(UPDATE_SIGNAL);
@@ -522,6 +524,26 @@ export function requestPostPaintCallback(callback: (time: number) => void) {
522524
// noop
523525
}
524526

527+
export function shouldSuspendCommit(type: Type, props: Props): boolean {
528+
return false;
529+
}
530+
531+
export function startSuspendingCommit(): SuspenseyCommitPayload {
532+
return null;
533+
}
534+
535+
export function accumulateSuspenseyCommitPayload(
536+
type: Type,
537+
props: Props,
538+
payload: SuspenseyCommitPayload,
539+
): SuspenseyCommitPayload {
540+
return null;
541+
}
542+
543+
export function waitForCommitToBeReady(payload: SuspenseyCommitPayload): null {
544+
return null;
545+
}
546+
525547
export function prepareRendererToRender(container: Container): void {
526548
// noop
527549
}

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,12 @@ type CreateRootOptions = {
7273
...
7374
};
7475

76+
type SuspenseyCommitSubscription = {
77+
pendingCount: number,
78+
commit: null | (() => void),
79+
};
80+
type SuspenseyCommitPayload = SuspenseyCommitSubscription | null;
81+
7582
const NO_CONTEXT = {};
7683
const UPPERCASE_CONTEXT = {};
7784
const UPDATE_SIGNAL = {};
@@ -238,6 +245,11 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
238245
hidden: !!newProps.hidden,
239246
context: instance.context,
240247
};
248+
249+
if (type === 'suspensey-thing' && typeof newProps.src === 'string') {
250+
clone.src = newProps.src;
251+
}
252+
241253
Object.defineProperty(clone, 'id', {
242254
value: clone.id,
243255
enumerable: false,
@@ -271,6 +283,16 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
271283
return hostContext === UPPERCASE_CONTEXT ? rawText.toUpperCase() : rawText;
272284
}
273285

286+
type SuspenseyThingRecord = {
287+
status: 'pending' | 'fulfilled',
288+
subscriptions: Array<SuspenseyCommitSubscription> | null,
289+
};
290+
291+
let suspenseyThingCache: Map<
292+
SuspenseyThingRecord,
293+
'pending' | 'fulfilled',
294+
> | null = null;
295+
274296
const sharedHostConfig = {
275297
supportsSingletons: false,
276298

@@ -322,6 +344,11 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
322344
hidden: !!props.hidden,
323345
context: hostContext,
324346
};
347+
348+
if (type === 'suspensey-thing' && typeof props.src === 'string') {
349+
inst.src = props.src;
350+
}
351+
325352
// Hide from unit tests
326353
Object.defineProperty(inst, 'id', {value: inst.id, enumerable: false});
327354
Object.defineProperty(inst, 'parent', {
@@ -480,6 +507,106 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
480507
const endTime = Scheduler.unstable_now();
481508
callback(endTime);
482509
},
510+
511+
shouldSuspendCommit(type: string, props: Props): boolean {
512+
if (type === 'suspensey-thing' && typeof props.src === 'string') {
513+
if (suspenseyThingCache === null) {
514+
suspenseyThingCache = new Map();
515+
}
516+
const record = suspenseyThingCache.get(props.src);
517+
if (record === undefined) {
518+
const newRecord: SuspenseyThingRecord = {
519+
status: 'pending',
520+
subscriptions: null,
521+
};
522+
suspenseyThingCache.set(props.src, newRecord);
523+
const onLoadStart = props.onLoadStart;
524+
if (typeof onLoadStart === 'function') {
525+
onLoadStart();
526+
}
527+
return props.src;
528+
} else {
529+
if (record.status === 'pending') {
530+
// The resource was already requested, but it hasn't finished
531+
// loading yet.
532+
return true;
533+
} else {
534+
// The resource has already loaded. If the renderer is confident that
535+
// the resource will still be cached by the time the render commits,
536+
// then it can return false, like we do here.
537+
return false;
538+
}
539+
}
540+
}
541+
// Don't need to suspend.
542+
return false;
543+
},
544+
545+
startSuspendingCommit(): SuspenseyCommitSubscription | null {
546+
// This creates an initial commit payload that we can use to keep track
547+
// of pending suspensey things in the host components. It's also where
548+
// we might suspend on things that aren't associated with a particular
549+
// node, like document.fonts.ready.
550+
return null;
551+
},
552+
553+
accumulateSuspenseyCommitPayload(
554+
type: string,
555+
props: Props,
556+
subscription: SuspenseyCommitSubscription | null,
557+
): SuspenseyCommitSubscription | null {
558+
const src = props.src;
559+
if (type === 'suspensey-thing' && typeof src === 'string') {
560+
// Attach a listener to the suspensey thing and create a subscription
561+
// object that uses reference counting to track when all the suspensey
562+
// things have loaded.
563+
const record = suspenseyThingCache.get(src);
564+
if (record === undefined) {
565+
throw new Error('Could not find record for key.');
566+
}
567+
if (record.status === 'pending') {
568+
if (subscription === null) {
569+
subscription = {
570+
pendingCount: 1,
571+
commit: null,
572+
};
573+
} else {
574+
// There's an existing subscription, add to that one. It's OK
575+
// to mutate the commit payload because it's only used for a single
576+
// atomic commit.
577+
subscription.pendingCount++;
578+
}
579+
}
580+
// Stash the subscription on the record. In `resolveSuspenseyThing`,
581+
// we'll use this fire the commit once all the things have loaded.
582+
if (record.subscriptions === null) {
583+
record.subscriptions = [];
584+
}
585+
record.subscriptions.push(subscription);
586+
} else {
587+
throw new Error(
588+
'Did not expect this host component to be visited when suspending ' +
589+
'the commit. Did you check the SuspendCommit flag?',
590+
);
591+
}
592+
return subscription;
593+
},
594+
595+
waitForCommitToBeReady(
596+
subscription: SuspenseyCommitPayload,
597+
): ((commit: () => mixed) => () => void) | null {
598+
if (subscription !== null) {
599+
return (commit: () => void) => {
600+
subscription.commit = commit;
601+
const cancelCommit = () => {
602+
subscription.commit = null;
603+
};
604+
return cancelCommit;
605+
};
606+
}
607+
return null;
608+
},
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: SuspenseyCommitPayload = {
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 payload = subscriptions[i];
1081+
payload.pendingCount--;
1082+
if (payload.pendingCount === 0) {
1083+
const commit = payload.commit;
1084+
payload.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,

0 commit comments

Comments
 (0)