Skip to content

Commit f1d80f6

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 f10f0ed commit f1d80f6

File tree

9 files changed

+620
-34
lines changed

9 files changed

+620
-34
lines changed

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

Lines changed: 146 additions & 3 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 = {
@@ -240,6 +241,11 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
240241
hidden: !!newProps.hidden,
241242
context: instance.context,
242243
};
244+
245+
if (type === 'suspensey-thing' && typeof newProps.src === 'string') {
246+
instance.src = newProps.src;
247+
}
248+
243249
Object.defineProperty(clone, 'id', {
244250
value: clone.id,
245251
enumerable: false,
@@ -273,6 +279,16 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
273279
return hostContext === UPPERCASE_CONTEXT ? rawText.toUpperCase() : rawText;
274280
}
275281

282+
type SuspenseyThingRecord = {
283+
status: 'pending' | 'fulfilled',
284+
listeners: Set<() => void> | null,
285+
};
286+
287+
let trackedSuspendCommitPaylods: Map<
288+
SuspenseyThingRecord,
289+
'pending' | 'fulfilled',
290+
> | null = null;
291+
276292
const sharedHostConfig = {
277293
supportsSingletons: false,
278294

@@ -324,6 +340,11 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
324340
hidden: !!props.hidden,
325341
context: hostContext,
326342
};
343+
344+
if (type === 'suspensey-thing' && typeof props.src === 'string') {
345+
inst.src = props.src;
346+
}
347+
327348
// Hide from unit tests
328349
Object.defineProperty(inst, 'id', {value: inst.id, enumerable: false});
329350
Object.defineProperty(inst, 'parent', {
@@ -488,7 +509,7 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
488509
type: string,
489510
newProps: Props,
490511
): SuspendCommitPayload | null {
491-
return null;
512+
return shouldSuspendCommit(instance, type, newProps);
492513
},
493514

494515
shouldSuspendUpdate(
@@ -497,19 +518,94 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
497518
oldProps: Props,
498519
newProps: Props,
499520
): SuspendCommitPayload | null {
500-
return null;
521+
return shouldSuspendCommit(instance, type, newProps);
501522
},
502523

503524
waitForCommitToBeReady(
504525
suspenseyThings: Array<SuspendCommitPayload> | null,
505-
): null {
526+
): ((commit: () => mixed) => () => void) | null {
527+
if (suspenseyThings !== null) {
528+
const subscribeToOnReady = commit => {
529+
// Attach a listener to all the suspensey things. Once they've all
530+
// fired, we can commit. Unless the commit is canceled.
531+
532+
let didCancel = false;
533+
let refCount = 0;
534+
const onLoad = () => {
535+
refCount--;
536+
if (refCount === 0 && !didCancel) {
537+
commit();
538+
}
539+
};
540+
for (let i = 0; i < suspenseyThings.length; i++) {
541+
const suspenseyThing = suspenseyThings[i];
542+
const record = trackedSuspendCommitPaylods.get(suspenseyThing);
543+
if (record === undefined) {
544+
throw new Error('Could not find record for key.');
545+
}
546+
if (record.status === 'pending') {
547+
refCount++;
548+
if (record.listeners === null) {
549+
record.listeners = [];
550+
}
551+
record.listeners.push(onLoad);
552+
}
553+
}
554+
555+
if (refCount === 0) {
556+
// Nothing is pending anymore. We can commit immediately.
557+
return null;
558+
}
559+
560+
// React will call this if there's an interruption.
561+
const cancelPendingCommit = () => {
562+
didCancel = true;
563+
};
564+
return cancelPendingCommit;
565+
};
566+
return subscribeToOnReady;
567+
}
506568
return null;
507569
},
508570

509571
prepareRendererToRender() {},
510572
resetRendererAfterRender() {},
511573
};
512574

575+
function shouldSuspendCommit(instance: Instance, type: string, props: Props) {
576+
if (type === 'suspensey-thing' && typeof props.src === 'string') {
577+
if (trackedSuspendCommitPaylods === null) {
578+
trackedSuspendCommitPaylods = new Map();
579+
}
580+
const record = trackedSuspendCommitPaylods.get(props.src);
581+
if (record === undefined) {
582+
const newRecord: SuspendCommitPayload = {
583+
status: 'pending',
584+
listeners: null,
585+
};
586+
trackedSuspendCommitPaylods.set(props.src, newRecord);
587+
const onLoadStart = props.onLoadStart;
588+
if (typeof onLoadStart === 'function') {
589+
onLoadStart();
590+
}
591+
return props.src;
592+
} else {
593+
if (record.status === 'pending') {
594+
// The resource was already requested, but it hasn't finished
595+
// loading yet.
596+
return props.src;
597+
} else {
598+
// The resource has already loaded. If the renderer is confident that
599+
// the resource will still be cached by the time the render commits,
600+
// then it can return nothing, like we do here.
601+
return null;
602+
}
603+
}
604+
}
605+
// Don't need to suspend.
606+
return null;
607+
}
608+
513609
const hostConfig = useMutation
514610
? {
515611
...sharedHostConfig,
@@ -534,6 +630,11 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
534630
hostUpdateCounter++;
535631
instance.prop = newProps.prop;
536632
instance.hidden = !!newProps.hidden;
633+
634+
if (type === 'suspensey-thing' && typeof newProps.src === 'string') {
635+
instance.src = newProps.src;
636+
}
637+
537638
if (shouldSetTextContent(type, newProps)) {
538639
if (__DEV__) {
539640
checkPropStringCoercion(newProps.children, 'children');
@@ -715,6 +816,9 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
715816
if (instance.hidden) {
716817
props.hidden = true;
717818
}
819+
if (instance.src) {
820+
props.src = instance.src;
821+
}
718822
if (children !== null) {
719823
props.children = children;
720824
}
@@ -941,6 +1045,45 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
9411045
return getPendingChildrenAsJSX(container);
9421046
},
9431047

1048+
getSuspenseyThingStatus(src): string | null {
1049+
if (trackedSuspendCommitPaylods === null) {
1050+
return null;
1051+
} else {
1052+
const record = trackedSuspendCommitPaylods.get(src);
1053+
return record === undefined ? null : record.status;
1054+
}
1055+
},
1056+
1057+
async resolveSuspenseyThing(key: string): void {
1058+
if (trackedSuspendCommitPaylods === null) {
1059+
trackedSuspendCommitPaylods = new Map();
1060+
}
1061+
const record = trackedSuspendCommitPaylods.get(key);
1062+
if (record === undefined) {
1063+
const newRecord: SuspendCommitPayload = {
1064+
status: 'fulfilled',
1065+
listeners: null,
1066+
};
1067+
trackedSuspendCommitPaylods.set(key, newRecord);
1068+
} else {
1069+
if (record.status === 'pending') {
1070+
record.status = 'fulfilled';
1071+
const listeners = record.listeners;
1072+
if (listeners !== null) {
1073+
record.listeners = null;
1074+
for (let i = 0; i < listeners.length; i++) {
1075+
const listener = listeners[i];
1076+
listener();
1077+
}
1078+
}
1079+
}
1080+
}
1081+
},
1082+
1083+
resetSuspenseyThingCache() {
1084+
trackedSuspendCommitPaylods = null;
1085+
},
1086+
9441087
createPortal(
9451088
children: ReactNodeList,
9461089
container: Container,

0 commit comments

Comments
 (0)