Skip to content

perf(tabs): scroll the active tab into view once per activation, not three times - #1277

Merged
h4yfans merged 2 commits into
mainfrom
tabs-open-cap-and-scroll-scheduling
Aug 12, 2026
Merged

perf(tabs): scroll the active tab into view once per activation, not three times#1277
h4yfans merged 2 commits into
mainfrom
tabs-open-cap-and-scroll-scheduling

Conversation

@h4yfans

@h4yfans h4yfans commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Closes #1105. Part of epic #987 (memory/CPU audit 2026-08).

What was wrong

apps/desktop/src/renderer/src/components/tabs/tab-bar-with-drag.tsx:111-116 (pre-fix) — one tab activation fired three smooth scrollIntoView animations:

useLayoutEffect(() => {
  if (!activeTabId || activeDragItem) return
  const tabEl = scrollRef.current?.querySelector(`[data-tab-id="${CSS.escape(activeTabId)}"]`)
  tabEl?.scrollIntoView?.({ inline: 'nearest', block: 'nearest', behavior: 'smooth' })
}, [activeTabId, regularTabsLength, activeDragItem, canScrollToStart, canScrollToEnd])

canScrollToStart / canScrollToEnd are gutter flags, and each one that flips adds a ps-7 / pe-7 gutter to the strip. The activation scroll is what makes them flip, so the effect re-entered twice more and re-issued a smooth scroll each time, restarting the animation mid-flight. Measured on a strip that overflows: 3 scrollIntoView calls for a single activation (1 on activation, +1 when canScrollToEnd flips in the same commit, +1 when canScrollToStart flips on the first scroll event).

The gutter re-runs are not pointless — a widening gutter really can push the tab back out of view — so they could not simply be dropped from the deps.

What changed

  • The effect now remembers which tab it last scrolled to (scrolledTabIdRef). A run whose target changed always animates; a repeat run for the same tab (i.e. a gutter-driven one) re-scrolls only if the tab is no longer fully inside the strip. isTabFullyVisible compares the rects and subtracts the strip's inline padding, so a tab hidden under a chevron still counts as out of view — the correction the old deps existed for is preserved, minus the redundant animations.
  • Docs: apps/docs/src/user-guide/tabs-split-view.md — one line on the single scroll animation, one line stating tabs are never auto-closed.

Deliberately NOT done: the tab cap

The issue also asks for a maxTabs soft cap with LRU-close. I did not add one, on purpose:

  • Every cap variant can destroy user state. LRU-closing loses a tab the user deliberately kept (and recentlyClosed only retains 25 entries, in memory, so it is not a safety net across a restart). Refusing to open past a limit means clicking a note in the sidebar silently does nothing. Both are worse than the thing they fix. This is a live-user build; tab state is persisted.
  • The cost being capped is already small and already bounded. Hidden tabs are not mounted — split-view/tab-pane.tsx:57 renders <TabContent> for the active tab only — so an idle tab costs one record plus its viewState. The other per-tab accumulators are already capped: activation history at 50 (reducers/history-helpers.ts:3), recently-closed at 25 (reducers/tab-crud-reducer.ts:29), and the persistence serialize + JSON.stringify now runs once per debounce window rather than per state change (perf(tabs): serialize the tab tree once per debounce window #1174, which closed [HIGH][tabs] Tab persistence serializes + JSON.stringifies the whole tab tree on every state change (debounce only covers the write) #1056 — re-verified in persistence/hooks.ts:62-88).
  • The user-facing remedy already exists and is non-destructive: Close others / Close to the right / Close all in the tab context menu, now called out in the docs.

The only maxTabs reference in the tree is a stray field in a test fixture (persistence/persistence.test.tsx:112) that is cast away and read by nothing; left untouched as pre-existing dead data. Since no cap is introduced, a persisted session of any size still restores in full.

How it was proven

New apps/desktop/src/renderer/src/components/tabs/tab-bar-scroll.test.tsx drives the real component with stubbed strip geometry (scrollWidth / clientWidth / scrollLeft), so the gutter flags flip exactly as they do in the app, and counts scrollIntoView calls.

after mount+activation after the second gutter flips after switching to another tab
before (origin/main source, same test) 2 3 3
after 1 1 2 (1 per activation)
  • Before: git checkout origin/main -- …/tab-bar-with-drag.tsxTest Files 1 failed (1) / Tests 2 failed (2)expected "vi.fn()" to be called 1 times, but got 2 times.
  • After: Test Files 1 passed (1) / Tests 2 passed (2).

Verification

./node_modules/.bin/vitest run --config apps/desktop/config/vitest.config.ts --project renderer \
  src/renderer/src/components/tabs src/renderer/src/components/cold-major-components.test.tsx \
  src/renderer/src/components/small-zero-surfaces.test.tsx src/renderer/src/components/split-view
  → Test Files 17 passed (17), Tests 90 passed (90)
    (includes the pre-existing "scrolls the active tab into view so it never stays
     past the strip edge" case, still green)

pnpm --filter @memry/desktop typecheck:web   → clean
pnpm exec eslint …/tab-bar-with-drag.tsx     → 0 errors
git diff --check                             → clean
pnpm docs:impact --base origin/main --strict → covered
pnpm docs:build                              → build complete

Backward compatibility

Render-time only: no schema, contract, settings, persisted-tab-state, or sync-shape change, and no cap that could drop tabs from an existing session.

@github-actions github-actions Bot added documentation Improvements or additions to documentation test labels Aug 12, 2026
@github-actions

Copy link
Copy Markdown

React Doctor found no new issues. 🎉

Reviewed by React Doctor for commit a463d1b.

@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation test

Projects

None yet

1 participant