Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

During a Swipe Gesture Render a Clone Offscreen and Animate it Onscreen #32500

Merged
merged 8 commits into from
Mar 5, 2025

Conversation

sebmarkbage
Copy link
Collaborator

This is really the essence mechanism of the useSwipeTransition feature.

We don't want to immediately switch to the destination state when starting a gesture. The effects remain mounted on the current state. We want the current state to be "live". This is important to for example allow a video to keeping playing while starting a swipe (think TikTok/Reels) and not stop until you've committed the action. The only thing that can be live is the "new" state. Therefore we treat the destination as the "old" state and perform a reverse animation from there.

Ideally we could apply the old state to the DOM tree, take a snapshot and then revert it back in the mutation of startViewTransition. Unfortunately, the way startViewTransition was designed it always paints one frame of the "old" state which would lead this to cause a flicker.

To work around this, we need to create a clone of any View Transition boundary that might be mutated and then render that offscreen. That way we can render the "current" state on screen and the "destination" state offscreen for the screenshots. Being mutated can be either due to React doing a DOM mutation or if a child boundary resizes that causes the parent to relayout. We don't have to do this for insertions or deletions since they only appear on one side.

The worst case scenario is that we have to clone the whole root. That's what this first PR implements. We clone the container and if it's not absolutely positioned, we position it on top of the current one. If the container is document or <html> we instead clone the <body> tag since it's the only one we can insert a duplicate of. If the container is deep in the tree we clone just that even though technically we should probably clone the whole document in that case. We just keep the impact smaller. Ideally though we'd never hit this case. In fact, if we clone the document we issue a warning (always for now) since you probably should optimize this. In the future I intend to add optimizations when affected View Transition boundaries are absolutely positioned since they cannot possibly relayout the parent. This would be the ideal way to use this feature most efficiently but it still works without it.

Since we render the "old" state outside the viewport, we need to then adjust the animation to put it back into the viewport. This is the trickiest part to get right while still preserving any customization of the View Transitions done using CSS. This current approach reapplies all the animations with adjusted keyframes.

In the case of an "exit" the pseudo-element itself is positioned outside the viewport but since we can't programmatically update the style of the pseudo-element itself we instead adjust all the keyframes to put it back into the viewport. If there is no animation on the group we add one.

In the case of an "update" the pseudo-element is positioned on the new state which is already inside the viewport. However, the auto-generated animation of the group has a starting keyframe that starts outside the viewport. In this case we need to adjust that keyframe.

In the future I might explore a technique that inserts stylesheets instead of mutating the animations. It might be simpler. But whatever hacks work to maximize the compatibility is best.

@sebmarkbage sebmarkbage requested a review from jackpope March 2, 2025 22:49
@github-actions github-actions bot added the React Core Team Opened by a member of the React Core Team label Mar 2, 2025
@react-sizebot
Copy link

react-sizebot commented Mar 2, 2025

Comparing: 2980f27...c70ce90

Critical size changes

Includes critical production bundles, as well as any change greater than 2%:

Name +/- Base Current +/- gzip Base gzip Current gzip
oss-stable/react-dom/cjs/react-dom.production.js = 6.68 kB 6.68 kB = 1.83 kB 1.83 kB
oss-stable/react-dom/cjs/react-dom-client.production.js +0.02% 518.24 kB 518.35 kB = 92.43 kB 92.42 kB
oss-experimental/react-dom/cjs/react-dom.production.js = 6.69 kB 6.69 kB = 1.83 kB 1.83 kB
oss-experimental/react-dom/cjs/react-dom-client.production.js +2.80% 570.57 kB 586.56 kB +2.80% 101.56 kB 104.40 kB
facebook-www/ReactDOM-prod.classic.js +0.15% 638.06 kB 639.01 kB +0.12% 112.28 kB 112.41 kB
facebook-www/ReactDOM-prod.modern.js +0.15% 628.38 kB 629.33 kB +0.12% 110.70 kB 110.84 kB
oss-experimental/react-dom/cjs/react-dom-unstable_testing.production.js +2.73% 585.30 kB 601.29 kB +2.75% 105.12 kB 108.01 kB
oss-experimental/react-dom/cjs/react-dom-profiling.profiling.js +2.54% 626.05 kB 641.97 kB +2.57% 110.30 kB 113.14 kB

Significant size changes

Includes any change greater than 0.2%:

Expand to show
Name +/- Base Current +/- gzip Base gzip Current gzip
oss-experimental/react-dom/cjs/react-dom-client.production.js +2.80% 570.57 kB 586.56 kB +2.80% 101.56 kB 104.40 kB
oss-experimental/react-dom/cjs/react-dom-unstable_testing.production.js +2.73% 585.30 kB 601.29 kB +2.75% 105.12 kB 108.01 kB
oss-experimental/react-dom/cjs/react-dom-profiling.profiling.js +2.54% 626.05 kB 641.97 kB +2.57% 110.30 kB 113.14 kB
oss-experimental/react-dom/cjs/react-dom-client.development.js +1.95% 1,045.95 kB 1,066.31 kB +2.05% 175.18 kB 178.77 kB
oss-experimental/react-dom/cjs/react-dom-profiling.development.js +1.92% 1,062.35 kB 1,082.71 kB +2.02% 178.03 kB 181.63 kB
oss-experimental/react-dom/cjs/react-dom-unstable_testing.development.js +1.92% 1,062.87 kB 1,083.23 kB +2.01% 178.90 kB 182.49 kB
oss-stable-semver/react-noop-renderer/cjs/react-noop-renderer.production.js +1.38% 36.99 kB 37.50 kB +0.87% 6.92 kB 6.98 kB
oss-stable/react-noop-renderer/cjs/react-noop-renderer.production.js +1.38% 37.02 kB 37.53 kB +0.82% 6.95 kB 7.01 kB
oss-experimental/react-noop-renderer/cjs/react-noop-renderer.production.js +1.37% 37.02 kB 37.53 kB +0.82% 6.95 kB 7.01 kB
oss-stable-semver/react-noop-renderer/cjs/react-noop-renderer-persistent.production.js +1.37% 37.12 kB 37.63 kB +0.86% 6.94 kB 7.00 kB
oss-stable/react-noop-renderer/cjs/react-noop-renderer-persistent.production.js +1.37% 37.14 kB 37.65 kB +0.82% 6.97 kB 7.03 kB
oss-experimental/react-noop-renderer/cjs/react-noop-renderer-persistent.production.js +1.37% 37.15 kB 37.66 kB +0.83% 6.97 kB 7.03 kB
oss-experimental/react-reconciler/cjs/react-reconciler.production.js +1.34% 438.36 kB 444.24 kB +1.16% 70.77 kB 71.59 kB
oss-stable-semver/react-noop-renderer/cjs/react-noop-renderer.development.js +1.31% 41.22 kB 41.76 kB +0.83% 7.51 kB 7.58 kB
oss-stable/react-noop-renderer/cjs/react-noop-renderer.development.js +1.31% 41.24 kB 41.78 kB +0.78% 7.54 kB 7.60 kB
oss-experimental/react-noop-renderer/cjs/react-noop-renderer.development.js +1.31% 41.25 kB 41.79 kB +0.78% 7.55 kB 7.61 kB
oss-stable-semver/react-noop-renderer/cjs/react-noop-renderer-persistent.development.js +1.30% 41.36 kB 41.90 kB +0.81% 7.53 kB 7.59 kB
oss-stable/react-noop-renderer/cjs/react-noop-renderer-persistent.development.js +1.30% 41.38 kB 41.92 kB +0.77% 7.56 kB 7.62 kB
oss-experimental/react-noop-renderer/cjs/react-noop-renderer-persistent.development.js +1.30% 41.39 kB 41.93 kB +0.77% 7.57 kB 7.62 kB
oss-experimental/react-reconciler/cjs/react-reconciler.development.js +1.20% 728.43 kB 737.20 kB +1.23% 114.98 kB 116.39 kB
oss-experimental/react-reconciler/cjs/react-reconciler.profiling.js +1.20% 491.32 kB 497.20 kB +1.07% 78.64 kB 79.48 kB
test_utils/ReactAllWarnings.js +1.03% 63.52 kB 64.17 kB +1.14% 15.90 kB 16.08 kB

Generated by 🚫 dangerJS against 0fb9fcf

Copy link
Contributor

@jackpope jackpope left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is super cool. Fixture looks good and performs nicely during a slow swipe/cancel. I'm excited to play with it some more.

Lots here and lots of additional todos, so lets get this unblocked and start to gather feedback.

I am curious about the perf of large trees with the cloning approach but maybe we just need to see some real usage first.

Comment on lines +107 to +108
// Only insert clones if this tree is going to be visible. No need to
// clone invisible content.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related, could it be worth optimizing by only cloning nodes within the viewport? I'm not sure what the tradeoff would be with cost of that detection and any layout implications, but it seems like the clone could get heavy for large trees.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, that's a good point that I hadn't really considered. The tricky part is that many siblings add up to compute layout that ends up placing nodes that are in the viewport. Part of the need to clone something in the first place is to allow layout to be computed on a whole tree. However there may be various other heuristics that can combine to exclude some trees.

A simple one might be if a cloned child is itself positioned absolutely and is out of the viewport then we don't really need it. The problem is that to know that we'd have to call getComputedStyle on every single node and that can become expensive.

A similar trade of is that we currently just call cloneNode(true) and let the native side of the browser do the work. A benefit of this is that the cloning itself is faster but if we also don't touch the DOM node, there's no JS wrapper allocated around it. It exists only in the C++ layer which is itself more efficient too. That does mean that we can't just mess with arbitrary subtrees as easily though.

One major issue is that for updates we can know that a "current" cloned tree was outside the viewport. But we don't yet know if we the new resulting layout will be inside the viewport. We need to do the clone before we compute that new layout. If one of them are within the viewport we still need to compute both sides so they can transition between.

The only case we can know is a "current" node that gets deleted. We know its position and whether it'll have a pair. If it's outside the viewport and is a pure exit, we can skip it. But this doesn't matter because those are already not cloned.

So I'm not sure if we can effectively apply this anywhere really.

I think in general the solution is just to try to isolate updates to absolutely positioned trees.

Comment on lines +1229 to +1230
// If the container is not the whole document, then we ideally should probably
// clone the whole document outside of the React too.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whats the downside of only cloning the subtree affected? Seems like it would be more efficient. Is it that there could be parents affecting layout?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea. If the root container itself changes size and that causes parents to relayout then really the parent tree should cross-fade. That's what happens with fire-and-forget View Transitions.

@sebmarkbage sebmarkbage merged commit e9252bc into facebook:main Mar 5, 2025
194 checks passed
github-actions bot pushed a commit that referenced this pull request Mar 5, 2025
…en (#32500)

This is really the essence mechanism of the `useSwipeTransition`
feature.

We don't want to immediately switch to the destination state when
starting a gesture. The effects remain mounted on the current state. We
want the current state to be "live". This is important to for example
allow a video to keeping playing while starting a swipe (think
TikTok/Reels) and not stop until you've committed the action. The only
thing that can be live is the "new" state. Therefore we treat the
destination as the "old" state and perform a reverse animation from
there.

Ideally we could apply the old state to the DOM tree, take a snapshot
and then revert it back in the mutation of `startViewTransition`.
Unfortunately, the way `startViewTransition` was designed it always
paints one frame of the "old" state which would lead this to cause a
flicker.

To work around this, we need to create a clone of any View Transition
boundary that might be mutated and then render that offscreen. That way
we can render the "current" state on screen and the "destination" state
offscreen for the screenshots. Being mutated can be either due to React
doing a DOM mutation or if a child boundary resizes that causes the
parent to relayout. We don't have to do this for insertions or deletions
since they only appear on one side.

The worst case scenario is that we have to clone the whole root. That's
what this first PR implements. We clone the container and if it's not
absolutely positioned, we position it on top of the current one. If the
container is `document` or `<html>` we instead clone the `<body>` tag
since it's the only one we can insert a duplicate of. If the container
is deep in the tree we clone just that even though technically we should
probably clone the whole document in that case. We just keep the impact
smaller. Ideally though we'd never hit this case. In fact, if we clone
the document we issue a warning (always for now) since you probably
should optimize this. In the future I intend to add optimizations when
affected View Transition boundaries are absolutely positioned since they
cannot possibly relayout the parent. This would be the ideal way to use
this feature most efficiently but it still works without it.

Since we render the "old" state outside the viewport, we need to then
adjust the animation to put it back into the viewport. This is the
trickiest part to get right while still preserving any customization of
the View Transitions done using CSS. This current approach reapplies all
the animations with adjusted keyframes.

In the case of an "exit" the pseudo-element itself is positioned outside
the viewport but since we can't programmatically update the style of the
pseudo-element itself we instead adjust all the keyframes to put it back
into the viewport. If there is no animation on the group we add one.

In the case of an "update" the pseudo-element is positioned on the new
state which is already inside the viewport. However, the auto-generated
animation of the group has a starting keyframe that starts outside the
viewport. In this case we need to adjust that keyframe.

In the future I might explore a technique that inserts stylesheets
instead of mutating the animations. It might be simpler. But whatever
hacks work to maximize the compatibility is best.

DiffTrain build for [e9252bc](e9252bc)
github-actions bot pushed a commit that referenced this pull request Mar 5, 2025
…en (#32500)

This is really the essence mechanism of the `useSwipeTransition`
feature.

We don't want to immediately switch to the destination state when
starting a gesture. The effects remain mounted on the current state. We
want the current state to be "live". This is important to for example
allow a video to keeping playing while starting a swipe (think
TikTok/Reels) and not stop until you've committed the action. The only
thing that can be live is the "new" state. Therefore we treat the
destination as the "old" state and perform a reverse animation from
there.

Ideally we could apply the old state to the DOM tree, take a snapshot
and then revert it back in the mutation of `startViewTransition`.
Unfortunately, the way `startViewTransition` was designed it always
paints one frame of the "old" state which would lead this to cause a
flicker.

To work around this, we need to create a clone of any View Transition
boundary that might be mutated and then render that offscreen. That way
we can render the "current" state on screen and the "destination" state
offscreen for the screenshots. Being mutated can be either due to React
doing a DOM mutation or if a child boundary resizes that causes the
parent to relayout. We don't have to do this for insertions or deletions
since they only appear on one side.

The worst case scenario is that we have to clone the whole root. That's
what this first PR implements. We clone the container and if it's not
absolutely positioned, we position it on top of the current one. If the
container is `document` or `<html>` we instead clone the `<body>` tag
since it's the only one we can insert a duplicate of. If the container
is deep in the tree we clone just that even though technically we should
probably clone the whole document in that case. We just keep the impact
smaller. Ideally though we'd never hit this case. In fact, if we clone
the document we issue a warning (always for now) since you probably
should optimize this. In the future I intend to add optimizations when
affected View Transition boundaries are absolutely positioned since they
cannot possibly relayout the parent. This would be the ideal way to use
this feature most efficiently but it still works without it.

Since we render the "old" state outside the viewport, we need to then
adjust the animation to put it back into the viewport. This is the
trickiest part to get right while still preserving any customization of
the View Transitions done using CSS. This current approach reapplies all
the animations with adjusted keyframes.

In the case of an "exit" the pseudo-element itself is positioned outside
the viewport but since we can't programmatically update the style of the
pseudo-element itself we instead adjust all the keyframes to put it back
into the viewport. If there is no animation on the group we add one.

In the case of an "update" the pseudo-element is positioned on the new
state which is already inside the viewport. However, the auto-generated
animation of the group has a starting keyframe that starts outside the
viewport. In this case we need to adjust that keyframe.

In the future I might explore a technique that inserts stylesheets
instead of mutating the animations. It might be simpler. But whatever
hacks work to maximize the compatibility is best.

DiffTrain build for [e9252bc](e9252bc)
github-actions bot pushed a commit to dislido/react that referenced this pull request Mar 5, 2025
…en (facebook#32500)

This is really the essence mechanism of the `useSwipeTransition`
feature.

We don't want to immediately switch to the destination state when
starting a gesture. The effects remain mounted on the current state. We
want the current state to be "live". This is important to for example
allow a video to keeping playing while starting a swipe (think
TikTok/Reels) and not stop until you've committed the action. The only
thing that can be live is the "new" state. Therefore we treat the
destination as the "old" state and perform a reverse animation from
there.

Ideally we could apply the old state to the DOM tree, take a snapshot
and then revert it back in the mutation of `startViewTransition`.
Unfortunately, the way `startViewTransition` was designed it always
paints one frame of the "old" state which would lead this to cause a
flicker.

To work around this, we need to create a clone of any View Transition
boundary that might be mutated and then render that offscreen. That way
we can render the "current" state on screen and the "destination" state
offscreen for the screenshots. Being mutated can be either due to React
doing a DOM mutation or if a child boundary resizes that causes the
parent to relayout. We don't have to do this for insertions or deletions
since they only appear on one side.

The worst case scenario is that we have to clone the whole root. That's
what this first PR implements. We clone the container and if it's not
absolutely positioned, we position it on top of the current one. If the
container is `document` or `<html>` we instead clone the `<body>` tag
since it's the only one we can insert a duplicate of. If the container
is deep in the tree we clone just that even though technically we should
probably clone the whole document in that case. We just keep the impact
smaller. Ideally though we'd never hit this case. In fact, if we clone
the document we issue a warning (always for now) since you probably
should optimize this. In the future I intend to add optimizations when
affected View Transition boundaries are absolutely positioned since they
cannot possibly relayout the parent. This would be the ideal way to use
this feature most efficiently but it still works without it.

Since we render the "old" state outside the viewport, we need to then
adjust the animation to put it back into the viewport. This is the
trickiest part to get right while still preserving any customization of
the View Transitions done using CSS. This current approach reapplies all
the animations with adjusted keyframes.

In the case of an "exit" the pseudo-element itself is positioned outside
the viewport but since we can't programmatically update the style of the
pseudo-element itself we instead adjust all the keyframes to put it back
into the viewport. If there is no animation on the group we add one.

In the case of an "update" the pseudo-element is positioned on the new
state which is already inside the viewport. However, the auto-generated
animation of the group has a starting keyframe that starts outside the
viewport. In this case we need to adjust that keyframe.

In the future I might explore a technique that inserts stylesheets
instead of mutating the animations. It might be simpler. But whatever
hacks work to maximize the compatibility is best.

DiffTrain build for [e9252bc](facebook@e9252bc)
github-actions bot pushed a commit to dislido/react that referenced this pull request Mar 5, 2025
…en (facebook#32500)

This is really the essence mechanism of the `useSwipeTransition`
feature.

We don't want to immediately switch to the destination state when
starting a gesture. The effects remain mounted on the current state. We
want the current state to be "live". This is important to for example
allow a video to keeping playing while starting a swipe (think
TikTok/Reels) and not stop until you've committed the action. The only
thing that can be live is the "new" state. Therefore we treat the
destination as the "old" state and perform a reverse animation from
there.

Ideally we could apply the old state to the DOM tree, take a snapshot
and then revert it back in the mutation of `startViewTransition`.
Unfortunately, the way `startViewTransition` was designed it always
paints one frame of the "old" state which would lead this to cause a
flicker.

To work around this, we need to create a clone of any View Transition
boundary that might be mutated and then render that offscreen. That way
we can render the "current" state on screen and the "destination" state
offscreen for the screenshots. Being mutated can be either due to React
doing a DOM mutation or if a child boundary resizes that causes the
parent to relayout. We don't have to do this for insertions or deletions
since they only appear on one side.

The worst case scenario is that we have to clone the whole root. That's
what this first PR implements. We clone the container and if it's not
absolutely positioned, we position it on top of the current one. If the
container is `document` or `<html>` we instead clone the `<body>` tag
since it's the only one we can insert a duplicate of. If the container
is deep in the tree we clone just that even though technically we should
probably clone the whole document in that case. We just keep the impact
smaller. Ideally though we'd never hit this case. In fact, if we clone
the document we issue a warning (always for now) since you probably
should optimize this. In the future I intend to add optimizations when
affected View Transition boundaries are absolutely positioned since they
cannot possibly relayout the parent. This would be the ideal way to use
this feature most efficiently but it still works without it.

Since we render the "old" state outside the viewport, we need to then
adjust the animation to put it back into the viewport. This is the
trickiest part to get right while still preserving any customization of
the View Transitions done using CSS. This current approach reapplies all
the animations with adjusted keyframes.

In the case of an "exit" the pseudo-element itself is positioned outside
the viewport but since we can't programmatically update the style of the
pseudo-element itself we instead adjust all the keyframes to put it back
into the viewport. If there is no animation on the group we add one.

In the case of an "update" the pseudo-element is positioned on the new
state which is already inside the viewport. However, the auto-generated
animation of the group has a starting keyframe that starts outside the
viewport. In this case we need to adjust that keyframe.

In the future I might explore a technique that inserts stylesheets
instead of mutating the animations. It might be simpler. But whatever
hacks work to maximize the compatibility is best.

DiffTrain build for [e9252bc](facebook@e9252bc)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
CLA Signed React Core Team Opened by a member of the React Core Team
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants