├── .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 [![NPM version](https://img.shields.io/npm/v/react-component-benchmark?style=flat-square)](https://www.npmjs.com/package/react-component-benchmarkhttps://www.npmjs.com/package/react-component-benchmark) [![CI status](https://img.shields.io/github/checks-status/paularmstrong/react-component-benchmark/main?style=flat-square)](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 | --------------------------------------------------------------------------------