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.