diff --git a/src/arts/perf/README.md b/src/arts/perf/README.md new file mode 100644 index 000000000..5d62e235c --- /dev/null +++ b/src/arts/perf/README.md @@ -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. diff --git a/src/arts/perf/active-perf.test.ts b/src/arts/perf/active-perf.test.ts new file mode 100644 index 000000000..12c8ad1d3 --- /dev/null +++ b/src/arts/perf/active-perf.test.ts @@ -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]) + }) +}) diff --git a/src/arts/perf/active-perf.ts b/src/arts/perf/active-perf.ts new file mode 100644 index 000000000..2f8d0e81e --- /dev/null +++ b/src/arts/perf/active-perf.ts @@ -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 */ +} diff --git a/src/arts/perf/index.ts b/src/arts/perf/index.ts new file mode 100644 index 000000000..1f2a37dd9 --- /dev/null +++ b/src/arts/perf/index.ts @@ -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' diff --git a/src/arts/perf/types.ts b/src/arts/perf/types.ts new file mode 100644 index 000000000..0f17accd4 --- /dev/null +++ b/src/arts/perf/types.ts @@ -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; +} diff --git a/src/uix/active-uix/active-uix.svelte.ts b/src/uix/active-uix/active-uix.svelte.ts index 793132651..50061ea5e 100644 --- a/src/uix/active-uix/active-uix.svelte.ts +++ b/src/uix/active-uix/active-uix.svelte.ts @@ -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. diff --git a/src/uix/active-uix/types.ts b/src/uix/active-uix/types.ts index 08ad0ce6b..dce791bb8 100644 --- a/src/uix/active-uix/types.ts +++ b/src/uix/active-uix/types.ts @@ -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; /** diff --git a/svelte.config.js b/svelte.config.js index a596f5705..35f938a81 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -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'), diff --git a/vite.config.ts b/vite.config.ts index d0f5e1e71..ef1ec27b9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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'),