Skip to content

Commit 7cd3646

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 7cd3646

20 files changed

+806
-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: 180 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,79 @@ 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+
let suspenseyCommitSubscription: SuspenseyCommitSubscription | null = null;
296+
297+
function startSuspendingCommit(): void {
298+
// This is where we might suspend on things that aren't associated with a
299+
// particular node, like document.fonts.ready.
300+
suspenseyCommitSubscription = null;
301+
}
302+
303+
function suspendInstance(type: string, props: Props): void {
304+
const src = props.src;
305+
if (type === 'suspensey-thing' && typeof src === 'string') {
306+
// Attach a listener to the suspensey thing and create a subscription
307+
// object that uses reference counting to track when all the suspensey
308+
// things have loaded.
309+
const record = suspenseyThingCache.get(src);
310+
if (record === undefined) {
311+
throw new Error('Could not find record for key.');
312+
}
313+
if (record.status === 'pending') {
314+
if (suspenseyCommitSubscription === null) {
315+
suspenseyCommitSubscription = {
316+
pendingCount: 1,
317+
commit: null,
318+
};
319+
} else {
320+
// There's an existing subscription, add to that one. It's OK
321+
// to mutate the commit payload because it's only used for a single
322+
// atomic commit.
323+
suspenseyCommitSubscription.pendingCount++;
324+
}
325+
}
326+
// Stash the subscription on the record. In `resolveSuspenseyThing`,
327+
// we'll use this fire the commit once all the things have loaded.
328+
if (record.subscriptions === null) {
329+
record.subscriptions = [];
330+
}
331+
record.subscriptions.push(suspenseyCommitSubscription);
332+
} else {
333+
throw new Error(
334+
'Did not expect this host component to be visited when suspending ' +
335+
'the commit. Did you check the SuspendCommit flag?',
336+
);
337+
}
338+
return suspenseyCommitSubscription;
339+
}
340+
341+
function waitForCommitToBeReady():
342+
| ((commit: () => mixed) => () => void)
343+
| null {
344+
const subscription = suspenseyCommitSubscription;
345+
if (subscription !== null) {
346+
suspenseyCommitSubscription = null;
347+
return (commit: () => void) => {
348+
subscription.commit = commit;
349+
const cancelCommit = () => {
350+
subscription.commit = null;
351+
};
352+
return cancelCommit;
353+
};
354+
}
355+
return null;
356+
}
357+
274358
const sharedHostConfig = {
275359
supportsSingletons: false,
276360

@@ -322,6 +406,11 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
322406
hidden: !!props.hidden,
323407
context: hostContext,
324408
};
409+
410+
if (type === 'suspensey-thing' && typeof props.src === 'string') {
411+
inst.src = props.src;
412+
}
413+
325414
// Hide from unit tests
326415
Object.defineProperty(inst, 'id', {value: inst.id, enumerable: false});
327416
Object.defineProperty(inst, 'parent', {
@@ -480,6 +569,45 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
480569
const endTime = Scheduler.unstable_now();
481570
callback(endTime);
482571
},
572+
573+
shouldSuspendCommit(type: string, props: Props): boolean {
574+
if (type === 'suspensey-thing' && typeof props.src === 'string') {
575+
if (suspenseyThingCache === null) {
576+
suspenseyThingCache = new Map();
577+
}
578+
const record = suspenseyThingCache.get(props.src);
579+
if (record === undefined) {
580+
const newRecord: SuspenseyThingRecord = {
581+
status: 'pending',
582+
subscriptions: null,
583+
};
584+
suspenseyThingCache.set(props.src, newRecord);
585+
const onLoadStart = props.onLoadStart;
586+
if (typeof onLoadStart === 'function') {
587+
onLoadStart();
588+
}
589+
return props.src;
590+
} else {
591+
if (record.status === 'pending') {
592+
// The resource was already requested, but it hasn't finished
593+
// loading yet.
594+
return true;
595+
} else {
596+
// The resource has already loaded. If the renderer is confident that
597+
// the resource will still be cached by the time the render commits,
598+
// then it can return false, like we do here.
599+
return false;
600+
}
601+
}
602+
}
603+
// Don't need to suspend.
604+
return false;
605+
},
606+
607+
startSuspendingCommit,
608+
suspendInstance,
609+
waitForCommitToBeReady,
610+
483611
prepareRendererToRender() {},
484612
resetRendererAfterRender() {},
485613
};
@@ -508,6 +636,11 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
508636
hostUpdateCounter++;
509637
instance.prop = newProps.prop;
510638
instance.hidden = !!newProps.hidden;
639+
640+
if (type === 'suspensey-thing' && typeof newProps.src === 'string') {
641+
instance.src = newProps.src;
642+
}
643+
511644
if (shouldSetTextContent(type, newProps)) {
512645
if (__DEV__) {
513646
checkPropStringCoercion(newProps.children, 'children');
@@ -689,6 +822,9 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
689822
if (instance.hidden) {
690823
props.hidden = true;
691824
}
825+
if (instance.src) {
826+
props.src = instance.src;
827+
}
692828
if (children !== null) {
693829
props.children = children;
694830
}
@@ -915,6 +1051,50 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
9151051
return getPendingChildrenAsJSX(container);
9161052
},
9171053

1054+
getSuspenseyThingStatus(src): string | null {
1055+
if (suspenseyThingCache === null) {
1056+
return null;
1057+
} else {
1058+
const record = suspenseyThingCache.get(src);
1059+
return record === undefined ? null : record.status;
1060+
}
1061+
},
1062+
1063+
resolveSuspenseyThing(key: string): void {
1064+
if (suspenseyThingCache === null) {
1065+
suspenseyThingCache = new Map();
1066+
}
1067+
const record = suspenseyThingCache.get(key);
1068+
if (record === undefined) {
1069+
const newRecord: SuspenseyThingRecord = {
1070+
status: 'fulfilled',
1071+
subscriptions: null,
1072+
};
1073+
suspenseyThingCache.set(key, newRecord);
1074+
} else {
1075+
if (record.status === 'pending') {
1076+
record.status = 'fulfilled';
1077+
const subscriptions = record.subscriptions;
1078+
if (subscriptions !== null) {
1079+
record.subscriptions = null;
1080+
for (let i = 0; i < subscriptions.length; i++) {
1081+
const subscription = subscriptions[i];
1082+
subscription.pendingCount--;
1083+
if (subscription.pendingCount === 0) {
1084+
const commit = subscription.commit;
1085+
subscription.commit = null;
1086+
commit();
1087+
}
1088+
}
1089+
}
1090+
}
1091+
}
1092+
},
1093+
1094+
resetSuspenseyThingCache() {
1095+
suspenseyThingCache = null;
1096+
},
1097+
9181098
createPortal(
9191099
children: ReactNodeList,
9201100
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)