eleven / messenger Public

web: fix first-open layout shift + prebuild visible chats (#49)

Opening a chat for the first time flashed and juddered: images reserved no
space until their blob decrypted (bubbles collapsed, then jumped apart), and
the feed was built one message at a time on-screen (messages stacked from the
top, then snapped to the bottom once the load finished).

- Reserve each image's box up front (definite width + aspect-ratio) so layout
  is stable before the photo decrypts.
- Build the first-open history off-DOM in a fragment, attach in one shot.
- Warm above-the-fold chats into feedCache in the background so the first tap
  into any of them is the instant-restore path (no fetch flash).

appendMessage / renderReactions / bump+paintReplyCount now take an explicit
cache entry, so a background prebuild writes only into its own thread's state,
never the open thread's globals.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell 24ec129b306aa72de239442a43d5d40daf69032a parent 4d433e8
1 file changed, +119 −24
  • web/static/chat.js +119 −24
diff --git a/web/static/chat.js b/web/static/chat.js
index 0c8dd0c..4f66b81 100644
--- a/web/static/chat.js
+++ b/web/static/chat.js
@@ -311,6 +311,9 @@ async function boot() {
// Reveal only once the initial feed is loaded + scrolled — so the first paint
// is the finished layout, not an empty feed that then jumps to the bottom.
await opened;
+ // With the open thread in place, warm the other visible chats in the background
+ // so the first tap into any of them is instant (no fetch flash).
+ prebuildVisibleThreads();
} catch (e) {
console.error(e);
app.innerHTML =
@@ -871,6 +874,7 @@ function renderThreadList() {
for (const t of shown) {
const row = document.createElement("button");
row.className = "thread-row" + (t.id === activeId ? " active" : "");
+ row.dataset.id = t.id; // lets prebuildVisibleThreads map a visible row to its thread
// DM/self rows show the person's profile photo; groups use initials (group
// photos are thread-scoped — phase 2).
@@ -1208,7 +1212,15 @@ async function openThread(id) {
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);
+ // Build the history off-DOM, then attach it in one shot. Appending live (one
+ // node per awaited decrypt) reflowed #feed on every message and left it pinned
+ // to the top until the whole load finished, then snapped to the bottom — a
+ // cascade of visible layout shift on first open. A detached fragment never
+ // paints mid-build, so the feed appears complete, already at the latest message.
+ const frag = document.createDocumentFragment();
+ for (const m of r.messages || []) await appendMessage(id, m, false, frag, cache);
+ if (myOpen !== openSeq) return; // switched away mid-build — don't attach a stale feed
+ feed.appendChild(frag);
cache.loaded = true;
} catch (e) {
console.error("load messages", e);
@@ -1244,8 +1256,69 @@ function pinFeedToBottom(feed, gen) {
requestAnimationFrame(tick);
}
-async function appendMessage(threadId, m, scroll = true) {
- if (threadId !== activeId) return;
+const PREBUILD_MAX = 10; // cap on chats warmed on load (a safety net over "visible")
+
+// Build a thread's feed off-screen into its feedCache entry, so the *first* tap
+// into it is the instant-restore path in openThread — no fetch round-trip, no
+// flash. Idempotent, and isolated from the open thread: it writes only into this
+// thread's own cache entry (the explicit `cache` arg threaded through
+// appendMessage), never the active-thread globals, so it's safe to run in the
+// background while another chat is open.
+async function prebuildThread(id) {
+ if (id === activeId || feedCache[id] || !threadKeys[id]) return;
+ const cache = (feedCache[id] = newFeedCache()); // claim the slot so nothing double-builds
+ try {
+ const r = await (
+ await fetch(`/api/messages?token=${encodeURIComponent(session.token)}&thread=${encodeURIComponent(id)}`)
+ ).json();
+ if (feedCache[id] !== cache) return; // opened for real meanwhile — let that build win
+ const frag = document.createDocumentFragment();
+ for (const m of r.messages || []) await appendMessage(id, m, false, frag, cache);
+ if (feedCache[id] !== cache) return; // superseded while decrypting
+ cache.nodes = frag; // parked; openThread re-attaches it instantly
+ cache.scrollTop = null; // null → pin to bottom on first show
+ cache.loaded = true;
+ } catch {
+ if (feedCache[id] === cache) delete feedCache[id]; // failed — let a real open retry cleanly
+ }
+}
+
+// After first paint, warm the chats visible in the list so tapping any of them is
+// instant. Only above-the-fold rows (the flash the user sees is the initial fetch),
+// one at a time at idle so it never competes with the open thread or first paint.
+function prebuildVisibleThreads() {
+ const run = async () => {
+ for (const id of visibleThreadIds()) await prebuildThread(id);
+ };
+ if (typeof requestIdleCallback === "function") requestIdleCallback(() => run());
+ else setTimeout(run, 200);
+}
+
+// Thread ids whose list rows are on-screen (capped). Falls back to the top rows
+// when the list isn't laid out — e.g. mobile currently showing a thread.
+function visibleThreadIds() {
+ const list = document.getElementById("thread-list");
+ if (!list) return [];
+ const rows = [...list.querySelectorAll(".thread-row")];
+ const vh = window.innerHeight || document.documentElement.clientHeight || 0;
+ const onScreen = [];
+ for (const row of rows) {
+ const b = row.getBoundingClientRect();
+ if (b.width === 0 && b.height === 0) continue; // not laid out (hidden pane)
+ if (b.bottom > 0 && b.top < vh) onScreen.push(row.dataset.id);
+ }
+ const ids = onScreen.length ? onScreen : rows.map((r) => r.dataset.id);
+ return ids.filter(Boolean).slice(0, PREBUILD_MAX);
+}
+
+// `container` lets the first open build the whole history into a detached fragment
+// (see openThread) so there's no per-message reflow; live appends omit it and
+// target the real #feed. Scroll/query still resolve against #feed either way.
+async function appendMessage(threadId, m, scroll = true, container = null, cache = null) {
+ // A live append (no container) must target the open thread. Off-DOM builds — the
+ // first-open fragment and prebuildThread — pass a container and write into an
+ // explicit cache entry, so they're allowed to run for any thread.
+ if (!container && threadId !== activeId) return;
const key = threadKeys[threadId];
if (!key) return;
let data;
@@ -1254,11 +1327,18 @@ async function appendMessage(threadId, m, scroll = true) {
} catch {
return; // can't decrypt — skip
}
- if (threadId !== activeId) return; // switched threads mid-decrypt — don't bleed into the new feed
+ if (!container && threadId !== activeId) return; // switched mid-decrypt — don't bleed into the new feed
+ // The state maps this message registers into: an explicit entry for off-DOM
+ // builds, else the active thread's — whose maps ARE the globals (bindThreadState),
+ // so live/catch-up appends behave exactly as before.
+ const st = cache || feedCache[threadId];
+ if (!st) return;
// Their message just arrived → they're no longer typing. Clear it immediately
- // rather than waiting out the typing-beat expiry (the guaranteed signal).
- if (data.name && typingUntil.delete(data.name)) renderTyping();
+ // rather than waiting out the typing-beat expiry (the guaranteed signal). A
+ // background prebuild (cache set) must never touch the open thread's typing.
+ if (!cache && data.name && typingUntil.delete(data.name)) renderTyping();
const feed = document.getElementById("feed");
+ const target = container || feed; // where the node lands; feed for live appends
const mine = data.name === session.name;
const t = threads.find((x) => x.id === threadId);
const wrap = document.createElement("div");
@@ -1288,7 +1368,17 @@ async function appendMessage(threadId, m, scroll = true) {
const img = document.createElement("img");
img.className = "msg-image";
img.alt = a.name || "photo";
- if (a.w && a.h) img.style.aspectRatio = `${a.w} / ${a.h}`;
+ // Reserve the image's final box *before* the blob decrypts. aspect-ratio
+ // alone can't do it: an unloaded <img> has no intrinsic width, so with only
+ // a max-width it lays out 0×0 and the bubble collapses until the photo pops
+ // in — the "bubbles run into each other then jump" shift on first open. Give
+ // it a definite width (the same 260×320 cap as .msg-image, scaled to the
+ // real dimensions, never upscaled) so the height is reserved up front.
+ if (a.w && a.h) {
+ img.style.aspectRatio = `${a.w} / ${a.h}`;
+ const scale = Math.min(260 / a.w, 320 / a.h, 1);
+ img.style.width = Math.round(a.w * scale) + "px";
+ }
loadBlob(a.id, threadId).then((url) => {
if (url) {
img.src = url;
@@ -1344,12 +1434,12 @@ async function appendMessage(threadId, m, scroll = true) {
// Reactions: track which are mine (server's per-message flag), render the chips
// below the bubble (outside .content), and wire the press-hold / double-tap picker.
for (const r of m.reactions || []) {
- if (r.mine) myReactions.add(`${m.id}|${r.emoji}`);
+ if (r.mine) st.myReactions.add(`${m.id}|${r.emoji}`);
}
- renderReactions(wrap, m.id, m.reactions || []);
+ renderReactions(wrap, m.id, m.reactions || [], st.myReactions);
attachReactionGesture(wrap, m.id);
// Count this reply against its parent, so the parent's tag can show "N Replies".
- if (data.replyTo && data.replyTo.id) bumpReplyCount(data.replyTo.id);
+ if (data.replyTo && data.replyTo.id) bumpReplyCount(data.replyTo.id, st);
// A persistent "N Replies" tag below the message, tappable to jump to the first
// reply. Hidden until a reply arrives.
const replyCount = document.createElement("div");
@@ -1360,13 +1450,13 @@ async function appendMessage(threadId, m, scroll = true) {
if (first) scrollToMessage(first.dataset.id);
};
wrap.appendChild(replyCount);
- replyCountEls[m.id] = replyCount;
- paintReplyCount(m.id); // reflect replies that may already have arrived
+ st.replyCountEls[m.id] = replyCount;
+ paintReplyCount(m.id, st); // reflect replies that may already have arrived
if (m.edited_at) markEdited(wrap); // persistent "Edited" label below the bubble
- msgEls[m.id] = wrap;
- msgData[m.id] = { mine, createdAt: m.created_at, data, replyMeta };
+ st.msgEls[m.id] = wrap;
+ st.msgData[m.id] = { mine, createdAt: m.created_at, data, replyMeta };
if (scroll) wrap.classList.add("appear"); // live send/receive eases in; history doesn't
- feed.appendChild(wrap);
+ target.appendChild(wrap);
refreshSeparator(wrap); // centered time separator above, if the gap warrants
refreshSenderName(wrap); // hide a repeated "them" name within a run
if (scroll) {
@@ -1442,22 +1532,27 @@ function markEdited(wrap) {
}
// Bump (and repaint) the reply tally for a parent message as replies to it arrive.
-function bumpReplyCount(parentId) {
- replyCounts[parentId] = (replyCounts[parentId] || 0) + 1;
- paintReplyCount(parentId);
+// `st` targets a specific thread's cache entry (prebuild / first-open); omitted, it
+// falls back to the active thread's globals.
+function bumpReplyCount(parentId, st) {
+ const counts = st ? st.replyCounts : replyCounts;
+ counts[parentId] = (counts[parentId] || 0) + 1;
+ paintReplyCount(parentId, st);
}
-function paintReplyCount(parentId) {
- const el = replyCountEls[parentId];
+function paintReplyCount(parentId, st) {
+ const el = (st ? st.replyCountEls : replyCountEls)[parentId];
if (!el) return; // parent older than the loaded window — nothing to mark
- const n = replyCounts[parentId] || 0;
+ const n = (st ? st.replyCounts : replyCounts)[parentId] || 0;
el.textContent = n === 1 ? "1 Reply" : n + " Replies";
el.hidden = n === 0;
}
// Render a message's reaction chips (rebuilds the row in place). "Mine" chips are
-// highlighted; tapping a chip toggles that reaction.
-function renderReactions(wrap, msgId, reactions) {
+// highlighted; tapping a chip toggles that reaction. `mineSet` targets a specific
+// thread's reaction set (prebuild / first-open); omitted, it uses the active one.
+function renderReactions(wrap, msgId, reactions, mineSet) {
+ const mine = mineSet || myReactions;
const old = wrap.querySelector(".reactions");
if (old) old.remove();
if (!reactions || !reactions.length) return;
@@ -1466,7 +1561,7 @@ function renderReactions(wrap, msgId, reactions) {
for (const r of reactions) {
if (!r.count) continue;
const chip = document.createElement("button");
- chip.className = "reaction-chip" + (myReactions.has(`${msgId}|${r.emoji}`) ? " mine" : "");
+ chip.className = "reaction-chip" + (mine.has(`${msgId}|${r.emoji}`) ? " mine" : "");
chip.textContent = `${r.emoji} ${r.count}`;
chip.onclick = (e) => {
e.stopPropagation();