9.2 KiB
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:
-
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 mutatesMdirectly and callsM.render*(). There is no contract between modules; forward references are sometimes guarded withM.foo && M.foo()(channelSync.js:502), often not. -
Full re-render of every row on every change.
trackContainer.js:101-165clearsinnerHTMLand 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.trackdivs with fresh closures each time. Search (queue.js:196-204) filters the full array synchronously per keystroke. -
God module.
public/trackContainer.js(972 lines) fuses rendering, drag state, selection, drop-zones, and context-menu DOM. -
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 statepublic/trackContainer.js(972 lines) — render + drag + selection + context menupublic/trackContainer.js:101-165—render()full clear+rebuildpublic/ui.js:175-182— 5s cache-poll re-render triggerpublic/queue.js:196-204— synchronous per-keystroke filter- All IIFEs in
public/*.js— module pattern
Goals (in priority order)
- Virtualize the library list so 10k+ tracks render smoothly.
- Introduce a tiny reactive store so state changes are explicit and modules subscribe rather than mutate globals.
- Decompose
trackContainer.jsinto focused modules. - 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):
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):
(function () {
const M = window.MusicRoom;
const state = {}; // the actual values
const subs = new Map(); // key -> Set<callback>
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— theselection/lastSelectedstate (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 (showContextMenuUIat: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 byinit.js,controls.js, and both call sites intrackContainer.js. Today it's copy-pasted 4× (see assessment). - Cookie helpers: one
M.prefs.get/set/clear(withSecureadded — see XSS plan) replacing the three copies incore.js,themes.js,visualizer.js. escapeHtml: singleM.escapeHtml(already part of the XSS plan).- Segment scan: one
M.getBufferedSegments(audio, trackId)used byaudioCache.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.renderLegacyListand 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.