Skip to content

Commit

Permalink
View Transition Refs (#32038)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
sebmarkbage authored Jan 10, 2025
1 parent 056073d commit 0bf1f39
Show file tree
Hide file tree
Showing 16 changed files with 232 additions and 40 deletions.
5 changes: 5 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,11 @@ module.exports = {
WheelEventHandler: 'readonly',
FinalizationRegistry: 'readonly',
Omit: 'readonly',
Keyframe: 'readonly',
PropertyIndexedKeyframes: 'readonly',
KeyframeAnimationOptions: 'readonly',
GetAnimationsOptions: 'readonly',
Animatable: 'readonly',

spyOnDev: 'readonly',
spyOnDevAndProd: 'readonly',
Expand Down
16 changes: 15 additions & 1 deletion fixtures/view-transition/src/components/Page.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import React, {
unstable_ViewTransition as ViewTransition,
unstable_Activity as Activity,
useRef,
useLayoutEffect,
} from 'react';

import './Page.css';
Expand Down Expand Up @@ -35,7 +37,19 @@ function Component() {
}

export default function Page({url, navigate}) {
const ref = useRef();
const show = url === '/?b';
useLayoutEffect(() => {
const viewTransition = ref.current;
requestAnimationFrame(() => {
const keyframes = [
{rotate: '0deg', transformOrigin: '30px 8px'},
{rotate: '360deg', transformOrigin: '30px 8px'},
];
viewTransition.old.animate(keyframes, 300);
viewTransition.new.animate(keyframes, 300);
});
}, [show]);
const exclamation = (
<ViewTransition name="exclamation">
<span>!</span>
Expand All @@ -62,7 +76,7 @@ export default function Page({url, navigate}) {
{a}
</div>
)}
<ViewTransition>
<ViewTransition ref={ref}>
{show ? <div>hello{exclamation}</div> : <section>Loading</section>}
</ViewTransition>
<p>scroll me</p>
Expand Down
8 changes: 8 additions & 0 deletions packages/react-art/src/ReactFiberConfigART.js
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,14 @@ export function startViewTransition() {
return false;
}

export type ViewTransitionInstance = null | {name: string, ...};

export function createViewTransitionInstance(
name: string,
): ViewTransitionInstance {
return null;
}

export function clearContainer(container) {
// TODO Implement this
}
Expand Down
77 changes: 77 additions & 0 deletions packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,14 @@ export type RendererInspectionConfig = $ReadOnly<{}>;

export type TransitionStatus = FormStatus;

export type ViewTransitionInstance = {
name: string,
group: Animatable,
imagePair: Animatable,
old: Animatable,
new: Animatable,
};

type SelectionInformation = {
focusedElem: null | HTMLElement,
selectionRange: mixed,
Expand Down Expand Up @@ -1323,6 +1331,75 @@ export function startViewTransition(
}
}

interface ViewTransitionPseudoElementType extends Animatable {
_scope: HTMLElement;
_selector: string;
}

function ViewTransitionPseudoElement(
this: ViewTransitionPseudoElementType,
pseudo: string,
name: string,
) {
// TODO: Get the owner document from the root container.
this._scope = (document.documentElement: any);
this._selector = '::view-transition-' + pseudo + '(' + name + ')';
}
// $FlowFixMe[prop-missing]
ViewTransitionPseudoElement.prototype.animate = function (
this: ViewTransitionPseudoElementType,
keyframes: Keyframe[] | PropertyIndexedKeyframes | null,
options?: number | KeyframeAnimationOptions,
): Animation {
const opts: any =
typeof options === 'number'
? {
duration: options,
}
: Object.assign(({}: KeyframeAnimationOptions), options);
opts.pseudoElement = this._selector;
// TODO: Handle multiple child instances.
return this._scope.animate(keyframes, opts);
};
// $FlowFixMe[prop-missing]
ViewTransitionPseudoElement.prototype.getAnimations = function (
this: ViewTransitionPseudoElementType,
options?: GetAnimationsOptions,
): Animation[] {
const scope = this._scope;
const selector = this._selector;
const animations = scope.getAnimations({subtree: true});
const result = [];
for (let i = 0; i < animations.length; i++) {
const effect: null | {
target?: Element,
pseudoElement?: string,
...
} = (animations[i].effect: any);
// TODO: Handle multiple child instances.
if (
effect !== null &&
effect.target === scope &&
effect.pseudoElement === selector
) {
result.push(animations[i]);
}
}
return result;
};

export function createViewTransitionInstance(
name: string,
): ViewTransitionInstance {
return {
name: name,
group: new (ViewTransitionPseudoElement: any)('group', name),
imagePair: new (ViewTransitionPseudoElement: any)('image-pair', name),
old: new (ViewTransitionPseudoElement: any)('old', name),
new: new (ViewTransitionPseudoElement: any)('new', name),
};
}

export function clearContainer(container: Container): void {
const nodeType = container.nodeType;
if (nodeType === DOCUMENT_NODE) {
Expand Down
8 changes: 8 additions & 0 deletions packages/react-native-renderer/src/ReactFiberConfigNative.js
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,14 @@ export function startViewTransition(
return false;
}

export type ViewTransitionInstance = null | {name: string, ...};

export function createViewTransitionInstance(
name: string,
): ViewTransitionInstance {
return null;
}

export function clearContainer(container: Container): void {
// TODO Implement this for React Native
// UIManager does not expose a "remove all" type method.
Expand Down
6 changes: 6 additions & 0 deletions packages/react-noop-renderer/src/createReactNoop.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ export type TransitionStatus = mixed;

export type FormInstance = Instance;

export type ViewTransitionInstance = null | {name: string, ...};

const NO_CONTEXT = {};
const UPPERCASE_CONTEXT = {};
if (__DEV__) {
Expand Down Expand Up @@ -786,6 +788,10 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
return false;
},

createViewTransitionInstance(name: string): ViewTransitionInstance {
return null;
},

resetTextContent(instance: Instance): void {
instance.text = null;
},
Expand Down
5 changes: 3 additions & 2 deletions packages/react-reconciler/src/ReactFiber.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import type {
} from './ReactFiberActivityComponent';
import type {
ViewTransitionProps,
ViewTransitionInstance,
ViewTransitionState,
} from './ReactFiberViewTransitionComponent';
import type {TracingMarkerInstance} from './ReactFiberTracingMarkerComponent';

Expand Down Expand Up @@ -884,9 +884,10 @@ export function createFiberFromViewTransition(
const fiber = createFiber(ViewTransitionComponent, pendingProps, key, mode);
fiber.elementType = REACT_VIEW_TRANSITION_TYPE;
fiber.lanes = lanes;
const instance: ViewTransitionInstance = {
const instance: ViewTransitionState = {
autoName: null,
paired: null,
ref: null,
};
fiber.stateNode = instance;
return fiber;
Expand Down
10 changes: 8 additions & 2 deletions packages/react-reconciler/src/ReactFiberBeginWork.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import type {
} from './ReactFiberActivityComponent';
import type {
ViewTransitionProps,
ViewTransitionInstance,
ViewTransitionState,
} from './ReactFiberViewTransitionComponent';
import {assignViewTransitionAutoName} from './ReactFiberViewTransitionComponent';
import {OffscreenDetached} from './ReactFiberActivityComponent';
Expand Down Expand Up @@ -3246,7 +3246,7 @@ function updateViewTransition(
renderLanes: Lanes,
) {
const pendingProps: ViewTransitionProps = workInProgress.pendingProps;
const instance: ViewTransitionInstance = workInProgress.stateNode;
const instance: ViewTransitionState = workInProgress.stateNode;
if (pendingProps.name != null && pendingProps.name !== 'auto') {
// Explicitly named boundary. We track it so that we can pair it up with another explicit
// boundary if we get deleted.
Expand All @@ -3264,6 +3264,12 @@ function updateViewTransition(
// counter in the commit phase instead.
assignViewTransitionAutoName(pendingProps, instance);
}
if (current !== null && current.memoizedProps.name !== pendingProps.name) {
// If the name changes, we schedule a ref effect to create a new ref instance.
workInProgress.flags |= Ref | RefStatic;
} else {
markRef(current, workInProgress);
}
const nextChildren = pendingProps.children;
reconcileChildren(current, workInProgress, nextChildren, renderLanes);
return workInProgress.child;
Expand Down
35 changes: 25 additions & 10 deletions packages/react-reconciler/src/ReactFiberCommitEffects.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,26 @@ import type {Fiber} from './ReactInternalTypes';
import type {UpdateQueue} from './ReactFiberClassUpdateQueue';
import type {FunctionComponentUpdateQueue} from './ReactFiberHooks';
import type {HookFlags} from './ReactHookEffectTags';
import {
getViewTransitionName,
type ViewTransitionState,
type ViewTransitionProps,
} from './ReactFiberViewTransitionComponent';

import {
enableProfilerTimer,
enableProfilerCommitHooks,
enableProfilerNestedUpdatePhase,
enableSchedulingProfiler,
enableScopeAPI,
enableUseResourceEffectHook,
enableViewTransition,
} from 'shared/ReactFeatureFlags';
import {
ClassComponent,
HostComponent,
HostHoistable,
HostSingleton,
ScopeComponent,
ViewTransitionComponent,
} from './ReactWorkTags';
import {NoFlags} from './ReactFiberFlags';
import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber';
Expand All @@ -40,7 +45,10 @@ import {
commitCallbacks,
commitHiddenCallbacks,
} from './ReactFiberClassUpdateQueue';
import {getPublicInstance} from './ReactFiberConfig';
import {
getPublicInstance,
createViewTransitionInstance,
} from './ReactFiberConfig';
import {
captureCommitPhaseError,
setIsRunningInsertionEffect,
Expand Down Expand Up @@ -865,20 +873,27 @@ export function safelyCallComponentWillUnmount(
function commitAttachRef(finishedWork: Fiber) {
const ref = finishedWork.ref;
if (ref !== null) {
const instance = finishedWork.stateNode;
let instanceToUse;
switch (finishedWork.tag) {
case HostHoistable:
case HostSingleton:
case HostComponent:
instanceToUse = getPublicInstance(instance);
instanceToUse = getPublicInstance(finishedWork.stateNode);
break;
case ViewTransitionComponent:
if (enableViewTransition) {
const instance: ViewTransitionState = finishedWork.stateNode;
const props: ViewTransitionProps = finishedWork.memoizedProps;
const name = getViewTransitionName(props, instance);
if (instance.ref === null || instance.ref.name !== name) {
instance.ref = createViewTransitionInstance(name);
}
instanceToUse = instance.ref;
break;
}
// Fallthrough
default:
instanceToUse = instance;
}
// Moved outside to ensure DCE works with this flag
if (enableScopeAPI && finishedWork.tag === ScopeComponent) {
instanceToUse = instance;
instanceToUse = finishedWork.stateNode;
}
if (typeof ref === 'function') {
if (shouldProfile(finishedWork)) {
Expand Down
Loading

0 comments on commit 0bf1f39

Please sign in to comment.