Skip to content

perf(timeline): reduce project switch timeline rendering work#784

Merged
matt2e merged 5 commits into
mainfrom
proper-switch-fix
Jun 12, 2026
Merged

perf(timeline): reduce project switch timeline rendering work#784
matt2e merged 5 commits into
mainfrom
proper-switch-fix

Conversation

@matt2e

@matt2e matt2e commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Summary

  • lazy-mount branch timeline interiors only near the scroll viewport while preserving measured heights
  • cap collapsed branch timelines to the latest rows with a Show older expander
  • replace several per-row tooltip overlays with native title/aria labels to reduce DOM work

Tests

  • Push hooks ran: crates-fmt, crates-test, crates-lint, differ-ci, staged-ci

matt2e and others added 3 commits June 12, 2026 12:29
…se 1)

Phase 1 of the project-switch-freeze fix plan. Switching projects
synchronously mounts the incoming branch tree, and ~90% of that flush is
native DOM insertion of bits-ui Tooltip/ContextMenu portal trees scaling
as branches × rows × ~50 overlay instances. This attacks the per-row
×~50 multiplier.

- Replace the three metadata tooltips (meta, secondaryMeta,
  tertiaryMeta) with a native title= attribute. Their tooltip content
  was identical to the visible trigger text and only existed to reveal
  ellipsis-truncated values, which title= does with zero components.
- Trim the action-button tooltips: drop the bits-ui Tooltip wrapper for
  static, low-value labels (Start, Retry, Resume, Diff, View session),
  moving the label to title=/aria-label= on the Button. Keep bits-ui
  tooltips only where they surface a non-obvious *DisabledReason (Pull,
  Push, Force Push, Rebase, Commit, Discard, Delete).
- Context menu left unchanged (one per row, no native equivalent).

Behavior preserved: truncated meta still reveals full text on hover,
disabled-button reasons still surface, right-click menu still works.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
…ionObserver (phase 2a)

Phase 2a of the project-switch-freeze fix plan. Switching projects
synchronously mounts the incoming project's entire branch tree; the
dominant cost is DOM nodes created in one flush, scaling as
branches × rows × overlay-instances. Phase 1 cut the per-row overlay
multiplier. This makes per-switch node count nearly independent of
project size by not mounting the heavy timeline interior of off-screen
branch cards. Hiding nodes (display:none / content-visibility) does not
help — only not creating them does — so the interior is genuinely
{#if}-gated, not merely hidden.

- The cheap shell (header: PR/cloud icon, BranchCardHeaderInfo,
  BranchCardActionsBar, status badges) always renders. Only the heavy
  <BranchTimeline> is wrapped in {#if shouldMountInterior} ... {:else}
  a height-preserving placeholder. All existing props pass through
  unchanged when it mounts.
- An IntersectionObserver on the card root (existing cardElement bind)
  drives shouldMountInterior: true on enter, false on leave, so
  off-screen detail genuinely unmounts (extends "don't keep details
  mounted" to the scroll axis and relieves memory pressure). rootMargin
  '150% 0px' (~1.5 viewports) mounts interiors just before they scroll
  in and avoids mount/unmount thrash on brief scroll-bys. Observer root
  is the .main-panel scroll container via cardEl.closest('.main-panel'),
  falling back to the viewport (null) if not found. Created in an
  $effect and disconnected in its cleanup so switching projects can't
  leak observers; guarded for environments without IntersectionObserver.
- Placeholder height comes from the interior's last-measured offsetHeight,
  tracked by a ResizeObserver while mounted and cached in a module-level
  Map keyed by branch.id (survives both unmount and the component being
  destroyed/recreated across switches). Never-measured cards use a 160px
  estimate, corrected on first mount. Applied via style:min-height.

Side-effect check: <BranchTimeline> is a presentational consumer of the
`timeline` state, which BranchCard loads via its own effects/event
listeners independent of the timeline being mounted — so lazy-mounting
doesn't disrupt data flow or anything the header reads. Its only side
effect (liveSessionHintPoller) is cosmetic live-hint polling that pauses
while off-screen and restarts on re-mount; its only transient state
(liveSessionHints) is repopulated by that poller, so no user-meaningful
state is lost on unmount.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
…hase 2b)

Phase 2b of the project-switch-freeze fix plan, and the most
conditional/design-gated phase (it changes default-visible content, so
the truncated-older UX should get a design sign-off). The synchronous
project-switch flush cost scales as branches × rows × overlay-instances:
Phase 1 cut the per-row overlay count, Phase 2a stopped mounting
off-screen card interiors, and this bounds the inner `rows` term so a
single very long timeline can't blow up the flush even when its card is
on screen. Governing principle preserved: only NOT creating DOM nodes
helps, so deferred rows are genuinely unrendered (sliced array), never
CSS-hidden.

- Render only the latest N=20 normalItems by default (TIMELINE_ROW_CAP,
  a single named constant — raise it to effectively disable the cap).
  normalItems is sorted ascending, so the most-recent rows are the tail;
  we slice the tail (latest) and defer the older head rows. Only
  normalItems is capped — gitFooterItems, pending/drop placeholders, and
  the action footer render in their own loops and are untouched. The
  {#each} key stays (item.key); sliced rows keep their keys.
- A "Show {n} older" ghost Button (house Button component, size=xs)
  mounts the deferred rows on demand; it sits above the rows since older
  items belong at the head. "Show less" collapses back to the cheap
  state. aria-expanded reflects state.
- Expanded flag persists in a module-level Map<string, boolean> keyed by
  repoDir (the branch's worktree path) so it survives Phase 2a unmounting
  the whole BranchTimeline when the card scrolls >~1.5 viewports
  off-screen. BranchTimeline isn't passed branch.id and BranchCard must
  not be modified, so repoDir is the most stable branch-unique id in
  scope; rare cloud-only branches with no worktree path fall back to a
  non-persisted (resets on scroll-away) flag. Mirrors BranchCard's
  module-scoped interiorHeightCache pattern.

Composes with Phase 2a: collapsed interiors are shorter, so BranchCard's
ResizeObserver simply caches the shorter height — no action needed.

Verified: pnpm run check (svelte-check --fail-on-warnings + tsc) → 0
errors, 0 warnings; prettier --check clean on the edited file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
@matt2e matt2e requested review from baxen and wesbillman as code owners June 12, 2026 03:31

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 72c3463796

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread apps/staged/src/lib/features/timeline/BranchTimeline.svelte Outdated
matt2e added 2 commits June 12, 2026 13:36
Revert 72c3463 to restore full BranchTimeline row rendering and remove the show-older cap.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
@matt2e matt2e merged commit 620eee5 into main Jun 12, 2026
5 checks passed
@matt2e matt2e deleted the proper-switch-fix branch June 12, 2026 04:27
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.

1 participant