fix(tui): break scrollRows ↔ outer.height feedback that crashed CardStream#549
Merged
Conversation
…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.
This was referenced May 10, 2026
Closed
Merged
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A user reported the TUI crashing with
Maximum update depth exceeded,thrown from inside ink's
useBoxMetrics(use-box-metrics.js:51,called from
emitLayoutListeners→resetAfterCommit).Reproducer
User says it triggers intermittently when invoking
/modelor/sessions. Both commands mount a picker as a flex sibling ofCardStream's column, soouter.heightdrops by however many rows thepicker claims — often 10+. That layout shock is what pushes the latent
loop below past React's nesting cap.
Root cause
CardStreammeasures the outer Box viauseBoxMetrics, computesmaxScroll = inner.height - outer.height, and pushes it up touseChatScroll. In pinned mode,useChatScrollwrites that value backas
scrollRows. The component then usesscrollRowsfor twothings:
marginTop={-scrollRows}on the inner Box — intended.↑ earlierhint, conditionally rendered as a sibling of themeasured outer Box when
scrollRows > 0— accidental feedback.Toggling the hint shifts
outer.heightby one row, so the loop is:Each cycle is a few renders. Steady-state it converges. But when
/modelor/sessionsmounts andouter.heightjumps from fullviewport 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 = 50from inside theuseBoxMetricspost-commitsetMetrics.Fix
Reserve the hint row unconditionally with
<Box height={1}>, with thetext content still gated on
scrollRows > 0.outer.heightis nowindependent of
scrollRows, so the loop is broken at its source — themodal-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 --noEmitcleannpx vitest run— 2404 passed, 2 skipped/modeland/sessionsrepeatedly with a long chathistory scrolled to the bottom — confirm no crash
confirm hint shows when scrolled and the reserved row is empty
when at bottom