You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/audit/components/listbox.md

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 GLOBAL opts.value and does O(value.length) Array.includes(). This is the textbook A31 pattern: every value mutation invalidates all N items' isSelected derivations, 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 building new Set(this.opts.value.current) with an explicit comment (141-146) documenting 'avoids an O(N²) cascade' — yet the sibling isSelected reactive 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-level readonly selectedSet = $derived(new Set(this.opts.value.current)) consulted via .has() from isSelected(), 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 NO listbox: 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)

Powered by TurnKey Linux.