Skip to content

Commit

Permalink
fix(react-motion): apply initial styles consistently
Browse files Browse the repository at this point in the history
  • Loading branch information
layershifter committed Feb 10, 2025
1 parent dc55108 commit dd8ff06
Show file tree
Hide file tree
Showing 3 changed files with 121 additions and 62 deletions.
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { useEventCallback } from '@fluentui/react-utilities';
import { act, render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as React from 'react';
Expand All @@ -12,13 +11,9 @@ const TestPresence = createPresenceComponent({
enter: { keyframes, ...options },
exit: { keyframes: keyframes.slice().reverse(), ...options },
});
const TestComponent: React.FC<{ appear?: boolean; finish?: () => void }> = props => {
const { appear, finish } = props;

const TestComponent: React.FC<{ appear?: boolean; onMotionFinish?: () => void }> = props => {
const { appear, onMotionFinish } = props;
const [visible, setVisible] = React.useState(true);
const onMotionFinish = useEventCallback(() => {
finish?.();
});

return (
<>
Expand All @@ -43,9 +38,10 @@ describe('createPresenceComponent (jest)', () => {
});

it('unmounts when state changes', () => {
const finish = jest.fn();
const { getByText, queryByText } = render(<TestComponent finish={finish} />);
const onMotionFinish = jest.fn();
const { getByText, queryByText } = render(<TestComponent onMotionFinish={onMotionFinish} />);

expect(onMotionFinish).toHaveBeenCalledTimes(0);
expect(queryByText('Hello')).toBeInTheDocument();

// ---
Expand All @@ -54,7 +50,7 @@ describe('createPresenceComponent (jest)', () => {
userEvent.click(getByText('Click me'));
});

expect(finish).toHaveBeenCalledTimes(1);
expect(onMotionFinish).toHaveBeenCalledTimes(1);
expect(queryByText('Hello')).not.toBeInTheDocument();

// ---
Expand All @@ -63,15 +59,15 @@ describe('createPresenceComponent (jest)', () => {
userEvent.click(getByText('Click me'));
});

expect(finish).toHaveBeenCalledTimes(2);
expect(onMotionFinish).toHaveBeenCalledTimes(2);
expect(queryByText('Hello')).toBeInTheDocument();
});

it('handles "appear"', () => {
const finish = jest.fn();
const { getByText, queryByText } = render(<TestComponent appear finish={finish} />);
const onMotionFinish = jest.fn();
const { getByText, queryByText } = render(<TestComponent appear onMotionFinish={onMotionFinish} />);

expect(finish).toHaveBeenCalledTimes(1);
expect(onMotionFinish).toHaveBeenCalledTimes(1);
expect(queryByText('Hello')).toBeInTheDocument();

// ---
Expand All @@ -80,7 +76,7 @@ describe('createPresenceComponent (jest)', () => {
userEvent.click(getByText('Click me'));
});

expect(finish).toHaveBeenCalledTimes(2);
expect(onMotionFinish).toHaveBeenCalledTimes(2);
expect(queryByText('Hello')).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ function createElementMock() {
set onfinish(fn: () => void) {
fn();
},
set oncancel(fn: () => void) {
fn();
},
}));
const ElementMock = React.forwardRef<{ animate: () => void }, { onRender?: () => void }>((props, ref) => {
React.useImperativeHandle(ref, () => ({
Expand Down Expand Up @@ -67,30 +70,45 @@ describe('createPresenceComponent', () => {
});

describe('appear', () => {
it('does not animate by default', () => {
it('by default on initial mount applies styles immediately', () => {
const onMotionFinish = jest.fn();
const TestPresence = createPresenceComponent(motion);
const { animateMock, ElementMock } = createElementMock();
const { animateMock, ElementMock, finishMock } = createElementMock();

render(
<TestPresence visible>
<TestPresence onMotionFinish={onMotionFinish} visible>
<ElementMock />
</TestPresence>,
);

expect(animateMock).not.toHaveBeenCalled();
// Should be called with "enter" keyframes
expect(animateMock).toHaveBeenCalledTimes(1);
expect(animateMock).toHaveBeenCalledWith(enterKeyframes, options);

expect(finishMock).toHaveBeenCalledTimes(1);
expect(onMotionFinish).toHaveBeenCalledTimes(0);
});

it('animates when is "true"', () => {
it('runs animation on mount when is "true"', async () => {
const onMotionFinish = jest.fn();
const TestPresence = createPresenceComponent(motion);
const { animateMock, ElementMock } = createElementMock();
const { animateMock, ElementMock, finishMock } = createElementMock();

render(
<TestPresence appear visible>
<TestPresence appear onMotionFinish={onMotionFinish} visible>
<ElementMock />
</TestPresence>,
);

expect(animateMock).toHaveBeenCalledTimes(1);
expect(animateMock).toHaveBeenCalledWith(enterKeyframes, options);

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

expect(finishMock).toHaveBeenCalledTimes(0);
expect(onMotionFinish).toHaveBeenCalledTimes(1);
});

it('animates when is "true" (without .persist())', () => {
Expand All @@ -111,27 +129,30 @@ describe('createPresenceComponent', () => {
});
});

it('finishes motion when wrapped in motion behaviour context with skip behaviour', async () => {
const TestPresence = createPresenceComponent(motion);
const { finishMock, ElementMock } = createElementMock();
const onMotionStart = jest.fn();
const onMotionFinish = jest.fn();
describe('MotionBehaviourProvider', () => {
it('finishes motion when wrapped in context with "skip" behaviour, but executes callbacks', async () => {
const onMotionStart = jest.fn();
const onMotionFinish = jest.fn();

const { queryByText } = render(
<TestPresence visible appear onMotionStart={onMotionStart} onMotionFinish={onMotionFinish}>
<ElementMock />
</TestPresence>,
{ wrapper: ({ children }) => <MotionBehaviourProvider value="skip">{children}</MotionBehaviourProvider> },
);
const TestPresence = createPresenceComponent(motion);
const { finishMock, ElementMock } = createElementMock();

await act(async () => {
await new Promise<void>(process.nextTick);
});
const { queryByText } = render(
<TestPresence appear onMotionStart={onMotionStart} onMotionFinish={onMotionFinish} visible>
<ElementMock />
</TestPresence>,
{ wrapper: ({ children }) => <MotionBehaviourProvider value="skip">{children}</MotionBehaviourProvider> },
);

expect(queryByText('ElementMock')).toBeTruthy();
expect(finishMock).toHaveBeenCalledTimes(1);
expect(onMotionStart).toHaveBeenCalledTimes(1);
expect(onMotionFinish).toHaveBeenCalledTimes(1);
await act(async () => {
await new Promise<void>(process.nextTick);
});

expect(queryByText('ElementMock')).toBeTruthy();
expect(finishMock).toHaveBeenCalledTimes(1);
expect(onMotionStart).toHaveBeenCalledTimes(1);
expect(onMotionFinish).toHaveBeenCalledTimes(1);
});
});
});

Expand Down Expand Up @@ -274,32 +295,45 @@ describe('createPresenceComponent', () => {
});

describe('visible', () => {
it('animates when state changes', () => {
const TestPresence = createPresenceComponent(motion);
it('animates when state changes', async () => {
const onMotionFinish = jest.fn();
const onRender = jest.fn();

const TestPresence = createPresenceComponent(motion);
const { animateMock, ElementMock, finishMock } = createElementMock();

const { rerender } = render(
<TestPresence visible>
<TestPresence onMotionFinish={onMotionFinish} visible>
<ElementMock onRender={onRender} />
</TestPresence>,
);

expect(animateMock).not.toHaveBeenCalled();
expect(animateMock).toHaveBeenCalledTimes(1);
expect(animateMock).toHaveBeenCalledWith(enterKeyframes, options);
expect(finishMock).toHaveBeenCalledTimes(1);

expect(onMotionFinish).toHaveBeenCalledTimes(0);
expect(onRender).toHaveBeenCalledTimes(1);
expect(finishMock).not.toHaveBeenCalled();

// ---

jest.clearAllMocks();

rerender(
<TestPresence visible={false}>
<TestPresence onMotionFinish={onMotionFinish} visible={false}>
<ElementMock onRender={onRender} />
</TestPresence>,
);

expect(animateMock).toHaveBeenCalledTimes(1);
expect(animateMock).toHaveBeenCalledWith(exitKeyframes, options);
expect(finishMock).not.toHaveBeenCalled();

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

expect(onMotionFinish).toHaveBeenCalledTimes(1);
expect(onRender).toHaveBeenCalledTimes(1);
});

Expand All @@ -313,25 +347,44 @@ describe('createPresenceComponent', () => {
</TestPresence>,
);

expect(animateMock).toHaveBeenCalledTimes(1);
expect(animateMock).toHaveBeenCalledWith(exitKeyframes, options);
expect(finishMock).toHaveBeenCalled();
});
});

describe('unmountOnExit', () => {
it('unmounts when state changes', async () => {
it('unmounted when "visible" is "false"', () => {
const TestPresence = createPresenceComponent(motion);
const { queryByText } = render(
<TestPresence visible={false} unmountOnExit>
<div>ElementMock</div>
</TestPresence>,
);

expect(queryByText('ElementMock')).toBe(null);
});

it('unmounts when state changes', async () => {
const onMotionFinish = jest.fn();
const onRender = jest.fn();
const { animateMock, ElementMock } = createElementMock();

const TestPresence = createPresenceComponent(motion);
const { animateMock, finishMock, ElementMock } = createElementMock();

const { rerender, queryByText } = render(
<TestPresence visible unmountOnExit>
<TestPresence onMotionFinish={onMotionFinish} visible unmountOnExit>
<ElementMock onRender={onRender} />
</TestPresence>,
);

expect(queryByText('ElementMock')).toBeTruthy();
expect(animateMock).not.toHaveBeenCalled();

expect(animateMock).toHaveBeenCalledTimes(1);
expect(animateMock).toHaveBeenCalledWith(enterKeyframes, options);
expect(finishMock).toHaveBeenCalledTimes(1);

expect(onMotionFinish).toHaveBeenCalledTimes(0);
expect(onRender).toHaveBeenCalledTimes(1);

// ---
Expand All @@ -340,14 +393,19 @@ describe('createPresenceComponent', () => {

await act(async () => {
rerender(
<TestPresence visible={false} unmountOnExit>
<TestPresence onMotionFinish={onMotionFinish} visible={false} unmountOnExit>
<ElementMock onRender={onRender} />
</TestPresence>,
);
});

expect(queryByText('ElementMock')).toBe(null);

expect(animateMock).toHaveBeenCalledTimes(1);
expect(animateMock).toHaveBeenCalledWith(exitKeyframes, options);
expect(finishMock).toHaveBeenCalledTimes(0);

expect(onMotionFinish).toHaveBeenCalledTimes(1);
expect(onRender).toHaveBeenCalledTimes(1);
});

Expand Down Expand Up @@ -384,6 +442,7 @@ describe('createPresenceComponent', () => {
describe('definitions', () => {
it('supports functions as motion definitions', () => {
const fnMotion = jest.fn().mockImplementation(() => motion);

const TestPresence = createPresenceComponent(fnMotion);
const { animateMock, ElementMock } = createElementMock();

Expand All @@ -393,12 +452,16 @@ describe('createPresenceComponent', () => {
</TestPresence>,
);

expect(animateMock).not.toHaveBeenCalled();
expect(fnMotion).not.toHaveBeenCalled();
expect(animateMock).toHaveBeenCalledTimes(1);
expect(animateMock).toHaveBeenCalledWith(enterKeyframes, options);

// Is called to apply initial styles
expect(fnMotion).toHaveBeenCalledTimes(1);

// ---

jest.clearAllMocks();

rerender(
<TestPresence visible={false}>
<ElementMock />
Expand All @@ -408,6 +471,7 @@ describe('createPresenceComponent', () => {
expect(fnMotion).toHaveBeenCalledTimes(1);
expect(fnMotion).toHaveBeenCalledWith({ element: { animate: animateMock } /* mock of html element */ });

expect(animateMock).toHaveBeenCalledTimes(1);
expect(animateMock).toHaveBeenCalledWith(exitKeyframes, options);
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,6 @@ export type PresenceComponent<MotionParams extends Record<string, MotionParam> =
[MOTION_DEFINITION]: PresenceMotionFn<MotionParams>;
};

function shouldSkipAnimation(appear: boolean | undefined, isFirstMount: boolean, visible: boolean | undefined) {
return !appear && isFirstMount && !!visible;
}

export function createPresenceComponent<MotionParams extends Record<string, MotionParam> = {}>(
value: PresenceMotion | PresenceMotionFn<MotionParams>,
): PresenceComponent<MotionParams> {
Expand Down Expand Up @@ -143,17 +139,20 @@ export function createPresenceComponent<MotionParams extends Record<string, Moti
() => {
const element = elementRef.current;

if (!element || shouldSkipAnimation(optionsRef.current.appear, isFirstMount, visible)) {
if (!element) {
return;
}

const presenceMotion =
typeof value === 'function' ? value({ element, ...optionsRef.current.params }) : (value as PresenceMotion);
const atoms = visible ? presenceMotion.enter : presenceMotion.exit;

const atoms = visible ? presenceMotion.enter : presenceMotion.exit;
const direction: PresenceDirection = visible ? 'enter' : 'exit';
const applyInitialStyles = !visible && isFirstMount;
const skipAnimation = optionsRef.current.skipMotions;

// Heads up!
// Initial styles are applied when the component is mounted for the first time and "appear" is set to "false" (otherwise animations are triggered)
const applyInitialStyles = !optionsRef.current.appear && isFirstMount;
const skipAnimationByConfig = optionsRef.current.skipMotions;

if (!applyInitialStyles) {
handleMotionStart(direction);
Expand All @@ -174,7 +173,7 @@ export function createPresenceComponent<MotionParams extends Record<string, Moti
() => handleMotionCancel(direction),
);

if (skipAnimation) {
if (skipAnimationByConfig) {
handle.finish();
}

Expand Down

0 comments on commit dd8ff06

Please sign in to comment.