From e2031bf928201ccd7414a747b9bca44e24e89d75 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 22 May 2026 10:48:08 +0300 Subject: [PATCH] refactor(team): extract snapshot structural sharing --- src/renderer/store/slices/teamSlice.ts | 61 +-------- .../team/teamSnapshotStructuralSharing.ts | 61 +++++++++ .../teamSnapshotStructuralSharing.test.ts | 117 ++++++++++++++++++ 3 files changed, 179 insertions(+), 60 deletions(-) create mode 100644 src/renderer/store/team/teamSnapshotStructuralSharing.ts create mode 100644 test/renderer/store/teamSnapshotStructuralSharing.test.ts diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 4a14cfd5..8947b797 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -91,6 +91,7 @@ import { collectTeamScopedStateRemovals, collectTeamScopedVisibleLoadingResets, } from '../team/teamScopedStateCleanup'; +import { structurallyShareTeamSnapshot } from '../team/teamSnapshotStructuralSharing'; import { noteTeamRefreshFanout } from '../teamRefreshFanoutDiagnostics'; import { getWorktreeNavigationState } from '../utils/stateResetHelpers'; @@ -532,66 +533,6 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -function isPlainObject(value: unknown): value is Record { - if (value == null || typeof value !== 'object') { - return false; - } - const prototype = Object.getPrototypeOf(value); - return prototype === Object.prototype || prototype === null; -} - -function structurallySharePlainValue(previous: T, next: T): T { - if (Object.is(previous, next)) { - return previous; - } - - if (Array.isArray(previous) && Array.isArray(next)) { - let changed = previous.length !== next.length; - const result = next.map((nextItem, index) => { - const sharedItem = structurallySharePlainValue(previous[index], nextItem); - if (!Object.is(sharedItem, previous[index])) { - changed = true; - } - return sharedItem; - }); - return changed ? (result as T) : previous; - } - - if (isPlainObject(previous) && isPlainObject(next)) { - const previousRecord = previous as Record; - const nextRecord = next as Record; - const previousKeys = Object.keys(previousRecord); - const nextKeys = Object.keys(nextRecord); - let changed = previousKeys.length !== nextKeys.length; - const result: Record = {}; - - for (const key of nextKeys) { - if (!Object.prototype.hasOwnProperty.call(previousRecord, key)) { - changed = true; - } - const sharedValue = structurallySharePlainValue(previousRecord[key], nextRecord[key]); - if (!Object.is(sharedValue, previousRecord[key])) { - changed = true; - } - result[key] = sharedValue; - } - - return changed ? (result as T) : previous; - } - - return next; -} - -function structurallyShareTeamSnapshot( - previous: TeamViewSnapshot | null | undefined, - next: TeamViewSnapshot -): TeamViewSnapshot { - if (!previous) { - return next; - } - return structurallySharePlainValue(previous, next); -} - const ACTIVE_PROVISIONING_STATES = new Set([ 'validating', 'spawning', diff --git a/src/renderer/store/team/teamSnapshotStructuralSharing.ts b/src/renderer/store/team/teamSnapshotStructuralSharing.ts new file mode 100644 index 00000000..f90500e3 --- /dev/null +++ b/src/renderer/store/team/teamSnapshotStructuralSharing.ts @@ -0,0 +1,61 @@ +import type { TeamViewSnapshot } from '@shared/types'; + +function isPlainObject(value: unknown): value is Record { + if (value == null || typeof value !== 'object') { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +export function structurallySharePlainValue(previous: T, next: T): T { + if (Object.is(previous, next)) { + return previous; + } + + if (Array.isArray(previous) && Array.isArray(next)) { + let changed = previous.length !== next.length; + const result = next.map((nextItem, index) => { + const sharedItem = structurallySharePlainValue(previous[index], nextItem); + if (!Object.is(sharedItem, previous[index])) { + changed = true; + } + return sharedItem; + }); + return changed ? (result as T) : previous; + } + + if (isPlainObject(previous) && isPlainObject(next)) { + const previousRecord = previous as Record; + const nextRecord = next as Record; + const previousKeys = Object.keys(previousRecord); + const nextKeys = Object.keys(nextRecord); + let changed = previousKeys.length !== nextKeys.length; + const result: Record = {}; + + for (const key of nextKeys) { + if (!Object.prototype.hasOwnProperty.call(previousRecord, key)) { + changed = true; + } + const sharedValue = structurallySharePlainValue(previousRecord[key], nextRecord[key]); + if (!Object.is(sharedValue, previousRecord[key])) { + changed = true; + } + result[key] = sharedValue; + } + + return changed ? (result as T) : previous; + } + + return next; +} + +export function structurallyShareTeamSnapshot( + previous: TeamViewSnapshot | null | undefined, + next: TeamViewSnapshot +): TeamViewSnapshot { + if (!previous) { + return next; + } + return structurallySharePlainValue(previous, next); +} diff --git a/test/renderer/store/teamSnapshotStructuralSharing.test.ts b/test/renderer/store/teamSnapshotStructuralSharing.test.ts new file mode 100644 index 00000000..c5020f40 --- /dev/null +++ b/test/renderer/store/teamSnapshotStructuralSharing.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest'; + +import { + structurallySharePlainValue, + structurallyShareTeamSnapshot, +} from '../../../src/renderer/store/team/teamSnapshotStructuralSharing'; + +import type { TeamViewSnapshot } from '../../../src/shared/types'; + +function createSnapshot(overrides: Partial = {}): TeamViewSnapshot { + return { + teamName: 'my-team', + config: { name: 'My Team' }, + members: [], + tasks: [], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + ...overrides, + }; +} + +describe('teamSnapshotStructuralSharing', () => { + it('returns the next snapshot when there is no previous snapshot', () => { + const next = createSnapshot(); + + expect(structurallyShareTeamSnapshot(null, next)).toBe(next); + expect(structurallyShareTeamSnapshot(undefined, next)).toBe(next); + }); + + it('preserves the previous snapshot reference when values are deeply equal', () => { + const previous = createSnapshot({ + config: { name: 'My Team', description: 'Same description' }, + warnings: ['same warning'], + isAlive: true, + }); + const next = createSnapshot({ + config: { name: 'My Team', description: 'Same description' }, + warnings: ['same warning'], + isAlive: true, + }); + + expect(structurallyShareTeamSnapshot(previous, next)).toBe(previous); + }); + + it('replaces only changed snapshot branches while sharing unchanged branches', () => { + const previousWarnings = ['same warning']; + const previous = createSnapshot({ + config: { name: 'My Team', description: 'Old description' }, + warnings: previousWarnings, + isAlive: true, + }); + const next = createSnapshot({ + config: { name: 'My Team', description: 'New description' }, + warnings: ['same warning'], + isAlive: true, + }); + + const shared = structurallyShareTeamSnapshot(previous, next); + + expect(shared).not.toBe(previous); + expect(shared).toEqual(next); + expect(shared.config).not.toBe(previous.config); + expect(shared.warnings).toBe(previousWarnings); + expect(shared.members).toBe(previous.members); + expect(shared.tasks).toBe(previous.tasks); + expect(shared.kanbanState).toBe(previous.kanbanState); + expect(shared.processes).toBe(previous.processes); + }); + + it('shares unchanged array entries and replaces changed entries', () => { + const previous = [ + { id: 'task-1', title: 'Keep' }, + { id: 'task-2', title: 'Old' }, + ]; + const next = [ + { id: 'task-1', title: 'Keep' }, + { id: 'task-2', title: 'New' }, + ]; + + const shared = structurallySharePlainValue(previous, next); + + expect(shared).not.toBe(previous); + expect(shared).toEqual(next); + expect(shared[0]).toBe(previous[0]); + expect(shared[1]).not.toBe(previous[1]); + }); + + it('replaces objects when keys are added or removed', () => { + const previous = { id: 'task-1', title: 'Same', extra: true }; + const next = { id: 'task-1', title: 'Same' }; + + const shared = structurallySharePlainValue(previous, next); + + expect(shared).not.toBe(previous); + expect(shared).toEqual(next); + }); + + it('treats null-prototype objects as plain values', () => { + const previous = Object.assign(Object.create(null) as Record, { + id: 'task-1', + title: 'Same', + }); + const next = Object.assign(Object.create(null) as Record, { + id: 'task-1', + title: 'Same', + }); + + expect(structurallySharePlainValue(previous, next)).toBe(previous); + }); + + it('replaces non-plain objects instead of traversing them', () => { + const previous = new Date('2026-05-22T10:00:00.000Z'); + const next = new Date('2026-05-22T10:00:00.000Z'); + + expect(structurallySharePlainValue(previous, next)).toBe(next); + }); +});