[Float] Suspend unstyled content for up to 1 minute (#26532)
We almost never want to show content before its styles have loaded. But eventually we will give up and allow unstyled content. So this extends the timeout to a full minute. This somewhat arbitrary — big enough that you'd only reach it under extreme circumstances. Note that, like regular Suspense, the app is still interactive while we're waiting for content to load. Only the unstyled content is blocked from appearing, not updates in general. A new update will interrupt it. We should figure out what the browser engines do during initial page load and consider aligning our behavior with that. It's supposed to be render blocking by default but there may be some cases where they, too, give up and FOUC.
Andrew Clark committed
Mar 31, 2023 at 15:45 UTC
0ae348018d5b3a3f1ccdd92de85d9cc581b2b98d
2 files changed
+38
-17
packages/react-dom-bindings/src/client/ReactDOMHostConfig.js
+21
-15
@@ -3333,28 +3333,34 @@ export function waitForCommitToBeReady(): null | (Function => Function) {
3333
// tasks to wait on.
3334
if (state.count > 0) {
3335
return commit => {
3336
- unsuspendAfterTimeout(state);
3336
+ // We almost never want to show content before its styles have loaded. But
3337
+ // eventually we will give up and allow unstyled content. So this number is
3338
+ // somewhat arbitrary — big enough that you'd only reach it under
3339
+ // extreme circumstances.
3340
+ // TODO: Figure out what the browser engines do during initial page load and
3341
+ // consider aligning our behavior with that.
3342
+ const stylesheetTimer = setTimeout(() => {
3343
+ if (state.stylesheets) {
3344
+ insertSuspendedStylesheets(state, state.stylesheets);
3345
+ }
3346
+ if (state.unsuspend) {
3347
+ const unsuspend = state.unsuspend;
3348
+ state.unsuspend = null;
3349
+ unsuspend();
3350
+ }
3351
+ }, 60000); // one minute
3352
+
3353
state.unsuspend = commit;
3354
3339
- return () => (state.unsuspend = null);
3355
+ return () => {
3356
+ state.unsuspend = null;
3357
+ clearTimeout(stylesheetTimer);
3358
+ };
3359
};
3360
}
3361
return null;
3362
}
3363
3345
-function unsuspendAfterTimeout(state: SuspendedState) {
3346
- setTimeout(() => {
3347
- if (state.stylesheets) {
3348
- insertSuspendedStylesheets(state, state.stylesheets);
3349
- }
3350
- if (state.unsuspend) {
3351
- const unsuspend = state.unsuspend;
3352
- state.unsuspend = null;
3353
- unsuspend();
3354
- }
3355
- }, 500);
3356
-}
3357
-
3364
function onUnsuspend(this: SuspendedState) {
3365
this.count--;
3366
if (this.count === 0) {
packages/react-dom/src/__tests__/ReactDOMFloat-test.js
+17
-2
@@ -3163,7 +3163,7 @@ body {
3163
);
3164
});
3165
3166
- it('can unsuspend after a timeout even if some assets never load', async () => {
3166
+ it('stylesheets block render, with a really long timeout', async () => {
3167
function App({children}) {
3168
return (
3169
<html>
@@ -3191,7 +3191,22 @@ body {
3191
</html>,
3192
);
3193
3194
- jest.advanceTimersByTime(1000);
3194
+ // Advance time by 50 seconds. Even still, the transition is suspended.
3195
+ jest.advanceTimersByTime(50000);
3196
+ await waitForAll([]);
3197
+ expect(getMeaningfulChildren(document)).toEqual(
3198
+ <html>
3199
+ <head>
3200
+ <link rel="preload" href="foo" as="style" />
3201
+ </head>
3202
+ <body />
3203
+ </html>,
3204
+ );
3205
+
3206
+ // Advance time by 10 seconds more. A full minute total has elapsed. At this
3207
+ // point, something must have really gone wrong, so we time out and allow
3208
+ // unstyled content to be displayed.
3209
+ jest.advanceTimersByTime(10000);
3210
expect(getMeaningfulChildren(document)).toEqual(
3211
<html>
3212
<head>