7.0 KiB
Audit: listbox
audit-version: 1 audited-at: 2026-06-26 scope: (SCOPE-DRIFT → SYS-1) method: adversarially-verified workflow (analyze → refute); HIGH lead-verified by direct read of the cited code. B5 ground-truth: the A31 O(N²) isSelected/isExpanded (.includes from a per-item $derived) is confirmed across listbox/grid-list/tree-view/tree-grid/tag-group (SYS-7); rovingTargetEl is correctly LIFTED everywhere (not A31); virtual-* use SvelteMap (A33-clean). provider: /g/dev/svelte/vicen/src/uix/soma/components/listbox reactivity (A31/A33/A35): FOUND: A31 O(N²) latent bug at line 413. Per-item isSelected $derived calls provider.isSelected() which does array.includes() — triggers on every provider.value change. Multi-select mode: N items × O(N) array search = O(N²). Roving-target was correctly lifted (line 148-153) but isSelected was not. Single-select unaffected (selection ≤ 1). Tests max 3 items, so bug not caught. HIGH severity.
Summary
Counts (post-verification): CRITICAL 0 · HIGH 1 · MEDIUM 1 · LOW 0.
Findings
HIGH: A31: Per-item $derived reading global provider state via .includes() O(N) method — listbox-001
- dimension: B (Reactivity - A31)
- rule: A31: Per-item $derived reading global provider state via .includes() O(N) method
- location: src/uix/soma/components/listbox/listbox-provider.svelte.ts:413, :157-158
- evidence: Line 413:
readonly isSelected = $derived.by(() => this.provider.isSelected(this.opts.value.current));— per-item derived calling Line 157-158:isSelected(value: string): boolean { return this.opts.value.current.includes(value); }. When provider.opts.value changes, ALL N items' derived re-run, each performing O(N) array.includes() search. With multi-select, N items × O(N) selection-array = O(N²). - impact: Multi-select listboxes with 30+ items + selections will experience quadratic re-derivation cost. Single-select mode is unaffected (selection array ≤ 1 item). Performance degradation not caught by existing tests which max 3 items.
- proposed-fix: Lift isSelected as a provider-level $derived returning a Set keyed by value, similar to rovingTargetEl pattern (line 148-153). Items would then do O(1) .has() checks instead of O(N) .includes() calls. Alternatively, compute a derived Set at provider level and expose it for item consumption.
- verify: [confirmed] CONFIRMED HIGH. Re-read listbox-provider.svelte.ts. Line 413 (per-item):
readonly isSelected = $derived.by(() => this.provider.isSelected(this.opts.value.current));. Line 157-158 (provider method):isSelected(value: string): boolean { return this.opts.value.current.includes(value); }— reads GLOBALopts.valueand does O(value.length) Array.includes(). This is the textbook A31 pattern: everyvaluemutation invalidates all N items'isSelectedderivations, each re-running an O(K) array search (K = selection size). Multi-select: N items x O(N) = O(N²). Single-select: K<=1 so effectively O(N), unaffected. The asymmetry is the proof this is a genuine miss, not a design choice:rovingTargetEl(lines 148-153) was deliberately LIFTED to ONE provider-level $derived buildingnew Set(this.opts.value.current)with an explicit comment (141-146) documenting 'avoids an O(N²) cascade' — yet the siblingisSelectedreactive path was left calling the O(N) provider method per-item. Matches the documented Listbox-rovingTarget incident exactly (same provider). Tests max out at 3 items (alpha/beta/gamma, lines 167-212) with no large-N/perf test, so the hazard is unguarded. Fix per candidate is correct; cleanest form: provider-levelreadonly selectedSet = $derived(new Set(this.opts.value.current))consulted via.has()fromisSelected(), mirroring the existing line-151 Set. - fix-status: fixed (
92f988e7)
MEDIUM: SYS-1: Scope drift — eidos recipe directory exists but morfo.scope does not declare 'eidos — listbox-002
- dimension: A (Contract)
- rule: SYS-1: Scope drift — eidos recipe directory exists but morfo.scope does not declare 'eidos'
- location: src/uix/morfo/components/listbox.ts:7 declares scope: ['soma', 'sema']; eidos components exist at src/uix/eidos/components/listbox/
- evidence: Morfo line 7:
scope: ['soma', 'sema']omits 'eidos'. Directory structure confirms eidos layer exists with full implementation: listbox.svelte, listbox-item.svelte, listbox-group.svelte, listbox-item-indicator.svelte, listbox-group-label.svelte, listbox.css, types.ts, index.ts. - impact: Morfo contract validator may not check eidos layer compliance; documentation/tooling may omit eidos layer from automated coverage. Scope mismatch creates maintenance liability if eidos changes are made without realizing morfo is unaware.
- proposed-fix: Add 'eidos' to morfo.scope array:
scope: ['soma', 'sema', 'eidos'] - verify: [confirmed] CONFIRMED MEDIUM (canonical SYS-1). Morfo line 7:
scope: ['soma', 'sema'](file ends correctly with} as const satisfies Morfo;— contract annotation is fine, not a separate finding). The eidos layer materially exists at src/uix/eidos/components/listbox/: listbox.css (6574 bytes), listbox.svelte, listbox-item.svelte, listbox-group.svelte, listbox-group-label.svelte, listbox-item-indicator.svelte, types.ts, index.ts — a full implementation. Per SYS-1, eidos-recipe-dir-exists + morfo-scope-omits-'eidos' = MEDIUM scope-drift, and that is exactly the case here. Context: this is systemic — only 63/128 morfo components declare 'eidos' in scope despite 130 eidos dirs; major interactive siblings (toggle/dialog/select/combobox/popover) all omit it too. Minor correction to candidate evidence: there is NOlistbox:recipe key in lib/recipes/base.ts (grep returns nothing) — the CSS is foundation-only, not recipe-driven. But SYS-1 triggers on the eidos DIR existence, which is confirmed, so the finding stands at MEDIUM. Fix: add 'eidos' to the scope array. - fix-status: fixed (
212624e0)
No-findings dimensions
C (DOM-selector - CSS.escape use safe on framework numeric IDs), D (Frontier - no soma→eidos imports, no double-write divergence), E (TSC theming - all CSS vars reference canonical --color-* / --space-* / --radius-* / --opacity-* / --font-size-*), G (Redundancy - keyboard nav index math consistent, no duplication with divergence risk)
Theming facts (E-bis)
- magic z-index: none
- magic literals: 0.875rem (listbox.css:185 item-indicator size - should reference --icon-size-sm or similar scale)
- undeclared parts: none
- roles clean: true · variants clean: true
Tests (F)
- exists: true · env: jsdom (@vitest-environment jsdom)
- covers: Root accessibility props + focus state; Item selection + toggle + roving tabindex; Keyboard navigation (Arrow Down/Up, Home, End, Ctrl+A typeahead); Group + GroupLabel context wiring; ItemIndicator presence toggling
- untested: Large-scale multi-select (30+ items) performance under rapid selection changes — A31 O(N²) risk not validated; Disabled item filtering in roving target (getItems() correctly scoped to enabled items per line 137-138); Horizontal orientation + RTL keyboard direction (declared in morfo, not tested)