Files
robonen 661a55719e perf(primitives): add performance audit report and vitest bench baselines
Library-wide Vue+V8 perf/leak audit (PERF_AUDIT.md) plus bench baselines for the
hot-path modules (timeline, curve-editor, spline, pointer-drag, collection, etc.).
2026-06-15 16:54:28 +07:00

1039 lines
187 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Performance & Memory-Leak Audit — `@robonen/primitives`
> Generated by a 120-agent workflow audit (per-component review against a Vue-internals + V8-JIT checklist, with adversarial verification of every critical/high/leak finding). This is the **baseline** stage; fixes and re-measurement follow.
## Headline numbers
| Metric | Count |
|---|---|
| Components audited | 77 |
| Components clean (0 findings) | 16 |
| Total findings (non-false-positive) | 142 |
| High severity | 10 |
| Confirmed memory leaks | 8 |
| Hot-path findings | 87 |
| Bench baselines authored | 13 |
## Executive summary
Across ~70 confirmed findings, @robonen/primitives is structurally sound but carries two categories of real risk that concentrate in the canvas/media-editor and selection/menu families. (1) Memory leaks: at least four high-severity gesture leaks (canvas/crop, canvas/curve-editor, canvas/levels, forms/slider) and several medium/low ones (overlays/drawer body-position pin, drawer/canvas-stage/date-picker timers and stale element refs) all share one root cause — resources (window pointer listeners, ResizeObservers, setTimeout, body style mutations, context element refs) are torn down only on the gesture's own happy-path end event, never on onScopeDispose/unmount, so any mid-gesture unmount (v-if toggle, route change, panel close) leaks listeners plus the retained reactive component graph and detached DOM. (2) Hot-path waste: the single largest multiplier is the shared collection getItems() (utilities/collection/useCollection.ts:94), whose sort comparator calls orderedNodes.indexOf twice per comparison — O(n^2 log n) plus a fresh querySelectorAll — and is invoked per keystroke/pointer-move by listbox, menu, menubar, toolbar, tabs, tree, roving-focus, radio-group, stepper, accordion and more; fixing its comparator to a precomputed Map<node,index> turns dozens of interactions from quadratic to O(n log n) in one edit. The second systemic multiplier is per-pointer-frame overhead: the internal usePointerDrag state is a deep reactive() firing ~13 subscriber-less triggers per frame, and many canvas components re-read getBoundingClientRect() every drag frame instead of caching the rect at gesture start (color-area, hue-slider, angle-dial, levels, waveform, compare-slider). Recurring micro-patterns — deep ref() on wholesale-replaced HSVA/array state that should be shallowRef, deep:true watches on objects only ever replaced by reference, O(n) find/indexOf per list item driven by a per-frame-invalidated computed (O(n^2)), Array.from({length}) defeating V8 packed arrays, and per-call Intl.DateTimeFormat construction — repeat across 8+ components each. Headline: 4 high-sev leaks + ~3 more leak findings, 1 shared collection fix unblocking ~12 components, and ~25 per-frame allocation/layout-read findings clustered in the canvas editors.
## Cross-cutting themes
### 1. Gesture-scoped resources removed only on the happy-path end event, never on scope-dispose/unmount (window listeners, observers, timers, body-style, context element refs leak on mid-gesture unmount)
**Components:** `canvas/crop`, `canvas/curve-editor`, `canvas/levels`, `forms/slider`, `overlays/drawer`, `canvas/canvas-stage`, `display/date-picker`
**Impact:** High. Window pointermove/up/cancel listeners, ResizeObservers, setTimeout callbacks and body position:fixed are torn down only inside the pointerup/settle handler reachable from the gesture itself. If the component unmounts while a drag/zoom is in flight (parent v-if, route change, panel teardown), the resource survives and its closure retains the unmounted instance, its reactive state and detached DOM — a real per-interrupted-gesture leak, plus broken page scroll (drawer body pin) and callbacks firing on dead refs. Uniform fix: route gestures through usePointerDrag (which scope-disposes) or add onScopeDispose(stopGesture) so unmount runs the same teardown.
### 2. Shared collection getItems() uses indexOf-in-sort-comparator (O(n^2 log n)) plus per-call querySelectorAll, fanned out per keystroke and per list item
**Components:** `utilities/collection`, `selection/listbox`, `menus/menu`, `menus/menubar`, `menus/toolbar`, `disclosure/tabs`, `navigation/tree`, `utilities/roving-focus`, `forms/radio-group`, `forms/stepper`, `forms/toggle-group`, `disclosure/accordion`, `menus/navigation-menu`
**Impact:** High leverage / single root cause. The comparator `orderedNodes.indexOf(a.ref) - orderedNodes.indexOf(b.ref)` is O(n) twice per comparison, making every keyboard nav, typeahead, and per-item isTabStop/index computed quadratic; listbox Shift-nav alone re-runs it ~6x per keystroke. One fix (build a Map<node,index> once, sort by lookup) drops the whole family to O(n log n). Compounded by callers that call getItems multiple times per interaction and by per-item computeds that each re-invoke it on every invalidation.
### 3. Per-item O(n) find/findIndex/indexOf inside a computed that is invalidated every drag frame or every selection change -> O(n^2)
**Components:** `canvas/curve-editor`, `canvas/gradient-editor`, `canvas/keyframe-track`, `canvas/timeline`, `canvas/waveform`, `navigation/tree`, `forms/toggle-group`, `forms/radio-group`, `menus/toolbar`, `selection/select`
**Impact:** High in the canvas editors. Each list-item part resolves its own data by scanning the shared array (find by id, indexOf), and the shared array is replaced wholesale every rAF during a drag (or on every selection mutation), so all N items re-scan every frame = O(n^2) per frame / per select. Uniform fix: build one memoized id->{item,index} Map (or a single shared tab-stop computed) in the Root and have each item do an O(1) lookup / equality check.
### 4. deep ref() / deep reactive() on state that is always replaced wholesale and never mutated in place
**Components:** `internal/pointer-drag`, `internal/snapping`, `internal/utils`, `overlays/tooltip`, `color/alpha-slider`, `color/color-field`, `forms/pin-input`, `forms/checkbox`, `feedback/toast`
**Impact:** Medium-high on hot paths. usePointerDrag's deep reactive() state fires ~13 subscriber-less triggers per frame; HSVA color state, snap-target arrays, grace-area polygons, pin/checkbox arrays and toast swipe deltas are deep ref()s whose objects are replaced by reference, so deep proxying buys nothing and adds per-property proxy-get/track cost on every pointer-move. Uniform fix: shallowRef (or plain object/locals) since identity replacement still triggers correctly.
### 5. deep:true watch on an object/array that only ever changes by reference
**Components:** `color/alpha-slider`, `color/color-field`, `color/hue-slider`, `selection/combobox`, `canvas/keyframe-track`, `disclosure/tabs`
**Impact:** Medium. Watchers on HSVA / model arrays use deep:true, forcing a full recursive traverse() to re-collect deps on every fire — once per pointer-move during a color drag — even though the wholesale ref-identity change already triggers a shallow watch. Drop deep:true (or use deep:1 if a bounded splice walk is needed). Several share the same useColorState/useHsvaSetters module, so one fix compounds across hue/alpha/saturation sliders.
### 6. getBoundingClientRect() (or layout read) per pointer-move frame instead of caching the rect at gesture start
**Components:** `color/color-area`, `color/hue-slider`, `canvas/angle-dial`, `canvas/levels`, `canvas/waveform`, `canvas/compare-slider`, `display/scroll-area`, `overlays/drawer`
**Impact:** Medium. Each drag frame forces a synchronous layout/reflow to re-measure an element whose box does not change mid-drag; usePointerDrag already exposes trackElementRect/elementPoint or an onStart hook to snapshot it once. Cache rect/center/size in onStart and reuse on move; clear on end. Removes one forced reflow per frame per active gesture.
### 7. Fresh object/array/style literal allocated per render or per frame on hot/repeated elements (no stable identity)
**Components:** `canvas/zoom-pan`, `canvas/histogram`, `canvas/waveform`, `canvas/flow`, `overlays/popover`, `overlays/popper`, `menus/menu`, `menus/context-menu`, `feedback/toast`, `canvas/angle-dial`
**Impact:** Medium/low. Static :style objects, per-bar style objects, per-edge interaction styles, and rect/point literals are re-created every render or every position-update frame, churning the young gen (and, for v-bind=obj, flipping vnodes to FULL_PROPS which disables patchFlag fast paths). Fixes: hoist static portions to module-level frozen consts, precompute per-item styles inside the existing series/buckets computed, and split static-vs-dynamic style with `:style="[BASE, dynamic]"`.
### 8. Array.from({length:n}) / new Array(n) pre-fill defeating V8 packed-double arrays on numeric hot paths
**Components:** `internal/spline`, `internal/histogram (canvas/histogram)`, `display/calendar (formatter caching is sibling pattern)`
**Impact:** Medium/low. LUT/polyline/bar builders seed arrays with `undefined` (tagged PACKED_ELEMENTS) before writing numbers, so V8 never transitions to unboxed PACKED_DOUBLE, and the pre-fill is a wasted pass — on per-pixel/per-frame paths (toLUT 256+ entries, projectBars per channel, sampleFnToPolyline SAMPLES=256). Fix: build packed with [] + push (or strictly ascending index writes).
### 9. Constructing Intl formatters / RegExp / reflection descriptors per call instead of caching by key
**Components:** `display/calendar`, `forms/number-field`, `forms/visually-hidden (utilities/visually-hidden)`, `internal/color`
**Impact:** Medium/low. new Intl.DateTimeFormat per day-cell/weekday, new RegExp per keystroke, and Object.getOwnPropertyDescriptor per value-sync are 1-2 orders of magnitude costlier than reusing a cached instance, and run on mount-of-a-42-cell-grid / per-character-typed / per-driven-value-change paths. Fix: module-scope Map cache keyed by (locale,options) / separator, and memoize the two HTMLInputElement setters once.
### 10. Per-pointer-move work running while idle / hover (handler not gated, no rAF coalescing, listener bound unconditionally)
**Components:** `display/scroll-area`, `canvas/compare-slider`, `canvas/levels`, `forms/slider`
**Impact:** Medium. pointermove handlers fire on mere hover or on every raw event (no rAF batching), doing emits, layout reads and scroll writes for zero visual benefit — scroll-area even scrolls-to-0 on hover. Fixes: early-return when not actively dragging (use the existing drag flag), coalesce hover updates into one rAF, and bind the listener via a reactive getter target so it only attaches when enabled.
## Recommended fix order
1. **utilities/collection getItems() — replace indexOf-in-comparator with a precomputed Map<node,index> (and short-circuit size<=1)** — Single ~5-line edit in one shared file flips ~12 components (listbox/menu/menubar/toolbar/tabs/tree/roving-focus/radio-group/stepper/accordion/navigation-menu) from O(n^2 log n) to O(n log n) per keystroke; highest leverage, lowest risk, behavior-identical.
2. **internal/pointer-drag — make DragState a plain (non-reactive) object; remove the total/delta computeds** — Eliminates ~13 subscriber-less reactive triggers + nested-Proxy set-traps per frame on the package's single shared drag primitive; all consumers verified to read state imperatively, so non-breaking and benefits every draggable at once.
3. **High-severity gesture leaks — canvas/crop, canvas/levels, forms/slider, canvas/curve-editor (and overlays/drawer body-pin, canvas-stage/drawer/date-picker timers/refs)** — Correctness/leak class: add onScopeDispose teardown (or route through usePointerDrag) so a mid-gesture unmount removes window listeners/observers/timers and restores body style; one pattern resolves all of them and they accumulate retained component graphs in real app teardown flows.
4. **color/* shared state — shallowRef HSVA in alpha/color-field roots and drop deep:true in useColorState/useHsvaSetters; same shallowRef for snapping/grace-area/pin-input/checkbox/toast** — One change in the shared useColorState/useHsvaSetters module fixes hue+alpha+saturation+color-field per-frame deep-traverse; the shallowRef rule is a mechanical, behavior-identical sweep across all wholesale-replaced state.
5. **Canvas O(n^2)-per-frame item lookups — gradient-editor, keyframe-track, timeline, curve-editor, waveform; plus shared tab-stop computeds for toggle-group/radio-group/timeline** — Build one memoized id->{item,index} Map (or single shared tab-stop computed) in each Root; same pattern across the canvas family collapses per-frame/per-select cost from O(n^2) to O(n) where large lists actually jank.
6. **Per-frame getBoundingClientRect caching — color-area, hue-slider, angle-dial, levels, waveform, compare-slider** — Cache rect/center/size in usePointerDrag.onStart and reuse on move; removes one forced reflow per frame per active drag using an already-available hook, low risk, directly improves drag smoothness.
7. **display/scroll-area — gate onPointerMove on the drag flag, stop the per-frame rAF object allocation, debounce/share the ResizeObservers, scope the wheel listener** — Fixes a correctness bug (scroll-to-0 on hover) plus steady idle GC/main-thread cost from 2-4 permanent rAF loops and page-wide non-passive wheel; several independent wins in one component.
8. **V8 packed-array + cache fixes — spline toLUT/sample, histogram projectBars/styles, calendar/number-field/visually-hidden formatter & regex & setter caching** — Mechanical hot-loop hygiene ([]+push, module-scope Map/regex/setter cache) on per-pixel/per-keystroke paths; independent, low-risk, and each is contained to one util.
9. **Static :style / object-literal hoisting and v-bind=obj -> explicit props — zoom-pan, popover, popper, menu, context-menu, histogram, flow, toast, hover-card** — Lowest-risk allocation cleanups: hoist constant styles to frozen consts, precompute per-item styles in existing computeds, split static/dynamic style; do last as a batch since each is small and non-load-bearing.
10. **menus/context-menu — switch content update-position-strategy from "always" to "optimized"** — One-line change stops a continuous per-animation-frame reposition + getBoundingClientRect loop for a static cursor anchor; scroll/resize listeners fully cover correctness, so it is safe and removes a steady rAF cost while the menu is open.
## Findings by component
Verification: ✅ = adversarially verified against real code · ❓ = uncertain (needs runtime proof) · · = medium/low candidate (not individually re-verified). `H` = hot path, `L` = leak.
### `canvas/angle-dial` — 2 finding(s)
- **🟡 medium** · dom · H · ✅ verified — `AngleDialRoot.vue:151` (geometry)
- **Issue:** geometry() calls el.getBoundingClientRect() on EVERY pointer frame. applyPointer (line 179) invokes geometry() per rAF-coalesced onMove, so each drag frame forces a synchronous layout/reflow read and allocates a fresh { cx, cy, radius } object. The dial's rect does not change during a drag (no resize/scroll-tracking), so this read is redundant after the first frame. The sibling canvas/crop/CropRoot.vue caches the rect once in startGesture (surfaceRect = ...getBoundingClientRect() on pointerdown) and reuses it across all moves.
- **Fix:** Compute the rect/center/radius once at gesture start and reuse it for the rest of the gesture. usePointerDrag already exposes an onStart and getRect/trackElementRect hook: in onStart, read rootRef.value.getBoundingClientRect() once into a gesture-scoped local (e.g. let geo: {cx,cy,radius}|null), and have applyPointer read that cached geo on move frames instead of calling geometry() each frame. Clear it onCommit/onEnd. This removes the per-frame forced layout and the per-frame object allocation.
- **⚪ low** · reactivity · H · · candidate — `AngleDialThumb.vue:47` (point)
- **Issue:** The point computed calls angleToPoint(...), which already returns a fresh { x, y } object, then re-wraps it into a second literal `return { x: p.x, y: p.y }`. This allocates two objects per recompute. point recomputes on every value change (i.e. once per frame while dragging) and feeds positionStyle, so it is on the drag update path.
- **Fix:** Return the object from angleToPoint directly: `const point = computed(() => angleToPoint(value.value, 0.5, { x: 0.5, y: 0.5 }))`. (The center literal arg can also be hoisted to a module-scope const CENTER = { x: 0.5, y: 0.5 } to drop one more per-call allocation, since angleToPoint only reads center.x/center.y.)
### `canvas/canvas-stage` — 3 finding(s), 1 leak
- **⚪ low** · memory-leak · L · ✅ verified — `CanvasStageZoomIndicator.vue:54` (watch(() => ctx.viewport.value.zoom) debounce timer)
- **Issue:** The debounce timer (let timer = setTimeout(...)) is cleared only on the NEXT zoom change inside the watcher; it is never cleared on component unmount. If the indicator unmounts while a settle-debounce timer is pending (common: toggling the indicator in a tool panel during an active pinch/wheel zoom, or unmounting within settleDelay=200ms of the last zoom tick), the timer survives unmount and its callback fires on a disposed component, writing announced.value on a dead reactive ref. Each such unmount-with-pending-timer leaves one dangling timer + the closure capturing this component's reactive state until it fires.
- **Fix:** Register cleanup so the pending timer is killed on unmount: add `import { onScopeDispose } from 'vue'` and `onScopeDispose(() => { if (timer !== null) clearTimeout(timer); });` (or onUnmounted). Cleaner still: replace the manual let-timer + setTimeout with the repo's useDebounceFn / a useTimeoutFn composable, which self-stops on scope dispose.
- **⚪ low** · watchers · · candidate — `CanvasStageRoot.vue:120` (watch([paneWidth, paneHeight, contentSize], ...))
- **Issue:** The source array mixes two primitive refs (paneWidth, paneHeight) with contentSize, a computed<Dimensions> returning a fresh object literal each evaluation. The callback only ever reads paneWidth/paneHeight (to flip `measured` once), so including contentSize adds a dependency whose new-object identity retriggers the watcher on every content-size change for no benefit. Cold (fires on resize/measure, not per-frame), but the contentSize entry is dead weight and the array form forces a shallow array-diff each run.
- **Fix:** Drop contentSize from the source and watch a single getter: `watch(() => paneWidth.value > 0 && paneHeight.value > 0, ready => { if (ready) measured.value = true; }, { immediate: true })`. Once measured flips true it stays true, so this also lets you `stop()` it after first success like the fitOnReady watcher does.
- **⚪ low** · v8-jit · · candidate — `CanvasStageRoot.vue:240` (onKeydown -> arrows Record literal)
- **Issue:** onKeydown allocates a fresh `arrows: Record<string, [number, number]>` object (plus four tuple arrays) on every keydown, even for keys that aren't arrows. This is per-keypress, not per-frame, so it is cold and low impact, but the table is rebuilt needlessly and the tuple literals churn the young gen on key-repeat (holding an arrow auto-repeats keydown).
- **Fix:** Hoist the direction lookup to module scope as a constant keyed by event.key returning unit deltas, then scale by step inside the handler: `const PAN_DIRS = { ArrowUp:[0,1], ArrowDown:[0,-1], ArrowLeft:[1,0], ArrowRight:[-1,0] };` and `const d = PAN_DIRS[event.key]; if (d) api.setViewport({ zoom: vp.zoom, x: vp.x + d[0]*step, y: vp.y + d[1]*step });`. No allocation per keypress.
### `canvas/compare-slider` — 3 finding(s)
- **🟡 medium** · watchers · H · ✅ verified — `CompareSliderRoot.vue:148` (useEventListener(currentElement, 'pointermove', ...))
- **Issue:** The hover-follow pointermove handler runs synchronously on EVERY raw pointermove over the root: it calls positionFromClient() which does a forced getBoundingClientRect() layout read, then writes position.value, which fans out to the After clip-path computed plus the Divider and Handle position-style computeds and their DOM patches. Unlike the drag path (usePointerDrag coalesces all moves into exactly one requestAnimationFrame flush per event burst), this hover path has NO coalescing. On high-Hz pointers or coalesced pointermove bursts the browser fires many events per frame, multiplying layout reads + reactive style recomputes + DOM patches per displayed frame for zero visual benefit.
- **Fix:** Coalesce the hover update into a single rAF like the drag primitive does: stash event.clientX/clientY into module/closure scratch vars on each pointermove, schedule one requestAnimationFrame if not already pending, and apply position.value = positionFromClient(...) inside the rAF callback (cancel the pending rAF on pointerleave/unmount). This caps hover work at one layout read + one position write per frame regardless of pointer event rate.
- **🟡 medium** · dom · H · · candidate — `CompareSliderRoot.vue:148` (useEventListener(currentElement, 'pointermove', ...))
- **Issue:** The hover pointermove listener is attached unconditionally to the root element regardless of the `hover` prop. When hover is false (the default), the handler still fires on every pointer move over the root and only then bails via `if (disabled || !hover || drag.isDragging.value) return`. That is a wasted function call + closure invocation per pointer move for the common non-hover configuration.
- **Fix:** Bind the listener conditionally by passing a getter target so useEventListener's reactive-target watch attaches/detaches it: useEventListener(() => (hover && !disabled ? currentElement.value : null), 'pointermove', onHoverMove). When hover (or enabled) is false the target getter returns null and no native listener is registered, eliminating the per-move callback entirely; it rebinds automatically if hover flips on.
- **⚪ low** · reactivity · · candidate — `CompareSliderRoot.vue:171` (rootStyle)
- **Issue:** rootStyle is `computed(() => ({ position: 'relative' }))` but the value never changes, so the computed's ReactiveEffect and cache are allocated per instance for nothing. The sibling CompareSliderBefore already uses a plain frozen const object for the identical 'stable monomorphic style' purpose, so this is inconsistent as well as wasteful.
- **Fix:** Replace with a module- or setup-level plain const: `const rootStyle = { position: 'relative' } as const;` (mirroring CompareSliderBefore's style const). Identity stays stable for the :style patch and no effect is created.
### `canvas/crop` — 1 finding(s), 1 high, 1 leak
- **🔴 high** · memory-leak · L · ✅ verified — `CropRoot.vue:270` (startGesture / endGesture)
- **Issue:** startGesture attaches three window (globalThis) listeners — 'pointermove' (onPointerMove), 'pointerup' (onPointerUp), 'pointercancel' (onPointerCancel) — at lines 270-272. They are removed ONLY inside endGesture (lines 231-233), which is reachable solely from the pointerup/pointercancel handlers. There is no onScopeDispose / onUnmounted. If CropRoot unmounts while a drag/resize/create gesture is in flight (parent v-if toggles the crop off mid-drag, route change, conditional editor teardown), all three window listeners leak. Their closures retain onPointerMove/onPointerUp/onPointerCancel, which in turn capture the component's reactive state (localRect shallowRef, defineModel model, gestureStartRect, captureEl element ref), keeping the unmounted instance and a detached DOM element alive. The captureEl pointer capture is also never released. This is a real leak that accumulates one set of window listeners + a retained component graph per interrupted gesture.
- **Fix:** Register a setup-scope cleanup so the gesture tears down on unmount: import { onScopeDispose } from 'vue' and add `onScopeDispose(() => { if (pointerId !== -1) endGesture(false); });`. Setup-time onScopeDispose auto-runs on unmount and will remove all three window listeners and release pointer capture via the existing endGesture path (which already handles releasePointerCapture and resets pointerId/mode). No manual stop bookkeeping needed.
### `canvas/curve-editor` — 4 finding(s), 2 high, 1 leak
- **🔴 high** · memory-leak · L · ✅ verified — `CurveEditorRoot.vue:388` (watch(currentElement) -> new ResizeObserver(measure))
- **Issue:** The ResizeObserver created at line 388-391 is observe()'d but never disconnected. `ro` is a function-local with no reference retained for cleanup, so it survives component unmount (it keeps the root node, the measure closure, and the component scope alive) and leaks. Worse, the watcher fires whenever currentElement changes; each run allocates and observes a NEW ResizeObserver without disconnecting the prior one, so observers stack. The watcher uses immediate:false and never returns/registers a teardown.
- **Fix:** Disconnect on teardown and on re-run. Hoist the observer reference and disconnect it: declare `let ro: ResizeObserver | undefined` at setup scope, set it inside the watcher, `ro?.disconnect()` before creating a new one, and register `onScopeDispose(() => ro?.disconnect())` (or use the watch cleanup callback / onWatcherCleanup). Simplest: use the project's useEventListener-style ResizeObserver wrapper or call `watch(currentElement, (node, _old, onCleanup) => { ...; const ro = new ResizeObserver(measure); ro.observe(node); onCleanup(() => ro.disconnect()); })`.
- **🔴 high** · watchers · H · ✅ verified — `CurveEditorRoot.vue:239` (commit() / updateAnchor / updateHandle)
- **Issue:** commit() (line 239) deep-copies the entire anchor array `localAnchors.value.map(a => ({ ...a }))` and emits 'anchorsCommit' on EVERY updateAnchor (line 274) and updateHandle (line 293) call. Those run from the drag onMove callback (CurveEditorPoint.vue:101, CurveEditorHandle.vue:79), i.e. once per requestAnimationFrame for the whole duration of a drag. So a drag fires N-object-allocating array copies + a parent emit every frame. This is per-frame hot-path allocation/notify waste AND a semantic defect: the emit doc (CurveEditorRoot.vue:65) says anchorsCommit fires 'after a drag or keypress settles', but it currently fires continuously mid-drag.
- **Fix:** Separate the live update from the commit. Keep updateAnchor/updateHandle mutating localAnchors (live), but do NOT call commit() inside them. Fire commit() from the gesture end: wire usePointerDrag's onCommit (or onEnd) in CurveEditorPoint/Handle to call a ctx.commit() exposed on the context, and for keyboard nudges call commit() once per keydown (already discrete). This drops the per-frame array deep-copy + emit to one per settle.
- **🟡 medium** · reactivity · H · · candidate — `CurveEditorPoint.vue:43` (index = computed(() => ctx.indexOf(anchor.id)) / isEndpoint)
- **Issue:** ctx.indexOf (CurveEditorRoot.vue:193) is an O(n) linear id scan. CurveEditorPoint derives `index` (line 43) and `isEndpoint` (line 44, which itself calls indexOf again internally) as computeds that invalidate every time localAnchors is replaced — i.e. every drag frame. Across a list of N points that is O(n^2) id scans per dragged frame. Negligible for typical tone curves (handful of anchors) but scales poorly and is pure churn on the hot path.
- **Fix:** Maintain an id->index Map in CurveEditorRoot rebuilt once whenever localAnchors changes (e.g. a computed Map), and have indexOf/isEndpoint read from it (O(1)). Alternatively pass the index down as a prop from the v-for in the consumer so each Point needn't scan. isEndpoint should reuse the already-computed index rather than calling indexOf a second time.
- **⚪ low** · template · H · · candidate — `CurveEditorPoint.vue:51` (positionStyle computed -> { left, top })
- **Issue:** positionStyle allocates a fresh {left, top} object whenever pxX/pxY change (every drag frame for the dragged point). Vue value-diffs :style so it does not force extra patches, but it is a per-frame allocation per moving point. Minor.
- **Fix:** Acceptable as-is given :style value-diffing; if optimizing, bind left/top via CSS custom properties or a transform string and update a single string, or accept the allocation. Not worth changing unless profiling shows GC pressure with many simultaneously animating points.
### `canvas/flow` — 3 finding(s)
- **🟡 medium** · list · H · · candidate — `FlowMiniMap.vue:88` (nodes / <rect v-for>)
- **Issue:** `nodes = computed(() => [...ctx.nodeLookup.value.values()].filter(n => !n.hidden))` recomputes on every `triggerRef(nodeLookup)` — which fires once per frame during a node drag and (with measurement) during pan. Each frame it allocates a fresh array of every node, and the keyed `<rect v-for="n in nodes">` has no v-memo, so all N rects re-evaluate their x/y/width/height/data-selected bindings every frame even though only the dragged node's geometry changed. With the MiniMap mounted this is per-frame O(n) allocation + patch on the drag hot path. `bounds`/`viewRect` (getNodesBounds over all nodes) also re-run each frame.
- **Fix:** Add `v-memo="[n.positionAbsolute.x, n.positionAbsolute.y, n.measured.width, n.measured.height, ctx.selection.value.nodes.has(n.id)]"` to the node <rect> so untouched rects skip patching. Optionally throttle the minimap to ~30fps during interaction (gate the heavy recompute on ctx.isInteracting) since it is an overview, not a precise surface. The array spread itself is hard to avoid while staying reactive, but the per-rect patch is the dominant cost and v-memo eliminates it.
- **⚪ low** · template · H · · candidate — `FlowEdge.vue:124` (interactionPathStyle)
- **Issue:** `interactionPathStyle` is a computed that allocates a fresh `{ pointerEvents, cursor }` object whenever `edge.value` identity changes, which happens every drag/pan frame for edges incident to a moving node (the edge re-renders because its endpoints recompute). The sibling visible path correctly uses the frozen `visiblePathStyle` constant. The value only varies on `edge.selectable`, so the per-frame re-allocation is wasted (style is value-diffed, so no DOM write, but it allocates an object per incident edge per frame).
- **Fix:** Since only two outcomes exist, precompute two frozen constants (`STROKE = { pointerEvents: 'stroke', cursor: 'pointer' }` and `NONE = { pointerEvents: 'none', cursor: 'pointer' }`) and select between them: `const interactionPathStyle = computed(() => edge.value?.selectable === false ? NONE : STROKE)` — returns a stable reference, zero allocation on the hot path.
- **⚪ low** · dom · · candidate — `FlowPane.vue:49` (useElementBounding(currentElement))
- **Issue:** useElementBounding is called with default options, which use `updateTiming: 'sync'` and install a capture-phase, document-wide `scroll` listener plus a `resize` listener and a MutationObserver. Each of those fires `recalculate()` synchronously, forcing a layout (getBoundingClientRect) on the pane the moment any ancestor scroll container scrolls. It is not on the pan/zoom path (pan does not emit scroll), so impact is small, but a scrollable page around the canvas will thrash layout on every scroll tick.
- **Fix:** Pass `{ updateTiming: 'next-frame' }` to coalesce the rect read into one rAF per frame: `useElementBounding(currentElement, { updateTiming: 'next-frame' })`. The pane origin only feeds coordinate math, so a one-frame-late rect is imperceptible.
### `canvas/gradient-editor` — 3 finding(s)
- **🟡 medium** · reactivity · H · ✅ verified — `GradientEditorStop.vue:43` (stop / index computeds)
- **Issue:** Each GradientEditorStop derives its own data with two O(n) scans over the shared list: `stop = ctx.stops.value.find(s => s.id === stopId)` (line 43) and `index = ctx.indexOf(stopId)` (line 44), where ctx.indexOf is the root's looping scan (GradientEditorRoot.vue:167). Dragging one stop calls moveStop -> updateStop -> commitStops, which replaces localStops with a fresh slice EVERY rAF frame, invalidating the shared `sorted` computed. That re-runs find + indexOf in EVERY mounted stop, producing O(n^2) work per pointer-move frame (n = stop count). It is also redundant: find and indexOf scan the same array for the same id.
- **Fix:** Build the per-id derivation once in the root and share it. Add a `computed(() => { const m = new Map<string,{stop,index}>(); sorted.value.forEach((s,i)=>m.set(s.id,{stop:s,index:i})); return m; })` to context and have each stop read `const entry = computed(() => ctx.stopIndex.value.get(stopId))` -> entry.stop / entry.index. That collapses the per-stop cost to a single O(1) Map.get and makes the whole list O(n) per frame instead of O(n^2). Drop the root's looping indexOf in favor of the same map.
- **⚪ low** · reactivity · H · · candidate — `GradientEditorColorEditor.vue:38` (selectedStop computed)
- **Issue:** `selectedStop = computed(() => ctx.stops.value.find(s => s.id === ctx.selectedId.value))` re-runs an O(n) find every time `sorted` changes. While the selected stop is being dragged, `sorted` is replaced every rAF frame, so this re-finds (and feeds a fresh color string into ColorFieldRoot) once per frame. Single-component cost (not per-stop), so impact is small, but it disappears for free if the root exposes the id->stop map suggested for the Stop finding.
- **Fix:** Read from the shared `ctx.stopIndex` map: `computed(() => { const id = ctx.selectedId.value; return id ? ctx.stopIndex.value.get(id)?.stop ?? null : null; })`, turning the per-frame O(n) find into an O(1) lookup.
- **⚪ low** · gatekeeping · · candidate — `GradientEditorAngle.vue:67` (AngleDialThumb v-bind literal)
- **Issue:** `<AngleDialThumb v-bind="{ 'aria-label': 'Gradient angle' }" />` allocates a fresh object literal and uses v-bind (FULL_PROPS) on a component vnode every time GradientEditorAngle re-renders (e.g. on every angle change during a dial drag), disabling patch-flag fast paths and forcing the thumb child to update. Cold-ish (only the default-slot fallback, only re-renders on angle change) so severity is low, but it is a pure static value.
- **Fix:** Replace with a static bound attr that the compiler can hoist: `<AngleDialThumb aria-label="Gradient angle" />` (no v-bind, no per-render object allocation).
### `canvas/histogram` — 3 finding(s)
- **🟡 medium** · v8-jit · H · · candidate — `utils.ts:103` (projectBars)
- **Issue:** `const out: number[] = Array.from({ length: bins.length })` allocates a 256-element array pre-filled with `undefined`, then overwrites each index. This seeds the array as a HOLEY/undefined-filled elements kind before it is repopulated with numbers, defeating the packed-double fast path V8 would give a freshly grown numeric array. projectBars runs per channel (up to 3 for RGB) every time the series computed recomputes — a per-render hot path over 256 bins.
- **Fix:** Build a packed array by growing it: `const out: number[] = []; for (let i = 0; i < bins.length; i++) out.push(projectBarHeight(bins[i]!, max, scaleType));` (push keeps it PACKED_DOUBLE). If pre-sizing is preferred, use `const out = new Array(bins.length); for (...) out[i] = ...` filling strictly 0..n-1 in order, but plain []+push is the safest packed pattern.
- **🟡 medium** · template · H · · candidate — `HistogramBars.vue:75` (default bars v-for)
- **Issue:** `:style="{ height: `${h * 100}%` }"` allocates a fresh style object (and a fresh template string) for every bar on every render of HistogramBars — up to ~256 bars per channel, ~768 across RGB. :style is value-diffed so this does not cause extra patches, but it is N object + N string allocations per render in the default (fallback) paint, churning the young-gen each time the root's data/scaleType/channel changes.
- **Fix:** Precompute the style strings once per render in the series computed instead of in the template: map each channel's `heights` to `{ height: `${h*100}%` }` (or a CSS height string) inside the existing `series` computed so the array of style objects is allocated once per recompute and reused by the v-for, rather than re-created on every render pass. Alternatively expose the raw number and let consumers paint, but the built-in fallback should hoist the per-bar style out of the template expression.
- **⚪ low** · template · H · · candidate — `HistogramBars.vue:68` (channel wrapper :style)
- **Issue:** `:style="{ '--histogram-color': row.color }"` allocates a new style object per channel per render (1-3x). Minor relative to the per-bar case, but it is part of the same default-paint allocation churn and `row.color` is already a stable constant from HISTOGRAM_CHANNEL_COLORS.
- **Fix:** Fold the wrapper style into the same precomputed `series` rows (add a `style`/`varStyle` field built once in the computed) so the v-for binds a stable object reference instead of constructing one each render.
### `canvas/keyframe-track` — 4 finding(s)
- **🟡 medium** · reactivity · H · ✅ verified — `KeyframeTrackKeyframe.vue:35` (keyframe / index computeds)
- **Issue:** Each KeyframeTrackKeyframe derives its data via ctx.keyframes.value.find(k => k.id === keyframeId) (line 35) AND a second full scan ctx.keyframes.value.findIndex(...) (line 36). KeyframeTrackSegment does the same findIndex (line 35). During a drag, moveKeyframe -> setKeyframes replaces working.value and triggerRef(working) on every committed pointer frame (Root.vue line 300/354), invalidating the keyframes computed (Root line 159). That re-runs find+findIndex for ALL N keyframe parts every frame => O(N^2) array scans per pointer-move with N keyframes mounted. KeyframeTrackKeyframe even scans twice (find + findIndex) where one indexed lookup would yield both.
- **Fix:** Expose a memoized id->index Map (and/or id->keyframe Map) on the context, rebuilt once per keyframes change in Root, e.g. const indexById = computed(() => { const m = new Map<string,number>(); working.value.forEach((k,i)=>m.set(k.id,i)); return m; }). Then in the part: const i = ctx.indexById.value.get(keyframeId) ?? -1; const keyframe = computed(() => ctx.keyframes.value[ctx.indexById.value.get(keyframeId) ?? -1]). This turns each part's per-frame lookup into O(1) and the whole-track cost from O(N^2) to O(N) per frame.
- **🟡 medium** · reactivity · H · ✅ verified — `KeyframeTrackRoot.vue:254` (snapEngine config (axis: 'x'))
- **Issue:** useSnapping is configured with axis: 'x' (line 254). snapTime() is called once per pointer-move from KeyframeTrackKeyframe.onMove (Keyframe.vue line 107). In useSnapping.snap1d, mode 'x' does allTargets.filter(t => t.axis === axis) on EVERY call (useSnapping.ts line 184). The component's snapTargets are built exclusively via gridTargets(..., 'x') (Root lines 248), so 100% of targets already have axis:'x' — the filter reallocates a full grid array (hundreds-to-thousands of entries for a multi-second track at fps granularity, gridTargets emits one target per frame) every pointer frame, producing an identical array each time.
- **Fix:** Set axis: '1d' instead of 'x' in the useSnapping options (Root line 254). In '1d' mode useSnapping uses all targets as a single pool with no per-call filter (useSnapping.ts line 184 branch). Since every produced target is already x-axis this is behaviour-equivalent and removes the per-frame array allocation+scan.
- **🟡 medium** · template · H · · candidate — `KeyframeTrackSegment.vue:57` (path computed)
- **Issue:** When samples >= 2, the path computed resamples the eased curve `samples` times, each calling ctx.sampleAt (binary search over keyframes, O(log N)) plus projection/projectValue calls, and string-concatenates the SVG `d` (Segment lines 57-79). It depends on start/end/laneHeight/projection/sampleAt, all of which change on every drag frame, so during a drag every visible segment with samples set recomputes segments x samples x O(log N) work per frame. With the default-off samples=0 this is inert, but a consumer that sets samples on many segments incurs heavy per-frame churn.
- **Fix:** Gate the resample to when the segment is actually animating and de-dupe redundant recompute: wrap the rendered <path>/segment item in v-memo keyed by a cheap signature (e.g. [start.time, end.time, start.value, end.value, JSON of easing, laneHeight]) so segments whose endpoints did not move that frame skip the resample; or only mount the path for the currently-dragging/selected segment. Also hoist `samples` reads and avoid string concat by pushing into a packed array joined once.
- **🟡 medium** · reactivity · · candidate — `KeyframeTrackRoot.vue:157` (watch(model, reconcile, { deep: true }))
- **Issue:** reconcile is wired with deep:true over the entire model array of keyframe objects. The component's own writes always replace the array AND its elements by reference (model.value = candidate.map(k => ({...k})), Root lines 318/328/369/382), so a bounded depth catches every internal change; the full deep walk additionally traverses into each keyframe's nested easing tuple on every reconcile and registers a dependency on every scalar of every keyframe, an O(N) deep traversal that is pure overhead for the by-reference write pattern.
- **Fix:** Use deep: 1 instead of deep: true. depth-1 still detects array length changes and per-index element-reference swaps (covering both the component's by-reference writes and an external splice/push that swaps elements), without walking into each object's value/easing fields. If only by-reference array replacement must be observed, a plain shallow watch(() => model.value, reconcile) suffices.
### `canvas/levels` — 2 finding(s), 1 high, 1 leak
- **🔴 high** · memory-leak · L · ✅ verified — `LevelsRoot.vue:236` (startDrag / handlePointerUp)
- **Issue:** startDrag attaches globalThis 'pointermove' and 'pointerup' listeners (lines 236-237) that are removed ONLY inside handlePointerUp (lines 225-226). LevelsRoot has no onScopeDispose/onBeforeUnmount. If the component unmounts while a drag is active (activeKind !== null) — e.g. an editor panel closes while the user holds a thumb — pointerup never fires for this scope, so both window listeners survive unmount. Their closures (handlePointerMove/handlePointerUp) capture the entire setup scope (localValue, value, emit, snapHandle, etc.), retaining the dead component instance forever, and handlePointerMove keeps calling setHandle on the unmounted component on every subsequent pointermove anywhere on the page.
- **Fix:** Register cleanup at setup: onScopeDispose(() => { globalThis.removeEventListener('pointermove', handlePointerMove); globalThis.removeEventListener('pointerup', handlePointerUp); }). Better, drop the hand-rolled listeners entirely and route the whole gesture through the existing usePointerDrag (as LevelsThumb already does), which gesture-scopes the window listeners and tears them down via its own onScopeDispose.
- **🟡 medium** · dom · H · ✅ verified — `LevelsRoot.vue:217` (handlePointerMove / getValueFromPointer)
- **Issue:** handlePointerMove runs synchronously on every raw 'pointermove' with no requestAnimationFrame coalescing, and getValueFromPointer calls track.getBoundingClientRect() (line 175) on each call — forcing a layout read per raw pointer event (which can fire several times per frame), plus a reactive object replacement each time. The sibling usePointerDrag deliberately stash-and-flushes exactly one frame per event burst to avoid this; the root's hand-rolled path bypasses that.
- **Fix:** Stash the latest event and flush in a single requestAnimationFrame (cancel it on pointerup/scope-dispose), mirroring usePointerDrag.flush/schedule. Optionally cache the track rect at startDrag instead of re-measuring per move. Easiest path: delegate the move phase to usePointerDrag entirely.
### `canvas/timeline` — 2 finding(s)
- **🟡 medium** · reactivity · H · ✅ verified — `TimelineRoot.vue:317` (snapTargets)
- **Issue:** During a clip move/trim drag, each onMove calls moveClip/trimClip -> setClip -> triggerRef(clipLookup) (line 387), which invalidates the snapTargets computed. The next pointermove's snapTime() re-reads snapTargets.value, rebuilding the ENTIRE target pool every frame: 3 edge targets per clip (edgeTargets, O(N) allocs), marker/playhead point targets, AND gridTargets across the full visible window. gridTargets emits one entry per snap step across [visibleStart, visibleEnd]; at low zoom (e.g. pxPerSecond=2, ~500s window, 1/30s step) that is ~15000 SnapTarget object allocations per drag frame. The grid, markers, and non-dragged clip edges do not change during a drag (offset/zoom/window/markers are constant), so reallocating them every pointermove is pure churn on the hottest path.
- **Fix:** Split the snap-target pool into a static part and the live part. Memoize gridTargets in its own computed keyed only on visibleStart/visibleEnd/step (so a drag frame that only mutates clipLookup does not rebuild it), and likewise memoize marker/other-clip edge targets separately from the dragged clip. Concatenate the cached arrays at snap1d call time, or have the snap engine read from pre-split pools. Alternatively, do not triggerRef(clipLookup) for snap purposes mid-drag — keep the dragged clip's transient position out of the snap pool entirely (it is already excluded via dragExclude) so the pool stays stable across the gesture.
- **🟡 medium** · reactivity · · candidate — `TimelineClip.vue:43` (isTabStop)
- **Issue:** Each clip's isTabStop computed scans ctx.orderedClipIds (loop at line 47) to locate the first selected clip in time order. With N rendered clips this is O(N) per clip, so any selection change (marquee select, click, nudge) recomputes N clip computeds at O(N) each = O(N^2) total. Not per-frame, but a 1000+ clip timeline will jank on every select/marquee release.
- **Fix:** Compute the single roving tab-stop id once in TimelineRoot as a computed (first selected clip in orderedClipIds, else orderedClipIds[0]) and expose it on the context. Each TimelineClip then does isTabStop = computed(() => ctx.tabStopClipId.value === clipId), turning the per-selection cost from O(N^2) into O(N).
### `canvas/waveform` — 3 finding(s)
- **🟡 medium** · dom · H · ✅ verified — `WaveformRegionHandle.vue:78` (usePointerDrag.onMove -> timeFromClientX -> bodyRect)
- **Issue:** On every pointermove frame of a handle drag, onMove calls timeFromClientX -> bodyRect(), which walks parentElement up the DOM tree searching for data-waveform-root and then calls getBoundingClientRect() (a forced synchronous layout/reflow). Both the tree walk and the layout read are repeated per frame even though the root element and (during a single drag with no scroll/resize) its left origin do not change.
- **Fix:** Resolve the root element and its bounding rect once in usePointerDrag.onStart (cache them in gesture-local let variables, e.g. let originLeft = bodyRect()?.left ?? 0), then in onMove compute projection.invert(state.point.x - originLeft) using the cached value. Alternatively pass getRect to usePointerDrag so it captures the rect on pointerdown. Clear the cache in onCommit/onEnd. This removes the per-frame DOM walk and the per-frame forced reflow.
- **🟡 medium** · list · H · · candidate — `WaveformRegion.vue:34` (region = computed(() => ctx.regions.value.find(r => r.id === regionId)))
- **Issue:** Each WaveformRegion resolves its own data with ctx.regions.value.find(...), an O(M) linear scan over the regions array. With M regions rendered (one WaveformRegion each), any change to the regions array re-runs every region's computed -> O(M^2) total work, and each scan registers proxy reads across the whole array. start/end/leftPx/rightPx/ariaLabel/positionStyle all chain off this. Fine for a handful of regions, but quadratic as count grows.
- **Fix:** Expose a Map-based lookup from the root context (e.g. regionsById: ComputedRef<Map<string, WaveformRegionData>> built once per regions change) and read ctx.regionsById.value.get(regionId) in WaveformRegion, turning per-region resolution from O(M) into O(1) and the total from O(M^2) into O(M). The root already owns the array, so building the Map alongside regionList is cheap.
- **🟡 medium** · template · H · · candidate — `WaveformBars.vue:58` (v-for bar -> :style="barStyle(bar)")
- **Issue:** barStyle(bar) is invoked per bar inside the v-for and builds a fresh CSSProperties object (with several interpolated px/% strings and a custom property) on every render of the bars list. buckets can be hundreds of bars; each buckets recompute (width/peaks/window/barWidth/barGap change) reallocates N style objects plus N strings. :style is value-diffed so it is cheaper than :class, but the allocation/string churn is the inner render-loop cost.
- **Fix:** Fold the presentational style into the bucket geometry produced by buildBars (precompute the left/width/height strings and the --waveform-bar value once when buckets is computed), or memoize per-bar style keyed by the bar's identity, so the template binds a precomputed object instead of calling a method that allocates per item per render. This moves the string-building out of the hot render path and into the single buckets computation.
### `canvas/zoom-pan` — 2 finding(s)
- **🟡 medium** · template · H · · candidate — `ViewportContent.vue:40` (ViewportContent :style)
- **Issue:** The transformed layer (the single GPU-composited element that moves on every pan/zoom frame) binds :style to one inline object literal that mixes 3 static strings (position, inset, transformOrigin) with the dynamic `transform` computed and a conditional `willChange`. Each viewport mutation re-renders this component every gesture frame, reallocating the whole style object and re-walking/value-diffing the 3 static keys per frame. transform/willChange are the only values that ever change.
- **Fix:** Hoist the static portion to a module/setup-level frozen constant (`const BASE_STYLE = { position:'absolute', inset:'0', transformOrigin:'0 0' }`) and expose only the dynamic part via a computed, e.g. `:style="[BASE_STYLE, dynamicStyle]"` where dynamicStyle is a computed returning `{ transform, willChange }`. This stops re-diffing the static keys and confines per-frame allocation to the 2 changing properties.
- **⚪ low** · v8-jit · H · · candidate — `useZoomPan.ts:228` (keydown handler `arrows` record)
- **Issue:** Every keydown on the focused surface allocates a fresh `Record<string,[number,number]>` plus four tuple arrays before checking whether the key is even an arrow. During arrow-key panning (OS key-repeat) this is a warm path, and the object is allocated for non-arrow keys too.
- **Fix:** Replace the per-event object with a branchless lookup that allocates nothing on the miss path: compute dx/dy via a small switch on event.key (returning early for non-arrows) using the local `step`, or keep a module-scope unit table `{ ArrowUp:[0,1], ... }` and multiply by `step` only after a hit. No object is then allocated unless an arrow key is actually pressed.
### `color/alpha-slider` — 2 finding(s)
- **🟡 medium** · reactivity · H · · candidate — `AlphaSliderRoot.vue:57` (standalone)
- **Issue:** The canonical colour is held in a deep ref<HSVA> (standalone = ref<HSVA>(...)). Every alpha write replaces the object wholesale via { ...cur, a } in useHsvaSetters.setAlpha — the HSVA is never mutated in place — so Vue's deep reactivity conversion and per-property dependency tracking on the nested {h,s,v,a} object is wasted work. During a pointer drag setAlpha runs once per committed frame, so this overhead is paid per pointer-move.
- **Fix:** Use shallowRef<HSVA> instead of ref<HSVA>. Since the object is always replaced (never mutated field-by-field), shallowRef gives identical reactivity at the consumer (alpha = computed(() => hsva.value.a) still re-runs on replacement) with no nested proxying. Apply the same to the standaloneState computed wrapper which inherits the deep ref.
- **🟡 medium** · watchers · H · · candidate — `../color-field/useColorState.ts:31` (useHsvaSetters watch(() => hsva.value, ..., { deep: true }))
- **Issue:** In standalone mode AlphaSliderRoot drives useHsvaSetters, which registers watch(() => hsva.value, cb, { deep: true }) to track last non-zero hue. The watched HSVA is always replaced wholesale by the setters (commit assigns hsva.value = next), so a plain getter watch already fires on the identity change. deep:true adds a full recursive traversal of the HSVA object both when establishing the dependency and on every change — i.e. on every setAlpha during a drag. The callback only reads c.s/c.v/c.h (top-level scalars), so no deep walk is needed.
- **Fix:** Drop { deep: true } (watch the getter () => hsva.value with default shallow behavior). The wholesale replacement guarantees the getter watch fires; the callback only reads top-level channels. This is a shared module also used by hue/saturation sliders, so the win compounds. If a future caller mutates a channel in place, prefer deep:1 over deep:true.
### `color/color-area` — 1 finding(s)
- **🟡 medium** · dom · H · ✅ verified — `ColorAreaRoot.vue:91` (setFromPointer)
- **Issue:** setFromPointer is invoked from usePointerDrag's onMove, i.e. once per requestAnimationFrame flush for the entire duration of a pointer drag. On every frame it calls track.getBoundingClientRect() (line 91) to read rect.left/top/width/height. getBoundingClientRect forces a synchronous layout/reflow, so the picker performs one forced layout flush per drag frame. This is fully redundant: usePointerDrag is configured with trackElementRect:true (line 104), which already snapshots the element rect once at pointerdown and exposes state.elementPoint (= last - rect.left/top). The element box does not change mid-drag, so re-measuring it every frame is wasted layout work on the hottest path of the component.
- **Fix:** Cache the rect once when the gesture begins and reuse it on every move. Add `let dragRect: DOMRect | null = null;` in setup, set `dragRect = trackRef.value?.getBoundingClientRect() ?? null` inside onStart (which fires once at pointerdown), clear it to null in an onEnd handler, and have setFromPointer use the cached dragRect (falling back to a one-off getBoundingClientRect for the keyboard/programmatic path) instead of measuring every frame. This removes the per-frame forced reflow while keeping the 0-1 normalization (which needs rect.width/height) intact.
### `color/color-field` — 2 finding(s)
- **🟡 medium** · watchers · H · · candidate — `ColorFieldRoot.vue:89` (watch(hsva, c => pushOut(c), { deep: true }))
- **Issue:** This watcher fires on every channel setter call (i.e. per pointer-move during a slider/area drag). Every setter in useHsvaSetters does commit({...}) which REPLACES hsva.value with a brand-new object reference; nothing ever mutates hsva.value's h/s/v/a in place (verified across the whole color/ package). With deep:true, Vue runs traverse() over the HSVA object on every fire to re-collect dependencies — pure overhead, because the wholesale ref-identity change already triggers the watcher. The deep walk is small (4 numeric props) but it runs continuously throughout a drag.
- **Fix:** Drop the deep option: watch(hsva, c => pushOut(c)). The ref's value is replaced by reference on every commit, so the default (non-deep) ref watch fires on exactly the same changes without the per-run traversal.
- **🟡 medium** · watchers · H · · candidate — `useColorState.ts:31` (watch(() => hsva.value, (c) => { if (c.s>0 && c.v>0) lastHue = c.h }, { deep: true }))
- **Issue:** In standalone mode (sub-picker without a ColorFieldRoot ancestor) this runs once per pointer-move during a drag. The setters returned by useHsvaSetters always commit({...}) a fresh object, so hsva.value's identity changes on every write and the getter () => hsva.value already yields a new reference each time. The combination of a getter source AND deep:true makes Vue traverse the HSVA object on every fire to re-collect deps — redundant overhead on the hot drag path. (It also reads slightly inconsistently: a getter source plus deep:true when the value is never mutated in place.)
- **Fix:** Watch the ref identity without deep: watch(hsva, (c) => { if (c.s>0 && c.v>0) lastHue = c.h }). Since every commit replaces the object, this fires on the same changes and drops the per-run traversal.
### `color/hue-slider` — 2 finding(s), 1 high
- **🔴 high** · dom · H · ✅ verified — `HueSliderRoot.vue:84` (hueFromPointer)
- **Issue:** hueFromPointer() calls track.getBoundingClientRect() on every onStart and every onMove (each rAF-coalesced pointer frame), forcing a synchronous layout/reflow read per frame for the entire duration of a hue drag. usePointerDrag is already configured with trackElementRect:true (line 98), which caches the track rect on pointerdown and exposes the pointer-relative offset as state.elementPoint (last - rect.left/top) — exactly the 'offset' this function recomputes. The rect is therefore read twice per gesture-start and re-read needlessly every move.
- **Fix:** Cache the track rect once at gesture start (capture rect/size in the onStart callback, or read getBoundingClientRect() a single time there) and during onMove derive hue from state.elementPoint.x/y over the cached size instead of calling getBoundingClientRect() again. Concretely: store const size/rect locals in onStart, then in onMove compute offset = horizontal ? state.elementPoint.x : state.elementPoint.y, apply the same flip, and clampChannel((offset/size)*360, 360) — eliminating the per-frame layout read.
- **🟡 medium** · watchers · H · · candidate — `../color-field/useColorState.ts:31` (useHsvaSetters watch)
- **Issue:** In standalone mode the hue-slider's setHue goes through useHsvaSetters, whose watch(() => hsva.value, cb, { deep: true }) fires on every commit. Each setter replaces the whole HSVA object ({ ...cur, h }), so the object identity already changes — a shallow getter watch would fire correctly. The deep:true adds a full recursive traversal and deep reactive tracking of the (flat, 4-number) HSVA on every dependency collection and every fire, i.e. once per pointer-move frame during a standalone hue drag, for no behavioral benefit since only top-level replacement ever occurs here.
- **Fix:** Drop { deep: true } (the object is replaced by identity on every setter, so a shallow getter watch already triggers), or if a future in-place mutation is a concern use deep:1. This removes per-frame recursive traversal/tracking on the standalone hue-drag path.
### `disclosure/accordion` — 1 finding(s)
- **⚪ low** · reactivity · · candidate — `AccordionRoot.vue:185` (triggerElements)
- **Issue:** triggerElements = computed(() => getItems(true).map(i => i.ref)) is backed by the shared collection getItems(), which runs querySelectorAll + Array.from + a full .sort() whose comparator calls orderedNodes.indexOf(a.ref) for both operands — an O(n^2) sort — and rebuilds the array on every itemMap triggerRef (item mount/unmount). It is also handed to children via context. In practice this only matters on mount/unmount churn or when read inside onTriggerKeyDown; it is never read in any child render or per-frame, so the real cost is bounded to keypresses and item-set changes, not the hottest path.
- **Fix:** No change needed in the accordion itself; the cost lives in the shared collection helper (utilities/collection/useCollection.ts getItems). If the collection is ever used with large item sets, replace the indexOf-in-comparator sort with a single Map<Node, index> built from orderedNodes (O(n)) before sorting. Within the accordion, onTriggerKeyDown already gates reads behind an actual key action, so it is safe as-is.
### `disclosure/collapsible` — 1 finding(s)
- **⚪ low** · watchers · · candidate — `CollapsibleContent.vue:70` (watch(() => [isOpen.value, presenceRef.value?.present]))
- **Issue:** The size-measuring watcher uses the default flush:'pre' and works around the timing by doing `async () => { await nextTick(); ... }` before reading layout via node.getBoundingClientRect() and mutating node.style. Reading layout in a pre-flush watcher and then deferring with nextTick is the manual equivalent of a post-flush watcher: it adds an extra microtask hop and reads/writes DOM at a less predictable point in the flush cycle. This fires only on open/close transitions (cold-ish, not per-frame), so cost is small, but the pattern is fragile.
- **Fix:** Pass { immediate: true, flush: 'post' } to the watch and drop the leading `await nextTick()`; the callback then runs after the DOM patch with layout settled, so getBoundingClientRect reads the committed size directly. Keep the rest of the body (style freeze, measure, restore) unchanged.
### `disclosure/tabs` — 2 finding(s)
- **🟡 medium** · watchers · · candidate — `TabsIndicator.vue:56` (watch(() => [ctx.value.value, ctx.tabsListElement.value, ctx.direction.value, isHorizontal.value]))
- **Issue:** The watch effect tears down (listObserver?.disconnect()) and rebuilds a brand-new ResizeObserver every time it fires, re-running querySelectorAll('[role="tab"]') and observe() over every tab. Crucially it lists ctx.value.value (the selected tab) as a dependency, but selecting a different tab never adds/removes tabs or changes the list element — so every single tab switch needlessly destroys and recreates the entire observer set across all triggers, plus a full querySelectorAll DOM walk. The observer only needs rebuilding when tabsListElement changes (or the tab set changes), not on selection. Selection already updates the indicator via the [data-state=active] query inside updateIndicatorStyle, which the existing ResizeObserver does not even drive (it fires on resize, not on attribute change).
- **Fix:** Drop ctx.value.value from the observer-rebuild watch source: watch only (() => ctx.tabsListElement.value) for the observe()/disconnect() lifecycle, and use a separate cheap watch on (() => [ctx.value.value, ctx.direction.value, isHorizontal.value]) that just calls updateIndicatorStyle() (no observer churn). This removes a full disconnect+new ResizeObserver+querySelectorAll+observe-all-tabs cycle on every tab change.
- **🟡 medium** · list · · candidate — `TabsRoot.vue:140` (onTriggerKeyDown -> getItems(true))
- **Issue:** Arrow/Home/End/PageUp/PageDown keyboard roving calls getItems(true) (useCollection.ts:85-100), whose comparator does orderedNodes.indexOf(a.ref) for both operands on every comparison: that is an O(n) linear scan inside an O(n log n) sort, i.e. O(n^2 log n) DOM-array scanning per keystroke, on top of a querySelectorAll. For typical small tablists this is negligible, but it scales poorly for large/generated tab sets and runs on every arrow-key press while navigating.
- **Fix:** In getItems, build a Map<HTMLElement, number> from orderedNodes once (orderedNodes.forEach((n,i)=>m.set(n,i))) and sort with (a,b)=>m.get(a.ref)-m.get(b.ref), making it O(n log n). This lives in the shared utilities/collection module, so the fix benefits every roving consumer, not just tabs.
### `display/calendar` — 3 finding(s)
- **🟡 medium** · v8-jit · H · ✅ verified — `date-utils.ts:162` (formatDate)
- **Issue:** formatDate constructs `new Intl.DateTimeFormat(locale, opts)` on EVERY call and only uses it for a single `.format(d)`. It backs formatFullDate (CalendarCellTrigger.labelText, computed per day cell), formatMonthYear (heading), and is the engine for all calendar formatting. Intl.DateTimeFormat construction is ~1-2 orders of magnitude more expensive than calling .format() on a cached instance. On mount of a numberOfMonths-month grid the per-cell aria-label (formatFullDate) alone builds ~42 fresh formatters per month, and the whole batch is rebuilt whenever `locale` or `grid` changes (locale switch, paging that re-mounts cells).
- **Fix:** Add a module-scope cache: `const fmtCache = new Map<string, Intl.DateTimeFormat>()` keyed by `${locale}|${JSON.stringify(opts)}`; return a cached formatter (lazily created) and call `.format(d)` on it. Formatters are immutable and reusable, so the cache is bounded by distinct (locale, options) pairs (a handful in practice).
- **🟡 medium** · v8-jit · H · · candidate — `date-utils.ts:170` (formatWeekday)
- **Issue:** formatWeekday builds `new Intl.DateTimeFormat(locale, { weekday: width })` on every call. It is invoked 7× inside getWeekdayLabels (utils.ts:59) to build the header row, and once per CalendarHeadCell for the long aria-label (CalendarHeadCell.vue:24). With multiple months and on every locale/weekStartsOn/weekdayFormat change these formatters are reconstructed from scratch.
- **Fix:** Route through the same module-level formatter cache used for formatDate (key by `${locale}|weekday:${width}`), so the 7 weekday labels and per-head-cell long labels reuse one formatter per (locale,width) instead of allocating one each.
- **⚪ low** · v8-jit · H · · candidate — `CalendarCellTrigger.vue:57` (dayValue)
- **Issue:** `adapter.value.getParts(day).day.toLocaleString(ctx.locale.value)` calls Number.prototype.toLocaleString with an explicit locale, which internally constructs an Intl.NumberFormat on each call. This computed runs once per day-cell trigger (~42 per month) on mount and re-runs on locale change. Minor versus the DateTimeFormat cost but the same class of per-item Intl allocation.
- **Fix:** Cache an Intl.NumberFormat per locale at module scope (`const numFmtCache = new Map<string, Intl.NumberFormat>()`) and call `.format(day)` on the cached instance; or, since the value is a plain day-of-month digit, return `String(day)` when no locale-specific numbering is required.
### `display/date-picker` — 2 finding(s), 1 leak
- **⚪ low** · memory-leak · L · ✅ verified — `DatePickerTrigger.vue:31` (onMounted -> ctx.triggerElement.value)
- **Issue:** onMounted assigns ctx.triggerElement.value = currentElement.value but there is no onUnmounted/onBeforeUnmount that nulls it. DatePickerRoot owns triggerElement (shallowRef) and typically outlives the trigger. If the trigger is conditionally rendered (v-if) or replaced while the root stays mounted, the root context retains a reference to the now-detached trigger element, and DatePickerContent's unmount-auto-focus handler will still call ctx.triggerElement.value?.focus() on a stale node.
- **Fix:** Add onBeforeUnmount(() => { if (ctx.triggerElement.value === currentElement.value) ctx.triggerElement.value = undefined; }) so the detached element is released and stale-focus calls are avoided when the trigger unmounts independently of the root.
- **⚪ low** · memory-leak · · candidate — `DatePickerFieldRoot.vue:81` (segmentMap)
- **Issue:** segmentMap is a shallowRef<Map<HTMLElement,SegmentPart>> that is written by registerSegment (set + triggerRef) and cleaned by the returned disposer (delete + triggerRef), but it is NEVER READ anywhere in the component or the wider src/ tree. orderedSegments() resolves focus order via querySelectorAll instead. So the Map is a pure write-only registry: every segment mount/unmount allocates a markRaw(el) entry and fires triggerRef(segmentMap) notifications that have zero subscribers (wasted scheduler work), while the Map holds raw element references until the disposer runs. It is not a hard leak (onBeforeUnmount calls cleanup), but it is dead allocation plus dead reactivity churn on every field mount.
- **Fix:** Delete segmentMap entirely along with the markRaw/set/delete/triggerRef lines in registerSegment; have registerSegment just return the disposer. Since nothing consumes the map, focus ordering already works off the live DOM via orderedSegments(). This removes the per-mount Map churn and the no-op triggerRef notifications.
### `display/progress` — 1 finding(s)
- **⚪ low** · reactivity · H · · candidate — `ProgressRoot.vue:78` (value (defineModel get))
- **Issue:** defineModel get-transformers are NOT memoized like computed: every read of value.value re-invokes resolveValue (isNullish/isFiniteNumber/clamp) and every read of max.value re-invokes resolveMax. value.value is read ~8x per render (state, progress x2, valueText, ariaLabel, :aria-valuenow x2, :data-value, slot) and max.value similarly, so each progress update redundantly re-runs the validation pipeline at every read site instead of once. Cheap functions, but a progress bar can update per animation frame / per upload chunk.
- **Fix:** Validate/clamp only in the set transformer (and on the external-prop fallback path) and keep get a thin pass-through, then expose a single memoized resolved value via a computed (e.g. const resolvedValue = computed(() => resolveValue(value.value, max.value))) used by state/progress/percentage/valueText/template/context so resolveValue runs at most once per update. Same pattern for max via a resolvedMax computed.
### `display/qr-code` — 2 finding(s)
- **🟡 medium** · reactivity · H · · candidate — `QrCodeRoot.vue:102` (isReserved)
- **Issue:** ctx.isReserved iterates a reactive Map via `for (const r of reserved.values())` (QrCodeRoot.vue:86 reserved=reactive(new Map), :102 iteration). It is passed as opts.isReserved into cellsPath and invoked from isCell for every module; for the fluid pattern cellSnippet additionally calls isCell on all 4 neighbours (utils.ts:152-155), so isReserved runs up to ~5x per module. Iterating a reactive Map re-walks Vue's instrumented values()/ITERATE_KEY tracking on each of those ~5*N^2 calls (up to ~150k on version 40) rather than plain array iteration. Empty/single-entry maps make each call cheap, but the per-call reactive-proxy overhead is multiplied by the matrix loop.
- **Fix:** In QrCodeCells, snapshot the reserved regions into a plain array once per recompute (read ctx reactivity a single time) and pass a closure that tests x/y against that packed array, e.g. build `const regions = [...]` from the context and pass `isReserved: (x,y)=>{...}` over the plain array. This keeps the reactive dependency (the snapshot read) but removes reactive-Map iteration from the inner geometry loop.
- **⚪ low** · v8-jit · H · ✅ verified — `utils.ts:168` (cellSnippet)
- **Issue:** cellsPath calls cellSnippet once per dark module inside the O(n^2) double loop (lines 183-188). For every non-fluid module cellSnippet recomputes loop-invariant scalars that are identical for the entire matrix: clamp(opts.gap,0,0.95) -> gap, inset=gap/2, s=1-gap (lines 168-170), and for the rounded pattern clamp(opts.radius,0,1)*(s/2) (line 175). On a version-40 code that is up to ~31k redundant clamp()+arithmetic evaluations per recompute, and these recompute on every change of the reactive gap/radius/pattern props (e.g. a config slider).
- **Fix:** Compute gap/inset/s once (and the rounded base radius once) in cellsPath before the loop, store them on the opts/CellOptions object (or a derived plain object), and have cellSnippet read the precomputed values. Same for the fluid branch's r = clamp(opts.radius,0,1)*0.5 (line 151), which is also constant across the matrix. This collapses N^2 clamp calls to O(1).
### `display/scroll-area` — 4 finding(s), 1 high
- **🔴 high** · gatekeeping · H · ✅ verified — `ScrollAreaScrollbarImpl.vue:116` (onPointerMove)
- **Issue:** @pointermove is bound unconditionally (template line 293) and onPointerMove always emits('dragScroll', getPointerPosition(event)). pointermove fires continuously whenever the pointer is simply hovering the scrollbar track, not only during a drag. Each move emits an event up to ScrollAreaScrollbarVisible.onDragScroll which writes vp.scrollLeft/scrollTop (a layout-affecting DOM write). Because rectRef is null when not dragging, getPointerPosition returns 0 and the viewport is forced toward scroll position 0 on every hover-move — both a correctness bug and per-pointermove waste (event emit + forced scroll write while merely hovering).
- **Fix:** Early-return in onPointerMove when not dragging: `if (!rectRef.value) return;` (rectRef is only set on pointerdown and cleared on pointerup, so it is the natural drag flag). This skips the emit and the scroll write entirely while hovering, fixing both the wasted per-move work and the spurious scroll-to-0 behavior.
- **🟡 medium** · dom · H · ✅ verified — `utils.ts:73` (addUnlinkedScrollListener)
- **Issue:** The rAF loop reschedules unconditionally every frame (raf = requestAnimationFrame(loop) at line 78) so it spins forever while mounted, regardless of whether scrolling is happening. Each tick allocates a new object `const pos = { left: node.scrollLeft, top: node.scrollTop }` (line 74) and reassigns `prev = pos`, producing one garbage object per frame per loop. This listener is created in ScrollAreaThumb, ScrollAreaScrollbarScroll, and ScrollAreaScrollbarGlimpse, so a single scroll area (often X+Y) runs 2-4 permanent rAF loops each allocating per frame even when idle. This is steady GC pressure and wasted main-thread work at ~60fps for the lifetime of the component.
- **Fix:** Read scrollLeft/scrollTop into two scratch number locals reused across ticks instead of allocating an object each frame (e.g. let prevLeft = node.scrollLeft, prevTop = node.scrollTop; in loop compare node.scrollLeft/node.scrollTop directly, then assign the primitives). Better: replace the rAF poll with a passive 'scroll' listener on the node (the viewport) which already fires only on actual scroll — the 'unlinked' rAF polling is only needed for programmatic scroll edge cases and can be gated behind a flag or dropped in favor of the existing onViewportScroll scroll listener in the Impl. At minimum, keep the hidden class stable (it already is) and stop the per-frame object allocation.
- **🟡 medium** · dom · H · · candidate — `ScrollAreaScrollbarImpl.vue:251` (onMounted / onWheel)
- **Issue:** A non-passive 'wheel' listener is added to document for every scrollbar Impl instance (document.addEventListener('wheel', onWheel, { passive: false })). With both X and Y scrollbars that is two document-wide non-passive wheel listeners; onWheel runs on every wheel event anywhere on the page and only then does a `sb.contains(event.target)` check. Non-passive wheel on document forces the browser to wait for the handler before scrolling, hurting scroll responsiveness across the whole page, and the handler executes for unrelated wheel events. (Cleanup is correct in onScopeDispose, so no leak.)
- **Fix:** Attach the non-passive wheel listener to the scrollbar element itself (currentElement.value) instead of document, re-attaching in the existing `watch([...currentElement], attach)`. The contains-check becomes unnecessary and wheel events outside the scrollbar no longer pay the non-passive cost. If document is required for capture reasons, scope by region or keep it passive and avoid preventDefault.
- **🟡 medium** · watchers · · candidate — `ScrollAreaScrollbarImpl.vue:217` (attach / measure)
- **Issue:** measure() is passed raw as the callback to three separate ResizeObservers (scrollbar, viewport, content — lines 217/221/225). measure calls globalThis.getComputedStyle(sb) and reads scrollWidth/scrollHeight/offsetWidth, then emits sizesChange which writes the parent `sizes` ref and re-renders. A single layout change that resizes more than one observed element invokes measure multiple times synchronously, each forcing a style/layout read. Unlike ScrollAreaScrollbarAuto (which debounces its resize handler), Impl's measure is undebounced.
- **Fix:** Wrap measure in a small debounce (e.g. debounce(measureImpl, 10) like ScrollAreaScrollbarAuto) or share one ResizeObserver across the three targets so the getComputedStyle read coalesces to once per frame. Cancel the debounce in detach()/onScopeDispose.
### `feedback/toast` — 3 finding(s)
- **🟡 medium** · reactivity · H · · candidate — `ToastRoot.vue:105` (pointerStart / swipeDelta)
- **Issue:** pointerStart and swipeDelta are declared as deep ref<{x;y}|null>() but are read and written ONLY inside the JS pointer handlers (onPointerDown/Move/Up) — never in the template, no computed depends on them (confirmed by grep). On the swipe hot path onPointerMove fires per pointer-move and does `swipeDelta.value = delta` (and pointerStart on down), so each move pays Vue's dep track/trigger machinery for zero subscribers, and the freshly-allocated {x,y} object gets wrapped in a reactive proxy on every assignment. Pure overhead on a per-pointer-move path.
- **Fix:** Make them plain mutable locals: `let pointerStart: {x:number;y:number} | null = null;` and `let swipeDelta: {x:number;y:number} | null = null;` (drop `.value`). They drive no rendering so they need no reactivity; this removes per-move proxy wrapping and trigger cost. If a future template binding is wanted, use shallowRef instead of ref.
- **⚪ low** · template · · candidate — `ToastViewport.vue:185` (DismissableLayerBranch :style)
- **Issue:** `:style="{ pointerEvents: hasToasts ? undefined : 'none' }"` allocates a fresh style object passed to a child component vnode (DismissableLayerBranch) on every viewport render. Viewport re-renders on every toast add/remove (toastCount drives hasToasts), so it churns an allocation + forwards a new object to the child each time. Off the hottest path (not per-frame), hence low.
- **Fix:** Hoist to a computed: `const branchStyle = computed(() => ({ pointerEvents: hasToasts.value ? undefined : 'none' as const }))` and bind `:style="branchStyle"`. Computed caches the object so the child sees a stable reference between unrelated re-renders.
- **⚪ low** · dom · · candidate — `ToastRoot.vue:271` (onPointerUp click-suppression listener)
- **Issue:** `toast?.addEventListener('click', e => e.preventDefault(), { once: true })` allocates a fresh arrow closure on every swipe-ending pointerup. It is correctly self-removing via { once: true } (no leak), but the closure capture is avoidable. Per-swipe-end frequency, not per-frame, so low.
- **Fix:** Hoist a module-scope handler `const preventClickOnce = (e: Event) => e.preventDefault();` and reuse it: `toast?.addEventListener('click', preventClickOnce, { once: true })`. Same identity every time, no per-gesture allocation.
### `forms/checkbox` — 3 finding(s)
- **🟡 medium** · reactivity · · candidate — `CheckboxGroupRoot.vue:78` (localValue)
- **Issue:** `localValue` is declared `ref<T[]>(defaultValue ?? [])`, but the comment on lines 76-77 explicitly states the array model should use `shallowRef` to avoid deep-tracking each member. Sibling form roots (slider/SliderRoot.vue:123, tags-input/TagsInputRoot.vue:99) follow this convention; this file's comment matches but the code does not. `AcceptableValue` includes `Record<string, unknown>`, so a deep `ref` recursively wraps every object member in a reactive proxy. The array is always replaced wholesale (`toggle` builds `[...currentValue.value]` then assigns `model.value = next`; never mutates in place), so per-member reactivity is pure overhead: proxies are recreated on every toggle and add proxy-read cost to the per-child `isChecked` `isEqual` linear scans.
- **Fix:** Import `shallowRef` and change to `const localValue = shallowRef<T[]>(defaultValue ?? []) as Ref<T[]>;` to match the comment and the slider/tags-input siblings. Behavior is identical since the array is only ever replaced wholesale.
- **⚪ low** · reactivity · · candidate — `CheckboxRoot.vue:87` (localChecked)
- **Issue:** `localChecked` is `ref<T | 'indeterminate'>`. The component docs (lines 13-18) explicitly support arbitrary/object values as the checked model via `trueValue`/`falseValue`. A deep `ref` wraps an object value in a reactive proxy even though the value is only ever replaced wholesale (via `setChecked` -> `checked.value = v` -> the defineModel setter assigns `localChecked.value = v`). No member-level reactivity is needed.
- **Fix:** Use `shallowRef<T | 'indeterminate'>(defaultChecked ?? (falseValue as T))` instead of `ref(...)`. Avoids deep-proxying object models; boolean models are unaffected.
- **⚪ low** · watchers · · candidate — `CheckboxGroupRoot.vue:119` (watch(model))
- **Issue:** `watch(model, (v) => { if (v !== undefined && v !== localValue.value) localValue.value = v; })` duplicates the sync already done by the defineModel `set` (lines 83-86, which assigns `localValue.value = (v ?? [])`). `currentValue` also falls back to `localValue`, so the watcher's job is already covered. It is a setup-time watcher (auto-stops on unmount, no leak) but fires extra work on every model change and can momentarily store the raw vs normalized array.
- **Fix:** Remove the `watch(model, ...)` block; the defineModel setter and `currentValue` fallback already keep `localValue` in sync. If a sync for externally-controlled `modelValue` is genuinely needed, watch a getter `() => model.value` with a ref-identity guard, matching slider/SliderRoot.vue:131.
### `forms/number-field` — 1 finding(s)
- **🟡 medium** · v8-jit · H · · candidate — `utils.ts:216` (createNumberFormat.isValidPartial)
- **Issue:** A new RegExp is constructed on every call to isValidPartial, which runs from NumberFieldInput's @beforeinput handler (via ctx.validate) on EVERY keystroke when formatOptions is set. escapeRegExp(decimal) also recompiles its own /[.*+?^${}()|[\]\\]/g pass each time. The pattern depends solely on the locale decimal separator, which is already a memoized `separators` computed and only changes when locale/formatOptions change — so the regex is rebuilt needlessly per character typed, allocating a fresh RegExp object on a user-input hot path.
- **Fix:** Memoize the validation RegExp in a computed keyed on separators.value.decimal, e.g. `const partialRe = computed(() => new RegExp(`^[+-]?[\\d${escapeRegExp(separators.value.decimal)}.,\\s\\u00A0%$€£¥]*$`))`, then in isValidPartial do `return partialRe.value.test(raw)`. This rebuilds the regex only when the separator actually changes and keeps every keystroke allocation-free.
### `forms/pin-input` — 1 finding(s)
- **🟡 medium** · reactivity · H · · candidate — `PinInputRoot.vue:95` (value)
- **Issue:** `value` is a deep `ref<string[]>` but the array is only ever replaced wholesale by reference (writes at lines 102, 111, 136 all assign a fresh local array built via slice()/Array.from; line 110 is the only `value.value[i]` access and it is a read). The deep ref therefore wraps every element in a reactive Proxy and tracks per-index dependencies for no benefit. On every keystroke/paste, commit() replaces the whole array and each of the N cells' `displayed` (PinInputInput.vue:47) and `placeholderText` (PinInputInput.vue:57) computeds read `ctx.value.value[index]` back through that proxy, paying proxy-get + per-index re-tracking overhead across all cells.
- **Fix:** Change `const value = ref<string[]>(...)` to `const value = shallowRef<string[]>(...)` (shallowRef is already imported on line 44). All mutations already go through whole-array replacement (`value.value = next/nv/v`), so shallowRef triggers identically with no behavior change; it removes the deep-proxy wrapping and per-index tracking, matching the sibling `inputs` shallowRef and the repo's element/collection rule. The `nv.join('') !== value.value.join('')` guard at line 101 keeps working since join only reads.
### `forms/radio-group` — 3 finding(s)
- **🟡 medium** · reactivity · · candidate — `RadioGroupRoot.vue:114` (items)
- **Issue:** items = computed(() => getItems(true).map(i => i.ref)). getItems (utilities/collection/useCollection.ts:85) does querySelectorAll + Array.from(map.values()) + Array.sort whose comparator calls orderedNodes.indexOf(a.ref) on both sides — O(n^2 log n). The computed depends on itemMap (shallowRef + triggerRef), which is written once per CollectionItem mount, so the computed is invalidated N times while the group mounts. Each invalidation re-runs every dependent reader. For a 100+ item group this is a quadratic mount cost. Cold-once after mount, but visible on large lists and on any add/remove.
- **Fix:** Memoize DOM order cheaper: build a Map<HTMLElement, index> from the querySelectorAll NodeList once, then sort with map.get(ref) ?? Infinity instead of indexOf (O(n log n)). Better, debounce/coalesce the triggerRef so a burst of CollectionItem registrations during initial mount produces a single invalidation (e.g. a microtask-batched triggerRef) rather than N. This lives in the shared Collection; if it cannot change, have RadioGroupRoot read getItems lazily (only inside onItemKeyDown) rather than exposing a per-item-read items computed.
- **🟡 medium** · reactivity · · candidate — `RadioGroupItem.vue:82` (isTabStop)
- **Issue:** isTabStop is a computed in EVERY item; when nothing is selected (ctx.value.value === undefined) each item runs ctx.items.value.find(x => !x.hasAttribute('data-disabled')). ctx.items invalidates once per item during mount (see RadioGroupRoot items finding), so all N items re-run this find N times -> O(n^2) reads plus O(n^2) DOM hasAttribute calls in the unselected initial state. Short-circuits once a value is selected (line 81), so the cost is bounded to the unselected phase.
- **Fix:** Compute 'which element is the roving tab stop' ONCE in RadioGroupRoot (a single computed: checked element, else first enabled element) and expose it via context; each item compares currentElement === tabStopEl (O(1)) instead of every item independently scanning ctx.items. Removes the per-item O(n) find and collapses N scans to one.
- **⚪ low** · dom · · candidate — `RadioGroupItem.vue:67` (ariaLabel)
- **Issue:** ariaLabel reads label.innerText, which forces a synchronous layout/reflow (innerText is layout-aware), unlike textContent. Evaluated once on mount per item, so cost is N reflows at mount for icon-only labelled radios. Same pattern in Radio.vue:59.
- **Fix:** Use label?.textContent?.trim() || undefined instead of label.innerText. textContent does not trigger reflow and is sufficient for an accessible name derived from label text. Apply to both RadioGroupItem.vue:67 and Radio.vue:59.
### `forms/slider` — 2 finding(s), 1 high, 1 leak
- **🔴 high** · memory-leak · L · ✅ verified — `SliderRoot.vue:241` (startDragFromTrack / handlePointerUp)
- **Issue:** During a drag, window-level 'pointermove' and 'pointerup' listeners are added (lines 241-242) and only removed inside handlePointerUp (lines 228-229). There is no 'pointercancel' listener, so an interrupted gesture (touch cancelled by the OS, lost pointer capture, context menu, scroll-gesture takeover) never fires pointerup: both global listeners stay attached and activeIndex stays pinned to a stale thumb index. There is also no onScopeDispose/onBeforeUnmount, so unmounting the slider mid-drag leaks both window listeners, which close over the component's handlers and state and keep the instance alive. setPointerCapture is used (line 252), making capture-loss / pointercancel a realistic path.
- **Fix:** Add a handlePointerCancel that runs the same teardown as handlePointerUp (reset activeIndex, remove both listeners), and register globalThis.addEventListener('pointercancel', handlePointerCancel) alongside the move/up listeners in startDragFromTrack. Factor the removeEventListener pair into a single stopDrag() helper and call it from up, cancel, AND an onScopeDispose(stopDrag) (or onBeforeUnmount) so an unmount mid-drag also detaches the window listeners.
- **🟡 medium** · dom · · candidate — `SliderThumb.vue:54` (useElementSize(currentElement))
- **Issue:** useElementSize is called unconditionally for every thumb, creating a ResizeObserver per thumb. But width/height are only consumed by inBoundsOffset, which short-circuits to 0 (line 59) before touching them whenever thumbAlignment !== 'contain' — i.e. in the default 'overflow' mode the measured size is never read. The observer is therefore pure overhead on the default path: a ResizeObserver allocation per thumb at mount plus a callback that runs on every thumb layout reflow, doing reactive shallowRef writes nobody reads. (Not a leak — useResizeObserver tears down via tryOnScopeDispose.)
- **Fix:** Only measure when needed. Either gate measurement on alignment, e.g. const needSize = computed(() => ctx.thumbAlignment.value === 'contain') and pass a target getter that resolves to null when !needSize (so useResizeObserver's targets computed yields an empty array and never instantiates an observer), or lazily create the observer the first time 'contain' is active. For the common single-alignment app this drops one ResizeObserver per thumb.
### `forms/stepper` — 1 finding(s)
- **🟡 medium** · reactivity · · candidate — `StepperRoot.vue:102` (total)
- **Issue:** total = computed(() => getItems(true).length) invokes the collection's unmemoized getItems just to read a count. getItems runs collectionRef.querySelectorAll('[data-collection-item]'), two Array.from allocations, and items.sort() whose comparator calls orderedNodes.indexOf(a.ref) twice per comparison (O(n^2 log n) with per-comparison linear scans). All of that work and allocation is discarded except for .length. The computed is cached so it only recomputes when itemMap invalidates (step mount/unmount), but the count is needed at every such change and feeds total -> announcement, hasNext/hasPrev, isLastStep, the root slot :total and defineExpose, so the heavy path is taken whenever the step set changes.
- **Fix:** Use the count accessor the collection already provides instead of deriving from getItems. Destructure itemMapSize from useCollectionProvider() and set `const total = itemMapSize;` (or `const total = computed(() => itemMapSize.value)` if a ComputedRef<number> shape is required). This returns itemMap.value.size with no querySelectorAll, no Array.from, and no sort, while staying reactive to registration changes via the same itemMap dependency.
### `forms/tags-input` — 3 finding(s)
- **🟡 medium** · reactivity · ✅ verified — `TagsInputClear.vue:25` (onClick)
- **Issue:** Clear empties the list by looping `while (modelValue.length > 0) onRemoveValue(length-1)`. Each onRemoveValue does cur.slice() (O(N) copy) + splice + commit(), and commit() assigns both localValue.value and model.value. Clearing N tags costs O(N^2) array copies, N model writes, N reactive flushes/re-renders of every consumer, and N separate `removeTag` emits for what the user perceives as one action.
- **Fix:** Clear in one commit instead of replaying removals. Expose an onClearValue on the context (or inline) that does `const removed = ctx.modelValue.value; if (removed.length) commit([])` once, then emit removeTag per removed value if the contract requires it (or add a dedicated clear event). Single slice/replace, single model write, single re-render.
- **🟡 medium** · list · · candidate — `TagsInputItemDelete.vue:27` (onClick)
- **Issue:** Delete resolves the tag's index with `ctx.modelValue.value.findIndex(v => isEqual(v, item.value.value))` — a full-list scan that runs deep structural equality on each element. For object tags with many items this is O(N * deepCompare) per click, even though the item already owns its identity/position via its CollectionItem registration.
- **Fix:** Use the collection's known position for this item (the item is registered in itemMap with its DOM ref and value) or pass the index down through item context, instead of a deep-equality findIndex. If a value lookup must stay, short-circuit the cheap reference/primitive equality (v === item.value.value) before falling back to isEqual, so only genuine object tags pay the deep comparison.
- **⚪ low** · reactivity · · candidate — `TagsInputRoot.vue:155` (onAddValue)
- **Issue:** The duplicate guard `cur.some(v => isEqual(v, payload) || display(v) === display(payload))` recomputes `display(payload)` on every iteration of the scan, and runs isEqual (deep) plus a display() call for each existing tag on every add/commit.
- **Fix:** Hoist `const payloadLabel = display(payload)` above the `.some` so it is computed once, and consider checking `v === payload` (reference/primitive) before the deep isEqual to keep the common string-tag case cheap.
### `forms/toggle-group` — 2 finding(s), 1 high
- **🔴 high** · reactivity · H · ✅ verified — `ToggleGroupItem.vue:37` (isTabStop)
- **Issue:** isTabStop runs once per item and, for every item, reads ctx.items.value then .filter()s the full item list and .find()s it reading getAttribute('aria-pressed') and getAttribute('aria-checked') per element. ctx.items (ToggleGroupRoot.vue:154 `getItems(true).map(i => i.ref)`) depends on the Collection itemMap shallowRef, which is triggerRef'd on every item mount/unmount/value change and re-derives via querySelectorAll + Array.from + Array.sort each time. So when the group mounts or items change, all M item isTabStop computeds re-run, each O(N) over the list -> O(N^2) attribute reads plus repeated querySelectorAll passes. It also reads DOM attributes instead of the reactive isPressed/isDisabled, so it is simultaneously expensive and stale (won't recompute the tab-stop when pressed state changes reactively).
- **Fix:** Compute the tab-stop once in ToggleGroupRoot from reactive state (a computed returning the tab-stop HTMLElement: first item whose reactive isPressed is true, else first enabled item), expose it via context, and in ToggleGroupItem make isTabStop just `currentElement.value === ctx.tabStopElement.value`. That turns the group cost from O(N^2) DOM-attribute reads + N querySelectorAll into O(N) over a single shared computed, and makes it react to pressed changes correctly.
- **🟡 medium** · dom · H · · candidate — `ToggleGroupRoot.vue:154` (items)
- **Issue:** `const items = computed(() => getItems(true).map(i => i.ref))` recomputes whenever the Collection itemMap is triggerRef'd (each item register/unregister/value change). getItems(true) does collectionRef.querySelectorAll([data-collection-item]) + Array.from + Array.sort (O(N log N) with N indexOf calls) and then .map allocates a fresh element array. It is memoized as a computed, but it is read by onItemKeyDown (per keystroke) and by every item's isTabStop, so each invalidation pays the full DOM-query + sort once and re-allocates; combined with finding #1 it is the source of the per-group quadratic settle. This is the contributing factor to fix alongside isTabStop -- a single Root-owned ordered-items + tab-stop computation removes the repeated querySelectorAll fan-out.
- **Fix:** Keep a single Root-level computed for the ordered enabled items and derive both the tab-stop element and key-nav list from it, so querySelectorAll/sort run once per invalidation instead of being re-read and re-filtered by every item. Avoid re-filtering `!hasAttribute('data-disabled')` in items (ToggleGroupItem.vue:39) since the Root already knows the enabled set.
### `internal/color` — 2 finding(s)
- **🟡 medium** · v8-jit · H · · candidate — `convert.ts:7` (hueToRgbWeights)
- **Issue:** hueToRgbWeights returns a fresh 3-element tuple array ([c,x,0], [x,c,0], ...) on every invocation, and is called by hsvToRgb (convert.ts:28) and hslToRgb (convert.ts:114), which immediately destructure it into r,g,b. hsvToRgb sits on a per-pointer-move path: color-area/ColorAreaRoot.vue, hue-slider, alpha-slider and color-field all funnel through hsvToRgb/hsvaToCss/formatHsva inside computed()s that re-run on every drag tick. That is one throwaway packed-array heap allocation + destructure per pointer-move frame, pure GC churn with no benefit.
- **Fix:** Eliminate the tuple. Inline the hue-sector branching directly into hsvToRgb and hslToRgb, computing r/g/b as locals (e.g. a switch on Math.floor(h/60) assigning the three locals), or have hueToRgbWeights write into three out-params / a reused module-scope scratch object. Simplest: inline a `let r,g,b; if (h<60){r=c;g=x;b=0} else if ...` block in each converter so no array is allocated. Result objects already have stable shape; this just removes the intermediate allocation on the hot path.
- **⚪ low** · v8-jit · · candidate — `parse.ts:26` (parseColor)
- **Issue:** `[r, g, b, a].some(Number.isNaN)` (also at parse.ts:36 for h,s,l,a) allocates a 4-element array and runs a callback iteration purely to validate four numbers. parseColor runs on color-field text input (typing/paste), not per animation frame, so this is cold and low priority, but it is needless allocation in an otherwise allocation-light function.
- **Fix:** Replace with a direct boolean: `if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b) || Number.isNaN(a)) return null;`. No array, no callback, same semantics. Apply the same at line 36 for h/s/l/a.
### `internal/pointer-drag` — 2 finding(s)
- **🟡 medium** · reactivity · H · · candidate — `usePointerDrag.ts:405` (total / delta computeds)
- **Issue:** `total: computed(() => ({ x: state.total.x, y: state.total.y }))` and `delta: computed(() => ({ ... }))` recompute on every frame while dragging (their deps state.total.x/y change each flush) and allocate a fresh {x,y} object per recompute/access. No in-repo consumer reads drag.total or drag.delta reactively (zoom-pan reads state.total imperatively inside onMove; nobody reads the computeds). They exist only to wrap fields already exposed on `state`, so they add a reactive computed + a per-frame allocation for nothing.
- **Fix:** Remove the `total` and `delta` computeds from the return (consumers already read state.total/state.delta inside callbacks). If a public Point accessor must be retained, expose plain getters that read the live fields rather than allocation-per-recompute computeds. Combined with making `state` a plain object (finding 1), this removes two reactive computeds from the per-frame path entirely.
- **⚪ low** · reactivity · H · ✅ verified — `usePointerDrag.ts:112` (state)
- **Issue:** `state` is a deep reactive<DragState>() whose 6 nested objects (startPoint/point/elementPoint/delta/total/modifiers) each become a nested reactive Proxy. flush() (per-rAF, while dragging) writes ~9 of these nested fields individually (state.point.x/y, state.elementPoint.x/y, state.delta.x/y, state.total.x/y, state.axis) plus setModifiers writes 4 more — ~13 separate reactive set-traps + dependency triggers per animation frame, per active gesture, scaled by every simultaneously-mounted draggable. Grepping all in-repo consumers shows none of them reactively read state/total/delta/modifiers; every read is imperative inside onMove/onStart/onEnd callbacks. The deep reactivity (and the per-frame trigger storm it causes) has zero subscribers and is pure overhead on the hottest path of the package's single shared drag primitive.
- **Fix:** Make the gesture state a plain (non-reactive) object: declare `const state: DragState = { ... }` and mutate its fields directly in flush()/onPointerDown/setModifiers — the callbacks already receive it by reference and read it synchronously, so no reactivity is needed. Keep only `isDragging` as the shallowRef. Return the plain object (e.g. as `readonly(state)` cast or just the object) instead of reactive(state). usePointerDrag is internal-only (not in any public barrel) and every call site was verified to consume state imperatively, so this is non-breaking. This eliminates ~13 reactive triggers + all nested-Proxy set-trap overhead per frame.
### `internal/scale` — 2 finding(s)
- **⚪ low** · v8-jit · H · · candidate — `useScale.ts:104` (scale / invert)
- **Issue:** scale() and invert() each do `const [er0, er1] = reverse.value ? [r1, r0] : [r0, r1];`, allocating a fresh 2-element array literal on every call purely to swap two scalars. These projectors are documented hot-path closures and are invoked inside wheel/keyboard/pointer handlers in callers (e.g. canvas/time-ruler TimeRulerRoot.vue invert() in onWheel and arrow-key zoom), so the throwaway tuple is garbage generated per gesture event.
- **Fix:** Branch on scalars without allocating: `let er0 = r0, er1 = r1; if (reverse.value) { er0 = r1; er1 = r0; }` then use er0/er1 directly. Same for invert(). Eliminates the per-call array allocation while keeping identical behavior.
- **⚪ low** · v8-jit · H · · candidate — `ticks.ts:220` (frameLadder)
- **Issue:** frameLadder(fps) is rebuilt on every timecodeTicks() call, and timecodeTicks() runs inside the reactive `ticks` computed, which recomputes on every domain/range change (i.e. per frame during a pan or zoom gesture). The function allocates several arrays and uses `ladder.includes(v)` inside a loop (O(n^2) dedup) plus a sort, all to produce a ladder that depends solely on fps. fps changes far less often than domain/range, so this work is redundantly repeated per recompute.
- **Fix:** Memoize the ladder by fps (e.g. a small module-level Map<number, number[]> keyed by Math.round(fps), or hoist computation so it is keyed off fps in useScale). Within frameLadder, drop the includes-based dedup in favor of building into a Set then spreading once, or just allow the (tiny, fixed) base array without per-element includes. n is small so impact is minor, but it removes a per-pan/zoom-frame allocation + sort.
### `internal/snapping` — 4 finding(s), 1 high
- **🔴 high** · reactivity · H · ✅ verified — `useSnapping.ts:79` (activeTargetsRef)
- **Issue:** activeTargets is a deep ref<SnapTarget[]>. On every snapped pointer-move frame, `activeTargetsRef.value = [r.target]` (line 216) and `= matched` (line 261) force Vue to deep-reactive-wrap the opaque SnapTarget element (px/value/kind/id/strength). SnapTarget is plain data exposed to consumers (read for guide rendering); deep-tracking + readonly() deep-proxy wrapping on the per-frame hot path is pure overhead, and consumers reading target.px/.value pay proxy-get cost.
- **Fix:** Use shallowRef<SnapTarget[]>([]) instead of ref(). Identity-replace assignment (= [r.target]) still triggers correctly; the SnapTarget objects stay raw. Drop the readonly() deep wrap or keep it over the shallowRef.
- **🟡 medium** · reactivity · H · ✅ verified — `useSnapping.ts:242` (snap2d (xTargets/yTargets) + snap1d axis filter)
- **Issue:** snap2d runs TWO full allTargets.filter(t=>t.axis===...) passes every pointer-move frame (lines 242-243), and snap1d in 'x'/'y' mode filters the whole array every call (lines 184-186). Targets routinely number in the hundreds (full visible frame grid + clip edges*3 + markers + playhead, per TimelineRoot snapTargets). This is an O(n) allocation per frame that is redundant: findNearestTarget already iterates every target and has `t` in hand, so an off-axis target can be skipped inline.
- **Fix:** Add an optional `axis` filter param to findNearestTarget (skip `if (t.axis !== wantAxis) continue;` inline) and pass the unfiltered allTargets for 'x'/'y'/2d modes, removing the .filter() allocations entirely. For 2d call findNearestTarget twice with axis 'x' then 'y' over the same array.
- **🟡 medium** · v8-jit · H · · candidate — `useSnapping.ts:257` (snap2d matched array)
- **Issue:** `const matched: SnapTarget[] = []` is allocated on every non-bypass snap2d frame (even when neither axis snaps), then assigned to activeTargets. Per-pointer-move garbage.
- **Fix:** Maintain a reused module/closure scratch array (matched.length=0; conditional push) or assign activeTargets directly from rx/ry targets without the intermediate; combined with the shallowRef change this removes the per-frame array churn on the no-snap case.
- **🟡 medium** · v8-jit · H · · candidate — `useSnapping.ts:134` (resolveAxis locked-id scans)
- **Issue:** When a lock is held, resolveAxis linearly scans the full targets array to compute prevDistPx (lines 134-143) and then, if the lock is kept, scans the SAME array again for the same `locked` id (lines 149-156). That is two extra O(n) passes per axis per frame searching for one id, on top of findNearestTarget's own full pass. On a large grid a held snap is 3-4 O(n) walks per pointer-move.
- **Fix:** Do a single scan that captures both the locked target reference and its prevDistPx in one loop, reuse that captured target for the keep-lock branch instead of re-scanning. Optionally have findNearestTarget surface the locked target's index in the same pass it already runs.
### `internal/spline` — 3 finding(s)
- **🟡 medium** · v8-jit · H · ✅ verified — `path.ts:17` (toLUT)
- **Issue:** `const lut: number[] = Array.from({ length: count })` pre-fills the array with `undefined` (a tagged value), so the array starts in the tagged PACKED_ELEMENTS elements-kind. The subsequent `lut[i] = fn(x)` writes plain numbers into that tagged array, so V8 never transitions it to the fast unboxed PACKED_DOUBLE_ELEMENTS representation, and the `Array.from` pre-fill is a wasted pass. This LUT is the heavy product of the module: callers build 256+ entry numeric LUTs (levels/utils.ts, CurveEditorRoot.toLUT) that are then read per-pixel, so the slower elements-kind and extra allocation pass are paid on a hot/heavy path.
- **Fix:** Build it packed-from-empty: `const lut: number[] = []; if (count === 1) { lut.push(fn(x0)); return lut; } for (let i = 0; i < count; i++) { const x = x0 + ((x1 - x0) * i) / (count - 1); lut.push(fn(x)); }`. This keeps the array in PACKED_SMI→PACKED_DOUBLE_ELEMENTS and drops the undefined pre-fill.
- **🟡 medium** · v8-jit · H · · candidate — `sample.ts:9` (sampleToPolyline / sampleFnToPolyline)
- **Issue:** Both samplers use `const out: Point[] = Array.from({ length: count + 1 })` (line 9 and line 20) then `out[i] = ...`. The arrays hold Point objects so elements-kind stays tagged either way (smaller impact than the numeric LUT), but `Array.from({length})` still allocates and fills count+1 `undefined` slots before the loop overwrites them — a redundant pass. sampleFnToPolyline runs inside CurveEditorCurve's `pathD` computed (SAMPLES=256) which re-evaluates on every anchor drag, so this allocation churn is on a per-pointer-move path.
- **Fix:** Use `const out: Point[] = []; for (let i = 0; i <= count; i++) out.push(curve(i / count));` (and the analogous `out.push({ x, y: fn(x) })` in sampleFnToPolyline at line 20). Avoids the undefined pre-fill and keeps the array packed.
- **⚪ low** · v8-jit · · candidate — `interpolation.ts:79` (monotoneCubic)
- **Issue:** `dx` (line 79), `slope` (line 80) and `m` (line 88) are numeric arrays created with `Array.from({ length: ... })`, which pre-fills with `undefined` and starts them in tagged PACKED_ELEMENTS; the later numeric writes (`m[i]=0`, conditional `m[i]=(s0+s1)/2`) never reach the unboxed PACKED_DOUBLE_ELEMENTS kind, and `m[i]` is even assigned out of index order (m[0], m[n-1], then the middle loop). This is the interpolant BUILD step (setup-once per curve change, not per-frame), so the cost is low, but the fast pattern is still preferable since these arrays are read in the returned hot evaluator's binary search.
- **Fix:** Build numeric arrays packed: e.g. `const dx: number[] = []; const slope: number[] = []; for (let i = 0; i < n - 1; i++){ const h = xs[i+1]!-xs[i]!; dx.push(h); slope.push(h===0?0:(ys[i+1]!-ys[i]!)/h); }`. For `m`, fill it in ascending index order with push (compute m[0], then the middle, then m[n-1]) or pre-size via a single ascending push loop, avoiding the undefined-filled Array.from and the out-of-order index writes.
### `internal/utils` — 1 finding(s)
- **🟡 medium** · reactivity · H · · candidate — `useGraceArea.ts:26` (pointerGraceArea)
- **Issue:** pointerGraceArea is a deep ref<Polygon | null> holding the safe-area polygon (an array of plain {x,y} point objects, up to ~6 hull vertices). It is written once per pointer transit but read on every pointermove inside onMove (per-pointer-move hot path) and passed to isPointInPolygon(point, pointerGraceArea.value), which iterates the vertices reading polygon[i].x / polygon[i].y. Because the ref is deep-reactive, every array-index access and every nested .x/.y access goes through Vue's reactive Proxy get trap (unwrap + identity bookkeeping) on each pointermove. This data is purely internal to the composable — it is never rendered, watched, or exposed (only the boolean isPointerInTransit is shared via context to TooltipProvider/HoverCardRoot), so deep reactivity buys nothing and only adds per-vertex proxy overhead during pointer drag.
- **Fix:** Import shallowRef and declare `const pointerGraceArea = shallowRef<Polygon | null>(null)`. Assignment (pointerGraceArea.value = getHull(...)) still triggers reactivity by reference; the polygon contents are immutable after creation, so shallowRef is fully equivalent for the boolean-driving logic but the geometry loop now reads plain non-proxied objects. Leave isPointerInTransit as a plain ref (boolean, correct as-is).
### `menus/command` — 2 finding(s)
- **🟡 medium** · dom · H · ✅ verified — `CommandRoot.vue:159` (getSelectableItems)
- **Issue:** getSelectableItems() runs root.querySelectorAll([data-primitives-command-item]) over the entire list, then Array.from, then builds an index Map of every DOM item, then allocates a candidates array of {value,score,idx} objects and sorts — all O(n) in the rendered item count. It is invoked on EVERY ArrowUp/Down/Home/End/PageUp/PageDown keypress (via moveBy/moveTo in CommandInput) and again on EVERY search keystroke via the flush:'post' watch at line 243. For a ~1000-item palette each arrow press and each typed character triggers a full DOM walk + allocations, making keyboard nav and typing scale linearly with list size.
- **Fix:** Derive DOM order once and cache it instead of re-querying per call. Options: (1) maintain a DOM-order index in the existing MutationObserver in CommandList (which already fires on childList changes) and store it on the context, so getSelectableItems only reads filteredItems + that cached order map; or (2) memoize the querySelectorAll result keyed by a registry/DOM version that only bumps on register/unregister/reorder, not on every keypress. Then getSelectableItems becomes an O(filtered) sort with no DOM access. Pre-size and reuse arrays where possible.
- **🟡 medium** · watchers · · candidate — `CommandRoot.vue:235` (watch auto-highlight)
- **Issue:** watch([() => search.value, filteredItems, allItems], ...) lists three sources, but filteredItems is a computed already derived from search.value and allItems.value, so it re-evaluates and changes identity whenever either underlying source changes. The extra () => search.value getter and the allItems shallowRef are redundant tracking, and the array-of-sources form allocates a new [...] tuple on each evaluation. The body also calls getSelectableItems() (the expensive DOM-walk in finding 1) on every fire.
- **Fix:** Watch only filteredItems (e.g. watch(filteredItems, () => { ... }, { flush: 'post' })). It already changes whenever search or item registrations change, removing the redundant sources and the per-trigger array allocation, and reduces the number of times the body (and getSelectableItems) runs.
### `menus/context-menu` — 3 finding(s)
- **🟡 medium** · dom · H · · candidate — `ContextMenuContent.vue:75` (update-position-strategy="always")
- **Issue:** The content hardcodes update-position-strategy="always", which makes floating-ui autoUpdate run with animationFrame:true (PopperContent.vue:203). That re-computes the floating position and calls the reference's getBoundingClientRect() on EVERY animation frame for as long as the menu is open. The context-menu anchor is a fixed virtual point built from event.clientX/clientY (viewport-relative, does not shift on scroll), so the rect is constant and the per-frame polling produces identical results frame after frame — continuous rAF + layout work with zero positioning benefit. The 'optimized' strategy still wires scroll/resize/ancestor listeners (which is all a static cursor anchor could ever need), so switching to it removes the rAF loop entirely while preserving correctness.
- **Fix:** Pass update-position-strategy="optimized" (the PopperContent default) instead of "always" here. The anchor is a non-moving virtual point, so animationFrame polling is unnecessary; scroll/resize listeners from the optimized strategy fully cover repositioning. If a future use-case anchors to a moving DOM element, gate 'always' on that case rather than hardcoding it for the cursor-point default.
- **⚪ low** · v8-jit · H · · candidate — `ContextMenuTrigger.vue:30` (virtualEl.getBoundingClientRect)
- **Issue:** virtualEl.getBoundingClientRect() returns a brand-new object literal (8 fields + toJSON) on every invocation. Because the content uses the 'always' position strategy, floating-ui calls this getter on every animation frame while the menu is open, so this allocates a short-lived rect object per frame. Minor on its own, but it compounds with the 'always' finding; if that strategy is fixed to 'optimized' the allocation only happens on scroll/resize and becomes negligible.
- **Fix:** Primary fix is switching the content to 'optimized' so this getter is no longer called per-frame. If per-frame access is ever required, hoist a single scratch rect object captured in the closure and mutate its x/left/right/top/bottom fields from point.value in place (same hidden class each call) instead of returning a fresh literal.
- **⚪ low** · template · · candidate — `ContextMenuTrigger.vue:98` (:style on Primitive)
- **Issue:** The :style bound to the Primitive is a constant inline object literal ({ WebkitTouchCallout: 'none', pointerEvents: 'auto' }) reallocated on every render of the trigger. :style is value-diffed so it never causes an unnecessary DOM patch, and the trigger is not in a hot loop, so cost is just a tiny per-render allocation. Reported only as a trivial cleanup, not a real hot path.
- **Fix:** Hoist the style object to a module-scope const (it is fully static) and bind :style="triggerStyle", eliminating the per-render allocation. Low priority.
### `menus/menu` — 2 finding(s)
- **🟡 medium** · dom · H · · candidate — `MenuItemImpl.vue:95` (Primitive :ref callback)
- **Issue:** The element ref is bound with an inline arrow `(el: unknown) => { itemRef = el as HTMLElement | null }`. A ref function with a new identity each render makes Vue tear down the old ref (call it with null) and set up the new one on every patch of the item. MenuItemImpl is the per-item leaf reused for every MenuItem/CheckboxItem/RadioItem/SubTrigger, so this churn runs once per item on every menu re-render — the hottest list path in the component.
- **Fix:** Hoist a stable ref setter in setup: `const setItemRef = (el: unknown) => { itemRef.value = el as HTMLElement | null }` and bind `:ref="setItemRef"`. Stable identity lets Vue skip re-binding the ref each patch.
- **⚪ low** · template · · candidate — `MenuContentImpl.vue:218` (PopperContent :style)
- **Issue:** `:style` is bound to a brand-new object literal of three fixed CSS-variable mappings every render. The object is fully static (no reactive interpolation), yet it is reallocated on each content re-render and value-diffed by the patcher. It also sits on a component vnode (PopperContent) that already takes `v-bind="popperProps"`, so the style allocation is pure waste.
- **Fix:** Hoist the object to a module-scope constant (e.g. `const CONTENT_STYLE = { '--primitives-menu-content-transform-origin': 'var(--popper-transform-origin)', ... }` outside setup) and bind `:style="CONTENT_STYLE"`. Stable identity avoids the per-render allocation and lets the diff short-circuit on reference equality.
### `menus/menubar` — 2 finding(s)
- **🟡 medium** · reactivity · · candidate — `MenubarTrigger.vue:42` (isCurrentTabStop)
- **Issue:** The per-trigger `isCurrentTabStop` computed calls `rootCtx.getTriggers()` (-> collection getItems), which runs `collectionRef.querySelectorAll([data-collection-item])` + `Array.from(itemMap.values())` + an O(n^2) sort (`orderedNodes.indexOf` inside the comparator). This computed depends on `rootCtx.currentTabStopId` and on `itemMap`, so a single tab-stop change (focus move / menu open / toggle) invalidates the computed on EVERY trigger simultaneously, producing N querySelectorAll calls + N O(n^2) sorts per interaction (~O(N^2) DOM work). It also forces a fresh DOM query on each `:tabindex` recompute instead of reusing a memoized DOM-ordered list. Bounded by trigger count so it is fine for a typical 5-8 menu bar, but it scales poorly and re-queries the DOM for a value (the ordered enabled-trigger list) that does not change between item mount/unmount.
- **Fix:** Lift the ordered/enabled trigger list to a single memoized source in MenubarRoot (e.g. a `computed` over the collection that returns the DOM-ordered enabled values, recomputed only when itemMap/collectionRef change) and expose `firstEnabledValue` + an `isTabStop(value)` helper, so each trigger's tabindex computed only reads two refs (currentTabStopId + the memoized first-value) instead of running querySelectorAll+sort. Also fix the collection's getItems sort to precompute a `Map<el,index>` from orderedNodes once (O(n)) instead of `indexOf` per comparison (O(n^2)).
- **⚪ low** · watchers · · candidate — `MenubarTrigger.vue:147` (watch(() => rootCtx.searchRef.value))
- **Issue:** Every MenubarTrigger registers a watcher on the shared `rootCtx.searchRef`. Each typeahead keystroke (which appends to searchRef in MenubarRoot.onKeyDownCapture) fires this watcher on ALL N triggers at once. The `document.activeElement !== currentElement.value` guard cheaply short-circuits non-focused triggers, but the focused trigger still rebuilds the candidate list via `collection.getItems(true).map(i => i.ref).filter(...)` (another querySelectorAll + sort) on every keystroke of the buffered search.
- **Fix:** Move the typeahead match logic to the MenubarRoot keydown.capture handler (which already owns searchRef) so only one watcher/lookup runs per keystroke instead of one per trigger, and build the candidate trigger list from a single memoized DOM-ordered collection snapshot rather than re-querying per keystroke.
### `menus/navigation-menu` — 4 finding(s)
- **🟡 medium** · reactivity · · candidate — `NavigationMenuRoot.vue:153` (watchEffect (activeTrigger resolver))
- **Issue:** On every modelValue change this watchEffect calls getItems() — which internally does querySelectorAll + Array.from + an O(n^2) sort (indexOf per comparison) + filter — then allocates another array via .map(i => i.ref) and runs .find(... id.includes('-trigger-'+value)). The intermediate .map array is pure churn, and the substring .includes scan is both slower than an exact id match and prone to false positives (value 'a' matches a '-trigger-ab' id).
- **Fix:** Resolve without the extra allocation and with an exact match: iterate getItems(true) directly and compare item.ref.id === makeTriggerId(toValue(baseId), modelValue.value) (import makeTriggerId from ./utils), assigning the first hit. Drop the .map() entirely.
- **🟡 medium** · reactivity · · candidate — `NavigationMenuSub.vue:69` (watchEffect (activeTrigger resolver))
- **Issue:** Same pattern as Root: getItems() (querySelectorAll + O(n^2) sort) followed by a throwaway .map(i => i.ref) and a .includes substring scan to locate the active trigger, re-run on every submenu modelValue change. Extra array allocation plus the substring-collision risk on the id match.
- **Fix:** Mirror the Root fix: iterate getItems(true) and match item.ref.id === makeTriggerId(toValue(parentContext.baseId)/baseId, modelValue.value) exactly, removing the .map() allocation.
- **🟡 medium** · dom · · candidate — `NavigationMenuContentImpl.vue:40` (motionAttribute computed)
- **Issue:** This computed runs a querySelectorAll over the whole root nav, then Array.from + .map(split/pop) + .filter, then values.indexOf(...) up to four times. It re-evaluates whenever modelValue/previousValue/dir change (every open/close/switch), allocating two arrays and doing repeated linear scans each time, all to produce a single data-motion string.
- **Fix:** Compute the ordered trigger values once into a const and replace the repeated values.indexOf(...) calls with a single Map<string,number> lookup built in one pass; or memoize the ordered-values array on the trigger collection instead of re-querying the DOM. At minimum hoist the repeated indexOf results into locals (index/prevIndex/curIndex are recomputed redundantly).
- **⚪ low** · watchers · · candidate — `NavigationMenuIndicator.vue:53` (watch(() => [activeTrigger, indicatorTrack, isHorizontal], recompute))
- **Issue:** The watch source is a getter returning a new array literal on every evaluation, so a fresh array is allocated each tracked run and Vue can never short-circuit on identity. Functionally fine but needless allocation/churn on every dependency change.
- **Fix:** Use the multi-source array-of-getters form: watch([() => menuContext.activeTrigger.value, () => menuContext.indicatorTrack.value, isHorizontal], recompute, { immediate: true }). This avoids the per-run array literal and gives per-source change detection.
### `menus/toolbar` — 3 finding(s)
- **🟡 medium** · reactivity · H · · candidate — `ToolbarRoot.vue:154` (onItemKeyDown/enabledItems)
- **Issue:** enabledItems() calls getItems() (querySelectorAll over the toolbar subtree + Array.from(itemMap.values()) + sort whose comparator calls orderedNodes.indexOf twice per comparison => O(n^2 log n)) on every arrow/Home/End/PageUp/PageDown keydown (line 154) and again indirectly via focusIndex (line 116). For a toolbar with many items, each keypress re-queries the DOM and re-sorts the whole list. Arrow-key auto-repeat (held key) makes this fire rapidly.
- **Fix:** Memoize the sorted enabled list for the duration of a single navigation: compute the list once per keydown and reuse it (onItemKeyDown already calls enabledItems() once at line 154 — pass that array into focusIndex instead of having focusIndex re-call enabledItems() at line 116). Better, cache the getItems() result on the collection keyed by an itemMap version/triggerRef counter so repeated reads between mutations are O(1).
- **🟡 medium** · reactivity · · candidate — `ToolbarRoot.vue:201` (watchSyncEffect)
- **Issue:** The watchSyncEffect reads items.value (a computed wrapping getItems(), which does querySelectorAll + an O(n^2 log n) sort) on every collection mutation, only to clamp activeIndex when it exceeds the list length. watchSyncEffect re-runs synchronously each time the collection changes, forcing the full sort eagerly even though the body only needs the count and only acts on the rare out-of-range case. The collection already exposes a cheap itemMapSize ComputedRef (Map.size, no sort).
- **Fix:** Provide/expose itemMapSize from useCollectionProvider into the toolbar and guard on it: watchSyncEffect(() => { const n = itemMapSize.value; if (n === 0) return; if (activeIndex.value >= n) activeIndex.value = 0; }). This avoids invoking the sorted getItems() path on every register/unregister. If the enabled-only semantics matter, still prefer gating the heavy recompute behind the cheap size check.
- **🟡 medium** · reactivity · · candidate — `ToolbarButton.vue:28` (index)
- **Issue:** Each ToolbarButton (and ToolbarLink line 25) defines index = computed(() => ctx.items.value.indexOf(currentElement.value)). ctx.items is the shared computed that runs the O(n^2 log n) getItems() sort. Every item subscribes to it, so a single collection mutation invalidates ctx.items once but then fans out N indexOf re-runs (one per mounted item), each over the freshly re-sorted N-element array. On a large toolbar this is N*O(N) work per mount/unmount on top of the sort itself.
- **Fix:** Avoid per-item indexOf against the shared sorted array. Since activeIndex is already tracked on the root and onItemFocus resolves the index there, drive isActive by comparing the item's own id/element to a single currentTabStop the root publishes (e.g. ctx.currentTabStopId / ctx.activeElement) rather than each item recomputing its own indexOf into ctx.items. That replaces N O(N) recomputations with N O(1) equality checks and lets ctx.items invalidation stop cascading into every leaf.
### `navigation/pagination` — 2 finding(s)
- **⚪ low** · gatekeeping · H · · candidate — `PaginationListItem.vue:56` (<Primitive v-bind="attrs">)
- **Issue:** PaginationListItem is the per-item component rendered once per visible page in the list v-for, making it the hottest element in this module. It spreads a computed `attrs` object via v-bind="attrs", which compiles to FULL_PROPS and disables Vue's patchFlag fast-path diffing on the most-repeated component. The attrs object is also re-created (new object identity) whenever ctx.currentPage or ctx.disabled changes, which on a page change forces a props patch on every rendered page button. Impact is bounded (N is small, ~siblingCount*2+5 buttons) and the attrs computed only re-evaluates on ctx change, and this matches the deliberate library-wide Primitive pattern, so churn is minor.
- **Fix:** If ever profiled as hot, bind the handful of attributes individually (:data-type, :aria-label, :aria-current, :data-selected, :disabled, :type) so the compiler can emit per-prop patchFlags instead of FULL_PROPS; otherwise leave as-is to stay consistent with the primitives convention.
- **⚪ low** · list · · candidate — `utils.ts:22` (getRange / page())
- **Issue:** getRange() rebuilds the entire items array with freshly-allocated {type,value} objects on every recompute (the page() helper allocates a new object each call; only the shared ELLIPSIS constant is reused). Because PaginationList's computed returns these new object identities on each page change, every list item gets a new identity even for page links whose value/position is unchanged, causing the consumer's keyed v-for to re-diff the whole list rather than just the changed buttons. This is off the hottest path (recomputes only on page/sibling/total change, not per-frame) and N is small, so cost is low.
- **Fix:** Acceptable as-is given the tiny bounded N. If desired, memoize page-number item objects in a small module-level Map keyed by value so repeated pages return stable identities across recomputes, letting the keyed v-for skip unchanged items.
### `navigation/tree` — 3 finding(s)
- **🟡 medium** · reactivity · H · ✅ verified — `TreeRoot.vue:269` (isIndeterminate)
- **Issue:** isIndeterminate(item) is invoked from each visible parent TreeItem's `isIndeterminate` computed (TreeItem.vue:59), and it re-runs `collectDescendantKeys(item.value, ...)` (TreeRoot.vue:272) — a full iterative walk of that item's entire subtree — every time `selectedSet` changes. Because every parent item's computed depends on selectedSet.value, a single selection mutation re-walks every parent's subtree: O(visibleParents x subtreeSize) per selection change (only when bubbleSelect/propagateSelect is enabled, which is exactly the mode where many parents are indeterminate). The same collectDescendantKeys walk is also repeated inside applyBubbleSelect/select for the cascade path, so a multi-select click pays the walk multiple times over.
- **Fix:** Build one memoized structure keyed off the `items` prop identity (reuse/extend the existing flatByKey computed): a Map<string, string[]> of key->descendantKeys, or better a Map<string, number> of descendant counts. Then isIndeterminate becomes: count how many descendants are in selectedSet (a single pass or a precomputed per-parent selectedCount), and return `selected>0 && selected<descendantCount`. This removes the per-item subtree re-walk and makes the whole tree's indeterminate refresh O(totalNodes) once per selection instead of O(parents x subtree).
- **🟡 medium** · dom · H · · candidate — `TreeRoot.vue:349` (onTypeahead)
- **Issue:** onTypeahead runs on every printable keystroke and calls `enabled.map(textOf)` (TreeRoot.vue:353). textOf -> itemOfElement (TreeRoot.vue:326) does a linear scan over flatItems for each element to resolve its FlatItem, so building `values` is O(n^2) in the number of enabled items, plus n `el.textContent` reads (layout-touching) when getLabel is not supplied. getNextMatch then does another wrapArray allocation + filter + find over that values array.
- **Fix:** Resolve text by data-key against a precomputed Map<key, label> (reuse flatByKey / a key->label map) instead of itemOfElement's linear flatItems scan, so textOf is O(1). When getLabel is provided, prefer it directly off the key without reading the DOM. Optionally cache the enabled elements + their keys for the duration of the type-ahead buffer window rather than rebuilding per keystroke.
- **⚪ low** · dom · H · · candidate — `TreeRoot.vue:380` (onItemKeyDown)
- **Issue:** onItemKeyDown calls collectEnabled() (TreeRoot.vue:380) which reads treeItemElements.value -> getItems(true). getItems (utilities/collection/useCollection.ts:85-100) runs querySelectorAll plus Array.from(itemMap.values()).sort() whose comparator is `orderedNodes.indexOf(a.ref) - orderedNodes.indexOf(b.ref)` — indexOf inside a comparator is O(n^2) in item count. extendRange (TreeRoot.vue:366) calls collectEnabled() a second time within the same Shift+Arrow keydown, doubling that cost. This is keyboard-rate, not frame-rate, but scales poorly on large trees.
- **Fix:** Compute `const enabled = collectEnabled()` once at the top of onItemKeyDown and pass it (and its precomputed data-key array) into extendRange instead of recollecting. Longer term, fix getItems' sort in the shared collection module to precompute a Map<element, domIndex> from orderedNodes (one pass) and sort by map lookups, turning the O(n^2) sort into O(n log n).
### `overlays/alert-dialog` — 1 finding(s)
- **⚪ low** · gatekeeping · · candidate — `AlertDialogContent.vue:49` (DialogContent v-bind="props")
- **Issue:** Binding the whole reactive props proxy via v-bind="props" on the DialogContent component vnode flips it to FULL_PROPS, so Vue diffs every prop on each update instead of using compiler patch flags. The realistic prop surface is small (effectively as and forceMount, since role is overridden literally and trapFocus/disableOutsidePointerEvents are omitted from the type) and Content only re-renders when dialog open-state changes (a cold path), so the impact is minor — but the FULL_PROPS flag is still set unnecessarily.
- **Fix:** Bind the two real passthrough props explicitly instead of the whole proxy: replace v-bind="props" with :as="props.as" :force-mount="props.forceMount" (keeping role="alertdialog" and data-alert-dialog-content as literals). This keeps the vnode on the patchFlag fast path. Optional only — negligible on this cold update path.
### `overlays/drawer` — 5 finding(s), 2 leak
- **🟡 medium** · memory-leak · L · ✅ verified — `usePositionFixed.ts:108` (usePositionFixed watch([isOpen, hasBeenOpened]))
- **Issue:** On Safari the open drawer pins document.body to position:fixed via setPositionFixed() and records the prior style in the module-level previousBodyPosition. Restoration only happens in restorePositionSetting(), which runs when isOpen flips to false. If the drawer component is unmounted while still open (e.g. a parent v-if removes the tree), the setup-time watch auto-stops without ever restoring, so the body stays position:fixed (page scroll broken) and the module-level previousBodyPosition retains the stale snapshot for the next drawer. There is no onScopeDispose/onUnmounted in the entire package.
- **Fix:** Add onScopeDispose(() => restorePositionSetting()) (or tryOnScopeDispose) inside usePositionFixed so an unmount-while-open still restores the body and clears previousBodyPosition.
- **🟡 medium** · dom · H · · candidate — `controls.ts:391` (onDrag (no-snap-points transform write))
- **Issue:** The per-pointer-move transform write setStyle(drawerRef.value, { transform }) uses setStyle's default caching path (ignoreCache=false), so every frame allocates a fresh style object literal, builds a 'previous' object, runs Object.entries, and writes the originalStyles WeakMap. The overlay-opacity and scale writes in the same function (lines 367, 375) correctly pass ignoreCache=true. This transform is never restored via resetStyle (resetDrawer sets transform:'translate3d(0,0,0)' explicitly and resetStyle is only ever called on document.body), so the cache write is pure per-frame waste.
- **Fix:** Pass ignoreCache=true to this transform setStyle call (and the rubber-band one at line 354) to skip the per-frame previous-object allocation, Object.entries, and WeakMap write.
- **🟡 medium** · dom · H · · candidate — `controls.ts:371` (onDrag (scale-background branch))
- **Issue:** Inside the per-frame scale-background branch, getScale() is invoked twice in one expression (getScale() + percentageDragged * (1 - getScale())), and getScale() itself reads window.innerWidth twice. That is four window.innerWidth reads per pointer-move, all returning the identical value, each a potential layout read.
- **Fix:** Hoist once: const scale = getScale(); const scaleValue = Math.min(scale + percentageDragged * (1 - scale), 1);
- **🟡 medium** · dom · H · · candidate — `useSnapPoints.ts:235` (onDrag (snap-points drag transform write))
- **Issue:** The snap-point drag path writes the drawer transform every pointer-move with setStyle(drawerRef.value, { transform }) on the default caching path, so each frame allocates a 'previous' object, runs Object.entries, and overwrites the originalStyles WeakMap entry. This transform is never restored via resetStyle, so the cache bookkeeping is per-frame waste.
- **Fix:** Pass ignoreCache=true for this per-frame transform write (the settle/snap writes elsewhere can keep caching if desired).
- **⚪ low** · memory-leak · L · ✅ verified — `controls.ts:565` (onNestedOpenChange (nestedOpenChangeTimer))
- **Issue:** The 500ms nestedOpenChangeTimer is cleared only on the next onNestedOpenChange call (line 556), never on unmount/scope-dispose. If a nested drawer closes and the tree unmounts within 500ms, the pending callback runs and dereferences drawerRef.value! (DrawerContent's currentElement watch clears drawerRef to undefined on unmount), so getTranslate(drawerRef.value!) can throw on a detached/undefined element.
- **Fix:** Clear the timer on scope dispose (onScopeDispose(() => { if (nestedOpenChangeTimer.value) clearTimeout(nestedOpenChangeTimer.value); })) and/or guard the callback with `if (!drawerRef.value) return;`.
### `overlays/hover-card` — 3 finding(s)
- **⚪ low** · v8-jit · · candidate — `HoverCardTrigger.vue:32` (onPointerLeave)
- **Issue:** The if/else collapses to the same action: `if (!isPointerInTransit && !open) onClose(); else if (!isPointerInTransit) onClose();` — both branches call ctx.onClose() and are both guarded solely by !isPointerInTransit, so the !open check and the branch split are dead code. Pure redundancy that obscures intent; no behavioral effect.
- **Fix:** Replace the whole if/else with `if (!ctx.isPointerInTransit.value) ctx.onClose();`.
- **⚪ low** · gatekeeping · · candidate — `HoverCardTrigger.vue:46` (template @pointerenter/@pointerleave)
- **Issue:** Template binds @pointerenter="excludeTouch(ctx.onOpen)($event)" and @pointerleave="excludeTouch(onPointerLeave)($event)". excludeTouch returns a NEW closure on every invocation, which is then immediately called and discarded — one throwaway function allocation per pointerenter/pointerleave. Same pattern in HoverCardContentImpl.vue onContentPointerEnter (excludeTouch(() => ctx.onOpen())(event)). pointerenter/leave are low-frequency so cost is small, but it is needless churn.
- **Fix:** Create stable handlers once in setup: `const onTriggerEnter = excludeTouch(() => ctx.onOpen()); const onTriggerLeave = excludeTouch(onPointerLeave);` and bind @pointerenter="onTriggerEnter" @pointerleave="onTriggerLeave". Likewise pre-wrap onContentPointerEnter in ContentImpl.
- **⚪ low** · template · · candidate — `HoverCardContentImpl.vue:183` (PopperContent :style)
- **Issue:** A fresh :style object literal (7 keys, 5 of them constant var() mappings) is allocated on every render of the PopperContent component vnode. Only two keys (user-select / -webkit-user-select) actually depend on containSelection; the five CSS-custom-property mappings are static. Each Popper position update re-renders this and re-allocates the whole object. :style is value-diffed so patch cost is low, but the allocation is avoidable.
- **Fix:** Hoist the five static var() mappings into a module-scope const object and merge with a small computed that only carries the containSelection-dependent user-select keys (or move the static custom props to a CSS class), so render diffs a small object instead of rebuilding seven entries each time.
### `overlays/popover` — 1 finding(s)
- **🟡 medium** · template · H · · candidate — `PopoverContentImpl.vue:79` (PopperContent :style)
- **Issue:** The :style on PopperContent is a fresh object literal of five constant CSS-variable mappings ('--popover-content-transform-origin': 'var(--popper-transform-origin)', etc.) allocated on every render of PopoverContentImpl. PopperContent drives positioning via @floating-ui/vue useFloating (floatingStyles), and with updatePositionStrategy:'always' it re-positions on every animation frame; PopoverContentImpl re-renders on these reactive position updates while the popover is open, so this static object is re-allocated and re-walked by the style patcher each time even though its values never change. Because the object identity changes every render Vue cannot short-circuit the style diff.
- **Fix:** Hoist the object to a module-level const (const POPOVER_CONTENT_STYLE = { '--popover-content-transform-origin': 'var(--popper-transform-origin)', '--popover-content-available-width': 'var(--popper-available-width)', '--popover-content-available-height': 'var(--popper-available-height)', '--popover-trigger-width': 'var(--popper-anchor-width)', '--popover-trigger-height': 'var(--popper-anchor-height)' }) and bind :style="POPOVER_CONTENT_STYLE". Stable reference lets Vue skip the diff and removes the per-render allocation.
### `overlays/popper` — 2 finding(s)
- **🟡 medium** · template · H · · candidate — `PopperArrow.vue:78` (template :style object on <span>)
- **Issue:** The wrapper <span> :style is a fresh object literal allocated on every PopperArrow render (which, per the finding above, is every scroll/resize/layoutShift frame). It reads 7 reactive sources (arrowX, arrowY, placedSide x3 via baseSide/TRANSFORM_ORIGIN/TRANSFORM, shouldHideArrow). :style is value-diffed so it won't over-patch, but it reallocates the object and re-reads every ref each frame, and the computed `baseSide`/`arrowPath` plus two table lookups run inline.
- **Fix:** Move the style into a single `const arrowStyle = computed(() => ({ position:'absolute', left:..., top:..., [baseSide.value]:0, transformOrigin: TRANSFORM_ORIGIN[contentContext.placedSide.value], transform: TRANSFORM[contentContext.placedSide.value], visibility: contentContext.shouldHideArrow.value ? 'hidden' : undefined }))` and bind `:style="arrowStyle"`. Caches the object, memoizes the table lookups, and only recomputes when its tracked deps actually change.
- **⚪ low** · template · H · · candidate — `PopperContent.vue:245` (wrapper <div> :style)
- **Issue:** The floating wrapper <div> :style spreads floatingStyles and computes 6 derived keys (transform fallback, --popper-transform-origin via Array.join, visibility/pointerEvents from middlewareData.hide) inline. This object is reallocated and the Array `[x,y].join(' ')` runs on every position update frame. floatingStyles itself changes per-frame so some realloc is unavoidable, but the join and conditional reads can be memoized.
- **Fix:** Wrap in `const wrapperStyle = computed(() => ({ ...floatingStyles.value, ... }))`. Vue computeds memoize on dep change, avoiding redundant re-evaluation when the template re-renders for unrelated reasons, and isolate the per-frame allocation to a single tracked site.
### `overlays/tooltip` — 1 finding(s)
- **🟡 medium** · reactivity · H · · candidate — `../../internal/utils/useGraceArea.ts:26` (pointerGraceArea)
- **Issue:** pointerGraceArea is a deep ref<Polygon | null>, where Polygon is an array of {x,y} point objects. It is always replaced wholesale (pointerGraceArea.value = getHull(...)), never mutated in place, so deep reactivity is pure overhead: Vue deep-converts every point object into a reactive proxy on creation, and on every document pointermove while a tooltip is in transit, onMove passes the proxied array to isPointInPolygon (geometry.ts:9), which loops reading .x/.y on each vertex through the reactive proxy — N proxy reads per pointer-move on the hover-card/tooltip grace-area hot path.
- **Fix:** Change `const pointerGraceArea = ref<Polygon | null>(null)` to `shallowRef<Polygon | null>(null)`. Whole-array replacement still triggers the useEventListener target getter (so the conditional pointermove bind still works), but the per-vertex reads in isPointInPolygon become plain object reads and the convex-hull points are no longer deep-proxied at creation.
### `selection/combobox` — 2 finding(s)
- **🟡 medium** · watchers · H · · candidate — `ComboboxInput.vue:82` (watch(() => rootCtx.modelValue.value, ..., { deep: true }))
- **Issue:** The model-value watcher uses deep:true. In multiple mode the model is an array of AcceptableValue (which includes Record<string,unknown> objects), so every selection change triggers a full recursive traversal of the entire selected-array AND every nested object inside it. The callback only reads isUserInputted, resetSearchTermOnSelect and searchTerm to decide whether to clear the search term and sync the display value; it never inspects nested model contents. Every commit path in Root (commitValue / onValueChange) reassigns the ref with a fresh value/array reference, so a shallow (reference) watch already fires correctly. deep:true is pure overhead that scales with selection size and value-object depth.
- **Fix:** Drop deep:true (a plain getter watch fires on the reference change every commit path produces). If you want to defend against an external parent mutating the same array in place, use deep:1 to walk only the array's top level instead of recursing into every selected object.
- **⚪ low** · dom · H · · candidate — `ComboboxRoot.vue:276` (getVisibleItemElements)
- **Issue:** getVisibleItemElements does Array.from(root.querySelectorAll('[data-primitives-combobox-item]')) plus a fresh visible[] array on every call. It is invoked on every keystroke (handleInput -> nextTick -> highlightFirstItem), every ArrowUp/ArrowDown (moveHighlight), and Home/End. With v-show keeping all N items in the DOM, this is an O(N) DOM scan plus two array allocations per navigation event. Bounded but on a genuinely hot path for large lists.
- **Fix:** Since items already self-register in allItems (a Map in insertion order) and filterState.items holds the visible id set, derive the ordered visible id list from allItems + filterState instead of re-querying the DOM each time, or cache the querySelectorAll result and invalidate it on filterState/allItems change. Reuse a scratch array rather than allocating visible[] each call.
### `selection/listbox` — 4 finding(s), 1 high
- **🔴 high** · dom · H · ✅ verified — `ListboxRoot.vue:198` (changeHighlight)
- **Issue:** changeHighlight runs getItems(true).find(i => i.ref === el) on every highlight change. changeHighlight is invoked from onPointerMove in ListboxItem.vue (line 70) on each pointer move over items when highlightOnHover is enabled, making this a genuine per-pointer-move hot path. getItems is O(n^2 log n) (sort comparator uses orderedNodes.indexOf, useCollection.ts:94), so hovering a large list re-scans and re-sorts the whole collection on every pointermove just to map an element back to its item for the highlight emit.
- **Fix:** Resolve the item without getItems: read it from ctx's collection itemMap by element key (Map.get(el)) which is O(1), or pass the already-known item/value from the caller. At minimum, gate the emit so the O(n) (not O(n^2)) path is used, and reuse a single getItems() result when the caller already computed one.
- **🟡 medium** · dom · H · ✅ verified — `ListboxRoot.vue:225` (handleMultipleReplace)
- **Issue:** In multiple+replace Shift-navigation this calls getItems()/enabledEls() up to 5 times in one keystroke: enabledEls() (line 225), then the itemOf closure (line 226) calls getItems(true).find for targetEl and again for collection.at(-1)/collection[0] (lines 228-230), then getItems(true).map for allValues (line 234). Each getItems is a full DOM querySelectorAll scan plus an O(n^2 log n) sort (orderedNodes.indexOf inside the comparator in useCollection.ts:94). Compounded with the enabledEls() at line 246 and the final changeHighlight->getItems().find (line 198), one Shift+Arrow does ~6 full O(n^2) rescans of the list.
- **Fix:** Compute const items = getItems(true) once at the top of onKeydownNavigation and pass it (and a derived enabled subset) into handleMultipleReplace and changeHighlight instead of each helper re-fetching. Build a single el->item / value lookup from that one array. This collapses ~6 O(n^2 log n) scans per keystroke down to one.
- **🟡 medium** · dom · H · · candidate — `context.ts:1` (getItems (via useCollectionProvider))
- **Issue:** The collection getItems() that ListboxRoot relies on (defined in utilities/collection/useCollection.ts:85-100) sorts itemMap values with (a,b)=>orderedNodes.indexOf(a.ref)-orderedNodes.indexOf(b.ref); indexOf inside a comparator is O(n) per comparison, making each getItems call O(n^2 log n) on top of a full querySelectorAll. This is the root multiplier behind both ListboxRoot findings above; listed here because the component cannot reach its hot-path budget without it.
- **Fix:** In getItems, build a positional index once: const order = new Map(); orderedNodes.forEach((n,i)=>order.set(n,i)); then sort by order.get(a.ref)-order.get(b.ref) (O(n log n)). This single change makes every listbox keyboard/pointer interaction O(n log n) instead of O(n^2 log n).
- **⚪ low** · watchers · H · · candidate — `ListboxFilter.vue:59` (watchSyncEffect activedescendant)
- **Issue:** watchSyncEffect reads ctx.highlightedElement.value?.id and writes activedescendant synchronously. highlightedElement changes on every navigation/type-ahead/pointer-hover, so this runs synchronously within those already-hot handlers. The write only feeds an aria-activedescendant attribute, which does not need synchronous flush timing.
- **Fix:** Replace watchSyncEffect with a plain computed: const activedescendant = computed(() => ctx.highlightedElement.value?.id). This removes the extra synchronous effect and the separate ref, and lets Vue batch the attribute patch with the normal render.
### `selection/select` — 3 finding(s)
- **🟡 medium** · dom · H · · candidate — `SelectViewport.vue:81` (handleScroll / @scroll)
- **Issue:** The viewport @scroll handler is registered non-passively. handleScroll reads layout (window.innerHeight, contentWrapper style min-height/height) and writes style.height / viewport.scrollTop on every scroll event, but it never calls event.preventDefault(). A non-passive scroll listener forces the browser to wait for the JS handler before committing the scroll, which can introduce jank during the expand-on-scroll behaviour in item-aligned mode.
- **Fix:** Mark the listener passive: change @scroll="handleScroll" to @scroll.passive="handleScroll". The handler does not (and must not) call preventDefault, so passive is safe and lets scrolling stay on the compositor.
- **⚪ low** · dom · H · · candidate — `SelectContentImpl.vue:146` (handleKeyDown / getItems)
- **Issue:** On every navigation keydown, getItems() runs a fresh querySelectorAll + Array.from, then handleKeyDown allocates several intermediate arrays (let candidates = [...items], candidates.slice().reverse(), candidates.slice(currentIndex+1)) and schedules a new setTimeout closure (line 162). This is repeated per keypress during arrow/Home/End navigation. The querySelectorAll re-walks the DOM each time and the chained slices double-allocate.
- **Fix:** Build the candidate list in one pass: start from the items array, reverse-iterate (or build a reversed view) only when ArrowUp/End, and slice once. Avoid the [...items] copy when no reversal is needed. Optionally hoist the focusFirst callback so the per-keypress setTimeout does not allocate a fresh closure capturing candidates each time.
- **⚪ low** · reactivity · · candidate — `SelectValue.vue:33` (selectedLabel computed)
- **Issue:** selectedLabel does Array.from(rootCtx.optionsSet.value) and then .find(...) per resolved value (and per array element in multiple mode). The Array.from allocation plus linear find runs whenever value or optionsSet changes; with a large option list and a multi-select array this is O(items * selected). It is memoized (a computed), so it is not per-frame, but the intermediate array is unnecessary.
- **Fix:** Iterate rootCtx.optionsSet.value directly (for...of) and break on first match instead of Array.from + find, avoiding the intermediate array; for multiple mode build a single value->label lookup once per recompute rather than scanning the option set per selected element.
### `utilities/collection` — 2 finding(s)
- **🟡 medium** · v8-jit · H · ✅ verified — `useCollection.ts:94` (getItems)
- **Issue:** The sort comparator calls orderedNodes.indexOf(a.ref) - orderedNodes.indexOf(b.ref). Array.prototype.indexOf is O(n) and runs twice per comparison, so sorting n items is O(n^2 log n). getItems is the navigation primitive consumed by listbox/menu/roving-focus, called per arrow-keystroke to find next/prev/first/last; for a menu with hundreds of items this is quadratic work per key press. It also calls querySelectorAll on every invocation (re-querying the whole subtree each time) and builds two fresh arrays. The querySelectorAll cost is largely unavoidable for DOM-order correctness, but the comparator cost is not.
- **Fix:** Build an O(n) lookup once: const order = new Map(); orderedNodes.forEach((node, i) => order.set(node, i)); then sort with (a, b) => (order.get(a.ref) ?? -1) - (order.get(b.ref) ?? -1). This turns each comparison into an O(1) Map.get, making the sort O(n log n) instead of O(n^2 log n). Optionally short-circuit when itemMap.value.size <= 1 to skip querySelectorAll + sort entirely.
- **🟡 medium** · reactivity · H · · candidate — `useCollection.ts:159` (CollectionItem.setup/render)
- **Issue:** Both CollectionItem (line 159) and CollectionSlot (line 111) create a brand-new arrow function for the `ref:` prop on every render. Vue compares ref-function identity; when it changes between renders it invokes the previous ref with null and the new ref with the element. So every re-render of an item (e.g. when the parent list re-renders, value changes, or attrs update) triggers two extra ref invocations + resolveItemElement work per item. In a large keyed list each item pays this on every list re-render. The handlers close only over stable module/setup values (currentElement, collectionRef), so they can be hoisted to a single stable reference per component instance.
- **Fix:** Create the ref callback once in setup, before the returned render fn: `const setItemRef = (el: unknown) => { const element = resolveItemElement(el); if (element) currentElement.value = element; };` then pass `ref: setItemRef` in the h(Slot, ...) call. Do the same for CollectionSlot with a hoisted `setCollectionRef`. This keeps the ref identity stable so Vue skips the null/re-set churn on re-render. (Note: the current closures never reset on null, so a detached item's currentElement is only cleared via the watch/unmount cleanup, not the ref callback — behavior is unchanged by hoisting.)
### `utilities/dismissable-layer` — 2 finding(s)
- **⚪ low** · v8-jit · · candidate — `DismissableLayer.vue:97` (emitPreventable)
- **Issue:** On each outside interaction emitPreventable assigns `event.preventDefault = () => {...}` as an own property onto a native PointerEvent/MouseEvent/FocusEvent instance, then restores the original by reassigning. Patching a property onto (and off) a host DOM object transitions its shape and allocates a fresh closure per call; because three different event constructors (Pointer/Mouse/Focus) flow through the same site it is also polymorphic. It fires once per pointer-outside and once per focusin that leaves the layer (called twice each: interactOutside then pointerDownOutside/focusOutside), not per-frame, so cost is negligible — but it is the only shape-churn spot. The patch is load-bearing (focusin is non-cancelable so native defaultPrevented can never flip), so it cannot simply be removed.
- **Fix:** If micro-optimizing later, avoid mutating the native event: pass a plain `{ defaultPrevented: false }` flag object to the emit callbacks and have emitPreventable read that, or hoist the two preventDefault wrapper closures so they are allocated once and toggle a captured boolean. Not worth changing now; documented for completeness.
- **⚪ low** · reactivity · · candidate — `stack.ts:109` (getBranches / pointerEventsFor)
- **Issue:** getBranches() does `Array.from(branches)` and is passed as the `ignore` getter to useClickOutside, so it allocates a fresh array on every document-wide pointerdown (capture+passive listener). pointerEventsFor() and the stack's hasDisablingLayerAbove() (the latter unused by the component) also allocate via `layers.slice(i+1).some(...)`. These run on discrete pointer/stack-mutation events, not per-frame, and the arrays are tiny (layer/branch counts are small), so impact is minimal.
- **Fix:** For getBranches, the click-outside listener only needs membership testing — expose an `isInBranch(node)`-style predicate (already present) to useClickOutside instead of materializing an array via the ignore option, eliminating the per-pointerdown allocation. For hasDisablingLayerAbove, replace `layers.slice(i+1).some(...)` with a plain reverse for-loop down to i+1 to avoid the slice allocation. Low priority.
### `utilities/roving-focus` — 3 finding(s)
- **🟡 medium** · v8-jit · H · · candidate — `RovingFocusItem.vue:82` (handleKeydown)
- **Issue:** On every arrow/Home/End keydown (auto-repeats while a key is held) the handler does getItems().map(i => i.ref).filter(i => i.dataset['disabled'] !== ''). getItems() already filters out data-disabled items by default, so this re-filter is redundant; the .map(i => i.ref) then allocates a second array. For prev/next it additionally reverse()s and either wrapArray()s (allocates an Array.from-built array) or slice()s, producing up to 3-4 intermediate arrays per keypress. With a held arrow key on a long toolbar/menu this is repeated allocation churn plus the underlying getItems() cost.
- **Fix:** Call getItems() once (it already strips disabled items), then map to refs only when needed: const refs = getItems().map(i => i.ref). Drop the redundant .filter(disabled). For prev/next, compute currentIndex against refs and avoid the extra reverse when wrapArray is used (wrapArray + a direction sign can replace reverse()+slice). Reuse a single scratch array where possible instead of reverse+slice+wrapArray chaining.
- **🟡 medium** · v8-jit · H · · candidate — `RovingFocusGroup.vue:126` (handleFocus)
- **Issue:** On group entry-focus the handler builds items via getItems().map().filter() (the .filter on data-disabled is redundant since getItems() already excludes disabled items by default), then runs three separate Array.prototype.find() passes (active, highlighted, current) over the same array, then spreads [...items] into a brand-new array and runs .filter(Boolean) plus a cast. That is one map + one redundant filter + three full scans + one spread-copy + one final filter — ~6 passes/allocations over the item list each time focus enters the group.
- **Fix:** Fetch getItems() once (already disabled-filtered) and map to refs once. Replace the three find() scans with a single for-loop that captures activeItem/highlightedItem/currentItem in one pass over items, reading getAttribute once per item. Build the candidate array by pushing the (deduped) priority items then the rest, avoiding the [...items] spread + filter(Boolean). focusFirst already no-ops on duplicates, but pushing only defined refs keeps the array packed and skips the Boolean filter.
- **⚪ low** · reactivity · H · · candidate — `RovingFocusItem.vue:82` (getItems (via useCollection))
- **Issue:** getItems() in the injected collection runs collectionRef.querySelectorAll('[data-collection-item]') + Array.from + a sort whose comparator calls orderedNodes.indexOf(ref) (O(n) per comparison => effectively O(n^2 log n)) on every handleKeydown/handleFocus. For a roving group this re-walks the DOM and re-sorts the full item list on each navigation keystroke. The implementation lives in collection/useCollection.ts, but RovingFocus is its primary hot caller, so the cost shows up here.
- **Fix:** Within a single handleKeydown/handleFocus, call getItems() exactly once and reuse the result (the two findings above already consolidate the call). If profiling shows this dominating on large menus/listboxes, precompute an index Map<HTMLElement,number> from orderedNodes once and have the sort comparator look up positions in O(1) instead of indexOf, turning the sort back into O(n log n).
### `utilities/visually-hidden` — 1 finding(s)
- **🟡 medium** · v8-jit · H · · candidate — `VisuallyHiddenInputBubble.vue:76` (syncNativeInput)
- **Issue:** Object.getOwnPropertyDescriptor(globalThis.HTMLInputElement.prototype, prop) walks the prototype to fetch the value/checked accessor descriptor on EVERY programmatic value/checked change. The descriptor object for 'value' and 'checked' on HTMLInputElement.prototype is constant for the entire page lifetime, yet it is re-resolved each sync. This component backs number-field (drag), color-field, tags-input and toggle/switch controls whose driven value changes frequently, so the reflection lookup repeatedly hits a moderately hot path and also produces a polymorphic call site (descriptor varies per instance between two shapes).
- **Fix:** Memoize the two setters once at module scope, lazily so it stays SSR-safe behind the existing window===undefined guard, e.g. a small cache: `let valueSetter, checkedSetter;` resolved on first call from HTMLInputElement.prototype, then `const setter = isCheckbox.value ? checkedSetter : valueSetter; setter?.call(input, next)`. This removes the per-change Object.getOwnPropertyDescriptor prototype walk and gives each branch a stable monomorphic setter reference.
## Clean components (no findings)
`canvas/time-ruler`, `canvas/transform-box`, `display/aspect-ratio`, `display/avatar`, `display/separator`, `forms/editable`, `forms/label`, `forms/switch`, `forms/toggle`, `internal/primitive`, `menus/dropdown-menu`, `overlays/dialog`, `utilities/config-provider`, `utilities/focus-scope`, `utilities/presence`, `utilities/teleport`
## Bench baselines
Captured via `pnpm bench` (vitest bench, chromium browser mode) — **15 bench files, 161 blocks, 0 failures**. Full raw output: [`.bench-baseline.txt`](.bench-baseline.txt). Re-run after fixes and compare to prove deltas.
Numbers below are throughput (hz) at each scale; the **stress scales** (1000 / 500) are where stage-2 fixes should move the needle. Watch especially: `pointer-drag` per-frame stream (fix #2), `curve-editor` per-edit build+sample (fix #5), `color-area` setters/mount (fix #4/#6), `timeline`/`keyframe-track` mount + per-frame lookups (fix #5), `spline` LUT builders (fix #8).
**`internal/pointer-drag`** (7 benches)
- resolveAxisLock — per-frame axis decision — static axis "x" — fast path (100 frames)=5,190,889.99hz · axis "both", no shift-lock (100 frames)=5,139,134.18hz · axis "both" + shift-lock dominant-axis pick (100 frames)=3,188,760.26hz · axis "both" + shift-lock dominant-axis pick (1000 frames)=536,272.00hz
- computeFrame — single frame (feature on/off matrix) — free move, no snap/bounds/rect=7,265,881.98hz · axis-locked + scalar snap + bounds + rect (all features)=7,268,978.27hz · tuple snap + bounds (per-axis grid)=7,100,307.97hz
- computeFrame — full gesture stream — 100 frames — free move (no snap/bounds)=2,542,427.52hz · 100 frames — snap + bounds + rect=1,352,931.41hz · 1000 frames — snap + bounds + rect (stress)=158,140.37hz
- simulated flush() pipeline — resolveAxisLock + computeFrame — 100 moves — shift-lock, no snap/bounds=1,252,108.00hz · 100 moves — shift-lock + snap + bounds + rect=714,692.00hz · 1000 moves — shift-lock + snap + bounds + rect (stress)=71,891.62hz
- usePointerDrag — mount N instances — mount 50 draggable handles=3,532.68hz · mount 500 draggable handles (stress)=375.97hz
- usePointerDrag — update after prop change — 50 handles → re-render to 60 handles=1,159.21hz
- usePointerDrag — live event round-trip (rAF-coalesced) — mount + down + 20 moves + up=5.7202hz
**`internal/scale`** (15 benches)
- math: scaleLinear (pointer projection) — scaleLinear ×100=5,601,259.99hz · scaleLinear ×1000=1,711,881.63hz
- math: roundToStep (snap-to-step hot path) — roundToStep ×100=139,038.19hz · roundToStep ×1000=14,550.00hz
- math: getStepDecimals (per-step cache miss) — getStepDecimals ×1000 (varied step)=57,814.44hz
- math: getClosestValueIndex (nearest-thumb pick) — 100 thumbs ×100 picks=185,872.83hz · 1000 thumbs ×100 picks=19,448.00hz
- math: hasMinStepsBetweenSortedValues (drag invariant) — 100 values=5,019,368.13hz · 1000 values=1,238,818.25hz
- math: niceNum (tick rounding primitive) — niceNum ×1000 (varied magnitude)=1,927,046.59hz
- ticks: niceTicks (realistic vs stress) — realistic (600s axis)=2,542,638.00hz · stress (10h axis, dense range)=186,886.00hz · stress + custom format=164,223.16hz
- ticks: timeTicks (human time ladder) — realistic (600s axis)=1,647,574.49hz · stress (10h axis, dense range)=53,833.23hz
- ticks: timecodeTicks (frame-aligned, fps conversion) — realistic (600s @ 30fps)=548,354.33hz · stress (10h @ 29.97fps drop-frame labels)=57,158.00hz
- ticks: frameTicks (integer-frame axis) — realistic (18000-frame axis)=15,666.00hz · stress (1.08M-frame axis, dense range)=583.65hz
- timecode: framesToTimecode label formatting — non-drop ×100=131,950.00hz · drop-frame 29.97 ×100=98,946.00hz · drop-frame 29.97 ×1000=11,649.67hz
- timecode: scalar label formatters — formatClock ×1000=38,464.00hz · formatTimecode ×1000 (@30fps)=14,127.17hz · formatFrames ×1000=125.00hz · secondsToFrames ×1000=1,958,522.30hz
- useScale: composable construction — build (plain options)=3,913,615.30hz · build (clamp + step + ticks)=3,798,454.33hz
- useScale: pointer-move loop (scale/invert/roundValue) — invert+round ×100 events=75,092.00hz · invert+round ×1000 events=7,250.55hz · scale ×1000 events=32,496.00hz
- useScale: reactive tick recompute on domain change (zoom/pan) — zoom step → recompute ticks/major/minor=425,508.00hz
**`internal/spline`** (16 benches)
- evalCubicBezier — sweep t — 100 params=5,712,887.99hz · 1000 params=1,745,702.86hz
- cubicBezierTangent — sweep t — 100 params=5,713,947.23hz · 1000 params=1,732,195.56hz
- solveBezierX — ease (x→y) — 100 params=430,674.00hz · 1000 params=75,640.00hz · 1000 params — identity short-circuit=746,870.00hz
- cubicBezier1D — scalar Bernstein — 1000 params=1,727,796.00hz
- catmullRom — sweep t — 50 knots × 100 params=573,049.39hz · 500 knots × 100 params=566,750.00hz · 500 knots × 1000 params=61,514.00hz · 500 knots × 1000 params — closed=25,026.00hz
- monotoneCubic — build — 100 knots=107,972.00hz · 1000 knots=11,616.00hz
- monotoneCubic — apply (pre-built fn) — 100 knots → 256-LUT=112,353.53hz · 1000 knots → 256-LUT=98,026.39hz · 1000 knots → 1024-LUT=22,792.00hz
- monotoneCubic — build + apply (knots changed) — 100 knots → build + 256-LUT=51,035.79hz · 1000 knots → build + 256-LUT=10,241.95hz
- linearInterpolate — query sweep — 100 knots × 1000 queries=58,300.00hz · 1000 knots × 1000 queries=45,376.00hz
- sampleToPolyline — bezier curve — 100 segments=302,246.00hz · 1000 segments=29,642.07hz
- sampleFnToPolyline — monotone curve — 100 segments=237,716.46hz · 1000 segments=24,476.00hz
- buildPolylinePath — string concat — 100 points=292,709.46hz · 1000 points=20,331.93hz
- buildSmoothPath — Catmull-Rom cubics — 50 points=164,496.00hz · 500 points=11,876.00hz · 500 points — tension 0.5=11,907.62hz
- buildBezierPath — single segment — 1 segment=7,229,343.95hz
- pointer-move — smooth path rebuild — drag mutate + buildSmoothPath (64 points)=124,047.19hz
- pointer-move — curve recompute — mutate knot + monotoneCubic + 256-LUT (100 knots)=50,884.00hz
**`canvas/curve-editor`** (13 benches)
- buildEvaluator — build cost — linear — 16 anchors=1,234,628.00hz · linear — 256 anchors=125,932.00hz · monotone — 16 anchors=383,598.00hz · monotone — 256 anchors=33,197.36hz · catmull-rom — 16 anchors=79,434.11hz · catmull-rom — 256 anchors=50,563.89hz · bezier — 16 anchors=720,043.99hz · bezier — 256 anchors=68,496.00hz
- evaluator sampling — 256 samples — linear=343,429.32hz · monotone=592,140.00hz · catmull-rom=248,890.00hz · bezier (Newton-Raphson per call)=161,676.00hz
- evaluator sampling — 1024 samples (stress) — monotone — 16 anchors=156,256.00hz · monotone — 256 anchors (deep binary search)=111,976.00hz · bezier — 16 anchors=48,394.00hz
- build + sample 256 (full per-edit, 16 anchors) — monotone=185,120.98hz · catmull-rom=58,420.32hz · bezier=120,559.89hz
- toLUT — spline lookup table — monotone — 256 entries=110,670.00hz · monotone — 1024 entries=27,216.56hz · bezier — 256 entries=82,832.00hz
- curve path `d` build — sampled polyline (256 samples) — monotone — sample + project + buildPolylinePath=40,060.00hz · catmull-rom — sample + project + buildPolylinePath=36,482.00hz
- curve path `d` build — bezier segment chain — 16 anchors (15 segments)=234,902.00hz · 256 anchors (255 segments)=11,619.68hz
- spline primitives — per-call baselines — linearInterpolate — 256-pt table lookup=7,088,362.35hz · catmullRom — 16-pt parametric eval=7,313,673.99hz · evalCubicBezier — single cubic eval=6,966,088.85hz · monotoneCubic — build closure (16 pts)=510,439.91hz
- anchor housekeeping — sortAnchors — 16 (unsorted)=1,003,727.99hz · sortAnchors — 256 (unsorted)=46,338.73hz · anchorsToPoints — 16=1,232,089.59hz · anchorsToPoints — 256=125,538.00hz
- pointer-move clamp math — clampAnchorX — interior anchor (neighbour clamp), 16=7,403,125.41hz · clampAnchorX — interior anchor (neighbour clamp), 256=7,373,969.27hz · clampAnchorY — domain clamp=7,443,999.99hz · simulated updateAnchor step — clamp + slice-replace, 16=6,432,109.99hz · simulated updateAnchor step — clamp + slice-replace, 256=5,501,865.63hz
- simulated drag stroke (60 frames, monotone, 16 anchors) — clamp + rebuild + sample-256 per frame=3,084.00hz
- mount — Root + Curve + N Points — 50 points (monotone)=231.22hz · 500 points (monotone, stress)=23.6220hz · 50 points (bezier path)=239.62hz
- update after prop change (50 points) — switch interpolation monotone→bezier→monotone=165.90hz · replace model array (commit an edit)=118.34hz
**`color/color-area`** (6 benches)
- pointer → saturation/value math — pointerToSV — 100 moves (ltr)=2,405,101.98hz · pointerToSV — 1000 moves (ltr)=333,625.28hz · pointerToSV — 1000 moves (rtl flip)=336,208.76hz
- clampChannel — channel clamp — clampChannel — 1000 calls=675,342.93hz
- hsvToRgb — hue background recompute — hsvToRgb — 100 colors=187,550.00hz · hsvToRgb — 1000 colors=21,726.00hz · hsvaToCss — 1000 colors (full hsva)=15,998.80hz
- preserve-hue setters — drag/key commit — setSaturationValue — 1000 commits (sweep incl. grey)=477.43hz · setSaturation + setValue — 1000 key nudges=215.27hz
- mount — ColorAreaRoot + N thumbs — mount + unmount — 50 thumbs=481.33hz · mount + unmount — 500 thumbs=48.6003hz
- update — re-render after HSVA change — 1 thumb — mount then patch new HSVA=8,746.25hz
**`canvas/timeline`** (11 benches)
- ruler ticks — timecode (per pan/zoom) — timecodeTicks — 100-clip span (~150s)=53,809.24hz · timecodeTicks — 1000-clip span (~1500s)=18,988.20hz · timecodeTicks — wide window, fixed viewport (1200px)=838,271.99hz
- ruler ticks — wall clock (per pan/zoom) — timeTicks — 100-clip span (~150s)=157,950.41hz · timeTicks — 1000-clip span (~1500s)=32,748.00hz
- scale projection (scaleLinear over clips) — scaleLinear — project 100 clip edges=3,262,253.56hz · scaleLinear — project 1000 clip edges=466,074.79hz
- timecode formatting (per clip label) — timeToTimecode — 100 clip durations=113,117.38hz · timeToTimecode — 1000 clip durations=11,245.75hz · framesToTimecode — 1000 (raw, pre-converted)=14,099.18hz
- snapToFrame (nudge / grid granularity) — snapToFrame — 100 clip starts=2,369,298.14hz · snapToFrame — 1000 clip starts=308,024.40hz
- marquee hit-test (clipIntersectsTime per pointer move) — clipIntersectsTime — 100 clips=3,878,759.99hz · clipIntersectsTime — 1000 clips=600,243.95hz
- clipsDuration (auto-duration recompute) — clipsDuration — 100 clips=4,189,313.97hz · clipsDuration — 1000 clips=639,822.00hz
- applyClipChanges (controlled reducer) — applyClipChanges — 100 clips / 100 changes=115,978.00hz · applyClipChanges — 1000 clips / 1000 changes=10,148.00hz · applyClipChanges — 1000 clips / single move=17,258.00hz
- applyTrackChanges (controlled reducer) — applyTrackChanges — 50 tracks / 50 patches=190,324.00hz · applyTrackChanges — 500 tracks / 500 patches=18,284.00hz
- TimelineRoot — mount (full tree) — mount — 4 tracks / 50 clips=196.51hz · mount — 8 tracks / 500 clips=22.7790hz
- TimelineRoot — update after prop change — zoom change (pxPerSecond) — 8 tracks / 500 clips=14.4949hz · clips-array swap — 8 tracks / 500 clips=13.0225hz
**`canvas/keyframe-track`** (9 benches)
- sampleKeyframes — single sample by curve size — 100 keyframes — sample mid-range=6,003,987.24hz · 1000 keyframes — sample mid-range=6,322,849.43hz
- sampleKeyframes — full curve sweep (per-frame readout) — 100 keyframes × 120 samples=131,984.00hz · 1000 keyframes × 120 samples=132,100.00hz
- solveBezierX — easing solve — identity (linear) × 64=4,689,626.08hz · ease-in-out (Newton-Raphson) × 64=684,475.11hz
- sortKeyframes — reconcile / commit — 100 keyframes (reverse-sorted input)=572,597.48hz · 1000 keyframes (reverse-sorted input)=58,944.00hz
- clampKeyframeTime — neighbour clamp (pointer drag) — 100 keyframes × 100 moves=1,118,014.00hz · 1000 keyframes × 100 moves=1,098,890.23hz
- snapTimeToFrame — frame-grid quantize — 100 quantize ops @30fps=2,803,763.98hz
- defaultKeyframeValueText — aria-valuetext — 100 value-text formats (with property)=374,868.00hz
- KeyframeTrackRoot — mount + unmount — mount 50 keyframes=124.68hz · mount 500 keyframes=5.5850hz
- KeyframeTrackRoot — re-render after prop change — 50 keyframes — duration change + flush=106.93hz · 500 keyframes — duration change + flush=5.0792hz
**`canvas/waveform`** (11 benches)
- countBars — small body (300px)=7,352,141.59hz · large body (1800px)=7,120,649.88hz
- resamplePeaks — by source length (100 buckets) — 100 peaks=1,162,090.00hz · 1000 peaks=274,426.00hz · 10000 peaks=32,241.55hz · 10000 peaks (Float32Array)=28,240.00hz
- resamplePeaks — by bucket count (10000 peaks) — 100 buckets=30,416.00hz · 600 buckets=24,258.00hz · upsample → 2000 buckets=17,884.42hz
- resamplePeaks — windowed slice (zoom/scroll) — full window — 600 buckets over 10000=24,211.16hz · 25% zoom window — 600 buckets over slice=81,302.00hz
- buildBars — bars-mode geometry — 100 bars from 1000 peaks=192,124.00hz · 600 bars from 10000 peaks=20,179.96hz · 600 bars from 10000 peaks (Float32Array)=20,065.99hz
- buildBars — sliding window (simulated scrub/zoom recompute) — 600 bars, window slides per iteration=48,556.00hz
- buildPathPoints — path-mode silhouette — 256 samples from 1000 peaks=143,684.00hz · 1024 samples from 10000 peaks=17,946.41hz
- buildSmoothPath — Catmull-Rom path string — 256 points, tension 0=20,822.00hz · 256 points, tension 0.5=21,690.00hz · 1024 points, tension 0=3,891.22hz
- WaveformRoot + WaveformBars — mount — mount with ~50-bar fixture=3,469.92hz · mount with ~500-bar fixture=2,211.16hz
- WaveformRoot — update after prop change — currentTime change → patch=3,270.69hz · peaks swap → re-resample + patch=3,008.00hz
- WaveformRoot + WaveformPath — mount — path mode, 256 samples=3,722.51hz · path mode, 1024 samples=3,783.24hz
**`canvas/histogram`** (8 benches)
- histogramMax — peak scan — 100 bins=4,995,467.98hz · 256 bins=3,199,458.11hz · 1000 bins=1,277,438.00hz · 256 bins — all zero (guard path)=3,267,112.59hz
- projectBars — linear (peak scan + normalise + alloc) — 100 bins=340,717.86hz · 256 bins=141,573.69hz · 1000 bins=35,842.00hz
- projectBars — log (log1p per bin + alloc) — 100 bins=269,120.18hz · 256 bins=107,862.00hz · 1000 bins=24,446.00hz
- projectBars — all-zero guard (no NaN, no divide) — 256 bins — linear=144,297.14hz · 256 bins — log=142,830.00hz
- projectBarHeight — per-bin scalar (1000x loop) — linear x1000=1,734,069.99hz · log x1000=1,715,284.95hz
- per-channel projection (RGB composite, record data) — 4 channels x 100 bins x 2 scales=33,410.00hz · 4 channels x 1000 bins x 2 scales=3,681.26hz
- HistogramRoot + HistogramBars — mount — 50 bars (linear)=1,078.00hz · 500 bars (linear)=137.50hz · 500 bars (log)=135.59hz
- HistogramRoot + HistogramBars — update after prop change — 500 bars — scaleType linear → log=96.9721hz · record data — channel l → rgb (expand to 3 primaries)=166.20hz
**`canvas/flow`** (20 benches)
- edge-paths — straight — 100 edges=664,128.00hz · 1000 edges=72,543.49hz
- edge-paths — bezier — 100 edges=77,522.00hz · 1000 edges=6,873.25hz
- edge-paths — smoothstep (corner builder) — 100 edges=13,001.40hz · 1000 edges=1,290.19hz
- edge-paths — step (zero-radius smoothstep) — 100 edges=12,797.44hz · 1000 edges=1,268.48hz
- pointer math — screenToFlow — 100 moves=3,327,878.00hz · 1000 moves=533,703.26hz
- pointer math — flowToScreen — 100 points=3,548,374.33hz · 1000 points=596,212.76hz
- pointer math — zoomAtPointer (wheel zoom) — 100 wheel steps=3,119,281.99hz · 1000 wheel steps=466,404.72hz
- pointer math — snapPoint (drag with snap-to-grid) — 100 moves=1,187,976.00hz · 1000 moves=137,674.00hz
- getNodesBounds — 100 nodes=1,637,925.99hz · 1000 nodes=167,484.00hz
- fitViewTransform (bounds + fit) — 100 nodes=1,622,870.00hz · 1000 nodes=168,224.36hz
- getNodePositionAbsolute — parent chain (depth 64) — single leaf walk=725,752.85hz · 64 nodes (all walked)=25,778.00hz
- visibleFlowRect + getVisibleNodeIds (node cull) — 100 nodes=800,992.00hz · 1000 nodes=80,856.00hz
- getVisibleEdgeIds (edge cull by visible node set) — 100 edges=796,650.67hz · 1000 edges=79,270.15hz
- getNodesInsideRect (marquee selection) — 100 nodes=1,742,606.00hz · 1000 nodes=159,956.01hz
- findClosestHandle (connect-drag snapping) — 100 nodes=3,915.22hz · 1000 nodes=360.41hz
- applyNodeChanges (drag → position changes) — 100 position changes=115,198.00hz · 1000 position changes=10,344.00hz
- applyEdgeChanges (select changes) — 100 select changes=113,930.00hz · 1000 select changes=10,150.00hz
- addEdge (dedupe scan on connect) — append into 100 edges=422,387.52hz · append into 1000 edges=41,092.00hz
- FlowRoot — mount + unmount — 50 nodes / 50 edges=127.81hz · 500 nodes / 500 edges=13.8007hz
- FlowRoot — re-render after prop change (viewport pan) — 50 nodes — viewport setProps=78.8022hz · 500 nodes — nodes setProps (controlled replace)=3.8464hz
**`canvas/zoom-pan`** (13 benches)
- screenToContent — over N points — 100 points=1,192,582.00hz · 1000 points=143,410.00hz
- contentToScreen — over N points — 100 points=1,182,360.00hz · 1000 points=146,178.76hz
- round-trip screen→content→screen — over N points — 100 points=1,179,437.99hz · 1000 points=141,443.71hz
- zoomAtPointer — over N anchor points — 100 points=1,095,020.00hz · 1000 points=128,084.38hz
- clampViewport — zoom-only (no extent) — 100 viewports=964,282.00hz · 1000 viewports=107,640.47hz
- clampViewport — with translate extent — 100 viewports=902,672.00hz · 1000 viewports=97,734.45hz
- clampViewport — degenerate extent (centring branch) — 100 viewports=919,704.06hz · 1000 viewports=105,346.00hz
- wheelToZoomFactor — over N wheel events — 100 events=174,063.19hz · 1000 events=16,542.00hz
- wheel-zoom pipeline (factor → clamp → zoomAtPointer) — 100 steps=205,246.00hz · 1000 steps=19,588.00hz
- drag-pan move (translate + clamp) — 100 moves=822,331.53hz · 1000 moves=85,706.86hz
- fitViewTransform — single fit=7,124,967.01hz · 100 fits=1,157,900.00hz
- measureContentRect (real getBoundingClientRect) — 100 measurements=17,414.52hz
- ViewportRoot — mount with N tiles — 50 tiles — mount + unmount=2,345.61hz · 500 tiles — mount + unmount=875.12hz
**`canvas/time-ruler`** (12 benches)
- tick generation — timeTicks (seconds mode) — realistic window (~15s @ 40px/s)=2,270,653.88hz · stress window (1000s @ 4px/s)=628,932.00hz
- tick generation — timecodeTicks (timecode mode) — realistic window=659,680.07hz · stress window=212,063.59hz · realistic window — drop-frame labels=639,334.00hz
- tick generation — frameTicks (frames mode) — realistic window — timecode ticker w/ frame labels=13,785.24hz · stress window — integer-frame axis=3,373.33hz
- tick generation — niceTicks (generic axis) — realistic window=2,482,165.58hz · stress window=689,030.00hz
- projection math — scaleLinear (time → px) — 100 values=5,357,289.99hz · 1000 values=954,867.03hz
- projection math — scaleLinear (px → time, invert) — 100 pixels=5,374,567.97hz · 1000 pixels=897,614.00hz
- projection math — roundToStep (snap, pointer path) — 100 values=145,666.87hz · 1000 values=13,971.21hz
- useScale — projector closures — scale() × 1000=24,637.07hz · invert() × 100 (pointer sweep)=362,189.56hz
- label formatting — per mode — formatClock × 1000 (seconds)=58,456.00hz · formatTimecode × 1000 (timecode)=14,244.00hz · framesToTimecode × 1000 — drop-frame=11,798.00hz · formatFrames × 1000 (frames)=126.51hz · formatTimeForMode × 1000 — dispatch (timecode)=14,360.00hz
- mode plumbing — modeToTickKind × 3 modes=7,138,575.98hz · tickFormatFor × 3 modes=7,160,973.99hz · secondsToFrames × 1000=900,848.00hz
- TimeRulerRoot — mount — mount — seconds mode=3,622.55hz · mount — timecode mode=3,641.08hz · mount — frames mode=3,690.00hz
- TimeRulerRoot — re-render after prop change — zoom change (pan/zoom gesture stream)=2,864.85hz · offset change (pan stream)=2,864.85hz · mode change (timecode → frames, regenerate ladder)=2,636.42hz
**`canvas/transform-box`** (10 benches)
- rotatePoint — kernel — rotatePoint × 100=4,186,828.67hz · rotatePoint × 1000=893,619.28hz · rotateVector (origin-free) × 1000=896,738.65hz
- pointer angle math — pointerAngle × 100=3,031,405.99hz · pointerAngle × 1000=889,550.00hz · shortestAngleDelta × 1000=900,573.89hz · normalizeRotation × 1000=1,020,559.89hz · snapRotation (15°) × 1000=1,030,022.00hz
- rotate drag — per-frame — rotationFromPointer × 100 frames=533,194.00hz · rotationFromPointer × 1000 frames=52,782.00hz
- resizeEdge — per-frame — resizeEdge corner (no options) × 100=108,148.00hz · resizeEdge corner (no options) × 1000=10,806.00hz · resizeEdge aspect-locked corner × 1000=8,762.25hz · resizeEdge symmetric (Alt) corner × 1000=9,848.00hz · resizeEdge edge handle × 1000=13,704.00hz · rotated scale frame (rotateVector → resizeEdge) × 1000=8,680.53hz
- aspect + axes helpers — applyAspectRatio × 1000=421,907.62hz · handleAxes × 8 positions × 125 (=1000)=967,260.00hz
- constrain + move — constrainRect × 1000=901,852.00hz · moveBox × 1000=61,595.68hz · resolvePivot (center) × 1000=898,150.37hz
- local ⇄ world — localToWorld → worldToLocal round-trip × 1000=892,126.00hz
- decomposeTransform — corners — decomposeTransform × 100=175,760.00hz · decomposeTransform × 1000=15,972.00hz
- TransformBoxRoot — mount full part set — mount + unmount — 1 box (root + 8 handles + rotate + status)=1,188.34hz · mount + unmount — 50 boxes=26.9024hz · mount + unmount — 500 boxes (stress)=2.5223hz
- TransformBoxRoot — update after transform change — mount → setProps(transform) → update — 50 boxes=30.7515hz
---
# Stage 2 — fixes applied & verification
Five highest-leverage fixes from the audit were applied and verified. Correctness
gate: `pnpm test` (browser mode) — **1855/1856 passing**; the one failure
(`scroll-area > glimpse stays hidden before any interaction`) is a **pre-existing
test-isolation flake** (a `document`-wide `[data-state="visible"]` query that
counts the scrollbars a sibling test reveals with a 5000 ms hide delay) — it uses
none of the changed modules and passes 3/3 in isolation. All 14 changed files are
lint-clean (the one remaining lint error, `window` in `addWindowListeners`, is
pre-existing untracked code, not in this diff).
## Fixes
1. **`utilities/collection` `getItems()`** — replaced the `orderedNodes.indexOf()`
comparator (O(n) per comparison → O(n² log n)) with a precomputed
`Map<node,index>` (O(1) per comparison → O(n log n)), plus a `≤1`-item
short-circuit. Behaviour-identical (1 ordering for not-found preserved).
Improves every consumer that calls `getItems()` per keystroke/pointer-move
(~12 components: listbox/menu/menubar/toolbar/tabs/tree/roving-focus/
radio-group/stepper/accordion/navigation-menu).
2. **`internal/pointer-drag`** — drag state changed from deep `reactive()` to a
plain object (it is written ~13 fields/frame and nothing subscribes to it
reactively); `total`/`delta` are now live getter views. Removes a Proxy
set-trap + subscriber-less trigger per field per frame on the package's single
shared drag primitive.
3. **4 gesture leaks**`onScopeDispose` teardown so a mid-gesture unmount detaches
window pointer listeners + releases capture: `canvas/crop`, `canvas/levels`,
`forms/slider` (also added the missing `pointercancel` path), and
`canvas/curve-editor` (disconnect the `ResizeObserver` via the watch-cleanup,
fixing both the unmount leak and the observer-stacking on re-run).
4. **`color/*` shared state** — `shallowRef` for the HSVA in all four roots
(color-area, hue-slider, alpha-slider, color-field) and dropped `deep:true`
from the `useColorState`/ColorField watches. HSVA is always replaced wholesale
(verified: no in-place channel mutation anywhere), so identity replacement still
triggers; the deep proxy + per-update `{h,s,v,a}` traverse were pure overhead.
5. **Per-frame `getBoundingClientRect()` caching** — snapshot the track/element
rect in `onStart`, reuse across `onMove` frames, clear in `onEnd`:
`hue-slider`, `color-area`, `angle-dial`, `waveform`, `compare-slider`.
Removes one forced layout reflow per frame per active drag (hover paths keep
measuring live).
## Measured deltas (vitest bench, chromium)
Two deterministic micro-benches were added to prove the complexity/overhead wins
(noise-immune: tight loops, low rme). Component mount/update wall-clock benches
were too machine-load-sensitive this session (±50% swings on *unchanged* code) to
quote small deltas precisely.
**Fix #1 — `collection/__test__/Collection.bench.ts`** (OLD indexOf vs NEW Map):
| Items | OLD hz | NEW hz | Speedup |
|------:|-------:|-------:|--------:|
| 12 | 1,138,470 | 910,042 | 0.80× (Map setup; sub-µs either way) |
| 50 | 158,320 | 167,980 | 1.06× |
| 200 | 19,482 | 35,057 | **1.80×** |
| 1000 | 1,661 | 9,214 | **5.55×** |
**Fix #2 — `pointer-drag/__test__/StateUpdate.bench.ts`** (per-frame state writes):
| State | 1000-frame hz | |
|------|-------:|---|
| OLD — `reactive()` | 295 | (±0.31% rme) |
| NEW — plain object | 477,980 | **1619× faster** |
Plus, from the full `pnpm bench` re-run, on changed code: `usePointerDrag` mount of
500 instances **+30%** (376 → ~504 hz, consistent across runs). Pure-function
baselines (computeFrame, spline, scale, …) are flat within run-to-run noise, as
expected — their code did not change.
Re-measure anytime: `cd vue/primitives && pnpm bench`, diff vs `.bench-baseline.txt`.
---
# Stage 3 — remaining audit items (21 components)
Addressed the lower-priority audit items via a design+adversarial-verify workflow,
then a read-only verify-and-repair pass. **`pnpm test` 1856/1856 passing**, lint clean
(introduced issues fixed; pre-existing warnings untouched).
**Process note:** the first design workflow's agents wrote 32 files directly (the
default agent has Edit access), so their edits landed unverified. Recovery: the
agents' changes were reconstructed and reverted from the run transcripts
(roundtrip-validated: original + edits == proposed, byte-exact), then re-reviewed
read-only — **31 keep, 1 repair, 0 revert**. The one repair caught a real bug (a
`baseSide` computed shadowed by a string in PopperArrow, breaking `[baseSide.value]`).
The 9 components whose agents returned patches without writing were verified and
applied directly (39 edits). Total: 71 edits across 21 components.
## What changed (by theme)
- **Packed arrays (V8 PACKED_DOUBLE):** `[]`+push instead of `Array.from({length})`/
`new Array(n)` pre-fill in spline LUT/polyline builders (toLUT, sampleToPolyline,
sampleFnToPolyline, monotoneCubic) and histogram `projectBars`.
- **O(1) id→{item,index} maps** memoized in the Root, replacing per-item O(n)
find/indexOf scans on every drag frame: gradient-editor, keyframe-track,
timeline, curve-editor (`indexById`); shared tab-stop computed for toggle-group.
- **Emit timing (curve-editor):** `anchorsCommit` moved from per-frame to per-settle
(onCommit), matching its documented "after a drag or keypress settles" contract;
`updateAnchor` returns a moved-bool so keyboard no-ops skip the commit.
- **Formatter/descriptor caching:** module-scope Intl.DateTimeFormat/NumberFormat
cache (calendar), cached number format (number-field), memoized HTMLInputElement
value/checked setters (visually-hidden), inlined hue-sector mapping (internal/color).
- **Allocation/identity hoisting:** static `:style`/object literals hoisted to frozen
module consts or memoized computeds (popover, popper, flow, toast, angle-dial,
histogram, menu); shallowRef for wholesale-replaced swipe state (toast); deep:1
watch (keyframe-track).
- **scroll-area:** gate pointermove on the active-drag flag (fixes scroll-to-0 on
hover), reuse the rAF scratch object, share/debounce ResizeObservers, scope the
wheel listener.
## Measured deltas (vitest bench, isolated runs vs pre-fix `.bench-baseline.txt`)
Pure-function hot paths (deterministic, low rme) — the headline wins:
| Bench | Before → After | Δ |
|---|---|---|
| histogram `projectBars` linear, 100 bins | 341k → 1,906k hz | **+459%** |
| histogram `projectBars` linear, 256 bins | 142k → 737k hz | **+420%** |
| spline `sampleToPolyline` bezier, 100 seg | 302k → 1,531k hz | **+406%** |
| spline `sampleToPolyline` bezier, 1000 seg | 30k → 146k hz | **+392%** |
| spline `monotoneCubic` build, 100 knots | 108k → 358k hz | **+231%** |
| curve-editor `toLUT` monotone, 256 | 111k → 318k hz | **+188%** |
| spline `monotoneCubic` apply, 100→256-LUT | 112k → 318k hz | **+183%** |
Component paths (browser mount/update; higher variance):
| Bench | Before → After | Δ |
|---|---|---|
| keyframe-track mount 500 keyframes | 5.6 → 12.0 hz | **+114%** |
| keyframe-track re-render 500 (prop change) | 5.1 → 7.9 hz | **+55%** |
| curve-editor simulated drag stroke (60 frames) | 3,084 → 4,135 hz | **+34%** |
| curve-editor mount 500 points | 23.6 → 30.6 hz | **+30%** |
| curve-editor update after edit (50 pts) | 118 → 152 hz | **+29%** |
| timeline update (zoom, 8 tracks/500 clips) | 14.5 → 16.4 hz | **+13%** |
No real regressions: zoom-pan mount measured -40% in a CPU-contended batch run but is
flat (+0.3% / -7%, within ±11% rme) in isolation. The small histogram/keyframe-track
"update" dips at N=50 are the memoization crossover (fixed Map/precompute overhead
that pays off at scale — same shape as the large wins above and the collection fix).