blastoise/todo/client-store-and-windowing/overview.md

9.2 KiB
Raw Blame History

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-165render() 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):

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 — 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.