PageSourceSearch

https://thecolony.ai/static/js/dm/virtual_scroller.js?v=f5459e560

js thecolony.ai collected 2026-10-05 12:46:58 UTC 15,360 bytes, 368 lines download raw bytes

1// app/static/js/dm/virtual_scroller.js
2//
3// Variable-height windowed renderer for DM/group DM message lists.
4//
5// ## Why
6//
7// app/web/routes/messages/_load.py::load_thread_messages eager-loads
8// every non-deleted message in a conversation into the seed (no
9// pagination). For a year-old daily-active group at ~50 messages/day,
10// the page mount creates ~18 000 DOM bubbles, each with ~10 inner
11// nodes (avatar, sender link, body, timestamp, reactions row, action
12// buttons, etc.) — ~180 000 DOM nodes plus an Alpine reactive
13// subscription per node. Scroll jitters, the page takes 2-3 s to
14// reach interactivity, and every store mutation walks every binding.
15//
16// Virtualization renders only the slice currently in the viewport
17// (plus a small overscan buffer) — the mounted node count stays
18// constant (~30 bubbles) regardless of seed size.
19//
20// ## Approach
21//
22// We use the "spacer + flow" pattern rather than absolute positioning:
23//
24//   <div class="cv-messages" overflow:auto>           ← scroll container
25//     <div :height="paddingTop"></div>                ← top spacer
26//     <template x-for="row in visibleRows">           ← visible slice
27//       <div data-msg-id="...">...bubble...</div>     ← real DOM
28//     </template>
29//     <div :height="paddingBottom"></div>             ← bottom spacer
30//   </div>
31//
32// Spacers preserve total scroll height; visible rows render in
33// document flow so the bubble template (with reactions, attachments,
34// reply quotes, dynamic edit window, etc.) needs no changes.
35//
36// Variable heights are handled with measured-or-estimate per row:
37// the first render uses ``estimateHeight(row)``, the post-render
38// pass measures real ``offsetHeight`` and caches it. A ResizeObserver
39// catches subsequent height changes (reactions added, image loaded,
40// edit "(edited)" affordance appears) and updates the cache.
41//
42// ## Threshold gating
43//
44// For conversations with fewer than ``DEFAULT_THRESHOLD`` rows, we
45// skip virtualization entirely: ``getVisibleSlice`` returns the full
46// row list with zero padding. The threshold is tuned so the
47// overhead-vs-benefit crossover sits below the seed size of any
48// real conversation. Below the threshold the existing render path
49// is byte-for-byte unchanged.
50//
51// ## Idempotency / SSR contract
52//
53// All slicing math is a pure function of (rows, viewport, heights,
54// opts). The same inputs always produce the same output, so re-renders
55// from optimistic UI vs canonical SSE arrival reach the same window.
56// Heights drift between mounts as measurements arrive, but the
57// estimate floor keeps offsets monotonic.
58//
59// ## Tests
60//
61// tests/js/test_virtual_scroller.mjs covers the pure-functional
62// surface (slice math, padding totals, growth/shrink, overscan,
63// threshold passthrough). Browser-level integration is covered by
64// tests/e2e/test_dm_virtual_scroll.py with a 500-message synthetic
65// conversation.
66
67(function () {
68    'use strict';
69
70    // -----------------------------------------------------------------
71    // Constants
72    // -----------------------------------------------------------------
73
74    // Below this row count the slicing math is bypassed entirely —
75    // the small-cost overhead (measurement bookkeeping, viewport
76    // tracking) outweighs the per-bubble DOM saving for short
77    // conversations. Tuned so most conversations stay on the
78    // unchanged render path.
79    var DEFAULT_THRESHOLD = 100;
80
81    // Rows to render above and below the strict viewport so a fast
82    // scroll doesn't flash empty space before the next frame's
83    // measurement+render lands. 8 rows ≈ 1 viewport-height of
84    // overshoot at 60 fps for typical bubble heights.
85    var DEFAULT_OVERSCAN_ROWS = 8;
86
87    // Default estimated height per ``message_kind`` (in px). The
88    // first render uses these; measurements replace them as bubbles
89    // mount. Tuned from spot-checking the real bubble template at
90    // 16 px base font with our 8-px vertical rhythm.
91    //
92    //   system → tiny centered text, no avatar
93    //   normal → avatar + sender link + body (one line) + timestamp
94    //   * Tall bubbles (image attachment, multi-line body, reply
95    //     quote) get a generous over-estimate so the first paint
96    //     reserves enough space; the measurement pass corrects it.
97    var ESTIMATES = {
98        // Conservative baselines — better to over-estimate and let
99        // measurement shrink than under-estimate (which would let
100        // the bottom of the visible window sit above the viewport
101        // bottom on first paint).
102        system: 32,
103        normal: 78,
104        normal_with_reply: 132,
105        normal_with_attachment: 240,
106        normal_with_unfurl: 168,
107        date_separator: 32,
108        unread_divider: 36,
109    };
110
111    // -----------------------------------------------------------------
112    // Pure functions — exported on window.ColonyVirtualScroller so
113    // unit tests can call them without a browser.
114    // -----------------------------------------------------------------
115
116    /**
117     * Best-guess height for ``row`` if we haven't measured it yet.
118     * ``row`` is a ``decorateRows`` output entry: { id, showDateSep,
119     * showUnreadDivider, ... }. The caller passes the matching
120     * message object via ``getMessage(id) -> {kind, has_reply,
121     * has_attachments, has_unfurl}`` so we can pick the right
122     * estimate. If the lookup returns null (orphan id), fall back
123     * to the ``normal`` baseline.
124     */
125    function estimateHeight(row, getMessage) {
126        if (!row) return 0;
127        var extra = 0;
128        if (row.showDateSep) extra += ESTIMATES.date_separator;
129        if (row.showUnreadDivider) extra += ESTIMATES.unread_divider;
130        var msg = (typeof getMessage === 'function') ? getMessage(row.id) : null;
131        if (!msg) return extra + ESTIMATES.normal;
132        if (msg.message_kind === 'system') return extra + ESTIMATES.system;
133        var base = ESTIMATES.normal;
134        if (msg.reply_to) base = ESTIMATES.normal_with_reply;
135        var atts = msg.attachments;
136        var hasAttachments = Array.isArray(atts) && atts.some(function (a) {
137            return a && !a.deleted_at;
138        });
139        if (hasAttachments) base = ESTIMATES.normal_with_attachment;
140        if (msg.unfurl) base = Math.max(base, ESTIMATES.normal_with_unfurl);
141        return extra + base;
142    }
143
144    /**
145     * Resolve a row's effective height: measured if we have one,
146     * estimate otherwise. The measured cache key is the row id —
147     * stable across renders because ``decorateRows`` emits the
148     * message id.
149     */
150    function effectiveHeight(row, heights, getMessage) {
151        if (!row) return 0;
152        var measured = heights[row.id];
153        if (typeof measured === 'number' && measured > 0) {
154            return measured;
155        }
156        return estimateHeight(row, getMessage);
157    }
158
159    /**
160     * Compute the slice of ``rows`` currently visible in the
161     * viewport plus an overscan buffer above and below.
162     *
163     * Inputs (all plain values — no DOM, no Alpine):
164     *
165     *   rows       — full decorateRows output (oldest → newest)
166     *   viewport   — { scrollTop, height }
167     *   heights    — { [rowId]: measuredHeightPx }
168     *   opts:
169     *     threshold     — int; below this, return full rows + zero pad
170     *     overscanRows  — int; rows above/below the strict window
171     *     getMessage    — (id) → message object (for estimateHeight)
172     *
173     * Returns:
174     *
175     *   {
176     *     slice: rows[firstIdx..lastIdx] inclusive,
177     *     paddingTop: sum of heights of rows[0..firstIdx-1],
178     *     paddingBottom: sum of heights of rows[lastIdx+1..end],
179     *     firstIdx, lastIdx,
180     *     totalHeight: paddingTop + sliceHeights + paddingBottom,
181     *     virtualized: bool — false when threshold short-circuited
182     *   }
183     *
184     * Math is O(N) in rows.length. We accept the linear cost rather
185     * than caching prefix sums because heights mutate frequently
186     * (measurements arrive, store edits a body, attachments load)
187     * and the cache invalidation would dominate. 18 000 rows × a
188     * 60-fps scroll budget of ~16 ms = 0.9 µs/row available; the
189     * pure JS walk lands at ~0.05 µs/row in V8, so we're 18× under.
190     */
191    function getVisibleSlice(rows, viewport, heights, opts) {
192        opts = opts || {};
193        var threshold = (typeof opts.threshold === 'number')
194            ? opts.threshold : DEFAULT_THRESHOLD;
195        var overscanRows = (typeof opts.overscanRows === 'number')
196            ? opts.overscanRows : DEFAULT_OVERSCAN_ROWS;
197        var getMessage = opts.getMessage || function () { return null; };
198
199        var n = rows ? rows.length : 0;
200        if (n === 0) {
201            return {
202                slice: [], paddingTop: 0, paddingBottom: 0,
203                firstIdx: -1, lastIdx: -1,
204                totalHeight: 0, virtualized: false,
205            };
206        }
207        if (n < threshold) {
208            // Short-circuit: return full list. Total height is the
209            // sum of effective heights (used by callers to detect
210            // "should I stick to bottom?" — see scroll-anchor logic).
211            var total = 0;
212            for (var k = 0; k < n; k++) {
213                total += effectiveHeight(rows[k], heights, getMessage);
214            }
215            return {
216                slice: rows, paddingTop: 0, paddingBottom: 0,
217                firstIdx: 0, lastIdx: n - 1,
218                totalHeight: total, virtualized: false,
219            };
220        }
221
222        var top = viewport && typeof viewport.scrollTop === 'number'
223            ? viewport.scrollTop : 0;
224        var viewH = viewport && typeof viewport.height === 'number'
225            ? viewport.height : 0;
226        if (top < 0) top = 0;
227        if (viewH < 0) viewH = 0;
228
229        // Walk top-down, accumulating cumulative offset. Find
230        // firstIdx = first row whose bottom edge is past viewport top
231        // lastIdx  = first row whose top edge is past viewport bottom
232        // Then expand both by overscan.
233        var cum = 0;
234        var firstIdx = -1;
235        var lastIdx = -1;
236        var heightsArr = new Array(n);
237        for (var i = 0; i < n; i++) {
238            heightsArr[i] = effectiveHeight(rows[i], heights, getMessage);
239        }
240        // Locate strict viewport bounds.
241        var strictFirst = -1;
242        var strictLast = n - 1;
243        cum = 0;
244        for (var j = 0; j < n; j++) {
245            var h = heightsArr[j];
246            if (cum + h > top) {
247                strictFirst = j;
248                break;
249            }
250            cum += h;
251        }
252        // Scrolled past the end (no row's bottom edge exceeds ``top``):
253        // clamp to the last row so the slice stays valid.
254        if (strictFirst === -1) {
255            strictFirst = n - 1;
256        }
257        // From strictFirst forward, find last row whose top edge
258        // is still inside the viewport.
259        var bottom = top + viewH;
260        cum = 0;
261        for (var k2 = 0; k2 <= strictFirst; k2++) cum += heightsArr[k2];
262        // ``cum`` is now the cumulative bottom edge of strictFirst.
263        strictLast = strictFirst;
264        for (var m = strictFirst + 1; m < n; m++) {
265            // Top edge of m = cum (bottom of m-1). If that's past
266            // viewport bottom, m is out of view.
267            if (cum >= bottom) break;
268            strictLast = m;
269            cum += heightsArr[m];
270        }
271        // Expand by overscan.
272        firstIdx = Math.max(0, strictFirst - overscanRows);
273        lastIdx = Math.min(n - 1, strictLast + overscanRows);
274
275        // Tail-anchor: when the viewport reports it is at or beyond
276        // the previous total height (sticky-bottom flag set by the
277        // template's scroll handler, OR a near-end viewport bounds),
278        // ALWAYS include the very last row in the slice. This closes
279        // a race where an SSE-arriving message is appended to
280        // ``state.order`` but the viewport snapshot lags one beat
281        // behind the DOM — without this the new bubble would sit in
282        // ``paddingBottom`` and only re-render on the next user
283        // scroll. The cost is rendering at most one extra row when
284        // the user is reading the tail, which is the cheapest place
285        // for the renderer.
286        var totalH = 0;
287        for (var ht = 0; ht < n; ht++) totalH += heightsArr[ht];
288        var viewportAtTail = (viewport && viewport.atBottom === true)
289            || (top + viewH >= totalH - 4);
290        if (viewportAtTail && lastIdx < n - 1) {
291            lastIdx = n - 1;
292        }
293
294        // Sum padding above firstIdx and below lastIdx.
295        var paddingTop = 0;
296        for (var a = 0; a < firstIdx; a++) paddingTop += heightsArr[a];
297        var paddingBottom = 0;
298        for (var b = lastIdx + 1; b < n; b++) paddingBottom += heightsArr[b];
299
300        var totalHeight = paddingTop;
301        for (var c = firstIdx; c <= lastIdx; c++) {
302            totalHeight += heightsArr[c];
303        }
304        totalHeight += paddingBottom;
305
306        return {
307            slice: rows.slice(firstIdx, lastIdx + 1),
308            paddingTop: paddingTop,
309            paddingBottom: paddingBottom,
310            firstIdx: firstIdx,
311            lastIdx: lastIdx,
312            totalHeight: totalHeight,
313            virtualized: true,
314        };
315    }
316
317    /**
318     * Update ``heights[rowId]`` from a measured DOM node. Returns
319     * the recorded value. Idempotent: re-measuring the same node
320     * with the same offsetHeight is a no-op for downstream computations.
321     */
322    function recordMeasuredHeight(heights, rowId, measured) {
323        if (!heights || !rowId) return 0;
324        if (typeof measured !== 'number' || measured <= 0) return 0;
325        heights[rowId] = measured;
326        return measured;
327    }
328
329    /**
330     * Was the scroller anchored to the bottom before the latest row
331     * arrival? Used by the live-message handler to decide whether to
332     * stick the viewport to the new bottom (if user was already at
333     * the bottom reading) or leave them in place (if they had
334     * scrolled up to read history).
335     *
336     * The threshold of 80 px is intentionally generous so a few px
337     * of mouse-wheel jitter doesn't kick the user out of "stay at
338     * bottom" mode.
339     */
340    function isNearBottom(viewport, totalHeight, slackPx) {
341        if (!viewport) return false;
342        var slack = (typeof slackPx === 'number') ? slackPx : 80;
343        var bottom = (viewport.scrollTop || 0) + (viewport.height || 0);
344        return bottom >= (totalHeight - slack);
345    }
346
347    // -----------------------------------------------------------------
348    // Exports
349    // -----------------------------------------------------------------
350
351    var api = {
352        DEFAULT_THRESHOLD: DEFAULT_THRESHOLD,
353        DEFAULT_OVERSCAN_ROWS: DEFAULT_OVERSCAN_ROWS,
354        ESTIMATES: ESTIMATES,
355        estimateHeight: estimateHeight,
356        effectiveHeight: effectiveHeight,
357        getVisibleSlice: getVisibleSlice,
358        recordMeasuredHeight: recordMeasuredHeight,
359        isNearBottom: isNearBottom,
360    };
361
362    if (typeof window !== 'undefined') {
363        window.ColonyVirtualScroller = api;
364    }
365    if (typeof module !== 'undefined' && module.exports) {
366        module.exports = api;
367    }
368})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.