Skip to content

Commit c49636d

Browse files
committed
Settings: make setSetting idempotent on unchanged values
`setSetting` used to unconditionally schedule a save, notify listeners, and emit the cross-window `settings:changed` event even when the new value matched the cached value. The cascade was load-bearing in production for real changes (cross-window sync, applier-driven Tauri commands), but for no-op writes it was pure waste. Under parallel-shard E2E load this surfaced as a flake on `network-toggle.spec.ts > toggling back on restores 'Network'`: the test body did ON→OFF→ON, then `afterEach` did another `setSetting('network.enabled', true)`. Each `network.enabled` toggle cascades through the applier to `setNetworkEnabled` IPC, and the OFF path synchronously stops the mDNS daemon and emits 14 `network-host-lost` events. The redundant afterEach call queued more event-loop work right when an `mcp_round_trip` was waiting on `mcp-response`, so the FE missed the 5s budget ~25% of the time. The fix is one guard: skip the cascade when `settingsCache[id] === value`. `===` is the right comparator because every registered setting is either a primitive or a pinned-shape JSON object that callers replace by reference when mutating — same-reference always means no change. If a future setting needs deep-equality, narrow the comparison there rather than dropping the guard. Wins beyond the test fix: no disk write for redundant clicks on a toggle, no cross-window emit, no applier-driven Tauri command. The new `settings-store.test.ts` pins the behavior — including that real value flips after a no-op still fire correctly.
1 parent 50da907 commit c49636d

2 files changed

Lines changed: 110 additions & 0 deletions

File tree

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest'
2+
import { emit } from '@tauri-apps/api/event'
3+
import { setSetting, onSettingChange } from './settings-store'
4+
5+
// `test-setup.ts` already mocks `@tauri-apps/api/event` globally. We don't
6+
// reset the module here: settings-store's `settingsCache` is module-scoped,
7+
// so each test picks a setting ID nobody else in this file uses to avoid
8+
// leaking cache state between tests.
9+
const mockedEmit = vi.mocked(emit)
10+
11+
const settingsChangedEmits = () => mockedEmit.mock.calls.filter((c) => c[0] === 'settings:changed')
12+
13+
describe('setSetting idempotency', () => {
14+
beforeEach(() => {
15+
mockedEmit.mockClear()
16+
})
17+
18+
it('cascades on a real value change: emits + notifies listeners', () => {
19+
// First test in the file: `appearance.uiDensity` is still at default in
20+
// the cache, so this is a real change.
21+
const listener = vi.fn()
22+
const unsub = onSettingChange(listener)
23+
24+
setSetting('appearance.uiDensity', 'spacious')
25+
26+
expect(listener).toHaveBeenCalledTimes(1)
27+
expect(listener).toHaveBeenCalledWith('appearance.uiDensity', 'spacious')
28+
expect(settingsChangedEmits()).toHaveLength(1)
29+
expect(settingsChangedEmits()[0]?.[1]).toEqual({ id: 'appearance.uiDensity', value: 'spacious' })
30+
31+
unsub()
32+
})
33+
34+
it('short-circuits when value is unchanged: no emit, no listener', () => {
35+
// Use a fresh setting ID so the cache for it starts undefined.
36+
const listener = vi.fn()
37+
const unsub = onSettingChange(listener)
38+
39+
setSetting('network.enabled', false)
40+
expect(listener).toHaveBeenCalledTimes(1)
41+
listener.mockClear()
42+
mockedEmit.mockClear()
43+
44+
// Same value: must be a complete no-op past validation.
45+
setSetting('network.enabled', false)
46+
47+
expect(listener).not.toHaveBeenCalled()
48+
expect(settingsChangedEmits()).toHaveLength(0)
49+
50+
unsub()
51+
})
52+
53+
it('cascades again when the value flips after a no-op', () => {
54+
const listener = vi.fn()
55+
const unsub = onSettingChange(listener)
56+
57+
// Note: `appearance.uiDensity` is already 'spacious' in the cache from
58+
// the first test in this file. Setting it again must short-circuit.
59+
setSetting('appearance.uiDensity', 'spacious')
60+
expect(listener).not.toHaveBeenCalled()
61+
62+
// Real change: cascade fires.
63+
setSetting('appearance.uiDensity', 'compact')
64+
expect(listener).toHaveBeenCalledTimes(1)
65+
expect(listener).toHaveBeenCalledWith('appearance.uiDensity', 'compact')
66+
expect(settingsChangedEmits()).toHaveLength(1)
67+
68+
unsub()
69+
})
70+
71+
it('handles numbers the same way (=== covers all primitive setting types)', () => {
72+
const listener = vi.fn()
73+
const unsub = onSettingChange(listener)
74+
75+
setSetting('advanced.maxLogStorageMb', 123)
76+
expect(listener).toHaveBeenCalledTimes(1)
77+
78+
listener.mockClear()
79+
setSetting('advanced.maxLogStorageMb', 123)
80+
expect(listener).not.toHaveBeenCalled()
81+
82+
setSetting('advanced.maxLogStorageMb', 456)
83+
expect(listener).toHaveBeenCalledTimes(1)
84+
expect(listener).toHaveBeenLastCalledWith('advanced.maxLogStorageMb', 456)
85+
86+
unsub()
87+
})
88+
})

apps/desktop/src/lib/settings/settings-store.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,13 +217,35 @@ export function getSetting<K extends SettingId>(id: K): SettingsValues[K] {
217217
/**
218218
* Set a setting value. Validates against constraints before storing.
219219
* Throws SettingValidationError if invalid.
220+
*
221+
* Idempotent: when `value` strictly equals the currently-cached value, the
222+
* call returns after validation without scheduling a save, notifying
223+
* listeners, or emitting the cross-window event. This avoids redundant work
224+
* for the (common) case of writing the same value twice — e.g. a settings UI
225+
* onChange that fires for any click, or test setup/teardown that resets a
226+
* setting back to its already-current value. The cascade for `network.enabled`
227+
* alone fires 14 `network-host-lost` events through the FE event loop on a
228+
* real toggle, so the redundant call used to be heavy enough to occasionally
229+
* starve a concurrent `mcp_round_trip` waiting on `mcp-response`.
230+
*
231+
* `===` is the right comparator here: every registered setting is a primitive
232+
* (`boolean | number | string`) or a pinned-shape JSON object that callers
233+
* replace by reference when they mutate, so same-reference always means
234+
* no-change. If you add a setting that requires deep-equality, narrow the
235+
* comparison here instead of dropping the guard.
220236
*/
221237
export function setSetting<K extends SettingId>(id: K, value: SettingsValues[K]): void {
222238
log.debug('setSetting({id}, {value})', { id, value })
223239

224240
// Validate the value
225241
validateSettingValue(id, value)
226242

243+
// Idempotency: skip the cascade when nothing actually changed.
244+
if (settingsCache[id] === value) {
245+
log.debug('setSetting({id}): unchanged, skipping notify+save+emit', { id })
246+
return
247+
}
248+
227249
// Update cache immediately for synchronous access
228250
settingsCache[id] = value
229251

0 commit comments

Comments
 (0)