Skip to content

Commit

Permalink
Feature: Suspend commit without blocking render
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
acdlite committed Mar 16, 2023
1 parent 27a255c commit 7cd3646
Show file tree
Hide file tree
Showing 20 changed files with 806 additions and 63 deletions.
11 changes: 11 additions & 0 deletions packages/react-art/src/ReactARTHostConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,17 @@ export function requestPostPaintCallback(callback: (time: number) => void) {
// noop
}

export function shouldSuspendCommit(type, props) {
return false;
}

export function startSuspendingCommit() {}

export function suspendInstance(type, props) {}

export function waitForCommitToBeReady() {
return null;
}
// eslint-disable-next-line no-undef
export function prepareRendererToRender(container: Container): void {
// noop
Expand Down
13 changes: 13 additions & 0 deletions packages/react-dom-bindings/src/client/ReactDOMHostConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -1608,6 +1608,19 @@ export function requestPostPaintCallback(callback: (time: number) => void) {
localRequestAnimationFrame(time => callback(time));
});
}

export function shouldSuspendCommit(type: Type, props: Props): boolean {
return false;
}

export function startSuspendingCommit(): void {}

export function suspendInstance(type: Type, props: Props): void {}

export function waitForCommitToBeReady(): null {
return null;
}

// -------------------
// Resources
// -------------------
Expand Down
12 changes: 12 additions & 0 deletions packages/react-native-renderer/src/ReactFabricHostConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,18 @@ export function requestPostPaintCallback(callback: (time: number) => void) {
// noop
}

export function shouldSuspendCommit(type: Type, props: Props): boolean {
return false;
}

export function startSuspendingCommit(): void {}

export function suspendInstance(type: Type, props: Props): void {}

export function waitForCommitToBeReady(): null {
return null;
}

export function prepareRendererToRender(container: Container): void {
// noop
}
Expand Down
12 changes: 12 additions & 0 deletions packages/react-native-renderer/src/ReactNativeHostConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,18 @@ export function requestPostPaintCallback(callback: (time: number) => void) {
// noop
}

export function shouldSuspendCommit(type: Type, props: Props): boolean {
return false;
}

export function startSuspendingCommit(): void {}

export function suspendInstance(type: Type, props: Props): void {}

export function waitForCommitToBeReady(): null {
return null;
}

export function prepareRendererToRender(container: Container): void {
// noop
}
Expand Down
3 changes: 3 additions & 0 deletions packages/react-noop-renderer/src/ReactNoop.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ export const {
createLegacyRoot,
getChildrenAsJSX,
getPendingChildrenAsJSX,
getSuspenseyThingStatus,
resolveSuspenseyThing,
resetSuspenseyThingCache,
createPortal,
render,
renderLegacySyncRoot,
Expand Down
3 changes: 3 additions & 0 deletions packages/react-noop-renderer/src/ReactNoopPersistent.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ export const {
createLegacyRoot,
getChildrenAsJSX,
getPendingChildrenAsJSX,
getSuspenseyThingStatus,
resolveSuspenseyThing,
resetSuspenseyThingCache,
createPortal,
render,
renderLegacySyncRoot,
Expand Down
180 changes: 180 additions & 0 deletions packages/react-noop-renderer/src/createReactNoop.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ type Props = {
left?: null | number,
right?: null | number,
top?: null | number,
src?: string,
...
};
type Instance = {
Expand All @@ -72,6 +73,11 @@ type CreateRootOptions = {
...
};

type SuspenseyCommitSubscription = {
pendingCount: number,
commit: null | (() => void),
};

const NO_CONTEXT = {};
const UPPERCASE_CONTEXT = {};
const UPDATE_SIGNAL = {};
Expand Down Expand Up @@ -238,6 +244,11 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
hidden: !!newProps.hidden,
context: instance.context,
};

if (type === 'suspensey-thing' && typeof newProps.src === 'string') {
clone.src = newProps.src;
}

Object.defineProperty(clone, 'id', {
value: clone.id,
enumerable: false,
Expand Down Expand Up @@ -271,6 +282,79 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
return hostContext === UPPERCASE_CONTEXT ? rawText.toUpperCase() : rawText;
}

type SuspenseyThingRecord = {
status: 'pending' | 'fulfilled',
subscriptions: Array<SuspenseyCommitSubscription> | null,
};

let suspenseyThingCache: Map<
SuspenseyThingRecord,
'pending' | 'fulfilled',
> | null = null;

let suspenseyCommitSubscription: SuspenseyCommitSubscription | null = null;

function startSuspendingCommit(): void {
// This is where we might suspend on things that aren't associated with a
// particular node, like document.fonts.ready.
suspenseyCommitSubscription = null;
}

function suspendInstance(type: string, props: Props): void {
const src = props.src;
if (type === 'suspensey-thing' && typeof src === 'string') {
// Attach a listener to the suspensey thing and create a subscription
// object that uses reference counting to track when all the suspensey
// things have loaded.
const record = suspenseyThingCache.get(src);
if (record === undefined) {
throw new Error('Could not find record for key.');
}
if (record.status === 'pending') {
if (suspenseyCommitSubscription === null) {
suspenseyCommitSubscription = {
pendingCount: 1,
commit: null,
};
} else {
// There's an existing subscription, add to that one. It's OK
// to mutate the commit payload because it's only used for a single
// atomic commit.
suspenseyCommitSubscription.pendingCount++;
}
}
// Stash the subscription on the record. In `resolveSuspenseyThing`,
// we'll use this fire the commit once all the things have loaded.
if (record.subscriptions === null) {
record.subscriptions = [];
}
record.subscriptions.push(suspenseyCommitSubscription);
} else {
throw new Error(
'Did not expect this host component to be visited when suspending ' +
'the commit. Did you check the SuspendCommit flag?',
);
}
return suspenseyCommitSubscription;
}

function waitForCommitToBeReady():
| ((commit: () => mixed) => () => void)
| null {
const subscription = suspenseyCommitSubscription;
if (subscription !== null) {
suspenseyCommitSubscription = null;
return (commit: () => void) => {
subscription.commit = commit;
const cancelCommit = () => {
subscription.commit = null;
};
return cancelCommit;
};
}
return null;
}

const sharedHostConfig = {
supportsSingletons: false,

Expand Down Expand Up @@ -322,6 +406,11 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
hidden: !!props.hidden,
context: hostContext,
};

if (type === 'suspensey-thing' && typeof props.src === 'string') {
inst.src = props.src;
}

// Hide from unit tests
Object.defineProperty(inst, 'id', {value: inst.id, enumerable: false});
Object.defineProperty(inst, 'parent', {
Expand Down Expand Up @@ -480,6 +569,45 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
const endTime = Scheduler.unstable_now();
callback(endTime);
},

shouldSuspendCommit(type: string, props: Props): boolean {
if (type === 'suspensey-thing' && typeof props.src === 'string') {
if (suspenseyThingCache === null) {
suspenseyThingCache = new Map();
}
const record = suspenseyThingCache.get(props.src);
if (record === undefined) {
const newRecord: SuspenseyThingRecord = {
status: 'pending',
subscriptions: null,
};
suspenseyThingCache.set(props.src, newRecord);
const onLoadStart = props.onLoadStart;
if (typeof onLoadStart === 'function') {
onLoadStart();
}
return props.src;
} else {
if (record.status === 'pending') {
// The resource was already requested, but it hasn't finished
// loading yet.
return true;
} else {
// The resource has already loaded. If the renderer is confident that
// the resource will still be cached by the time the render commits,
// then it can return false, like we do here.
return false;
}
}
}
// Don't need to suspend.
return false;
},

startSuspendingCommit,
suspendInstance,
waitForCommitToBeReady,

prepareRendererToRender() {},
resetRendererAfterRender() {},
};
Expand Down Expand Up @@ -508,6 +636,11 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
hostUpdateCounter++;
instance.prop = newProps.prop;
instance.hidden = !!newProps.hidden;

if (type === 'suspensey-thing' && typeof newProps.src === 'string') {
instance.src = newProps.src;
}

if (shouldSetTextContent(type, newProps)) {
if (__DEV__) {
checkPropStringCoercion(newProps.children, 'children');
Expand Down Expand Up @@ -689,6 +822,9 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
if (instance.hidden) {
props.hidden = true;
}
if (instance.src) {
props.src = instance.src;
}
if (children !== null) {
props.children = children;
}
Expand Down Expand Up @@ -915,6 +1051,50 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
return getPendingChildrenAsJSX(container);
},

getSuspenseyThingStatus(src): string | null {
if (suspenseyThingCache === null) {
return null;
} else {
const record = suspenseyThingCache.get(src);
return record === undefined ? null : record.status;
}
},

resolveSuspenseyThing(key: string): void {
if (suspenseyThingCache === null) {
suspenseyThingCache = new Map();
}
const record = suspenseyThingCache.get(key);
if (record === undefined) {
const newRecord: SuspenseyThingRecord = {
status: 'fulfilled',
subscriptions: null,
};
suspenseyThingCache.set(key, newRecord);
} else {
if (record.status === 'pending') {
record.status = 'fulfilled';
const subscriptions = record.subscriptions;
if (subscriptions !== null) {
record.subscriptions = null;
for (let i = 0; i < subscriptions.length; i++) {
const subscription = subscriptions[i];
subscription.pendingCount--;
if (subscription.pendingCount === 0) {
const commit = subscription.commit;
subscription.commit = null;
commit();
}
}
}
}
}
},

resetSuspenseyThingCache() {
suspenseyThingCache = null;
},

createPortal(
children: ReactNodeList,
container: Container,
Expand Down
23 changes: 23 additions & 0 deletions packages/react-reconciler/src/ReactFiberCommitWork.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ import {
LayoutMask,
PassiveMask,
Visibility,
SuspenseyCommit,
} from './ReactFiberFlags';
import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber';
import {
Expand Down Expand Up @@ -158,6 +159,7 @@ import {
mountHoistable,
unmountHoistable,
prepareToCommitHoistables,
suspendInstance,
} from './ReactFiberHostConfig';
import {
captureCommitPhaseError,
Expand Down Expand Up @@ -4062,6 +4064,27 @@ export function commitPassiveUnmountEffects(finishedWork: Fiber): void {
resetCurrentDebugFiberInDEV();
}

export function recursivelyAccumulateSuspenseyCommit(parentFiber: Fiber): void {
if (parentFiber.subtreeFlags & SuspenseyCommit) {
let child = parentFiber.child;
while (child !== null) {
recursivelyAccumulateSuspenseyCommit(child);
switch (child.tag) {
case HostComponent:
case HostHoistable: {
if (child.flags & SuspenseyCommit) {
const type = child.type;
const props = child.memoizedProps;
suspendInstance(type, props);
}
break;
}
}
child = child.sibling;
}
}
}

function detachAlternateSiblings(parentFiber: Fiber) {
// A fiber was deleted from this parent fiber, but it's still part of the
// previous (alternate) parent fiber's list of children. Because children
Expand Down
Loading

0 comments on commit 7cd3646

Please sign in to comment.