Skip to content

perf(sidebar): coalesce the resize drag to one width update per frame - #131

Merged
amirlehmam merged 1 commit into
amirlehmam:masterfrom
ConnorRobinson13:pr/sidebar-drag-coalesce
Jul 31, 2026
Merged

perf(sidebar): coalesce the resize drag to one width update per frame#131
amirlehmam merged 1 commit into
amirlehmam:masterfrom
ConnorRobinson13:pr/sidebar-drag-coalesce

Conversation

@ConnorRobinson13

Copy link
Copy Markdown
Contributor

One sidebar drag does far more work than it needs to. Not a user-visible break on its own — this is churn, not correctness.

Cause

SidebarResizeHandle.tsx handles mousemove raw, with no throttle or rAF coalescing:

const onMouseMove = (ev: MouseEvent) => {
  const delta = ev.clientX - startX;
  onWidthChange(delta);
};

So ~60 width updates a second while dragging. Each one sets sidebarWidth in App.tsx, which relayouts the workspace — which wakes every terminal's ResizeObserver (useTerminal.ts:858), calls fit() and pty.resize(), and makes ConPTY re-emit its visible screen. A one-second drag is sixty rounds of that across every open terminal.

sidebarWidth is also a dependency of the auto-save listener effect and of handleSaveSession, so every intermediate value tears down and re-registers the listener and rebuilds the callback.

Fix

createResizeDrag collects pointer positions and emits at most one delta per frame. stop() flushes a position still waiting for its frame, so the width settles where the cursor was released rather than a frame behind it.

The frame scheduler is injected rather than calling requestAnimationFrame directly. That's the one design decision worth pointing at: it's what makes the coalescing testable in the existing node-environment unit suite, with no DOM and no new test dependency.

App.tsx reads the width through a ref instead of taking it as an effect/callback dependency, so only the settled width matters.

The rAF shape matches what useTerminal.ts already does at :860 and :980.

Behaviour

Unchanged from the user's side — the width tracks the cursor, and dragging below 80px still auto-collapses. The difference is one update per frame instead of one per event.

Verification

  • 7 unit tests drive the coalescing with a hand-cranked scheduler: burst-collapses-to-one, next frame emits again, stop() flushes the pending position, stop() cancels the frame, no emit when the pointer never moved, no double-emit.
  • npm test → 550 passed. The 3 failures (pty-manager, shell-context-menu) are present identically on unmodified master — both need native Windows.

`SidebarResizeHandle`'s mousemove handler was raw — one `onWidthChange`
per event, so roughly 60 width updates a second while dragging. Each one
relayouts the workspace, which wakes every terminal's ResizeObserver,
calls `fit()` and `pty.resize()`, and makes ConPTY re-emit its visible
screen. A one-second drag did sixty rounds of that across every open
terminal.

`sidebarWidth` was also a dependency of the auto-save listener effect and
the save callback, so each intermediate value re-subscribed the listener
and rebuilt the callback. Reading it through a ref keeps both stable, so
only the settled width matters.

`createResizeDrag` collects pointer positions and emits at most one delta
per frame. `stop()` flushes a position still waiting for its frame, so the
width settles where the cursor was released rather than a frame behind it.
The scheduler is injected rather than calling requestAnimationFrame
directly, which is what makes the coalescing testable in the
node-environment unit suite (7 tests, no DOM needed).

Behaviour is unchanged from the user's side: the width still tracks the
cursor, and dragging below 80px still auto-collapses.
@amirlehmam
amirlehmam merged commit 42f9655 into amirlehmam:master Jul 31, 2026
amirlehmam added a commit that referenced this pull request Jul 31, 2026
Merges simplyBarbe's end-to-end audit of the renderer for user-facing text
that never reached useT()/translate(): SplitPane, Tutorial, Sidebar, every
Settings sub-panel, ErrorBoundary, OrchestrationPanel, ShortcutCheatSheet,
the Diff pane, CopyMode/FindBar/bell, Titlebar notifications, AddressBar,
CommandPalette, and the default workspace/session titles.

Also carries the markdown-file fix: read/write errors always returned a
truthy English `error` string, so the renderer's t('markdown.error.*')
fallback could never fire. A stable `code` field now travels alongside it —
CLI and pipe callers still see the English message verbatim, the renderer
maps the code to a translated one.

Conflict resolved against PR #131: the save-session notification takes the
translated string, and its dependency array stays free of sidebarWidth,
which is now read through sidebarWidthRef so a drag cannot rebuild the
callback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@amirlehmam

Copy link
Copy Markdown
Owner

Merged in 42f9655, shipping in v0.40.0. Thanks — and the framing in the description ("this is churn, not correctness") is why I merged it as-is rather than asking for changes.

The cost chain you traced is the real argument: one mousemovesetSidebarWidth → workspace relayout → every terminal's ResizeObserverfit()pty.resize() → ConPTY re-emits its visible screen. At ~60 Hz across every open terminal, a one-second drag was doing far more work in the PTY layer than in the layout layer, and none of it was visible as a bug — which is exactly why it survived this long.

Injecting the frame scheduler instead of calling requestAnimationFrame directly is the decision I would have argued for, and you got there first. It is what makes the coalescing testable in the existing node-environment suite with no DOM and no new dependency, and the hand-cranked fakeFrames reads better than a mocked global would. stop() flushing the pending position is the detail that would have been missed: without it the width settles one frame behind the cursor, which is a visible regression traded for an invisible win.

The sidebarWidthRef change carries its own weight too — sidebarWidth as a dependency was tearing down and re-registering the auto-save listener on every intermediate value of a drag.

One note for the record: this collided with #136 on handleSaveSession. I resolved it in favour of both — the notification string is translated, and the dependency array stays free of sidebarWidth since it is read through the ref.

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