Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
Comment thread
robertpenner marked this conversation as resolved.
"type": "minor",
"comment": "feat(react-motion): add replayKey prop to replay motion without remounting",
"packageName": "@fluentui/react-motion",
"email": "robertpenner@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export type MotionComponentProps = {
onMotionFinish?: (ev: null) => void;
onMotionCancel?: (ev: null) => void;
onMotionStart?: (ev: null) => void;
replayKey?: string | number;
};

// @public (undocumented)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@ const motion: AtomMotion = {

function createElementMock() {
const finishMock = jest.fn();
const cancelMock = jest.fn();
const playMock = jest.fn();
const animateMock = jest.fn().mockImplementation(() => ({
cancel: jest.fn(),
cancel: cancelMock,
play: playMock,
persist: jest.fn(),
finish: finishMock,
set onfinish(fn: () => void) {
Expand All @@ -32,6 +35,8 @@ function createElementMock() {

return {
animateMock,
cancelMock,
playMock,
ElementMock,
finishMock,
};
Expand Down Expand Up @@ -133,6 +138,104 @@ describe('createMotionComponent', () => {
expect(onMotionFinish).toHaveBeenCalledTimes(1);
});

it('replays animation when replayKey changes', () => {
const TestAtom = createMotionComponent(motion);
const { animateMock, cancelMock, playMock, ElementMock } = createElementMock();

const { rerender } = render(
<TestAtom replayKey="a">
<ElementMock />
</TestAtom>,
);

// element.animate() is called once on initial mount
expect(animateMock).toHaveBeenCalledTimes(1);

// Same replayKey — no replay
rerender(
<TestAtom replayKey="a">
<ElementMock />
</TestAtom>,
);
expect(animateMock).toHaveBeenCalledTimes(1);
expect(cancelMock).not.toHaveBeenCalled();
expect(playMock).not.toHaveBeenCalled();

// Changed replayKey — imperatively cancel + replay on the existing handle
rerender(
<TestAtom replayKey="b">
<ElementMock />
</TestAtom>,
);
// element.animate() is NOT called again — no new Animation objects created
expect(animateMock).toHaveBeenCalledTimes(1);
expect(cancelMock).toHaveBeenCalledTimes(1);
expect(playMock).toHaveBeenCalledTimes(1);
});

it('does not remount the child element when replayKey changes', () => {
const TestAtom = createMotionComponent(motion);
const { ElementMock } = createElementMock();
let mountCount = 0;

const TrackedChild = React.forwardRef<{ animate: () => void }, Record<string, never>>((_, ref) => {
React.useEffect(() => {
mountCount++;
}, []);
return <ElementMock ref={ref} />;
});

const { rerender } = render(
<TestAtom replayKey="a">
<TrackedChild />
</TestAtom>,
);

expect(mountCount).toBe(1);

rerender(
<TestAtom replayKey="b">
<TrackedChild />
</TestAtom>,
);

expect(mountCount).toBe(1); // child was not remounted
});

it('fires onMotionStart and onMotionFinish again when replayKey changes', async () => {
const TestAtom = createMotionComponent(motion);
const onMotionStart = jest.fn();
const onMotionFinish = jest.fn();
const { ElementMock } = createElementMock();

const { rerender } = render(
<TestAtom replayKey="a" onMotionStart={onMotionStart} onMotionFinish={onMotionFinish}>
<ElementMock />
</TestAtom>,
);

await act(async () => {
await new Promise<void>(process.nextTick);
});

expect(onMotionStart).toHaveBeenCalledTimes(1);
expect(onMotionFinish).toHaveBeenCalledTimes(1);

rerender(
<TestAtom replayKey="b" onMotionStart={onMotionStart} onMotionFinish={onMotionFinish}>
<ElementMock />
</TestAtom>,
);

await act(async () => {
await new Promise<void>(process.nextTick);
});

// onMotionStart and onMotionFinish fire again for the replayed animation
expect(onMotionStart).toHaveBeenCalledTimes(2);
expect(onMotionFinish).toHaveBeenCalledTimes(2);
});

it('finishes motion when wrapped in motion context with skip behaviour', async () => {
const TestAtom = createMotionComponent(motion);
const onMotionStart = jest.fn();
Expand All @@ -155,4 +258,34 @@ describe('createMotionComponent', () => {
expect(onMotionFinish).toHaveBeenCalledTimes(1);
expect(finishMock).toHaveBeenCalledTimes(1);
});

it('calls finish on replay when wrapped in motion context with skip behaviour', async () => {
const TestAtom = createMotionComponent(motion);
const { finishMock, ElementMock } = createElementMock();

const { rerender } = render(
<TestAtom replayKey="a">
<ElementMock />
</TestAtom>,
{ wrapper: ({ children }) => <MotionBehaviourProvider value="skip">{children}</MotionBehaviourProvider> },
);

await act(async () => {
await new Promise<void>(process.nextTick);
});

expect(finishMock).toHaveBeenCalledTimes(1);

rerender(
<TestAtom replayKey="b">
<ElementMock />
</TestAtom>,
);

await act(async () => {
await new Promise<void>(process.nextTick);
});

expect(finishMock).toHaveBeenCalledTimes(2);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { useAnimateAtoms } from '../hooks/useAnimateAtoms';
import { useMotionImperativeRef } from '../hooks/useMotionImperativeRef';
import { useIsReducedMotion } from '../hooks/useIsReducedMotion';
import { useChildElement } from '../utils/useChildElement';
import type { AtomMotion, AtomMotionFn, MotionParam, MotionImperativeRef } from '../types';
import type { AtomMotion, AtomMotionFn, MotionParam, MotionImperativeRef, AnimationHandle } from '../types';
import { useMotionBehaviourContext } from '../contexts/MotionBehaviourContext';

/**
Expand Down Expand Up @@ -51,6 +51,31 @@ export type MotionComponentProps = {
*/
// eslint-disable-next-line @nx/workspace-consistent-callback-type -- EventHandler<T> does not support "null"
onMotionStart?: (ev: null) => void;

/**
* When this value changes, the animation replays from the start on the same DOM element,
* cancelling any in-progress animation, without remounting the component or its children.
*
* **Why not just use a React `key`?** Changing a React `key` forces a full unmount and
* remount of the subtree: DOM nodes are destroyed and recreated, focus is lost, and any
* child state is reset. `replayKey` avoids all of that — only the animation effect reruns
* while the DOM and component state remain intact.
*
* Use this when you want to retrigger a motion in response to a state change (e.g. a user
* action or a data update) while preserving DOM continuity. It is the declarative equivalent
* of calling `imperativeRef.current.play()` but driven by a prop rather than a ref call.
*
* @example
* ```tsx
* // Replay a Fade.In each time the user clicks "Refresh"
* const [replayKey, setReplayKey] = React.useState(0);
* <Fade.In replayKey={replayKey}>
* <div>Content</div>
* </Fade.In>
* <button onClick={() => setReplayKey(k => k + 1)}>Refresh</button>
* ```
*/
replayKey?: string | number;
};

export type MotionComponent<MotionParams extends Record<string, MotionParam> = {}> = React.FC<
Expand All @@ -76,12 +101,14 @@ export function createMotionComponent<MotionParams extends Record<string, Motion
onMotionFinish: onMotionFinishProp,
onMotionStart: onMotionStartProp,
onMotionCancel: onMotionCancelProp,
replayKey,
..._rest
} = props;
const params = _rest as Exclude<typeof props, MotionComponentProps>;
const [child, childRef] = useChildElement(children);

const handleRef = useMotionImperativeRef(imperativeRef);
const isInitialRender = React.useRef(true);
const skipMotions = useMotionBehaviourContext() === 'skip';
const optionsRef = React.useRef<{ skipMotions: boolean; params: MotionParams }>({
skipMotions,
Expand All @@ -103,6 +130,21 @@ export function createMotionComponent<MotionParams extends Record<string, Motion
onMotionCancelProp?.(null);
});

// Stable callback (all deps are refs or useEventCallback) that activates a handle for a new playback cycle.
//
// TODO: consider moving the cancel+play+rewire sequence into a handle.replay() method on AnimationHandle,
// keeping pure animation sequencing on the handle and React callbacks here in the component.
const activateAnimationHandle = React.useCallback(
(handle: AnimationHandle) => {
onMotionStart();
handle.setMotionEndCallbacks(onMotionFinish, onMotionCancel);
if (optionsRef.current.skipMotions) {
handle.finish();
}
},
[onMotionStart, onMotionFinish, onMotionCancel],
);

useIsomorphicLayoutEffect(() => {
// Heads up!
// We store the params in a ref to avoid re-rendering the component when the params change.
Expand All @@ -115,20 +157,32 @@ export function createMotionComponent<MotionParams extends Record<string, Motion
if (element) {
const atoms = typeof value === 'function' ? value({ element, ...optionsRef.current.params }) : value;

onMotionStart();
const handle = animateAtoms(element, atoms, { isReducedMotion: isReducedMotion() });
handleRef.current = handle;
handle.setMotionEndCallbacks(onMotionFinish, onMotionCancel);

if (optionsRef.current.skipMotions) {
handle.finish();
}
activateAnimationHandle(handle);

return () => {
handle.cancel();
};
}
}, [animateAtoms, childRef, handleRef, isReducedMotion, onMotionFinish, onMotionStart, onMotionCancel]);
}, [animateAtoms, childRef, handleRef, isReducedMotion, activateAnimationHandle]);

// Skips initial mount; on replayKey changes, reuses existing Animation objects via cancel+play
// rather than recreating them, preserving DOM continuity.
useIsomorphicLayoutEffect(() => {
if (isInitialRender.current) {
isInitialRender.current = false;
return;
}

const handle = handleRef.current;
if (handle) {
handle.cancel();
handle.play();
activateAnimationHandle(handle);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- replayKey is intentionally the only trigger; other deps are stable refs/callbacks
}, [replayKey]);

return child;
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The `replayKey` prop retriggers the animation each time its value changes, without remounting the component. Unlike changing a React `key`, which destroys and recreates the subtree, `replayKey` reruns only the animation while keeping the DOM, focus, and child state intact.
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { Button, Card, makeStyles, Text, tokens } from '@fluentui/react-components';
import { Scale } from '@fluentui/react-motion-components-preview';
import * as React from 'react';
import type { JSXElement } from '@fluentui/react-components';

import description from './CreateMotionComponentReplayKey.stories.md';

const useClasses = makeStyles({
root: {
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gridTemplateRows: 'auto auto auto',
gridTemplateAreas: `"labelBefore labelAfter" "cardBefore cardAfter" "controls controls"`,
gap: '12px 20px',
},

label: {
textAlign: 'center',
},

card: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '160px',
},

controls: {
gridArea: 'controls',
display: 'flex',
justifyContent: 'center',
},

counter: {
color: tokens.colorBrandForeground1,
fontSize: tokens.fontSizeHero900,
fontWeight: tokens.fontWeightBold,
lineHeight: '1',
},
});

export const CreateMotionComponentReplayKey = (): JSXElement => {
const classes = useClasses();
const [count, setCount] = React.useState(0);

return (
<div className={classes.root}>
<Text className={classes.label} weight="semibold">
Without replayKey
</Text>
<Text className={classes.label} weight="semibold">
With replayKey
</Text>

<Card className={classes.card} style={{ gridArea: 'cardBefore' }}>
<Scale.In duration={2000} outScale={1.5} animateOpacity={false}>
<span className={classes.counter}>{count}</span>
</Scale.In>
</Card>
<Card className={classes.card} style={{ gridArea: 'cardAfter' }}>
<Scale.In duration={2000} outScale={1.5} animateOpacity={false} replayKey={count}>
<span className={classes.counter}>{count}</span>
</Scale.In>
</Card>

<div className={classes.controls}>
<Button appearance="primary" onClick={() => setCount(n => n + 1)}>
Increment
</Button>
</div>
</div>
);
};

CreateMotionComponentReplayKey.parameters = {
docs: { description: { story: description } },
};
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export { CreateMotionComponentLifecycleCallbacks as LifecycleCallbacks } from '.
export { CreateMotionComponentArrays as arrays } from './CreateMotionComponentArrays.stories';
export { CreateMotionComponentFunctions as functions } from './CreateMotionComponentFunctions.stories';
export { CreateMotionComponentFunctionParams as functionParams } from './CreateMotionComponentFunctionParams.stories';
export { CreateMotionComponentReplayKey as replayKey } from './CreateMotionComponentReplayKey.stories';

export default {
title: 'Motion/APIs/createMotionComponent',
Expand Down
Loading