661a55719e
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.).
1039 lines
187 KiB
Markdown
1039 lines
187 KiB
Markdown
# 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(' |