Add `arts/perf` — a dev-only forced-reflow detector on the Long Animation Frames
API. Turns Chrome's opaque "[Violation] Forced reflow while executing JavaScript
took Nms" into an attributed report: which script forced how much synchronous
style+layout (`forcedStyleAndLayoutDuration`). It catches the actual runtime bug
regardless of static pattern — what a grep guard can't do (the codebase has ~120
legitimate layout reads across ~48 components; the fault is the temporal
sync-read-after-write ordering, not the read itself).
`createActivePerf({ threshold, onReport, log })` owns the only PerformanceObserver
the framework creates; inert where LoAF is unsupported (non-Chromium). Discoverable
as `uix.perf`, opt-in via `createActiveUix({ reflowDetector: import.meta.env.DEV })`;
`ActivePerf` (stateful → Active*) is disposed by the composition root.
- src/arts/perf/{types,active-perf,index}.ts + README + 6 tests
- $perf alias (vite.config.ts + svelte.config.js)
- ActiveUix.perf getter + reflowDetector option
- disabled-dom stub gains measure() (completes the dom.measure interface)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
menubar-v4-safe
parent
99a7882a64
commit
7433d171b9
@ -0,0 +1,48 @@
|
||||
# perf (`uix.perf`)
|
||||
|
||||
Dev-only **forced-reflow detector**. Turns Chrome's opaque
|
||||
`[Violation] Forced reflow while executing JavaScript took Nms` into an
|
||||
attributed report — _which script forced how much synchronous style+layout_ —
|
||||
via the **Long Animation Frames API** (`script.forcedStyleAndLayoutDuration`).
|
||||
|
||||
Pure runtime artifact: no DOM service, no framework deps. Owns the **only**
|
||||
`PerformanceObserver` the framework creates.
|
||||
|
||||
## Why
|
||||
|
||||
`ActiveDom` governs DOM **writes** and `uix.timers` governs **time**, but the
|
||||
class of bug that motivated this — a layout-forcing READ fired synchronously
|
||||
after a write — is invisible until it ships. A grep guard can't catch it: it's a
|
||||
temporal ordering, and most layout reads are legitimate (the codebase has ~120
|
||||
across ~48 components, nearly all deferred / safe). The detector catches the
|
||||
_actual_ forced reflow at runtime and names the script, regardless of static
|
||||
pattern. It's what would have caught the original ColorPicker reflow.
|
||||
|
||||
## Use
|
||||
|
||||
```ts
|
||||
import { createActivePerf } from '$perf'
|
||||
|
||||
const perf = createActivePerf({ threshold: 16 }) // report frames ≥16ms forced
|
||||
// …
|
||||
perf.dispose()
|
||||
```
|
||||
|
||||
Or via the composition root — opt in and reach it as `uix.perf`:
|
||||
|
||||
```ts
|
||||
createActiveUix({ langs, reflowDetector: import.meta.env.DEV })
|
||||
// uix.perf?.active → true in dev on Chromium
|
||||
```
|
||||
|
||||
`uix.perf` is `undefined` when `reflowDetector` is off or where Long Animation
|
||||
Frames is unsupported (non-Chromium). `ActivePerf` (stateful → `Active*`, not
|
||||
`Engine*`) owns the observer lifecycle; `dispose()` disconnects it (the
|
||||
composition root calls it on `uix.dispose()`).
|
||||
|
||||
## Report shape
|
||||
|
||||
`ForcedReflowReport`: `frameDuration` · `blockingDuration` · `forcedDuration`
|
||||
(sum across scripts) · `scripts[]` (`source` / `forcedMs` / `durationMs`, sorted
|
||||
by `forcedMs` desc). Pass `onReport` for custom handling (a dev overlay, a CI
|
||||
budget); `log: true` (default) also emits a console group.
|
||||
@ -0,0 +1,106 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createActivePerf, isReflowDetectorSupported } from './active-perf'
|
||||
|
||||
type EmitFn = (list: { getEntries: () => unknown[] }) => void
|
||||
|
||||
/** Controllable PerformanceObserver double — capture the callback, emit frames. */
|
||||
class MockPerformanceObserver {
|
||||
static supportedEntryTypes: string[] = ['long-animation-frame']
|
||||
static instances: MockPerformanceObserver[] = []
|
||||
observed: unknown[] = []
|
||||
disconnected = false
|
||||
#cb: EmitFn
|
||||
constructor(cb: EmitFn) {
|
||||
this.#cb = cb
|
||||
MockPerformanceObserver.instances.push(this)
|
||||
}
|
||||
observe(options: unknown): void {
|
||||
this.observed.push(options)
|
||||
}
|
||||
disconnect(): void {
|
||||
this.disconnected = true
|
||||
}
|
||||
emit(entries: unknown[]): void {
|
||||
this.#cb({ getEntries: () => entries })
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a LoAF-shaped entry whose scripts force the given style+layout durations (ms). */
|
||||
function frame(forced: number[], duration = 200) {
|
||||
return {
|
||||
duration,
|
||||
blockingDuration: duration - 50,
|
||||
scripts: forced.map((forcedStyleAndLayoutDuration, i) => ({
|
||||
sourceURL: `/chunk-${i}.js`,
|
||||
forcedStyleAndLayoutDuration,
|
||||
duration: forcedStyleAndLayoutDuration + 10
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
describe('ActivePerf — forced-reflow detector', () => {
|
||||
beforeEach(() => {
|
||||
MockPerformanceObserver.instances = []
|
||||
MockPerformanceObserver.supportedEntryTypes = ['long-animation-frame']
|
||||
vi.stubGlobal('PerformanceObserver', MockPerformanceObserver)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('reports a frame whose forced style+layout reaches the threshold', () => {
|
||||
const onReport = vi.fn()
|
||||
createActivePerf({ threshold: 16, log: false, onReport })
|
||||
MockPerformanceObserver.instances[0].emit([frame([120, 40])])
|
||||
|
||||
expect(onReport).toHaveBeenCalledTimes(1)
|
||||
const report = onReport.mock.calls[0][0]
|
||||
expect(report.forcedDuration).toBe(160)
|
||||
expect(report.frameDuration).toBe(200)
|
||||
expect(report.scripts[0]).toMatchObject({ source: '/chunk-0.js', forcedMs: 120 })
|
||||
})
|
||||
|
||||
it('ignores a frame below the threshold', () => {
|
||||
const onReport = vi.fn()
|
||||
createActivePerf({ threshold: 50, log: false, onReport })
|
||||
MockPerformanceObserver.instances[0].emit([frame([10, 5])])
|
||||
|
||||
expect(onReport).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('observes long-animation-frame buffered and reports active', () => {
|
||||
const perf = createActivePerf({ log: false })
|
||||
expect(MockPerformanceObserver.instances[0].observed[0]).toEqual({
|
||||
type: 'long-animation-frame',
|
||||
buffered: true
|
||||
})
|
||||
expect(perf.active).toBe(true)
|
||||
})
|
||||
|
||||
it('disconnects on dispose, goes inactive, and is idempotent', () => {
|
||||
const perf = createActivePerf({ log: false })
|
||||
const observer = MockPerformanceObserver.instances[0]
|
||||
perf.dispose()
|
||||
expect(observer.disconnected).toBe(true)
|
||||
expect(perf.active).toBe(false)
|
||||
perf.dispose()
|
||||
})
|
||||
|
||||
it('stays inert (no observer) where LoAF is unsupported', () => {
|
||||
MockPerformanceObserver.supportedEntryTypes = ['paint']
|
||||
expect(isReflowDetectorSupported()).toBe(false)
|
||||
const perf = createActivePerf({ log: false })
|
||||
expect(perf.active).toBe(false)
|
||||
expect(MockPerformanceObserver.instances).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('lists only scripts that forced layout, sorted descending', () => {
|
||||
const onReport = vi.fn()
|
||||
createActivePerf({ threshold: 1, log: false, onReport })
|
||||
MockPerformanceObserver.instances[0].emit([frame([0, 30, 0, 90])])
|
||||
|
||||
const report = onReport.mock.calls[0][0]
|
||||
expect(report.scripts.map((s: { forcedMs: number }) => s.forcedMs)).toEqual([90, 30])
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,117 @@
|
||||
// Dev forced-reflow detector. Turns Chrome's opaque
|
||||
// "[Violation] Forced reflow while executing JavaScript took Nms" into an
|
||||
// attributed report — which script forced how much synchronous style+layout —
|
||||
// via the Long Animation Frames API (`forcedStyleAndLayoutDuration`).
|
||||
//
|
||||
// Pure runtime artifact: no DOM service, no framework deps. Owns the ONLY
|
||||
// `PerformanceObserver` the framework creates. Dev-only by convention (the
|
||||
// composition root opts in); a no-op where LoAF is unsupported (non-Chromium).
|
||||
|
||||
import type { ActivePerfOptions, ForcedReflowReport, ForcedReflowScript } from './types'
|
||||
|
||||
const DEFAULT_THRESHOLD = 16
|
||||
|
||||
// LoAF shapes aren't in the standard DOM lib yet — type the slice we read.
|
||||
interface LoAFScript {
|
||||
readonly sourceURL?: string
|
||||
readonly invoker?: string
|
||||
readonly sourceFunctionName?: string
|
||||
readonly forcedStyleAndLayoutDuration?: number
|
||||
readonly duration?: number
|
||||
}
|
||||
interface LoAFEntry {
|
||||
readonly duration?: number
|
||||
readonly blockingDuration?: number
|
||||
readonly scripts?: readonly LoAFScript[]
|
||||
}
|
||||
|
||||
/** Whether this environment exposes Long Animation Frames (Chromium 123+). */
|
||||
export function isReflowDetectorSupported(): boolean {
|
||||
return (
|
||||
typeof PerformanceObserver !== 'undefined' &&
|
||||
Array.isArray(PerformanceObserver.supportedEntryTypes) &&
|
||||
PerformanceObserver.supportedEntryTypes.includes('long-animation-frame')
|
||||
)
|
||||
}
|
||||
|
||||
export interface ActivePerf {
|
||||
/** Whether the LoAF observer is actually running (false where unsupported). */
|
||||
readonly active: boolean
|
||||
/** Stop observing and release the observer. Idempotent. */
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
export function createActivePerf(options: ActivePerfOptions = {}): ActivePerf {
|
||||
const threshold = options.threshold ?? DEFAULT_THRESHOLD
|
||||
const log = options.log ?? true
|
||||
let observer: PerformanceObserver | undefined
|
||||
let disposed = false
|
||||
|
||||
if (isReflowDetectorSupported()) {
|
||||
observer = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries() as unknown as LoAFEntry[]) {
|
||||
const report = buildReport(entry, threshold)
|
||||
if (!report) continue
|
||||
if (log) logReport(report)
|
||||
options.onReport?.(report)
|
||||
}
|
||||
})
|
||||
try {
|
||||
observer.observe({ type: 'long-animation-frame', buffered: true })
|
||||
} catch {
|
||||
// A browser that lists the type but rejects observe() — stay inert.
|
||||
observer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get active() {
|
||||
return observer !== undefined && !disposed
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
observer?.disconnect()
|
||||
observer = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scriptSource(script: LoAFScript): string {
|
||||
return script.sourceURL || script.invoker || script.sourceFunctionName || '(anonymous)'
|
||||
}
|
||||
|
||||
function buildReport(entry: LoAFEntry, threshold: number): ForcedReflowReport | null {
|
||||
const scripts: ForcedReflowScript[] = []
|
||||
let forcedDuration = 0
|
||||
for (const script of entry.scripts ?? []) {
|
||||
const forcedMs = Math.round(script.forcedStyleAndLayoutDuration ?? 0)
|
||||
forcedDuration += forcedMs
|
||||
if (forcedMs > 0) {
|
||||
scripts.push({ source: scriptSource(script), forcedMs, durationMs: Math.round(script.duration ?? 0) })
|
||||
}
|
||||
}
|
||||
if (forcedDuration < threshold) return null
|
||||
scripts.sort((a, b) => b.forcedMs - a.forcedMs)
|
||||
return {
|
||||
frameDuration: Math.round(entry.duration ?? 0),
|
||||
blockingDuration: Math.round(entry.blockingDuration ?? 0),
|
||||
forcedDuration,
|
||||
scripts
|
||||
}
|
||||
}
|
||||
|
||||
function logReport(report: ForcedReflowReport): void {
|
||||
const headline = `[uix.perf] forced reflow — ${report.forcedDuration}ms of synchronous style+layout in a ${report.frameDuration}ms frame`
|
||||
/* eslint-disable no-console */
|
||||
if (typeof console.groupCollapsed === 'function') {
|
||||
console.groupCollapsed(headline)
|
||||
for (const script of report.scripts) {
|
||||
console.warn(`${script.forcedMs}ms forced — ${script.source} (script ${script.durationMs}ms)`)
|
||||
}
|
||||
console.groupEnd()
|
||||
} else {
|
||||
console.warn(headline, report.scripts)
|
||||
}
|
||||
/* eslint-enable no-console */
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
// Public surface of the perf artifact (`uix.perf` / `$perf`) — a dev-only
|
||||
// forced-reflow detector built on the Long Animation Frames API.
|
||||
|
||||
export { createActivePerf, isReflowDetectorSupported, type ActivePerf } from './active-perf'
|
||||
export type { ActivePerfOptions, ForcedReflowReport, ForcedReflowScript } from './types'
|
||||
@ -0,0 +1,35 @@
|
||||
// Public types for the dev forced-reflow detector (`uix.perf` / `$perf`).
|
||||
|
||||
/** One script within a long animation frame that forced synchronous style+layout. */
|
||||
export interface ForcedReflowScript {
|
||||
/** Best-effort attribution — `sourceURL`, else `invoker`, else function name. */
|
||||
readonly source: string;
|
||||
/** Synchronous style+layout this script forced, in ms (rounded). */
|
||||
readonly forcedMs: number;
|
||||
/** Total execution time of this script, in ms (rounded). */
|
||||
readonly durationMs: number;
|
||||
}
|
||||
|
||||
/** A frame whose scripts forced enough synchronous style+layout to be worth flagging. */
|
||||
export interface ForcedReflowReport {
|
||||
/** The long-animation-frame total duration, ms. */
|
||||
readonly frameDuration: number;
|
||||
/** Time the frame blocked the main thread beyond 50ms, ms. */
|
||||
readonly blockingDuration: number;
|
||||
/** Sum of `forcedStyleAndLayoutDuration` across the frame's scripts, ms. */
|
||||
readonly forcedDuration: number;
|
||||
/** Offending scripts, sorted by `forcedMs` descending. */
|
||||
readonly scripts: readonly ForcedReflowScript[];
|
||||
}
|
||||
|
||||
export interface ActivePerfOptions {
|
||||
/**
|
||||
* Minimum forced style+layout (ms) in a single frame before a report fires.
|
||||
* @default 16
|
||||
*/
|
||||
readonly threshold?: number;
|
||||
/** Called once per frame whose forced style+layout reaches the threshold. */
|
||||
readonly onReport?: (report: ForcedReflowReport) => void;
|
||||
/** Also emit a `console.warn` group per report. @default true */
|
||||
readonly log?: boolean;
|
||||
}
|
||||
Loading…
Reference in new issue