Skip to content

Commit d025547

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 0f274cd commit d025547

18 files changed

+787
-104
lines changed

packages/react-art/src/ReactARTHostConfig.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -460,13 +460,20 @@ export function requestPostPaintCallback(callback: (time: number) => void) {
460460
}
461461

462462
export function shouldSuspendCommit(type, props) {
463+
return false;
464+
}
465+
466+
export function startSuspendingCommit() {
463467
return null;
464468
}
465469

466-
export function waitForCommitToBeReady(suspenseyThings) {
470+
export function accumulateSuspenseyCommitPayload(type, props, payload) {
467471
return null;
468472
}
469473

474+
export function waitForCommitToBeReady(payload) {
475+
return null;
476+
}
470477
// eslint-disable-next-line no-undef
471478
export function prepareRendererToRender(container: Container): void {
472479
// noop

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

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +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 SuspendCommitPayload = null;
159+
export type SuspenseyCommitPayload = null;
160160

161161
type SelectionInformation = {
162162
focusedElem: null | HTMLElement,
@@ -1610,16 +1610,23 @@ export function requestPostPaintCallback(callback: (time: number) => void) {
16101610
});
16111611
}
16121612

1613-
export function shouldSuspendCommit(
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(
16141622
type: Type,
16151623
props: Props,
1616-
): SuspendCommitPayload | null {
1624+
payload: SuspenseyCommitPayload,
1625+
): SuspenseyCommitPayload {
16171626
return null;
16181627
}
16191628

1620-
export function waitForCommitToBeReady(
1621-
suspenseyThings: Array<SuspendCommitPayload> | null,
1622-
): null {
1629+
export function waitForCommitToBeReady(payload: SuspenseyCommitPayload): null {
16231630
return null;
16241631
}
16251632

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

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

90-
export type SuspendCommitPayload = null;
90+
export type SuspenseyCommitPayload = null;
9191

9292
// TODO: Remove this conditional once all changes have propagated.
9393
if (registerEventHandler) {
@@ -420,19 +420,25 @@ export function requestPostPaintCallback(callback: (time: number) => void) {
420420
// noop
421421
}
422422

423-
export function shouldSuspendCommit(
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(
424432
type: Type,
425433
props: Props,
426-
): SuspendCommitPayload | null {
434+
payload: SuspenseyCommitPayload,
435+
): SuspenseyCommitPayload {
427436
return null;
428437
}
429438

430-
export function waitForCommitToBeReady(
431-
suspenseyThings: Array<SuspendCommitPayload> | null,
432-
): null {
439+
export function waitForCommitToBeReady(payload: SuspenseyCommitPayload): null {
433440
return null;
434441
}
435-
436442
export function prepareRendererToRender(container: Container): void {
437443
// noop
438444
}

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

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

58-
export type SuspendCommitPayload = null;
58+
export type SuspenseyCommitPayload = null;
5959

6060
const UPDATE_SIGNAL = {};
6161
if (__DEV__) {
@@ -524,16 +524,23 @@ export function requestPostPaintCallback(callback: (time: number) => void) {
524524
// noop
525525
}
526526

527-
export function shouldSuspendCommit(
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(
528536
type: Type,
529537
props: Props,
530-
): SuspendCommitPayload | null {
538+
payload: SuspenseyCommitPayload,
539+
): SuspenseyCommitPayload {
531540
return null;
532541
}
533542

534-
export function waitForCommitToBeReady(
535-
suspenseyThings: Array<SuspendCommitPayload> | null,
536-
): null {
543+
export function waitForCommitToBeReady(payload: SuspenseyCommitPayload): null {
537544
return null;
538545
}
539546

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

Lines changed: 169 additions & 6 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,7 +73,11 @@ type CreateRootOptions = {
7273
...
7374
};
7475

75-
type SuspendCommitPayload = string;
76+
type SuspenseyCommitSubscription = {
77+
pendingCount: number,
78+
commit: null | (() => void),
79+
};
80+
type SuspenseyCommitPayload = SuspenseyCommitSubscription | null;
7681

7782
const NO_CONTEXT = {};
7883
const UPPERCASE_CONTEXT = {};
@@ -240,6 +245,11 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
240245
hidden: !!newProps.hidden,
241246
context: instance.context,
242247
};
248+
249+
if (type === 'suspensey-thing' && typeof newProps.src === 'string') {
250+
clone.src = newProps.src;
251+
}
252+
243253
Object.defineProperty(clone, 'id', {
244254
value: clone.id,
245255
enumerable: false,
@@ -273,6 +283,16 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
273283
return hostContext === UPPERCASE_CONTEXT ? rawText.toUpperCase() : rawText;
274284
}
275285

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+
276296
const sharedHostConfig = {
277297
supportsSingletons: false,
278298

@@ -324,6 +344,11 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
324344
hidden: !!props.hidden,
325345
context: hostContext,
326346
};
347+
348+
if (type === 'suspensey-thing' && typeof props.src === 'string') {
349+
inst.src = props.src;
350+
}
351+
327352
// Hide from unit tests
328353
Object.defineProperty(inst, 'id', {value: inst.id, enumerable: false});
329354
Object.defineProperty(inst, 'parent', {
@@ -483,16 +508,102 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
483508
callback(endTime);
484509
},
485510

486-
shouldSuspendCommit(
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(
487554
type: string,
488555
props: Props,
489-
): SuspendCommitPayload | null {
490-
return null;
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;
491593
},
492594

493595
waitForCommitToBeReady(
494-
suspenseyThings: Array<SuspendCommitPayload> | null,
495-
): null {
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+
}
496607
return null;
497608
},
498609

@@ -524,6 +635,11 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
524635
hostUpdateCounter++;
525636
instance.prop = newProps.prop;
526637
instance.hidden = !!newProps.hidden;
638+
639+
if (type === 'suspensey-thing' && typeof newProps.src === 'string') {
640+
instance.src = newProps.src;
641+
}
642+
527643
if (shouldSetTextContent(type, newProps)) {
528644
if (__DEV__) {
529645
checkPropStringCoercion(newProps.children, 'children');
@@ -705,6 +821,9 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
705821
if (instance.hidden) {
706822
props.hidden = true;
707823
}
824+
if (instance.src) {
825+
props.src = instance.src;
826+
}
708827
if (children !== null) {
709828
props.children = children;
710829
}
@@ -931,6 +1050,50 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
9311050
return getPendingChildrenAsJSX(container);
9321051
},
9331052

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+
9341097
createPortal(
9351098
children: ReactNodeList,
9361099
container: Container,

0 commit comments

Comments
 (0)