Skip to content

perf(ui): yield scroll updates and highlight work to input timers#422

Merged
benvinegar merged 3 commits into
modem-dev:mainfrom
fink-andreas:perf/scroll-render-defer
Jun 13, 2026
Merged

perf(ui): yield scroll updates and highlight work to input timers#422
benvinegar merged 3 commits into
modem-dev:mainfrom
fink-andreas:perf/scroll-render-defer

Conversation

@fink-andreas

Copy link
Copy Markdown
Contributor

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.tsx

Previously, every scroll change scheduled a queueMicrotask to 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:

scheduledViewportRead = setTimeout(() => {
  readViewport();
}, 16);

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.ts

Previously, serialized syntax highlighting work used queueMicrotask:

queueMicrotask(() => {
  resolve(run());
});

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:

setTimeout(() => {
  resolve(run());
}, 0);

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.tsx

Previously, scrolling cleared hover/add-note affordances globally. The clear signal was passed to every visible file section, which could invalidate multiple memoized DiffSection renders.

Now Hunk tracks the currently hover-owned file:

const hoveredFileIdRef = useRef<string | null>(null);

On scroll, Hunk only clears hover state when a file actually owns hover actions, and only sends the clear signal to that file:

hoverClearSignal={
  addNoteHoverClearFileId === file.id ? addNoteHoverClearSignal : 0
}

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.ts

The benchmark previously wrapped scroll/render work in React test act() and Bun.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:

bun run typecheck
bun run lint -- src/ui/components/panes/DiffPane.tsx src/ui/diff/pierre.ts benchmarks/large-stream.ts
bun test src/ui/AppHost.scroll-regression.test.tsx
bun run bench:large-stream

Manual testing confirmed smoother continuous mouse-wheel scrolling and smoother held down-arrow scrolling.

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-apps

greptile-apps Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This 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 setTimeout(0) to yield to input/render callbacks, and scoping hover-clear signals to only the currently-hovered file to avoid cascading DiffSection re-renders.

  • DiffPane.tsx: Adds hoveredFileIdRef to track hover ownership without stale-closure problems, replaces the queueMicrotask viewport coalescer with setTimeout(16) (with proper cleanup in the effect return), and gates hoverClearSignal on the specific addNoteHoverClearFileId so other DiffSections skip the re-render.
  • pierre.ts: Single-line swap of queueMicrotasksetTimeout(0) in queueHighlightedWork to let UI timers interleave with background highlight jobs.
  • benchmarks/large-stream.ts: Disables IS_REACT_ACT_ENVIRONMENT post-init to stop the test harness from draining timers, but this also prevents the new 16 ms viewport-read timer from firing in the scroll-tick loop, so windowed_scroll_ticks_ms now measures a narrower slice of the scroll path than before.

Confidence Score: 4/5

The 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

Filename Overview
src/ui/components/panes/DiffPane.tsx Adds targeted hover-clear state (addNoteHoverClearFileId + hoveredFileIdRef) to avoid re-rendering all DiffSections on scroll, and changes viewport-read coalescing from queueMicrotask to a 16ms setTimeout with proper cleanup. Logic is well-structured and the ref/state pair stays synchronized.
src/ui/diff/pierre.ts Changes queueHighlightedWork serialization from queueMicrotask to setTimeout(0), yielding to input and frame timers between highlight jobs. Straightforward one-line swap with no functional change to job ordering or error handling.
benchmarks/large-stream.ts Extracts createBenchmarkRenderer() and disables IS_REACT_ACT_ENVIRONMENT globally to avoid test-harness timer draining. The global mutation means the 16ms viewport-read timer never fires during measureScrollTicksMs, so the scroll metric no longer exercises the full coalesced-read path optimized by this PR.
CHANGELOG.md Adds a single changelog entry for the scroll-coalescing and highlighting-scheduling improvements. Entry is accurate and well-worded.

Sequence Diagram

sequenceDiagram
    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
Loading

Comments Outside Diff (1)

  1. benchmarks/large-stream.ts, line 91-107 (link)

    P2 Scroll-tick metric may no longer exercise the coalesced viewport-read path

    With IS_REACT_ACT_ENVIRONMENT = false set before this loop, await setup.renderOnce() won't drain timers. The handleViewportChange handler schedules a setTimeout(..., 16) on each scroll event; since that timer is never advanced here, readViewport() / setScrollViewport() never fires during the measurement window. The loop is therefore timing scroll-event dispatch + a raw render with an unchanged React viewport state — it no longer measures the React-state-update cost that was the target of the coalescing optimization. The metric will report numbers that look good because the heaviest part of the optimized path isn't running.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: benchmarks/large-stream.ts
    Line: 91-107
    
    Comment:
    **Scroll-tick metric may no longer exercise the coalesced viewport-read path**
    
    With `IS_REACT_ACT_ENVIRONMENT = false` set before this loop, `await setup.renderOnce()` won't drain timers. The `handleViewportChange` handler schedules a `setTimeout(..., 16)` on each scroll event; since that timer is never advanced here, `readViewport()` / `setScrollViewport()` never fires during the measurement window. The loop is therefore timing scroll-event dispatch + a raw render with an unchanged React viewport state — it no longer measures the React-state-update cost that was the target of the coalescing optimization. The metric will report numbers that look good because the heaviest part of the optimized path isn't running.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
benchmarks/large-stream.ts:91-107
**Scroll-tick metric may no longer exercise the coalesced viewport-read path**

With `IS_REACT_ACT_ENVIRONMENT = false` set before this loop, `await setup.renderOnce()` won't drain timers. The `handleViewportChange` handler schedules a `setTimeout(..., 16)` on each scroll event; since that timer is never advanced here, `readViewport()` / `setScrollViewport()` never fires during the measurement window. The loop is therefore timing scroll-event dispatch + a raw render with an unchanged React viewport state — it no longer measures the React-state-update cost that was the target of the coalescing optimization. The metric will report numbers that look good because the heaviest part of the optimized path isn't running.

Reviews (1): Last reviewed commit: "perf(ui): yield scroll updates and highl..." | Re-trigger Greptile

@fink-andreas

Copy link
Copy Markdown
Contributor Author

Fixed in e991b25: the scroll benchmark now waits for VIEWPORT_READ_COALESCE_MS + 1 after each wheel event, so the timer-coalesced viewport read updates React state before renderOnce() is measured.

Fixed-harness results:

revision cold_first_frame_ms warm_first_frame_ms windowed_scroll_ticks_ms
before scroll-render-defer (834f7a8, fixed harness copied over) 76.76 63.22 7220.48
after scroll-render-defer (e991b25) 42.94 55.19 5802.51

So with the viewport-read path included, the scroll metric still improves from 7220.48ms to 5802.51ms for 4 scroll ticks (~20% faster).

@benvinegar

Copy link
Copy Markdown
Member

@fink-andreas Thanks for submitting. I made one tweak that removed a magic number (16) in favor of a declared const (maybe a bit inelegant having a dedicated file for this but hey, at least discoverable).

@benvinegar benvinegar merged commit 8403a5e into modem-dev:main Jun 13, 2026
7 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