Skip to content

Commit 98418e8

Browse files
authored
[Fiber] Suspend the commit while we wait for the previous View Transition to finish (#32002)
Stacked on #31975. View Transitions cannot handle interruptions in that if you start a new one before the previous one has finished, it just stops and then restarts. It doesn't seamlessly transition into the new transition. This is generally considered a bad thing but I actually think it's quite good for fire-and-forget animations (gestures is another story). There are too many examples of bad animations in fast interactions because the scenario wasn't predicted. Like overlapping toasts or stacked layers that look bad. The only case interrupts tend to work well is when you do a strict reversal of an animation like returning to the page you just left or exiting a modal just being opened. However, we're limited by the platform even in that regard. I think one reason interruptions have traditionally been seen as good is because it's hard if you have a synchronous framework to not interrupt since your application state has already moved on. We don't have that limitation since we can suspend commits. We can do all the work to prepare for the next commit by rendering while the animation is going but then delay the commit until the previous one finishes. Another technical limitation earlier animation libraries suffered from is only have the option to either interrupt or sequence animations since it's modeling just one change set. Like showing one toast at a time. That's bad. We don't have that limitation because we can interrupt a previously suspended commit and start working on a new one instead. That's what we do for suspended transitions in general. The net effect is that we batch the commits. Therefore if you get multiple toasts flying in fast, they can animate as a batch in together all at once instead of overlapping slightly or being staggered. Interruptions (often) bad. Staggered animations bad. Batched animations good. This PR stashes the currently active View Transition with an expando on the container that's animating (currently always document). This is similar to what we do with event handlers etc. We reason we do this with an expando is that if you have multiple Reacts on the same page they need to wait for each other. However, one of those might also be the SSR runtime. So this lets us wait for the SSR runtime's animations to finish before starting client ones. This could really be a more generic name since this should ideally be shared across frameworks. It's kind of strange that this property doesn't already exist in the DOM given that there can only be one. It would be useful to be able to coordinate this across libraries.
1 parent 38127b2 commit 98418e8

File tree

9 files changed

+55
-5
lines changed

9 files changed

+55
-5
lines changed

packages/react-art/src/ReactFiberConfigART.js

+2
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,8 @@ export function startSuspendingCommit() {}
542542

543543
export function suspendInstance(type, props) {}
544544

545+
export function suspendOnActiveViewTransition(container) {}
546+
545547
export function waitForCommitToBeReady() {
546548
return null;
547549
}

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

+28-1
Original file line numberDiff line numberDiff line change
@@ -1219,8 +1219,14 @@ export function startViewTransition(
12191219
},
12201220
types: null, // TODO: Provide types.
12211221
});
1222+
// $FlowFixMe[prop-missing]
1223+
ownerDocument.__reactViewTransition = transition;
12221224
transition.ready.then(layoutCallback, layoutCallback);
1223-
transition.finished.then(passiveCallback);
1225+
transition.finished.then(() => {
1226+
// $FlowFixMe[prop-missing]
1227+
ownerDocument.__reactViewTransition = null;
1228+
passiveCallback();
1229+
});
12241230
return true;
12251231
} catch (x) {
12261232
// We use the error as feature detection.
@@ -3706,6 +3712,27 @@ export function suspendResource(
37063712
}
37073713
}
37083714

3715+
export function suspendOnActiveViewTransition(rootContainer: Container): void {
3716+
if (suspendedState === null) {
3717+
throw new Error(
3718+
'Internal React Error: suspendedState null when it was expected to exists. Please report this as a React bug.',
3719+
);
3720+
}
3721+
const state = suspendedState;
3722+
const ownerDocument =
3723+
rootContainer.nodeType === DOCUMENT_NODE
3724+
? rootContainer
3725+
: rootContainer.ownerDocument;
3726+
// $FlowFixMe[prop-missing]
3727+
const activeViewTransition = ownerDocument.__reactViewTransition;
3728+
if (activeViewTransition == null) {
3729+
return;
3730+
}
3731+
state.count++;
3732+
const ping = onUnsuspend.bind(state);
3733+
activeViewTransition.finished.then(ping, ping);
3734+
}
3735+
37093736
export function waitForCommitToBeReady(): null | ((() => void) => () => void) {
37103737
if (suspendedState === null) {
37113738
throw new Error(

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

+2
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,8 @@ export function startSuspendingCommit(): void {}
551551

552552
export function suspendInstance(type: Type, props: Props): void {}
553553

554+
export function suspendOnActiveViewTransition(container: Container): void {}
555+
554556
export function waitForCommitToBeReady(): null {
555557
return null;
556558
}

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

+2
Original file line numberDiff line numberDiff line change
@@ -637,6 +637,8 @@ export function startSuspendingCommit(): void {}
637637

638638
export function suspendInstance(type: Type, props: Props): void {}
639639

640+
export function suspendOnActiveViewTransition(container: Container): void {}
641+
640642
export function waitForCommitToBeReady(): null {
641643
return null;
642644
}

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

+4
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,10 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
643643
);
644644
},
645645

646+
suspendOnActiveViewTransition(container: Container): void {
647+
// Not implemented
648+
},
649+
646650
waitForCommitToBeReady,
647651

648652
NotPendingTransition: (null: TransitionStatus),

packages/react-reconciler/src/ReactFiberWorkLoop.js

+12-4
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ import {
8585
noTimeout,
8686
afterActiveInstanceBlur,
8787
startSuspendingCommit,
88+
suspendOnActiveViewTransition,
8889
waitForCommitToBeReady,
8990
preloadInstance,
9091
preloadResource,
@@ -1393,19 +1394,26 @@ function commitRootWhenReady(
13931394
// one after the other.
13941395
const BothVisibilityAndMaySuspendCommit = Visibility | MaySuspendCommit;
13951396
const subtreeFlags = finishedWork.subtreeFlags;
1396-
if (
1397+
const isViewTransitionEligible =
1398+
enableViewTransition && includesOnlyViewTransitionEligibleLanes(lanes); // TODO: Use a subtreeFlag to optimize.
1399+
const maySuspendCommit =
13971400
subtreeFlags & ShouldSuspendCommit ||
13981401
(subtreeFlags & BothVisibilityAndMaySuspendCommit) ===
1399-
BothVisibilityAndMaySuspendCommit
1400-
) {
1402+
BothVisibilityAndMaySuspendCommit;
1403+
if (isViewTransitionEligible || maySuspendCommit) {
14011404
// Before committing, ask the renderer whether the host tree is ready.
14021405
// If it's not, we'll wait until it notifies us.
14031406
startSuspendingCommit();
14041407
// This will walk the completed fiber tree and attach listeners to all
14051408
// the suspensey resources. The renderer is responsible for accumulating
14061409
// all the load events. This all happens in a single synchronous
14071410
// transaction, so it track state in its own module scope.
1408-
accumulateSuspenseyCommit(finishedWork);
1411+
if (maySuspendCommit) {
1412+
accumulateSuspenseyCommit(finishedWork);
1413+
}
1414+
if (isViewTransitionEligible) {
1415+
suspendOnActiveViewTransition(root.containerInfo);
1416+
}
14091417
// At the end, ask the renderer if it's ready to commit, or if we should
14101418
// suspend. If it's not ready, it will return a callback to subscribe to
14111419
// a ready event.

packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js

+1
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ describe('ReactFiberHostContext', () => {
102102
},
103103
startSuspendingCommit() {},
104104
suspendInstance(type, props) {},
105+
suspendOnActiveViewTransition(container) {},
105106
waitForCommitToBeReady() {
106107
return null;
107108
},

packages/react-reconciler/src/forks/ReactFiberConfig.custom.js

+2
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ export const maySuspendCommit = $$$config.maySuspendCommit;
8585
export const preloadInstance = $$$config.preloadInstance;
8686
export const startSuspendingCommit = $$$config.startSuspendingCommit;
8787
export const suspendInstance = $$$config.suspendInstance;
88+
export const suspendOnActiveViewTransition =
89+
$$$config.suspendOnActiveViewTransition;
8890
export const waitForCommitToBeReady = $$$config.waitForCommitToBeReady;
8991
export const NotPendingTransition = $$$config.NotPendingTransition;
9092
export const HostTransitionContext = $$$config.HostTransitionContext;

packages/react-test-renderer/src/ReactFiberConfigTestHost.js

+2
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,8 @@ export function startSuspendingCommit(): void {}
425425

426426
export function suspendInstance(type: Type, props: Props): void {}
427427

428+
export function suspendOnActiveViewTransition(container: Container): void {}
429+
428430
export function waitForCommitToBeReady(): null {
429431
return null;
430432
}

0 commit comments

Comments
 (0)