From f6f0b5b5967cedc6de01cafcaf76dd659dfe8f6a Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 5 Jun 2026 14:04:43 +1000 Subject: [PATCH 1/5] chore(staged): instrument project-switch render path with timing logs Add [perf][project-switch] debug logs to measure how long the detail page takes to render after a project switch: - navigation: stamp performance.now() in selectProject and expose msSinceProjectSwitch() so downstream components can report elapsed time - ProjectHome: log when the detail selection resolves and time the safe-to-delete check across visible projects - ProjectSection: time hashtag-item building and note loading, and log mount timing relative to the switch Also picks up a Cargo.lock sync for the doctor crate (nix, wait-timeout). Signed-off-by: Matt Toohey --- .../lib/features/layout/navigation.svelte.ts | 14 ++++++++++++++ .../lib/features/projects/ProjectHome.svelte | 18 +++++++++++++++++- .../features/projects/ProjectSection.svelte | 18 +++++++++++++++++- 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/apps/staged/src/lib/features/layout/navigation.svelte.ts b/apps/staged/src/lib/features/layout/navigation.svelte.ts index 437734d9..c6e5b7f9 100644 --- a/apps/staged/src/lib/features/layout/navigation.svelte.ts +++ b/apps/staged/src/lib/features/layout/navigation.svelte.ts @@ -78,8 +78,22 @@ export function showAllRepos(): void { navigation.showReposList = true; } +/** + * Timestamp (performance.now()) of the most recent project switch, used to + * measure how long the detail page takes to render. See [perf][project-switch] + * debug logs in ProjectHome/ProjectSection. + */ +let lastProjectSwitchAt = 0; + +/** Elapsed ms since the most recent project switch, for debug perf logging. */ +export function msSinceProjectSwitch(): number { + return performance.now() - lastProjectSwitchAt; +} + /** Navigate to a specific project's detail view. */ export function selectProject(projectId: string): void { + lastProjectSwitchAt = performance.now(); + console.info(`[perf][project-switch] selectProject('${projectId}') — switch started`); showWorkspaceView(); navigation.selectedProjectId = projectId; navigation.showReposList = false; diff --git a/apps/staged/src/lib/features/projects/ProjectHome.svelte b/apps/staged/src/lib/features/projects/ProjectHome.svelte index a5074f64..535bc5f8 100644 --- a/apps/staged/src/lib/features/projects/ProjectHome.svelte +++ b/apps/staged/src/lib/features/projects/ProjectHome.svelte @@ -18,7 +18,7 @@ import * as commands from '../../api/commands'; import { listenToRepoActionsDetection } from '../actions/actions'; import { projectDisplayName } from '../../shared/utils'; - import { goHome, selectProject } from '../layout/navigation.svelte'; + import { goHome, selectProject, msSinceProjectSwitch } from '../layout/navigation.svelte'; import ProjectSection from './ProjectSection.svelte'; import type { RepoSelection as RepoPickerSelection } from '../../shared/githubUrl'; import NewProjectModal from './NewProjectModal.svelte'; @@ -368,6 +368,17 @@ ) ); + // Debug: log how long after a project switch the detail selection resolves. + $effect(() => { + const id = selectedProjectId; + if (!id) return; + const found = visibleProjects.length > 0; + console.info( + `[perf][project-switch] ProjectHome selection resolved for '${id}' ` + + `(${visibleProjects.length} visible, found=${found}) at +${msSinceProjectSwitch().toFixed(1)}ms` + ); + }); + // Track which projects are safe to delete (for button styling) let safeToDeleteProjects = $state>(new Set()); @@ -377,6 +388,7 @@ // and the result is only consumed in the visibleProjects render loop. $effect(() => { const updateSafeStatus = async () => { + const startedAt = performance.now(); const nextSafe = new Set(); for (const project of visibleProjects) { @@ -401,6 +413,10 @@ } safeToDeleteProjects = nextSafe; + console.info( + `[perf][project-switch] ProjectHome safe-to-delete check finished for ` + + `${visibleProjects.length} visible project(s) in ${(performance.now() - startedAt).toFixed(1)}ms` + ); }; updateSafeStatus(); diff --git a/apps/staged/src/lib/features/projects/ProjectSection.svelte b/apps/staged/src/lib/features/projects/ProjectSection.svelte index 963a97c5..bceb13c1 100644 --- a/apps/staged/src/lib/features/projects/ProjectSection.svelte +++ b/apps/staged/src/lib/features/projects/ProjectSection.svelte @@ -30,7 +30,7 @@ HashtagItem, } from '../../types'; import { projectDisplayName } from '../../shared/utils'; - import { goHome } from '../layout/navigation.svelte'; + import { goHome, msSinceProjectSwitch } from '../layout/navigation.svelte'; import * as commands from '../../api/commands'; import HashtagInput from '../sessions/HashtagInput.svelte'; import { buildProjectHashtagItems } from '../sessions/hashtagItems'; @@ -211,9 +211,15 @@ $effect(() => { const _v = hashtagVersion; // reactive dependency for manual invalidation let stale = false; + const startedAt = performance.now(); buildProjectHashtagItems(project.id, branches, reposById) .then((items) => { if (!stale) hashtagItems = items; + console.info( + `[perf][project-switch] ProjectSection built ${items.length} hashtag item(s) for ` + + `'${project.id}' in ${(performance.now() - startedAt).toFixed(1)}ms` + + (stale ? ' (stale, discarded)' : '') + ); }) .catch((err) => { console.error('[ProjectSection] Failed to build hashtag items:', err); @@ -408,8 +414,14 @@ let deletingNoteIds = $state>(new Set()); async function loadProjectNotes() { + const startedAt = performance.now(); try { projectNotes = await commands.listProjectNotes(project.id); + console.info( + `[perf][project-switch] ProjectSection loaded ${projectNotes.length} note(s) for ` + + `'${project.id}' in ${(performance.now() - startedAt).toFixed(1)}ms ` + + `(+${msSinceProjectSwitch().toFixed(1)}ms since switch)` + ); } catch (e) { console.error('[ProjectSection] Failed to load project notes:', e); } @@ -465,6 +477,10 @@ // ── Lifecycle ────────────────────────────────────────────────────────── onMount(() => { + console.info( + `[perf][project-switch] ProjectSection mounted for '${project.id}' ` + + `(${branches.length} branch(es)) at +${msSinceProjectSwitch().toFixed(1)}ms since switch` + ); loadProjectNotes(); // Refresh hashtag items when branch timelines are invalidated (e.g. branch session completion) From 0de9d61b84846ad168640eff086b6a431fa9edfe Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 5 Jun 2026 14:32:27 +1000 Subject: [PATCH 2/5] chore(staged): refine project-switch timing logs with switch tokens Sharpen the [perf][project-switch] instrumentation so the logs can be read unambiguously across re-fires and the keyed-block swap: - navigation: replace the bare lastProjectSwitchAt timestamp with a currentSwitch record carrying a monotonic token plus the target project id; expose currentProjectSwitchToken/Target and stamp the end of selectProject's synchronous body to isolate sync work from the unmeasured section swap. - ProjectHome: only the first effect firing per switch token reports elapsed-since-switch; later event-driven re-fires are labelled as such so they don't report a misleading elapsed value. - ProjectSection: log teardown timing in onDestroy and tag mount/destroy logs with the switch token, bracketing the keyed-block swap. Signed-off-by: Matt Toohey --- .../lib/features/layout/navigation.svelte.ts | 37 +++++++++++++++---- .../lib/features/projects/ProjectHome.svelte | 20 +++++++++- .../features/projects/ProjectSection.svelte | 19 +++++++++- 3 files changed, 65 insertions(+), 11 deletions(-) diff --git a/apps/staged/src/lib/features/layout/navigation.svelte.ts b/apps/staged/src/lib/features/layout/navigation.svelte.ts index c6e5b7f9..7d503559 100644 --- a/apps/staged/src/lib/features/layout/navigation.svelte.ts +++ b/apps/staged/src/lib/features/layout/navigation.svelte.ts @@ -79,21 +79,37 @@ export function showAllRepos(): void { } /** - * Timestamp (performance.now()) of the most recent project switch, used to - * measure how long the detail page takes to render. See [perf][project-switch] - * debug logs in ProjectHome/ProjectSection. + * The most recent project switch, used to measure how long the detail page + * takes to render. Each switch gets a fresh, monotonically increasing token so + * downstream debug effects can tell whether they are reporting the initial + * post-switch render or a later re-fire (which would otherwise report a + * misleading elapsed-since-switch). See [perf][project-switch] debug logs in + * ProjectHome/ProjectSection. */ -let lastProjectSwitchAt = 0; +let currentSwitch = { projectId: null as string | null, at: 0, token: 0 }; /** Elapsed ms since the most recent project switch, for debug perf logging. */ export function msSinceProjectSwitch(): number { - return performance.now() - lastProjectSwitchAt; + return performance.now() - currentSwitch.at; +} + +/** Token identifying the most recent project switch (bumped on every switch). */ +export function currentProjectSwitchToken(): number { + return currentSwitch.token; +} + +/** The project id targeted by the most recent switch, or null. */ +export function currentProjectSwitchTarget(): string | null { + return currentSwitch.projectId; } /** Navigate to a specific project's detail view. */ export function selectProject(projectId: string): void { - lastProjectSwitchAt = performance.now(); - console.info(`[perf][project-switch] selectProject('${projectId}') — switch started`); + const startedAt = performance.now(); + currentSwitch = { projectId, at: startedAt, token: currentSwitch.token + 1 }; + console.info( + `[perf][project-switch] selectProject('${projectId}') — switch started (token ${currentSwitch.token})` + ); showWorkspaceView(); navigation.selectedProjectId = projectId; navigation.showReposList = false; @@ -102,6 +118,13 @@ export function selectProject(projectId: string): void { if (projectStateStore.isUnread(projectId)) { projectStateStore.markAsRead(projectId); } + // Stamp the end of the synchronous body so we can isolate how much of the + // switch latency is spent here vs. in the (unmeasured) keyed-block swap that + // tears down the old ProjectSection and mounts the new one. + console.info( + `[perf][project-switch] selectProject('${projectId}') — synchronous body done in ` + + `${(performance.now() - startedAt).toFixed(1)}ms (token ${currentSwitch.token})` + ); } /** Navigate to a project and scroll to a specific branch card. */ diff --git a/apps/staged/src/lib/features/projects/ProjectHome.svelte b/apps/staged/src/lib/features/projects/ProjectHome.svelte index 535bc5f8..cef0dfb0 100644 --- a/apps/staged/src/lib/features/projects/ProjectHome.svelte +++ b/apps/staged/src/lib/features/projects/ProjectHome.svelte @@ -18,7 +18,13 @@ import * as commands from '../../api/commands'; import { listenToRepoActionsDetection } from '../actions/actions'; import { projectDisplayName } from '../../shared/utils'; - import { goHome, selectProject, msSinceProjectSwitch } from '../layout/navigation.svelte'; + import { + goHome, + selectProject, + msSinceProjectSwitch, + currentProjectSwitchToken, + currentProjectSwitchTarget, + } from '../layout/navigation.svelte'; import ProjectSection from './ProjectSection.svelte'; import type { RepoSelection as RepoPickerSelection } from '../../shared/githubUrl'; import NewProjectModal from './NewProjectModal.svelte'; @@ -369,13 +375,23 @@ ); // Debug: log how long after a project switch the detail selection resolves. + // Only the first firing per switch token reports elapsed-since-switch timing; + // later re-fires (driven by events, not the switch) are labelled as re-fires + // so they don't report a misleading elapsed value. + let lastResolvedSwitchToken = -1; $effect(() => { const id = selectedProjectId; if (!id) return; const found = visibleProjects.length > 0; + const token = currentProjectSwitchToken(); + const isInitial = token !== lastResolvedSwitchToken && id === currentProjectSwitchTarget(); + lastResolvedSwitchToken = token; console.info( `[perf][project-switch] ProjectHome selection resolved for '${id}' ` + - `(${visibleProjects.length} visible, found=${found}) at +${msSinceProjectSwitch().toFixed(1)}ms` + `(${visibleProjects.length} visible, found=${found}) ` + + (isInitial + ? `at +${msSinceProjectSwitch().toFixed(1)}ms (token ${token})` + : `(re-fire, token ${token})`) ); }); diff --git a/apps/staged/src/lib/features/projects/ProjectSection.svelte b/apps/staged/src/lib/features/projects/ProjectSection.svelte index bceb13c1..75d1cd6c 100644 --- a/apps/staged/src/lib/features/projects/ProjectSection.svelte +++ b/apps/staged/src/lib/features/projects/ProjectSection.svelte @@ -30,7 +30,11 @@ HashtagItem, } from '../../types'; import { projectDisplayName } from '../../shared/utils'; - import { goHome, msSinceProjectSwitch } from '../layout/navigation.svelte'; + import { + goHome, + msSinceProjectSwitch, + currentProjectSwitchToken, + } from '../layout/navigation.svelte'; import * as commands from '../../api/commands'; import HashtagInput from '../sessions/HashtagInput.svelte'; import { buildProjectHashtagItems } from '../sessions/hashtagItems'; @@ -202,7 +206,17 @@ }); onDestroy(() => { + // Stamp when the outgoing section tears down relative to the switch. Pairing + // this with the new section's mount log brackets the keyed-block swap: if + // teardown starts late, the gap is upstream (flush is blocked); if it starts + // early but mount is late, the gap is in the swap itself. + const startedAt = performance.now(); liveSessionHintPoller.destroy(); + console.info( + `[perf][project-switch] ProjectSection destroyed for '${project.id}' in ` + + `${(performance.now() - startedAt).toFixed(1)}ms (+${msSinceProjectSwitch().toFixed(1)}ms since switch, ` + + `token ${currentProjectSwitchToken()})` + ); }); // Hashtag reference items @@ -479,7 +493,8 @@ onMount(() => { console.info( `[perf][project-switch] ProjectSection mounted for '${project.id}' ` + - `(${branches.length} branch(es)) at +${msSinceProjectSwitch().toFixed(1)}ms since switch` + `(${branches.length} branch(es)) at +${msSinceProjectSwitch().toFixed(1)}ms since switch ` + + `(token ${currentProjectSwitchToken()})` ); loadProjectNotes(); From 9383693ae662a976c9a8f7b5aa8a76f7b0904a81 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 5 Jun 2026 14:55:42 +1000 Subject: [PATCH 3/5] fix(staged): stop safe-to-delete git check from freezing project switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cosmetic "safe-to-delete" button styling ran an eager $effect that spawned a per-branch `hasUnpushedCommits` git subprocess loop on every project switch. For a project with real local branches it cost ~5s cold, re-fired ~12x with no stale guard, and held the event loop so the keyed-block swap couldn't flush (teardown +9915ms, mount +10864ms). Phase 1 (frontend, ProjectHome.svelte) — the real fix: - Defer updateSafeStatus() off the critical render path via requestIdleCallback (setTimeout fallback), cancelled in teardown, so the switch's section swap flushes immediately and the cosmetic styling settles late. - Add a stale guard (mirroring the sibling hashtag effect) that aborts in-flight work on re-fire/teardown so older loops can't clobber newer state. - Dedupe on a cheap structural signature of only the inputs that affect the result (prState, branchType, repoCount, prHeadSha). Background hydration reassigns the source Maps ~12x per switch without changing these fields; the signature early-return spawns no git work. Trades a small staleness window in the red styling for eliminating the freeze. - Parallelize the check across visible projects via Promise.all instead of sequential for-await. Phase 2 (backend, prs.rs): - Wrap the local-branch path of has_unpushed_commits_impl in spawn_blocking, matching the remote path, so a slow cold git subprocess can't block the Tauri IPC thread. Extract computeSafeToDeleteSignature into projectDeleteSafety.ts with unit tests covering identical-input stability and prHeadSha/repoCount invalidation. Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/prs.rs | 12 ++- .../lib/features/projects/ProjectHome.svelte | 93 ++++++++++++++----- .../projects/projectDeleteSafety.test.ts | 43 ++++++++- .../features/projects/projectDeleteSafety.ts | 29 ++++++ 4 files changed, 153 insertions(+), 24 deletions(-) diff --git a/apps/staged/src-tauri/src/prs.rs b/apps/staged/src-tauri/src/prs.rs index 29df3413..9cf1af05 100644 --- a/apps/staged/src-tauri/src/prs.rs +++ b/apps/staged/src-tauri/src/prs.rs @@ -1080,8 +1080,16 @@ pub(crate) async fn has_unpushed_commits_impl( .map_err(|e| e.to_string())? .ok_or_else(|| format!("No worktree for branch: {branch_id}"))?; - git::has_unpushed_commits(Path::new(&workdir.path), &branch.branch_name) - .map_err(|e| e.to_string()) + // Run the blocking git subprocesses on a background thread so a slow cold + // `git` invocation can't block the Tauri IPC thread and freeze the UI, + // matching the remote path above. + let path = workdir.path.clone(); + let branch_name = branch.branch_name.clone(); + tauri::async_runtime::spawn_blocking(move || { + git::has_unpushed_commits(Path::new(&path), &branch_name).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("has_unpushed_commits task failed: {e}"))? } /// Push a branch to its remote by kicking off an agent session. diff --git a/apps/staged/src/lib/features/projects/ProjectHome.svelte b/apps/staged/src/lib/features/projects/ProjectHome.svelte index cef0dfb0..7dd509f1 100644 --- a/apps/staged/src/lib/features/projects/ProjectHome.svelte +++ b/apps/staged/src/lib/features/projects/ProjectHome.svelte @@ -38,7 +38,10 @@ import { projectRunActionsStore } from '../../stores/projectRunActions.svelte'; import { repoBadgeStore } from '../../stores/repoBadges.svelte'; import { projectStateStore } from '../../stores/projectState.svelte'; - import { canDeleteProjectWithoutConfirmation } from './projectDeleteSafety'; + import { + canDeleteProjectWithoutConfirmation, + computeSafeToDeleteSignature, + } from './projectDeleteSafety'; /** * Merge incoming branches with existing ones, preserving worktreePath when @@ -402,40 +405,88 @@ // Only check visible projects — calling hasUnpushedCommits for every // project wastes IPC round-trips (especially expensive for remote branches) // and the result is only consumed in the visibleProjects render loop. + // Signature of the last inputs the safe-to-delete check actually ran against. + // Background hydration/pollers reassign visibleProjects/branchesByProject/ + // repoCountsByProject many times per switch even when the fields this check + // depends on are unchanged; deduping on the signature keeps the expensive + // per-branch git work from re-firing on every reassignment. + let lastSafeSignature: string | null = null; $effect(() => { + // Read reactive deps synchronously so the effect re-subscribes correctly. + const projectsSnapshot = visibleProjects; + const branches = branchesByProject; + const repoCounts = repoCountsByProject; + + const signature = computeSafeToDeleteSignature(projectsSnapshot, branches, repoCounts); + if (signature === lastSafeSignature) { + // Inputs relevant to the result are unchanged — skip the git work. + return; + } + lastSafeSignature = signature; + + // Bail out of stale work: if the effect re-fires (or the component tears + // down) before this run resolves, `stale` flips so we neither spawn the + // git loop's tail nor clobber newer state. + let stale = false; + let idleHandle: number | undefined; + const updateSafeStatus = async () => { const startedAt = performance.now(); - const nextSafe = new Set(); - for (const project of visibleProjects) { - const branches = branchesByProject.get(project.id) || []; - const repoCount = repoCountsByProject.get(project.id) || 0; + // Parallelize across projects so the project-home grid doesn't serialize + // every project's git work. + const results = await Promise.all( + projectsSnapshot.map(async (project) => { + const projectBranches = branches.get(project.id) || []; + const repoCount = repoCounts.get(project.id) || 0; + + // Don't show red styling for projects with no repos — there's nothing + // to call attention to when no repositories have been added yet. + if (repoCount === 0) { + return { id: project.id, safe: false }; + } - // Don't show red styling for projects with no repos — there's nothing - // to call attention to when no repositories have been added yet. - if (repoCount === 0) { - continue; - } + const safe = await canDeleteProjectWithoutConfirmation({ + branches: projectBranches, + repoCount, + hasUnpushedCommits: commands.hasUnpushedCommits, + }); + return { id: project.id, safe }; + }) + ); - const safe = await canDeleteProjectWithoutConfirmation({ - branches, - repoCount, - hasUnpushedCommits: commands.hasUnpushedCommits, - }); + if (stale) return; - if (safe) { - nextSafe.add(project.id); - } + const nextSafe = new Set(); + for (const { id, safe } of results) { + if (safe) nextSafe.add(id); } - safeToDeleteProjects = nextSafe; console.info( `[perf][project-switch] ProjectHome safe-to-delete check finished for ` + - `${visibleProjects.length} visible project(s) in ${(performance.now() - startedAt).toFixed(1)}ms` + `${projectsSnapshot.length} visible project(s) in ${(performance.now() - startedAt).toFixed(1)}ms` ); }; - updateSafeStatus(); + // Defer off the critical render path: let the switch's keyed-block swap + // flush first, then settle the cosmetic styling during idle. + const schedule = + typeof requestIdleCallback === 'function' + ? (cb: () => void) => requestIdleCallback(cb) + : (cb: () => void) => setTimeout(cb, 0) as unknown as number; + const cancel = + typeof cancelIdleCallback === 'function' + ? (handle: number) => cancelIdleCallback(handle) + : (handle: number) => clearTimeout(handle); + + idleHandle = schedule(() => { + void updateSafeStatus(); + }); + + return () => { + stale = true; + if (idleHandle !== undefined) cancel(idleHandle); + }; }); $effect(() => { diff --git a/apps/staged/src/lib/features/projects/projectDeleteSafety.test.ts b/apps/staged/src/lib/features/projects/projectDeleteSafety.test.ts index 6a0c2823..510a7eea 100644 --- a/apps/staged/src/lib/features/projects/projectDeleteSafety.test.ts +++ b/apps/staged/src/lib/features/projects/projectDeleteSafety.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi } from 'vitest'; import type { Branch } from '../../types'; -import { canDeleteProjectWithoutConfirmation } from './projectDeleteSafety'; +import { + canDeleteProjectWithoutConfirmation, + computeSafeToDeleteSignature, +} from './projectDeleteSafety'; function branch(overrides: Partial = {}): Branch { return { @@ -113,3 +116,41 @@ describe('canDeleteProjectWithoutConfirmation', () => { expect(onCheckError).toHaveBeenCalledWith(error, checkedBranch); }); }); + +describe('computeSafeToDeleteSignature', () => { + const projects = [{ id: 'project-1' }]; + + it('produces an identical signature for identical inputs', () => { + const branchesA = new Map([['project-1', [branch()]]]); + const branchesB = new Map([['project-1', [branch()]]]); + const repoCounts = new Map([['project-1', 1]]); + + expect(computeSafeToDeleteSignature(projects, branchesA, repoCounts)).toBe( + computeSafeToDeleteSignature(projects, branchesB, repoCounts) + ); + }); + + it('changes when a branch prHeadSha changes', () => { + const repoCounts = new Map([['project-1', 1]]); + const before = computeSafeToDeleteSignature( + projects, + new Map([['project-1', [branch({ prHeadSha: 'abc' })]]]), + repoCounts + ); + const after = computeSafeToDeleteSignature( + projects, + new Map([['project-1', [branch({ prHeadSha: 'def' })]]]), + repoCounts + ); + + expect(before).not.toBe(after); + }); + + it('changes when the repo count changes', () => { + const branches = new Map([['project-1', [branch()]]]); + const before = computeSafeToDeleteSignature(projects, branches, new Map([['project-1', 1]])); + const after = computeSafeToDeleteSignature(projects, branches, new Map([['project-1', 2]])); + + expect(before).not.toBe(after); + }); +}); diff --git a/apps/staged/src/lib/features/projects/projectDeleteSafety.ts b/apps/staged/src/lib/features/projects/projectDeleteSafety.ts index e284b69b..90268e09 100644 --- a/apps/staged/src/lib/features/projects/projectDeleteSafety.ts +++ b/apps/staged/src/lib/features/projects/projectDeleteSafety.ts @@ -32,3 +32,32 @@ export async function canDeleteProjectWithoutConfirmation({ return branchSafety.every(Boolean); } + +/** + * Compute a cheap signature of only the inputs that affect the safe-to-delete + * result, so the eager `$effect` in ProjectHome can skip recomputation (and the + * expensive per-branch git work it spawns) when background hydration reassigns + * the source Maps without actually changing the relevant fields. + * + * Keys on exactly the fields `canDeleteProjectWithoutConfirmation` branches on + * (`prState`, `branchType`, `repoCount`) plus `prHeadSha`, which invalidates the + * cache when a PR head moves — the main case where unpushed-commit state + * changes in practice. This trades a small staleness window (a local commit not + * reflected in the cosmetic styling until the next genuine input change) for + * eliminating the per-switch freeze. + */ +export function computeSafeToDeleteSignature( + projects: Array<{ id: string }>, + branchesByProject: Map, + repoCountsByProject: Map +): string { + return projects + .map((p) => { + const repoCount = repoCountsByProject.get(p.id) || 0; + const branches = (branchesByProject.get(p.id) || []) + .map((b) => `${b.id}:${b.prState}:${b.branchType}:${b.prHeadSha ?? ''}`) + .join(','); + return `${p.id}|${repoCount}|${branches}`; + }) + .join(';'); +} From 0ed134132efe21ce97df498b5204002f0a3e1fd3 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 5 Jun 2026 15:02:06 +1000 Subject: [PATCH 4/5] fix(staged): keep safe-to-delete dedup from dropping its only scheduled check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve review feedback on the safe-to-delete deferral: - Record lastSafeSignature only after updateSafeStatus actually completes (inside the !stale block), not before scheduling. Previously a re-fire or teardown could cancel the pending idle callback after the signature was already stamped, so the next fire computed the same signature and early-returned without rescheduling — dropping the check permanently for that signature. Now a cancelled run leaves the signature unchanged and the next fire reschedules. - Pass a 2000ms timeout to requestIdleCallback so the cosmetic check still runs even while the main thread stays busy through the post-switch hydration window, instead of being deferred indefinitely. Signed-off-by: Matt Toohey --- .../src/lib/features/projects/ProjectHome.svelte | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/staged/src/lib/features/projects/ProjectHome.svelte b/apps/staged/src/lib/features/projects/ProjectHome.svelte index 7dd509f1..9d10f116 100644 --- a/apps/staged/src/lib/features/projects/ProjectHome.svelte +++ b/apps/staged/src/lib/features/projects/ProjectHome.svelte @@ -422,7 +422,6 @@ // Inputs relevant to the result are unchanged — skip the git work. return; } - lastSafeSignature = signature; // Bail out of stale work: if the effect re-fires (or the component tears // down) before this run resolves, `stale` flips so we neither spawn the @@ -462,6 +461,11 @@ if (safe) nextSafe.add(id); } safeToDeleteProjects = nextSafe; + // Only record the signature once the check actually completes. If this + // run is cancelled (re-fire/teardown) before it gets here, the signature + // stays unchanged so the next fire reschedules instead of dropping the + // check permanently for that signature. + lastSafeSignature = signature; console.info( `[perf][project-switch] ProjectHome safe-to-delete check finished for ` + `${projectsSnapshot.length} visible project(s) in ${(performance.now() - startedAt).toFixed(1)}ms` @@ -469,10 +473,13 @@ }; // Defer off the critical render path: let the switch's keyed-block swap - // flush first, then settle the cosmetic styling during idle. + // flush first, then settle the cosmetic styling during idle. The timeout + // guarantees the check still runs even while the main thread stays busy + // through the post-switch hydration window (otherwise an idle callback can + // be deferred indefinitely under sustained load). const schedule = typeof requestIdleCallback === 'function' - ? (cb: () => void) => requestIdleCallback(cb) + ? (cb: () => void) => requestIdleCallback(cb, { timeout: 2000 }) : (cb: () => void) => setTimeout(cb, 0) as unknown as number; const cancel = typeof cancelIdleCallback === 'function' From 04d7126902aed5bf87f644a5f1014febda3e09c1 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 5 Jun 2026 16:32:00 +1000 Subject: [PATCH 5/5] chore(staged): remove project-switch timing instrumentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The [perf][project-switch] debug logs added earlier on this branch did their job — they traced the stall to the cosmetic safe-to-delete git check, which is now fixed. Strip the instrumentation back out: - navigation: drop currentSwitch/msSinceProjectSwitch/ currentProjectSwitchToken/currentProjectSwitchTarget and restore selectProject to its original body. - ProjectHome: remove the selection-resolved debug effect and the safe-to-delete timing log (keeping the actual deferral/dedup fix). - ProjectSection: remove the mount/destroy, hashtag-build, and note-load timing logs and their now-unused imports. Signed-off-by: Matt Toohey --- .../lib/features/layout/navigation.svelte.ts | 37 ------------------- .../lib/features/projects/ProjectHome.svelte | 35 +----------------- .../features/projects/ProjectSection.svelte | 33 +---------------- 3 files changed, 2 insertions(+), 103 deletions(-) diff --git a/apps/staged/src/lib/features/layout/navigation.svelte.ts b/apps/staged/src/lib/features/layout/navigation.svelte.ts index 7d503559..437734d9 100644 --- a/apps/staged/src/lib/features/layout/navigation.svelte.ts +++ b/apps/staged/src/lib/features/layout/navigation.svelte.ts @@ -78,38 +78,8 @@ export function showAllRepos(): void { navigation.showReposList = true; } -/** - * The most recent project switch, used to measure how long the detail page - * takes to render. Each switch gets a fresh, monotonically increasing token so - * downstream debug effects can tell whether they are reporting the initial - * post-switch render or a later re-fire (which would otherwise report a - * misleading elapsed-since-switch). See [perf][project-switch] debug logs in - * ProjectHome/ProjectSection. - */ -let currentSwitch = { projectId: null as string | null, at: 0, token: 0 }; - -/** Elapsed ms since the most recent project switch, for debug perf logging. */ -export function msSinceProjectSwitch(): number { - return performance.now() - currentSwitch.at; -} - -/** Token identifying the most recent project switch (bumped on every switch). */ -export function currentProjectSwitchToken(): number { - return currentSwitch.token; -} - -/** The project id targeted by the most recent switch, or null. */ -export function currentProjectSwitchTarget(): string | null { - return currentSwitch.projectId; -} - /** Navigate to a specific project's detail view. */ export function selectProject(projectId: string): void { - const startedAt = performance.now(); - currentSwitch = { projectId, at: startedAt, token: currentSwitch.token + 1 }; - console.info( - `[perf][project-switch] selectProject('${projectId}') — switch started (token ${currentSwitch.token})` - ); showWorkspaceView(); navigation.selectedProjectId = projectId; navigation.showReposList = false; @@ -118,13 +88,6 @@ export function selectProject(projectId: string): void { if (projectStateStore.isUnread(projectId)) { projectStateStore.markAsRead(projectId); } - // Stamp the end of the synchronous body so we can isolate how much of the - // switch latency is spent here vs. in the (unmeasured) keyed-block swap that - // tears down the old ProjectSection and mounts the new one. - console.info( - `[perf][project-switch] selectProject('${projectId}') — synchronous body done in ` + - `${(performance.now() - startedAt).toFixed(1)}ms (token ${currentSwitch.token})` - ); } /** Navigate to a project and scroll to a specific branch card. */ diff --git a/apps/staged/src/lib/features/projects/ProjectHome.svelte b/apps/staged/src/lib/features/projects/ProjectHome.svelte index 9d10f116..1107088b 100644 --- a/apps/staged/src/lib/features/projects/ProjectHome.svelte +++ b/apps/staged/src/lib/features/projects/ProjectHome.svelte @@ -18,13 +18,7 @@ import * as commands from '../../api/commands'; import { listenToRepoActionsDetection } from '../actions/actions'; import { projectDisplayName } from '../../shared/utils'; - import { - goHome, - selectProject, - msSinceProjectSwitch, - currentProjectSwitchToken, - currentProjectSwitchTarget, - } from '../layout/navigation.svelte'; + import { goHome, selectProject } from '../layout/navigation.svelte'; import ProjectSection from './ProjectSection.svelte'; import type { RepoSelection as RepoPickerSelection } from '../../shared/githubUrl'; import NewProjectModal from './NewProjectModal.svelte'; @@ -377,27 +371,6 @@ ) ); - // Debug: log how long after a project switch the detail selection resolves. - // Only the first firing per switch token reports elapsed-since-switch timing; - // later re-fires (driven by events, not the switch) are labelled as re-fires - // so they don't report a misleading elapsed value. - let lastResolvedSwitchToken = -1; - $effect(() => { - const id = selectedProjectId; - if (!id) return; - const found = visibleProjects.length > 0; - const token = currentProjectSwitchToken(); - const isInitial = token !== lastResolvedSwitchToken && id === currentProjectSwitchTarget(); - lastResolvedSwitchToken = token; - console.info( - `[perf][project-switch] ProjectHome selection resolved for '${id}' ` + - `(${visibleProjects.length} visible, found=${found}) ` + - (isInitial - ? `at +${msSinceProjectSwitch().toFixed(1)}ms (token ${token})` - : `(re-fire, token ${token})`) - ); - }); - // Track which projects are safe to delete (for button styling) let safeToDeleteProjects = $state>(new Set()); @@ -430,8 +403,6 @@ let idleHandle: number | undefined; const updateSafeStatus = async () => { - const startedAt = performance.now(); - // Parallelize across projects so the project-home grid doesn't serialize // every project's git work. const results = await Promise.all( @@ -466,10 +437,6 @@ // stays unchanged so the next fire reschedules instead of dropping the // check permanently for that signature. lastSafeSignature = signature; - console.info( - `[perf][project-switch] ProjectHome safe-to-delete check finished for ` + - `${projectsSnapshot.length} visible project(s) in ${(performance.now() - startedAt).toFixed(1)}ms` - ); }; // Defer off the critical render path: let the switch's keyed-block swap diff --git a/apps/staged/src/lib/features/projects/ProjectSection.svelte b/apps/staged/src/lib/features/projects/ProjectSection.svelte index 75d1cd6c..963a97c5 100644 --- a/apps/staged/src/lib/features/projects/ProjectSection.svelte +++ b/apps/staged/src/lib/features/projects/ProjectSection.svelte @@ -30,11 +30,7 @@ HashtagItem, } from '../../types'; import { projectDisplayName } from '../../shared/utils'; - import { - goHome, - msSinceProjectSwitch, - currentProjectSwitchToken, - } from '../layout/navigation.svelte'; + import { goHome } from '../layout/navigation.svelte'; import * as commands from '../../api/commands'; import HashtagInput from '../sessions/HashtagInput.svelte'; import { buildProjectHashtagItems } from '../sessions/hashtagItems'; @@ -206,17 +202,7 @@ }); onDestroy(() => { - // Stamp when the outgoing section tears down relative to the switch. Pairing - // this with the new section's mount log brackets the keyed-block swap: if - // teardown starts late, the gap is upstream (flush is blocked); if it starts - // early but mount is late, the gap is in the swap itself. - const startedAt = performance.now(); liveSessionHintPoller.destroy(); - console.info( - `[perf][project-switch] ProjectSection destroyed for '${project.id}' in ` + - `${(performance.now() - startedAt).toFixed(1)}ms (+${msSinceProjectSwitch().toFixed(1)}ms since switch, ` + - `token ${currentProjectSwitchToken()})` - ); }); // Hashtag reference items @@ -225,15 +211,9 @@ $effect(() => { const _v = hashtagVersion; // reactive dependency for manual invalidation let stale = false; - const startedAt = performance.now(); buildProjectHashtagItems(project.id, branches, reposById) .then((items) => { if (!stale) hashtagItems = items; - console.info( - `[perf][project-switch] ProjectSection built ${items.length} hashtag item(s) for ` + - `'${project.id}' in ${(performance.now() - startedAt).toFixed(1)}ms` + - (stale ? ' (stale, discarded)' : '') - ); }) .catch((err) => { console.error('[ProjectSection] Failed to build hashtag items:', err); @@ -428,14 +408,8 @@ let deletingNoteIds = $state>(new Set()); async function loadProjectNotes() { - const startedAt = performance.now(); try { projectNotes = await commands.listProjectNotes(project.id); - console.info( - `[perf][project-switch] ProjectSection loaded ${projectNotes.length} note(s) for ` + - `'${project.id}' in ${(performance.now() - startedAt).toFixed(1)}ms ` + - `(+${msSinceProjectSwitch().toFixed(1)}ms since switch)` - ); } catch (e) { console.error('[ProjectSection] Failed to load project notes:', e); } @@ -491,11 +465,6 @@ // ── Lifecycle ────────────────────────────────────────────────────────── onMount(() => { - console.info( - `[perf][project-switch] ProjectSection mounted for '${project.id}' ` + - `(${branches.length} branch(es)) at +${msSinceProjectSwitch().toFixed(1)}ms since switch ` + - `(token ${currentProjectSwitchToken()})` - ); loadProjectNotes(); // Refresh hashtag items when branch timelines are invalidated (e.g. branch session completion)