Skip to content

Commit 8888746

Browse files
authored
Allow transitions to interrupt Suspensey commits (#26531)
I originally made it so that a Suspensey commit — i.e. a commit that's waiting for a stylesheet, image, or font to load before proceeding — could not be interrupted by transitions. My reasoning was that Suspensey commits always time out after a short interval, anyway, so if the incoming update isn't urgent, it's better to wait to commit the current frame instead of throwing it away. I don't think this rationale was correct, for a few reasons. There are some cases where we'll suspend for a longer duration, like stylesheets — it's nearly always a bad idea to show content before its styles have loaded, so we're going to be extend this timeout to be really long. But even in the case where the timeout is shorter, like fonts, if you get a new update, it's possible (even likely) that update will allow us to avoid showing a fallback, like by navigating to a different page. So we might as well try. The behavior now matches our behavior for interrupting a suspended render phase (i.e. `use`), which makes sense because they're not that conceptually different.
1 parent 09c8d25 commit 8888746

File tree

4 files changed

+40
-65
lines changed

4 files changed

+40
-65
lines changed

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

Lines changed: 26 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -3216,7 +3216,7 @@ body {
32163216
);
32173217
});
32183218

3219-
it('can start a new suspended commit after a previous one finishes', async () => {
3219+
it('can interrupt a suspended commit with a new transition', async () => {
32203220
function App({children}) {
32213221
return (
32223222
<html>
@@ -3225,81 +3225,66 @@ body {
32253225
);
32263226
}
32273227
const root = ReactDOMClient.createRoot(document);
3228-
root.render(<App />);
3228+
root.render(<App>(empty)</App>);
3229+
3230+
// Start a transition to "A"
32293231
React.startTransition(() => {
32303232
root.render(
32313233
<App>
3232-
hello
3233-
<link rel="stylesheet" href="foo" precedence="default" />
3234+
A
3235+
<link rel="stylesheet" href="A" precedence="default" />
32343236
</App>,
32353237
);
32363238
});
32373239
await waitForAll([]);
3240+
3241+
// "A" hasn't loaded yet, so we remain on the initial UI. Its preload
3242+
// has been inserted into the head, though.
32383243
expect(getMeaningfulChildren(document)).toEqual(
32393244
<html>
32403245
<head>
3241-
<link rel="preload" href="foo" as="style" />
3246+
<link rel="preload" href="A" as="style" />
32423247
</head>
3243-
<body />
3248+
<body>(empty)</body>
32443249
</html>,
32453250
);
32463251

3252+
// Interrupt the "A" transition with a new one, "B"
32473253
React.startTransition(() => {
32483254
root.render(
32493255
<App>
3250-
hello2
3251-
{null}
3252-
<link rel="stylesheet" href="bar" precedence="default" />
3256+
B
3257+
<link rel="stylesheet" href="B" precedence="default" />
32533258
</App>,
32543259
);
32553260
});
32563261
await waitForAll([]);
3257-
expect(getMeaningfulChildren(document)).toEqual(
3258-
<html>
3259-
<head>
3260-
<link rel="preload" href="foo" as="style" />
3261-
</head>
3262-
<body />
3263-
</html>,
3264-
);
32653262

3266-
loadPreloads();
3267-
loadStylesheets();
3268-
assertLog(['load preload: foo', 'load stylesheet: foo']);
3263+
// Still on the initial UI because "B" hasn't loaded, but its preload
3264+
// is now in the head, too.
32693265
expect(getMeaningfulChildren(document)).toEqual(
32703266
<html>
32713267
<head>
3272-
<link rel="stylesheet" href="foo" data-precedence="default" />
3273-
<link rel="preload" href="foo" as="style" />
3268+
<link rel="preload" href="A" as="style" />
3269+
<link rel="preload" href="B" as="style" />
32743270
</head>
3275-
<body>hello</body>
3271+
<body>(empty)</body>
32763272
</html>,
32773273
);
32783274

3279-
// The second update should process now
3280-
await waitForAll([]);
3281-
expect(getMeaningfulChildren(document)).toEqual(
3282-
<html>
3283-
<head>
3284-
<link rel="stylesheet" href="foo" data-precedence="default" />
3285-
<link rel="preload" href="foo" as="style" />
3286-
<link rel="preload" href="bar" as="style" />
3287-
</head>
3288-
<body>hello</body>
3289-
</html>,
3290-
);
3275+
// Finish loading
32913276
loadPreloads();
32923277
loadStylesheets();
3293-
assertLog(['load preload: bar', 'load stylesheet: bar']);
3278+
assertLog(['load preload: A', 'load preload: B', 'load stylesheet: B']);
3279+
// The "B" transition has finished.
32943280
expect(getMeaningfulChildren(document)).toEqual(
32953281
<html>
32963282
<head>
3297-
<link rel="stylesheet" href="foo" data-precedence="default" />
3298-
<link rel="stylesheet" href="bar" data-precedence="default" />
3299-
<link rel="preload" href="foo" as="style" />
3300-
<link rel="preload" href="bar" as="style" />
3283+
<link rel="stylesheet" href="B" data-precedence="default" />
3284+
<link rel="preload" href="A" as="style" />
3285+
<link rel="preload" href="B" as="style" />
33013286
</head>
3302-
<body>hello2</body>
3287+
<body>B</body>
33033288
</html>,
33043289
);
33053290
});

packages/react-reconciler/src/ReactFiberRootScheduler.js

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import {
1818
SyncLane,
1919
getHighestPriorityLane,
2020
getNextLanes,
21-
includesOnlyNonUrgentLanes,
2221
includesSyncLane,
2322
markStarvedLanesAsExpired,
2423
} from './ReactFiberLane';
@@ -292,14 +291,16 @@ function scheduleTaskForRootDuringMicrotask(
292291

293292
const existingCallbackNode = root.callbackNode;
294293
if (
294+
// Check if there's nothing to work on
295295
nextLanes === NoLanes ||
296296
// If this root is currently suspended and waiting for data to resolve, don't
297297
// schedule a task to render it. We'll either wait for a ping, or wait to
298298
// receive an update.
299-
(isWorkLoopSuspendedOnData() && root === workInProgressRoot) ||
300-
// We should only interrupt a pending commit if the new update
301-
// is urgent.
302-
(root.cancelPendingCommit !== null && includesOnlyNonUrgentLanes(nextLanes))
299+
//
300+
// Suspended render phase
301+
(root === workInProgressRoot && isWorkLoopSuspendedOnData()) ||
302+
// Suspended commit phase
303+
root.cancelPendingCommit !== null
303304
) {
304305
// Fast path: There's nothing to work on.
305306
if (existingCallbackNode !== null) {

packages/react-reconciler/src/ReactFiberWorkLoop.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -725,8 +725,11 @@ export function scheduleUpdateOnFiber(
725725
// Check if the work loop is currently suspended and waiting for data to
726726
// finish loading.
727727
if (
728-
workInProgressSuspendedReason === SuspendedOnData &&
729-
root === workInProgressRoot
728+
// Suspended render phase
729+
(root === workInProgressRoot &&
730+
workInProgressSuspendedReason === SuspendedOnData) ||
731+
// Suspended commit phase
732+
root.cancelPendingCommit !== null
730733
) {
731734
// The incoming update might unblock the current render. Interrupt the
732735
// current attempt and restart from the top.

packages/react-reconciler/src/__tests__/ReactSuspenseyCommitPhase-test.js

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -137,15 +137,15 @@ describe('ReactSuspenseyCommitPhase', () => {
137137
// Nothing showing yet.
138138
expect(root).toMatchRenderedOutput(null);
139139

140-
// If there's an urgent update, it should interrupt the suspended commit.
140+
// If there's an update, it should interrupt the suspended commit.
141141
await act(() => {
142142
root.render(<Text text="Something else" />);
143143
});
144144
assertLog(['Something else']);
145145
expect(root).toMatchRenderedOutput('Something else');
146146
});
147147

148-
test('a non-urgent update does not interrupt a suspended commit', async () => {
148+
test('a transition update interrupts a suspended commit', async () => {
149149
const root = ReactNoop.createRoot();
150150

151151
// Mount an image. This transition will suspend because it's not inside a
@@ -159,26 +159,12 @@ describe('ReactSuspenseyCommitPhase', () => {
159159
// Nothing showing yet.
160160
expect(root).toMatchRenderedOutput(null);
161161

162-
// If there's another transition update, it should not interrupt the
163-
// suspended commit.
162+
// If there's an update, it should interrupt the suspended commit.
164163
await act(() => {
165164
startTransition(() => {
166165
root.render(<Text text="Something else" />);
167166
});
168167
});
169-
// Still suspended.
170-
expect(root).toMatchRenderedOutput(null);
171-
172-
await act(() => {
173-
// Resolving the image should result in an immediate, synchronous commit.
174-
resolveSuspenseyThing('A');
175-
expect(root).toMatchRenderedOutput(<suspensey-thing src="A" />);
176-
});
177-
// Then the second transition is unblocked.
178-
// TODO: Right now the only way to unsuspend a commit early is to proceed
179-
// with the commit even if everything isn't ready. Maybe there should also
180-
// be a way to abort a commit so that it can be interrupted by
181-
// another transition.
182168
assertLog(['Something else']);
183169
expect(root).toMatchRenderedOutput('Something else');
184170
});

0 commit comments

Comments
 (0)