Skip to content

Commit 2e42765

Browse files
committed
Check if suspensey instance resolves in immediate task
When rendering a suspensey resource that we haven't seen before, it may have loaded in the background while we were rendering. We should yield to the main thread to see if the load event fires in an immediate task. For example, if the resource for a link element has already loaded, its load event will fire in a task right after React yields to the main thread. Because the continuation task is not scheduled until right before React yields, the load event will ping React before it resumes. If this happens, we can resume rendering without showing a fallback. I don't think this matters much for images, because the `completed` property tells us whether the image has loaded, and we currently never block the main thread for more than 5ms at a time (for now — we might increase this in the future). It matters more for stylesheets because the only way to check if it has loaded is by listening for the load event. This is essentially the same trick that `use` does for userspace promises, but a bit simpler because we don't need to replay the host component's begin phase; the work-in-progress fiber already completed, so we can just continue onto the next sibling without any additional work. As part of this change, I split the `shouldSuspendCommit` host config method into separate `maySuspendCommit` and `preloadInstance` methods. Previously `shouldSuspendCommit` was used for both. This raised a question of whether we should preload resources during a synchronous render. My initial instinct was that we shouldn't, because we're going to synchronously block the main thread until the resource is inserted into the DOM, anyway. But I wonder if the browser is able to initiate the preload even while the main thread is blocked. It's probably a micro-optimization either way because most resources will be loaded during transitions, not urgent renders.
1 parent 842bd78 commit 2e42765

File tree

13 files changed

+247
-83
lines changed

13 files changed

+247
-83
lines changed

packages/react-art/src/ReactARTHostConfig.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -459,10 +459,15 @@ export function requestPostPaintCallback(callback: (time: number) => void) {
459459
// noop
460460
}
461461

462-
export function shouldSuspendCommit(type, props) {
462+
export function maySuspendCommit(type, props) {
463463
return false;
464464
}
465465

466+
export function preloadInstance(type, props) {
467+
// Return true to indicate it's already loaded
468+
return true;
469+
}
470+
466471
export function startSuspendingCommit() {}
467472

468473
export function suspendInstance(type, props) {}

packages/react-dom-bindings/src/client/ReactDOMHostConfig.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1609,10 +1609,15 @@ export function requestPostPaintCallback(callback: (time: number) => void) {
16091609
});
16101610
}
16111611

1612-
export function shouldSuspendCommit(type: Type, props: Props): boolean {
1612+
export function maySuspendCommit(type: Type, props: Props): boolean {
16131613
return false;
16141614
}
16151615

1616+
export function preloadInstance(type: Type, props: Props): boolean {
1617+
// Return true to indicate it's already loaded
1618+
return true;
1619+
}
1620+
16161621
export function startSuspendingCommit(): void {}
16171622

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

packages/react-native-renderer/src/ReactFabricHostConfig.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -414,10 +414,14 @@ export function requestPostPaintCallback(callback: (time: number) => void) {
414414
// noop
415415
}
416416

417-
export function shouldSuspendCommit(type: Type, props: Props): boolean {
417+
export function maySuspendCommit(type: Type, props: Props): boolean {
418418
return false;
419419
}
420420

421+
export function preloadInstance(type: Type, props: Props): boolean {
422+
return true;
423+
}
424+
421425
export function startSuspendingCommit(): void {}
422426

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

packages/react-native-renderer/src/ReactNativeHostConfig.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -522,10 +522,15 @@ export function requestPostPaintCallback(callback: (time: number) => void) {
522522
// noop
523523
}
524524

525-
export function shouldSuspendCommit(type: Type, props: Props): boolean {
525+
export function maySuspendCommit(type: Type, props: Props): boolean {
526526
return false;
527527
}
528528

529+
export function preloadInstance(type: Type, props: Props): boolean {
530+
// Return true to indicate it's already loaded
531+
return true;
532+
}
533+
529534
export function startSuspendingCommit(): void {}
530535

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

packages/react-noop-renderer/src/createReactNoop.js

Lines changed: 43 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,9 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
312312
if (record === undefined) {
313313
throw new Error('Could not find record for key.');
314314
}
315-
if (record.status === 'pending') {
315+
if (record.status === 'fulfilled') {
316+
// Already loaded.
317+
} else if (record.status === 'pending') {
316318
if (suspenseyCommitSubscription === null) {
317319
suspenseyCommitSubscription = {
318320
pendingCount: 1,
@@ -321,20 +323,19 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
321323
} else {
322324
suspenseyCommitSubscription.pendingCount++;
323325
}
326+
// Stash the subscription on the record. In `resolveSuspenseyThing`,
327+
// we'll use this fire the commit once all the things have loaded.
328+
if (record.subscriptions === null) {
329+
record.subscriptions = [];
330+
}
331+
record.subscriptions.push(suspenseyCommitSubscription);
324332
}
325-
// Stash the subscription on the record. In `resolveSuspenseyThing`,
326-
// we'll use this fire the commit once all the things have loaded.
327-
if (record.subscriptions === null) {
328-
record.subscriptions = [];
329-
}
330-
record.subscriptions.push(suspenseyCommitSubscription);
331333
} else {
332334
throw new Error(
333335
'Did not expect this host component to be visited when suspending ' +
334336
'the commit. Did you check the SuspendCommit flag?',
335337
);
336338
}
337-
return suspenseyCommitSubscription;
338339
}
339340

340341
function waitForCommitToBeReady():
@@ -569,38 +570,42 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
569570
callback(endTime);
570571
},
571572

572-
shouldSuspendCommit(type: string, props: Props): boolean {
573-
if (type === 'suspensey-thing' && typeof props.src === 'string') {
574-
if (suspenseyThingCache === null) {
575-
suspenseyThingCache = new Map();
576-
}
577-
const record = suspenseyThingCache.get(props.src);
578-
if (record === undefined) {
579-
const newRecord: SuspenseyThingRecord = {
580-
status: 'pending',
581-
subscriptions: null,
582-
};
583-
suspenseyThingCache.set(props.src, newRecord);
584-
const onLoadStart = props.onLoadStart;
585-
if (typeof onLoadStart === 'function') {
586-
onLoadStart();
587-
}
588-
return props.src;
589-
} else {
590-
if (record.status === 'pending') {
591-
// The resource was already requested, but it hasn't finished
592-
// loading yet.
593-
return true;
594-
} else {
595-
// The resource has already loaded. If the renderer is confident that
596-
// the resource will still be cached by the time the render commits,
597-
// then it can return false, like we do here.
598-
return false;
599-
}
573+
maySuspendCommit(type: string, props: Props): boolean {
574+
// Asks whether it's possible for this combination of type and props
575+
// to ever need to suspend. This is different from asking whether it's
576+
// currently ready because even if it's ready now, it might get purged
577+
// from the cache later.
578+
return type === 'suspensey-thing' && typeof props.src === 'string';
579+
},
580+
581+
preloadInstance(type: string, props: Props): boolean {
582+
if (type !== 'suspensey-thing' || typeof props.src !== 'string') {
583+
throw new Error('Attempted to preload unexpected instance: ' + type);
584+
}
585+
586+
// In addition to preloading an instance, this method asks whether the
587+
// instance is ready to be committed. If it's not, React may yield to the
588+
// main thread and ask again. It's possible a load event will fire in
589+
// between, in which case we can avoid showing a fallback.
590+
if (suspenseyThingCache === null) {
591+
suspenseyThingCache = new Map();
592+
}
593+
const record = suspenseyThingCache.get(props.src);
594+
if (record === undefined) {
595+
const newRecord: SuspenseyThingRecord = {
596+
status: 'pending',
597+
subscriptions: null,
598+
};
599+
suspenseyThingCache.set(props.src, newRecord);
600+
const onLoadStart = props.onLoadStart;
601+
if (typeof onLoadStart === 'function') {
602+
onLoadStart();
600603
}
604+
return false;
605+
} else {
606+
// If this is false, React will trigger a fallback, if needed.
607+
return record.status === 'fulfilled';
601608
}
602-
// Don't need to suspend.
603-
return false;
604609
},
605610

606611
startSuspendingCommit,

packages/react-reconciler/src/ReactFiberCompleteWork.js

Lines changed: 50 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,8 @@ import {
111111
finalizeContainerChildren,
112112
preparePortalMount,
113113
prepareScopeUpdate,
114-
shouldSuspendCommit,
114+
maySuspendCommit,
115+
preloadInstance,
115116
} from './ReactFiberHostConfig';
116117
import {
117118
getRootHostContainer,
@@ -434,8 +435,6 @@ function updateHostComponent(
434435
// Even better would be if children weren't special cased at all tho.
435436
const instance: Instance = workInProgress.stateNode;
436437

437-
suspendHostCommitIfNeeded(workInProgress, type, newProps, renderLanes);
438-
439438
const currentHostContext = getHostContext();
440439
// TODO: Experiencing an error where oldProps is null. Suggests a host
441440
// component is hitting the resume path. Figure out why. Possibly
@@ -495,8 +494,6 @@ function updateHostComponent(
495494
recyclableInstance,
496495
);
497496

498-
suspendHostCommitIfNeeded(workInProgress, type, newProps, renderLanes);
499-
500497
if (
501498
finalizeInitialChildren(newInstance, type, newProps, currentHostContext)
502499
) {
@@ -519,17 +516,17 @@ function updateHostComponent(
519516
// not created until the complete phase. For our existing use cases, host nodes
520517
// that suspend don't have children, so it doesn't matter. But that might not
521518
// always be true in the future.
522-
function suspendHostCommitIfNeeded(
519+
function preloadInstanceAndSuspendIfNeeded(
523520
workInProgress: Fiber,
524521
type: Type,
525522
props: Props,
526523
renderLanes: Lanes,
527524
) {
528525
// Ask the renderer if this instance should suspend the commit.
529-
if (!shouldSuspendCommit(type, props)) {
526+
if (!maySuspendCommit(type, props)) {
530527
// If this flag was set previously, we can remove it. The flag represents
531528
// whether this particular set of props might ever need to suspend. The
532-
// safest thing to do is for shouldSuspendCommit to always return true, but
529+
// safest thing to do is for maySuspendCommit to always return true, but
533530
// if the renderer is reasonably confident that the underlying resource
534531
// won't be evicted, it can return false as a performance optimization.
535532
workInProgress.flags &= ~SuspenseyCommit;
@@ -543,24 +540,33 @@ function suspendHostCommitIfNeeded(
543540
// becomes hidden, we might want to suspend before revealing it again.
544541
workInProgress.flags |= SuspenseyCommit;
545542

546-
// Check if we're rendering at a "non-urgent" priority. This is the same
547-
// check that `useDeferredValue` does to determine whether it needs to
548-
// defer. This is partly for gradual adoption purposes (i.e. shouldn't start
549-
// suspending until you opt in with startTransition or Suspense) but it
550-
// also happens to be the desired behavior for the concrete use cases we've
551-
// thought of so far, like CSS loading, fonts, images, etc.
552543
// TODO: We may decide to expose a way to force a fallback even during a
553544
// sync update.
554-
if (!includesOnlyNonUrgentLanes(renderLanes)) {
555-
// This is an urgent render. Never suspend or trigger a fallback.
545+
if (shouldRemainOnPreviousScreen()) {
546+
// We don't need to trigger a fallback. Preload the resource and continue
547+
// rendering the rest of the tree.
548+
preloadInstance(type, props);
556549
} else {
557-
// Need to decide whether to activate the nearest fallback or to continue
558-
// rendering and suspend right before the commit phase.
559-
if (shouldRemainOnPreviousScreen()) {
560-
// It's OK to block the commit. Don't show a fallback.
550+
// Check if we're rendering at a "non-urgent" priority. This is the same
551+
// check that `useDeferredValue` does to determine whether it needs to
552+
// defer. This is partly for gradual adoption purposes (i.e. shouldn't start
553+
// suspending until you opt in with startTransition or Suspense) but it
554+
// also happens to be the desired behavior for the concrete use cases we've
555+
// thought of so far, like CSS loading, fonts, images, etc.
556+
if (!includesOnlyNonUrgentLanes(renderLanes)) {
557+
// This is an urgent render. Don't suspend or show a fallback. Also,
558+
// there's no need to preload, because we're going to commit this
559+
// synchronously anyway.
560+
// TODO: Could there be benefit to preloading even during a synchronous
561+
// render? The main thread will be blocked until the commit phase, but
562+
// maybe the browser would be able to start loading off thread anyway?
563+
// Likely a micro-optimization either way because typically new content
564+
// is loaded during a transition, not an urgent render.
561565
} else {
562-
// We shouldn't block the commit. Activate a fallback at the nearest
563-
// Suspense boundary.
566+
// This is not an urgent render. Trigger a fallback rather than block
567+
// the render. Also preload the resource so it starts loading as soon
568+
// as possible.
569+
preloadInstance(type, props);
564570
suspendCommit();
565571
}
566572
}
@@ -1054,6 +1060,17 @@ function completeWork(
10541060
);
10551061
}
10561062
bubbleProperties(workInProgress);
1063+
1064+
// This must come at the very end of the complete phase, because it might
1065+
// throw to suspend, and if the resource immediately loads, the work loop
1066+
// will resume rendering as if the work-in-progress completed. So it must
1067+
// fully complete.
1068+
preloadInstanceAndSuspendIfNeeded(
1069+
workInProgress,
1070+
workInProgress.type,
1071+
workInProgress.pendingProps,
1072+
renderLanes,
1073+
);
10571074
return null;
10581075
}
10591076
}
@@ -1192,14 +1209,23 @@ function completeWork(
11921209
}
11931210
}
11941211

1195-
suspendHostCommitIfNeeded(workInProgress, type, newProps, renderLanes);
1196-
11971212
if (workInProgress.ref !== null) {
11981213
// If there is a ref on a host node we need to schedule a callback
11991214
markRef(workInProgress);
12001215
}
12011216
}
12021217
bubbleProperties(workInProgress);
1218+
1219+
// This must come at the very end of the complete phase, because it might
1220+
// throw to suspend, and if the resource immediately loads, the work loop
1221+
// will resume rendering as if the work-in-progress completed. So it must
1222+
// fully complete.
1223+
preloadInstanceAndSuspendIfNeeded(
1224+
workInProgress,
1225+
type,
1226+
newProps,
1227+
renderLanes,
1228+
);
12031229
return null;
12041230
}
12051231
case HostText: {

packages/react-reconciler/src/ReactFiberThenable.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ export const SuspenseException: mixed = new Error(
3131
"call the promise's `.catch` method and pass the result to `use`",
3232
);
3333

34+
export const SuspenseyCommitException: mixed = new Error(
35+
'Suspense Exception: This is not a real error, and should not leak into ' +
36+
"userspace. If you're seeing this, it's likely a bug in React.",
37+
);
38+
3439
// This is a noop thenable that we use to trigger a fallback in throwException.
3540
// TODO: It would be better to refactor throwException into multiple functions
3641
// so we can trigger a fallback directly without having to check the type. But
@@ -151,7 +156,7 @@ export function suspendCommit(): void {
151156
// noopSuspenseyCommitThenable through to throwException.
152157
// TODO: Factor the thenable check out of throwException
153158
suspendedThenable = noopSuspenseyCommitThenable;
154-
throw SuspenseException;
159+
throw SuspenseyCommitException;
155160
}
156161

157162
// This is used to track the actual thenable that suspended so it can be

0 commit comments

Comments
 (0)