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>
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 { |