diff --git a/change/@fluentui-react-motion-b1997662-a01c-4258-b43e-d8022aab59b3.json b/change/@fluentui-react-motion-b1997662-a01c-4258-b43e-d8022aab59b3.json new file mode 100644 index 00000000000000..fa64fa609879d4 --- /dev/null +++ b/change/@fluentui-react-motion-b1997662-a01c-4258-b43e-d8022aab59b3.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat(react-motion): add replayKey prop to replay motion without remounting", + "packageName": "@fluentui/react-motion", + "email": "robertpenner@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-motion/library/etc/react-motion.api.md b/packages/react-components/react-motion/library/etc/react-motion.api.md index 02f4d98af4aefc..6f52aa71415896 100644 --- a/packages/react-components/react-motion/library/etc/react-motion.api.md +++ b/packages/react-components/react-motion/library/etc/react-motion.api.md @@ -72,6 +72,7 @@ export type MotionComponentProps = { onMotionFinish?: (ev: null) => void; onMotionCancel?: (ev: null) => void; onMotionStart?: (ev: null) => void; + replayKey?: string | number; }; // @public (undocumented) diff --git a/packages/react-components/react-motion/library/src/factories/createMotionComponent.test.tsx b/packages/react-components/react-motion/library/src/factories/createMotionComponent.test.tsx index 6eb1ad024ae2f9..e8f9a683722fa4 100644 --- a/packages/react-components/react-motion/library/src/factories/createMotionComponent.test.tsx +++ b/packages/react-components/react-motion/library/src/factories/createMotionComponent.test.tsx @@ -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) { @@ -32,6 +35,8 @@ function createElementMock() { return { animateMock, + cancelMock, + playMock, ElementMock, finishMock, }; @@ -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( + + + , + ); + + // element.animate() is called once on initial mount + expect(animateMock).toHaveBeenCalledTimes(1); + + // Same replayKey — no replay + rerender( + + + , + ); + expect(animateMock).toHaveBeenCalledTimes(1); + expect(cancelMock).not.toHaveBeenCalled(); + expect(playMock).not.toHaveBeenCalled(); + + // Changed replayKey — imperatively cancel + replay on the existing handle + rerender( + + + , + ); + // 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>((_, ref) => { + React.useEffect(() => { + mountCount++; + }, []); + return ; + }); + + const { rerender } = render( + + + , + ); + + expect(mountCount).toBe(1); + + rerender( + + + , + ); + + 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( + + + , + ); + + await act(async () => { + await new Promise(process.nextTick); + }); + + expect(onMotionStart).toHaveBeenCalledTimes(1); + expect(onMotionFinish).toHaveBeenCalledTimes(1); + + rerender( + + + , + ); + + await act(async () => { + await new Promise(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(); @@ -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( + + + , + { wrapper: ({ children }) => {children} }, + ); + + await act(async () => { + await new Promise(process.nextTick); + }); + + expect(finishMock).toHaveBeenCalledTimes(1); + + rerender( + + + , + ); + + await act(async () => { + await new Promise(process.nextTick); + }); + + expect(finishMock).toHaveBeenCalledTimes(2); + }); }); diff --git a/packages/react-components/react-motion/library/src/factories/createMotionComponent.ts b/packages/react-components/react-motion/library/src/factories/createMotionComponent.ts index d2560dabd49133..e441d517e8007a 100644 --- a/packages/react-components/react-motion/library/src/factories/createMotionComponent.ts +++ b/packages/react-components/react-motion/library/src/factories/createMotionComponent.ts @@ -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'; /** @@ -51,6 +51,31 @@ export type MotionComponentProps = { */ // eslint-disable-next-line @nx/workspace-consistent-callback-type -- EventHandler 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); + * + *
Content
+ *
+ * + * ``` + */ + replayKey?: string | number; }; export type MotionComponent = {}> = React.FC< @@ -76,12 +101,14 @@ export function createMotionComponent; 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, @@ -103,6 +130,21 @@ export function createMotionComponent { + 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. @@ -115,20 +157,32 @@ export function createMotionComponent { 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; }; diff --git a/packages/react-components/react-motion/stories/src/CreateMotionComponent/CreateMotionComponentReplayKey.stories.md b/packages/react-components/react-motion/stories/src/CreateMotionComponent/CreateMotionComponentReplayKey.stories.md new file mode 100644 index 00000000000000..3afaa6a8596fe3 --- /dev/null +++ b/packages/react-components/react-motion/stories/src/CreateMotionComponent/CreateMotionComponentReplayKey.stories.md @@ -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. diff --git a/packages/react-components/react-motion/stories/src/CreateMotionComponent/CreateMotionComponentReplayKey.stories.tsx b/packages/react-components/react-motion/stories/src/CreateMotionComponent/CreateMotionComponentReplayKey.stories.tsx new file mode 100644 index 00000000000000..29fcbfe3bbeedd --- /dev/null +++ b/packages/react-components/react-motion/stories/src/CreateMotionComponent/CreateMotionComponentReplayKey.stories.tsx @@ -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 ( +
+ + Without replayKey + + + With replayKey + + + + + {count} + + + + + {count} + + + +
+ +
+
+ ); +}; + +CreateMotionComponentReplayKey.parameters = { + docs: { description: { story: description } }, +}; diff --git a/packages/react-components/react-motion/stories/src/CreateMotionComponent/index.stories.ts b/packages/react-components/react-motion/stories/src/CreateMotionComponent/index.stories.ts index 62a08e6a44565f..4d837fafffcdae 100644 --- a/packages/react-components/react-motion/stories/src/CreateMotionComponent/index.stories.ts +++ b/packages/react-components/react-motion/stories/src/CreateMotionComponent/index.stories.ts @@ -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',