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

155 lines
9.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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