fix(ink): scroll jitter on rapid mouse wheel (#2076)#2095
Conversation
esengine
left a comment
There was a problem hiding this comment.
Two blockers — both need to be addressed before this can merge.
1. Same scope creep as #2085. The diff bundles the entire #2046 maxIterPerTurn patch (config loader, four call sites, CacheFirstLoopOptions field, loop iter guard, i18n × 4, tests). That belongs to #2046 only. Please drop all maxIterPerTurn-related hunks from this PR — they're not part of "fix(ink): scroll jitter" and they're now showing up in three of your open PRs (#2046, #2085, #2095) which makes each one harder to review independently.
2. The ink fix uses a React anti-pattern that introduces correctness regressions.
useEffect(() => { /* measure */ }, [ref.current])ref.current is not a reactive value — React doesn't track ref mutations and the dep array is compared by Object.is against the previous render's captured value. In practice this means the effect runs once on mount (when the ref attaches) and then never again unless the conditional rendering swaps the DOM node. That's not what useBoxMetrics / useTerminalViewport need:
useBoxMetrics: was deliberately running every render so the box re-measured when its parent's layout changed. With[ref.current]deps it captures the first measurement and never updates — so a parent resize will leave the child stuck on stale dimensions.useTerminalViewport: same issue. After the first parent-chain walk, scroll changes in any ancestor won't updateisVisible.
The CI suite doesn't catch the regression because there's no test that resizes a parent after first render and asserts the child re-measures.
The direction is right — re-measuring every render on every wheel event is wasteful. But the right way to skip the work is one of:
- Throttle / debounce the re-render at the scroll source (so wheel bursts coalesce to one update).
- Use a real reactive dep (
[terminalSize]is fine foruseTerminalViewport;useBoxMetricsneeds something that actually changes when the layout changes — a ResizeObserver wired through state is the canonical fix). - Move the work to
useLayoutEffectonly when a known layout-affecting dep changes.
The App.tsx memoization of TerminalSizeContext.Provider value (the class-property cache) is fine in principle — keeping that part is OK once the deps issue is fixed.
Please:
- Drop the
maxIterPerTurnhunks. - Rework the deps — don't put
ref.currentin a dep array, ever; pick one of the alternatives above. - Add a regression test that resizes a parent after first render and asserts
useBoxMetricsreports the new dimensions, so the next person who touches this can't reintroduce the bug.
…attern from hook deps - useBoxMetrics: remove [ref.current] dep array (React anti-pattern); add 16ms throttle to limit measurement frequency during rapid scrolling - useTerminalViewport: remove elementRef.current from deps, keep only [terminalSize] as the reactive dependency - Add regression test verifying useBoxMetrics re-measures on parent resize
…ort to prevent scroll jitter (esengine#2076) - useBoxMetrics: add [ref.current] deps so measurement only runs when element changes - useTerminalViewport: add [elementRef.current, terminalSize] deps to skip expensive parent-chain walk on every render - App: memoize TerminalSizeContext.Provider value to keep reference stable During rapid mouse wheel scrolling (10-20 events/100ms on Windows), the missing dependency arrays caused each wheel event to trigger 3-5 cascading renders (30-80 renders/100ms), producing visible text jitter.
…attern from hook deps - useBoxMetrics: remove [ref.current] dep array (React anti-pattern); add 16ms throttle to limit measurement frequency during rapid scrolling - useTerminalViewport: remove elementRef.current from deps, keep only [terminalSize] as the reactive dependency - Add regression test verifying useBoxMetrics re-measures on parent resize
e4f6a31 to
ff51a0f
Compare
esengine
left a comment
There was a problem hiding this comment.
Re-reviewed — both blockers resolved. (1) The #2046 maxIterPerTurn scope creep is fully dropped (0 refs). (2) The ref.current-in-deps anti-pattern is reworked: the effect now keys off [terminalSize] (a real reactive value, exactly as suggested) with a 16ms throttle guard via lastMeasureTimeRef (used as a throttle, not a dep) — the only ref.current mentions left are explanatory comments warning against the anti-pattern and a return value, not dependency arrays. CI green. Nice rework — merging.
Problem
Desktop Windows client text jitters when rapidly scrolling with mouse wheel (#2076).
Root cause: Two React hooks (
useBoxMetricsanduseTerminalViewport) had no dependency arrays, causing them to run expensive measurements on every render. During rapid wheel scrolling (10-20 events/100ms on Windows), each wheel event triggered 3-5 cascading renders, producing 30-80 renders/100ms and visible text jitter.Additionally,
TerminalSizeContext.Providercreated a new object on every render, defeating any dependency-array checks in consumers.Fix (3 files, 19 insertions, 6 deletions)
packages/ink/src/index.tsuseBoxMetrics: add[ref.current]deps — measurement only runs when element changespackages/ink/src/hooks/use-terminal-viewport.tsuseTerminalViewport: add[elementRef.current, terminalSize]deps — skips expensive parent-chain walk on every renderpackages/ink/src/components/App.tsxTerminalSizeContext.Providervalue — keeps reference stable so deps checks workRender chain before fix
Verification
ink-update-depth-reproandcardstream-architecturetests (related to Bug: React Maximum update depth exceeded in Ink TUI #2067 regression)