[Fiber] Use className on <ViewTransition> to assign view-transition-class (#31999)

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.](8b91b37bb8 (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 commit is contained in:
Sebastian Markbåge 2025-01-08 13:22:06 -05:00 committed by GitHub
parent a4d122f2d1
commit 3a5496b3f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 8395 additions and 5500 deletions

View File

@ -4,15 +4,23 @@
"private": true,
"devDependencies": {
"concurrently": "3.1.0",
"http-proxy-middleware": "0.17.3",
"react-scripts": "0.9.5"
"http-proxy-middleware": "3.0.3",
"react-scripts": "5.0.1",
"@babel/plugin-proposal-private-property-in-object": "7.21.11"
},
"dependencies": {
"@babel/register": "^7.25.9",
"express": "^4.14.0",
"ignore-styles": "^5.0.1",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"scripts": {
"predev": "cp -r ../../build/oss-experimental/* ./node_modules/",
"prestart": "cp -r ../../build/oss-experimental/* ./node_modules/",
@ -24,5 +32,17 @@
"build": "react-scripts build",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

View File

@ -1,9 +1,9 @@
require('ignore-styles');
const babelRegister = require('babel-register');
const babelRegister = require('@babel/register');
const proxy = require('http-proxy-middleware');
babelRegister({
ignore: /\/(build|node_modules)\//,
ignore: [/\/(build|node_modules)\//],
presets: ['react-app'],
});
@ -37,9 +37,10 @@ app.use(express.static(path.resolve(__dirname, '..', 'build')));
if (process.env.NODE_ENV === 'development') {
app.use(
'/',
proxy({
proxy.createProxyMiddleware({
ws: true,
target: 'http://localhost:3001',
changeOrigin: true,
target: 'http://127.0.0.1:3001',
})
);
}

View File

@ -11,7 +11,7 @@ if (process.env.NODE_ENV === 'development') {
// 'main.css': '',
};
} else {
assets = require('../build/asset-manifest.json');
assets = require('../build/asset-manifest.json').files;
}
export default function render(url, res) {

View File

@ -8,6 +8,8 @@ import React, {
import './Page.css';
import transitions from './Transitions.module.css';
const a = (
<div key="a">
<ViewTransition>
@ -24,6 +26,17 @@ const b = (
</div>
);
function Component() {
return (
<ViewTransition
className={
transitions['enter-slide-right'] + ' ' + transitions['exit-slide-left']
}>
<p>Slide In from Left, Slide Out to Right</p>
</ViewTransition>
);
}
export default function Page() {
const [show, setShow] = useState(false);
useEffect(() => {
@ -72,6 +85,7 @@ export default function Page() {
<div>!!</div>
</ViewTransition>
</Activity>
{show ? <Component /> : <p>&nbsp;</p>}
</div>
</ViewTransition>
</div>

View File

@ -0,0 +1,28 @@
@keyframes enter-slide-right {
0% {
opacity: 0;
translate: -200px 0;
}
100% {
opacity: 1;
translate: 0 0;
}
}
@keyframes exit-slide-left {
0% {
opacity: 1;
translate: 0 0;
}
100% {
opacity: 0;
translate: 200px 0;
}
}
::view-transition-new(.enter-slide-right):only-child {
animation: enter-slide-right ease-in 0.25s;
}
::view-transition-old(.exit-slide-left):only-child {
animation: exit-slide-left ease-in 0.25s;
}

File diff suppressed because it is too large Load Diff

View File

@ -455,7 +455,7 @@ export function unhideTextInstance(textInstance, text): void {
// Noop
}
export function applyViewTransitionName(instance, name) {
export function applyViewTransitionName(instance, name, className) {
// Noop
}

View File

@ -124,6 +124,8 @@ export type Props = {
display?: string,
viewTransitionName?: string,
'view-transition-name'?: string,
viewTransitionClass?: string,
'view-transition-class'?: string,
...
},
bottom?: null | number,
@ -987,10 +989,15 @@ export function unhideTextInstance(
export function applyViewTransitionName(
instance: Instance,
name: string,
className: ?string,
): void {
instance = ((instance: any): HTMLElement);
// $FlowFixMe[prop-missing]
instance.style.viewTransitionName = name;
if (className != null) {
// $FlowFixMe[prop-missing]
instance.style.viewTransitionClass = className;
}
}
export function restoreViewTransitionName(
@ -1000,7 +1007,7 @@ export function restoreViewTransitionName(
instance = ((instance: any): HTMLElement);
const styleProp = props[STYLE];
const viewTransitionName =
styleProp !== undefined && styleProp !== null
styleProp != null
? styleProp.hasOwnProperty('viewTransitionName')
? styleProp.viewTransitionName
: styleProp.hasOwnProperty('view-transition-name')
@ -1014,6 +1021,21 @@ export function restoreViewTransitionName(
: // The value would've errored already if it wasn't safe.
// eslint-disable-next-line react-internal/safe-string-coercion
('' + viewTransitionName).trim();
const viewTransitionClass =
styleProp != null
? styleProp.hasOwnProperty('viewTransitionClass')
? styleProp.viewTransitionClass
: styleProp.hasOwnProperty('view-transition-class')
? styleProp['view-transition-class']
: null
: null;
// $FlowFixMe[prop-missing]
instance.style.viewTransitionClass =
viewTransitionClass == null || typeof viewTransitionClass === 'boolean'
? ''
: // The value would've errored already if it wasn't safe.
// eslint-disable-next-line react-internal/safe-string-coercion
('' + viewTransitionClass).trim();
}
export function cancelViewTransitionName(

View File

@ -525,6 +525,7 @@ export function unhideInstance(instance: Instance, props: Props): void {
export function applyViewTransitionName(
instance: Instance,
name: string,
className: ?string,
): void {
// Not yet implemented
}

View File

@ -732,7 +732,11 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
textInstance.hidden = false;
},
applyViewTransitionName(instance: Instance, name: string): void {},
applyViewTransitionName(
instance: Instance,
name: string,
className: ?string,
): void {},
restoreViewTransitionName(instance: Instance, props: Props): void {},

View File

@ -545,6 +545,7 @@ let viewTransitionHostInstanceIdx = 0;
function applyViewTransitionToHostInstances(
child: null | Fiber,
name: string,
className: ?string,
collectMeasurements: null | Array<InstanceMeasurement>,
stopAtNestedViewTransitions: boolean,
): boolean {
@ -574,6 +575,7 @@ function applyViewTransitionToHostInstances(
: // If we have multiple Host Instances below, we add a suffix to the name to give
// each one a unique name.
name + '_' + viewTransitionHostInstanceIdx,
className,
);
viewTransitionHostInstanceIdx++;
} else if (
@ -592,6 +594,7 @@ function applyViewTransitionToHostInstances(
applyViewTransitionToHostInstances(
child.child,
name,
className,
collectMeasurements,
stopAtNestedViewTransitions,
)
@ -664,6 +667,7 @@ function commitAppearingPairViewTransitions(placement: Fiber): void {
const inViewport = applyViewTransitionToHostInstances(
child.child,
props.name,
props.className,
null,
false,
);
@ -686,14 +690,13 @@ function commitAppearingPairViewTransitions(placement: Fiber): void {
function commitEnterViewTransitions(placement: Fiber): void {
if (placement.tag === ViewTransitionComponent) {
const name = getViewTransitionName(
placement.memoizedProps,
placement.stateNode,
);
const props: ViewTransitionProps = placement.memoizedProps;
const name = getViewTransitionName(props, placement.stateNode);
viewTransitionHostInstanceIdx = 0;
const inViewport = applyViewTransitionToHostInstances(
placement.child,
name,
props.className,
null,
false,
);
@ -747,6 +750,7 @@ function commitDeletedPairViewTransitions(
const inViewport = applyViewTransitionToHostInstances(
child.child,
name,
props.className,
null,
false,
);
@ -787,6 +791,7 @@ function commitExitViewTransitions(
const inViewport = applyViewTransitionToHostInstances(
deletion.child,
name,
props.className,
null,
false,
);
@ -840,11 +845,13 @@ function commitBeforeUpdateViewTransition(current: Fiber): void {
// be unexpected but it is in line with the semantics that the ViewTransition is its
// own layer that cross-fades its content when it updates. If you want to reorder then
// each child needs its own ViewTransition.
const name = getViewTransitionName(current.memoizedProps, current.stateNode);
const props: ViewTransitionProps = current.memoizedProps;
const name = getViewTransitionName(props, current.stateNode);
viewTransitionHostInstanceIdx = 0;
applyViewTransitionToHostInstances(
current.child,
name,
props.className,
(current.memoizedState = []),
true,
);
@ -856,11 +863,13 @@ function commitNestedViewTransitions(changedParent: Fiber): void {
if (child.tag === ViewTransitionComponent) {
// In this case the outer ViewTransition component wins but if there
// was an update through this component then the inner one wins.
const name = getViewTransitionName(child.memoizedProps, child.stateNode);
const props: ViewTransitionProps = child.memoizedProps;
const name = getViewTransitionName(props, child.stateNode);
viewTransitionHostInstanceIdx = 0;
applyViewTransitionToHostInstances(
child.child,
name,
props.className,
(child.memoizedState = []),
false,
);
@ -1002,9 +1011,10 @@ function measureViewTransitionHostInstances(
parentViewTransition.flags |= AffectedParentLayout;
}
if ((parentViewTransition.flags & Update) !== NoFlags) {
const props: ViewTransitionProps = parentViewTransition.memoizedProps;
// We might update this node so we need to apply its new name for the new state.
const newName = getViewTransitionName(
parentViewTransition.memoizedProps,
props,
parentViewTransition.stateNode,
);
applyViewTransitionName(
@ -1014,6 +1024,7 @@ function measureViewTransitionHostInstances(
: // If we have multiple Host Instances below, we add a suffix to the name to give
// each one a unique name.
newName + '_' + viewTransitionHostInstanceIdx,
props.className,
);
}
if (!inViewport || (parentViewTransition.flags & Update) === NoFlags) {

View File

@ -18,6 +18,7 @@ import {getTreeId} from './ReactFiberTreeContext';
export type ViewTransitionProps = {
name?: string,
className?: string,
children?: ReactNodeList,
};

View File

@ -308,6 +308,7 @@ export function unhideTextInstance(
export function applyViewTransitionName(
instance: Instance,
name: string,
className: ?string,
): void {
// Noop
}