Skip to content

Commit 0bf1f39

Browse files
authored
View Transition Refs (#32038)
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.
1 parent 056073d commit 0bf1f39

File tree

16 files changed

+232
-40
lines changed

16 files changed

+232
-40
lines changed

.eslintrc.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,11 @@ module.exports = {
589589
WheelEventHandler: 'readonly',
590590
FinalizationRegistry: 'readonly',
591591
Omit: 'readonly',
592+
Keyframe: 'readonly',
593+
PropertyIndexedKeyframes: 'readonly',
594+
KeyframeAnimationOptions: 'readonly',
595+
GetAnimationsOptions: 'readonly',
596+
Animatable: 'readonly',
592597

593598
spyOnDev: 'readonly',
594599
spyOnDevAndProd: 'readonly',

fixtures/view-transition/src/components/Page.js

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import React, {
22
unstable_ViewTransition as ViewTransition,
33
unstable_Activity as Activity,
4+
useRef,
5+
useLayoutEffect,
46
} from 'react';
57

68
import './Page.css';
@@ -35,7 +37,19 @@ function Component() {
3537
}
3638

3739
export default function Page({url, navigate}) {
40+
const ref = useRef();
3841
const show = url === '/?b';
42+
useLayoutEffect(() => {
43+
const viewTransition = ref.current;
44+
requestAnimationFrame(() => {
45+
const keyframes = [
46+
{rotate: '0deg', transformOrigin: '30px 8px'},
47+
{rotate: '360deg', transformOrigin: '30px 8px'},
48+
];
49+
viewTransition.old.animate(keyframes, 300);
50+
viewTransition.new.animate(keyframes, 300);
51+
});
52+
}, [show]);
3953
const exclamation = (
4054
<ViewTransition name="exclamation">
4155
<span>!</span>
@@ -62,7 +76,7 @@ export default function Page({url, navigate}) {
6276
{a}
6377
</div>
6478
)}
65-
<ViewTransition>
79+
<ViewTransition ref={ref}>
6680
{show ? <div>hello{exclamation}</div> : <section>Loading</section>}
6781
</ViewTransition>
6882
<p>scroll me</p>

packages/react-art/src/ReactFiberConfigART.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,14 @@ export function startViewTransition() {
500500
return false;
501501
}
502502

503+
export type ViewTransitionInstance = null | {name: string, ...};
504+
505+
export function createViewTransitionInstance(
506+
name: string,
507+
): ViewTransitionInstance {
508+
return null;
509+
}
510+
503511
export function clearContainer(container) {
504512
// TODO Implement this
505513
}

packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,14 @@ export type RendererInspectionConfig = $ReadOnly<{}>;
187187

188188
export type TransitionStatus = FormStatus;
189189

190+
export type ViewTransitionInstance = {
191+
name: string,
192+
group: Animatable,
193+
imagePair: Animatable,
194+
old: Animatable,
195+
new: Animatable,
196+
};
197+
190198
type SelectionInformation = {
191199
focusedElem: null | HTMLElement,
192200
selectionRange: mixed,
@@ -1323,6 +1331,75 @@ export function startViewTransition(
13231331
}
13241332
}
13251333

1334+
interface ViewTransitionPseudoElementType extends Animatable {
1335+
_scope: HTMLElement;
1336+
_selector: string;
1337+
}
1338+
1339+
function ViewTransitionPseudoElement(
1340+
this: ViewTransitionPseudoElementType,
1341+
pseudo: string,
1342+
name: string,
1343+
) {
1344+
// TODO: Get the owner document from the root container.
1345+
this._scope = (document.documentElement: any);
1346+
this._selector = '::view-transition-' + pseudo + '(' + name + ')';
1347+
}
1348+
// $FlowFixMe[prop-missing]
1349+
ViewTransitionPseudoElement.prototype.animate = function (
1350+
this: ViewTransitionPseudoElementType,
1351+
keyframes: Keyframe[] | PropertyIndexedKeyframes | null,
1352+
options?: number | KeyframeAnimationOptions,
1353+
): Animation {
1354+
const opts: any =
1355+
typeof options === 'number'
1356+
? {
1357+
duration: options,
1358+
}
1359+
: Object.assign(({}: KeyframeAnimationOptions), options);
1360+
opts.pseudoElement = this._selector;
1361+
// TODO: Handle multiple child instances.
1362+
return this._scope.animate(keyframes, opts);
1363+
};
1364+
// $FlowFixMe[prop-missing]
1365+
ViewTransitionPseudoElement.prototype.getAnimations = function (
1366+
this: ViewTransitionPseudoElementType,
1367+
options?: GetAnimationsOptions,
1368+
): Animation[] {
1369+
const scope = this._scope;
1370+
const selector = this._selector;
1371+
const animations = scope.getAnimations({subtree: true});
1372+
const result = [];
1373+
for (let i = 0; i < animations.length; i++) {
1374+
const effect: null | {
1375+
target?: Element,
1376+
pseudoElement?: string,
1377+
...
1378+
} = (animations[i].effect: any);
1379+
// TODO: Handle multiple child instances.
1380+
if (
1381+
effect !== null &&
1382+
effect.target === scope &&
1383+
effect.pseudoElement === selector
1384+
) {
1385+
result.push(animations[i]);
1386+
}
1387+
}
1388+
return result;
1389+
};
1390+
1391+
export function createViewTransitionInstance(
1392+
name: string,
1393+
): ViewTransitionInstance {
1394+
return {
1395+
name: name,
1396+
group: new (ViewTransitionPseudoElement: any)('group', name),
1397+
imagePair: new (ViewTransitionPseudoElement: any)('image-pair', name),
1398+
old: new (ViewTransitionPseudoElement: any)('old', name),
1399+
new: new (ViewTransitionPseudoElement: any)('new', name),
1400+
};
1401+
}
1402+
13261403
export function clearContainer(container: Container): void {
13271404
const nodeType = container.nodeType;
13281405
if (nodeType === DOCUMENT_NODE) {

packages/react-native-renderer/src/ReactFiberConfigNative.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -591,6 +591,14 @@ export function startViewTransition(
591591
return false;
592592
}
593593

594+
export type ViewTransitionInstance = null | {name: string, ...};
595+
596+
export function createViewTransitionInstance(
597+
name: string,
598+
): ViewTransitionInstance {
599+
return null;
600+
}
601+
594602
export function clearContainer(container: Container): void {
595603
// TODO Implement this for React Native
596604
// UIManager does not expose a "remove all" type method.

packages/react-noop-renderer/src/createReactNoop.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ export type TransitionStatus = mixed;
9292

9393
export type FormInstance = Instance;
9494

95+
export type ViewTransitionInstance = null | {name: string, ...};
96+
9597
const NO_CONTEXT = {};
9698
const UPPERCASE_CONTEXT = {};
9799
if (__DEV__) {
@@ -786,6 +788,10 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
786788
return false;
787789
},
788790

791+
createViewTransitionInstance(name: string): ViewTransitionInstance {
792+
return null;
793+
},
794+
789795
resetTextContent(instance: Instance): void {
790796
instance.text = null;
791797
},

packages/react-reconciler/src/ReactFiber.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import type {
2121
} from './ReactFiberActivityComponent';
2222
import type {
2323
ViewTransitionProps,
24-
ViewTransitionInstance,
24+
ViewTransitionState,
2525
} from './ReactFiberViewTransitionComponent';
2626
import type {TracingMarkerInstance} from './ReactFiberTracingMarkerComponent';
2727

@@ -884,9 +884,10 @@ export function createFiberFromViewTransition(
884884
const fiber = createFiber(ViewTransitionComponent, pendingProps, key, mode);
885885
fiber.elementType = REACT_VIEW_TRANSITION_TYPE;
886886
fiber.lanes = lanes;
887-
const instance: ViewTransitionInstance = {
887+
const instance: ViewTransitionState = {
888888
autoName: null,
889889
paired: null,
890+
ref: null,
890891
};
891892
fiber.stateNode = instance;
892893
return fiber;

packages/react-reconciler/src/ReactFiberBeginWork.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import type {
3030
} from './ReactFiberActivityComponent';
3131
import type {
3232
ViewTransitionProps,
33-
ViewTransitionInstance,
33+
ViewTransitionState,
3434
} from './ReactFiberViewTransitionComponent';
3535
import {assignViewTransitionAutoName} from './ReactFiberViewTransitionComponent';
3636
import {OffscreenDetached} from './ReactFiberActivityComponent';
@@ -3246,7 +3246,7 @@ function updateViewTransition(
32463246
renderLanes: Lanes,
32473247
) {
32483248
const pendingProps: ViewTransitionProps = workInProgress.pendingProps;
3249-
const instance: ViewTransitionInstance = workInProgress.stateNode;
3249+
const instance: ViewTransitionState = workInProgress.stateNode;
32503250
if (pendingProps.name != null && pendingProps.name !== 'auto') {
32513251
// Explicitly named boundary. We track it so that we can pair it up with another explicit
32523252
// boundary if we get deleted.
@@ -3264,6 +3264,12 @@ function updateViewTransition(
32643264
// counter in the commit phase instead.
32653265
assignViewTransitionAutoName(pendingProps, instance);
32663266
}
3267+
if (current !== null && current.memoizedProps.name !== pendingProps.name) {
3268+
// If the name changes, we schedule a ref effect to create a new ref instance.
3269+
workInProgress.flags |= Ref | RefStatic;
3270+
} else {
3271+
markRef(current, workInProgress);
3272+
}
32673273
const nextChildren = pendingProps.children;
32683274
reconcileChildren(current, workInProgress, nextChildren, renderLanes);
32693275
return workInProgress.child;

packages/react-reconciler/src/ReactFiberCommitEffects.js

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,21 +11,26 @@ import type {Fiber} from './ReactInternalTypes';
1111
import type {UpdateQueue} from './ReactFiberClassUpdateQueue';
1212
import type {FunctionComponentUpdateQueue} from './ReactFiberHooks';
1313
import type {HookFlags} from './ReactHookEffectTags';
14+
import {
15+
getViewTransitionName,
16+
type ViewTransitionState,
17+
type ViewTransitionProps,
18+
} from './ReactFiberViewTransitionComponent';
1419

1520
import {
1621
enableProfilerTimer,
1722
enableProfilerCommitHooks,
1823
enableProfilerNestedUpdatePhase,
1924
enableSchedulingProfiler,
20-
enableScopeAPI,
2125
enableUseResourceEffectHook,
26+
enableViewTransition,
2227
} from 'shared/ReactFeatureFlags';
2328
import {
2429
ClassComponent,
2530
HostComponent,
2631
HostHoistable,
2732
HostSingleton,
28-
ScopeComponent,
33+
ViewTransitionComponent,
2934
} from './ReactWorkTags';
3035
import {NoFlags} from './ReactFiberFlags';
3136
import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber';
@@ -40,7 +45,10 @@ import {
4045
commitCallbacks,
4146
commitHiddenCallbacks,
4247
} from './ReactFiberClassUpdateQueue';
43-
import {getPublicInstance} from './ReactFiberConfig';
48+
import {
49+
getPublicInstance,
50+
createViewTransitionInstance,
51+
} from './ReactFiberConfig';
4452
import {
4553
captureCommitPhaseError,
4654
setIsRunningInsertionEffect,
@@ -865,20 +873,27 @@ export function safelyCallComponentWillUnmount(
865873
function commitAttachRef(finishedWork: Fiber) {
866874
const ref = finishedWork.ref;
867875
if (ref !== null) {
868-
const instance = finishedWork.stateNode;
869876
let instanceToUse;
870877
switch (finishedWork.tag) {
871878
case HostHoistable:
872879
case HostSingleton:
873880
case HostComponent:
874-
instanceToUse = getPublicInstance(instance);
881+
instanceToUse = getPublicInstance(finishedWork.stateNode);
875882
break;
883+
case ViewTransitionComponent:
884+
if (enableViewTransition) {
885+
const instance: ViewTransitionState = finishedWork.stateNode;
886+
const props: ViewTransitionProps = finishedWork.memoizedProps;
887+
const name = getViewTransitionName(props, instance);
888+
if (instance.ref === null || instance.ref.name !== name) {
889+
instance.ref = createViewTransitionInstance(name);
890+
}
891+
instanceToUse = instance.ref;
892+
break;
893+
}
894+
// Fallthrough
876895
default:
877-
instanceToUse = instance;
878-
}
879-
// Moved outside to ensure DCE works with this flag
880-
if (enableScopeAPI && finishedWork.tag === ScopeComponent) {
881-
instanceToUse = instance;
896+
instanceToUse = finishedWork.stateNode;
882897
}
883898
if (typeof ref === 'function') {
884899
if (shouldProfile(finishedWork)) {

0 commit comments

Comments
 (0)