diff --git a/src/renderer/store.ts b/src/renderer/store.ts index c0802bc4..24c55f8a 100644 --- a/src/renderer/store.ts +++ b/src/renderer/store.ts @@ -27,9 +27,11 @@ import { useSyncExternalStore } from 'react' import { initialState, + mergeWireSnapshot, rootReducer, type AppState, - type StateEvent + type StateEvent, + type WireSnapshotState } from '../shared/state' import type { LocalTransportHandle, BackendConnection } from './types' import { WebSocketClientTransport } from '../shared/transport/transport-websocket' @@ -53,12 +55,17 @@ class ClientStore { for (const l of this.listeners) l() } - setSnapshot(state: AppState): void { - // Merge with initialState so a remote backend on an older harness-server - // version (missing recently-added slices like snooze, repoConfigs, etc.) - // doesn't surface as renderer crashes on the first selector access of - // an undefined slice. New renderer + old server is the common skew. - this.state = { ...initialState, ...state } + setSnapshot(state: WireSnapshotState): void { + // Per-slice merge against initialState. This is the wire-side trust + // boundary: a remote `harness-server` on an older version may be + // missing entire slices (added after it shipped) AND/OR be missing + // individual fields inside slices it does send (e.g. v2.9.3 sends + // `settings` without `customThemes`, which 99262b2 added). A + // top-level shallow merge would only fix the first case; the + // per-slice merge fixes both. New renderer + old server is the + // common skew. The shared helper enforces — via the AppState return + // type — that future slice additions can't silently miss this list. + this.state = mergeWireSnapshot(state) for (const l of this.listeners) l() } diff --git a/src/shared/state/index.ts b/src/shared/state/index.ts index 8ac6a86f..11c7d062 100644 --- a/src/shared/state/index.ts +++ b/src/shared/state/index.ts @@ -262,6 +262,44 @@ export interface StateSnapshot { seq: number } +/** Snapshot shape as it comes off the wire. Each slice may be entirely + * absent (older server with a not-yet-existing slice) or present but + * missing recently-added fields (older server with the slice but an + * older schema). Consumers must merge with `initialState` defaults via + * `mergeWireSnapshot` before treating the value as a full `AppState`. */ +export type WireSnapshotState = { + [K in keyof AppState]?: Partial +} + +/** Per-slice shallow merge of a wire snapshot against `initialState` + * defaults. Protects against two version skews: + * + * 1. Older server is missing an entire slice (e.g. `snooze` added after + * the server shipped) — `initialState[slice]` fills it in. + * 2. Older server has the slice but is missing a recently-added field + * (e.g. `settings.customThemes` added in 99262b2 after v2.9.3) — + * the per-field default from `initialState[slice]` fills it in. + * + * If a future PR adds a slice to `AppState`/`initialState` and forgets + * to add a line here, TypeScript will fail the build: the object + * literal won't satisfy `AppState`. */ +export function mergeWireSnapshot(state: WireSnapshotState): AppState { + return { + settings: { ...initialState.settings, ...state.settings }, + prs: { ...initialState.prs, ...state.prs }, + onboarding: { ...initialState.onboarding, ...state.onboarding }, + hooks: { ...initialState.hooks, ...state.hooks }, + worktrees: { ...initialState.worktrees, ...state.worktrees }, + terminals: { ...initialState.terminals, ...state.terminals }, + updater: { ...initialState.updater, ...state.updater }, + repoConfigs: { ...initialState.repoConfigs, ...state.repoConfigs }, + costs: { ...initialState.costs, ...state.costs }, + browser: { ...initialState.browser, ...state.browser }, + jsonClaude: { ...initialState.jsonClaude, ...state.jsonClaude }, + snooze: { ...initialState.snooze, ...state.snooze } + } +} + /** Returns a snapshot with `jsonClaude.sessions[*].entries` elided. * Transports call this before serializing the initial-snapshot frame to * keep the wire payload bounded by the rest of the state — entries grow diff --git a/src/shared/state/wire-merge.test.ts b/src/shared/state/wire-merge.test.ts new file mode 100644 index 00000000..867b18ae --- /dev/null +++ b/src/shared/state/wire-merge.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest' +import { + initialState, + mergeWireSnapshot, + type AppState, + type WireSnapshotState +} from './index' +import { EMPTY_CUSTOM_THEMES } from './settings' + +describe('mergeWireSnapshot', () => { + it('fills in a recently-added field missing from an older servers settings', () => { + // Repro of the v2.9.3 server skew: commit 99262b2 added + // `customThemes`, so a v2.9.3 snapshot's `settings` object lacks the + // field. A top-level shallow merge would clobber initial.settings + // wholesale and leave customThemes undefined; the per-slice merge + // backfills the default array. + const wire: WireSnapshotState = { + settings: { + themeMode: 'system', + themeLight: 'solarized-light', + themeDark: 'dark' + // no customThemes + } + } + const merged = mergeWireSnapshot(wire) + expect(merged.settings.customThemes).toBe(EMPTY_CUSTOM_THEMES) + expect(merged.settings.themeMode).toBe('system') + expect(merged.settings.themeLight).toBe('solarized-light') + expect(merged.settings.themeDark).toBe('dark') + }) + + it('fills in an entirely-missing slice from initialState', () => { + const wire: WireSnapshotState = {} + const merged = mergeWireSnapshot(wire) + expect(merged.snooze).toEqual(initialState.snooze) + expect(merged.repoConfigs).toEqual(initialState.repoConfigs) + expect(merged.jsonClaude).toEqual(initialState.jsonClaude) + expect(merged.settings).toEqual(initialState.settings) + }) + + it('preserves server-sent values when the snapshot is complete', () => { + const wire: AppState = { + ...initialState, + settings: { + ...initialState.settings, + themeMode: 'dark', + customThemes: [ + { id: 'noir', name: 'Noir', mode: 'dark', colors: { bg: '#000' } } + ] + } + } + const merged = mergeWireSnapshot(wire) + expect(merged.settings.themeMode).toBe('dark') + expect(merged.settings.customThemes).toEqual([ + { id: 'noir', name: 'Noir', mode: 'dark', colors: { bg: '#000' } } + ]) + }) + + it('respects an explicit empty array from a mid-version server', () => { + const wire: WireSnapshotState = { + settings: { + themeMode: 'light', + themeLight: 'solarized-light', + themeDark: 'dark', + customThemes: [] + } + } + const merged = mergeWireSnapshot(wire) + expect(merged.settings.customThemes).toEqual([]) + }) +})