├── .eslintignore
├── .eslintrc.cjs
├── .github
├── FUNDING.yml
├── ISSUE_TEMPLATE.md
├── PULL_REQUEST_TEMPLATE.md
└── workflows
│ ├── pull-request.yml
│ └── push.yml
├── .gitignore
├── .husky
└── pre-commit
├── .prettierignore
├── .releaseit.cjs
├── .yarn
├── plugins
│ └── @yarnpkg
│ │ └── plugin-interactive-tools.cjs
└── releases
│ └── yarn-berry.cjs
├── .yarnrc.yml
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── examples
├── tests
│ └── jest.test.tsx
└── ui
│ └── index.js
├── jest.config.js
├── lint-staged.config.cjs
├── package.json
├── prettier.config.cjs
├── scripts
├── build.mjs
└── setup-jest.js
├── src
├── Benchmark.tsx
├── __tests__
│ ├── Benchmark.test.tsx
│ └── math.test.ts
├── index.ts
├── math.ts
├── timing.ts
└── types.ts
├── tsconfig.base.json
├── tsconfig.check.json
├── tsconfig.json
└── yarn.lock
/.eslintignore:
--------------------------------------------------------------------------------
1 | dist/*
2 | node_modules/*
3 | examples/*/node_modules/*
4 | flow-typed/*
5 | coverage/*
6 | tmp/*
7 |
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | module.exports = {
4 | parser: '@typescript-eslint/parser',
5 | parserOptions: {
6 | ecmaVersion: 'latest',
7 | sourceType: 'module',
8 | },
9 |
10 | env: {
11 | commonjs: false,
12 | es6: true,
13 | },
14 |
15 | plugins: ['@typescript-eslint'],
16 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:react/recommended'],
17 |
18 | rules: {
19 | 'no-console': 'error',
20 | 'no-undef': 'off',
21 | indent: 'off', // prettier instead
22 |
23 | '@typescript-eslint/ban-ts-comment': 'off', // sometimes you're smarter
24 | '@typescript-eslint/no-unused-vars': ['error', { ignoreRestSiblings: true }],
25 | '@typescript-eslint/no-var-requires': 'off',
26 | '@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }],
27 | '@typescript-eslint/no-empty-function': 'off',
28 | '@typescript-eslint/no-non-null-assertion': 'off',
29 | },
30 |
31 | settings: {
32 | react: {
33 | version: 'detect',
34 | },
35 | },
36 |
37 | overrides: [
38 | {
39 | files: ['**/*.test.tsx', '**/*.test.ts'],
40 | plugins: ['jest'],
41 | extends: ['plugin:jest/recommended'],
42 | rules: {
43 | 'jest/consistent-test-it': ['error', { fn: 'test' }],
44 | },
45 | },
46 | ],
47 | };
48 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github:
4 | - paularmstrong
5 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE.md:
--------------------------------------------------------------------------------
1 |
4 |
5 | # Problem
6 |
7 | A short explanation of your problem or use-case is helpful!
8 |
9 | **Input**
10 |
11 | Here's code that shows what I'm doing:
12 |
13 | ```js
14 | // insert code here
15 | ```
16 |
17 | **Output**
18 |
19 | Here's what I expect to see when I run the above:
20 |
21 | ```js
22 | // insert output here
23 | ```
24 |
25 | Here's what I _actually_ see when I run the above:
26 |
27 | ```js
28 | // insert expectation here
29 | ```
30 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 |
4 |
5 | # Problem
6 |
7 | Explain the problem that this pull request aims to resolve.
8 |
9 | # Solution
10 |
11 | Explain your approach. Sometimes it helps to justify your approach against some others that you didn't choose to explain why yours is better.
12 |
13 | # TODO
14 |
15 | - [ ] Add & update tests
16 | - [ ] Ensure CI is passing (lint, tests, flow)
17 | - [ ] Update relevant documentation and/or examples
18 |
--------------------------------------------------------------------------------
/.github/workflows/pull-request.yml:
--------------------------------------------------------------------------------
1 | name: Pull Request
2 |
3 | on: pull_request
4 |
5 | jobs:
6 | housekeeping:
7 | name: Housekeeping
8 | runs-on: ubuntu-latest
9 | steps:
10 | - uses: amannn/action-semantic-pull-request@v4
11 | env:
12 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
13 | with:
14 | types: |
15 | fix
16 | feat
17 | infra
18 | perf
19 | chore
20 | test
21 | docs
22 | refactor
23 | requireScope: false
24 |
25 | build:
26 | name: Build
27 | runs-on: ubuntu-latest
28 | steps:
29 | - uses: actions/checkout@v3
30 | - uses: actions/setup-node@v3
31 | with:
32 | node-version: 16
33 | cache: yarn
34 |
35 | - run: yarn install
36 |
37 | - name: Build
38 | run: yarn build
39 |
40 | run_tests:
41 | name: Tests
42 | runs-on: ubuntu-latest
43 | steps:
44 | - uses: actions/checkout@v3
45 | - uses: actions/setup-node@v3
46 | with:
47 | node-version: 16
48 | cache: yarn
49 |
50 | - run: yarn install
51 |
52 | - name: Tests
53 | run: yarn test
54 |
55 | typecheck:
56 | name: Typecheck
57 | runs-on: ubuntu-latest
58 | steps:
59 | - uses: actions/checkout@v3
60 | - uses: actions/setup-node@v3
61 | with:
62 | node-version: 16
63 | cache: yarn
64 |
65 | - run: yarn install
66 |
67 | - name: Build
68 | run: yarn build
69 |
70 | - name: Typecheck
71 | run: yarn tsc:check
72 |
73 | lint:
74 | name: Lint
75 | runs-on: ubuntu-latest
76 | steps:
77 | - uses: actions/checkout@v3
78 | - uses: actions/setup-node@v3
79 | with:
80 | node-version: 16
81 | cache: yarn
82 |
83 | - run: yarn install
84 |
85 | - name: Lint
86 | run: yarn lint
87 |
88 | - name: Format
89 | run: yarn format --check .
90 |
--------------------------------------------------------------------------------
/.github/workflows/push.yml:
--------------------------------------------------------------------------------
1 | name: On push
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | jobs:
9 | run_tests:
10 | name: Typecheck, Test, Lint
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: actions/checkout@v3
14 | - uses: actions/setup-node@v3
15 | with:
16 | node-version: 16
17 |
18 | - name: Get yarn cache directory path
19 | id: yarn-cache-dir-path
20 | run: echo "::set-output name=dir::$(yarn config get cacheFolder)"
21 |
22 | - uses: actions/cache@v3
23 | id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
24 | with:
25 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
26 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
27 | restore-keys: |
28 | ${{ runner.os }}-yarn-
29 |
30 | - run: yarn install
31 |
32 | - name: Build
33 | run: yarn build
34 |
35 | - name: Typecheck
36 | run: yarn tsc:check
37 |
38 | - name: Tests
39 | run: yarn test
40 |
41 | - name: Lint
42 | run: yarn lint
43 |
44 | - name: Format
45 | run: yarn format --check .
46 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 |
3 | .yarn/*
4 | !.yarn/releases
5 | !.yarn/plugins
6 | !.yarn/sdks
7 | !.yarn/versions
8 | .pnp.*
9 |
10 | node_modules/*
11 | dist/*
12 | *.log
13 | coverage/*
14 | .coveralls.yml
15 |
16 | *.tsbuildinfo
17 | *.d.ts.map
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | yarn lint-staged
5 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | .git
2 | .yarn
3 | node_modules/*
4 | dist
5 | .next
6 |
7 | *.tsbuildinfo
8 | *.d.ts.map
9 |
--------------------------------------------------------------------------------
/.releaseit.cjs:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | module.exports = {
4 | git: {
5 | commitMessage: 'chore: release v${version}',
6 | requireCleanWorkingDir: true,
7 | },
8 | plugins: {
9 | '@release-it/conventional-changelog': {
10 | preset: {
11 | name: 'conventionalcommits',
12 | types: [
13 | { type: 'fix', section: '🐞 Bug Fixes' },
14 | { type: 'feat', section: '🌟 Features' },
15 | { type: 'infra', section: '🏗 Internal improvements', hidden: true },
16 | { type: 'perf', section: '⚡️ Performance enhanchements' },
17 | { type: 'chore', section: '🧼 Chores', hidden: true },
18 | { type: 'test', section: '✅ Test coverage', hidden: true },
19 | { type: 'docs', section: '📚 Documentation' },
20 | { type: 'refactor', section: '♻️ Refactors' },
21 | ],
22 | },
23 | infile: 'CHANGELOG.md',
24 | header: '# Changelog',
25 | },
26 | },
27 | hooks: {
28 | 'after:@release-it/conventional-changelog:bump': ['yarn'],
29 | 'before:init': ['yarn build'],
30 | },
31 | npm: false,
32 | github: false,
33 | };
34 |
--------------------------------------------------------------------------------
/.yarnrc.yml:
--------------------------------------------------------------------------------
1 | nodeLinker: node-modules
2 |
3 | npmPublishAccess: public
4 |
5 | plugins:
6 | - path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
7 | spec: '@yarnpkg/plugin-interactive-tools'
8 |
9 | yarnPath: .yarn/releases/yarn-berry.cjs
10 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | ## Issues
4 |
5 | 1. Follow the [Issue Template](./.github/ISSUE_TEMPLATE.md) provided when opening a new Issue.
6 | 2. Provide a minimal, reproducible test-case.
7 | 3. Do not ask for help or usage questions in Issues. Use [StackOverflow](http://stackoverflow.com) for those.
8 |
9 | ## Pull Requests
10 |
11 | First, thank you so much for contributing to open source and this project!
12 |
13 | Follow the instructions on the Pull Request Template (shown when you open a new PR) and make sure you've done the following:
14 |
15 | - [ ] Add & update tests
16 | - [ ] Ensure CI is passing (lint, tests)
17 | - [ ] Update relevant documentation and/or examples
18 |
19 | ## Setup
20 |
21 | This package uses [yarn](https://yarnpkg.com) for development dependency management. Ensure you have it installed before continuing.
22 |
23 | ```sh
24 | yarn
25 | ```
26 |
27 | ## Running Tests
28 |
29 | ```sh
30 | yarn test
31 | ```
32 |
33 | ## Running Flowtype Checks
34 |
35 | ```sh
36 | yarn flow
37 | ```
38 |
39 | ## Lint
40 |
41 | Standard code style is nice. ESLint is used to ensure we continue to write similar code. The following command will also fix simple issues, like spacing and alphabetized imports:
42 |
43 | ```sh
44 | yarn lint
45 | ```
46 |
47 | ## Building
48 |
49 | ```sh
50 | yarn build
51 | ```
52 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2017 Paul Armstrong
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React Component Benchmark [](https://www.npmjs.com/package/react-component-benchmarkhttps://www.npmjs.com/package/react-component-benchmark) [](https://github.com/paularmstrong/react-component-benchmark/actions)
2 |
3 | This project aims to provide a method for gathering benchmarks of component tree _mount_, _update_, and _unmount_ timings.
4 |
5 | Please note that the values returned are _estimates_. Since this project does not hook into the React renderer directly, the values gathered are not 100% accurate and may vary slightly because they're taken from a wrapping component. That being said, running a large sample set should give you a confident benchmark metric.
6 |
7 | ## Motivation
8 |
9 | Historically, React has provided `react-addons-perf` in order to help gain insight into the performance of mounting, updating, and unmounting components. Unfortunately, as of React 16, it has been deprecated. Additionally, before deprecation, it was not usable in production React builds, making it less useful for many applications.
10 |
11 | ## Usage
12 |
13 | See the [examples](./examples) directory for ideas on how you might integrate this into your own project, whether in your [user-interface](#build-a-ui) or your [automated tests](./examples/tests/jest.test.js).
14 |
15 | ### Quick Start
16 |
17 | ```js
18 | import { Benchmark } from 'react-component-benchmark';
19 |
20 | function MyComponentBenchmark() {
21 | const ref = React.useRef();
22 |
23 | const handleComplete = React.useCallback((results) => {
24 | console.log(results);
25 | }, []);
26 |
27 | const handleStart = () => {
28 | ref.current.start();
29 | };
30 |
31 | return (
32 |
33 |
34 |
43 |
44 | );
45 | }
46 | ```
47 |
48 | ### In tests
49 |
50 | See [examples/tests](./examples/tests/) for various test integrations.
51 |
52 | ### Build a UI
53 |
54 | - Demo: https://react-component-benchmark.vercel.app/
55 | - See, edit, and fork the code from [paularmstrong/react-component-benchmark-example](https://github.com/paularmstrong/react-component-benchmark-example) or [codesandbox](https://codesandbox.io/s/react-component-benchmark-uy88d)
56 | - You can also do the same using Preact! https://codesandbox.io/s/react-component-benchmark-preact-69inw
57 |
58 | ### Benchmark props
59 |
60 | | key | type | description |
61 | | ---------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
62 | | `component` | `typeof React.Component` | The component that you would like to benchmark |
63 | | `componentProps` | `object` | Properties to be given to `component` when rendering |
64 | | `includeLayout` | `boolean` | Estimate the amount of time that the browser's rendering engine requires to layout the rendered HTML. Only available for `'mount'` and `'update'` benchmark types. Default: `false` |
65 | | `onComplete` | `(x: BenchResultsType) => void` | Receives the benchmark [results](#results) when the benchmarking is complete |
66 | | `samples` | `number` | Samples to run (default `50`) |
67 | | `timeout` | `number` | Amount of time in milliseconds to stop running (default `10000`) |
68 | | `type` | `string` | One of `'mount'`, `'update'`, or `'unmount'`. Also available from `BenchmarkType`. |
69 |
70 | ## Results
71 |
72 | > Note: All times and timestamps are in milliseconds. High resolution times provided when available.
73 |
74 | | key | type | description |
75 | | ------------- | -------------------------------- | ---------------------------------------------------------------------------------------------------------- |
76 | | `...` | `...ComputedResult` | All values from the `ComputedResult` table |
77 | | `startTime` | `number` | Timestamp of when the run started |
78 | | `endTime` | `number` | Timestamp of when the run completed |
79 | | `runTime` | `number` | Amount of time that it took to run all samples. |
80 | | `sampleCount` | `number` | The number of samples actually run. May be less than requested if the `timeout` was hit. |
81 | | `samples` | `Array<{ start, end, elapsed }>` | Raw sample data |
82 | | `layout` | `ComputedResult` | The benchmark results for the browser rendering engine layout, if available (see `includeLayout` property) |
83 |
84 | ### `ComputedResult`
85 |
86 | | key | type | description |
87 | | -------- | -------- | ------------------------------------------------------- |
88 | | `max` | `number` | Maximum time elapsed |
89 | | `min` | `number` | Minimum time elapsed |
90 | | `median` | `number` | Median time elapsed |
91 | | `mean` | `number` | Mean time elapsed |
92 | | `stdDev` | `number` | Standard deviation of all elapsed times |
93 | | `p70` | `number` | 70th percentile for time elapsed: `mean + stdDev` |
94 | | `p95` | `number` | 95th percentile for time elapsed: `mean + (stdDev * 2)` |
95 | | `p99` | `number` | 99th percentile for time elapsed: `mean + (stdDev * 3)` |
96 |
--------------------------------------------------------------------------------
/examples/tests/jest.test.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Benchmark } from '../../src';
3 | import type { BenchmarkType, BenchmarkRef, BenchResultsType } from '../../src';
4 | import { act, render, waitFor } from '@testing-library/react';
5 |
6 | // This is slow on purpose for demonstration purposes
7 | function slowFibonacci(num: number): number {
8 | if (num < 2) {
9 | return num;
10 | }
11 |
12 | return slowFibonacci(num - 1) + slowFibonacci(num - 2);
13 | }
14 |
15 | interface Props {
16 | component: React.ComponentType;
17 | props?: Record;
18 | samples?: number;
19 | type?: BenchmarkType;
20 | }
21 |
22 | /**
23 | * A wrapper function to make benchmarking in tests a bit more reusable.
24 | * You might tune this to your specific needs
25 | * @param {React.Component} options.component The component you'd like to benchmark
26 | * @param {Object} options.props Props for your component
27 | * @param {Number} options.samples Number of samples to take. default 50 is a safe number
28 | * @param {String} options.type Lifecycle of a component ('mount', 'update', or 'unmount')
29 | * @return {Object} Results object
30 | */
31 | async function runBenchmark({ component, props, samples = 50, type = 'mount' }: Props) {
32 | // Benchmarking requires a real time system and not mocks. Ensure you're not using fake timers
33 | jest.useRealTimers();
34 |
35 | const ref = React.createRef();
36 |
37 | let results: BenchResultsType;
38 | const handleComplete = jest.fn((res) => {
39 | results = res;
40 | });
41 |
42 | render(
43 |
51 | );
52 |
53 | act(() => {
54 | ref.current?.start();
55 | });
56 |
57 | await waitFor(() => expect(handleComplete).toHaveBeenCalled(), { timeout: 10000 });
58 | // @ts-ignore
59 | return results;
60 | }
61 |
62 | describe('Benchmark', () => {
63 | test('mounts slowly', async () => {
64 | function SlowMount() {
65 | // Running a slow calculation on mount
66 | const fib = slowFibonacci(32);
67 | return {JSON.stringify(fib)}
;
68 | }
69 |
70 | const results = await runBenchmark({ component: SlowMount });
71 | expect(results.mean).toBeGreaterThan(10);
72 | });
73 |
74 | test('mounts in a reasonable amount of time', async () => {
75 | // Run the slow calculation somewhere else ahead of time
76 | const fib = slowFibonacci(32);
77 | function FastMount() {
78 | return {JSON.stringify(fib)}
;
79 | }
80 |
81 | const results = await runBenchmark({ component: FastMount });
82 |
83 | expect(results.mean).toBeLessThan(4);
84 | });
85 |
86 | test('updates slowly', async () => {
87 | function SlowUpdate() {
88 | // Run a slow calculation on every render
89 | const fib = slowFibonacci(32);
90 | return {JSON.stringify(fib)}
;
91 | }
92 |
93 | const results = await runBenchmark({ component: SlowUpdate, type: 'update' });
94 |
95 | expect(results.mean).toBeGreaterThan(10);
96 | });
97 |
98 | test('updates in a reasonable amount of time', async () => {
99 | function FastUpdates() {
100 | // Memoize the slow calculation - slow mount, but fast updates
101 | const fib = React.useMemo(() => slowFibonacci(32), []);
102 | return {JSON.stringify(fib)}
;
103 | }
104 |
105 | const results = await runBenchmark({ component: FastUpdates, type: 'update' });
106 |
107 | expect(results.mean).toBeLessThan(4);
108 | });
109 |
110 | test('unmounts slowly', async () => {
111 | function SlowUnmount() {
112 | React.useEffect(() => {
113 | // return function from useEffect runs on teardown
114 | return () => {
115 | slowFibonacci(32);
116 | };
117 | }, []);
118 | return ;
119 | }
120 |
121 | const results = await runBenchmark({ component: SlowUnmount, type: 'unmount' });
122 |
123 | expect(results.mean).toBeGreaterThan(10);
124 | });
125 |
126 | test('unmounts in a reasonable amount of time', async () => {
127 | function FastUnmount() {
128 | return ;
129 | }
130 |
131 | const results = await runBenchmark({ component: FastUnmount, type: 'unmount' });
132 |
133 | expect(results.mean).toBeLessThan(4);
134 | });
135 | });
136 |
--------------------------------------------------------------------------------
/examples/ui/index.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Benchmark, BenchmarkType } from 'react-component-benchmark';
3 |
4 | export function MyComponentBenchmark() {
5 | const ref = React.useRef();
6 |
7 | const handleComplete = React.useCallback((results) => {
8 | // eslint-disable-next-line no-console
9 | console.log(results);
10 | }, []);
11 |
12 | const handleStart = () => {
13 | ref.start();
14 | };
15 |
16 | return (
17 |
18 |
19 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | resetMocks: true,
3 | roots: [''],
4 | setupFilesAfterEnv: ['/scripts/setup-jest.js'],
5 | testEnvironment: 'jsdom',
6 | transform: {
7 | '\\.[jt]sx?$': ['esbuild-jest', { sourcemap: true }],
8 | },
9 | };
10 |
--------------------------------------------------------------------------------
/lint-staged.config.cjs:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | module.exports = {
4 | '*.{cjs,mjs,js,ts,tsx}': ['yarn lint --fix', () => 'yarn tsc:check'],
5 | '*': 'yarn format --write',
6 | };
7 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-component-benchmark",
3 | "version": "2.0.0",
4 | "description": "A component utility for estimating benchmarks of React components",
5 | "main": "./dist/index.cjs",
6 | "module": "./dist/index.js",
7 | "source": "./src/index.ts",
8 | "types": "./dist/src/index.d.ts",
9 | "sideEffects": false,
10 | "repository": {
11 | "type": "git",
12 | "url": "https://github.com/paularmstrong/react-comonent-benchmark"
13 | },
14 | "homepage": "https://github.com/paularmstrong/react-component-benchmark",
15 | "author": "Paul Armstrong ",
16 | "license": "MIT",
17 | "scripts": {
18 | "prebuild": "rimraf dist",
19 | "build": "node scripts/build.mjs",
20 | "clean": "rimraf dist",
21 | "lint": "yarn lint:cmd --fix",
22 | "lint:cmd": "eslint ./",
23 | "format": "prettier --ignore-unknown",
24 | "pre-release": "release-it --preRelease=alpha",
25 | "release": "release-it",
26 | "test": "jest",
27 | "tsc:check": "tsc -p tsconfig.check.json"
28 | },
29 | "devDependencies": {
30 | "@release-it/conventional-changelog": "^5.1.1",
31 | "@testing-library/react": "^13.4.0",
32 | "@types/jest": "^29.2.0",
33 | "@types/node": "^18.11.7",
34 | "@types/react": "^18.0.24",
35 | "@typescript-eslint/eslint-plugin": "^5.41.0",
36 | "@typescript-eslint/parser": "^5.41.0",
37 | "esbuild": "^0.15.12",
38 | "esbuild-jest": "^0.5.0",
39 | "esbuild-node-externals": "^1.5.0",
40 | "eslint": "^8.26.0",
41 | "eslint-plugin-flowtype": "^5.2.0",
42 | "eslint-plugin-jest": "^27.1.3",
43 | "eslint-plugin-react": "^7.31.10",
44 | "husky": "^8.0.1",
45 | "jest": "^29.2.2",
46 | "jest-environment-jsdom": "^29.2.2",
47 | "lint-staged": "^13.0.3",
48 | "prettier": "^2.7.1",
49 | "react": "^18.2.0",
50 | "react-dom": "^18.2.0",
51 | "react-test-renderer": "^18.2.0",
52 | "release-it": "^15.5.0",
53 | "rimraf": "^3.0.2",
54 | "typescript": "^4.8.4"
55 | },
56 | "peerDependencies": {
57 | "react": "^18"
58 | },
59 | "files": [
60 | "dist/",
61 | "LICENSE",
62 | "README.md"
63 | ]
64 | }
65 |
--------------------------------------------------------------------------------
/prettier.config.cjs:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | module.exports = {
4 | printWidth: 120,
5 | singleQuote: true,
6 | useTabs: true,
7 | };
8 |
--------------------------------------------------------------------------------
/scripts/build.mjs:
--------------------------------------------------------------------------------
1 | import { execSync } from 'child_process';
2 | import { createRequire } from 'module';
3 | import path from 'path';
4 | import { fileURLToPath } from 'url';
5 | import esbuild from 'esbuild';
6 | import { nodeExternalsPlugin } from 'esbuild-node-externals';
7 | const require = createRequire(import.meta.url);
8 |
9 | const __dirname = path.dirname(fileURLToPath(import.meta.url));
10 | const root = path.resolve(__dirname, '..');
11 |
12 | /* eslint-disable no-console */
13 |
14 | const buildConfig = {
15 | bundle: true,
16 | sourcemap: true,
17 | target: ['esnext'],
18 | plugins: [nodeExternalsPlugin()],
19 | };
20 |
21 | const pkgRoot = path.resolve(__dirname, '..');
22 | const pkgPath = path.join(pkgRoot, 'package.json');
23 |
24 | async function build() {
25 | console.time('Total');
26 |
27 | const { source: src, main, module: moduleFile } = require(pkgPath);
28 | const entryPoints = [path.resolve(pkgRoot, src)];
29 |
30 | console.time('module');
31 | await esbuild.build({
32 | ...buildConfig,
33 | plugins: [
34 | nodeExternalsPlugin({
35 | packagePath: pkgPath,
36 | }),
37 | ],
38 | entryPoints,
39 | format: 'esm',
40 | outfile: path.resolve(pkgRoot, moduleFile),
41 | });
42 | console.timeEnd('module');
43 |
44 | console.time('commonjs');
45 | await esbuild.build({
46 | ...buildConfig,
47 | plugins: [
48 | nodeExternalsPlugin({
49 | packagePath: pkgPath,
50 | }),
51 | ],
52 | entryPoints,
53 | format: 'cjs',
54 | outfile: path.resolve(pkgRoot, main),
55 | });
56 | console.timeEnd('commonjs');
57 |
58 | console.time('types');
59 | execSync('yarn tsc -b', { cwd: root });
60 | console.timeEnd('types');
61 |
62 | console.timeEnd('Total');
63 | }
64 |
65 | build();
66 |
--------------------------------------------------------------------------------
/scripts/setup-jest.js:
--------------------------------------------------------------------------------
1 | import 'regenerator-runtime/runtime';
2 |
--------------------------------------------------------------------------------
/src/Benchmark.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as Timing from './timing';
3 | import type { BenchmarkRef, BenchmarkType, BenchResultsType, Sample } from './types';
4 | import { getMean, getMedian, getStdDev } from './math';
5 |
6 | const sortNumbers = (a: number, b: number): number => a - b;
7 |
8 | interface Props {
9 | component: React.ComponentType;
10 | componentProps?: T;
11 | includeLayout?: boolean;
12 | onComplete: (x: BenchResultsType) => void;
13 | samples: number;
14 | timeout?: number;
15 | type?: BenchmarkType;
16 | }
17 |
18 | type State = {
19 | running: boolean;
20 | startTime: number;
21 | cycle: number;
22 | samples: Array;
23 | };
24 |
25 | const initialState: State = {
26 | running: false,
27 | startTime: 0,
28 | cycle: 0,
29 | samples: [],
30 | };
31 |
32 | type Action =
33 | | { type: 'START'; payload: number }
34 | | { type: 'START_SAMPLE'; payload: number }
35 | | { type: 'END_SAMPLE'; payload: number }
36 | | { type: 'END_LAYOUT'; payload: number }
37 | | { type: 'TICK' }
38 | | { type: 'RESET' };
39 |
40 | // eslint-disable-next-line @typescript-eslint/ban-types
41 | function BenchmarkInner(
42 | {
43 | component: Component,
44 | componentProps,
45 | includeLayout = false,
46 | onComplete,
47 | samples: numSamples,
48 | timeout = 10000,
49 | type = 'mount',
50 | }: Props,
51 | ref: React.Ref
52 | ) {
53 | const [{ running, cycle, samples, startTime }, dispatch] = React.useReducer(reducer, initialState);
54 |
55 | React.useImperativeHandle(ref, () => ({
56 | start: () => {
57 | dispatch({ type: 'START', payload: Timing.now() });
58 | },
59 | }));
60 |
61 | const shouldRender = getShouldRender(type, cycle);
62 | const shouldRecord = getShouldRecord(type, cycle);
63 | const isDone = getIsDone(type, cycle, numSamples);
64 |
65 | const handleComplete = React.useCallback(
66 | (startTime: number, endTime: number, samples: Array) => {
67 | const runTime = endTime - startTime;
68 | const sortedElapsedTimes = samples.map(({ elapsed }: { elapsed: number }): number => elapsed).sort(sortNumbers);
69 | const mean = getMean(sortedElapsedTimes);
70 | const stdDev = getStdDev(sortedElapsedTimes);
71 |
72 | const result: BenchResultsType = {
73 | startTime,
74 | endTime,
75 | runTime,
76 | sampleCount: samples.length,
77 | samples,
78 | max: sortedElapsedTimes[sortedElapsedTimes.length - 1],
79 | min: sortedElapsedTimes[0],
80 | median: getMedian(sortedElapsedTimes),
81 | mean,
82 | stdDev,
83 | p70: mean + stdDev,
84 | p95: mean + stdDev * 2,
85 | p99: mean + stdDev * 3,
86 | layout: undefined,
87 | };
88 |
89 | if (includeLayout) {
90 | const sortedLayoutTimes = samples.map(({ layout }: { layout: number }) => layout).sort(sortNumbers);
91 | const mean = getMean(sortedLayoutTimes);
92 | const stdDev = getStdDev(sortedLayoutTimes);
93 | result.layout = {
94 | max: sortedLayoutTimes[sortedLayoutTimes.length - 1],
95 | min: sortedLayoutTimes[0],
96 | median: getMedian(sortedLayoutTimes),
97 | mean,
98 | stdDev,
99 | p70: mean + stdDev,
100 | p95: mean + stdDev * 2,
101 | p99: mean + stdDev * 3,
102 | };
103 | }
104 |
105 | onComplete(result);
106 |
107 | dispatch({ type: 'RESET' });
108 | },
109 | [includeLayout, onComplete]
110 | );
111 |
112 | // useMemo causes this to actually run _before_ the component mounts
113 | // as opposed to useEffect, which will run after
114 | React.useMemo(() => {
115 | if (running && shouldRecord) {
116 | dispatch({ type: 'START_SAMPLE', payload: Timing.now() });
117 | }
118 | }, [cycle, running, shouldRecord]);
119 |
120 | React.useEffect(() => {
121 | if (!running) {
122 | return;
123 | }
124 |
125 | const now = Timing.now();
126 |
127 | if (shouldRecord && samples.length && samples[samples.length - 1].end < 0) {
128 | if (includeLayout && type !== 'unmount' && document.body) {
129 | document.body.offsetWidth;
130 | }
131 | const layoutEnd = Timing.now();
132 |
133 | dispatch({ type: 'END_SAMPLE', payload: now });
134 | dispatch({ type: 'END_LAYOUT', payload: layoutEnd - now });
135 | return;
136 | }
137 |
138 | const timedOut = now - startTime > timeout;
139 | if (!isDone && !timedOut) {
140 | setTimeout(() => {
141 | dispatch({ type: 'TICK' });
142 | }, 1);
143 | return;
144 | } else if (isDone || timedOut) {
145 | handleComplete(startTime, now, samples);
146 | }
147 | }, [includeLayout, running, isDone, samples, shouldRecord, shouldRender, startTime, timeout]);
148 |
149 | return running && shouldRender ? (
150 | // @ts-ignore forcing a testid for cycling
151 |
152 | ) : null;
153 | }
154 |
155 | // eslint-disable-next-line @typescript-eslint/ban-types
156 | export const Benchmark = React.forwardRef(BenchmarkInner) as (
157 | p: Props & { ref?: React.Ref }
158 | ) => React.ReactElement;
159 |
160 | function reducer(state: State = initialState, action: Action) {
161 | switch (action.type) {
162 | case 'START':
163 | return {
164 | ...state,
165 | startTime: action.payload,
166 | running: true,
167 | };
168 |
169 | case 'START_SAMPLE': {
170 | const samples = [...state.samples];
171 | samples.push({ start: action.payload, end: -Infinity, elapsed: -Infinity, layout: -Infinity });
172 | return {
173 | ...state,
174 | samples,
175 | };
176 | }
177 |
178 | case 'END_SAMPLE': {
179 | const samples = [...state.samples];
180 | const index = samples.length - 1;
181 | samples[index].end = action.payload;
182 | samples[index].elapsed = action.payload - samples[index].start;
183 | return {
184 | ...state,
185 | samples,
186 | };
187 | }
188 |
189 | case 'END_LAYOUT': {
190 | const samples = [...state.samples];
191 | const index = samples.length - 1;
192 | samples[index].layout = action.payload;
193 | return {
194 | ...state,
195 | samples,
196 | };
197 | }
198 |
199 | case 'TICK':
200 | return {
201 | ...state,
202 | cycle: state.cycle + 1,
203 | };
204 |
205 | case 'RESET':
206 | return initialState;
207 |
208 | default:
209 | return state;
210 | }
211 | }
212 |
213 | function getShouldRender(type: BenchmarkType, cycle: number): boolean {
214 | switch (type) {
215 | // Render every odd iteration (first, third, etc)
216 | // Mounts and unmounts the component
217 | case 'mount':
218 | case 'unmount':
219 | return !((cycle + 1) % 2);
220 | // Render every iteration (updates previously rendered module)
221 | case 'update':
222 | return true;
223 | default:
224 | return false;
225 | }
226 | }
227 |
228 | function getShouldRecord(type: BenchmarkType, cycle: number): boolean {
229 | switch (type) {
230 | // Record every odd iteration (when mounted: first, third, etc)
231 | case 'mount':
232 | return !((cycle + 1) % 2);
233 | // Record every iteration
234 | case 'update':
235 | return cycle !== 0;
236 | // Record every even iteration (when unmounted)
237 | case 'unmount':
238 | return !(cycle % 2);
239 | default:
240 | return false;
241 | }
242 | }
243 |
244 | function getIsDone(type: BenchmarkType, cycle: number, numSamples: number): boolean {
245 | switch (type) {
246 | case 'mount':
247 | case 'unmount':
248 | return cycle >= numSamples * 2 - 1;
249 | case 'update':
250 | return cycle >= numSamples;
251 | default:
252 | return true;
253 | }
254 | }
255 |
--------------------------------------------------------------------------------
/src/__tests__/Benchmark.test.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Benchmark } from '../Benchmark';
3 | import type { BenchmarkRef, BenchmarkType, BenchResultsType } from '../types';
4 | import { act, render, waitFor } from '@testing-library/react';
5 | import type { Sample } from '../types';
6 |
7 | interface Props {
8 | testID: number;
9 | }
10 |
11 | function Test({ testID }: Props) {
12 | return hello
;
13 | }
14 |
15 | describe('new', () => {
16 | beforeEach(() => {
17 | jest.useRealTimers();
18 | });
19 |
20 | test('renders nothing if not running', () => {
21 | const { getByTestId } = render();
22 | expect(() => getByTestId('test')).toThrow('Unable to find an element');
23 | });
24 |
25 | test.each(['mount', 'update', 'unmount'] as const)('samples for %s', async (type: BenchmarkType) => {
26 | const ref = React.createRef();
27 | const handleComplete = jest.fn();
28 | render(
29 |
37 | );
38 |
39 | act(() => {
40 | ref.current && ref.current.start();
41 | });
42 |
43 | await waitFor(() =>
44 | expect(handleComplete).toHaveBeenCalledWith(
45 | expect.objectContaining({
46 | sampleCount: 10,
47 | })
48 | )
49 | );
50 | });
51 |
52 | describe('results', () => {
53 | let results: BenchResultsType;
54 |
55 | beforeAll(async () => {
56 | const ref = React.createRef();
57 | const handleComplete = jest.fn();
58 | render();
59 | act(() => {
60 | ref.current && ref.current.start();
61 | });
62 |
63 | await waitFor(() =>
64 | // eslint-disable-next-line jest/no-standalone-expect
65 | expect(handleComplete).toHaveBeenCalled()
66 | );
67 | results = handleComplete.mock.calls[0][0];
68 | });
69 |
70 | test.each([
71 | ['startTime', 'number'],
72 | ['endTime', 'number'],
73 | ['runTime', 'number'],
74 | ['sampleCount', 'number'],
75 | ['max', 'number'],
76 | ['min', 'number'],
77 | ['median', 'number'],
78 | ['mean', 'number'],
79 | ['stdDev', 'number'],
80 | ['p70', 'number'],
81 | ['p95', 'number'],
82 | ['p99', 'number'],
83 | ] as const)('include a key %s that is a %s', (key: keyof BenchResultsType, type: string) => {
84 | expect(results).toHaveProperty(key);
85 | expect(typeof results[key]).toEqual(type);
86 | });
87 |
88 | test('includes an array for samples', () => {
89 | expect(results).toHaveProperty('samples');
90 | expect(Array.isArray(results.samples)).toBe(true);
91 | results.samples.forEach((sample: Sample) => {
92 | expect(sample).toHaveProperty('start');
93 | expect(sample).toHaveProperty('end');
94 | expect(sample).toHaveProperty('elapsed');
95 | expect(sample).toHaveProperty('layout');
96 | });
97 | });
98 | });
99 | });
100 |
--------------------------------------------------------------------------------
/src/__tests__/math.test.ts:
--------------------------------------------------------------------------------
1 | import { getStdDev, getMean, getMedian } from '../math';
2 |
3 | describe('getMean', () => {
4 | test('returns the mean of an array of numbers', () => {
5 | expect(getMean([2, 6, 1])).toEqual(3);
6 | });
7 | });
8 |
9 | describe('getMedian', () => {
10 | test('returns the median of an array of numbers', () => {
11 | expect(getMedian([10, 4, 3, 1])).toEqual(3.5);
12 | });
13 | });
14 |
15 | describe('getStdDev', () => {
16 | test('returns the standard deviation of an array of numbers', () => {
17 | expect(Math.floor(getStdDev([10, 12, 4, 26, 1]) * 100) / 100).toEqual(8.66);
18 | });
19 | });
20 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Benchmark';
2 | export * from './types';
3 |
--------------------------------------------------------------------------------
/src/math.ts:
--------------------------------------------------------------------------------
1 | type ValuesType = Array;
2 |
3 | export const getStdDev = (values: ValuesType): number => {
4 | const avg = getMean(values);
5 |
6 | const squareDiffs = values.map((value: number) => {
7 | const diff = value - avg;
8 | return diff * diff;
9 | });
10 |
11 | return Math.sqrt(getMean(squareDiffs));
12 | };
13 |
14 | export const getMean = (values: ValuesType): number => {
15 | const sum = values.reduce((sum: number, value: number) => sum + value, 0);
16 | return sum / values.length;
17 | };
18 |
19 | export const getMedian = (values: ValuesType): number => {
20 | if (values.length === 1) {
21 | return values[0];
22 | }
23 |
24 | const numbers = values.sort((a: number, b: number) => a - b);
25 | return (numbers[(numbers.length - 1) >> 1] + numbers[numbers.length >> 1]) / 2;
26 | };
27 |
--------------------------------------------------------------------------------
/src/timing.ts:
--------------------------------------------------------------------------------
1 | const NS_PER_MS = 1e6;
2 | const MS_PER_S = 1e3;
3 |
4 | // Returns a high resolution time (if possible) in milliseconds
5 | export function now(): number {
6 | if (typeof window !== 'undefined' && window.performance) {
7 | return window.performance.now();
8 | } else if (typeof process !== 'undefined' && process.hrtime) {
9 | const [seconds, nanoseconds] = process.hrtime();
10 | const secInMS = seconds * MS_PER_S;
11 | const nSecInMS = nanoseconds / NS_PER_MS;
12 | return secInMS + nSecInMS;
13 | } else {
14 | return Date.now();
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | interface ComputedResult {
2 | max: number;
3 | min: number;
4 | median: number;
5 | mean: number;
6 | stdDev: number;
7 | p70: number;
8 | p95: number;
9 | p99: number;
10 | }
11 |
12 | export interface BenchResultsType extends ComputedResult {
13 | startTime: number;
14 | endTime: number;
15 | runTime: number;
16 | sampleCount: number;
17 | samples: Array;
18 | layout?: ComputedResult;
19 | }
20 |
21 | export interface Sample {
22 | start: number;
23 | end: number;
24 | elapsed: number;
25 | layout: number;
26 | }
27 |
28 | export type BenchmarkRef = { start: () => void };
29 | export type BenchmarkType = 'mount' | 'update' | 'unmount';
30 |
--------------------------------------------------------------------------------
/tsconfig.base.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "allowSyntheticDefaultImports": true,
4 | "composite": true,
5 | "declaration": true,
6 | "declarationMap": false,
7 | "downlevelIteration": true,
8 | "esModuleInterop": true,
9 | "emitDeclarationOnly": true,
10 | "forceConsistentCasingInFileNames": true,
11 | "incremental": true,
12 | "isolatedModules": true,
13 | "jsx": "react",
14 | "lib": ["dom", "dom.iterable", "esnext"],
15 | "module": "esnext",
16 | "moduleResolution": "node",
17 | "noEmit": false,
18 | "noImplicitAny": true,
19 | "noImplicitThis": true,
20 | "outDir": "./dist/",
21 | "skipLibCheck": true,
22 | "strict": true,
23 | "target": "es6"
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/tsconfig.check.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.base.json",
3 | "compilerOptions": {
4 | "emitDeclarationOnly": false,
5 | "noEmit": true
6 | },
7 | "exclude": ["**/dist", "./scripts/yarn", "./docs"]
8 | }
9 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.base.json",
3 | "exclude": ["**/dist", "**/*.test.ts", "**/*.test.tsx"]
4 | }
5 |
--------------------------------------------------------------------------------