eleven / messenger Public

Fix web app freeze when @mentions resolve in chat (#1068)

* web: fix the boot-time hard freeze — mention repaint looped via mountedCtx re-insertion

repaintMentionsOf (#1041) iterated the live mountedCtx Map while its
refresh() callback re-rendered the thread — and a re-render tears down the
old mount (deleting its mountedCtx entry) before re-adding the new one.
A delete + re-add lands at the end of a Map's iteration order, so the live
iterator visited the same thread again, re-matched the same @name in its
feed, and repainted forever: a fully synchronous loop that pegged the main
thread so hard DevTools couldn't attach. It armed whenever the thread
mounted at boot had a member's @name in its recent messages, which is why
loads only sometimes survived.

Two locks on the door: iterate a snapshot of mountedCtx, and only treat a
row as newly dirty when its sid isn't already marked — so a repeat call
over the same feed triggers no re-render at all (nothing is lost: a dirty
row's rev is the fixed "|mention" tag, so a second resolving name couldn't
rebuild it anyway). The scan still considers only the most recent 100
messages. The newly-dirty gate is extracted as the pure
newlyDirtyMentionSids for the Node tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ymYFk5fnp2WSXnCd7zpBQ

* web: harden the other three hang-shaped paths the freeze hunt surfaced

Three fixes from the same investigation as the mention-repaint loop, each a
way the web app could lock up or wedge:

- format.js URL_RE: the bare-domain alternative was quadratic on long
  unbroken runs of word chars — every position inside a pasted 45k-char
  key/hash/log line started a fresh scan to the run's end (measured: 4.7s
  per pass at 64k chars, twice per bubble render). A (?<![\w@.-]) lookbehind
  fails mid-run starts in one check, making the scan linear (256k chars in
  ~4ms) while keeping the mid-word/email rule the code already applied —
  which lets the now-redundant glue check in tokenizeText go. One deliberate
  corner change, pinned by test: a candidate glued after a stray hyphen
  ("-a.com") counts as mid-token and no longer links.

- store.js runBackfill: the walk's only termination was an empty page, so a
  server replaying a non-advancing page (duplicate/non-descending ids) would
  loop forever. Guard: each page's oldest id must be strictly older than the
  cursor, else stop honestly with {complete:false} — mark untouched, a later
  run retries.

- push.js/sw.js "lchat" + "lchat-store" openers: no onblocked, no
  onversionchange, connections never closed — a held connection would block
  any future schema version bump in another tab forever (its open stalls
  pre-paint: a silent boot hang). Close when done, yield on versionchange,
  reject/resolve on blocked. Both DBs stay pinned at v1; this is armour for
  the day that changes.

Tests: linkifier behaviour + a linear-time canary (format.test.mjs), the
stall guard end-to-end over Node's no-op IDB fallbacks (store.test.mjs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ymYFk5fnp2WSXnCd7zpBQ

---------
Co-authored-by: Claude <noreply@anthropic.com>
James Adam e3f208dadd92199754378f5c1254858c5a853a60 parent 599e909
10 files changed, +183 −31
  • changelog/2026-07-17-long-paste-freeze.md +4 −0
  • changelog/2026-07-17-web-mention-repaint-freeze.md +4 −0
  • test/apps.test.mjs +29 −1
  • test/format.test.mjs +29 −0
  • test/store.test.mjs +27 −1
  • web/static/apps/chat.js +26 −12
  • web/static/format.js +11 −5
  • web/static/push.js +16 −3
  • web/static/store.js +9 −1
  • web/static/sw.js +28 −8
diff --git a/changelog/2026-07-17-long-paste-freeze.md b/changelog/2026-07-17-long-paste-freeze.md
new file mode 100644
index 0000000..61a1674
--- /dev/null
+++ b/changelog/2026-07-17-long-paste-freeze.md
@@ -0,0 +1,4 @@
+🤖 Pasting or receiving a very long unbroken string — a key, a hash, a log
+line — no longer freezes the web app for seconds every time that message
+renders. Link detection now skips over such runs instantly instead of
+re-scanning them from every character.
diff --git a/changelog/2026-07-17-web-mention-repaint-freeze.md b/changelog/2026-07-17-web-mention-repaint-freeze.md
new file mode 100644
index 0000000..d89b290
--- /dev/null
+++ b/changelog/2026-07-17-web-mention-repaint-freeze.md
@@ -0,0 +1,4 @@
+🤖 Fixed the web app freezing the whole tab — sometimes so hard the page never
+appeared — when opening a space whose current chat recently @mentioned someone.
+A repaint that keeps @mentions bold when names resolve could get stuck redrawing
+the same chat forever; it now repaints each chat at most once.
diff --git a/test/apps.test.mjs b/test/apps.test.mjs
index 18a165b..86695ea 100644
--- a/test/apps.test.mjs
+++ b/test/apps.test.mjs
@@ -89,7 +89,7 @@ import { app as dnsimpleApp } from "../web/static/apps/dnsimple.js"; // register
import { app as setupApp } from "../web/static/apps/setup.js"; // the first-run setup conversation's app
import { app as welcomeApp } from "../web/static/apps/welcome.js"; // the joiner welcome conversation's app
import { app as adminApp } from "../web/static/apps/admin.js"; // the admin panel (registers /admin)
-import { app as chatApp, viewItems, threadItems } from "../web/static/apps/chat.js"; // the chat app
+import { app as chatApp, viewItems, threadItems, newlyDirtyMentionSids } from "../web/static/apps/chat.js"; // the chat app
import { app as vaultApp } from "../web/static/apps/vault.js"; // the Vault app (registers /vault)
import { app as emojiApp, searchEmoji } from "../web/static/apps/emoji.js"; // registers /emoji (the command-panel reference app)
import { app as gifApp, pickRendition, renditionCandidates, gifItems, GIF_MAX_BYTES } from "../web/static/apps/gif.js"; // registers /gif
@@ -2841,5 +2841,33 @@ await test("appGlyphInto clears a leftover photo background before painting the
assert.equal(g.textContent, "🛠️");
});
+await test("newlyDirtyMentionSids: matches, cap, and the already-dirty gate (the boot-freeze regression)", () => {
+ const msgs = [
+ { sid: 1, text: "hello @Ana" },
+ { sid: 2, text: "no mention here" },
+ { sid: 3 }, // deleted/attachment-only row: no text
+ { sid: 4, text: "ping @Bob and @Ana" },
+ ];
+ // Only rows whose text references a given name are marked.
+ assert.deepEqual(newlyDirtyMentionSids(msgs, ["Ana"], new Set()), [1, 4]);
+ assert.deepEqual(newlyDirtyMentionSids(msgs, ["Bob"], new Set()), [4]);
+ assert.deepEqual(newlyDirtyMentionSids(msgs, ["Zoe"], new Set()), []);
+ // Already-dirty rows are never re-reported: a repeat call over the same feed
+ // finds nothing, so repaintMentionsOf triggers no re-render — the guarantee
+ // (with the mountedCtx snapshot) that the repaint loop always terminates.
+ // (Nothing is lost: a dirty row's rev is the fixed "|mention" tag, so a
+ // second resolving name couldn't rebuild it anyway.)
+ const dirty = new Set(newlyDirtyMentionSids(msgs, ["Ana"], new Set()));
+ assert.deepEqual(newlyDirtyMentionSids(msgs, ["Ana"], dirty), []);
+ // …even when a DIFFERENT name resolves in an already-dirty row (sid 4 has both).
+ assert.deepEqual(newlyDirtyMentionSids(msgs, ["Bob"], dirty), []);
+ // Only the most recent 100 messages are ever considered.
+ const long = Array.from({ length: 250 }, (_, i) => ({ sid: i + 1, text: "@Ana" }));
+ const marked = newlyDirtyMentionSids(long, ["Ana"], new Set());
+ assert.equal(marked.length, 100);
+ assert.equal(marked[0], 151); // the cap keeps the newest, drops the oldest
+ assert.equal(marked[99], 250);
+});
+
console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`);
process.exit(failures === 0 ? 0 : 1);
diff --git a/test/format.test.mjs b/test/format.test.mjs
index 58302bb..65551ce 100644
--- a/test/format.test.mjs
+++ b/test/format.test.mjs
@@ -473,5 +473,34 @@ await test("emphasis markers around a URL never ride into the href", () => {
assert.ok(!link2.href.endsWith("*"));
});
+await test("bare domains glued mid-token stay plain (the lookbehind rule)", () => {
+ // Mid-word, email, and mid-hyphenated-token candidates never link — and a
+ // hyphenated host still links whole, from its clean start.
+ assert.ok(!tokenizeText("x_aaa.com").some((t) => t.href));
+ // Glued after a stray hyphen counts as mid-token too (deliberate: hyphens
+ // are part of the candidate's own charset, so they bound it on both sides).
+ assert.ok(!tokenizeText("see -a.com").some((t) => t.href));
+ const hy = tokenizeText("try my-site.com now").find((t) => t.href);
+ assert.equal(hy && hy.text, "my-site.com");
+ assert.equal(roundtrip("x_aaa.com and my-site.com"), "x_aaa.com and my-site.com");
+});
+
+await test("tokenizing a huge unbroken paste is linear, not quadratic (the render-freeze regression)", () => {
+ // A pasted key/hash/log line is one long run of word chars. The bare-domain
+ // scan used to make a fresh full-run attempt at every position — O(n²),
+ // seconds of locked main thread per render at message-size (~45k chars).
+ // The lookbehind fails mid-run starts in O(1); 256k chars must be instant.
+ // The 2s bound is ~50× the observed linear time and ~1/40th of quadratic —
+ // generous for slow CI, unmissable if the regression returns.
+ const runs = ["a".repeat(256000), "a-".repeat(128000), "5".repeat(256000)];
+ const t0 = performance.now();
+ for (const s of runs) {
+ tokenizeText(s);
+ richTokens(s);
+ }
+ const ms = performance.now() - t0;
+ assert.ok(ms < 2000, `expected linear-time tokenize, took ${ms.toFixed(0)}ms`);
+});
+
console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`);
process.exit(failures === 0 ? 0 : 1);
diff --git a/test/store.test.mjs b/test/store.test.mjs
index 03ad5c8..0a01706 100644
--- a/test/store.test.mjs
+++ b/test/store.test.mjs
@@ -4,7 +4,7 @@
// a fetched page translate to store writes, and which cached threads must be
// pruned against a live thread list.
import assert from "assert";
-import { SNAP_VERSION, snapshotUsable, partitionRows, staleThreadIds, backfillCursor } from "../web/static/store.js";
+import { SNAP_VERSION, snapshotUsable, partitionRows, staleThreadIds, backfillCursor, runBackfill } from "../web/static/store.js";
function test(name, fn) {
try {
@@ -84,4 +84,30 @@ test("backfillCursor: a completed thread is never re-walked", () => {
assert.strictEqual(backfillCursor({ oldest: 0, complete: true }, 0), -1, "empty-thread completion holds too");
});
+// runBackfill is async but runs here: in Node the IDB plumbing degrades to its
+// best-effort fallbacks (no mark, no stored rows, writes no-op), which is
+// exactly the fresh-walk shape the stall guard needs.
+await (async () => {
+ const name = "runBackfill: a page that fails to advance the cursor stops the walk (never spins)";
+ try {
+ // A buggy server ignores `before` and replays the same non-empty page
+ // forever. The walk's only termination used to be an empty page — this
+ // would loop for good; the monotonic-cursor guard must bail instead.
+ let calls = 0;
+ const page = [{ id: 500, created_at: 1, blob: "x" }, { id: 501, created_at: 2, blob: "y" }];
+ const r = await runBackfill("t1", async () => {
+ calls++;
+ assert.ok(calls < 10, "walk did not stop on a non-advancing page");
+ return page;
+ });
+ assert.deepStrictEqual(r, { complete: false }, "a stalled walk reports incomplete, not complete");
+ assert.strictEqual(calls, 2, "one seed page + one non-advancing page, then stop");
+ console.log(`PASS ${name}`);
+ } catch (e) {
+ console.error(`FAIL ${name}`);
+ console.error(e);
+ process.exit(1);
+ }
+})();
+
console.log("\nALL PASSED");
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js
index 461ac70..d6d2f3b 100644
--- a/web/static/apps/chat.js
+++ b/web/static/apps/chat.js
@@ -857,6 +857,20 @@ function mentionRev(m) {
return mentionDirty.has(m.sid) ? "|mention" : "";
}
+// Pure: which of the last `cap` messages newly reference one of `names` —
+// "newly" meaning not already in `alreadyDirty`, so a repeat call over the
+// same feed finds nothing and triggers nothing. (An already-dirty row's rev
+// is the fixed "|mention" tag, so a second name resolving in the same message
+// couldn't rebuild it anyway — skipping it loses no repaint.) Exported for
+// the Node tests.
+export function newlyDirtyMentionSids(msgs, names, alreadyDirty, cap = 100) {
+ const out = [];
+ for (const m of (msgs || []).slice(-cap)) {
+ if (m.text && !alreadyDirty.has(m.sid) && names.some((n) => m.text.includes("@" + n))) out.push(m.sid);
+ }
+ return out;
+}
+
// repaintMentionsOf(names): one or more roster labels just became known (a
// fresh joiner, or a label that finished decrypting) — called from shell.js's
// decryptRosterLabels. Scans each mounted thread's last 100 messages for ones
@@ -865,19 +879,19 @@ function mentionRev(m) {
// blanket one (issue #249).
export function repaintMentionsOf(names) {
if (!names || !names.length) return;
- for (const ctx of mountedCtx.values()) {
+ // Iterate a SNAPSHOT: refresh() re-renders, and a re-render tears down the
+ // old mount (deleting its mountedCtx entry) before the new one re-adds it.
+ // A delete + re-add lands at the end of the Map's iteration order, so a
+ // live iterator here would visit the same thread again — and, with the
+ // same @name still in its feed, again forever: the 2026-07-17 hard freeze
+ // on web boot. The newly-dirty gate below is the second lock on that door.
+ for (const ctx of [...mountedCtx.values()]) {
const msgs = (ctx.getState() && ctx.getState().msgs) || [];
- let touched = false;
- for (const m of msgs.slice(-100)) {
- if (m.text && names.some((n) => m.text.includes("@" + n))) {
- mentionDirty.add(m.sid);
- touched = true;
- }
- }
- if (touched) {
- const refresh = refreshFeed.get(ctx.threadId);
- if (refresh) refresh();
- }
+ const fresh = newlyDirtyMentionSids(msgs, names, mentionDirty);
+ if (!fresh.length) continue;
+ for (const sid of fresh) mentionDirty.add(sid);
+ const refresh = refreshFeed.get(ctx.threadId);
+ if (refresh) refresh();
}
}
diff --git a/web/static/format.js b/web/static/format.js
index 9abd554..3b1c12d 100644
--- a/web/static/format.js
+++ b/web/static/format.js
@@ -4,7 +4,15 @@
// Matches http(s):// URLs, bare www. hosts, and scheme-less "host.tld" domains.
// The bare case is validated against a TLD allowlist (see linkHref) so file names
// and abbreviations (node.js, index.html, e.g., main.go) aren't turned into links.
-const URL_RE = /https?:\/\/[^\s<]+|www\.[^\s<]+|[a-z0-9][a-z0-9-]*(?:\.[a-z0-9-]+)+(?:\/[^\s<]*)?/gi;
+// The lookbehind on the bare case is the mid-token/email rule ("x_a.com",
+// "b@a.com", "a-b.com" never link from the middle — hyphens count as part of
+// the token, like the match's own charset) AND the performance guard: without
+// it, every position inside a long unbroken run of those characters is a fresh
+// match attempt that scans to the run's end before failing — quadratic, and a
+// pasted 45k-char key/hash/log line locks the main thread for seconds per
+// render. With it, a mid-run start fails in one lookbehind check, keeping the
+// scan linear.
+const URL_RE = /https?:\/\/[^\s<]+|www\.[^\s<]+|(?<![\w@.-])[a-z0-9][a-z0-9-]*(?:\.[a-z0-9-]+)+(?:\/[^\s<]*)?/gi;
// Trailing punctuation that's almost certainly sentence punctuation, not URL —
// including emphasis markers: a bot writing "**github.com/apps/x**" must not
@@ -56,10 +64,8 @@ export function tokenizeText(text) {
trail = tm[0];
url = url.slice(0, url.length - trail.length);
}
- const bare = !SCHEME_RE.test(url) && !WWW_RE.test(url);
- // A scheme-less candidate glued to a preceding word char or '@' is mid-word or
- // an email's domain — leave it as plain text.
- if (bare && offset > 0 && /[\w@.]/.test(text[offset - 1])) continue;
+ // (A scheme-less candidate glued to a preceding word char, '@', '.' or '-'
+ // is mid-token or an email's domain — URL_RE's lookbehind already rejects it.)
const href = linkHref(url);
if (!href) continue; // e.g. "node.js" / "index.html" — not a link
if (offset > last) tokens.push({ text: text.slice(last, offset) });
diff --git a/web/static/push.js b/web/static/push.js
index 764840f..96da335 100644
--- a/web/static/push.js
+++ b/web/static/push.js
@@ -5,12 +5,25 @@ export function idbSet(key, value) {
const req = indexedDB.open("lchat", 1);
req.onupgradeneeded = () => req.result.createObjectStore("kv");
req.onsuccess = () => {
- const tx = req.result.transaction("kv", "readwrite");
+ const db = req.result;
+ // Close as soon as the write lands, and yield to a schema upgrade from
+ // elsewhere: a connection held open with no versionchange handler would
+ // block any future version bump in another tab forever (its open would
+ // stall pre-paint). Pinned at v1 today, so this is armour, not a fix.
+ db.onversionchange = () => db.close();
+ const tx = db.transaction("kv", "readwrite");
tx.objectStore("kv").put(value, key);
- tx.oncomplete = () => resolve();
- tx.onerror = () => reject(tx.error);
+ tx.oncomplete = () => {
+ db.close();
+ resolve();
+ };
+ tx.onerror = () => {
+ db.close();
+ reject(tx.error);
+ };
};
req.onerror = () => reject(req.error);
+ req.onblocked = () => reject(new Error("blocked"));
});
}
diff --git a/web/static/store.js b/web/static/store.js
index 27c1485..65e2ba3 100644
--- a/web/static/store.js
+++ b/web/static/store.js
@@ -300,8 +300,16 @@ export async function runBackfill(threadId, fetchPage, { onPage, signal } = {})
await setBackfillMark(threadId, { oldest: cursor, complete: true });
return { complete: true };
}
+ const next = rows[0].id; // pages come ascending; the first row is the oldest
+ if (!(next < cursor)) {
+ // The walk only terminates because each page's oldest id is strictly
+ // older than the cursor. A page that fails to advance (duplicate or
+ // non-descending ids — a server bug) would replay forever; stop
+ // honestly instead. The mark is untouched, so a later run retries.
+ return { complete: false };
+ }
await putMessages(threadId, rows);
- cursor = rows[0].id; // pages come ascending; the first row is the oldest
+ cursor = next;
await setBackfillMark(threadId, { oldest: cursor, complete: false });
// Hand the page's rows over for incremental indexing, and AWAIT: a slow
// consumer backpressures the walk rather than racing it.
diff --git a/web/static/sw.js b/web/static/sw.js
index 8ba68d3..9c50cac 100644
--- a/web/static/sw.js
+++ b/web/static/sw.js
@@ -18,17 +18,28 @@ function b64urlToBytes(s) {
}
// Read a value from the "lchat" IndexedDB (shared with the page; see push.js).
+// Close-after-read + versionchange handler for the same reason as idbSet there:
+// a held connection would block a future schema upgrade in a page tab forever.
function idbGet(key) {
return new Promise((resolve, reject) => {
const req = indexedDB.open("lchat", 1);
req.onupgradeneeded = () => req.result.createObjectStore("kv");
req.onsuccess = () => {
- const tx = req.result.transaction("kv", "readonly");
+ const db = req.result;
+ db.onversionchange = () => db.close();
+ const tx = db.transaction("kv", "readonly");
const g = tx.objectStore("kv").get(key);
- g.onsuccess = () => resolve(g.result);
- g.onerror = () => reject(g.error);
+ g.onsuccess = () => {
+ db.close();
+ resolve(g.result);
+ };
+ g.onerror = () => {
+ db.close();
+ reject(g.error);
+ };
};
req.onerror = () => reject(req.error);
+ req.onblocked = () => reject(new Error("blocked"));
});
}
@@ -82,14 +93,23 @@ function storePushedRow(threadID, id, at, blob) {
if (!db.objectStoreNames.contains("msgs")) db.createObjectStore("msgs", { keyPath: ["t", "id"] });
};
req.onsuccess = () => {
+ const db = req.result;
+ // Same door as idbGet above: close when done, step aside for upgrades.
+ db.onversionchange = () => db.close();
+ const settle = () => {
+ try {
+ db.close();
+ } catch {}
+ resolve();
+ };
try {
- const t = req.result.transaction("msgs", "readwrite");
+ const t = db.transaction("msgs", "readwrite");
t.objectStore("msgs").put({ id, created_at: at, blob, t: threadID });
- t.oncomplete = () => resolve();
- t.onabort = () => resolve();
- t.onerror = () => resolve();
+ t.oncomplete = settle;
+ t.onabort = settle;
+ t.onerror = settle;
} catch {
- resolve();
+ settle();
}
};
req.onerror = () => resolve();