Skip to content

Commit db281b3

Browse files
authoredMar 17, 2023
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.
1 parent 6310087 commit db281b3

23 files changed

+894
-130
lines changed
 

‎packages/react-art/src/ReactARTHostConfig.js

+11
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

+13
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

+12
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,18 @@ export function requestPostPaintCallback(callback: (time: number) => void) {
414414
// noop
415415
}
416416

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+
417429
export function prepareRendererToRender(container: Container): void {
418430
// noop
419431
}

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

+12
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

+3
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

+3
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

+179
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/ReactFiberBeginWork.js

+4-4
Original file line numberDiff line numberDiff line change
@@ -2298,7 +2298,7 @@ function updateSuspenseComponent(
22982298
const newOffscreenQueue: OffscreenQueue = {
22992299
transitions: currentTransitions,
23002300
markerInstances: parentMarkerInstances,
2301-
wakeables: null,
2301+
retryQueue: null,
23022302
};
23032303
primaryChildFragment.updateQueue = newOffscreenQueue;
23042304
} else {
@@ -2399,7 +2399,7 @@ function updateSuspenseComponent(
23992399
const newOffscreenQueue: OffscreenQueue = {
24002400
transitions: currentTransitions,
24012401
markerInstances: parentMarkerInstances,
2402-
wakeables: null,
2402+
retryQueue: null,
24032403
};
24042404
primaryChildFragment.updateQueue = newOffscreenQueue;
24052405
} else if (offscreenQueue === currentOffscreenQueue) {
@@ -2408,9 +2408,9 @@ function updateSuspenseComponent(
24082408
const newOffscreenQueue: OffscreenQueue = {
24092409
transitions: currentTransitions,
24102410
markerInstances: parentMarkerInstances,
2411-
wakeables:
2411+
retryQueue:
24122412
currentOffscreenQueue !== null
2413-
? currentOffscreenQueue.wakeables
2413+
? currentOffscreenQueue.retryQueue
24142414
: null,
24152415
};
24162416
primaryChildFragment.updateQueue = newOffscreenQueue;

0 commit comments

Comments
 (0)