perf(ui): yield scroll updates and highlight work to input timers#422
Conversation
Rapid wheel and held-arrow scrolling could turn every native scroll delta into a React state update on a queueMicrotask callback, and serialized syntax highlight work was also scheduled as microtasks. Microtasks run before input and render timers, so a backlog of highlight jobs could starve OpenTUI's frame loop and amplify per-delta state churn into visible jank. Coalesce scrollbox change/layout-changed/resized listeners into a single 16ms timer-deferred viewport read so bursts collapse into at most one React update per frame. Move the per-file highlight queue from queueMicrotask to setTimeout so input and frame timers get a chance to run between jobs. Target hover/add- note clear signals to the file that actually owns the hover action instead of broadcasting to every visible DiffSection on scroll. Update the large-stream benchmark to drop the React act/Bun.sleep(0) wrapper that was measuring test- harness scheduling rather than the OpenTUI render loop.
Greptile SummaryThis PR reduces scroll jank by coalescing viewport-read state updates behind a 16 ms timer (instead of a microtask), moving syntax-highlight jobs from microtasks to
Confidence Score: 4/5The production changes to DiffPane and pierre.ts are safe to ship — hover state management stays synchronized between ref and React state, and the effect cleanup correctly cancels any in-flight timer. The core scroll coalescing and highlight-yielding changes are well-implemented with no logic gaps. The one concern is in the benchmark: disabling IS_REACT_ACT_ENVIRONMENT prevents the new 16 ms viewport-read timer from firing during the scroll-tick loop, so the windowed_scroll_ticks_ms metric no longer reflects the full production scroll path. The benchmark will show artificially fast numbers for the scroll path that was optimized, which could mask regressions in future PRs that benchmark against this baseline. benchmarks/large-stream.ts — the scroll-tick measurement no longer exercises the viewport-read state update that was the primary target of this optimization. Important Files Changed
Sequence DiagramsequenceDiagram
participant User
participant OpenTUI
participant handleViewportChange
participant setTimeout16
participant readViewport
participant React
User->>OpenTUI: scroll event (wheel / key repeat)
OpenTUI->>handleViewportChange: "change" / "layout-changed"
alt "scheduled = false"
handleViewportChange->>setTimeout16: schedule(16ms)
Note over handleViewportChange: scheduled = true
else "scheduled = true"
handleViewportChange-->>handleViewportChange: skip (coalesced)
end
Note over User,OpenTUI: more scroll events arrive within 16ms (all skipped)
setTimeout16->>readViewport: fire after ~16ms
readViewport->>React: setScrollViewport(top, height)
readViewport->>React: clearAddNoteHoverForScroll (targeted file only)
React-->>OpenTUI: re-render windowed DiffSections
|
|
Fixed in e991b25: the scroll benchmark now waits for Fixed-harness results:
So with the viewport-read path included, the scroll metric still improves from 7220.48ms to 5802.51ms for 4 scroll ticks (~20% faster). |
|
@fink-andreas Thanks for submitting. I made one tweak that removed a magic number ( |
Scroll Performance Improvements
Summary
This change makes Hunk scrolling smoother by reducing how much React and background work runs for each scroll tick. The biggest improvement is visible when holding the down arrow key or continuously scrolling with the mouse wheel.
What Changed
1. Batched scroll-position updates
File:
src/ui/components/panes/DiffPane.tsxPreviously, every scroll change scheduled a
queueMicrotaskto mirror the OpenTUI scrollbox position into React state. During rapid mouse wheel or key-repeat scrolling, that could turn many tiny native scroll deltas into many React state updates and review-stream re-renders.Now viewport reads are coalesced with a short frame timer:
In simple terms: instead of doing React work for every scroll event, Hunk updates React at most about once per frame while scroll input is arriving.
2. Syntax highlighting yields to input/render timers
File:
src/ui/diff/pierre.tsPreviously, serialized syntax highlighting work used
queueMicrotask:Highlighting is CPU-heavy background work. Microtasks run before timers, input, and render callbacks, so a long queue of highlight jobs could delay visible scrolling.
Now highlighting jobs use timer scheduling:
In simple terms: highlighting still runs in the background, but it yields between files so OpenTUI input and frame rendering get more chances to run.
3. Hover/add-note clearing is targeted
File:
src/ui/components/panes/DiffPane.tsxPreviously, scrolling cleared hover/add-note affordances globally. The clear signal was passed to every visible file section, which could invalidate multiple memoized
DiffSectionrenders.Now Hunk tracks the currently hover-owned file:
On scroll, Hunk only clears hover state when a file actually owns hover actions, and only sends the clear signal to that file:
In simple terms: scrolling no longer tells every visible diff section to update just to hide one hover control.
4. Large-stream benchmark measures render cost more directly
File:
benchmarks/large-stream.tsThe benchmark previously wrapped scroll/render work in React test
act()andBun.sleep(0), which exaggerated scroll costs because it measured test-harness scheduling behavior.The benchmark now avoids that artificial pattern and measures the OpenTUI render loop more directly. This did not directly fix production scrolling, but helped separate benchmark artifacts from real app scroll-path costs.
Verification
Commands run:
Manual testing confirmed smoother continuous mouse-wheel scrolling and smoother held down-arrow scrolling.