refactor(team): extract snapshot structural sharing
This commit is contained in:
parent
c091bd8d96
commit
e2031bf928
3 changed files with 179 additions and 60 deletions
|
|
@ -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<void> {
|
|||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
if (value == null || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
function structurallySharePlainValue<T>(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<string, unknown>;
|
||||
const nextRecord = next as Record<string, unknown>;
|
||||
const previousKeys = Object.keys(previousRecord);
|
||||
const nextKeys = Object.keys(nextRecord);
|
||||
let changed = previousKeys.length !== nextKeys.length;
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
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',
|
||||
|
|
|
|||
61
src/renderer/store/team/teamSnapshotStructuralSharing.ts
Normal file
61
src/renderer/store/team/teamSnapshotStructuralSharing.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import type { TeamViewSnapshot } from '@shared/types';
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
if (value == null || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
export function structurallySharePlainValue<T>(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<string, unknown>;
|
||||
const nextRecord = next as Record<string, unknown>;
|
||||
const previousKeys = Object.keys(previousRecord);
|
||||
const nextKeys = Object.keys(nextRecord);
|
||||
let changed = previousKeys.length !== nextKeys.length;
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
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);
|
||||
}
|
||||
117
test/renderer/store/teamSnapshotStructuralSharing.test.ts
Normal file
117
test/renderer/store/teamSnapshotStructuralSharing.test.ts
Normal file
|
|
@ -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> = {}): 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<string, unknown>, {
|
||||
id: 'task-1',
|
||||
title: 'Same',
|
||||
});
|
||||
const next = Object.assign(Object.create(null) as Record<string, unknown>, {
|
||||
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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue