Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions src/renderer/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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()
}

Expand Down
38 changes: 38 additions & 0 deletions src/shared/state/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppState[K]>
}

/** 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
Expand Down
71 changes: 71 additions & 0 deletions src/shared/state/wire-merge.test.ts
Original file line number Diff line number Diff line change
@@ -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([])
})
})
Loading