feat(perf): dev forced-reflow detector — uix.perf / $perf

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
dev 3 months ago
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;
}

@ -38,6 +38,7 @@ import {
import { EngineSemantic } from '$uix/sema';
import { createEngineMotion, type EngineMotion } from '$motion';
import * as colorEngine from '$color';
import { createActivePerf, type ActivePerf } from '$perf';
import type { LangNode, SupportedLocale } from '$libs/langs';
import { commonLangs, componentLangs } from '$uix/langs';
@ -130,6 +131,9 @@ export function createActiveUix(options: ActiveUixOptions): ActiveUix {
// JS drivers settle instead of throwing). Eidos registers its presets at boot.
const motion = createEngineMotion({ dom });
// Dev forced-reflow detector — opt-in (typically gated on import.meta.env.DEV).
const perf = options.reflowDetector ? createActivePerf() : undefined;
if (options.registerDefaultLangs ?? true) {
langs.extend('common', commonLangs);
langs.extend('components', componentLangs);
@ -148,6 +152,7 @@ export function createActiveUix(options: ActiveUixOptions): ActiveUix {
disabledDom,
events,
motion,
perf,
portal: options.portal,
detachLangsPrefs
});
@ -168,6 +173,7 @@ export function attachActiveUix(app: ActiveApp, options: AttachActiveUixOptions
const appMotion = (app as unknown as { motion?: EngineMotion }).motion;
const motion = appMotion ?? createEngineMotion({ dom: appDom });
const ownsMotion = appMotion === undefined;
const perf = options.reflowDetector ? createActivePerf() : undefined;
if (options.registerDefaultLangs ?? true) {
(app.langs as ActiveLangs).extend('common', commonLangs);
@ -181,6 +187,7 @@ export function attachActiveUix(app: ActiveApp, options: AttachActiveUixOptions
events: appEvents,
motion,
ownsMotion,
perf,
portal: options.portal,
detachLangsPrefs: undefined
});
@ -201,6 +208,7 @@ interface StandaloneInit {
disabledDom: ActiveDom | undefined;
events: EngineSemantic | undefined;
motion: EngineMotion;
perf: ActivePerf | undefined;
portal: string | HTMLElement | undefined;
detachLangsPrefs: (() => void) | undefined;
}
@ -213,6 +221,7 @@ interface AttachInit {
motion: EngineMotion;
/** active-uix created the motion engine as a fallback (the app didn't declare one). */
ownsMotion: boolean;
perf: ActivePerf | undefined;
portal: string | HTMLElement | undefined;
detachLangsPrefs: (() => void) | undefined;
}
@ -304,6 +313,9 @@ function createDisabledActiveDom(): ActiveDom {
raf() {
return disabled();
},
measure() {
return disabled();
},
scrollWindowBy() {},
scrollWindowTo() {},
getDocument() {
@ -397,6 +409,10 @@ class ActiveUixImpl implements ActiveUix {
return colorEngine;
}
get perf(): ActivePerf | undefined {
return this.init.perf;
}
// ── Core ───────────────────────────────────────────────────────
get logger(): EngineLogger {
return this.init.mode === 'standalone' ? this.init.logger : this.init.app.logger;
@ -494,6 +510,8 @@ class ActiveUixImpl implements ActiveUix {
this.liveRegionElements.clear();
}
this.init.detachLangsPrefs?.();
// The forced-reflow detector (if enabled) is ours in both boot modes.
this.init.perf?.dispose();
// Standalone: tear down every service we instantiated. Reverse
// dependency order: format → events/sema → motion → dom → disabledDom →
// clipboard → langs → prefs → timers → bus → logger.

@ -73,6 +73,14 @@ export interface ActiveUixOptions {
* `ActiveLangs` so `morfo.translations` registered later become available.
*/
readonly registerDefaultLangs?: boolean;
/**
* Enable the dev forced-reflow detector (`uix.perf`). When `true`, observes
* Long Animation Frames and reports synchronous style+layout forced by scripts.
* Opt-in (typically gated on `import.meta.env.DEV`); inert where unsupported.
* @default false
*/
readonly reflowDetector?: boolean;
}
/**
@ -83,6 +91,8 @@ export interface ActiveUixOptions {
export interface AttachActiveUixOptions {
readonly portal?: string | HTMLElement;
readonly registerDefaultLangs?: boolean;
/** Enable the dev forced-reflow detector (`uix.perf`). @default false */
readonly reflowDetector?: boolean;
}
/**
@ -126,6 +136,14 @@ export interface ActiveUix {
*/
readonly color: EngineColor;
/**
* Dev forced-reflow detector (`arts/perf`). Present only when `reflowDetector`
* is enabled at boot; `undefined` otherwise (prod, or where Long Animation
* Frames is unsupported). Observes LoAF and attributes synchronous
* style+layout to the scripts that forced it.
*/
readonly perf: import('$perf').ActivePerf | undefined;
// ── Core (always present, regardless of boot mode) ─────────────────
readonly logger: EngineLogger;
/**

@ -30,6 +30,7 @@ const config = {
'$logger': resolve(__dirname, 'src/arts/logger'),
'$motion': resolve(__dirname, 'src/arts/motion'),
'$orca': resolve(__dirname, 'src/arts/orca'),
'$perf': resolve(__dirname, 'src/arts/perf'),
'$perm': resolve(__dirname, 'src/arts/perm'),
'$prefs': resolve(__dirname, 'src/arts/prefs'),
'$session': resolve(__dirname, 'src/arts/session'),

@ -24,6 +24,7 @@ const aliases = {
'$logger': resolve(__dirname, 'src/arts/logger'),
'$motion': resolve(__dirname, 'src/arts/motion'),
'$orca': resolve(__dirname, 'src/arts/orca'),
'$perf': resolve(__dirname, 'src/arts/perf'),
'$perm': resolve(__dirname, 'src/arts/perm'),
'$prefs': resolve(__dirname, 'src/arts/prefs'),
'$session': resolve(__dirname, 'src/arts/session'),

Loading…
Cancel
Save

Powered by TurnKey Linux.