-
Notifications
You must be signed in to change notification settings - Fork 24
Expand file tree
/
Copy pathttiBench.ts
More file actions
191 lines (170 loc) · 5.64 KB
/
Copy pathttiBench.ts
File metadata and controls
191 lines (170 loc) · 5.64 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
/* eslint no-console: 0 */
'use strict'
/**
* Time-to-interactive bench — companion to runBenchmarks.ts.
*
* runBenchmarks reports the React Profiler mount time (render-phase
* actualDuration only). That misses work deferred into commit/passive
* effects, so it can look great while the user-visible mount is unchanged.
* This measures what the user actually waits for, around the mount:
*
* FCP first contentful paint (ms from nav start)
* MountRender React Profiler mount actualTime (render phase only)
* LongestTask worst single main-thread block (ms) — the input killer
* TTIquiet end of the last long task in the mount window — when the
* main thread first goes quiet (≈ interactive)
* TBT total blocking time = sum(max(0, dur-50)) over long tasks
*
* A longtask + paint observer is injected before page scripts run.
* Steady-state dispatches are <50ms so they never register as long tasks —
* the only long tasks in the window are mount-related, which isolates it.
*
* Usage (build the scenario first, same as the other benches):
* yarn build price-ticker
* yarn tti -s price-ticker -v signals-base signals-mount
* yarn tti -s price-ticker # all built versions, defaults
*/
import path from 'path'
import fs from 'fs'
import playwright from 'playwright'
import glob from 'glob'
import Table from 'cli-table2'
import yargs from 'yargs/yargs'
import { runServer } from './utils/server'
const readFolderNames = (searchDir: string) =>
glob.sync('*/', { cwd: searchDir }).map((s) => s.replace('/', ''))
const allScenarios = readFolderNames(path.resolve('src/scenarios'))
const allBuiltVersions = readFolderNames(path.resolve('dist'))
const args = yargs(process.argv.slice(2))
.option('scenarios', {
alias: 's',
describe: 'List of benchmark scenarios to run',
type: 'array',
choices: allScenarios,
default: allScenarios,
})
.option('versions', {
alias: 'v',
describe: 'List of React-Redux versions to compare',
type: 'array',
choices: allBuiltVersions,
default: allBuiltVersions,
})
.option('runs', {
alias: 'r',
describe: 'Number of page loads to take the median over',
type: 'number',
default: 5,
})
.option('settle', {
describe: 'Milliseconds to wait after load for the mount to settle',
type: 'number',
default: 3500,
})
.option('headless', {
describe: 'Run Chrome in headless mode (default: true)',
type: 'boolean',
default: true,
})
.help('h')
.alias('h', 'help')
const PORT = 9998
const median = (xs: number[]) => {
const s = [...xs].sort((a, b) => a - b)
return s.length ? s[Math.floor(s.length / 2)] : 0
}
const f = (x: number) => x.toFixed(1)
interface TTISample {
fcp: number
mountRender: number
longest: number
ttiQuiet: number
tbt: number
}
async function measure(
browser: playwright.Browser,
url: string,
settleMs: number,
): Promise<TTISample> {
const context = await browser.newContext({})
const page = await context.newPage()
await page.addInitScript(() => {
;(window as any).__lt = []
try {
new PerformanceObserver((list) => {
for (const e of list.getEntries())
(window as any).__lt.push({ start: e.startTime, dur: e.duration })
}).observe({ type: 'longtask', buffered: true } as any)
} catch {}
})
await page.goto(url)
await page.waitForTimeout(settleMs)
const data = await page.evaluate(() => {
const paints = performance.getEntriesByType('paint')
const fcp = paints.find((p) => p.name === 'first-contentful-paint')
const mount = (window as any).renderResults?.[0]
return {
fcp: fcp ? fcp.startTime : null,
lt: (window as any).__lt as { start: number; dur: number }[],
mountRender: mount ? mount.actualTime : null,
}
})
await context.close()
const lt = data.lt.filter((t) => t.start < settleMs)
const longest = lt.length ? Math.max(...lt.map((t) => t.dur)) : 0
const ttiQuiet = lt.length
? Math.max(...lt.map((t) => t.start + t.dur))
: (data.fcp ?? 0)
const tbt = lt.reduce((s, t) => s + Math.max(0, t.dur - 50), 0)
return {
fcp: data.fcp ?? 0,
mountRender: data.mountRender ?? 0,
longest,
ttiQuiet,
tbt,
}
}
async function main() {
const argv = await args.argv
const scenarios = argv.scenarios as string[]
const versions = argv.versions as string[]
const runs = argv.runs as number
const settle = argv.settle as number
const dist = path.resolve('dist')
const server = await runServer(PORT, dist)
const browser = await playwright.chromium.launch({ headless: argv.headless })
try {
for (const scenario of scenarios) {
const table = new Table({
head: ['Version', 'FCP', 'MountRender', 'LongestTask', 'TTIquiet', 'TBT'],
})
for (const version of versions) {
if (!fs.existsSync(path.join(dist, version, scenario))) continue
const samples: TTISample[] = []
for (let i = 0; i < runs; i++) {
samples.push(
await measure(
browser,
`http://localhost:${PORT}/${version}/${scenario}`,
settle,
),
)
}
table.push([
version,
f(median(samples.map((s) => s.fcp))),
f(median(samples.map((s) => s.mountRender))),
f(median(samples.map((s) => s.longest))),
f(median(samples.map((s) => s.ttiQuiet))),
f(median(samples.map((s) => s.tbt))),
])
}
console.log(`\n${scenario} — time-to-interactive (median of ${runs}), all ms`)
console.log(table.toString())
}
} finally {
await browser.close()
server.close()
}
}
main()