Skip to content

Commit

Permalink
Add <ViewTransition> Component (#31975)
Browse files Browse the repository at this point in the history
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 <[email protected]>
  • Loading branch information
sebmarkbage and eps1lon authored Jan 8, 2025
1 parent e30c669 commit a4d122f
Show file tree
Hide file tree
Showing 57 changed files with 9,473 additions and 98 deletions.
30 changes: 30 additions & 0 deletions fixtures/view-transition/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# View Transition

A test case for View Transitions.

## Setup

To reference a local build of React, first run `npm run build` at the root
of the React project. Then:

```
cd fixtures/view-transition
yarn
yarn start
```

The `start` command runs a webpack dev server and a server-side rendering server in development mode with hot reloading.

**Note: whenever you make changes to React and rebuild it, you need to re-run `yarn` in this folder:**

```
yarn
```

If you want to try the production mode instead run:

```
yarn start:prod
```

This will pre-build all static resources and then start a server-side rendering HTTP server that hosts the React app and service the static resources (without hot reloading).
28 changes: 28 additions & 0 deletions fixtures/view-transition/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"name": "react-fixtures-view-transition",
"version": "0.1.0",
"private": true,
"devDependencies": {
"concurrently": "3.1.0",
"http-proxy-middleware": "0.17.3",
"react-scripts": "0.9.5"
},
"dependencies": {
"express": "^4.14.0",
"ignore-styles": "^5.0.1",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"scripts": {
"predev": "cp -r ../../build/oss-experimental/* ./node_modules/",
"prestart": "cp -r ../../build/oss-experimental/* ./node_modules/",
"prebuild": "cp -r ../../build/oss-experimental/* ./node_modules/",
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
"dev:client": "PORT=3001 react-scripts start",
"dev:server": "NODE_ENV=development node server",
"start": "react-scripts build && NODE_ENV=production node server",
"build": "react-scripts build",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject"
}
}
Binary file added fixtures/view-transition/public/favicon.ico
Binary file not shown.
13 changes: 13 additions & 0 deletions fixtures/view-transition/public/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!doctype html>
<html>
<body>
<script>
/*
This is just a placeholder to make react-scripts happy.
We're not using it. If we end up here, redirect to the
primary server.
*/
location.href = '//localhost:3000/';
</script>
</body>
</html>
70 changes: 70 additions & 0 deletions fixtures/view-transition/server/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
require('ignore-styles');
const babelRegister = require('babel-register');
const proxy = require('http-proxy-middleware');

babelRegister({
ignore: /\/(build|node_modules)\//,
presets: ['react-app'],
});

const express = require('express');
const path = require('path');

const app = express();

// Application
if (process.env.NODE_ENV === 'development') {
app.get('/', function (req, res) {
// In development mode we clear the module cache between each request to
// get automatic hot reloading.
for (var key in require.cache) {
delete require.cache[key];
}
const render = require('./render').default;
render(req.url, res);
});
} else {
const render = require('./render').default;
app.get('/', function (req, res) {
render(req.url, res);
});
}

// Static resources
app.use(express.static(path.resolve(__dirname, '..', 'build')));

// Proxy everything else to create-react-app's webpack development server
if (process.env.NODE_ENV === 'development') {
app.use(
'/',
proxy({
ws: true,
target: 'http://localhost:3001',
})
);
}

app.listen(3000, () => {
console.log('Listening on port 3000...');
});

app.on('error', function (error) {
if (error.syscall !== 'listen') {
throw error;
}

var bind = typeof port === 'string' ? 'Pipe ' + port : 'Port ' + port;

switch (error.code) {
case 'EACCES':
console.error(bind + ' requires elevated privileges');
process.exit(1);
break;
case 'EADDRINUSE':
console.error(bind + ' is already in use');
process.exit(1);
break;
default:
throw error;
}
});
44 changes: 44 additions & 0 deletions fixtures/view-transition/server/render.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import React from 'react';
import {renderToPipeableStream} from 'react-dom/server';

import App from '../src/components/App';

let assets;
if (process.env.NODE_ENV === 'development') {
// Use the bundle from create-react-app's server in development mode.
assets = {
'main.js': '/static/js/bundle.js',
// 'main.css': '',
};
} else {
assets = require('../build/asset-manifest.json');
}

export default function render(url, res) {
res.socket.on('error', error => {
// Log fatal errors
console.error('Fatal', error);
});
let didError = false;
const {pipe, abort} = renderToPipeableStream(<App assets={assets} />, {
bootstrapScripts: [assets['main.js']],
onShellReady() {
// If something errored before we started streaming, we set the error code appropriately.
res.statusCode = didError ? 500 : 200;
res.setHeader('Content-type', 'text/html');
pipe(res);
},
onShellError(x) {
// Something errored before we could complete the shell so we emit an alternative shell.
res.statusCode = 500;
res.send('<!doctype><p>Error</p>');
},
onError(x) {
didError = true;
console.error(x);
},
});
// Abandon and switch to client rendering after 5 seconds.
// Try lowering this to see the client recover.
setTimeout(abort, 5000);
}
12 changes: 12 additions & 0 deletions fixtures/view-transition/src/components/App.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import React from 'react';

import Chrome from './Chrome';
import Page from './Page';

export default function App({assets}) {
return (
<Chrome title="Hello World" assets={assets}>
<Page />
</Chrome>
);
}
5 changes: 5 additions & 0 deletions fixtures/view-transition/src/components/Chrome.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
body {
margin: 10px;
padding: 0;
font-family: sans-serif;
}
33 changes: 33 additions & 0 deletions fixtures/view-transition/src/components/Chrome.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import React, {Component} from 'react';

import './Chrome.css';

export default class Chrome extends Component {
render() {
const assets = this.props.assets;
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="shortcut icon" href="favicon.ico" />
<link rel="stylesheet" href={assets['main.css']} />
<title>{this.props.title}</title>
</head>
<body>
<noscript
dangerouslySetInnerHTML={{
__html: `<b>Enable JavaScript to run this app.</b>`,
}}
/>
{this.props.children}
<script
dangerouslySetInnerHTML={{
__html: `assetManifest = ${JSON.stringify(assets)};`,
}}
/>
</body>
</html>
);
}
}
Empty file.
79 changes: 79 additions & 0 deletions fixtures/view-transition/src/components/Page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import React, {
unstable_ViewTransition as ViewTransition,
startTransition,
useEffect,
useState,
unstable_Activity as Activity,
} from 'react';

import './Page.css';

const a = (
<div key="a">
<ViewTransition>
<div>a</div>
</ViewTransition>
</div>
);

const b = (
<div key="b">
<ViewTransition>
<div>b</div>
</ViewTransition>
</div>
);

export default function Page() {
const [show, setShow] = useState(false);
useEffect(() => {
startTransition(() => {
setShow(true);
});
}, []);
const exclamation = (
<ViewTransition name="exclamation">
<span>!</span>
</ViewTransition>
);
return (
<div>
<button
onClick={() => {
startTransition(() => {
setShow(show => !show);
});
}}>
{show ? 'A' : 'B'}
</button>
<ViewTransition>
<div>
{show ? (
<div>
{a}
{b}
</div>
) : (
<div>
{b}
{a}
</div>
)}
<ViewTransition>
{show ? <div>hello{exclamation}</div> : <section>Loading</section>}
</ViewTransition>
{show ? null : (
<ViewTransition>
<div>world{exclamation}</div>
</ViewTransition>
)}
<Activity mode={show ? 'visible' : 'hidden'}>
<ViewTransition>
<div>!!</div>
</ViewTransition>
</Activity>
</div>
</ViewTransition>
</div>
);
}
6 changes: 6 additions & 0 deletions fixtures/view-transition/src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import React from 'react';
import {hydrateRoot} from 'react-dom/client';

import App from './components/App';

hydrateRoot(document, <App assets={window.assetManifest} />);
Loading

0 comments on commit a4d122f

Please sign in to comment.