Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

experiment: explore benchmarking with playwright #4776

Closed
wants to merge 2 commits into from
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6,171 changes: 5,960 additions & 211 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -53,6 +53,7 @@
"@types/jest": "29.5.6",
"@typescript-eslint/eslint-plugin": "^7.11.0",
"@typescript-eslint/parser": "^7.3.1",
"esbuild": "0.23.0",
"eslint": "8.56.0",
"eslint-import-resolver-typescript": "3.6.1",
"eslint-plugin-github": "5.0.1",
@@ -67,6 +68,7 @@
"eslint-plugin-ssr-friendly": "1.3.0",
"eslint-plugin-storybook": "0.8.0",
"eslint-plugin-testing-library": "6.0.2",
"fast-glob": "3.3.2",
"husky": "8.0.3",
"jest": "29.7.0",
"jest-watch-typeahead": "2.2.2",
167 changes: 167 additions & 0 deletions packages/react/src/Button/Button.benchmarks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import React, {Profiler} from 'react'
import {createRoot, type Root} from 'react-dom/client'
import {Button} from '../Button'
import BaseStyles from '../BaseStyles'
import ThemeProvider from '../ThemeProvider'

declare global {
interface Window {
BenchmarkRunner: BenchmarkRunner
}
}

interface BenchmarkRunner {

Check failure on line 13 in packages/react/src/Button/Button.benchmarks.tsx

GitHub Actions / lint

Unsafe declaration merging between classes and interfaces
addBenchmark(benchmark: Benchmark): void
runBenchmarks(): Promise<
Array<{
benchmark: Benchmark
result: BenchmarkResult
}>
>
}

class BenchmarkRunner implements BenchmarkRunner {

Check failure on line 23 in packages/react/src/Button/Button.benchmarks.tsx

GitHub Actions / lint

Unsafe declaration merging between classes and interfaces
benchmarks: Array<Benchmark> = []

addBenchmark(benchmark: Benchmark): void {
this.benchmarks.push(benchmark)
}

async runBenchmarks(): Promise<
Array<{
benchmark: Benchmark
result: BenchmarkResult
}>
> {
const results = []

for (const benchmark of this.benchmarks) {
const runs: Array<BenchmarkRunResult> = []

for (let i = 0; i < 1000; i++) {
await benchmark.setup()

const run = await benchmark.run()
runs.push(run)

await benchmark.teardown()
}

const result: BenchmarkResult = {
averageBaseDuration: average(runs, 'baseDuration'),
averageActualDuration: average(runs, 'actualDuration'),
medianBaseDuration: median(runs, 'baseDuration'),
medianActualDuration: median(runs, 'actualDuration'),
runs,
}

results.push({
benchmark,
result,
})
}

return results
}
}

function average<K extends string, T extends Record<K, number>>(series: Array<T>, field: K): number {
let sum = 0

for (const value of series) {
sum += value[field]
}

return sum / series.length
}

function median<K extends string, T extends Record<K, number>>(series: Array<T>, field: K): number {
const values: Array<number> = series.map(value => value[field]).sort()
return values[Math.floor(values.length / 2)]
}

interface Benchmark {
name: string
setup(): Promise<void> | void
run(): Promise<BenchmarkRunResult>
teardown(): Promise<void> | void
}

interface BenchmarkResult {
averageActualDuration: number
averageBaseDuration: number
medianActualDuration: number
medianBaseDuration: number
runs: Array<BenchmarkRunResult>
}

interface BenchmarkRunResult {
phase: 'update' | 'mount' | 'nested-update'
actualDuration: number
baseDuration: number
}

window.BenchmarkRunner = new BenchmarkRunner()

Check failure on line 104 in packages/react/src/Button/Button.benchmarks.tsx

GitHub Actions / lint

Use of DOM global 'window' is forbidden in module scope

benchmark('Button', ({render, onRender}) => {
render(
<ThemeProvider>
<BaseStyles>
<Profiler id="benchmark" onRender={onRender}>
<Button>Test case</Button>
</Profiler>
</BaseStyles>
</ThemeProvider>,
)
})

function benchmark(
name: string,
fn: ({render, onRender}: {render: (element: JSX.Element) => void; onRender: React.ProfilerProps['onRender']}) => void,
) {
let root: Root | null = null

function setup() {
const node = document.createElement('div')
node.setAttribute('id', 'benchmark')
document.body.appendChild(node)
root = createRoot(node)
}

function teardown() {
root?.unmount()
const node = document.getElementById('benchmark')
if (node) {
node.remove()
}
root = null
}

function run(): Promise<BenchmarkRunResult> {
return new Promise(resolve => {
const onRender: React.ProfilerProps['onRender'] = (_id, phase, actualDuration, baseDuration) => {
resolve({
phase,
actualDuration,
baseDuration,
})
}
const render = (element: JSX.Element) => {
root?.render(element)
}
fn({
render,
onRender,
})
})
}

const benchmark: Benchmark = {
name,
setup,
teardown,
run,
}

window.BenchmarkRunner.addBenchmark(benchmark)
}
193 changes: 193 additions & 0 deletions script/benchmarks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import {chromium, devices} from 'playwright'
import process from 'node:process'
import path from 'node:path'
import glob from 'fast-glob'
import * as esbuild from 'esbuild'
import {createServer, request, type Server} from 'node:http'
import {parse} from 'node:url'
import {existsSync, createReadStream} from 'node:fs'

async function benchmarks() {
const cwd = process.cwd()
const files = await glob('**/*.benchmarks.tsx', {
cwd,
ignore: ['**/node_modules/**', '**/dist/**', '**/lib/**', '**/lib-esm/**'],
})

await esbuild.build({
bundle: true,
platform: 'browser',
entryPoints: files,
tsconfig: path.join(cwd, 'packages', 'react', 'tsconfig.build.json'),
outdir: 'assets',
outbase: cwd,
minify: true,
define: {
__DEV__: 'false',
},
alias: {
'react-dom/client': 'react-dom/profiling',
},
})

const server = createServer(async (req, res) => {
try {
console.log('debug: %s %s', req.method, req.url)
if (!req.url) {
throw new Error('Invalid URL')
}

const url = parse(req.url, true)
if (!url.path) {
throw new Error('Invalid path in URL for requested asset')
}

if (req.url?.startsWith('/assets')) {
const filepath = path.join(cwd, url.path)
if (!existsSync(filepath)) {

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
res.statusCode = 404
res.end('not found')
return
}

res.writeHead(200, {
'Content-Type': 'application/javascript',
})
createReadStream(filepath).pipe(res, {end: true})

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
} else if (req.url?.startsWith('/benchmarks')) {
const benchmarkPath = path.parse(url.path.split('/').slice(2).join(path.sep))
const assetPath = path.format({
...benchmarkPath,
base: '',
ext: '.js',
})

res.writeHead(200, {
'Content-Type': 'text-html',
})
res.end(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Benchmark</title>
</head>
<body>
<div id="root"></div>
<script src="/assets/${assetPath}"></script>
</body>
</html>
`)
} else {
res.statusCode = 404
res.end('not found')
}
} catch (error) {
console.error('Error occurred handling', req.url, error)
res.statusCode = 500
res.end('internal server error')
}

// try {
// if (req.method !== 'GET') {
// res.statusCode = 405
// res.end('method not allowed')
// return
// }

// if (req.url?.startsWith('/benchmarks')) {
// console.log('debug: GET %s', req.url)

// const url = parse(req.url, true)
// if (!url.path) {
// throw new Error('Invalid path in URL for requested benchmark')
// }

// const requestedBenchmarkPath = url.path.split('/').slice(2).join(path.sep)
// const filepath = path.join(cwd, requestedBenchmarkPath)
// console.log(filepath)
// if (!existsSync(filepath)) {
// res.statusCode = 404
// res.end('not found')
// return
// }

// console.log(filepath)

// res.writeHead(200, {
// 'Content-Type': 'text/html',
// })
// res.end(`
// <!DOCTYPE html>
// <html lang="en">
// <head>
// <meta charset="UTF-8">
// <title>Benchmark</title>
// </head>
// <body>
// <h1>Hello world</h1>
// </body>
// </html>
// `)
// return
// }

// res.statusCode = 404
// res.end('not found')
// } catch (err) {
// console.error('Error occurred handling', req.url, err)
// res.statusCode = 500
// res.end('internal server error')
// }
})

server.once('error', err => {
console.error(err)
process.exit(1)
})

await listen(server, 3000, '0.0.0.0')
console.log('debug: server running on', `http://0.0.0.0:3000`)

// Setup
const browser = await chromium.launch()
const context = await browser.newContext(devices['Desktop Chrome'])

for (const file of files) {
const page = await context.newPage()
const url = new URL(`/benchmarks/${file}`, 'http://0.0.0.0:3000')
await page.goto(url.toString())

const results = await page.evaluate(() => {
return window.BenchmarkRunner.runBenchmarks()

Check failure on line 162 in script/benchmarks.ts

GitHub Actions / type-check

Property 'BenchmarkRunner' does not exist on type 'Window & typeof globalThis'.
})
console.log(results[0].result.runs)

await page.close()
}

await context.close()
await browser.close()
await close(server)
}

function close(server: Server): Promise<void> {
return new Promise(resolve => {
server.close(() => {
resolve()
})
})
}

function listen(server: Server, port: number, hostname: string): Promise<void> {
return new Promise(resolve => {
server.listen(port, hostname, () => {
resolve()
})
})
}

benchmarks().catch(error => {
console.log(error)
process.exit(1)
})