openvibers/openvibe.live Commits

Channel chat history: older scrollback goes above the newest 120 rows a piece at a time, not all 1000 rows in one idle task (2.7 s on a phone CPU, TBT 3.7 s on /@goosely); the cached first paint renders only its newest piece (#207)

OP OpenVibers committed 27 days ago · f67fe00aae · 2 files, +88 −36

Changes

ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex 04f0cfd6..e111b524 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -2241,19 +2241,10 @@ function setupFullscreenChatOverlay() {
}
/**
-- * History, without the jumps.
-- *
-- * It used to clear the container (the reader was thrown to the top), then append up to 500 rows
-- * one at a time, scrolling and measuring after each — hundreds of forced layouts — and on /chat it
-- * did all of that twice. Now:
-- *
-- * 1. The last copy of this room from localStorage paints at once (a reload shows the conversation
-- * immediately, not a spinner).
-- * 2. The fresh history is fetched and rendered into a detached element, then swapped in with one
-- * DOM operation that keeps the reader where they were (at the bottom, or the same distance from it).
-- * 3. Anything that arrived live while the fetch was in flight is re-added if history did not have it.
-- *
-- * A newer hydrate (room switch) makes an older one give up (generation check).
++ * History without jumps: (1) this room's last local copy paints at once; (2) the fetched history renders
++ * into a detached element and is swapped in with one DOM operation, the reader kept where they were, older
++ * rows added above a piece at a time; (3) rows that arrived live during the fetch are re-added. A newer
++ * hydrate (room switch) makes an older one give up (generation check).
*/
let _chatStaging = null;
let _chatHydrating = false;
@@@ -2261,7 +2252,7 @@ let _chatLiveDuringHydrate = [];
let _chatHydrateGen = 0;
let _fcwHistoryBuffer = null;
--function _renderHistoryOffscreen(chatEl, fill) {
++function _renderHistoryOffscreen(chatEl, fill, widget = true) {
const staging = document.createElement('div');
_chatStaging = { ...chatEl, messages: staging };
_fcwHistoryBuffer = [];
@@@ -2270,20 +2261,25 @@ function _renderHistoryOffscreen(chatEl, fill) {
try { fill(); } finally { _chatStaging = null; _loadingHistory = prevLoading; }
// The floating widget gets the same history, once, in the background (it is hidden on most
// pages; mirroring 500 rows into it one by one cost as much as rendering the main chat).
-- const forWidget = _fcwHistoryBuffer.slice(-200);
++ const forWidget = widget ? _fcwHistoryBuffer.slice(-200) : [];
_fcwHistoryBuffer = null;
if (forWidget.length) _idle(() => { const prev = _loadingHistory; _loadingHistory = true; try { forWidget.forEach((m) => _fcwAddMessage(m)); } finally { _loadingHistory = prev; } });
return staging;
}
const _idle = (fn) => (window.requestIdleCallback ? requestIdleCallback(fn, { timeout: 1200 }) : setTimeout(fn, 60));
--/** The newest `n` rows of a payload: what fits on screen, rendered first. */
--function _historyTail(p, n) {
-- if (!p || !p.data) return p;
-- const t = (a) => (Array.isArray(a) && a.length > n ? a.slice(-n) : a);
-- return { ...p, data: { ...p.data, messages: t(p.data.messages) }, globalMsgs: t(p.globalMsgs) };
++/** A payload as pieces of `n` rows in time order (as the renderer merges them), newest piece first. */
++function _historyPieces(p, n) {
++ if (!p || !p.data) return [p];
++ const t = (m) => { const d = Date.parse(String(m.timestamp || '').replace(' ', 'T')); return isNaN(d) ? 0 : d; };
++ const all = (p.data.messages || []).map((m) => [m, 0]).concat((p.globalMsgs || []).map((m) => [m, 1])).sort((a, b) => t(a[0]) - t(b[0]));
++ const out = [];
++ for (let end = all.length; end > 0; end -= n) {
++ const part = all.slice(Math.max(0, end - n), end);
++ out.push({ ...p, data: { ...p.data, messages: part.filter((x) => !x[1]).map((x) => x[0]) }, globalMsgs: part.filter((x) => x[1]).map((x) => x[0]) });
++ }
++ return out.length ? out : [p];
}
--const _payloadSize = (p) => ((p && p.data && p.data.messages) ? p.data.messages.length : 0) + ((p && p.globalMsgs) ? p.globalMsgs.length : 0);
function _swapChatHistory(container, staging) {
const pinned = _chatPinned || !container.children.length;
@@@ -2412,7 +2408,7 @@ async function hydrateActiveChatHistory(streamId, { clear = false } = {}) {
const cached = _chatCacheRead(room);
if (cached) {
_chatPinned = true;
-- _swapChatHistory(messages, _renderHistoryOffscreen(chatEl, () => _renderHistoryPayload(cached)));
++ _swapChatHistory(messages, _renderHistoryOffscreen(chatEl, () => _renderHistoryPayload(_historyPieces(cached, 120)[0])));
messages.dataset.room = room;
} else if (clear || roomChanged) {
messages.innerHTML = '<div class="chat-loading-history"><i class="fa-solid fa-spinner fa-spin"></i> Loading history…</div>';
@@@ -2439,26 +2435,25 @@ async function hydrateActiveChatHistory(streamId, { clear = false } = {}) {
if (now.messages !== messages) return; // the page changed under us
if (payload) {
-- // What fits on screen first; the rest of the scrollback follows when the browser is idle.
-- const TAIL = 120;
-- const big = _payloadSize(payload) > TAIL + 40;
-- _swapChatHistory(messages, _renderHistoryOffscreen(now, () => _renderHistoryPayload(big ? _historyTail(payload, TAIL) : payload)));
++ // The newest 120 rows first; older scrollback is put above them a piece per idle moment (all 1000 rows
++ // in one go was a 2.7 s task on a phone), keeping the reader where they are.
++ const pieces = _historyPieces(payload, 120);
++ _swapChatHistory(messages, _renderHistoryOffscreen(now, () => _renderHistoryPayload(pieces[0])));
messages.dataset.room = room;
_chatCacheWrite(room, payload);
// 3. Live messages that raced the fetch.
const late = _chatLiveDuringHydrate;
_chatLiveDuringHydrate = [];
late.forEach((m) => addChatMessage(m));
-- if (big) {
-- _idle(() => {
-- if (gen !== _chatHydrateGen || getChatEl().messages !== messages) return;
-- const liveTail = [...messages.querySelectorAll(':scope > .chat-msg-new')];
-- _swapChatHistory(messages, _renderHistoryOffscreen(getChatEl(), () => _renderHistoryPayload(payload)));
-- // Rows that arrived live after the first paint stay.
-- liveTail.forEach((el) => { if (!el.dataset.msgId || !messages.querySelector(`[data-msg-id="${CSS.escape(el.dataset.msgId)}"]`)) messages.appendChild(el); });
-- if (_chatPinned) messages.scrollTop = messages.scrollHeight;
-- });
-- }
++ const older = (i) => i < pieces.length && _idle(() => {
++ if (gen !== _chatHydrateGen || getChatEl().messages !== messages) return;
++ const rows = _renderHistoryOffscreen(getChatEl(), () => _renderHistoryPayload(pieces[i]), false);
++ const top = messages.scrollTop, h = messages.scrollHeight;
++ messages.prepend(...rows.childNodes);
++ messages.scrollTop = _chatPinned ? messages.scrollHeight : top + messages.scrollHeight - h;
++ older(i + 1);
++ });
++ older(1);
} else {
messages.querySelector(':scope > .chat-loading-history')?.remove();
}
ddiff --git a/test/chat-history-pieces.test.js b/test/chat-history-pieces.test.js
nnew file mode 100644
iindex 00000000..48570df7
---- /dev/null
++++ b/test/chat-history-pieces.test.js
@@@ -0,0 +1,57 @@
++'use strict';
++/**
++ * Chat history on a channel page (public/js/chat.js hydrateActiveChatHistory): the newest 120 rows render first and
++ * the older scrollback is put above them a piece at a time. Rendering all of it (up to 500 channel + 500 global rows)
++ * in one idle callback was a 2.7 s task on a phone-speed CPU (2026-10-17 trace of /@goosely).
++ *
++ * _historyPieces splits a payload by time, in the order the renderer merges channel and global rows: every row in
++ * exactly one piece, the newest piece first, each piece in time order.
++ *
++ * node test/chat-history-pieces.test.js
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const src = fs.readFileSync(path.join(__dirname, '../public/js/chat.js'), 'utf8');
++const start = src.indexOf('function _historyPieces(');
++assert.ok(start > 0, '_historyPieces is in chat.js');
++let depth = 0, end = src.indexOf('{', start);
++for (let i = end; i < src.length; i++) {
++ if (src[i] === '{') depth++;
++ else if (src[i] === '}' && --depth === 0) { end = i + 1; break; }
++}
++const _historyPieces = new Function(`${src.slice(start, end)}; return _historyPieces;`)();
++
++const ts = (n) => `2026-10-17 10:${String(Math.floor(n / 60)).padStart(2, '0')}:${String(n % 60).padStart(2, '0')}`;
++const channel = Array.from({ length: 300 }, (_, i) => ({ id: `c${i}`, timestamp: ts(i * 2) }));
++const global = Array.from({ length: 250 }, (_, i) => ({ id: `g${i}`, timestamp: ts(i * 2 + 1) }));
++const payload = { kind: 'channel', data: { messages: channel, liveSlots: [1], channel: 'goosely' }, globalMsgs: global };
++
++const pieces = _historyPieces(payload, 120);
++assert.strictEqual(pieces.length, Math.ceil(550 / 120));
++const rows = (p) => p.data.messages.length + p.globalMsgs.length;
++assert.deepStrictEqual(pieces.map(rows), [120, 120, 120, 120, 70], 'pieces of 120, the oldest takes the rest');
++// Every row exactly once.
++const ids = pieces.flatMap((p) => [...p.data.messages, ...p.globalMsgs].map((m) => m.id));
++assert.strictEqual(new Set(ids).size, 550);
++// The newest piece first: its oldest row is newer than every row of the next piece.
++const tOf = (m) => Date.parse(m.timestamp.replace(' ', 'T'));
++for (let i = 0; i + 1 < pieces.length; i++) {
++ const oldestHere = Math.min(...[...pieces[i].data.messages, ...pieces[i].globalMsgs].map(tOf));
++ const newestNext = Math.max(...[...pieces[i + 1].data.messages, ...pieces[i + 1].globalMsgs].map(tOf));
++ assert.ok(oldestHere > newestNext, `piece ${i} is newer than piece ${i + 1}`);
++}
++// The newest piece holds the newest rows of both feeds; the payload's other fields ride along.
++assert.ok(pieces[0].data.messages.some((m) => m.id === 'c299') && pieces[0].globalMsgs.some((m) => m.id === 'g249'));
++assert.deepStrictEqual([pieces[0].kind, pieces[0].data.channel, pieces[0].data.liveSlots], ['channel', 'goosely', [1]]);
++
++// The global room has no second feed; a small payload is one piece; an empty one is itself.
++const g = _historyPieces({ kind: 'global', data: { messages: global.slice(0, 50) } }, 120);
++assert.strictEqual(g.length, 1);
++assert.strictEqual(g[0].data.messages.length, 50);
++const empty = { kind: 'channel', data: { messages: [] }, globalMsgs: [] };
++assert.deepStrictEqual(_historyPieces(empty, 120), [empty]);
++assert.deepStrictEqual(_historyPieces(null, 120), [null]);
++
++console.log('chat history pieces: all checks passed');