Page MenuHomePhabricator

Tab freezes after committing a cell value (infinite render loop) (!43)
Closed, ResolvedPublic

Description

Symptom: Browser tab freezes within seconds after committing any cell value (Enter or click outside the cell). Chromium offers "wait or kill"; DevTools become inaccessible. Reproduces on main (v0.25.0) and on the live deployment.

Root cause: Infinite render loop in src/app.jsx triggered by the per-category Commons-existence check added in 6bb990f (T425950 follow-up).

The cat-check effect (src/app.jsx:574-666) depends on [items]. Inside its Promise.all(...).then() it calls setItems((prev) => prev.map((row) => { ... })). The per-row bailout returns the original row reference, but prev.map() itself always returns a new array reference — React's Object.is(newArr, prevArr) is always false → re-render → items memo recomputes → effect re-fires. With at least one row carrying a category that's already in categoryCheckRef.current, the cached path runs Promise.all([]).then(...) which resolves on the next microtask → setItems again → loop. The microtask queue self-fills; the event loop never yields to render or input → tab hangs and DevTools can't attach.

Manifests "on commit" because that's the most common setItems trigger; once a category sits in the cache, any setItems call (any cell commit, dup-check resolve, background refresh) freezes the tab.

Fix: Track a changed flag inside the prev.map; return prev (same reference) when no row mutated. Lets Object.is bail. Targeted ~5-line change in src/app.jsx.

Repro:

  1. Add a category to a stash row, commit (Enter)
  2. Wait ~1s for the category-existence fetch to resolve
  3. Edit any cell on any row and commit → freeze

Severity: Critical — every active editor hits this once they have categories on rows. Lost work, forced tab kill.

Event Timeline

Daanvr triaged this task as High priority.
Daanvr moved this task from Doing to Done on the Tool-upload-workbench board.

Merged as v0.25.1.

Lessons learned

The bug was a textbook React infinite render loop, but the failure mode was unusually severe: because the loop closed entirely inside the microtask queue (Promise.all([]).then(...) resolves immediately on the cached/no-op path), the event loop never yielded to the renderer or to input handling. That made the tab freeze hard enough that DevTools couldn't even attach to inspect the runaway main thread — so the usual "Profiler shows excessive renders" workflow was unavailable. Diagnosis had to come from reading the code, not from observing the running app.

Three things to remember next time:

  1. prev.map() always returns a new array reference, even when every callback returns the original element. Per-row return row; bailouts inside .map only stabilise the elements; the array wrapper is always new. React's Object.is then sees a different reference and re-renders. Inside an effect that depends on the array state, that's an infinite loop. Track a changed flag in the map, return prev (same reference) when nothing changed, and let Object.is bail.
  1. Microtask-only loops freeze the tab silently and undebuggably. A loop that goes through the macrotask queue (e.g. setTimeout(fn, 0)) eventually yields and lets the browser show "page unresponsive" with stack info; one that goes through the microtask queue (any synchronous-resolving promise chain) starves the event loop entirely. DevTools require the main thread to service their CDP messages — a microtask-only loop blocks attach. So: if a user reports "freezes and DevTools won't open", look for Promise.all, Promise.resolve, or await-on-already-cached-value patterns inside effects.
  1. The [items] dependency on a memoised array is fragile when paired with state setters that always produce new array references. Whenever you write setState((prev) => prev.map(...)) inside a useEffect whose dep is the same state, ask: "what stops this from looping?" If the answer is "the per-row bailout returns the original row" — that's not enough. The answer needs to be "the setter returns prev (same reference) when nothing changed."

Rule added to CLAUDE.md → "Durable rules learned so far" so this doesn't recur silently.

Total time from user-reports-freeze to merged fix: ~45 minutes (most of it in worktree/CI ceremony — the diagnosis itself was ~10 min once the loop hypothesis was confirmed by reading the cat-check effect).

Daanvr renamed this task from Tab freezes after committing a cell value (infinite render loop) to Tab freezes after committing a cell value (infinite render loop) (!43).May 15 2026, 1:01 PM