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>
10 files changed,
+183
−31
changelog/2026-07-17-long-paste-freeze.md+4 −0changelog/2026-07-17-web-mention-repaint-freeze.md+4 −0test/apps.test.mjs+29 −1test/format.test.mjs+29 −0test/store.test.mjs+27 −1web/static/apps/chat.js+26 −12web/static/format.js+11 −5web/static/push.js+16 −3web/static/store.js+9 −1web/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(); |