perf(timeline): reduce project switch timeline rendering work#784
Merged
Conversation
…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>
There was a problem hiding this comment.
💡 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".
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Tests