Skip to content

Commit

Permalink
build: add lottie-web dependency to extension (#27632)
Browse files Browse the repository at this point in the history
<!--
Please submit this PR as a draft initially.
Do not mark it as "Ready for review" until the template has been
completely filled out, and PR status checks have passed at least once.
-->

## **Description**
This PR adds a new dependency, [Lottie](https://airbnb.io/lottie), to
the extension. Lottie is an animation library.

The pros:
* Enhanced User Experience: Lottie enables us to add high-quality,
lightweight animations that will make our extension more visually
appealing.
* Community Support: It is well-maintained by Airbnb and has a strong
community behind it.
* Consistency with Mobile App: We’re already using Lottie in mobile, so
integrating it into the extension will provide a consistent user
experience across platforms.

With any additional dependency, we need a consider:
* supply chain attack surface managed by lavamoat.
* increase in bundle size – in this case < 100kB gzip-ed.

[More context on
slack](https://consensys.slack.com/archives/CTQAGKY5V/p1717772021376029).

[![Open in GitHub
Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/27632?quickstart=1)

## **Related issues**

* **Related:** #27650
– use lottie animation in the extension.

## **Manual testing steps**

1. Go to this page...
2.
3.

## **Screenshots/Recordings**

<!-- If applicable, add screenshots and/or recordings to visualize the
before and after of your change. -->

### **Before**

<!-- [screenshots/recordings] -->

### **After**

<!-- [screenshots/recordings] -->

## **Pre-merge author checklist**

- [ ] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask
Extension Coding
Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.github/guidelines/CODING_GUIDELINES.md).
- [ ] I've completed the PR template to the best of my ability
- [ ] I’ve included tests if applicable
- [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [ ] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-extension/blob/develop/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.

## **Pre-merge reviewer checklist**

- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.

---------

Co-authored-by: MetaMask Bot <[email protected]>
Co-authored-by: legobeat <[email protected]>
  • Loading branch information
3 people authored Oct 7, 2024
1 parent 93500d2 commit 8dedd3c
Show file tree
Hide file tree
Showing 7 changed files with 232 additions and 1 deletion.
6 changes: 5 additions & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ module.exports = {
// TODO: enable resetMocks
// resetMocks: true,
restoreMocks: true,
setupFiles: ['<rootDir>/test/setup.js', '<rootDir>/test/env.js'],
setupFiles: [
'jest-canvas-mock',
'<rootDir>/test/setup.js',
'<rootDir>/test/env.js',
],
setupFilesAfterEnv: ['<rootDir>/test/jest/setup.js'],
testMatch: [
'<rootDir>/app/scripts/**/*.test.(js|ts|tsx)',
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,7 @@
"localforage": "^1.9.0",
"lodash": "^4.17.21",
"loglevel": "^1.8.1",
"lottie-web": "^5.12.2",
"luxon": "^3.2.1",
"nanoid": "^2.1.6",
"pify": "^5.0.0",
Expand Down
8 changes: 8 additions & 0 deletions test/jest/setup.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
// This file is for Jest-specific setup only and runs before our Jest tests.
import '../helpers/setup-after-helper';

jest.mock('webextension-polyfill', () => {
return {
runtime: {
getManifest: () => ({ manifest_version: 2 }),
},
};
});

jest.mock('../../ui/hooks/usePetnamesEnabled', () => ({
usePetnamesEnabled: () => false,
}));
Expand Down
2 changes: 2 additions & 0 deletions ui/components/component-library/lottie-animation/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { LottieAnimation } from './lottie-animation';
export type { LottieAnimationProps } from './lottie-animation';
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import React from 'react';
import { render, act } from '@testing-library/react';
import lottie from 'lottie-web/build/player/lottie_light';
import { LottieAnimation } from './lottie-animation';

// Mock lottie-web
jest.mock('lottie-web/build/player/lottie_light', () => {
const eventListeners: { [key: string]: (() => void) | undefined } = {};
return {
loadAnimation: jest.fn(() => ({
destroy: jest.fn(),
addEventListener: jest.fn((event: string, callback: () => void) => {
eventListeners[event] = callback;
}),
removeEventListener: jest.fn((event: string) => {
delete eventListeners[event];
}),
// Method to trigger the 'complete' event in tests
triggerComplete: () => eventListeners.complete?.(),
})),
};
});

describe('LottieAnimation', () => {
const mockData = {
/* Your mock animation data here */
};
const mockPath = 'https://example.com/animation.json';

it('renders without crashing', () => {
const { container } = render(<LottieAnimation data={mockData} />);
expect(container.firstChild).toBeInTheDocument();
});

it('applies custom className', () => {
const customClass = 'custom-class';
const { container } = render(
<LottieAnimation data={mockData} className={customClass} />,
);
expect(container.firstChild).toHaveClass(customClass);
});

it('applies custom style', () => {
const customStyle = { width: '100px', height: '100px' };
const { container } = render(
<LottieAnimation data={mockData} style={customStyle} />,
);
const element = container.firstChild as HTMLElement;
expect(element).toHaveStyle('width: 100px');
expect(element).toHaveStyle('height: 100px');
});

it('calls lottie.loadAnimation with correct config when using data', () => {
render(<LottieAnimation data={mockData} loop={false} autoplay={false} />);

expect(lottie.loadAnimation).toHaveBeenCalledWith(
expect.objectContaining({
animationData: mockData,
loop: false,
autoplay: false,
renderer: 'svg',
container: expect.any(HTMLElement),
}),
);
});

it('calls lottie.loadAnimation with correct config when using path', () => {
render(<LottieAnimation path={mockPath} loop={true} autoplay={true} />);

expect(lottie.loadAnimation).toHaveBeenCalledWith(
expect.objectContaining({
path: mockPath,
loop: true,
autoplay: true,
renderer: 'svg',
container: expect.any(HTMLElement),
}),
);
});

it('logs an error when neither data nor path is provided', () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
render(<LottieAnimation />);

expect(consoleSpy).toHaveBeenCalledWith(
'LottieAnimation: Exactly one of data or path must be provided',
);
consoleSpy.mockRestore();
});

it('logs an error when both data and path are provided', () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
render(<LottieAnimation data={mockData} path={mockPath} />);

expect(consoleSpy).toHaveBeenCalledWith(
'LottieAnimation: Exactly one of data or path must be provided',
);
consoleSpy.mockRestore();
});

it('calls onComplete when animation completes', () => {
const onCompleteMock = jest.fn();

render(<LottieAnimation data={mockData} onComplete={onCompleteMock} />);

const animationInstance = (lottie.loadAnimation as jest.Mock).mock
.results[0].value;

act(() => {
animationInstance.triggerComplete();
});

expect(onCompleteMock).toHaveBeenCalledTimes(1);
});

it('removes event listener on unmount', () => {
const onCompleteMock = jest.fn();

const { unmount } = render(
<LottieAnimation data={mockData} onComplete={onCompleteMock} />,
);

const animationInstance = (lottie.loadAnimation as jest.Mock).mock
.results[0].value;

unmount();

expect(animationInstance.removeEventListener).toHaveBeenCalledWith(
'complete',
expect.any(Function),
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import React, { useEffect, useRef } from 'react';
import {
AnimationConfigWithData,
AnimationConfigWithPath,
AnimationItem,
} from 'lottie-web';
// Use lottie_light to avoid unsafe-eval which breaks the CSP
// https://github.com/airbnb/lottie-web/issues/289#issuecomment-1454909624
import lottie from 'lottie-web/build/player/lottie_light';

export type LottieAnimationProps = {
data?: object;
path?: string;
loop?: boolean;
autoplay?: boolean;
style?: React.CSSProperties;
className?: string;
onComplete?: () => void;
};

export const LottieAnimation: React.FC<LottieAnimationProps> = ({
data,
path,
loop = true,
autoplay = true,
style = {},
className = '',
onComplete = () => null,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const animationInstance = useRef<AnimationItem | null>(null);

useEffect(() => {
if (!containerRef.current) {
console.error('LottieAnimation: containerRef is null');
return () => null;
}

if (Boolean(data) === Boolean(path)) {
console.error(
'LottieAnimation: Exactly one of data or path must be provided',
);
return () => null;
}

const animationConfig: AnimationConfigWithData | AnimationConfigWithPath = {
container: containerRef.current,
renderer: 'svg',
loop,
autoplay,
...(data ? { animationData: data } : { path }),
};

try {
animationInstance.current = lottie.loadAnimation(animationConfig);
animationInstance.current.addEventListener('complete', onComplete);

animationInstance.current.addEventListener('error', (error) => {
console.error('LottieAnimation error:', error);
});
} catch (error) {
console.error('Failed to load animation:', error);
}

return () => {
if (animationInstance.current) {
animationInstance.current.removeEventListener('complete', onComplete);
animationInstance.current.destroy();
animationInstance.current = null;
}
};
}, [data, path, loop, autoplay, onComplete]);

return <div ref={containerRef} style={style} className={className}></div>;
};
8 changes: 8 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -25302,6 +25302,13 @@ __metadata:
languageName: node
linkType: hard

"lottie-web@npm:^5.12.2":
version: 5.12.2
resolution: "lottie-web@npm:5.12.2"
checksum: 10/cd377d54a675b37ac9359306b84097ea402dff3d74a2f45e6e0dbcff1df94b3a978e92e48fd34765754bdbb94bd2d8d4da31954d95f156e77489596b235cac91
languageName: node
linkType: hard

"lower-case@npm:^2.0.2":
version: 2.0.2
resolution: "lower-case@npm:2.0.2"
Expand Down Expand Up @@ -26295,6 +26302,7 @@ __metadata:
lodash: "npm:^4.17.21"
loglevel: "npm:^1.8.1"
loose-envify: "npm:^1.4.0"
lottie-web: "npm:^5.12.2"
luxon: "npm:^3.2.1"
mocha: "npm:^10.2.0"
mocha-junit-reporter: "npm:^2.2.1"
Expand Down

0 comments on commit 8dedd3c

Please sign in to comment.