Skip to content

Commit 5f3ed0d

Browse files
committed
fix(settings): wait for the initial client snapshot
1 parent 72d917d commit 5f3ed0d

4 files changed

Lines changed: 113 additions & 8 deletions

File tree

‎packages/devframe/src/client/rpc-shared-state.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ export function createRpcSharedStateClientHost(rpc: DevframeRpcClient): RpcShare
121121
}
122122
}
123123

124-
return new Promise<SharedState<T>>((resolve) => {
124+
return new Promise<SharedState<T>>((resolve, reject) => {
125125
if (!rpc.isTrusted) {
126126
resolve(state)
127127
let initialized = false
@@ -133,7 +133,7 @@ export function createRpcSharedStateClientHost(rpc: DevframeRpcClient): RpcShare
133133
})
134134
}
135135
else {
136-
initSharedState().then(resolve)
136+
initSharedState().then(resolve, reject)
137137
}
138138
})
139139
},

‎packages/devframe/src/client/scope.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ function createMockClient() {
1111

1212
// eslint-disable-next-line slop/no-chained-type-assertions -- partial test mock exercises only the members client.scope() touches
1313
const rpc = {
14+
ensureTrusted: async () => true,
1415
call: vi.fn((..._args: any[]) => Promise.resolve('ok')),
1516
callEvent: vi.fn((..._args: any[]) => {}),
1617
callOptional: vi.fn((..._args: any[]) => Promise.resolve('ok')),
@@ -126,7 +127,7 @@ describe('client.scope()', () => {
126127
const { settings } = createScopedClientContext(rpc, 'my-plugin')
127128

128129
await settings.global.set('token', 'abc')
129-
expect(rpc.sharedState.get).toHaveBeenCalledWith('devframe:settings:global:my-plugin', { initialValue: {} })
130+
expect(rpc.sharedState.get).toHaveBeenCalledWith('devframe:settings:global:my-plugin')
130131
expect(await settings.global.get('token')).toBe('abc')
131132

132133
await settings.project.set('theme', 'dark')
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import type { DevframeRpcClient } from './rpc'
2+
import { setImmediate } from 'node:timers/promises'
3+
import { createEventEmitter } from 'devframe/utils/events'
4+
import { describe, expect, it, vi } from 'vitest'
5+
import { DEVFRAME_EVENTS } from '../events'
6+
import { createRpcSharedStateClientHost } from './rpc-shared-state'
7+
import { createClientSettings } from './settings'
8+
9+
function setup(backend = 'websocket', trusted = true) {
10+
const snapshot = Promise.withResolvers<Record<string, unknown> | undefined>()
11+
const trust = Promise.withResolvers<boolean>()
12+
const handlers = new Map<string, (...args: any[]) => void>()
13+
// eslint-disable-next-line slop/no-chained-type-assertions -- partial RPC mock retains the real shared-state client
14+
const rpc = {
15+
connectionMeta: { backend },
16+
isTrusted: trusted,
17+
events: createEventEmitter<any>(),
18+
client: { register: (fn: any) => handlers.set(fn.name, fn.handler) },
19+
ensureTrusted: vi.fn(() => trusted ? Promise.resolve(true) : trust.promise),
20+
call: vi.fn(() => snapshot.promise),
21+
callEvent: vi.fn(),
22+
} as unknown as DevframeRpcClient
23+
rpc.sharedState = createRpcSharedStateClientHost(rpc)
24+
const settings = createClientSettings(rpc, 'test')
25+
return { rpc, settings, snapshot, trust, handlers }
26+
}
27+
28+
describe('client settings initialization', () => {
29+
it.each(['project', 'global'] as const)('reads the initial %s snapshot before resolving', async (scope) => {
30+
const { settings, snapshot } = setup()
31+
const read = settings[scope].get('theme')
32+
await setImmediate()
33+
snapshot.resolve({ theme: 'light' })
34+
await expect(read).resolves.toBe('light')
35+
})
36+
37+
it.each(['project', 'global'] as const)('preserves an immediate %s write and unrelated settings', async (scope) => {
38+
const { rpc, settings, snapshot } = setup()
39+
const write = settings[scope].set('theme', 'dark')
40+
const read = settings[scope].all()
41+
await setImmediate()
42+
snapshot.resolve({ theme: 'light', language: 'en' })
43+
await write
44+
await expect(settings[scope].get('theme')).resolves.toBe('dark')
45+
await expect(read).resolves.toEqual({ theme: 'dark', language: 'en' })
46+
expect(rpc.call).toHaveBeenCalledTimes(1)
47+
})
48+
49+
it('applies an immediate deletion after the initial snapshot', async () => {
50+
const { settings, snapshot } = setup()
51+
const deletion = settings.project.delete('theme')
52+
await setImmediate()
53+
snapshot.resolve({ theme: 'light', language: 'en' })
54+
await deletion
55+
await expect(settings.project.all()).resolves.toEqual({ language: 'en' })
56+
})
57+
58+
it.each(['websocket', 'static'])('starts an absent %s store with an empty object', async (backend) => {
59+
const { settings, snapshot } = setup(backend)
60+
const read = settings.project.all()
61+
await setImmediate()
62+
snapshot.resolve(undefined)
63+
await expect(read).resolves.toEqual({})
64+
await settings.project.set('theme', 'dark')
65+
await expect(settings.project.get('theme')).resolves.toBe('dark')
66+
})
67+
68+
it('waits for trust before requesting the initial snapshot', async () => {
69+
const { rpc, settings, snapshot, trust } = setup('websocket', false)
70+
const write = settings.project.set('theme', 'dark')
71+
await setImmediate()
72+
expect(rpc.call).not.toHaveBeenCalled()
73+
expect(rpc.callEvent).not.toHaveBeenCalled()
74+
Object.defineProperty(rpc, 'isTrusted', { value: true })
75+
trust.resolve(true)
76+
snapshot.resolve({ language: 'en' })
77+
await write
78+
await expect(settings.project.all()).resolves.toEqual({ theme: 'dark', language: 'en' })
79+
})
80+
81+
it('rejects a failed initial snapshot and retries on the next operation', async () => {
82+
const { rpc, settings } = setup()
83+
vi.mocked(rpc.call).mockRejectedValueOnce(new Error('snapshot failed'))
84+
await expect(settings.project.get('theme')).rejects.toThrow('snapshot failed')
85+
vi.mocked(rpc.call).mockResolvedValueOnce({ theme: 'light' })
86+
await expect(settings.project.get('theme')).resolves.toBe('light')
87+
})
88+
89+
it('continues to accept remote updates after initialization', async () => {
90+
const { settings, snapshot, handlers } = setup()
91+
await setImmediate()
92+
snapshot.resolve({ theme: 'light' })
93+
await settings.project.set('theme', 'dark')
94+
handlers.get(DEVFRAME_EVENTS.broadcast.clientStateUpdated)!('devframe:settings:project:test', { theme: 'system' }, 'remote')
95+
await expect(settings.project.get('theme')).resolves.toBe('system')
96+
})
97+
})

‎packages/devframe/src/client/settings.ts‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,20 @@ function createClientSettingsStore<T extends Record<string, any>>(
1111
const stateKey = `devframe:settings:${scope}:${namespace}`
1212
let statePromise: Promise<SharedState<T>> | undefined
1313

14-
// The client mirrors the server's file-backed settings store over the
15-
// shared-state sync protocol: providing an empty initial value lets the
16-
// client subscribe and merge the authoritative server snapshot, and any
17-
// local `set` is pushed back to (and persisted by) the server.
14+
// Resolve the server snapshot before reading or changing settings so that
15+
// initialization cannot overwrite the first local operation.
1816
function store(): Promise<SharedState<T>> {
1917
if (!statePromise) {
20-
statePromise = (rpc.sharedState.get as any)(stateKey, { initialValue: {} }) as Promise<SharedState<T>>
18+
statePromise = (async () => {
19+
await rpc.ensureTrusted()
20+
const state = await rpc.sharedState.get<T>(stateKey)
21+
if (state.value() === undefined)
22+
state.mutate(() => ({} as T))
23+
return state
24+
})().catch((error) => {
25+
statePromise = undefined
26+
throw error
27+
})
2128
}
2229
return statePromise
2330
}

0 commit comments

Comments
 (0)