Skip to content

Commit

Permalink
Feature: Suspend commit without blocking render (#26398)
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 17, 2023
1 parent 6310087 commit db281b3
Show file tree
Hide file tree
Showing 23 changed files with 894 additions and 130 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 @@ -414,6 +414,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
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,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,78 @@ 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;

// Represents a subscription for all the suspensey things that block a
// particular commit. Once they've all loaded, the commit phase can proceed.
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 {
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 +405,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 +568,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 +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: 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
8 changes: 4 additions & 4 deletions packages/react-reconciler/src/ReactFiberBeginWork.js
Original file line number Diff line number Diff line change
Expand Up @@ -2298,7 +2298,7 @@ function updateSuspenseComponent(
const newOffscreenQueue: OffscreenQueue = {
transitions: currentTransitions,
markerInstances: parentMarkerInstances,
wakeables: null,
retryQueue: null,
};
primaryChildFragment.updateQueue = newOffscreenQueue;
} else {
Expand Down Expand Up @@ -2399,7 +2399,7 @@ function updateSuspenseComponent(
const newOffscreenQueue: OffscreenQueue = {
transitions: currentTransitions,
markerInstances: parentMarkerInstances,
wakeables: null,
retryQueue: null,
};
primaryChildFragment.updateQueue = newOffscreenQueue;
} else if (offscreenQueue === currentOffscreenQueue) {
Expand All @@ -2408,9 +2408,9 @@ function updateSuspenseComponent(
const newOffscreenQueue: OffscreenQueue = {
transitions: currentTransitions,
markerInstances: parentMarkerInstances,
wakeables:
retryQueue:
currentOffscreenQueue !== null
? currentOffscreenQueue.wakeables
? currentOffscreenQueue.retryQueue
: null,
};
primaryChildFragment.updateQueue = newOffscreenQueue;
Expand Down
Loading

0 comments on commit db281b3

Please sign in to comment.