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,
|
collectTeamScopedStateRemovals,
|
||||||
collectTeamScopedVisibleLoadingResets,
|
collectTeamScopedVisibleLoadingResets,
|
||||||
} from '../team/teamScopedStateCleanup';
|
} from '../team/teamScopedStateCleanup';
|
||||||
|
import { structurallyShareTeamSnapshot } from '../team/teamSnapshotStructuralSharing';
|
||||||
import { noteTeamRefreshFanout } from '../teamRefreshFanoutDiagnostics';
|
import { noteTeamRefreshFanout } from '../teamRefreshFanoutDiagnostics';
|
||||||
import { getWorktreeNavigationState } from '../utils/stateResetHelpers';
|
import { getWorktreeNavigationState } from '../utils/stateResetHelpers';
|
||||||
|
|
||||||
|
|
@ -532,66 +533,6 @@ function sleep(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
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([
|
const ACTIVE_PROVISIONING_STATES = new Set([
|
||||||
'validating',
|
'validating',
|
||||||
'spawning',
|
'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