Skip to content

fix(app): eliminate terminal scroll lag by removing MutationObserver and throttling scroll events#1622

Open
TommyLike wants to merge 1 commit into
getpaseo:mainfrom
TommyLike:scroll-up-down
Open

fix(app): eliminate terminal scroll lag by removing MutationObserver and throttling scroll events#1622
TommyLike wants to merge 1 commit into
getpaseo:mainfrom
TommyLike:scroll-up-down

Conversation

@TommyLike

@TommyLike TommyLike commented Jun 20, 2026

Copy link
Copy Markdown

Summary

Fixes terminal scroll freezing and mouse wheel unresponsiveness on the web version by removing a redundant MutationObserver that was flooding React with setState calls on every xterm DOM mutation.

Closes #1621.

Root Cause

The MutationObserver in terminal-emulator.tsx observed the entire xterm DOM subtree (subtree: true + childList: true + attributes: true) and fired React setState on every single mutation. Since xterm.js aggressively mutates its DOM during rendering (every character, cursor blink, scroll line), this created a cascade of React reconciliation passes that blocked the main thread.

The ResizeObserver (already present) covers the legitimate use case — it watches .xterm-viewport and .xterm-scroll-area for size changes. The MutationObserver was completely redundant.

Changes

  1. Remove MutationObserver — redundant with the existing ResizeObserver
  2. Throttle scroll handler with requestAnimationFrame — batches setState calls to at most one per frame
  3. Early-return in updateViewportMetrics — skips setState if metrics unchanged since last emission
  4. Cancel pending rAF on cleanup — avoids stale updates after unmount

Files Modified

  • packages/app/src/components/terminal-emulator.tsx

Testing

  • ✅ Lint: no new errors (2 pre-existing)
  • ✅ Format: passed
  • ✅ Typecheck: no errors in terminal-emulator.tsx

Co-Authored-By: TommyLike tommylikehu@gmail.com

…and throttling scroll events

The MutationObserver with subtree:true was firing React setState on every
xterm DOM mutation (character output, cursor blink, scroll line rendering),
causing cascading React re-renders that blocked the main thread and made
the mouse wheel unresponsive on web.

Changes:
- Remove MutationObserver (redundant with ResizeObserver for viewport sizing)
- Throttle scroll handler with requestAnimationFrame to batch setState calls
- Early-return in updateViewportMetrics when metrics unchanged since last emit
- Cancel pending rAF on cleanup to avoid stale updates

Co-Authored-By: TommyLike <tommylikehu@gmail.com>
@greptile-apps

greptile-apps Bot commented Jun 20, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes terminal scroll lag by removing a MutationObserver that was flooding React with setState calls on every xterm DOM mutation (character render, cursor blink, scroll line), and replaces it with an rAF-based scroll throttle plus a ref-based metrics deduplication guard.

  • Removes MutationObserver watching the full xterm subtree; the existing ResizeObserver on .xterm-viewport and .xterm-scroll-area already covers legitimate size-change notifications.
  • Adds requestAnimationFrame throttling to the scroll handler and a lastMetricsRef early-return guard in updateViewportMetrics to batch setState calls to at most one per frame with no-op skipping when values are unchanged.
  • Cancels any pending rAF on effect cleanup to avoid stale updates after unmount.

Confidence Score: 4/5

The core MutationObserver removal is sound and the rAF throttle is correctly implemented, but there is one correctness gap in the deduplication guard that could leave the scrollbar permanently hidden in an edge case.

When the viewport-not-found branch fires, it resets viewportMetrics state to zeros but does not reset lastMetricsRef. If the effect subsequently re-runs and the live DOM values match the stale ref, updateViewportMetrics skips the setState, leaving the state at zeros while the DOM is fully valid — the scrollbar stays invisible until a real size change triggers a ResizeObserver callback.

packages/app/src/components/terminal-emulator.tsx — specifically the viewport-not-found early-return block and its interaction with lastMetricsRef.

Important Files Changed

Filename Overview
packages/app/src/components/terminal-emulator.tsx Removes the redundant MutationObserver, adds rAF-based scroll throttle, and deduplicates setState via lastMetricsRef — good approach, but the ref is not reset in the viewport-not-found early-return branch, creating a potential state/ref mismatch.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[DOM scroll / resize / xterm mutation] --> B{Event source}
    B -->|scroll event| C[handleViewportScroll]
    B -->|ResizeObserver| E[updateViewportMetrics - direct]
    C --> D{scrollRafId pending?}
    D -->|yes| F[drop event]
    D -->|no| G[requestAnimationFrame]
    G --> H[updateViewportMetrics]
    E --> H
    H --> I{metrics changed vs lastMetricsRef?}
    I -->|no change| J[early return — no setState]
    I -->|changed| K[update lastMetricsRef]
    K --> L[setViewportMetrics setState]
    L --> M[scrollbar visibility effect]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[DOM scroll / resize / xterm mutation] --> B{Event source}
    B -->|scroll event| C[handleViewportScroll]
    B -->|ResizeObserver| E[updateViewportMetrics - direct]
    C --> D{scrollRafId pending?}
    D -->|yes| F[drop event]
    D -->|no| G[requestAnimationFrame]
    G --> H[updateViewportMetrics]
    E --> H
    H --> I{metrics changed vs lastMetricsRef?}
    I -->|no change| J[early return — no setState]
    I -->|changed| K[update lastMetricsRef]
    K --> L[setViewportMetrics setState]
    L --> M[scrollbar visibility effect]
Loading

Comments Outside Diff (1)

  1. packages/app/src/components/terminal-emulator.tsx, line 576-581 (link)

    P1 lastMetricsRef is not reset in the "viewport not found" early-return path. If the effect fires once without a viewport (setting viewportMetrics state to {0,0,0}), and then re-fires with a viewport whose real DOM values happen to match the stale ref (e.g., same terminal dimensions as a prior session), updateViewportMetrics will pass the equality check and skip the setState — leaving the state frozen at {0,0,0} while the DOM is fully populated. The scrollbar would then stay permanently hidden.

Reviews (1): Last reviewed commit: "fix(app): eliminate terminal scroll lag ..." | Re-trigger Greptile

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.

bug: Terminal scroll freezes or becomes unresponsive on web when scrolling with mouse wheel

1 participant