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 15, 2023
1 parent dadeb06 commit 1f7b766
Show file tree
Hide file tree
Showing 9 changed files with 620 additions and 34 deletions.
149 changes: 146 additions & 3 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 Down Expand Up @@ -240,6 +241,11 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
hidden: !!newProps.hidden,
context: instance.context,
};

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

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

type SuspenseyThingRecord = {
status: 'pending' | 'fulfilled',
listeners: Set<() => void> | null,
};

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

const sharedHostConfig = {
supportsSingletons: false,

Expand Down Expand Up @@ -324,6 +340,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 @@ -488,7 +509,7 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
type: string,
newProps: Props,
): SuspendCommitPayload | null {
return null;
return shouldSuspendCommit(instance, type, newProps);
},

shouldSuspendUpdate(
Expand All @@ -497,19 +518,94 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
oldProps: Props,
newProps: Props,
): SuspendCommitPayload | null {
return null;
return shouldSuspendCommit(instance, type, newProps);
},

waitForCommitToBeReady(
suspenseyThings: Array<SuspendCommitPayload> | null,
): null {
): ((commit: () => mixed) => () => void) | null {
if (suspenseyThings !== null) {
const subscribeToOnReady = commit => {
// Attach a listener to all the suspensey things. Once they've all
// fired, we can commit. Unless the commit is canceled.

let didCancel = false;
let refCount = 0;
const onLoad = () => {
refCount--;
if (refCount === 0 && !didCancel) {
commit();
}
};
for (let i = 0; i < suspenseyThings.length; i++) {
const suspenseyThing = suspenseyThings[i];
const record = trackedSuspendCommitPaylods.get(suspenseyThing);
if (record === undefined) {
throw new Error('Could not find record for key.');
}
if (record.status === 'pending') {
refCount++;
if (record.listeners === null) {
record.listeners = [];
}
record.listeners.push(onLoad);
}
}

if (refCount === 0) {
// Nothing is pending anymore. We can commit immediately.
return null;
}

// React will call this if there's an interruption.
const cancelPendingCommit = () => {
didCancel = true;
};
return cancelPendingCommit;
};
return subscribeToOnReady;
}
return null;
},

prepareRendererToRender() {},
resetRendererAfterRender() {},
};

function shouldSuspendCommit(instance: Instance, type: string, props: Props) {
if (type === 'suspensey-thing' && typeof props.src === 'string') {
if (trackedSuspendCommitPaylods === null) {
trackedSuspendCommitPaylods = new Map();
}
const record = trackedSuspendCommitPaylods.get(props.src);
if (record === undefined) {
const newRecord: SuspendCommitPayload = {
status: 'pending',
listeners: null,
};
trackedSuspendCommitPaylods.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 props.src;
} 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 nothing, like we do here.
return null;
}
}
}
// Don't need to suspend.
return null;
}

const hostConfig = useMutation
? {
...sharedHostConfig,
Expand All @@ -534,6 +630,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 @@ -715,6 +816,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 @@ -941,6 +1045,45 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
return getPendingChildrenAsJSX(container);
},

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

async resolveSuspenseyThing(key: string): void {
if (trackedSuspendCommitPaylods === null) {
trackedSuspendCommitPaylods = new Map();
}
const record = trackedSuspendCommitPaylods.get(key);
if (record === undefined) {
const newRecord: SuspendCommitPayload = {
status: 'fulfilled',
listeners: null,
};
trackedSuspendCommitPaylods.set(key, newRecord);
} else {
if (record.status === 'pending') {
record.status = 'fulfilled';
const listeners = record.listeners;
if (listeners !== null) {
record.listeners = null;
for (let i = 0; i < listeners.length; i++) {
const listener = listeners[i];
listener();
}
}
}
}
},

resetSuspenseyThingCache() {
trackedSuspendCommitPaylods = null;
},

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

0 comments on commit 1f7b766

Please sign in to comment.