Skip to content

Commit 8cc5a98

Browse files
committed
refactor: improve v6 stability and re-organize core logic
1 parent 9f050e6 commit 8cc5a98

18 files changed

Lines changed: 1109 additions & 633 deletions

benchmarks/README.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# React Tooltip Scaling Benchmark
2+
3+
Automated benchmark harness comparing V5 and V6 mount/unmount performance across tooltip counts.
4+
5+
## Quick start
6+
7+
```bash
8+
# Single benchmark pass (5 repeats per count)
9+
node benchmarks/run-benchmark.mjs
10+
11+
# Full statistical run (100 passes across 5 workers, then aggregate)
12+
yarn benchmark:scaling:full -r 100 -w 5
13+
14+
# Aggregate the latest N results
15+
node benchmarks/aggregate-benchmarks.mjs --latest 100
16+
```
17+
18+
## Options
19+
20+
| Flag | Default | Description |
21+
| ------------------ | ---------------------------- | -------------------------------------------------------------- |
22+
| `--counts` | `50,100,500,2000,5000,10000` | Comma-separated tooltip counts to benchmark |
23+
| `--repeats` | `5` | Measurement repeats per count |
24+
| `--warmups` | `1` | Warmup rounds per count (auto-scales to 2 for counts ≥ 10,000) |
25+
| `--timeoutMs` | `1200` | Max time (ms) to wait for render completion |
26+
| `--executablePath` | Playwright Chromium | Path to a Chrome/Chromium binary |
27+
| `-r` / `--runs` | `3` | Total benchmark passes (scaling series) |
28+
| `-w` / `--workers` | `1` | Parallel worker count (scaling series) |
29+
30+
## How it works
31+
32+
1. **Build** — Bundles the fixture app with esbuild, embedding both V5 and V6 tooltip builds
33+
2. **Launch** — Opens a headless Chromium instance with `--enable-precise-memory-info` and `--expose-gc`
34+
3. **Isolate** — Each version gets its own browser page to prevent GC/memory cross-contamination
35+
4. **Randomize** — V5/V6 execution order is randomized per run to eliminate ordering bias
36+
5. **Measure** — For each count: warmup rounds, then timed mount/unmount with GC-settled memory snapshots
37+
6. **Trim** — IQR-based outlier removal filters OS scheduling noise from aggregated results
38+
39+
## Latest results (100 runs, April 2026)
40+
41+
| Count | V5 mount | V6 mount | Delta | Spread | V5 unmount | V6 unmount | Delta | V5 update | V6 update | Delta | V6 mount mem | Mem savings |
42+
| ------ | --------- | --------- | --------- | ------ | ---------- | ---------- | -------- | --------- | --------- | -------- | ------------ | ----------- |
43+
| 50 | 0.70 ms | 0.60 ms | -0.10 ms | 0.0% | 0.20 ms | 0.20 ms | 0.00 ms | 8.30 ms | 8.40 ms | +0.10 ms | 41.5 KiB | -18.5 KiB |
44+
| 100 | 0.90 ms | 0.70 ms | -0.20 ms | 14.3% | 0.20 ms | 0.20 ms | 0.00 ms | 8.30 ms | 8.30 ms | 0.00 ms | 72.3 KiB | -15.0 KiB |
45+
| 500 | 3.00 ms | 2.60 ms | -0.40 ms | 6.7% | 0.50 ms | 0.40 ms | -0.10 ms | 8.30 ms | 8.30 ms | 0.00 ms | 358.3 KiB | -33.2 KiB |
46+
| 2,000 | 15.60 ms | 14.90 ms | -0.70 ms | 6.0% | 1.60 ms | 1.00 ms | -0.60 ms | 7.60 ms | 8.30 ms | +0.70 ms | 1,430 KiB | -34.7 KiB |
47+
| 5,000 | 91.55 ms | 87.25 ms | -4.30 ms | 21.9% | 4.10 ms | 2.40 ms | -1.70 ms | 16.80 ms | 13.30 ms | -3.50 ms | 3,568 KiB | -719.6 KiB |
48+
| 10,000 | 381.20 ms | 364.75 ms | -16.45 ms | 13.0% | 8.40 ms | 4.90 ms | -3.50 ms | 33.50 ms | 26.20 ms | -7.30 ms | 7,123 KiB | -1,330 KiB |
49+
50+
**Key takeaways:**
51+
52+
- V6 is faster on mount and unmount at all tested counts (up to 10k)
53+
- V6 uses less mount memory at every count (2k outlier resolved)
54+
- V6 prop updates are 21% faster at 10k (26.2 ms vs 33.5 ms)
55+
- Zero timeouts at all counts

benchmarks/aggregate-benchmarks.mjs

Lines changed: 46 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -28,29 +28,44 @@ function aggregateNumbers(values) {
2828
standardDeviation: null,
2929
spreadPercent: null,
3030
sampleCount: 0,
31+
trimmedCount: 0,
3132
}
3233
}
3334

34-
const middle = Math.floor(sorted.length / 2)
35+
// IQR-based outlier trimming (only when enough samples)
36+
let trimmed = sorted
37+
if (sorted.length >= 8) {
38+
const q1Index = Math.floor(sorted.length * 0.25)
39+
const q3Index = Math.floor(sorted.length * 0.75)
40+
const q1 = sorted[q1Index]
41+
const q3 = sorted[q3Index]
42+
const iqr = q3 - q1
43+
const lowerFence = q1 - 1.5 * iqr
44+
const upperFence = q3 + 1.5 * iqr
45+
trimmed = sorted.filter((value) => value >= lowerFence && value <= upperFence)
46+
if (trimmed.length < sorted.length * 0.5) {
47+
trimmed = sorted
48+
}
49+
}
50+
51+
const middle = Math.floor(trimmed.length / 2)
3552
const median =
36-
sorted.length % 2 === 0
37-
? (sorted[middle - 1] + sorted[middle]) / 2
38-
: sorted[middle]
39-
const mean = sorted.reduce((total, value) => total + value, 0) / sorted.length
40-
const variance =
41-
sorted.reduce((total, value) => total + (value - mean) ** 2, 0) / sorted.length
53+
trimmed.length % 2 === 0 ? (trimmed[middle - 1] + trimmed[middle]) / 2 : trimmed[middle]
54+
const mean = trimmed.reduce((total, value) => total + value, 0) / trimmed.length
55+
const variance = trimmed.reduce((total, value) => total + (value - mean) ** 2, 0) / trimmed.length
4256
const standardDeviation = Math.sqrt(variance)
43-
const p95 = sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95) - 1)]
57+
const p95 = trimmed[Math.min(trimmed.length - 1, Math.ceil(trimmed.length * 0.95) - 1)]
4458

4559
return {
4660
median,
4761
p95,
48-
min: sorted[0],
49-
max: sorted[sorted.length - 1],
62+
min: trimmed[0],
63+
max: trimmed[trimmed.length - 1],
5064
mean,
5165
standardDeviation,
5266
spreadPercent: median === 0 ? null : ((p95 - median) / Math.abs(median)) * 100,
5367
sampleCount: sorted.length,
68+
trimmedCount: trimmed.length,
5469
}
5570
}
5671

@@ -134,13 +149,13 @@ function buildMarkdownReport(result) {
134149
`- Generation filter: ${result.generationFilter}`,
135150
`- Counts: ${result.counts.join(', ')}`,
136151
'',
137-
'| Count | V5 mount | V6 mount | Mount delta | Mount spread | V5 unmount | V6 unmount | Unmount delta | Unmount spread | V5 mount mem | V6 mount mem | Mount mem delta | Mount mem spread | V5 unmount mem | V6 unmount mem | Unmount mem delta | Unmount mem spread | Samples | V5 timeouts | V6 timeouts |',
138-
'| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |',
152+
'| Count | V5 mount | V6 mount | Mount delta | Mount spread | V5 unmount | V6 unmount | Unmount delta | Unmount spread | V5 update | V6 update | Update delta | Update spread | V5 mount mem | V6 mount mem | Mount mem delta | Mount mem spread | V5 unmount mem | V6 unmount mem | Unmount mem delta | Unmount mem spread | Samples | V5 timeouts | V6 timeouts |',
153+
'| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |',
139154
]
140155

141156
result.summary.forEach((row) => {
142157
lines.push(
143-
`| ${row.count} | ${formatMs(row.v5.mount.median)} | ${formatMs(row.v6.mount.median)} | ${formatMs(row.mountDeltaMs)} | ${formatPercent(row.mountDeltaSpreadPercent)} | ${formatMs(row.v5.unmount.median)} | ${formatMs(row.v6.unmount.median)} | ${formatMs(row.unmountDeltaMs)} | ${formatPercent(row.unmountDeltaSpreadPercent)} | ${formatBytes(row.v5.mountMemory.median)} | ${formatBytes(row.v6.mountMemory.median)} | ${formatBytes(row.mountMemoryDeltaBytes)} | ${formatPercent(row.mountMemoryDeltaSpreadPercent)} | ${formatBytes(row.v5.unmountMemory.median)} | ${formatBytes(row.v6.unmountMemory.median)} | ${formatBytes(row.unmountMemoryDeltaBytes)} | ${formatPercent(row.unmountMemoryDeltaSpreadPercent)} | ${row.sampleCount} | ${row.v5.timeoutCount} | ${row.v6.timeoutCount} |`,
158+
`| ${row.count} | ${formatMs(row.v5.mount.median)} | ${formatMs(row.v6.mount.median)} | ${formatMs(row.mountDeltaMs)} | ${formatPercent(row.mountDeltaSpreadPercent)} | ${formatMs(row.v5.unmount.median)} | ${formatMs(row.v6.unmount.median)} | ${formatMs(row.unmountDeltaMs)} | ${formatPercent(row.unmountDeltaSpreadPercent)} | ${formatMs(row.v5.update.median)} | ${formatMs(row.v6.update.median)} | ${formatMs(row.updateDeltaMs)} | ${formatPercent(row.updateDeltaSpreadPercent)} | ${formatBytes(row.v5.mountMemory.median)} | ${formatBytes(row.v6.mountMemory.median)} | ${formatBytes(row.mountMemoryDeltaBytes)} | ${formatPercent(row.mountMemoryDeltaSpreadPercent)} | ${formatBytes(row.v5.unmountMemory.median)} | ${formatBytes(row.v6.unmountMemory.median)} | ${formatBytes(row.unmountMemoryDeltaBytes)} | ${formatPercent(row.unmountMemoryDeltaSpreadPercent)} | ${row.sampleCount} | ${row.v5.timeoutCount} | ${row.v6.timeoutCount} |`,
144159
)
145160
})
146161

@@ -163,9 +178,9 @@ async function main() {
163178
throw new Error('No benchmark result files matched the requested generation filter.')
164179
}
165180

166-
const counts = Array.from(
167-
new Set(runs.flatMap((run) => run.counts ?? [])),
168-
).sort((left, right) => left - right)
181+
const counts = Array.from(new Set(runs.flatMap((run) => run.counts ?? []))).sort(
182+
(left, right) => left - right,
183+
)
169184

170185
const summary = counts.map((count) => {
171186
const rows = runs
@@ -175,12 +190,10 @@ async function main() {
175190
const aggregateVersion = (version) => ({
176191
mount: aggregateNumbers(rows.map((row) => row[version]?.mount?.median)),
177192
unmount: aggregateNumbers(rows.map((row) => row[version]?.unmount?.median)),
193+
update: aggregateNumbers(rows.map((row) => row[version]?.update?.median)),
178194
mountMemory: aggregateNumbers(rows.map((row) => row[version]?.mountMemory?.median)),
179195
unmountMemory: aggregateNumbers(rows.map((row) => row[version]?.unmountMemory?.median)),
180-
timeoutCount: rows.reduce(
181-
(total, row) => total + (row[version]?.timeoutCount ?? 0),
182-
0,
183-
),
196+
timeoutCount: rows.reduce((total, row) => total + (row[version]?.timeoutCount ?? 0), 0),
184197
})
185198

186199
const v5 = aggregateVersion('v5')
@@ -203,6 +216,10 @@ async function main() {
203216
typeof v5.unmountMemory.median === 'number' && typeof v6.unmountMemory.median === 'number'
204217
? v6.unmountMemory.median - v5.unmountMemory.median
205218
: null
219+
const updateDeltaMs =
220+
typeof v5.update.median === 'number' && typeof v6.update.median === 'number'
221+
? v6.update.median - v5.update.median
222+
: null
206223

207224
return {
208225
count,
@@ -211,6 +228,7 @@ async function main() {
211228
v6,
212229
mountDeltaMs,
213230
unmountDeltaMs,
231+
updateDeltaMs,
214232
mountMemoryDeltaBytes,
215233
unmountMemoryDeltaBytes,
216234
mountDeltaSpreadPercent:
@@ -222,13 +240,19 @@ async function main() {
222240
? Math.max(v5.unmount.spreadPercent, v6.unmount.spreadPercent)
223241
: null,
224242
mountMemoryDeltaSpreadPercent:
225-
typeof v5.mountMemory.spreadPercent === 'number' && typeof v6.mountMemory.spreadPercent === 'number'
243+
typeof v5.mountMemory.spreadPercent === 'number' &&
244+
typeof v6.mountMemory.spreadPercent === 'number'
226245
? Math.max(v5.mountMemory.spreadPercent, v6.mountMemory.spreadPercent)
227246
: null,
228247
unmountMemoryDeltaSpreadPercent:
229-
typeof v5.unmountMemory.spreadPercent === 'number' && typeof v6.unmountMemory.spreadPercent === 'number'
248+
typeof v5.unmountMemory.spreadPercent === 'number' &&
249+
typeof v6.unmountMemory.spreadPercent === 'number'
230250
? Math.max(v5.unmountMemory.spreadPercent, v6.unmountMemory.spreadPercent)
231251
: null,
252+
updateDeltaSpreadPercent:
253+
typeof v5.update.spreadPercent === 'number' && typeof v6.update.spreadPercent === 'number'
254+
? Math.max(v5.update.spreadPercent, v6.update.spreadPercent)
255+
: null,
232256
}
233257
})
234258

benchmarks/fixture/app.tsx

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@ type FixtureState = {
1212
version: BenchmarkVersion
1313
count: number
1414
renderMode: RenderMode
15+
place?: string
1516
}
1617

1718
type ScenarioSample = {
1819
count: number
1920
mountDurationMs: number | null
2021
unmountDurationMs: number | null
22+
updateDurationMs: number | null
2123
mountMemoryDeltaBytes: number | null
2224
unmountMemoryDeltaBytes: number | null
2325
timedOut: boolean
@@ -98,7 +100,7 @@ async function waitUntil(predicate: () => boolean, timeoutMs: number) {
98100
return false
99101
}
100102

101-
function BenchmarkFixture({ version, count }: FixtureState) {
103+
function BenchmarkFixture({ version, count, place }: FixtureState) {
102104
const TooltipComponent = version === 'v5' ? TooltipV5 : TooltipV6
103105
const tooltipId = `benchmark-tooltip-${version}`
104106

@@ -128,7 +130,7 @@ function BenchmarkFixture({ version, count }: FixtureState) {
128130
</button>
129131
))}
130132
</div>
131-
<TooltipComponent id={tooltipId} />
133+
<TooltipComponent id={tooltipId} place={place} />
132134
</div>
133135
)
134136
}
@@ -179,8 +181,11 @@ async function runScalingBenchmark({
179181
const samplesByCount: ScenarioSample[] = []
180182

181183
for (const count of counts) {
182-
for (let warmupIndex = 0; warmupIndex < warmups; warmupIndex += 1) {
183-
onProgress?.(`count=${count} warmup ${warmupIndex + 1}/${warmups}`)
184+
// Scale warmups for large counts to ensure JIT is fully warm
185+
const effectiveWarmups = count >= 10000 ? Math.max(warmups, 2) : warmups
186+
187+
for (let warmupIndex = 0; warmupIndex < effectiveWarmups; warmupIndex += 1) {
188+
onProgress?.(`count=${count} warmup ${warmupIndex + 1}/${effectiveWarmups}`)
184189
await renderFixture({
185190
version,
186191
count: 0,
@@ -210,7 +215,9 @@ async function runScalingBenchmark({
210215
})
211216
await nextFrame()
212217

213-
const mountMemoryBefore = await readStableHeapBytes()
218+
// Settle memory before mount measurement
219+
await collectGarbage()
220+
const mountMemoryBefore = readUsedHeapBytes()
214221
const mountStartedAt = window.performance.now()
215222

216223
await renderFixture({
@@ -223,11 +230,34 @@ async function runScalingBenchmark({
223230
return document.querySelectorAll('[data-tooltip-id]').length === count
224231
}, timeoutMs)
225232

233+
const mountEndedAt = window.performance.now()
234+
235+
// Settle memory after mount, outside timing window
226236
await nextFrame()
237+
await collectGarbage()
238+
const mountMemoryAfter = readUsedHeapBytes()
227239

228-
const mountEndedAt = window.performance.now()
240+
// --- Update measurement: change `place` prop to trigger a re-render cycle ---
241+
const updateStartedAt = window.performance.now()
242+
243+
await renderFixture({
244+
version,
245+
count,
246+
renderMode,
247+
place: 'bottom',
248+
})
249+
await nextFrame()
250+
251+
const updateEndedAt = window.performance.now()
252+
253+
// Restore original place for a clean unmount
254+
await renderFixture({
255+
version,
256+
count,
257+
renderMode,
258+
})
259+
await nextFrame()
229260

230-
const mountMemoryAfter = await readStableHeapBytes()
231261
const unmountMemoryBefore = mountMemoryAfter
232262
const unmountStartedAt = window.performance.now()
233263

@@ -237,16 +267,17 @@ async function runScalingBenchmark({
237267
return document.querySelectorAll('[data-tooltip-id]').length === 0
238268
}, timeoutMs)
239269

240-
await nextFrame()
241-
242270
const unmountEndedAt = window.performance.now()
243271

244-
const unmountMemoryAfter = await readStableHeapBytes()
272+
await nextFrame()
273+
await collectGarbage()
274+
const unmountMemoryAfter = readUsedHeapBytes()
245275

246276
samplesByCount.push({
247277
count,
248278
mountDurationMs: mountReady ? mountEndedAt - mountStartedAt : null,
249279
unmountDurationMs: unmountReady ? unmountEndedAt - unmountStartedAt : null,
280+
updateDurationMs: mountReady ? updateEndedAt - updateStartedAt : null,
250281
mountMemoryDeltaBytes:
251282
mountReady && mountMemoryBefore !== null && mountMemoryAfter !== null
252283
? mountMemoryAfter - mountMemoryBefore

0 commit comments

Comments
 (0)