diff --git a/src/renderer/src/store/slices/worktree-catalog-reconciliation.test.ts b/src/renderer/src/store/slices/worktree-catalog-reconciliation.test.ts index 469ecaabb4d..474dc8dfb39 100644 --- a/src/renderer/src/store/slices/worktree-catalog-reconciliation.test.ts +++ b/src/renderer/src/store/slices/worktree-catalog-reconciliation.test.ts @@ -71,4 +71,44 @@ describe('reuseEqualCatalogRows', () => { expect(reconciled[0]).toBe(current[1]) expect(reconciled[1]).toBe(current[0]) }) + + it('reuses a match inside the duplicate-id scan window', () => { + const current = [ + { id: 'dup', marker: 'a' }, + { id: 'dup', marker: 'b' }, + { id: 'dup', marker: 'c' } + ] + + const reconciled = reuseEqualCatalogRows(current, [{ id: 'dup', marker: 'c' }]) + + expect(reconciled[0]).toBe(current[2]) + }) + + // Without the cap this walks the whole bucket, so a 64-row bucket costs 64 + // deep compares per incoming row. Counting reads keeps the guard deterministic + // — a wall-clock assertion would be flaky on shared CI runners. + it('caps the deep compares for one id instead of scanning the whole bucket', () => { + const bucketSize = 64 + const current = Array.from({ length: bucketSize }, (_, index) => ({ + id: 'dup', + marker: `previous-${index}` + })) + let reads = 0 + const incoming = [ + { + id: 'dup', + get marker(): string { + reads++ + return 'matches-nothing' + } + } + ] + + const reconciled = reuseEqualCatalogRows(current, incoming) + + // No match, so the incoming row is kept — a missed reuse costs identity, never correctness. + expect(reconciled[0]).toBe(incoming[0]) + expect(reads).toBeLessThanOrEqual(8) + expect(reads).toBeLessThan(bucketSize) + }) }) diff --git a/src/renderer/src/store/slices/worktree-catalog-reconciliation.ts b/src/renderer/src/store/slices/worktree-catalog-reconciliation.ts index 5442df285f8..1ef339a1808 100644 --- a/src/renderer/src/store/slices/worktree-catalog-reconciliation.ts +++ b/src/renderer/src/store/slices/worktree-catalog-reconciliation.ts @@ -30,6 +30,14 @@ function catalogValuesEqual(left: unknown, right: unknown): boolean { return true } +// NOTHING HITS THIS TODAY: both callers key on ids that are unique by +// construction, so buckets stay at 1-3. It only bounds the damage if that ever +// changes — a same-id bucket costs one deep compare per candidate, so an +// unbounded one is O(k^2). A cap beats a second index that would have to stay in +// step with catalogValuesEqual, and reuse is only an optimization: dropping a +// match past the window costs object identity, never correctness. +const MAX_DUPLICATE_ID_SCAN = 8 + export function reuseEqualCatalogRows( current: readonly T[] | undefined, incoming: readonly T[] @@ -48,10 +56,18 @@ export function reuseEqualCatalogRows( } const reconciled = incoming.map((row) => { const candidates = currentById.get(row.id) - const previousIndex = candidates?.findIndex((candidate) => catalogValuesEqual(candidate, row)) - return previousIndex !== undefined && previousIndex >= 0 - ? candidates!.splice(previousIndex, 1)[0] - : row + if (!candidates) { + return row + } + const scanLimit = Math.min(candidates.length, MAX_DUPLICATE_ID_SCAN) + for (let index = 0; index < scanLimit; index++) { + const candidate = candidates[index] + if (candidate !== undefined && catalogValuesEqual(candidate, row)) { + candidates.splice(index, 1) + return candidate + } + } + return row }) return current.length === reconciled.length && current.every((row, index) => row === reconciled[index])