Skip to content

Commit 768f965

Browse files
authored
Suspensily committing a prerendered tree (#26434)
Prerendering a tree (i.e. with Offscreen) should not suspend the commit phase, because the content is not yet visible. However, when revealing a prerendered tree, we should suspend the commit phase if resources in the prerendered tree haven't finished loading yet. To do this properly, we need to visit all the visible nodes in the tree that might possibly suspend. This includes nodes in the current tree, because even though they were already "mounted", the resources might not have loaded yet, because we didn't suspend when it was prerendered. We will need to add this capability to the Offscreen component's "manual" mode, too. Something like a `ready()` method that returns a promise that resolves when the tree has fully loaded. Also includes some fixes to #26450. See PR for details.
1 parent d12bdcd commit 768f965

9 files changed

+336
-85
lines changed

Diff for: packages/react-dom-bindings/src/client/ReactDOMHostConfig.js

+31-2
Original file line numberDiff line numberDiff line change
@@ -1788,7 +1788,7 @@ type StyleTagResource = TResource<'style', null>;
17881788
type StyleResource = StyleTagResource | StylesheetResource;
17891789
type ScriptResource = TResource<'script', null>;
17901790
type VoidResource = TResource<'void', null>;
1791-
type Resource = StyleResource | ScriptResource | VoidResource;
1791+
export type Resource = StyleResource | ScriptResource | VoidResource;
17921792

17931793
type LoadingState = number;
17941794
const NotLoaded = /* */ 0b000;
@@ -2170,6 +2170,7 @@ function preinit(href: string, options: PreinitOptions) {
21702170
state.loading |= Errored;
21712171
});
21722172

2173+
state.loading |= Inserted;
21732174
insertStylesheet(instance, precedence, resourceRoot);
21742175
}
21752176

@@ -2518,6 +2519,11 @@ export function acquireResource(
25182519

25192520
markNodeAsHoistable(instance);
25202521
setInitialProperties(instance, 'style', styleProps);
2522+
2523+
// TODO: `style` does not have loading state for tracking insertions. I
2524+
// guess because these aren't suspensey? Not sure whether this is a
2525+
// factoring smell.
2526+
// resource.state.loading |= Inserted;
25212527
insertStylesheet(instance, qualifiedProps.precedence, hoistableRoot);
25222528
resource.instance = instance;
25232529

@@ -2556,6 +2562,7 @@ export function acquireResource(
25562562
linkInstance.onerror = reject;
25572563
});
25582564
setInitialProperties(instance, 'link', stylesheetProps);
2565+
resource.state.loading |= Inserted;
25592566
insertStylesheet(instance, qualifiedProps.precedence, hoistableRoot);
25602567
resource.instance = instance;
25612568

@@ -2604,6 +2611,28 @@ export function acquireResource(
26042611
);
26052612
}
26062613
}
2614+
} else {
2615+
// In the case of stylesheets, they might have already been assigned an
2616+
// instance during `suspendResource`. But that doesn't mean they were
2617+
// inserted, because the commit might have been interrupted. So we need to
2618+
// check now.
2619+
//
2620+
// The other resource types are unaffected because they are not
2621+
// yet suspensey.
2622+
//
2623+
// TODO: This is a bit of a code smell. Consider refactoring how
2624+
// `suspendResource` and `acquireResource` work together. The idea is that
2625+
// `suspendResource` does all the same stuff as `acquireResource` except
2626+
// for the insertion.
2627+
if (
2628+
resource.type === 'stylesheet' &&
2629+
(resource.state.loading & Inserted) === NotLoaded
2630+
) {
2631+
const qualifiedProps: StylesheetQualifyingProps = props;
2632+
const instance: Instance = resource.instance;
2633+
resource.state.loading |= Inserted;
2634+
insertStylesheet(instance, qualifiedProps.precedence, hoistableRoot);
2635+
}
26072636
}
26082637
return resource.instance;
26092638
}
@@ -2613,7 +2642,7 @@ export function releaseResource(resource: Resource): void {
26132642
}
26142643
26152644
function insertStylesheet(
2616-
instance: HTMLElement,
2645+
instance: Element,
26172646
precedence: string,
26182647
root: HoistableRoot,
26192648
): void {

Diff for: packages/react-dom/src/__tests__/ReactDOMFloat-test.js

+18-5
Original file line numberDiff line numberDiff line change
@@ -2939,7 +2939,6 @@ body {
29392939
);
29402940
});
29412941

2942-
// @gate TODO
29432942
it('can interrupt a suspended commit with a new update', async () => {
29442943
function App({children}) {
29452944
return (
@@ -2949,9 +2948,13 @@ body {
29492948
);
29502949
}
29512950
const root = ReactDOMClient.createRoot(document);
2951+
2952+
// Do an initial render. This means subsequent insertions will suspend,
2953+
// unless they are wrapped inside a fresh Suspense boundary.
29522954
root.render(<App />);
29532955
await waitForAll([]);
29542956

2957+
// Insert a stylesheet. This will suspend because it's a transition.
29552958
React.startTransition(() => {
29562959
root.render(
29572960
<App>
@@ -2961,6 +2964,7 @@ body {
29612964
);
29622965
});
29632966
await waitForAll([]);
2967+
// Although the commit suspended, a preload was inserted.
29642968
expect(getMeaningfulChildren(document)).toEqual(
29652969
<html>
29662970
<head>
@@ -2970,6 +2974,9 @@ body {
29702974
</html>,
29712975
);
29722976

2977+
// Before the stylesheet has loaded, do an urgent update. This will insert a
2978+
// different stylesheet, and cancel the first one. This stylesheet will not
2979+
// suspend, even though it hasn't loaded, because it's an urgent update.
29732980
root.render(
29742981
<App>
29752982
hello2
@@ -2978,6 +2985,9 @@ body {
29782985
</App>,
29792986
);
29802987
await waitForAll([]);
2988+
2989+
// The bar stylesheet was inserted. There's still a "foo" preload, even
2990+
// though that update was superseded.
29812991
expect(getMeaningfulChildren(document)).toEqual(
29822992
<html>
29832993
<head>
@@ -2989,9 +2999,10 @@ body {
29892999
</html>,
29903000
);
29913001

2992-
// Even though foo was preloaded we don't see the stylesheet insert because the commit was cancelled.
2993-
// If we do a followup render that tries to recommit that resource it will insert right away because
2994-
// the preload is already loaded
3002+
// When "foo" finishes loading, nothing happens, because "foo" was not
3003+
// included in the last root update. However, if we insert "foo" again
3004+
// later, it should immediately commit without suspending, because it's
3005+
// been preloaded.
29953006
loadPreloads(['foo']);
29963007
assertLog(['load preload: foo']);
29973008
expect(getMeaningfulChildren(document)).toEqual(
@@ -3005,6 +3016,7 @@ body {
30053016
</html>,
30063017
);
30073018

3019+
// Now insert "foo" again.
30083020
React.startTransition(() => {
30093021
root.render(
30103022
<App>
@@ -3015,6 +3027,7 @@ body {
30153027
);
30163028
});
30173029
await waitForAll([]);
3030+
// Commits without suspending because "foo" was preloaded.
30183031
expect(getMeaningfulChildren(document)).toEqual(
30193032
<html>
30203033
<head>
@@ -3023,7 +3036,7 @@ body {
30233036
<link rel="preload" href="foo" as="style" />
30243037
<link rel="preload" href="bar" as="style" />
30253038
</head>
3026-
<body>hello2</body>
3039+
<body>hello3</body>
30273040
</html>,
30283041
);
30293042

Diff for: packages/react-reconciler/src/ReactFiberCommitWork.js

+41-6
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,8 @@ import {
9494
LayoutMask,
9595
PassiveMask,
9696
Visibility,
97-
SuspenseyCommit,
97+
ShouldSuspendCommit,
98+
MaySuspendCommit,
9899
} from './ReactFiberFlags';
99100
import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber';
100101
import {
@@ -4065,12 +4066,23 @@ export function commitPassiveUnmountEffects(finishedWork: Fiber): void {
40654066
resetCurrentDebugFiberInDEV();
40664067
}
40674068

4069+
// If we're inside a brand new tree, or a tree that was already visible, then we
4070+
// should only suspend host components that have a ShouldSuspendCommit flag.
4071+
// Components without it haven't changed since the last commit, so we can skip
4072+
// over those.
4073+
//
4074+
// When we enter a tree that is being revealed (going from hidden -> visible),
4075+
// we need to suspend _any_ component that _may_ suspend. Even if they're
4076+
// already in the "current" tree. Because their visibility has changed, the
4077+
// browser may not have prerendered them yet. So we check the MaySuspendCommit
4078+
// flag instead.
4079+
let suspenseyCommitFlag = ShouldSuspendCommit;
40684080
export function accumulateSuspenseyCommit(finishedWork: Fiber): void {
40694081
accumulateSuspenseyCommitOnFiber(finishedWork);
40704082
}
40714083

40724084
function recursivelyAccumulateSuspenseyCommit(parentFiber: Fiber): void {
4073-
if (parentFiber.subtreeFlags & SuspenseyCommit) {
4085+
if (parentFiber.subtreeFlags & suspenseyCommitFlag) {
40744086
let child = parentFiber.child;
40754087
while (child !== null) {
40764088
accumulateSuspenseyCommitOnFiber(child);
@@ -4083,7 +4095,7 @@ function accumulateSuspenseyCommitOnFiber(fiber: Fiber) {
40834095
switch (fiber.tag) {
40844096
case HostHoistable: {
40854097
recursivelyAccumulateSuspenseyCommit(fiber);
4086-
if (fiber.flags & SuspenseyCommit) {
4098+
if (fiber.flags & suspenseyCommitFlag) {
40874099
if (fiber.memoizedState !== null) {
40884100
suspendResource(
40894101
// This should always be set by visiting HostRoot first
@@ -4101,7 +4113,7 @@ function accumulateSuspenseyCommitOnFiber(fiber: Fiber) {
41014113
}
41024114
case HostComponent: {
41034115
recursivelyAccumulateSuspenseyCommit(fiber);
4104-
if (fiber.flags & SuspenseyCommit) {
4116+
if (fiber.flags & suspenseyCommitFlag) {
41054117
const type = fiber.type;
41064118
const props = fiber.memoizedProps;
41074119
suspendInstance(type, props);
@@ -4117,10 +4129,33 @@ function accumulateSuspenseyCommitOnFiber(fiber: Fiber) {
41174129

41184130
recursivelyAccumulateSuspenseyCommit(fiber);
41194131
currentHoistableRoot = previousHoistableRoot;
4120-
break;
4132+
} else {
4133+
recursivelyAccumulateSuspenseyCommit(fiber);
41214134
}
4135+
break;
4136+
}
4137+
case OffscreenComponent: {
4138+
const isHidden = (fiber.memoizedState: OffscreenState | null) !== null;
4139+
if (isHidden) {
4140+
// Don't suspend in hidden trees
4141+
} else {
4142+
const current = fiber.alternate;
4143+
const wasHidden =
4144+
current !== null &&
4145+
(current.memoizedState: OffscreenState | null) !== null;
4146+
if (wasHidden) {
4147+
// This tree is being revealed. Visit all newly visible suspensey
4148+
// instances, even if they're in the current tree.
4149+
const prevFlags = suspenseyCommitFlag;
4150+
suspenseyCommitFlag = MaySuspendCommit;
4151+
recursivelyAccumulateSuspenseyCommit(fiber);
4152+
suspenseyCommitFlag = prevFlags;
4153+
} else {
4154+
recursivelyAccumulateSuspenseyCommit(fiber);
4155+
}
4156+
}
4157+
break;
41224158
}
4123-
// eslint-disable-next-line-no-fallthrough
41244159
default: {
41254160
recursivelyAccumulateSuspenseyCommit(fiber);
41264161
}

0 commit comments

Comments
 (0)