Skip to content

fix(tui): break scrollRows ↔ outer.height feedback that crashed CardStream#549

Merged
esengine merged 1 commit into
mainfrom
fix/cardstream-update-depth
May 9, 2026
Merged

fix(tui): break scrollRows ↔ outer.height feedback that crashed CardStream#549
esengine merged 1 commit into
mainfrom
fix/cardstream-update-depth

Conversation

@esengine

@esengine esengine commented May 9, 2026

Copy link
Copy Markdown
Owner

A user reported the TUI crashing with Maximum update depth exceeded,
thrown from inside ink's useBoxMetrics (use-box-metrics.js:51,
called from emitLayoutListenersresetAfterCommit).

Reproducer

User says it triggers intermittently when invoking /model or
/sessions. Both commands mount a picker as a flex sibling of
CardStream's column, so outer.height drops by however many rows the
picker claims — often 10+. That layout shock is what pushes the latent
loop below past React's nesting cap.

Root cause

CardStream measures the outer Box via useBoxMetrics, computes
maxScroll = inner.height - outer.height, and pushes it up to
useChatScroll. In pinned mode, useChatScroll writes that value back
as scrollRows. The component then uses scrollRows for two
things:

  1. marginTop={-scrollRows} on the inner Box — intended.
  2. The ↑ earlier hint, conditionally rendered as a sibling of the
    measured outer Box
    when scrollRows > 0 — accidental feedback.

Toggling the hint shifts outer.height by one row, so the loop is:

scrollRows > 0 → hint visible → outer.height shrinks
              → maxScroll grows → pinned writes scrollRows = maxScroll
              → hint stays / re-evaluates

Each cycle is a few renders. Steady-state it converges. But when
/model or /sessions mounts and outer.height jumps from full
viewport to a few rows in a single commit, the cascade of pinned
writes + measurement updates + the hint flip stacks deep enough to
trip React's MAX_NESTED_UPDATES = 50 from inside the
useBoxMetrics post-commit setMetrics.

Fix

Reserve the hint row unconditionally with <Box height={1}>, with the
text content still gated on scrollRows > 0. outer.height is now
independent of scrollRows, so the loop is broken at its source — the
modal-mount layout shock no longer feeds back into hint visibility.

The trade is one row of permanent reservation at the top of the chat
viewport. Worth it.

Test plan

  • npx tsc --noEmit clean
  • npx vitest run — 2404 passed, 2 skipped
  • Smoke: open /model and /sessions repeatedly with a long chat
    history scrolled to the bottom — confirm no crash
  • Smoke: stream a long reply, scroll up/down with PgUp/PgDn/wheel,
    confirm hint shows when scrolled and the reserved row is empty
    when at bottom

…tream

The "↑ earlier" hint was rendered conditionally on scrollRows > 0, as a
sibling of the measured outer Box. That tied outer.height to scrollRows:
toggling the hint changed outer.height by one row, which changed
maxScroll, which pinned-mode wrote back as scrollRows, which toggled
the hint again. With streaming content compounding inside the same
React batch, the chain reached MAX_NESTED_UPDATES = 50 and aborted with
"Maximum update depth exceeded" inside ink's useBoxMetrics.

Reserve the hint row unconditionally so outer.height is independent of
scrollRows. Trade is one row of permanent reservation; the loop is
gone.
@esengine esengine merged commit 98183ba into main May 9, 2026
3 checks passed
@esengine esengine deleted the fix/cardstream-update-depth branch May 9, 2026 23:42
esengine added a commit that referenced this pull request May 12, 2026
…ng (#702)

Same shape as #549. In pinned mode scrollRows tracks maxScroll, and
maxScroll = inner.height - outer.height. The items useMemo depends on
both scrollRows and outer.height, so a sibling row toggling (ToastRail
appearing, ThinkingRow flipping) shifts outer.height by ±1 → window
moves → a card straddling the live↔spacer boundary flips → inner.height
changes by Δ → setMaxScroll → scrollRows changes → useMemo recomputes →
card flips back. With flash streaming compounding inside the same React
batch, the chain reaches MAX_NESTED_UPDATES and aborts inside ink's
useBoxMetrics (#700).

Quantize the window position to VISIBLE_BUFFER_ROWS buckets. Sub-bucket
scrollRows / outer.height wiggles now map to the same window, so items
is referentially-stable across them and the feedback loop dies on the
first re-render.

- extract computeCardStreamItems as a pure exported function for tests
- new tests/card-stream-items.test.ts asserts live-set stability across
  every sub-bucket scrollRows delta and a range of outer.height values

Closes #700
esengine pushed a commit that referenced this pull request May 16, 2026
…he (#989)

Three layered fixes for the streaming-card jitter feedback loop (refs #549 / #700):

- MeasuredCard reports height only on growth for unsettled cards, suppressing Yoga's transient shrink measurements that fed back into virtual-window oscillation. Settled cards still report the exact final height.
- useIncrementalWrap debounces lineCells changes by 120ms via useEffect + useState, holding the previous width during a terminal resize so the incremental wrap stays on its monotonic path until the new width settles.
- Rust render loop adds FrameCache: skip JSON deserialization, scene render, and ratatui diff when consecutive raw frames are byte-identical. Trace scene only changes on card/model/activity updates, so most frames between streaming deltas are duplicates.
ChasLui pushed a commit to ChasLui/DeepSeek-Reasonix that referenced this pull request May 23, 2026
…tream (esengine#549)

The "↑ earlier" hint was rendered conditionally on scrollRows > 0, as a
sibling of the measured outer Box. That tied outer.height to scrollRows:
toggling the hint changed outer.height by one row, which changed
maxScroll, which pinned-mode wrote back as scrollRows, which toggled
the hint again. With streaming content compounding inside the same
React batch, the chain reached MAX_NESTED_UPDATES = 50 and aborted with
"Maximum update depth exceeded" inside ink's useBoxMetrics.

Reserve the hint row unconditionally so outer.height is independent of
scrollRows. Trade is one row of permanent reservation; the loop is
gone.
ChasLui pushed a commit to ChasLui/DeepSeek-Reasonix that referenced this pull request May 23, 2026
…ng (esengine#702)

Same shape as esengine#549. In pinned mode scrollRows tracks maxScroll, and
maxScroll = inner.height - outer.height. The items useMemo depends on
both scrollRows and outer.height, so a sibling row toggling (ToastRail
appearing, ThinkingRow flipping) shifts outer.height by ±1 → window
moves → a card straddling the live↔spacer boundary flips → inner.height
changes by Δ → setMaxScroll → scrollRows changes → useMemo recomputes →
card flips back. With flash streaming compounding inside the same React
batch, the chain reaches MAX_NESTED_UPDATES and aborts inside ink's
useBoxMetrics (esengine#700).

Quantize the window position to VISIBLE_BUFFER_ROWS buckets. Sub-bucket
scrollRows / outer.height wiggles now map to the same window, so items
is referentially-stable across them and the feedback loop dies on the
first re-render.

- extract computeCardStreamItems as a pure exported function for tests
- new tests/card-stream-items.test.ts asserts live-set stability across
  every sub-bucket scrollRows delta and a range of outer.height values

Closes esengine#700
ChasLui pushed a commit to ChasLui/DeepSeek-Reasonix that referenced this pull request May 23, 2026
…he (esengine#989)

Three layered fixes for the streaming-card jitter feedback loop (refs esengine#549 / esengine#700):

- MeasuredCard reports height only on growth for unsettled cards, suppressing Yoga's transient shrink measurements that fed back into virtual-window oscillation. Settled cards still report the exact final height.
- useIncrementalWrap debounces lineCells changes by 120ms via useEffect + useState, holding the previous width during a terminal resize so the incremental wrap stays on its monotonic path until the new width settles.
- Rust render loop adds FrameCache: skip JSON deserialization, scene render, and ratatui diff when consecutive raw frames are byte-identical. Trace scene only changes on card/model/activity updates, so most frames between streaming deltas are duplicates.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant