Follow up to #32540.
We do allow gestures to be cancelled early (we call skipTransition) if
the gesture stops before it has even started.
This happens in the fixture when we auto-scroll.
*This API is experimental and subject to change or removal.*
This PR is an alternative to
https://github.com/facebook/react/pull/32421 based on feedback:
https://github.com/facebook/react/pull/32421#pullrequestreview-2625382015
. The difference here is that we traverse from the Fragment's fiber at
operation time instead of keeping a set of children on the
`FragmentInstance`. We still need to handle newly added or removed child
nodes to apply event listeners and observers, so we treat those updates
as effects.
**Fragment Refs**
This PR extends React's Fragment component to accept a `ref` prop. The
Fragment's ref will attach to a custom host instance, which will provide
an Element-like API for working with the Fragment's host parent and host
children.
Here I've implemented `addEventListener`, `removeEventListener`, and
`focus` to get started but we'll be iterating on this by adding
additional APIs in future PRs. This sets up the mechanism to attach refs
and perform operations on children. The FragmentInstance is implemented
in `react-dom` here but is planned for Fabric as well.
The API works by targeting the first level of host children and proxying
Element-like APIs to allow developers to manage groups of elements or
elements that cannot be easily accessed such as from a third-party
library or deep in a tree of Functional Component wrappers.
```javascript
import {Fragment, useRef} from 'react';
const fragmentRef = useRef(null);
<Fragment ref={fragmentRef}>
<div id="A" />
<Wrapper>
<div id="B">
<div id="C" />
</div>
</Wrapper>
<div id="D" />
</Fragment>
```
In this case, calling `fragmentRef.current.addEventListener()` would
apply an event listener to `A`, `B`, and `D`. `C` is skipped because it
is nested under the first level of Host Component. If another Host
Component was appended as a sibling to `A`, `B`, or `D`, the event
listener would be applied to that element as well and any other APIs
would also affect the newly added child.
This is an implementation of the basic feature as a starting point for
feedback and further iteration.
This fixes a critical issue with moveBefore. I was told that the
disconnected -> connected case was going to be relaxed and not be an
error but apparently that is not the case.
This means that we can't use this for initial insertions. Only moves.
Unfortunately React's internals doesn't distinguish these cases. This
adds a hack that checks each nodes but this is pretty bad for
performance. We should only call this in one or the other case.
Given that we still need feature detection. Both of which means that
these calls are no longer inlined and this extra code. I wonder if it's
even worth it given that you can't even rely on it working anyway since
not all browsers have it. Kind of don't want to ship this until all
browsers have it.
Even then we'd ideally refactor React to use separate code paths for
initial insertion vs moves. Which leads to some unfortunate code
duplication.
Otherwise these can survive into the next View Transition and cause
havoc to that transition.
This was appearing as a flash in Safari in the fixture when going from
A->B. This triggers a View Transition and at the same time the scroll
position updates in an effect. That fires a scroll event which starts a
gesture. This shouldn't really happen and the SwipeRecognizer should
ideally ignore those but it's good to surface edge cases. That gesture
is blocked on the View Transition finishing and then immediately after
it starts a gesture View Transition. That gesture then picked up the
former Animation from the previous transition which caused issues. This
PR fixes that flash.
Setting the animation's currentTime causes a quirk where the transition
can end up off by a bit and the end state can be slightly off the end
time.
However, I realized that we don't have to because if we just set the
direction in the `animate()` call directly the Safari bug goes away.
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.
We added support for `onScrollEnd` in #26789 but it only works in Chrome
and Firefox. Safari still doesn't support `scrollend` and there's no
indication that they will anytime soon so this polyfills it.
While I don't particularly love our synthetic event system this tries to
stay within the realm of how our other polyfills work. This implements
all `onScrollEnd` events as a plugin.
The basic principle is to first feature detect the `onscrollend` DOM
property to see if there's native support and otherwise just use the
native event.
Then we listen to `scroll` events and set a timeout. If we don't get any
more scroll events before the timeout we fire `onScrollEnd`. Basically
debouncing it. If we're currently pressing down on touch or a mouse then
we wait until it is lifted such as if you're scrolling with a finger or
using the scrollbars on desktop but isn't currently moving.
If we do get any native events even though we're in polyfilling mode, we
use that as an indication to fire the `onScrollEnd` early.
Part of the motivation is that this becomes extra useful pair for
https://github.com/facebook/react/pull/32422. We also probably need
these events to coincide with other gesture related internals so you're
better off using our polyfill so they're synced.
This adds a `ReactFiberApplyGesture` which is basically intended to be a
fork of the phases in `ReactFiberCommitWork` except for the fake commit
that `useSwipeTransition` does. So far none of the phases are actually
implemented yet. This is just the scaffolding around them so I can fill
them in later.
The important bit is that we call `startViewTransition` (via the
`startGestureTransition` Config) when a gesture starts. We add a paused
animation to prevent the transition from committing (even if the
ScrollTimeline goes to 100%). This also locks the documents so that we
can't commit any other Transitions until it completes.
When the gesture completes (scroll end) then we stop the gesture View
Transition. If there's no new work scheduled we do that immediately but
if there was any new work already scheduled, then we assume that this
will potentially commit the new state. So we wait for that to finish.
This lets us lock the animation in its state instead of snapping back
and then applying the real update.
Using this technique we can't actually run a View Transition from the
current state to the actual committed state because it would snap back
to the beginning and then run the View Transition from there. Therefore
any new commit needs to skip View Transitions even if it should've
technically animated to that state. We assume that the new state is the
same as the optimistic state you already swiped to. An alternative to
this technique could be to commit the optimistic state when we cancel
and then apply any new updates o top of that. I might explore that in
the future.
Regardless it's important that the `action` associated with the swipe
schedules some work before we cancel. Otherwise it risks reverting
first. So I had to update this in the fixture.
For the `useId` algorithm we used colon `:` before and after.
https://github.com/facebook/react/pull/23360
This avoids collisions in general by using an unusual characters. It
also avoids collisions when concatenated with some other ID.
Unfortunately, `:` is not a valid character in `view-transition-name`.
This PR swaps the format from:
```
:r123:
```
To the unicode:
```
«r123»
```
Which is valid CSS selectors. This also allows them being used for
`querySelector()` which we didn't really find a legit use for but seems
ok-ish.
That way you can get a view-transition-name that you can manually
reference. E.g. to generate styles:
```js
const id = useId();
return <>
<style>{`
::view-transition-group(${id}) { ... }
::view-transition-old(${id}) { ... }
::view-transition-new(${id}) { ... }
`}</style>
<ViewTransition name={id}>...</ViewTransition>
</>;
```
Link headers are optionally supported for cases where you prefer to send
resource loading hints before you're ready to send the body of a
request. While many resources can be correctly preloaded from a link
header responsive images are currently not supported and end up
preloading the default src rather than the correctly sized image. Until
responsive images are supported React will not allow these images to
preload as headers and will retain them to preload as HTML.
closes: #32437
Stacked on #32379
Track the range offsets along the timeline where previous/current/next
is. This can also be specified as an option. This lets you model more
than three states along a timeline by clamping them and then updating
the "current" as you go.
It also allows specifying the "current" offset as something different
than what it was when the gesture started such as if it has to start
after scroll has already happened (such as what happens if you listen to
the "scroll" event).
We can only render one direction at a time with View Transitions. When
the direction changes we need to do another render in the new direction
(returning previous or next).
To determine direction we store the position we started at and anything
moving to a lower value (left/up) is "previous" direction (`false`) and
anything else is "next" (`true`) direction.
For the very first render we won't know which direction you're going
since you're still on the initial position. It's useful to start the
render to allow the view transition to take control before anything
shifts around so we start from the original position. This is not
guaranteed though if the render suspends.
For now we start the first render by guessing the direction such as if
we know that prev/next are the same as current. With the upcoming auto
start mode we can guess more accurately there before we start. We can
also add explicit APIs to `startGesture` but ideally it wouldn't matter.
Ideally we could just start after the first change in direction from the
starting point.
followup to
* https://github.com/facebook/react/pull/32069
* https://github.com/facebook/react/pull/32163
* https://github.com/facebook/react/pull/32224
in react-dom in Dev we validate that the tag nesting is valid. This is
motivated primarily because while browsers are tolerant to poor HTML
there are many cases that if server rendered will be hydrated in a way
that will break hydration.
With the changes to singleton scoping where the document body is now the
implicit render/hydration context for arbitrary tags at the root we need
to adjust the validation logic to allow for valid programs such as
rendering divs as a child of a Document (since this div will actually
insert into the body).
While modern DOM implementations all support getRootNode if you are
running React in a runtime which does not the fallback logic which uses
`.ownerDocument` works everywhere except when the container is a
Document itself. This change corrects this by returning the container
intsance if it is a Document type.
3 years ago we partially disabled comment nodes as valid containers.
Some unflagged support was left in due to legacy APIs like
`unmountComponentAtNode` and `unstable_renderSubtreeIntoContainer` but
these were since removed in React 19. This update flags the remaining
uses of comments as containers.
follow up to https://github.com/facebook/react/pull/32163
This continues the work of making Suspense workable anywhere in a
react-dom tree. See the prior PRs for how we handle server rendering and
client rendering. In this change we update the hydration implementation
to be able to locate expected nodes. In particular this means hydration
understands now that the default hydration context is the document body
when the container is above the body.
One case that is unique to hydration is clearing Suspense boundaries.
When hydration fails or when the server instructs the client to recover
an errored boundary it's possible that the html, head, and body tags in
the initial document were written from a fallback or a different primary
content on the server and need to be replaced by the client render.
However these tags (and in the case of head, their content) won't be
inside the comment nodes that identify the bounds of the Suspense
boundary. And when client rendering you may not even render the same
singletons that were server rendered. So when server rendering a
boudnary which contributes to the preamble (the html, head, and body tag
openings plus the head contents) we emit a special marker comment just
before closing the boundary out. This marker encodes which parts of the
preamble this boundary owned. If we need to clear the suspense boundary
on the client we read this marker and use it to reset the appropriate
singleton state.
This is a follow up to https://github.com/facebook/react/pull/32069
In the prior change I updated Fizz to allow you to render Suspense
boundaries at any level within a react-dom application by treating the
document body as the default render scope. This change updates Fiber to
provide similar semantics. Note that this update still does not deliver
hydration so unifying the Fizz and Fiber implementations in a single App
is not possible yet.
The implementation required a rework of the getHostSibling and
getHostParent algorithms. Now most HostSingletons are invisible from a
host positioning perspective. Head is special in that it is a valid host
scope so when you have Placements inside of it, it will act as the
parent. But body, and html, will not directly participate in host
positioning.
Additionally to support flipping to a fallback html, head, and body tag
in a Suspense fallback I updated the offscreen hiding/unhide logic to
pierce through singletons when lookin for matching hidable nod
boundaries anywhere (excluding hydration)
This adds an isomorphic API to add Transition Types, which represent the
cause, to the current Transition. This is currently mainly for View
Transitions but as a concept it's broader and we might expand it to more
features and object types in the future.
```js
import { unstable_addTransitionType as addTransitionType } from 'react';
startTransition(() => {
addTransitionType('my-transition-type');
setState(...);
});
```
If multiple transitions get entangled this is additive and all
Transition Types are collected. You can also add more than one type to a
Transition (hence the `add` prefix).
Transition Types are reset after each commit. Meaning that `<Suspense>`
revealing after a `startTransition` does not get any View Transition
types associated with it.
Note that the scoping rules for this is a little "wrong" in this
implementation. Ideally it would be scoped to the nearest outer
`startTransition` and grouped with any `setState` inside of it.
Including Actions. However, since we currently don't have AsyncContext
on the client, it would be too easy to drop a Transition Type if there
were no other `setState` in the same `await` task. Multiple Transitions
are entangled together anyway right now as a result. So this just tracks
a global of all pending Transition Types for the next Transition. An
inherent tricky bit with this API is that you could update multiple
roots. In that case it should ideally be associated with each root.
Transition Tracing solves this by associating a Transition with any
updates that are later collected but this suffers from the problem
mentioned above. Therefore, I just associate Transition Types with one
root - the first one to commit. Since the View Transitions across roots
are sequential anyway it kind of makes sense that only one really is the
cause and the other one is subsequent.
Transition Types can be used to apply different animations based on what
caused the Transition. You have three different ways to choose from for
how to use them:
## CSS
It integrates with [View Transition
Types](https://www.w3.org/TR/css-view-transitions-2/#active-view-transition-pseudo-examples)
so you can match different animations based on CSS scopes:
```css
:root:active-view-transition-type(my-transition-type) {
&::view-transition-...(...) {
...
}
}
```
This is kind of a PITA to write though and if you have a CSS library
that provide View Transition Classes it's difficult to import those into
these scopes.
## Class per Type
This PR also adds an object-as-map form that can be passed to all
`className` properties:
```js
<ViewTransition className={{
'my-navigation-type': 'hello',
'default': 'world',
}}>
```
If multiple types match, then they're joined together. If no types match
then the special `"default"` entry is used instead. If any type has the
value `"none"` then that wins and the ViewTransition is disabled (not
assigned a name).
These can be combined with `enter`/`exit`/`update`/`layout`/`share`
props to match based on kind of trigger and Transition Type.
```js
<ViewTransition enter={{
'navigation-back': 'enter-right',
'navigation-forward': 'enter-left',
}}
exit={{
'navigation-back': 'exit-right',
'navigation-forward': 'exit-left',
}}>
```
## Events
In addition, you can also observe the types in the View Transition Event
callbacks as the second argument. That way you can pick different
imperative Animations based on the cause.
```js
<ViewTransition onUpdate={(inst, types) => {
if (types.includes('navigation-back')) {
...
} else if (types.includes('navigation-forward')) {
...
} else {
...
}
}}>
```
## Future
In the future we might expose types to `useEffect` for more general
purpose usage. This would also allow non-View Transition based
Animations such as existing libraries to use this same feature to
coordinate the same concept.
We might also allow richer objects to be passed along here. Only the
strings would apply to View Transitions but the imperative code and
effects could do something else with them.
Suspense is meant to be composable but there has been a lonstanding
limitation with using Suspense above the `<body>` tag of an HTML
document due to peculiarities of how HTML is parsed. For instance if you
used Suspense to render an entire HTML document and had a fallback that
might flush an alternate Document the comment nodes which describe this
boundary scope won't be where they need to be in the DOM for client
React to properly hydrate them. This is somewhat a problem of our own
making in that we have a concept of a Preamble and we leave the closing
body and html tags behind until streaming has completed which produces a
valid HTML document that also matches the DOM structure that would be
parsed from it. However Preambles as a concept are too important to
features like Float to imagine moving away from this model and so we can
either choose to just accept that you cannot use Suspense anywhere
except inside the `<body>` or we can build special support for Suspense
into react-dom that has a coherent semantic with how HTML documents are
written and parsed.
This change implements Suspense support for react-dom/server by
correctly serializing boundaries during rendering, prerendering, and
resumgin on the server. It does not yet support Suspense everywhere on
the client but this will arrive in a subsequent change. In practice
Suspense cannot be used above the `<body>` tag today so this is not a
breaking change since no programs in the wild could be using this
feature anyway.
React's streaming rendering of HTML doesn't lend itself to replacing the
contents of the documentElement, head, or body of a Document. These are
already special cased in fiber as HostSingletons and similarly for Fizz
the values we render for these tags must never be updated by the Fizz
runtime once written. To accomplish these we redefine the Preamble as
the tags that represent these three singletons plus the contents of the
document.head. If you use Suspense above any part of the Preamble then
nothing will be written to the destination until the boundary is no
longer pending. If the boundary completes then the preamble from within
that boudnary will be output. If the boundary postpones or errors then
the preamble from the fallback will be used instead.
Additionally, by default anything that is not part of the preamble is
implicitly in body scope. This leads to the somewhat counterintuitive
consequence that the comment nodes we use to mark the borders of a
Suspense boundary in Fizz can appear INSIDE the preamble that was
rendered within it.
```typescript
render((
<Suspense>
<html lang="en">
<body>
<div>hello world</div>
</body>
</html>
</Suspense>
))
```
will produce an HTML document like this
```html
<!DOCTYPE html>
<html lang="en">
<head></head>
<body>
<!--$--> <-- this is the comment Node representing the outermost Suspense
<div>hello world</div>
<$--/$-->
</body>
</html>
```
Later when I update Fiber to support Suspense anywhere hydration will
similarly start implicitly in the document body when the root is part of
the preamble (the document or one of it's singletons).
This adds refs to View Transition that can resolve to an instance of:
```js
type ViewTransitionRef = {
name: string,
group: Animatable,
imagePair: Animatable,
old: Animatable,
new: Animatable,
}
```
Animatable is a type that has `animate(keyframes, options)` and
`getAnimations()` on it. It's the interface that exists on Element that
lets you start animations on it. These ones are like that but for the
four pseudo-elements created by the view transition.
If a name changes, then a new ref is created. That way if you hold onto
a ref during an exit animation spawned by the name change, you can keep
calling functions on it. It will keep referring to the old name rather
than the new name.
This allows imperative control over the animations instead of using CSS
for this.
```js
const viewTransition = ref.current;
const groupAnimation = viewTransition.group.animate(keyframes, options);
const imagePairAnimation = viewTransition.imagePair.animate(keyframes, options);
const oldAnimation = viewTransition.old.animate(keyframes, options);
const newAnimation = viewTransition.new.animate(keyframes, options);
```
The downside of using this API is that it doesn't work with SSR so for
SSR rendered animations they'll fallback to the CSS. You could use this
for progressive enhancement though.
Note: In this PR the ref only controls one DOM node child but there can
be more than one DOM node in the ViewTransition fragment and they are
just left to their defaults. We could try something like making the
`animate()` function apply to multiple children but that could lead to
some weird consequences and the return value would be difficult to
merge. We could try to maintain an array of Animatable that updates with
how ever many things are currently animating but that makes the API more
complicated to use for the simple case. Conceptually this should be like
a fragment so we would ideally combine the multiple children into a
single isolate if we could. Maybe one day the same name could be applied
to multiple children to create a single isolate. For now I think I'll
just leave it like this and you're really expect to just use it with one
DOM node. If you have more than one they just get the default animations
from CSS.
Using this is a little tricky due timing. In this fixture I just use a
layout effect plus rAF to get into the right timing after the
startViewTransition is ready. In the future I'll add an event that fires
when View Transitions heuristics fire with the right timing.
Fonts flickering in while loading can be disturbing to any transition
but especially View Transitions. Even if they don't cause layout thrash
- the paint thrash is bad enough. We might add Suspensey fonts to all
Transitions in the future but it's especially a no-brainer for View
Transitions.
We need to apply mutations to the DOM first to know whether that will
trigger new fonts to load. For general Suspensey fonts, we'd have to
revert the commit by applying mutations in reverse to return to the
previous state. For View Transitions, since a snapshot is already
frozen, we can freeze the screen while we're waiting for the font at no
extra cost. It does mean that the page isn't responsive during this time
but we should only block this for a short period anyway.
The timeout needs to be short enough that it doesn't cause too much of
an issue when it's a new load and slow, yet long enough that you have a
chance to load it. Otherwise we wait for no reason. The assumption here
is that you likely have either cached the font or preloaded it earlier -
or you're on an extremely fast connection. This case is for optimizing
the high end experience.
Before:
https://github.com/user-attachments/assets/e0acfffe-fa49-40d6-82c3-5b08760175fb
After:
https://github.com/user-attachments/assets/615a03d3-9d6b-4eb1-8bd5-182c4c37a628
Note that since the Navigation is blocked on the font now the browser
spinner shows up while the font is loading.
This allows mutations and scrolling in the layout phase to be counted
towards the mutation. This would maybe not be the case for gestures but
it is useful for fire-and-forget.
This also avoids the issue that if you resolve navigation in
useLayoutEffect that it ends up dead locked.
It also means that useLayoutEffect does not observe the scroll
restoration and in fact, the scroll restoration would win over any
manual scrolling in layout effects. For better or worse, this is more in
line with how things worked before and how it works in popstate. So it's
less of a breaking change. This does mean that we can't unify the after
mutation phase with the layout phase though.
To do this we need split out flushSpawnedWork from the flushLayoutEffect
call.
Spawned work from setState inside the layout phase is done outside and
not counted towards the transition. They're sync updates and so are not
eligible for their own View Transitions. It's also tricky to support
this since it's unclear what things like exits in that update would
mean. This work will still be able to mutate the live DOM but it's just
not eligible to trigger new transitions or adjust the target of those.
One difference between popstate is that this spawned work is after
scroll restoration. So any scrolling spawned from a second pass would
now win over scroll restoration.
Another consequence of this change is that you can't safely animate
pseudo elements in useLayoutEffect. We'll introduce a better event for
that anyway.
This adds navigation support to the View Transition fixture using both
`history.pushState/popstate` and the Navigation API models.
Because `popstate` does scroll restoration synchronously at the end of
the event, but `startViewTransition` cannot start synchronously, it
would observe the "old" state as after applying scroll restoration. This
leads to weird artifacts. So we intentionally do not support View
Transitions in `popstate`. If it suspends anyway for some other reason,
then scroll restoration is broken anyway and then it is supported. We
don't have to do anything here because this is already how things worked
because the sync `popstate` special case already included the sync lane
which opts it out of View Transitions.
For the Navigation API, scroll restoration can be blocked. The best way
to do this is to resolve the Navigation API promise after React has
applied its mutation. We can detect if there's currently any pending
navigation and wait to resolve the `startViewTransition` until it
finishes and any scroll restoration has been applied.
https://github.com/user-attachments/assets/f53b3282-6315-4513-b3d6-b8981d66964e
There is a subtle thing here. If we read the viewport metrics before
scroll restoration has been applied, then we might assume something is
or isn't going to be within the viewport incorrectly. This is evident on
the "Slide In from Left" example. When we're going forward to that page
we shift the scroll position such that it's going to appear in the
viewport. If we did this before applying scroll restoration, it would
not animate because it wasn't in the viewport then. Therefore, we need
to run the after mutation phase after scroll restoration.
A consequence of this is that you have to resolve Navigation in
`useInsertionEffect` as otherwise it leads to a deadlock (which
eventually gets broken by `startViewTransition`'s timeout of 10
seconds). Another consequence is that now `useLayoutEffect` observes the
restored state. However, I think what we'll likely do is move the layout
phase to before the after mutation phase which also ensures that
auto-scrolling inside `useLayoutEffect` are considered in the viewport
measurements as well.
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.
Stacked on #31975.
We're going to recommend that the primary way you style a View
Transition is using a View Transition Class (and/or Type). These are
only available in the View Transitions v2 spec. When they're not
available it's better to fallback to just not animating instead of
animating with the wrong styling rules applied.
This is already widely supported in Chrome and Safari 18.2. Safari 18.2
usage is still somewhat low but it's rolling out quickly as we speak.
A way to detect this is by just passing the object form to
`startViewTransition` which throws if it's an earlier version. The
object form is required for `types` but luckily classes rolled out at
the same time. Therefore we're only indirectly detecting class support.
This means that in practice Safari 18.0 and 18.1 won't animate. We could
try to only apply the feature detection if you're actually using classes
or types, but that would create an unfortunate ecosystem burden to try
to support names. It also leads to flaky effects when only some
animations work. Better to just disable them all.
Firefox has yet to ship anything. We'll have to look out for how the
feature detection happens there and if they roll things out in different
order but if you ship late, you deal with web compat as the ball lies.
Stacked on #31975.
This is the primary way we recommend styling your View Transitions since
it allows for reusable styling such as a CSS library specializing in
View Transitions in a way that's composable and without naming
conflicts. E.g.
```js
<ViewTransition className="enter-slide-in exit-fade-out update-cross-fade">
```
This doesn't change the HTML `class` attribute. It's not a CSS class.
Instead it assign the `view-transition-class` style prop of the
underlying DOM node while it's transitioning.
You can also just use `<div style={{viewTransitionClass: ...}}>` on the
DOM node but it's convenient to control the Transition completely from
the outside and conceptually we're transitioning the whole fragment. You
can even make Transition components that just wraps existing components.
`<RevealTransition><Component /></RevealTransition>` this way.
Since you can also have multiple wrappers for different circumstances it
allows React's heuristics to use different classes for different
scenarios. We'll likely add more options like configuring different
classes for different `types` or scenarios that can't be described by
CSS alone.
## CSS Modules
```js
import transitions from './transitions.module.css';
<ViewTransition className={transitions.bounceIn}>...</ViewTransition>
```
CSS Modules works well with this strategy because you can have globally
unique namespaces and define your transitions in the CSS modules as a
library that you can import. [As seen in the fixture
here.](https://github.com/facebook/react/commit/8b91b37bb8b4add5f3f8be5ef8f49bb23966b13b#diff-b4d9854171ffdac4d2c01be92a5eff4f8e9e761e6af953094f99ca243b054a85R11)
I did notice an unfortunate bug in how CSS Modules (at least in Webpack)
generates class names. Sometimes the `+` character is used in the hash
of the class name which is not valid for `view-transition-class` and so
it breaks. I had to rename my class names until the hash yielded
something different to work around it. Ideally that bug gets fixed soon.
## className, rly?
`className` isn't exactly the most loved property name, however, I'm
using `className` here too for consistency. Even though in this case
there's no direct equivalent DOM property name. The CSS property is
named `viewTransitionClass`, but the "viewTransition" prefix is implied
by the Component it is on in this case. For most people the fact that
this is actually a different namespace than other CSS classes doesn't
matter. You'll most just use a CSS library anyway and conceptually
you're just assigning classes the same way as `className` on a DOM node.
But if we ever rename the `class` prop then we can do that for this one
as well.
This will provide the opt-in for using [View
Transitions](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API)
in React.
View Transitions only trigger for async updates like `startTransition`,
`useDeferredValue`, Actions or `<Suspense>` revealing from fallback to
content. Synchronous updates provide an opt-out but also guarantee that
they commit immediately which View Transitions can't.
There's no need to opt-in to View Transitions at the "cause" side like
event handlers or actions. They don't know what UI will change and
whether that has an animated transition described.
Conceptually the `<ViewTransition>` component is like a DOM fragment
that transitions its children in its own isolate/snapshot. The API works
by wrapping a DOM node or inner component:
```js
import {ViewTransition} from 'react';
<ViewTransition><Component /></ViewTransition>
```
The default is `name="auto"` which will automatically assign a
`view-transition-name` to the inner DOM node. That way you can add a
View Transition to a Component without controlling its DOM nodes styling
otherwise.
A difference between this and the browser's built-in
`view-transition-name: auto` is that switching the DOM nodes within the
`<ViewTransition>` component preserves the same name so this example
cross-fades between the DOM nodes instead of causing an exit and enter:
```js
<ViewTransition>{condition ? <ComponentA /> : <ComponentB />}</ViewTransition>
```
This becomes especially useful with `<Suspense>` as this example
cross-fades between Skeleton and Content:
```js
<ViewTransition>
<Suspense fallback={<Skeleton />}>
<Content />
</Suspense>
</ViewTransition>
```
Where as this example triggers an exit of the Skeleton and an enter of
the Content:
```js
<Suspense fallback={<ViewTransition><Skeleton /></ViewTransition>}>
<ViewTransition><Content /></ViewTransition>
</Suspense>
```
Managing instances and keys becomes extra important.
You can also specify an explicit `name` property for example for
animating the same conceptual item from one page onto another. However,
best practices is to property namespace these since they can easily
collide. It's also useful to add an `id` to it if available.
```js
<ViewTransition name="my-shared-view">
```
The model in general is the same as plain `view-transition-name` except
React manages a set of heuristics for when to apply it. A problem with
the naive View Transitions model is that it overly opts in every
boundary that *might* transition into transitioning. This is leads to
unfortunate effects like things floating around when unrelated updates
happen. This leads the whole document to animate which means that
nothing is clickable in the meantime. It makes it not useful for smaller
and more local transitions. Best practice is to add
`view-transition-name` only right before you're about to need to animate
the thing. This is tricky to manage globally on complex apps and is not
compositional. Instead we let React manage when a `<ViewTransition>`
"activates" and add/remove the `view-transition-name`. This is also when
React calls `startViewTransition` behind the scenes while it mutates the
DOM.
I've come up with a number of heuristics that I think will make a lot
easier to coordinate this. The principle is that only if something that
updates that particular boundary do we activate it. I hope that one day
maybe browsers will have something like these built-in and we can remove
our implementation.
A `<ViewTransition>` only activates if:
- If a mounted Component renders a `<ViewTransition>` within it outside
the first DOM node, and it is within the viewport, then that
ViewTransition activates as an "enter" animation. This avoids inner
"enter" animations trigger when the parent mounts.
- If an unmounted Component had a `<ViewTransition>` within it outside
the first DOM node, and it was within the viewport, then that
ViewTransition activates as an "exit" animation. This avoids inner
"exit" animations triggering when the parent unmounts.
- If an explicitly named `<ViewTransition name="...">` is deep within an
unmounted tree and one with the same name appears in a mounted tree at
the same time, then both are activated as a pair, but only if they're
both in the viewport. This avoids these triggering "enter" or "exit"
animations when going between parents that don't have a pair.
- If an already mounted `<ViewTransition>` is visible and a DOM
mutation, that might affect how it's painted, happens within its
children but outside any nested `<ViewTransition>`. This allows it to
"cross-fade" between its updates.
- If an already mounted `<ViewTransition>` resizes or moves as the
result of direct DOM nodes siblings changing or moving around. This
allows insertion, deletion and reorders into a list to animate all
children. It is only within one DOM node though, to avoid unrelated
changes in the parent to trigger this. If an item is outside the
viewport before and after, then it's skipped to avoid things flying
across the screen.
- If a `<ViewTransition>` boundary changes size, due to a DOM mutation
within it, then the parent activates (or the root document if there are
no more parents). This ensures that the container can cross-fade to
avoid abrupt relayout. This can be avoided by using absolutely
positioned children. When this can avoid bubbling to the root document,
whatever is not animating is still responsive to clicks during the
transition.
Conceptually each DOM node has its own default that activates the parent
`<ViewTransition>` or no transition if the parent is the root. That
means that if you add a DOM node like `<div><ViewTransition><Component
/></ViewTransition></div>` this won't trigger an "enter" animation since
it was the div that was added, not the ViewTransition. Instead, it might
cause a cross-fade of the parent ViewTransition or no transition if it
had no parent. This ensures that only explicit boundaries perform coarse
animations instead of every single node which is really the benefit of
the View Transitions model. This ends up working out well for simple
cases like switching between two pages immediately while transitioning
one floating item that appears on both pages. Because only the floating
item transitions by default.
Note that it's possible to add manual `view-transition-name` with CSS or
`style={{ viewTransitionName: 'auto' }}` that always transitions as long
as something else has a `<ViewTransition>` that activates. For example a
`<ViewTransition>` can wrap a whole page for a cross-fade but inside of
it an explicit name can be added to something to ensure it animates as a
move when something relates else changes its layout. Instead of just
cross-fading it along with the Page which would be the default.
There's more PRs coming with some optimizations, fixes and expanded
APIs. This first PR explores the above core heuristic.
---------
Co-authored-by: Sebastian "Sebbie" Silbermann <silbermann.sebastian@gmail.com>
To avoid GC pressure and accidentally hanging onto old trees Suspense
boundary retries are now implemented in the commit phase. I used the
Callback flag which was previously only used to schedule callbacks for
Class components. This isn't quite semantically equivalent but it's
unused and seemingly compatible.
When streaming SSR while hydrating React will wait for Suspense
boundaries to be revealed by the SSR stream before attempting to hydrate
them. The rationale here is that the Server render is likely further
ahead of whatever the client would produce so waiting to let the server
stream in the UI is preferable to retrying on the client and possibly
delaying how quickly the primary content becomes available. However If
the connection closes early (user hits stop for instance) or there is a
server error which prevents additional HTML from being delivered to the
client this can put React into a broken state where the boundary never
resolves nor errors and the hydration never retries that boundary
freezing it in it's fallback state.
Once the document has fully loaded we know there is not way any
additional Suspense boundaries can arrive. This update changes react-dom
on the client to schedule client renders for any unfinished Suspense
boundaries upon document loading.
The technique for client rendering a fallback is pretty straight
forward. When hydrating a Suspense boundary if the Document is in
'complete' readyState we interpret pending boundaries as fallback
boundaries. If the readyState is not 'complete' we register an event to
retry the boundary when the DOMContentLoaded event fires.
To test this I needed JSDOM to model readyState. We previously had a
temporary implementation of readyState for SSR streaming but I ended up
implementing this as a mock of JSDOM that implements a fake readyState
that is mutable. It starts off in 'loading' readyState and you can
advance it by mutating document.readyState. You can also reset it to
'loading'. It fires events when changing states.
This seems like the least invasive way to get closer-to-real-browser
behavior in a way that won't require remembering this subtle detail
every time you create a test that asserts Suspense resolution order.
A long standing issue for React has been that if you reorder stateful
nodes, they may lose their state and reload. The thing moving loses its
state. There's no way to solve this in general where two stateful nodes
swap.
The [`moveBefore()`
proposal](https://chromestatus.com/feature/5135990159835136?gate=5177450351558656)
has now moved to
[intent-to-ship](https://groups.google.com/a/chromium.org/g/blink-dev/c/YE_xLH6MkRs/m/_7CD0NYMAAAJ).
This function is kind of like `insertBefore` but preserves state.
There's [a demo here](https://state-preserving-atomic-move.glitch.me/).
Ideally we'd port this demo to a fixture so we can try it.
Currently this flag is always off - even in experimental. That's because
this is still behind a Chrome flag so it's a little early to turn it on
even in experimental. So you need a custom build. It's on in RN but only
because it doesn't apply there which makes it easier to tell that it's
safe to ship once it's on everywhere else.
The other reason it's still off is because there's currently a semantic
breaking change. `moveBefore()` errors if both nodes are disconnected.
That happens if we're inside a completely disconnected React root.
That's not usually how you should use React because it means effects
can't read layout etc. However, it is currently supported. To handle
this we'd have to try/catch the `moveBefore` to handle this case but we
hope this semantic will change before it ships. Before we turn this on
in experimental we either have to wait for the implementation to not
error in the disconnected-disconnected case in Chrome or we'd have to
add try/catch.
## Summary
This fixes a typo in the error that gets reported when Float errors
while hoisting a style tag that does not contain both `precedence` and
`href`. There was a typo in _conflict_ and the last part of the sentence
doesn't make sense. I assume it wasn't needed since the message already
suggests moving the style tag to the head manually.
When you schedule a microtask from render or effect and then call
setState (or ping) from there, the "event" is the event that React
scheduled (which will be a postMessage). The event time of this new
render will be before the last render finished.
We usually clamp these but in this scenario the update doesn't happen
while a render is happening. Causing overlapping events.
Before:
<img width="1229" alt="Screenshot 2024-11-12 at 11 01 30 PM"
src="https://github.com/user-attachments/assets/9652cf3b-b358-453c-b295-1239cbb15952">
Therefore when we finalize a render we need to store the end of the last
render so when we a new update comes in later with an event time earlier
than that, we know to clamp it.
There's also a special case here where when we enter the
`RootDidNotComplete` or `RootSuspendedWithDelay` case we neither leave
the root as in progress nor commit it. Those needs to finalize too.
Really this should be modeled as a suspended track that we haven't added
yet. That's the gap between "Blocked" and "message" below.
After:
<img width="1471" alt="Screenshot 2024-11-13 at 12 31 34 AM"
src="https://github.com/user-attachments/assets/b24f994e-9055-4b10-ad29-ad9b36302ffc">
I also fixed an issue where we may log the same event name multiple
times if we're rendering more than once in the same event. In this case
I just leave a blank trace between the last commit and the next update.
I also adding ignoring of the "message" event at all in these cases when
the event is from React's scheduling itself.
This tracks the current window.event.timeStamp the first time we
setState or call startTransition. For either the blocking track or
transition track. We can use this to show how long we were blocked by
other events or overhead from when the user interacted until we got
called into React.
Then we track the time we start awaiting a Promise returned from
startTransition. We can use this track how long we waited on an Action
to complete before setState was called.
Then finally we track when setState was called so we can track how long
we were blocked by other word before we could actually start rendering.
For a Transition this might be blocked by Blocking React render work.
We only log these once a subsequent render actually happened. If no
render was actually scheduled, then we don't log these. E.g. if an
isomorphic Action doesn't call startTransition there's no render so we
don't log it.
We only log the first event/update/transition even if multiple are
batched into it later. If multiple Actions are entangled they're all
treated as one until an update happens. If no update happens and all
entangled actions finish, we clear the transition so that the next time
a new sequence starts we can log it.
We also clamp these (start the track later) if they were scheduled
within a render/commit. Since we share a single track we don't want to
create overlapping tracks.
The purpose of this is not to show every event/action that happens but
to show a prelude to how long we were blocked before a render started.
So you can follow the first event to commit.
<img width="674" alt="Screenshot 2024-09-20 at 1 59 58 AM"
src="https://github.com/user-attachments/assets/151ba9e8-6b3c-4fa1-9f8d-e3602745eeb7">
I still need to add the rendering/suspended phases to the timeline which
why this screenshot has a gap.
<img width="993" alt="Screenshot 2024-09-20 at 12 50 27 AM"
src="https://github.com/user-attachments/assets/155b6675-b78a-4a22-a32b-212c15051074">
In this case it's a Form Action which started a render into the form
which then suspended on the action. The action then caused a refresh,
which interrupts with its own update that's blocked before rendering.
Suspended roots like this is interesting because we could in theory
start working on a different root in the meantime which makes this
timeline less linear.
Rewrite `containerInfo?.ownerDocument?.defaultView ?? window` to instead
use a ternary.
This changes the compilation output (see [bundle changes from
#30951](https://github.com/facebook/react/commit/d65fb06955e9f32e6a40d1c7177d77893dff95b9)).
```js
// compilation of containerInfo?.ownerDocument?.defaultView ?? window
var $jscomp$optchain$tmpm1756096108$1, $jscomp$nullish$tmp0;
containerInfo =
null !=
($jscomp$nullish$tmp0 =
null == containerInfo
? void 0
: null ==
($jscomp$optchain$tmpm1756096108$1 = containerInfo.ownerDocument)
? void 0
: $jscomp$optchain$tmpm1756096108$1.defaultView)
? $jscomp$nullish$tmp0
: window;
// compilation of ternary expression
containerInfo =
null != containerInfo &&
null != containerInfo.ownerDocument &&
null != containerInfo.ownerDocument.defaultView
? containerInfo.ownerDocument.defaultView
: window;
```
This also reduces the number of no-op bundle syncs for Meta. Note that
Closure compiler's `jscomp$optchain$tmp<HASH>` identifiers change when
we rebuild (likely due to version number changes). See
[workflow](https://github.com/facebook/react/actions/runs/10891164281/job/30221518374)
for a PR that was synced despite making no changes to the runtime.