Skip to content

Commit 68d05e4

Browse files
committed
[Fiber] render boundary in fallback if it contains a new stylesheet during sync update
When we implemented Suspensey CSS we had a heuristic that if the update was sync we would ignore the loading states of any new stylesheets and just do the commit. But for a stylesheet capability to be useful it needs to reliably prevent FOUC and since the stylesheet api is opt-in through precedence we don't have to maintain backaward compat (old stylesheets do not block commit but then nobody really renders them because of FOUC anyway) This update modifies the logic to put a boundary back into fallback if a sync update would lead to a stylesheet commiting before it loaded.
1 parent 0a0a5c0 commit 68d05e4

File tree

8 files changed

+334
-83
lines changed

8 files changed

+334
-83
lines changed

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

+3-3
Original file line numberDiff line numberDiff line change
@@ -3154,7 +3154,6 @@ export function mayResourceSuspendCommit(resource: Resource): boolean {
31543154
}
31553155

31563156
export function preloadInstance(type: Type, props: Props): boolean {
3157-
// Return true to indicate it's already loaded
31583157
return true;
31593158
}
31603159

@@ -3163,10 +3162,11 @@ export function preloadResource(resource: Resource): boolean {
31633162
resource.type === 'stylesheet' &&
31643163
(resource.state.loading & Settled) === NotLoaded
31653164
) {
3166-
// we have not finished loading the underlying stylesheet yet.
3165+
// Return false to indicate this resource should suspend
31673166
return false;
31683167
}
3169-
// Return true to indicate it's already loaded
3168+
3169+
// Return true to indicate this resource should not suspend
31703170
return true;
31713171
}
31723172

packages/react-dom/src/__tests__/ReactDOMFloat-test.js

+125-17
Original file line numberDiff line numberDiff line change
@@ -2946,25 +2946,8 @@ body {
29462946
<link rel="preload" as="style" href="bar" />,
29472947
]);
29482948

2949-
// Try just this and crash all of Jest
29502949
errorStylesheets(['bar']);
29512950

2952-
// // Try this and it fails the test when it shouldn't
2953-
// await act(() => {
2954-
// errorStylesheets(['bar']);
2955-
// });
2956-
2957-
// // Try this there is nothing throwing here which is not really surprising since
2958-
// // the error is bubbling up through some kind of unhandled promise rejection thingy but
2959-
// // still I thought it was worth confirming
2960-
// try {
2961-
// await act(() => {
2962-
// errorStylesheets(['bar']);
2963-
// });
2964-
// } catch (e) {
2965-
// console.log(e);
2966-
// }
2967-
29682951
loadStylesheets(['foo']);
29692952
assertLog(['load stylesheet: foo', 'error stylesheet: bar']);
29702953

@@ -3197,6 +3180,131 @@ body {
31973180
);
31983181
});
31993182

3183+
it('will put a Suspense boundary into fallback if it contains a stylesheet not loaded during a sync update', async () => {
3184+
function App({children}) {
3185+
return (
3186+
<html>
3187+
<body>{children}</body>
3188+
</html>
3189+
);
3190+
}
3191+
const root = ReactDOMClient.createRoot(document);
3192+
3193+
await clientAct(() => {
3194+
root.render(<App />);
3195+
});
3196+
await waitForAll([]);
3197+
3198+
await clientAct(() => {
3199+
root.render(
3200+
<App>
3201+
<Suspense fallback="loading...">
3202+
<div>
3203+
hello
3204+
<link rel="stylesheet" href="foo" precedence="default" />
3205+
</div>
3206+
</Suspense>
3207+
</App>,
3208+
);
3209+
});
3210+
await waitForAll([]);
3211+
3212+
// Although the commit suspended, a preload was inserted.
3213+
expect(getMeaningfulChildren(document)).toEqual(
3214+
<html>
3215+
<head>
3216+
<link rel="preload" href="foo" as="style" />
3217+
</head>
3218+
<body>loading...</body>
3219+
</html>,
3220+
);
3221+
3222+
loadPreloads(['foo']);
3223+
assertLog(['load preload: foo']);
3224+
expect(getMeaningfulChildren(document)).toEqual(
3225+
<html>
3226+
<head>
3227+
<link rel="stylesheet" href="foo" data-precedence="default" />
3228+
<link rel="preload" href="foo" as="style" />
3229+
</head>
3230+
<body>loading...</body>
3231+
</html>,
3232+
);
3233+
3234+
loadStylesheets(['foo']);
3235+
assertLog(['load stylesheet: foo']);
3236+
expect(getMeaningfulChildren(document)).toEqual(
3237+
<html>
3238+
<head>
3239+
<link rel="stylesheet" href="foo" data-precedence="default" />
3240+
<link rel="preload" href="foo" as="style" />
3241+
</head>
3242+
<body>
3243+
<div>hello</div>
3244+
</body>
3245+
</html>,
3246+
);
3247+
3248+
await clientAct(() => {
3249+
root.render(
3250+
<App>
3251+
<Suspense fallback="loading...">
3252+
<div>
3253+
hello
3254+
<link rel="stylesheet" href="foo" precedence="default" />
3255+
<link rel="stylesheet" href="bar" precedence="default" />
3256+
</div>
3257+
</Suspense>
3258+
</App>,
3259+
);
3260+
});
3261+
await waitForAll([]);
3262+
expect(getMeaningfulChildren(document)).toEqual(
3263+
<html>
3264+
<head>
3265+
<link rel="stylesheet" href="foo" data-precedence="default" />
3266+
<link rel="preload" href="foo" as="style" />
3267+
<link rel="preload" href="bar" as="style" />
3268+
</head>
3269+
<body>
3270+
<div style="display: none;">hello</div>loading...
3271+
</body>
3272+
</html>,
3273+
);
3274+
3275+
loadPreloads(['bar']);
3276+
assertLog(['load preload: bar']);
3277+
expect(getMeaningfulChildren(document)).toEqual(
3278+
<html>
3279+
<head>
3280+
<link rel="stylesheet" href="foo" data-precedence="default" />
3281+
<link rel="stylesheet" href="bar" data-precedence="default" />
3282+
<link rel="preload" href="foo" as="style" />
3283+
<link rel="preload" href="bar" as="style" />
3284+
</head>
3285+
<body>
3286+
<div style="display: none;">hello</div>loading...
3287+
</body>
3288+
</html>,
3289+
);
3290+
3291+
loadStylesheets(['bar']);
3292+
assertLog(['load stylesheet: bar']);
3293+
expect(getMeaningfulChildren(document)).toEqual(
3294+
<html>
3295+
<head>
3296+
<link rel="stylesheet" href="foo" data-precedence="default" />
3297+
<link rel="stylesheet" href="bar" data-precedence="default" />
3298+
<link rel="preload" href="foo" as="style" />
3299+
<link rel="preload" href="bar" as="style" />
3300+
</head>
3301+
<body>
3302+
<div style="">hello</div>
3303+
</body>
3304+
</html>,
3305+
);
3306+
});
3307+
32003308
it('can suspend commits on more than one root for the same resource at the same time', async () => {
32013309
document.body.innerHTML = '';
32023310
const container1 = document.createElement('div');

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -535,7 +535,7 @@ export function maySuspendCommit(type: Type, props: Props): boolean {
535535
}
536536

537537
export function preloadInstance(type: Type, props: Props): boolean {
538-
// Return true to indicate it's already loaded
538+
// Return false to indicate it's already loaded
539539
return true;
540540
}
541541

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

+1-2
Original file line numberDiff line numberDiff line change
@@ -611,12 +611,11 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
611611
}
612612
return false;
613613
} else {
614-
// If this is false, React will trigger a fallback, if needed.
615614
return record.status === 'fulfilled';
616615
}
617616
},
618617

619-
preloadResource(resource: mixed): boolean {
618+
preloadResource(resource: mixed): number {
620619
throw new Error(
621620
'Resources are not implemented for React Noop yet. This method should not be called',
622621
);

packages/react-reconciler/src/ReactFiberCompleteWork.js

+15-48
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,6 @@ import {
152152
getRenderTargetTime,
153153
getWorkInProgressTransitions,
154154
shouldRemainOnPreviousScreen,
155-
getWorkInProgressRootRenderLanes,
156155
} from './ReactFiberWorkLoop';
157156
import {
158157
OffscreenLane,
@@ -161,7 +160,6 @@ import {
161160
includesSomeLane,
162161
mergeLanes,
163162
claimNextRetryLane,
164-
includesOnlyNonUrgentLanes,
165163
} from './ReactFiberLane';
166164
import {resetChildFibers} from './ReactChildFiber';
167165
import {createScopeInstance} from './ReactFiberScope';
@@ -534,41 +532,15 @@ function preloadInstanceAndSuspendIfNeeded(
534532
// loaded yet.
535533
workInProgress.flags |= MaySuspendCommit;
536534

537-
// Check if we're rendering at a "non-urgent" priority. This is the same
538-
// check that `useDeferredValue` does to determine whether it needs to
539-
// defer. This is partly for gradual adoption purposes (i.e. shouldn't start
540-
// suspending until you opt in with startTransition or Suspense) but it
541-
// also happens to be the desired behavior for the concrete use cases we've
542-
// thought of so far, like CSS loading, fonts, images, etc.
543-
//
544-
// We check the "root" render lanes here rather than the "subtree" render
545-
// because during a retry or offscreen prerender, the "subtree" render
546-
// lanes may include additional "base" lanes that were deferred during
547-
// a previous render.
548-
// TODO: We may decide to expose a way to force a fallback even during a
549-
// sync update.
550-
const rootRenderLanes = getWorkInProgressRootRenderLanes();
551-
if (!includesOnlyNonUrgentLanes(rootRenderLanes)) {
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 {
561-
// Preload the instance
562-
const isReady = preloadInstance(type, props);
563-
if (!isReady) {
564-
if (shouldRemainOnPreviousScreen()) {
565-
// It's OK to suspend. Mark the fiber so we know to suspend before the
566-
// commit phase. Then continue rendering.
567-
workInProgress.flags |= ShouldSuspendCommit;
568-
} else {
569-
// Trigger a fallback rather than block the render.
570-
suspendCommit();
571-
}
535+
// preload the instance if necessary. Even if this is an urgent render there
536+
// could be benefits to preloading early.
537+
// @TODO we should probably do the preload in begin work
538+
const isReady = preloadInstance(type, props);
539+
if (!isReady) {
540+
if (shouldRemainOnPreviousScreen()) {
541+
workInProgress.flags |= ShouldSuspendCommit;
542+
} else {
543+
suspendCommit();
572544
}
573545
}
574546
}
@@ -588,17 +560,12 @@ function preloadResourceAndSuspendIfNeeded(
588560

589561
workInProgress.flags |= MaySuspendCommit;
590562

591-
const rootRenderLanes = getWorkInProgressRootRenderLanes();
592-
if (!includesOnlyNonUrgentLanes(rootRenderLanes)) {
593-
// This is an urgent render. Don't suspend or show a fallback.
594-
} else {
595-
const isReady = preloadResource(resource);
596-
if (!isReady) {
597-
if (shouldRemainOnPreviousScreen()) {
598-
workInProgress.flags |= ShouldSuspendCommit;
599-
} else {
600-
suspendCommit();
601-
}
563+
const isReady = preloadResource(resource);
564+
if (!isReady) {
565+
if (shouldRemainOnPreviousScreen()) {
566+
workInProgress.flags |= ShouldSuspendCommit;
567+
} else {
568+
suspendCommit();
602569
}
603570
}
604571
}

packages/react-reconciler/src/ReactFiberWorkLoop.js

+12-3
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import type {
2323
} from './ReactFiberTracingMarkerComponent';
2424
import type {OffscreenInstance} from './ReactFiberActivityComponent';
2525
import type {RenderTaskFn} from './ReactFiberRootScheduler';
26+
import type {Resource} from './ReactFiberConfig';
2627

2728
import {
2829
enableCreateEventHandleAPI,
@@ -75,6 +76,7 @@ import {
7576
startSuspendingCommit,
7677
waitForCommitToBeReady,
7778
preloadInstance,
79+
preloadResource,
7880
supportsHydration,
7981
setCurrentUpdatePriority,
8082
getCurrentUpdatePriority,
@@ -123,6 +125,7 @@ import {
123125
MountPassiveDev,
124126
MountLayoutDev,
125127
DidDefer,
128+
ShouldSuspendCommit,
126129
} from './ReactFiberFlags';
127130
import {
128131
NoLanes,
@@ -1182,7 +1185,7 @@ function commitRootWhenReady(
11821185
) {
11831186
// TODO: Combine retry throttling with Suspensey commits. Right now they run
11841187
// one after the other.
1185-
if (includesOnlyNonUrgentLanes(lanes)) {
1188+
if (finishedWork.subtreeFlags & ShouldSuspendCommit) {
11861189
// Before committing, ask the renderer whether the host tree is ready.
11871190
// If it's not, we'll wait until it notifies us.
11881191
startSuspendingCommit();
@@ -2219,9 +2222,13 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
22192222
break;
22202223
}
22212224
case SuspendedOnInstanceAndReadyToContinue: {
2225+
let resource: null | Resource = null;
22222226
switch (workInProgress.tag) {
2227+
case HostHoistable: {
2228+
resource = workInProgress.memoizedState;
2229+
}
2230+
// intentional fallthrough
22232231
case HostComponent:
2224-
case HostHoistable:
22252232
case HostSingleton: {
22262233
// Before unwinding the stack, check one more time if the
22272234
// instance is ready. It may have loaded when React yielded to
@@ -2232,7 +2239,9 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
22322239
const hostFiber = workInProgress;
22332240
const type = hostFiber.type;
22342241
const props = hostFiber.pendingProps;
2235-
const isReady = preloadInstance(type, props);
2242+
const isReady = resource
2243+
? preloadResource(resource)
2244+
: preloadInstance(type, props);
22362245
if (isReady) {
22372246
// The data resolved. Resume the work loop as if nothing
22382247
// suspended. Unlike when a user component suspends, we don't

0 commit comments

Comments
 (0)