Skip to content

fix(ink): scroll jitter on rapid mouse wheel (#2076)#2095

Merged
esengine merged 2 commits into
esengine:mainfrom
zhangyapu1:fix/2076-scroll-jitter
May 29, 2026
Merged

fix(ink): scroll jitter on rapid mouse wheel (#2076)#2095
esengine merged 2 commits into
esengine:mainfrom
zhangyapu1:fix/2076-scroll-jitter

Conversation

@zhangyapu1

Copy link
Copy Markdown

Problem

Desktop Windows client text jitters when rapidly scrolling with mouse wheel (#2076).

Root cause: Two React hooks (useBoxMetrics and useTerminalViewport) 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.Provider created a new object on every render, defeating any dependency-array checks in consumers.

Fix (3 files, 19 insertions, 6 deletions)

File Change
packages/ink/src/index.ts useBoxMetrics: add [ref.current] deps — measurement only runs when element changes
packages/ink/src/hooks/use-terminal-viewport.ts useTerminalViewport: add [elementRef.current, terminalSize] deps — skips expensive parent-chain walk on every render
packages/ink/src/components/App.tsx Memoize TerminalSizeContext.Provider value — keeps reference stable so deps checks work

Render chain before fix

wheel event → ScrollBox.scrollBy() → scheduleRender
  → useBoxMetrics (no deps) → measure → setSize → re-render
  → useTerminalViewport (no deps) → walk parent chain → update isVisible → re-render
  → 3-5 renders per wheel event → 30-80 renders/100ms → jitter

Verification

@esengine esengine left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 update isVisible.

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 for useTerminalViewport; useBoxMetrics needs something that actually changes when the layout changes — a ResizeObserver wired through state is the canonical fix).
  • Move the work to useLayoutEffect only 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:

  1. Drop the maxIterPerTurn hunks.
  2. Rework the deps — don't put ref.current in a dep array, ever; pick one of the alternatives above.
  3. Add a regression test that resizes a parent after first render and asserts useBoxMetrics reports the new dimensions, so the next person who touches this can't reintroduce the bug.

zhangyapu1 added a commit to zhangyapu1/DeepSeek-Reasonix that referenced this pull request May 28, 2026
…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
@zhangyapu1 zhangyapu1 force-pushed the fix/2076-scroll-jitter branch from e4f6a31 to ff51a0f Compare May 28, 2026 07:56

@esengine esengine left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@esengine esengine merged commit b5f884a into esengine:main May 29, 2026
4 checks passed
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.

2 participants