# Client Store & List Windowing (Strategic Refactor) Priority: **Medium (strategic)** · Effort: **High** · Risk: **Medium** This is the largest item of the eight. Unlike the others, it is a refactor rather than a bug fix. It should be sequenced *after* the XSS, cache, and WS work so the new structure inherits correct behavior. It can ship incrementally. ## Problem The client is a pre-module vanilla-JS app that has outgrown its structure: 1. **One mutable global** (`window.MusicRoom` / `M`, `public/core.js:4-73`) holds *all* state — audio element, WS, current track, queue, library, cache maps, permission flags, and UI bookkeeping (`lastProgressPct`, etc.). Every IIFE mutates `M` directly and calls `M.render*()`. There is no contract between modules; forward references are sometimes guarded with `M.foo && M.foo()` (`channelSync.js:502`), often not. 2. **Full re-render of every row on every change.** `trackContainer.js:101-165` clears `innerHTML` and rebuilds every track row — triggered by every WS state update, every 5s cache poll (`ui.js:175-182`), and every prefetch completion. No windowing/virtualization: a 10k-track library renders 10k `.track` divs with fresh closures each time. Search (`queue.js:196-204`) filters the full array synchronously per keystroke. 3. **God module.** `public/trackContainer.js` (972 lines) fuses rendering, drag state, selection, drop-zones, and context-menu DOM. 4. **Duplicated logic**: local-playback setup (4×), cookie get/set/clear (3×), `escapeHtml` (2×), segment-availability scan (3×). ## Affected Locations - `public/core.js:4-73` — global state - `public/trackContainer.js` (972 lines) — render + drag + selection + context menu - `public/trackContainer.js:101-165` — `render()` full clear+rebuild - `public/ui.js:175-182` — 5s cache-poll re-render trigger - `public/queue.js:196-204` — synchronous per-keystroke filter - All IIFEs in `public/*.js` — module pattern ## Goals (in priority order) 1. **Virtualize the library list** so 10k+ tracks render smoothly. 2. **Introduce a tiny reactive store** so state changes are explicit and modules subscribe rather than mutate globals. 3. **Decompose `trackContainer.js`** into focused modules. 4. **De-duplicate** playback setup, cookie helpers, escaping. Non-goals: adopting a framework (React/Vue/etc.). Bun serves static files with no build step; introducing one is out of scope unless explicitly desired. This plan stays vanilla. ## Implementation Plan The plan is staged so each phase ships value independently. ### Phase 1 — Virtualize the list (highest standalone value) Even without a store refactor, windowing fixes the worst performance problem. **1.1 Add a windowed renderer** for the library (and queue when long). Only render rows visible in the viewport plus a small overscan buffer (e.g. 10 rows above/below). Use a sentinel/spacer div with the full scroll height so the scrollbar stays accurate. Sketch (`trackContainer.js`): ```js const ROW_HEIGHT = 44; // measure actual rendered row height const OVERSCAN = 10; function renderWindow(container, items, scrollTop, viewportHeight) { const totalHeight = items.length * ROW_HEIGHT; const firstVisible = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN); const visibleCount = Math.ceil(viewportHeight / ROW_HEIGHT) + OVERSCAN * 2; const slice = items.slice(firstVisible, firstVisible + visibleCount); container.innerHTML = ""; // or keep a stable spacer + content wrapper const spacerTop = document.createElement("div"); spacerTop.style.height = `${firstVisible * ROW_HEIGHT}px`; const spacerBottom = document.createElement("div"); spacerBottom.style.height = `${(items.length - firstVisible - slice.length) * ROW_HEIGHT}px`; container.appendChild(spacerTop); for (let i = 0; i < slice.length; i++) { container.appendChild(renderRow(slice[i], firstVisible + i)); } container.appendChild(spacerBottom); } ``` **1.2 Drive renders from `scroll` events** (passive listener) and `requestAnimationFrame`-throttled. Debounce on resize. Do *not* re-render on every WS update — instead mark rows dirty and only update the visible slice. **1.3 Index rows by track id** so targeted updates (e.g. a single track's cache status changes) patch a specific DOM node instead of rebuilding the list. **1.4 Move search filtering to the model layer**: maintain `M.libraryFiltered` as a computed array; the filter runs once per query (debounced ~150ms), and the windowed renderer reads from it. Never re-filter on every keystroke synchronously. Deliverable: a library that scrolls smoothly at 10k tracks. ### Phase 2 — Introduce a minimal reactive store Replace ad-hoc `M.x = …; M.render()` with a store that notifies subscribers. **2.1 Store shape** (new file `public/store.js`): ```js (function () { const M = window.MusicRoom; const state = {}; // the actual values const subs = new Map(); // key -> Set M.store = { get(key) { return state[key]; }, set(key, value) { if (Object.is(state[key], value)) return; // skip no-op state[key] = value; subs.get(key)?.forEach((cb) => cb(value)); }, update(key, fn) { M.store.set(key, fn(state[key])); }, on(key, cb) { if (!subs.has(key)) subs.set(key, new Set()); subs.get(key).add(cb); return () => subs.get(key)?.delete(cb); // unsubscribe }, }; })(); ``` **2.2 Migrate state field by field.** Don't do a big-bang rewrite. Start with the fields that change most and drive renders: `M.library`, `M.queue`, `M.cachedTracks`, `M.currentTrackId`. Each becomes `M.store.set("library", …)` and the renderer subscribes via `M.store.on("library", renderLibrary)`. Keep the `M.library` getter as a compatibility shim (`Object.defineProperty(M, "library", { get: () => M.store.get("library") })`) so unmigrated callers keep working during the transition. **2.3 Decouple renders from setters.** Today `audioCache.js` calls `M.renderQueue()` directly. After migration it just `M.store.set("cachedTracks", …)` and the queue renderer — which subscribed once — decides whether and what to re-render. Deliverable: a single source of truth per field, explicit update points, and the ability to add per-field logging/invariants cheaply. ### Phase 3 — Decompose `trackContainer.js` Split the 972-line file along its existing seams: - `trackList.js` — the windowed renderer (from Phase 1) + list-level keyboard nav. - `trackSelection.js` — the `selection`/`lastSelected` state (`trackContainer.js:11-22`) and click/shift-click range logic. - `trackDrag.js` — drag state (`trackContainer.js:8,24-31`), drop-zone rendering, queue reorder calls. - `trackContextMenu.js` — menu construction + DOM (`showContextMenuUI` at `:880-949`), built on top of the row events. Each becomes a small module that subscribes to the store (Phase 2) and exposes a narrow API on `M.tracks.*`. Deliverable: no file over ~400 lines in the client. ### Phase 4 — De-duplicate - **Playback setup**: extract a single `M.loadAndPlay(track, { seek: 0 })` used by `init.js`, `controls.js`, and both call sites in `trackContainer.js`. Today it's copy-pasted 4× (see assessment). - **Cookie helpers**: one `M.prefs.get/set/clear` (with `Secure` added — see XSS plan) replacing the three copies in `core.js`, `themes.js`, `visualizer.js`. - **`escapeHtml`**: single `M.escapeHtml` (already part of the XSS plan). - **Segment scan**: one `M.getBufferedSegments(audio, trackId)` used by `audioCache.js`, `ui.js`, `controls.js`. Deliverable: fewer copies, clearer ownership. ## Validation - **List performance**: load a fixture library of 10k tracks (synthesize or copy metadata rows). Measure initial render time, scroll FPS, and keystroke-to-render latency in DevTools Performance. Target: 60fps scroll, <100ms keystroke response. - **Correctness parity**: after each phase, verify against a manual checklist — play, pause, seek, jump, add-to-queue, remove-from-queue, drag-reorder, multi-select, context-menu actions, cache indicator updates, search filter, channel switch. Each phase must preserve all of these. - **Store**: add a temporary `M.store.on("library", (v) => console.count("library"))` and confirm the count matches expected update frequency (not 5× per WS message). - **No regressions in cache behavior**: the windowed render must still update a row's cache indicator when its blob completes — verify by playing a track and watching its row in a scrolled-out list update without a full re-render. - **Decomposition**: confirm no module exceeds ~400 lines and that each can be reasoned about in isolation. ## Risk / Rollback - This is the highest-risk item because it touches the most code. Mitigate by: - Shipping phases in order; each is independently mergeable and each is a checkpoint. - Keeping `M.library`-style getters as shims during Phase 2 so partial migrations don't break. - Behind a fallback: if windowing introduces scroll glitches, the non-windowed path can be kept as `M.renderLegacyList` and re-enabled until fixed. - No server or DB changes; rollback is purely client-side file reversion. - Do **not** attempt this before the XSS, cache-race, and WS plans land — those fix correctness bugs that a refactor would otherwise carry forward or obscure.