Skip to content
Merged
Show file tree
Hide file tree
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
40 changes: 36 additions & 4 deletions .github/workflows/chart-library-benchmarks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ on:
- cron: '17 7 * * 1'
workflow_dispatch:
inputs:
diagnose_stats:
description: Run only the TanStack multi-series timing diagnostic, without retries or memory soak
required: false
type: boolean
default: false
upload_bundle_baseline_candidate:
description: Upload exact comparison bundle measurements
required: false
Expand All @@ -25,12 +30,13 @@ permissions:
actions: read

concurrency:
group: chart-library-benchmarks-${{ github.event_name }}-${{ github.ref }}
group: chart-library-benchmarks-${{ github.event_name }}-${{ github.ref }}${{ inputs.diagnose_stats && '-stats-diagnostic' || '' }}
cancel-in-progress: true

jobs:
changes:
name: Select CI partitions
if: github.event_name != 'workflow_dispatch' || !inputs.diagnose_stats
runs-on: ubuntu-24.04
timeout-minutes: 5
outputs:
Expand Down Expand Up @@ -67,6 +73,7 @@ jobs:

static:
name: Static checks
if: github.event_name != 'workflow_dispatch' || !inputs.diagnose_stats
runs-on: ubuntu-24.04
timeout-minutes: 15
env:
Expand Down Expand Up @@ -187,7 +194,7 @@ jobs:

bundle-baseline-candidate:
name: Bundle baseline candidate
if: github.event_name == 'workflow_dispatch' && inputs.upload_bundle_baseline_candidate
if: github.event_name == 'workflow_dispatch' && inputs.upload_bundle_baseline_candidate && !inputs.diagnose_stats
runs-on: ubuntu-24.04
timeout-minutes: 10

Expand Down Expand Up @@ -274,7 +281,7 @@ jobs:

compare-container-canary:
name: Comparison container canary
if: github.event_name == 'workflow_dispatch' && inputs.playwright_container_canary
if: github.event_name == 'workflow_dispatch' && inputs.playwright_container_canary && !inputs.diagnose_stats
runs-on: ubuntu-24.04
timeout-minutes: 20
container:
Expand Down Expand Up @@ -303,9 +310,34 @@ jobs:
if-no-files-found: error
retention-days: 14

stats-diagnostic:
name: Stats stress diagnostic
if: github.event_name == 'workflow_dispatch' && inputs.diagnose_stats
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup
uses: ./.github/actions/setup
with:
playwright: 'true'
- name: Diagnose one timing cell
run: node scripts/stress-chart-libraries.mjs --profile=standard --library=tanstack --workload=stats-multi-series-line --diagnostics
- name: Upload phase diagnostics
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: stats-stress-diagnostic-${{ github.run_id }}
path: .benchmark-output/stress/results
if-no-files-found: warn
retention-days: 7

ci:
name: CI
if: always()
if: always() && !inputs.diagnose_stats
needs:
- changes
- static
Expand Down
22 changes: 22 additions & 0 deletions scripts/benchmark/stress-diagnostics.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export function createStressDiagnostics(
enabled,
emit = () => {},
now = Date.now,
) {
const startedAt = now()
const phases = []
return {
mark(phase) {
if (!enabled) return
const entry = { phase, elapsedMs: now() - startedAt }
phases.push(entry)
emit(entry)
},
snapshot() {
return { elapsedMs: now() - startedAt, phases: [...phases] }
},
lastPhase() {
return phases.at(-1)?.phase ?? 'not started'
},
}
}
33 changes: 33 additions & 0 deletions scripts/benchmark/stress-diagnostics.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it, vi } from 'vitest'
import { createStressDiagnostics } from './stress-diagnostics.mjs'

describe('stress phase diagnostics', () => {
it('does not emit or retain phase records in normal runs', () => {
const emit = vi.fn()
const diagnostics = createStressDiagnostics(false, emit, () => 0)
diagnostics.mark('mount')
expect(emit).not.toHaveBeenCalled()
expect(diagnostics.snapshot().phases).toEqual([])
})

it('retains elapsed phases independently of the final cell result', () => {
let time = 100
const emit = vi.fn()
const diagnostics = createStressDiagnostics(true, emit, () => time)
diagnostics.mark('mount')
time = 150
diagnostics.mark('pointer:initial:activate:0')
const snapshot = diagnostics.snapshot()
time = 200
diagnostics.mark('complete')
expect(snapshot).toEqual({
elapsedMs: 50,
phases: [
{ phase: 'mount', elapsedMs: 0 },
{ phase: 'pointer:initial:activate:0', elapsedMs: 50 },
],
})
expect(emit).toHaveBeenCalledTimes(3)
expect(diagnostics.lastPhase()).toBe('complete')
})
})
111 changes: 111 additions & 0 deletions scripts/benchmark/stress-pointer.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// This function is also installed directly in the browser by Playwright.
export function installStressPointerTiming(host = globalThis) {
let pending
let cancel

host.__stressPointerBegin = (mode, previousSignature) => {
if (pending) throw new Error('Pointer timing is already armed.')
if (mode !== 'activate' && mode !== 'change') {
throw new Error(`Unknown pointer timing mode: ${mode}`)
}
if (host.__stressPointerActive() !== (mode === 'change')) {
throw new Error(`Pointer ${mode} timing has the wrong initial state.`)
}
let timer
let frame
let listener
const result = new Promise((resolve, reject) => {
const finish = (error, value) => {
host.clearTimeout(timer)
if (frame !== undefined) host.cancelAnimationFrame(frame)
host.document.removeEventListener('pointermove', listener, true)
cancel = undefined
if (error) reject(error)
else resolve(value)
}
cancel = () => finish(new Error('Pointer timing cancelled.'))
// Bound event delivery too, not only the frames after an event arrives.
let receivedEvent = false
timer = host.setTimeout(
() =>
finish(
new Error(
receivedEvent
? `Pointer ${mode} did not settle within 2 seconds.`
: `Pointer ${mode} did not receive pointermove within 2 seconds.`,
),
),
2_000,
)
listener = (event) => {
receivedEvent = true
const startedAt = host.performance.now()
const poll = () => {
const signature = host.__stressPointerSignature()
if (
host.__stressPointerActive() &&
(mode === 'activate' ||
(signature !== undefined && signature !== previousSignature))
) {
finish(undefined, {
durationMs: host.performance.now() - startedAt,
trusted: event.isTrusted,
...(mode === 'change' ? { signature } : {}),
})
} else {
frame = host.requestAnimationFrame(poll)
}
}
frame = host.requestAnimationFrame(poll)
}
host.document.addEventListener('pointermove', listener, {
capture: true,
once: true,
})
})
// Attach rejection handling before returning readiness to Playwright.
pending = result.then(
(value) => ({ value }),
(error) => ({ error: error.message }),
)
return true
}
host.__stressPointerRead = async () => {
if (!pending) throw new Error('Pointer timing is not armed.')
const owned = pending
try {
const outcome = await owned
if (outcome.error) throw new Error(outcome.error)
return outcome.value
} finally {
if (pending === owned) pending = undefined
}
}
host.__stressPointerCancel = () => {
cancel?.()
pending = undefined
}
}

export async function measureTrustedPointer(
page,
mode,
target,
previousSignature,
) {
// Await registration, not the eventual sample, before dispatching input.
await page.evaluate(
({ mode, previousSignature }) =>
globalThis.__stressPointerBegin(mode, previousSignature),
{ mode, previousSignature },
)
try {
await page.mouse.move(target.x, target.y)
return await page.evaluate(() => globalThis.__stressPointerRead())
} catch (error) {
await page
.evaluate(() => globalThis.__stressPointerCancel())
.catch(() => {})
throw error
}
}
Loading