eleven / ocho Public

web: cache rendered feeds so switching chats is instant (no flash) (#31)

Opening a thread cleared #feed and re-fetched /api/messages every time,
so every switch flashed empty for the network round-trip and reset the
scroll, and re-tapping the open chat rebuilt it needlessly.

Keep each opened thread's rendered feed alive instead. On leaving a
thread its message nodes are parked in a DocumentFragment (images stay
decoded, listeners intact) and its scroll offset saved; re-opening
re-attaches them instantly — no fetch, no flash, scroll preserved — then
a lightweight background catch-up appends anything that arrived while it
was in the background. Re-tapping the already-open thread is now a no-op
(just reveals the pane on mobile), so no rebuild and no wiped draft.

Each thread owns its own state maps (msgEls/msgData/reply counts/
myReactions); the module globals simply alias the open thread's entry,
so all the existing render/update code is unchanged.

Known follow-up: edits/deletes/reactions that land on a *background*
cached thread aren't reconciled on reopen (only new messages are
appended) — same gap the offline catch-up has; can extend live updates
to cached threads if we want perfect consistency.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell 523b70b29f053587cea6eacec4c4afaec1cd4349 parent 2d5df28
1 file changed, +108 −27
  • web/static/chat.js +108 −27
diff --git a/web/static/chat.js b/web/static/chat.js
index a9e682d..13eaa18 100644
--- a/web/static/chat.js
+++ b/web/static/chat.js
@@ -226,15 +226,18 @@ const ICON_TRASH =
'<polyline points="3 6 5 6 21 6"></polyline>' +
'<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>';
+// These five always ALIAS the open thread's feed-cache entry (see feedCache /
+// bindThreadState). All the render/update code operates on "the active thread"
+// through them; switching threads just re-points them at another cache entry.
let msgEls = {}; // message id -> its .msg element (for live reaction/edit updates)
let msgData = {}; // message id -> { mine, createdAt, data, replyMeta } (edit/reply)
// Reply threading, keyed by the parent's message id: how many replies it has
// (drives the "N Replies" tag) and the tag element to repaint as they arrive.
let replyCounts = {};
let replyCountEls = {};
+let myReactions = new Set(); // "<msgId>|<emoji>" the current user has reacted with
let editing = null; // message id being edited, or null
let replyingTo = null; // { id, name, snippet } being replied to, or null
-const myReactions = new Set(); // "<msgId>|<emoji>" the current user has reacted with
// Typing presence (for the open thread only): sender name -> epoch ms at which
// their "typing" lapses, plus the timers driving our own beat and the receiver
@@ -968,9 +971,61 @@ function refreshAvatars() {
let openSeq = 0; // bumped each openThread so a slow history load bails if you switch away
+// Per-thread rendered-feed cache. Switching chats used to clear #feed and
+// re-fetch every time, which flashed empty for the network round-trip and reset
+// the scroll. Instead we keep each opened thread's message nodes alive: when you
+// leave a thread its nodes are parked in a DocumentFragment (images stay decoded,
+// listeners intact) and its scroll offset saved; re-opening re-attaches them
+// instantly, then catches up in the background. Each entry also owns the state
+// maps that index its nodes; the module globals alias the open entry's maps.
+const feedCache = {}; // threadId -> cache entry (see newFeedCache)
+
+function newFeedCache() {
+ return {
+ nodes: null, // parked message nodes while this thread isn't the open one
+ scrollTop: null, // saved scroll offset (null → pin to bottom on first show)
+ loaded: false, // has the initial history load completed at least once?
+ msgEls: {},
+ msgData: {},
+ replyCounts: {},
+ replyCountEls: {},
+ myReactions: new Set(),
+ };
+}
+
+// Re-point the active-thread globals at a cache entry's state.
+function bindThreadState(c) {
+ msgEls = c.msgEls;
+ msgData = c.msgData;
+ replyCounts = c.replyCounts;
+ replyCountEls = c.replyCountEls;
+ myReactions = c.myReactions;
+}
+
+// Detach the open thread's nodes into its cache entry (kept alive in a fragment)
+// and remember where it was scrolled, so another thread can take the #feed slot.
+function stashFeed(id) {
+ const c = feedCache[id];
+ const feed = document.getElementById("feed");
+ if (!c || !feed) return;
+ c.scrollTop = feed.scrollTop;
+ const frag = document.createDocumentFragment();
+ while (feed.firstChild) frag.appendChild(feed.firstChild);
+ c.nodes = frag;
+}
+
async function openThread(id) {
+ // Re-tapping the thread that's already open must not rebuild it — just reveal
+ // the pane (mobile navigates list → thread). No flash, no wiped draft.
+ if (id === activeId) {
+ document.getElementById("tt").classList.add("show-thread");
+ if (!isCoarse) document.getElementById("text").focus();
+ return;
+ }
stopTyping(); // stop beating into the thread we're leaving
resetTyping(); // clear any "X is typing…" carried over from another thread
+ if (activeId) stashFeed(activeId); // park the outgoing thread's feed before the swap
+
const myOpen = ++openSeq;
activeId = id;
unread[id] = false;
@@ -991,27 +1046,42 @@ async function openThread(id) {
document.getElementById("tt").classList.add("show-thread");
renderThreadList();
- const feed = document.getElementById("feed");
- feed.innerHTML = "";
- msgEls = {};
- msgData = {};
- replyCounts = {};
- replyCountEls = {};
- myReactions.clear(); // per-thread highlight; repopulated from each message's `mine`
- clearPending(); // don't carry a staged photo (encrypted to the old thread's key) over
+ // The composer is never carried between threads: a staged photo is encrypted to
+ // the old thread's key, and a half-typed line belongs to the thread you left.
+ clearPending();
cancelEdit();
cancelReply();
const composerText = document.getElementById("text");
if (composerText) {
- composerText.value = ""; // drop any half-typed text from the previous thread
+ composerText.value = "";
composerText.dispatchEvent(new Event("input"));
}
+
+ const feed = document.getElementById("feed");
+ const cached = feedCache[id];
+ if (cached && cached.loaded) {
+ // Instant restore: re-attach the parked nodes, no fetch, no flash, scroll kept.
+ bindThreadState(cached);
+ feed.innerHTML = "";
+ if (cached.nodes) feed.appendChild(cached.nodes);
+ cached.nodes = null;
+ feed.scrollTop = cached.scrollTop != null ? cached.scrollTop : feed.scrollHeight;
+ if (!isCoarse) composerText.focus();
+ catchUpActiveMessages(); // background: append anything that arrived while it was away
+ return;
+ }
+
+ // First open of this thread: build the feed from scratch.
+ const cache = (feedCache[id] = newFeedCache());
+ bindThreadState(cache);
+ feed.innerHTML = "";
try {
const r = await (
await fetch(`/api/messages?token=${encodeURIComponent(session.token)}&thread=${encodeURIComponent(id)}`)
).json();
if (myOpen !== openSeq) return; // switched threads while loading — abandon this load
for (const m of r.messages || []) await appendMessage(id, m, false);
+ cache.loaded = true;
} catch (e) {
console.error("load messages", e);
}
@@ -1897,23 +1967,15 @@ function showConnStatus(text) {
el.classList.toggle("hidden", !text);
}
-// Catch up on messages missed while we were offline. The WebSocket only relays
-// *live* messages — nothing is replayed on connect — so anything that arrived
-// while the socket was down (phone asleep, tab hidden, a tapped notification
-// re-focusing a now-stale tab) never reaches the open feed until you leave and
-// re-enter the thread. Called on reconnect and on refocus: refresh the thread
-// list (previews / last-activity / any new threads) and append any messages the
-// open feed is missing. Incremental (append-only) so it doesn't clear the feed
-// or fight the scroll position when there's nothing new.
-let resyncing = false;
-async function resyncActive() {
- if (resyncing || !session) return;
- resyncing = true;
+// Append any messages the open feed is missing, without clearing it. Used both
+// for the offline catch-up (resyncActive) and when re-showing a cached thread
+// (live messages that arrived for a background thread aren't appended to its
+// parked feed, so we reconcile on reopen). Incremental/append-only so it never
+// flashes or fights the scroll position when there's nothing new.
+async function catchUpActiveMessages() {
+ const id = activeId;
+ if (!id || !threadKeys[id]) return;
try {
- await loadThreads();
- renderThreadList();
- const id = activeId;
- if (!id || !threadKeys[id]) return;
const r = await (
await fetch(`/api/messages?token=${encodeURIComponent(session.token)}&thread=${encodeURIComponent(id)}`)
).json();
@@ -1926,8 +1988,27 @@ async function resyncActive() {
added++;
}
// Only jump to the newest when we actually appended something, so a plain
- // refocus doesn't yank someone out of the history they were reading.
+ // reopen/refocus doesn't yank someone out of the history they were reading.
if (added && id === activeId && feed) pinFeedToBottom(feed, openSeq);
+ } catch (e) {
+ console.error("catch up messages", e);
+ }
+}
+
+// Catch up on messages missed while we were offline. The WebSocket only relays
+// *live* messages — nothing is replayed on connect — so anything that arrived
+// while the socket was down (phone asleep, tab hidden, a tapped notification
+// re-focusing a now-stale tab) never reaches the open feed until you leave and
+// re-enter the thread. Called on reconnect and on refocus: refresh the thread
+// list (previews / last-activity / any new threads), then reconcile the open feed.
+let resyncing = false;
+async function resyncActive() {
+ if (resyncing || !session) return;
+ resyncing = true;
+ try {
+ await loadThreads();
+ renderThreadList();
+ await catchUpActiveMessages();
} catch (e) {
console.error("resync", e);
} finally {