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 c75280a
Show file tree
Hide file tree
Showing 20 changed files with 853 additions and 63 deletions.
15 changes: 15 additions & 0 deletions packages/react-art/src/ReactARTHostConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,21 @@ export function requestPostPaintCallback(callback: (time: number) => void) {
// noop
}

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

export function startSuspendingCommit() {
return null;
}

export function accumulateSuspenseyCommitPayload(type, props, payload) {
return null;
}

export function waitForCommitToBeReady(payload) {
return null;
}
// eslint-disable-next-line no-undef
export function prepareRendererToRender(container: Container): void {
// noop
Expand Down
22 changes: 22 additions & 0 deletions packages/react-dom-bindings/src/client/ReactDOMHostConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ export type ChildSet = void; // Unused
export type TimeoutHandle = TimeoutID;
export type NoTimeout = -1;
export type RendererInspectionConfig = $ReadOnly<{}>;
export type SuspenseyCommitPayload = null;

type SelectionInformation = {
focusedElem: null | HTMLElement,
Expand Down Expand Up @@ -1608,6 +1609,27 @@ export function requestPostPaintCallback(callback: (time: number) => void) {
localRequestAnimationFrame(time => callback(time));
});
}

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

export function startSuspendingCommit(): SuspenseyCommitPayload {
return null;
}

export function accumulateSuspenseyCommitPayload(
type: Type,
props: Props,
payload: SuspenseyCommitPayload,
): SuspenseyCommitPayload {
return null;
}

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

// -------------------
// Resources
// -------------------
Expand Down
21 changes: 21 additions & 0 deletions packages/react-native-renderer/src/ReactFabricHostConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ export type RendererInspectionConfig = $ReadOnly<{
) => void,
}>;

export type SuspenseyCommitPayload = null;

// TODO: Remove this conditional once all changes have propagated.
if (registerEventHandler) {
/**
Expand Down Expand Up @@ -418,6 +420,25 @@ export function requestPostPaintCallback(callback: (time: number) => void) {
// noop
}

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

export function startSuspendingCommit(): SuspenseyCommitPayload {
return null;
}

export function accumulateSuspenseyCommitPayload(
type: Type,
props: Props,
payload: SuspenseyCommitPayload,
): SuspenseyCommitPayload {
return null;
}

export function waitForCommitToBeReady(payload: SuspenseyCommitPayload): null {
return null;
}
export function prepareRendererToRender(container: Container): void {
// noop
}
Expand Down
22 changes: 22 additions & 0 deletions packages/react-native-renderer/src/ReactNativeHostConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ export type RendererInspectionConfig = $ReadOnly<{
) => void,
}>;

export type SuspenseyCommitPayload = null;

const UPDATE_SIGNAL = {};
if (__DEV__) {
Object.freeze(UPDATE_SIGNAL);
Expand Down Expand Up @@ -522,6 +524,26 @@ export function requestPostPaintCallback(callback: (time: number) => void) {
// noop
}

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

export function startSuspendingCommit(): SuspenseyCommitPayload {
return null;
}

export function accumulateSuspenseyCommitPayload(
type: Type,
props: Props,
payload: SuspenseyCommitPayload,
): SuspenseyCommitPayload {
return null;
}

export function waitForCommitToBeReady(payload: SuspenseyCommitPayload): 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
179 changes: 179 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,12 @@ type CreateRootOptions = {
...
};

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

const NO_CONTEXT = {};
const UPPERCASE_CONTEXT = {};
const UPDATE_SIGNAL = {};
Expand Down Expand Up @@ -238,6 +245,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 +283,16 @@ 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;

const sharedHostConfig = {
supportsSingletons: false,

Expand Down Expand Up @@ -322,6 +344,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 +507,106 @@ 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(): SuspenseyCommitSubscription | null {
// This creates an initial commit payload that we can use to keep track
// of pending suspensey things in the host components. It's also where
// we might suspend on things that aren't associated with a particular
// node, like document.fonts.ready.
return null;
},

accumulateSuspenseyCommitPayload(
type: string,
props: Props,
subscription: SuspenseyCommitSubscription | null,
): SuspenseyCommitSubscription | null {
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 (subscription === null) {
subscription = {
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.
subscription.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(subscription);
} else {
throw new Error(
'Did not expect this host component to be visited when suspending ' +
'the commit. Did you check the SuspendCommit flag?',
);
}
return subscription;
},

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

prepareRendererToRender() {},
resetRendererAfterRender() {},
};
Expand Down Expand Up @@ -508,6 +635,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 +821,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 +1050,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: SuspenseyCommitPayload = {
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 payload = subscriptions[i];
payload.pendingCount--;
if (payload.pendingCount === 0) {
const commit = payload.commit;
payload.commit = null;
commit();
}
}
}
}
}
},

resetSuspenseyThingCache() {
suspenseyThingCache = null;
},

createPortal(
children: ReactNodeList,
container: Container,
Expand Down
Loading

0 comments on commit c75280a

Please sign in to comment.