Skip to content

Commit

Permalink
Suspense for CPU-bound trees (#19936)
Browse files Browse the repository at this point in the history
Adds a new prop to the Suspense component type,
`unstable_expectedLoadTime`. The presence of this prop indicates that
the content is computationally expensive to render.

During the initial mount, React will skip over expensive trees by
rendering a placeholder — just like we do with trees that are waiting
for data to resolve. That will help unblock the initial skeleton for the
new screen. Then we will continue rendering in the next commit.

For now, while we experiment with the API internally, any number passed
to `unstable_expectedLoadTime` will be treated as "computationally
expensive", no matter how large or small. So it's basically a boolean.
The reason it's a number is that, in the future, we may try to be clever
with this additional information. For example, SuspenseList could use
it as part of its heuristic to determine whether to keep rendering
additional rows.

Background
----------

Much of our early messaging and research into Suspense focused on its
ability to throttle the appearance of placeholder UIs. Our theory was
that, on a fast network, if everything loads quickly, excessive
placeholders will contribute to a janky user experience. This was backed
up by user research and has held up in practice.

However, our original demos made an even stronger assertion: not only is
it preferable to throttle successive loading states, but up to a certain
threshold, it’s also preferable to remain on the previous screen; or in
other words, to delay the transition.

This strategy has produced mixed results. We’ve found it works well for
certain transitions, but not for all them. When performing a full page
transition, showing an initial skeleton as soon as possible is crucial
to making the transition feel snappy. You still want throttle the nested
loading states as they pop in, but you need to show something on the new
route. Remaining on the previous screen can make the app feel
unresponsive.

That’s not to say that delaying the previous screen always leads to a
bad user experience. Especially if you can guarantee that the delay is
small enough that the user won’t notice it. This threshold is a called a
Just Noticeable Difference (JND). If we can stay under the JND, then
it’s worth skipping the first placeholder to reduce overall thrash.

Delays that are larger than the JND have some use cases, too. The main
one we’ve found is to refresh existing data, where it’s often preferable
to keep stale content on screen while the new data loads in the
background. It’s also useful as a fallback strategy if something
suspends unexpectedly, to avoid hiding parts of the UI that are already
visible.

We’re still in the process of optimizing our heuristics for the most
common patterns. In general, though, we are trending toward being more
aggressive about prioritizing the initial skeleton.

For example, Suspense is usually thought of as a feature for displaying
placeholders when the UI is missing data — that is, when rendering is
bound by pending IO.

But it turns out that the same principles apply to CPU-bound
transitions, too. It’s worth deferring a tree that’s slow to render if
doing so unblocks the rest of the transition — regardless of whether
it’s slow because of missing data or because of expensive CPU work.

We already take advantage of this idea in a few places, such as
hydration. Instead of hydrating server-rendered UI in a single pass,
React splits it into chunks. It can do this because the initial HTML
acts as its own placeholder. React can defer hydrating a chunk of UI as
long as it wants until the user interacts it. The boundary we use to
split the UI into chunks is the same one we use for IO-bound subtrees:
the <Suspense /> component.

SuspenseList does something similar. When streaming in a list of items,
it will occasionally stop to commit whatever items have already
finished, before continuing where it left off. It does this by showing a
placeholder for the remaining items, again using the same <Suspense />
component API, even if the item is CPU-bound.

Unresolved questions
--------------------

There is a concern that showing a placeholder without also loading new
data could be disorienting. Users are trained to believe that a
placeholder signals fresh content. So there are still some questions
we’ll need to resolve.
  • Loading branch information
acdlite authored Sep 30, 2020
1 parent 7f08e90 commit 1faf9e3
Show file tree
Hide file tree
Showing 5 changed files with 375 additions and 6 deletions.
36 changes: 33 additions & 3 deletions packages/react-reconciler/src/ReactFiberBeginWork.new.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ import {
SyncLane,
OffscreenLane,
DefaultHydrationLane,
SomeRetryLane,
NoTimestamp,
includesSomeLane,
laneToLanes,
Expand Down Expand Up @@ -1658,6 +1659,7 @@ function updateSuspenseOffscreenState(
};
}

// TODO: Probably should inline this back
function shouldRemainOnFallback(
suspenseContext: SuspenseContext,
current: null | Fiber,
Expand Down Expand Up @@ -1790,9 +1792,9 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) {
}
}

const nextPrimaryChildren = nextProps.children;
const nextFallbackChildren = nextProps.fallback;
if (showFallback) {
const nextPrimaryChildren = nextProps.children;
const nextFallbackChildren = nextProps.fallback;
const fallbackFragment = mountSuspenseFallbackChildren(
workInProgress,
nextPrimaryChildren,
Expand All @@ -1805,8 +1807,36 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) {
);
workInProgress.memoizedState = SUSPENDED_MARKER;
return fallbackFragment;
} else if (typeof nextProps.unstable_expectedLoadTime === 'number') {
// This is a CPU-bound tree. Skip this tree and show a placeholder to
// unblock the surrounding content. Then immediately retry after the
// initial commit.
const fallbackFragment = mountSuspenseFallbackChildren(
workInProgress,
nextPrimaryChildren,
nextFallbackChildren,
renderLanes,
);
const primaryChildFragment: Fiber = (workInProgress.child: any);
primaryChildFragment.memoizedState = mountSuspenseOffscreenState(
renderLanes,
);
workInProgress.memoizedState = SUSPENDED_MARKER;

// Since nothing actually suspended, there will nothing to ping this to
// get it started back up to attempt the next item. While in terms of
// priority this work has the same priority as this current render, it's
// not part of the same transition once the transition has committed. If
// it's sync, we still want to yield so that it can be painted.
// Conceptually, this is really the same as pinging. We can use any
// RetryLane even if it's the one currently rendering since we're leaving
// it behind on this node.
workInProgress.lanes = SomeRetryLane;
if (enableSchedulerTracing) {
markSpawnedWork(SomeRetryLane);
}
return fallbackFragment;
} else {
const nextPrimaryChildren = nextProps.children;
return mountSuspensePrimaryChildren(
workInProgress,
nextPrimaryChildren,
Expand Down
36 changes: 33 additions & 3 deletions packages/react-reconciler/src/ReactFiberBeginWork.old.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ import {
SyncLane,
OffscreenLane,
DefaultHydrationLane,
SomeRetryLane,
NoTimestamp,
includesSomeLane,
laneToLanes,
Expand Down Expand Up @@ -1657,6 +1658,7 @@ function updateSuspenseOffscreenState(
};
}

// TODO: Probably should inline this back
function shouldRemainOnFallback(
suspenseContext: SuspenseContext,
current: null | Fiber,
Expand Down Expand Up @@ -1789,9 +1791,9 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) {
}
}

const nextPrimaryChildren = nextProps.children;
const nextFallbackChildren = nextProps.fallback;
if (showFallback) {
const nextPrimaryChildren = nextProps.children;
const nextFallbackChildren = nextProps.fallback;
const fallbackFragment = mountSuspenseFallbackChildren(
workInProgress,
nextPrimaryChildren,
Expand All @@ -1804,8 +1806,36 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) {
);
workInProgress.memoizedState = SUSPENDED_MARKER;
return fallbackFragment;
} else if (typeof nextProps.unstable_expectedLoadTime === 'number') {
// This is a CPU-bound tree. Skip this tree and show a placeholder to
// unblock the surrounding content. Then immediately retry after the
// initial commit.
const fallbackFragment = mountSuspenseFallbackChildren(
workInProgress,
nextPrimaryChildren,
nextFallbackChildren,
renderLanes,
);
const primaryChildFragment: Fiber = (workInProgress.child: any);
primaryChildFragment.memoizedState = mountSuspenseOffscreenState(
renderLanes,
);
workInProgress.memoizedState = SUSPENDED_MARKER;

// Since nothing actually suspended, there will nothing to ping this to
// get it started back up to attempt the next item. While in terms of
// priority this work has the same priority as this current render, it's
// not part of the same transition once the transition has committed. If
// it's sync, we still want to yield so that it can be painted.
// Conceptually, this is really the same as pinging. We can use any
// RetryLane even if it's the one currently rendering since we're leaving
// it behind on this node.
workInProgress.lanes = SomeRetryLane;
if (enableSchedulerTracing) {
markSpawnedWork(SomeRetryLane);
}
return fallbackFragment;
} else {
const nextPrimaryChildren = nextProps.children;
return mountSuspensePrimaryChildren(
workInProgress,
nextPrimaryChildren,
Expand Down
11 changes: 11 additions & 0 deletions packages/react-reconciler/src/ReactFiberSuspenseComponent.new.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* @flow
*/

import type {ReactNodeList, Wakeable} from 'shared/ReactTypes';
import type {Fiber} from './ReactInternalTypes';
import type {SuspenseInstance} from './ReactFiberHostConfig';
import type {Lane} from './ReactFiberLane';
Expand All @@ -17,6 +18,16 @@ import {
isSuspenseInstanceFallback,
} from './ReactFiberHostConfig';

export type SuspenseProps = {|
children?: ReactNodeList,
fallback?: ReactNodeList,

// TODO: Add "unstable_" prefix?
suspenseCallback?: (Set<Wakeable> | null) => mixed,

unstable_expectedLoadTime?: number,
|};

// A null SuspenseState represents an unsuspended normal Suspense boundary.
// A non-null SuspenseState means that it is blocked for one reason or another.
// - A non-null dehydrated field means it's blocked pending hydration.
Expand Down
11 changes: 11 additions & 0 deletions packages/react-reconciler/src/ReactFiberSuspenseComponent.old.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* @flow
*/

import type {ReactNodeList, Wakeable} from 'shared/ReactTypes';
import type {Fiber} from './ReactInternalTypes';
import type {SuspenseInstance} from './ReactFiberHostConfig';
import type {Lane} from './ReactFiberLane';
Expand All @@ -17,6 +18,16 @@ import {
isSuspenseInstanceFallback,
} from './ReactFiberHostConfig';

export type SuspenseProps = {|
children?: ReactNodeList,
fallback?: ReactNodeList,

// TODO: Add "unstable_" prefix?
suspenseCallback?: (Set<Wakeable> | null) => mixed,

unstable_expectedLoadTime?: number,
|};

// A null SuspenseState represents an unsuspended normal Suspense boundary.
// A non-null SuspenseState means that it is blocked for one reason or another.
// - A non-null dehydrated field means it's blocked pending hydration.
Expand Down
Loading

0 comments on commit 1faf9e3

Please sign in to comment.