@samitouri / QOS-React-2 / commits / 0131d0cff4

Check if suspensey instance resolves in immediate task (#26427)

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 during a non-urgent render, we 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.

Andrew Clark committed Mar 20, 2023 at 12:35 UTC 0131d0cff40d4054ac72c857d3a13c5173c46e0a
13 files changed +271 -78
packages/react-art/src/ReactARTHostConfig.js
+6 -1
@@ -459,10 +459,15 @@ export function requestPostPaintCallback(callback: (time: number) => void) {
459 // noop
460 }
461
462 -export function shouldSuspendCommit(type, props) {
462 +export function maySuspendCommit(type, props) {
463 return false;
464 }
465
466 +export function preloadInstance(type, props) {
467 + // Return true to indicate it's already loaded
468 + return true;
469 +}
470 +
471 export function startSuspendingCommit() {}
472
473 export function suspendInstance(type, props) {}
packages/react-dom-bindings/src/client/ReactDOMHostConfig.js
+6 -1
@@ -1609,10 +1609,15 @@ export function requestPostPaintCallback(callback: (time: number) => void) {
1609 });
1610 }
1611
1612 -export function shouldSuspendCommit(type: Type, props: Props): boolean {
1612 +export function maySuspendCommit(type: Type, props: Props): boolean {
1613 return false;
1614 }
1615
1616 +export function preloadInstance(type: Type, props: Props): boolean {
1617 + // Return true to indicate it's already loaded
1618 + return true;
1619 +}
1620 +
1621 export function startSuspendingCommit(): void {}
1622
1623 export function suspendInstance(type: Type, props: Props): void {}
packages/react-native-renderer/src/ReactFabricHostConfig.js
+5 -1
@@ -421,10 +421,14 @@ export function requestPostPaintCallback(callback: (time: number) => void) {
421 // noop
422 }
423
424 -export function shouldSuspendCommit(type: Type, props: Props): boolean {
424 +export function maySuspendCommit(type: Type, props: Props): boolean {
425 return false;
426 }
427
428 +export function preloadInstance(type: Type, props: Props): boolean {
429 + return true;
430 +}
431 +
432 export function startSuspendingCommit(): void {}
433
434 export function suspendInstance(type: Type, props: Props): void {}
packages/react-native-renderer/src/ReactNativeHostConfig.js
+6 -1
@@ -522,10 +522,15 @@ export function requestPostPaintCallback(callback: (time: number) => void) {
522 // noop
523 }
524
525 -export function shouldSuspendCommit(type: Type, props: Props): boolean {
525 +export function maySuspendCommit(type: Type, props: Props): boolean {
526 return false;
527 }
528
529 +export function preloadInstance(type: Type, props: Props): boolean {
530 + // Return true to indicate it's already loaded
531 + return true;
532 +}
533 +
534 export function startSuspendingCommit(): void {}
535
536 export function suspendInstance(type: Type, props: Props): void {}
packages/react-noop-renderer/src/createReactNoop.js
+43 -38
@@ -312,7 +312,9 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
312 if (record === undefined) {
313 throw new Error('Could not find record for key.');
314 }
315 - if (record.status === 'pending') {
315 + if (record.status === 'fulfilled') {
316 + // Already loaded.
317 + } else if (record.status === 'pending') {
318 if (suspenseyCommitSubscription === null) {
319 suspenseyCommitSubscription = {
320 pendingCount: 1,
@@ -321,20 +323,19 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
323 } else {
324 suspenseyCommitSubscription.pendingCount++;
325 }
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);
332 }
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);
333 } else {
334 throw new Error(
335 'Did not expect this host component to be visited when suspending ' +
336 'the commit. Did you check the SuspendCommit flag?',
337 );
338 }
337 - return suspenseyCommitSubscription;
339 }
340
341 function waitForCommitToBeReady():
@@ -569,38 +570,42 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
570 callback(endTime);
571 },
572
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();
603 }
604 + return false;
605 + } else {
606 + // If this is false, React will trigger a fallback, if needed.
607 + return record.status === 'fulfilled';
608 }
602 - // Don't need to suspend.
603 - return false;
609 },
610
611 startSuspendingCommit,
packages/react-reconciler/src/ReactFiberCompleteWork.js
+44 -19
@@ -111,7 +111,8 @@ import {
111 finalizeContainerChildren,
112 preparePortalMount,
113 prepareScopeUpdate,
114 - shouldSuspendCommit,
114 + maySuspendCommit,
115 + preloadInstance,
116 } from './ReactFiberHostConfig';
117 import {
118 getRootHostContainer,
@@ -434,8 +435,6 @@ function updateHostComponent(
435 // Even better would be if children weren't special cased at all tho.
436 const instance: Instance = workInProgress.stateNode;
437
437 - suspendHostCommitIfNeeded(workInProgress, type, newProps, renderLanes);
438 -
438 const currentHostContext = getHostContext();
439 // TODO: Experiencing an error where oldProps is null. Suggests a host
440 // component is hitting the resume path. Figure out why. Possibly
@@ -495,8 +494,6 @@ function updateHostComponent(
494 recyclableInstance,
495 );
496
498 - suspendHostCommitIfNeeded(workInProgress, type, newProps, renderLanes);
499 -
497 if (
498 finalizeInitialChildren(newInstance, type, newProps, currentHostContext)
499 ) {
@@ -519,17 +516,17 @@ function updateHostComponent(
516 // not created until the complete phase. For our existing use cases, host nodes
517 // that suspend don't have children, so it doesn't matter. But that might not
518 // always be true in the future.
522 -function suspendHostCommitIfNeeded(
519 +function preloadInstanceAndSuspendIfNeeded(
520 workInProgress: Fiber,
521 type: Type,
522 props: Props,
523 renderLanes: Lanes,
524 ) {
525 // Ask the renderer if this instance should suspend the commit.
529 - if (!shouldSuspendCommit(type, props)) {
526 + if (!maySuspendCommit(type, props)) {
527 // If this flag was set previously, we can remove it. The flag represents
528 // 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
530 // if the renderer is reasonably confident that the underlying resource
531 // won't be evicted, it can return false as a performance optimization.
532 workInProgress.flags &= ~SuspenseyCommit;
@@ -552,16 +549,24 @@ function suspendHostCommitIfNeeded(
549 // TODO: We may decide to expose a way to force a fallback even during a
550 // sync update.
551 if (!includesOnlyNonUrgentLanes(renderLanes)) {
555 - // This is an urgent render. Never suspend or trigger a fallback.
552 + // This is an urgent render. Don't suspend or show a fallback. Also,
553 + // there's no need to preload, because we're going to commit this
554 + // synchronously anyway.
555 + // TODO: Could there be benefit to preloading even during a synchronous
556 + // render? The main thread will be blocked until the commit phase, but
557 + // maybe the browser would be able to start loading off thread anyway?
558 + // Likely a micro-optimization either way because typically new content
559 + // is loaded during a transition, not an urgent render.
560 } 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.
561 - } else {
562 - // We shouldn't block the commit. Activate a fallback at the nearest
563 - // Suspense boundary.
564 - suspendCommit();
561 + // Preload the instance
562 + const isReady = preloadInstance(type, props);
563 + if (!isReady) {
564 + if (shouldRemainOnPreviousScreen()) {
565 + // It's OK to suspend. Continue rendering.
566 + } else {
567 + // Trigger a fallback rather than block the render.
568 + suspendCommit();
569 + }
570 }
571 }
572 }
@@ -1054,6 +1059,17 @@ function completeWork(
1059 );
1060 }
1061 bubbleProperties(workInProgress);
1062 +
1063 + // This must come at the very end of the complete phase, because it might
1064 + // throw to suspend, and if the resource immediately loads, the work loop
1065 + // will resume rendering as if the work-in-progress completed. So it must
1066 + // fully complete.
1067 + preloadInstanceAndSuspendIfNeeded(
1068 + workInProgress,
1069 + workInProgress.type,
1070 + workInProgress.pendingProps,
1071 + renderLanes,
1072 + );
1073 return null;
1074 }
1075 }
@@ -1192,14 +1208,23 @@ function completeWork(
1208 }
1209 }
1210
1195 - suspendHostCommitIfNeeded(workInProgress, type, newProps, renderLanes);
1196 -
1211 if (workInProgress.ref !== null) {
1212 // If there is a ref on a host node we need to schedule a callback
1213 markRef(workInProgress);
1214 }
1215 }
1216 bubbleProperties(workInProgress);
1217 +
1218 + // This must come at the very end of the complete phase, because it might
1219 + // throw to suspend, and if the resource immediately loads, the work loop
1220 + // will resume rendering as if the work-in-progress completed. So it must
1221 + // fully complete.
1222 + preloadInstanceAndSuspendIfNeeded(
1223 + workInProgress,
1224 + type,
1225 + newProps,
1226 + renderLanes,
1227 + );
1228 return null;
1229 }
1230 case HostText: {
packages/react-reconciler/src/ReactFiberThenable.js
+6 -1
@@ -31,6 +31,11 @@ export const SuspenseException: mixed = new Error(
31 "call the promise's `.catch` method and pass the result to `use`",
32 );
33
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 +
39 // This is a noop thenable that we use to trigger a fallback in throwException.
40 // TODO: It would be better to refactor throwException into multiple functions
41 // so we can trigger a fallback directly without having to check the type. But
@@ -151,7 +156,7 @@ export function suspendCommit(): void {
156 // noopSuspenseyCommitThenable through to throwException.
157 // TODO: Factor the thenable check out of throwException
158 suspendedThenable = noopSuspenseyCommitThenable;
154 - throw SuspenseException;
159 + throw SuspenseyCommitException;
160 }
161
162 // This is used to track the actual thenable that suspended so it can be
packages/react-reconciler/src/ReactFiberWorkLoop.js
+83 -5
@@ -86,6 +86,7 @@ import {
86 resetRendererAfterRender,
87 startSuspendingCommit,
88 waitForCommitToBeReady,
89 + preloadInstance,
90 } from './ReactFiberHostConfig';
91
92 import {
@@ -114,6 +115,9 @@ import {
115 MemoComponent,
116 SimpleMemoComponent,
117 Profiler,
118 + HostComponent,
119 + HostHoistable,
120 + HostSingleton,
121 } from './ReactWorkTags';
122 import {ConcurrentRoot, LegacyRoot} from './ReactRootTags';
123 import type {Flags} from './ReactFiberFlags';
@@ -273,6 +277,7 @@ import {
277 import {processTransitionCallbacks} from './ReactFiberTracingMarkerComponent';
278 import {
279 SuspenseException,
280 + SuspenseyCommitException,
281 getSuspendedThenable,
282 isThenableResolved,
283 } from './ReactFiberThenable';
@@ -321,14 +326,16 @@ let workInProgress: Fiber | null = null;
326 // The lanes we're rendering
327 let workInProgressRootRenderLanes: Lanes = NoLanes;
328
324 -opaque type SuspendedReason = 0 | 1 | 2 | 3 | 4 | 5 | 6;
329 +opaque type SuspendedReason = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
330 const NotSuspended: SuspendedReason = 0;
331 const SuspendedOnError: SuspendedReason = 1;
332 const SuspendedOnData: SuspendedReason = 2;
333 const SuspendedOnImmediate: SuspendedReason = 3;
329 -const SuspendedOnDeprecatedThrowPromise: SuspendedReason = 4;
330 -const SuspendedAndReadyToContinue: SuspendedReason = 5;
331 -const SuspendedOnHydration: SuspendedReason = 6;
334 +const SuspendedOnInstance: SuspendedReason = 4;
335 +const SuspendedOnInstanceAndReadyToContinue: SuspendedReason = 5;
336 +const SuspendedOnDeprecatedThrowPromise: SuspendedReason = 6;
337 +const SuspendedAndReadyToContinue: SuspendedReason = 7;
338 +const SuspendedOnHydration: SuspendedReason = 8;
339
340 // When this is true, the work-in-progress fiber just suspended (or errored) and
341 // we've yet to unwind the stack. In some cases, we may yield to the main thread
@@ -1871,6 +1878,9 @@ function handleThrow(root: FiberRoot, thrownValue: any): void {
1878 // immediately resolved (i.e. in a microtask). Otherwise, trigger the
1879 // nearest Suspense fallback.
1880 SuspendedOnImmediate;
1881 + } else if (thrownValue === SuspenseyCommitException) {
1882 + thrownValue = getSuspendedThenable();
1883 + workInProgressSuspendedReason = SuspendedOnInstance;
1884 } else if (thrownValue === SelectiveHydrationException) {
1885 // An update flowed into a dehydrated boundary. Before we can apply the
1886 // update, we need to finish hydrating. Interrupt the work-in-progress
@@ -1938,6 +1948,13 @@ function handleThrow(root: FiberRoot, thrownValue: any): void {
1948 );
1949 break;
1950 }
1951 + case SuspendedOnInstance: {
1952 + // This is conceptually like a suspend, but it's not associated with
1953 + // a particular wakeable. It's associated with a host resource (e.g.
1954 + // a CSS file or an image) that hasn't loaded yet. DevTools doesn't
1955 + // handle this currently.
1956 + break;
1957 + }
1958 case SuspendedOnHydration: {
1959 // This is conceptually like a suspend, but it's not associated with
1960 // a particular wakeable. DevTools doesn't seem to care about this case,
@@ -2263,7 +2280,7 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
2280 // replay the suspended component.
2281 const unitOfWork = workInProgress;
2282 const thrownValue = workInProgressThrownValue;
2266 - switch (workInProgressSuspendedReason) {
2283 + resumeOrUnwind: switch (workInProgressSuspendedReason) {
2284 case SuspendedOnError: {
2285 // Unwind then continue with the normal work loop.
2286 workInProgressSuspendedReason = NotSuspended;
@@ -2310,6 +2327,11 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
2327 workInProgressSuspendedReason = SuspendedAndReadyToContinue;
2328 break outer;
2329 }
2330 + case SuspendedOnInstance: {
2331 + workInProgressSuspendedReason =
2332 + SuspendedOnInstanceAndReadyToContinue;
2333 + break outer;
2334 + }
2335 case SuspendedAndReadyToContinue: {
2336 const thenable: Thenable<mixed> = (thrownValue: any);
2337 if (isThenableResolved(thenable)) {
@@ -2325,6 +2347,62 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
2347 }
2348 break;
2349 }
2350 + case SuspendedOnInstanceAndReadyToContinue: {
2351 + switch (workInProgress.tag) {
2352 + case HostComponent:
2353 + case HostHoistable:
2354 + case HostSingleton: {
2355 + // Before unwinding the stack, check one more time if the
2356 + // instance is ready. It may have loaded when React yielded to
2357 + // the main thread.
2358 +
2359 + // Assigning this to a constant so Flow knows the binding won't
2360 + // be mutated by `preloadInstance`.
2361 + const hostFiber = workInProgress;
2362 + const type = hostFiber.type;
2363 + const props = hostFiber.pendingProps;
2364 + const isReady = preloadInstance(type, props);
2365 + if (isReady) {
2366 + // The data resolved. Resume the work loop as if nothing
2367 + // suspended. Unlike when a user component suspends, we don't
2368 + // have to replay anything because the host fiber
2369 + // already completed.
2370 + workInProgressSuspendedReason = NotSuspended;
2371 + workInProgressThrownValue = null;
2372 + const sibling = hostFiber.sibling;
2373 + if (sibling !== null) {
2374 + workInProgress = sibling;
2375 + } else {
2376 + const returnFiber = hostFiber.return;
2377 + if (returnFiber !== null) {
2378 + workInProgress = returnFiber;
2379 + completeUnitOfWork(returnFiber);
2380 + } else {
2381 + workInProgress = null;
2382 + }
2383 + }
2384 + break resumeOrUnwind;
2385 + }
2386 + break;
2387 + }
2388 + default: {
2389 + // This will fail gracefully but it's not correct, so log a
2390 + // warning in dev.
2391 + if (__DEV__) {
2392 + console.error(
2393 + 'Unexpected type of fiber triggered a suspensey commit. ' +
2394 + 'This is a bug in React.',
2395 + );
2396 + }
2397 + break;
2398 + }
2399 + }
2400 + // Otherwise, unwind then continue with the normal work loop.
2401 + workInProgressSuspendedReason = NotSuspended;
2402 + workInProgressThrownValue = null;
2403 + unwindSuspendedUnitOfWork(unitOfWork, thrownValue);
2404 + break;
2405 + }
2406 case SuspendedOnDeprecatedThrowPromise: {
2407 // Suspended by an old implementation that uses the `throw promise`
2408 // pattern. The newer replaying behavior can cause subtle issues
packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js
+4 -1
@@ -71,9 +71,12 @@ describe('ReactFiberHostContext', () => {
71 return DefaultEventPriority;
72 },
73 requestPostPaintCallback: function () {},
74 - shouldSuspendCommit(type, props) {
74 + maySuspendCommit(type, props) {
75 return false;
76 },
77 + preloadInstance(type, props) {
78 + return true;
79 + },
80 startSuspendingCommit() {},
81 suspendInstance(type, props) {},
82 waitForCommitToBeReady() {
packages/react-reconciler/src/__tests__/ReactSuspenseyCommitPhase-test.js
+57 -6
@@ -8,6 +8,7 @@ let SuspenseList;
8 let Scheduler;
9 let act;
10 let assertLog;
11 +let waitForPaint;
12
13 describe('ReactSuspenseyCommitPhase', () => {
14 beforeEach(() => {
@@ -28,6 +29,7 @@ describe('ReactSuspenseyCommitPhase', () => {
29 const InternalTestUtils = require('internal-test-utils');
30 act = InternalTestUtils.act;
31 assertLog = InternalTestUtils.assertLog;
32 + waitForPaint = InternalTestUtils.waitForPaint;
33 });
34
35 function Text({text}) {
@@ -108,12 +110,13 @@ describe('ReactSuspenseyCommitPhase', () => {
110 </Suspense>,
111 );
112 });
111 - // NOTE: `shouldSuspendCommit` is called even during synchronous renders
112 - // because if this node is ever hidden, then revealed again, we want to know
113 - // whether it's capable of suspending the commit. We track this using a
114 - // fiber flag.
115 - assertLog(['Image requested [A]']);
116 - expect(getSuspenseyThingStatus('A')).toBe('pending');
113 + // We intentionally don't preload during an urgent update because the
114 + // resource will be inserted synchronously, anyway.
115 + // TODO: Maybe we should, though? Could be that the browser is able to start
116 + // the preload in background even though the main thread is blocked. Likely
117 + // a micro-optimization either way because typically new content is loaded
118 + // during a transition, not an urgent render.
119 + expect(getSuspenseyThingStatus('A')).toBe(null);
120 expect(root).toMatchRenderedOutput(<suspensey-thing src="A" />);
121 });
122
@@ -228,4 +231,52 @@ describe('ReactSuspenseyCommitPhase', () => {
231 </>,
232 );
233 });
234 +
235 + test('avoid triggering a fallback if resource loads immediately', async () => {
236 + const root = ReactNoop.createRoot();
237 + await act(async () => {
238 + startTransition(() => {
239 + // Intentionally rendering <suspensey-thing>s in a variety of tree
240 + // positions to test that the work loop resumes correctly in each case.
241 + root.render(
242 + <Suspense fallback={<Text text="Loading..." />}>
243 + <suspensey-thing
244 + src="A"
245 + onLoadStart={() => Scheduler.log('Request [A]')}>
246 + <suspensey-thing
247 + src="B"
248 + onLoadStart={() => Scheduler.log('Request [B]')}
249 + />
250 + </suspensey-thing>
251 + <suspensey-thing
252 + src="C"
253 + onLoadStart={() => Scheduler.log('Request [C]')}
254 + />
255 + </Suspense>,
256 + );
257 + });
258 + // React will yield right after the resource suspends.
259 + // TODO: The child is preloaded first because we preload in the complete
260 + // phase. Ideally it should be in the begin phase, but we currently don't
261 + // create the instance until complete. However, it's unclear if we even
262 + // need the instance for preloading. So we should probably move this to
263 + // the begin phase.
264 + await waitForPaint(['Request [B]']);
265 + // Resolve in an immediate task. This could happen if the resource is
266 + // already loaded into the cache.
267 + resolveSuspenseyThing('B');
268 + await waitForPaint(['Request [A]']);
269 + resolveSuspenseyThing('A');
270 + await waitForPaint(['Request [C]']);
271 + resolveSuspenseyThing('C');
272 + });
273 + expect(root).toMatchRenderedOutput(
274 + <>
275 + <suspensey-thing src="A">
276 + <suspensey-thing src="B" />
277 + </suspensey-thing>
278 + <suspensey-thing src="C" />
279 + </>,
280 + );
281 + });
282 });
packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js
+2 -1
@@ -68,7 +68,8 @@ export const getInstanceFromScope = $$$hostConfig.getInstanceFromScope;
68 export const getCurrentEventPriority = $$$hostConfig.getCurrentEventPriority;
69 export const detachDeletedInstance = $$$hostConfig.detachDeletedInstance;
70 export const requestPostPaintCallback = $$$hostConfig.requestPostPaintCallback;
71 -export const shouldSuspendCommit = $$$hostConfig.shouldSuspendCommit;
71 +export const maySuspendCommit = $$$hostConfig.maySuspendCommit;
72 +export const preloadInstance = $$$hostConfig.preloadInstance;
73 export const startSuspendingCommit = $$$hostConfig.startSuspendingCommit;
74 export const suspendInstance = $$$hostConfig.suspendInstance;
75 export const waitForCommitToBeReady = $$$hostConfig.waitForCommitToBeReady;
packages/react-test-renderer/src/ReactTestHostConfig.js
+6 -1
@@ -324,10 +324,15 @@ export function requestPostPaintCallback(callback: (time: number) => void) {
324 // noop
325 }
326
327 -export function shouldSuspendCommit(type: Type, props: Props): boolean {
327 +export function maySuspendCommit(type: Type, props: Props): boolean {
328 return false;
329 }
330
331 +export function preloadInstance(type: Type, props: Props): boolean {
332 + // Return true to indicate it's already loaded
333 + return true;
334 +}
335 +
336 export function startSuspendingCommit(): void {}
337
338 export function suspendInstance(type: Type, props: Props): void {}
scripts/error-codes/codes.json
+3 -2
@@ -458,5 +458,6 @@
458 "470": "Only global symbols received from Symbol.for(...) can be passed to Server Functions. The symbol Symbol.for(%s) cannot be found among global symbols.",
459 "471": "BigInt (%s) is not yet supported as an argument to a Server Function.",
460 "472": "Type %s is not supported as an argument to a Server Function.",
461 - "473": "React doesn't accept base64 encoded file uploads because we don't except form data passed from a browser to ever encode data that way. If that's the wrong assumption, we can easily fix it."
462 -}
\ No newline at end of file
461 + "473": "React doesn't accept base64 encoded file uploads because we don't except form data passed from a browser to ever encode data that way. If that's the wrong assumption, we can easily fix it.",
462 + "474": "Suspense Exception: This is not a real error, and should not leak into userspace. If you're seeing this, it's likely a bug in React."
463 +}