From 5f3ed0d816e4988a2a1df0dd7dc69fcdc7b6e371 Mon Sep 17 00:00:00 2001 From: Sakana <15715093608@163.com> Date: Fri, 2 Oct 2026 22:23:51 +0800 Subject: [PATCH] fix(settings): wait for the initial client snapshot --- .../devframe/src/client/rpc-shared-state.ts | 4 +- packages/devframe/src/client/scope.test.ts | 3 +- packages/devframe/src/client/settings.test.ts | 97 +++++++++++++++++++ packages/devframe/src/client/settings.ts | 17 +++- 4 files changed, 113 insertions(+), 8 deletions(-) create mode 100644 packages/devframe/src/client/settings.test.ts diff --git a/packages/devframe/src/client/rpc-shared-state.ts b/packages/devframe/src/client/rpc-shared-state.ts index ae7c6b289..629aee9df 100644 --- a/packages/devframe/src/client/rpc-shared-state.ts +++ b/packages/devframe/src/client/rpc-shared-state.ts @@ -121,7 +121,7 @@ export function createRpcSharedStateClientHost(rpc: DevframeRpcClient): RpcShare } } - return new Promise>((resolve) => { + return new Promise>((resolve, reject) => { if (!rpc.isTrusted) { resolve(state) let initialized = false @@ -133,7 +133,7 @@ export function createRpcSharedStateClientHost(rpc: DevframeRpcClient): RpcShare }) } else { - initSharedState().then(resolve) + initSharedState().then(resolve, reject) } }) }, diff --git a/packages/devframe/src/client/scope.test.ts b/packages/devframe/src/client/scope.test.ts index 5923b76d4..f0acc8090 100644 --- a/packages/devframe/src/client/scope.test.ts +++ b/packages/devframe/src/client/scope.test.ts @@ -11,6 +11,7 @@ function createMockClient() { // eslint-disable-next-line slop/no-chained-type-assertions -- partial test mock exercises only the members client.scope() touches const rpc = { + ensureTrusted: async () => true, call: vi.fn((..._args: any[]) => Promise.resolve('ok')), callEvent: vi.fn((..._args: any[]) => {}), callOptional: vi.fn((..._args: any[]) => Promise.resolve('ok')), @@ -126,7 +127,7 @@ describe('client.scope()', () => { const { settings } = createScopedClientContext(rpc, 'my-plugin') await settings.global.set('token', 'abc') - expect(rpc.sharedState.get).toHaveBeenCalledWith('devframe:settings:global:my-plugin', { initialValue: {} }) + expect(rpc.sharedState.get).toHaveBeenCalledWith('devframe:settings:global:my-plugin') expect(await settings.global.get('token')).toBe('abc') await settings.project.set('theme', 'dark') diff --git a/packages/devframe/src/client/settings.test.ts b/packages/devframe/src/client/settings.test.ts new file mode 100644 index 000000000..c182ec228 --- /dev/null +++ b/packages/devframe/src/client/settings.test.ts @@ -0,0 +1,97 @@ +import type { DevframeRpcClient } from './rpc' +import { setImmediate } from 'node:timers/promises' +import { createEventEmitter } from 'devframe/utils/events' +import { describe, expect, it, vi } from 'vitest' +import { DEVFRAME_EVENTS } from '../events' +import { createRpcSharedStateClientHost } from './rpc-shared-state' +import { createClientSettings } from './settings' + +function setup(backend = 'websocket', trusted = true) { + const snapshot = Promise.withResolvers | undefined>() + const trust = Promise.withResolvers() + const handlers = new Map void>() + // eslint-disable-next-line slop/no-chained-type-assertions -- partial RPC mock retains the real shared-state client + const rpc = { + connectionMeta: { backend }, + isTrusted: trusted, + events: createEventEmitter(), + client: { register: (fn: any) => handlers.set(fn.name, fn.handler) }, + ensureTrusted: vi.fn(() => trusted ? Promise.resolve(true) : trust.promise), + call: vi.fn(() => snapshot.promise), + callEvent: vi.fn(), + } as unknown as DevframeRpcClient + rpc.sharedState = createRpcSharedStateClientHost(rpc) + const settings = createClientSettings(rpc, 'test') + return { rpc, settings, snapshot, trust, handlers } +} + +describe('client settings initialization', () => { + it.each(['project', 'global'] as const)('reads the initial %s snapshot before resolving', async (scope) => { + const { settings, snapshot } = setup() + const read = settings[scope].get('theme') + await setImmediate() + snapshot.resolve({ theme: 'light' }) + await expect(read).resolves.toBe('light') + }) + + it.each(['project', 'global'] as const)('preserves an immediate %s write and unrelated settings', async (scope) => { + const { rpc, settings, snapshot } = setup() + const write = settings[scope].set('theme', 'dark') + const read = settings[scope].all() + await setImmediate() + snapshot.resolve({ theme: 'light', language: 'en' }) + await write + await expect(settings[scope].get('theme')).resolves.toBe('dark') + await expect(read).resolves.toEqual({ theme: 'dark', language: 'en' }) + expect(rpc.call).toHaveBeenCalledTimes(1) + }) + + it('applies an immediate deletion after the initial snapshot', async () => { + const { settings, snapshot } = setup() + const deletion = settings.project.delete('theme') + await setImmediate() + snapshot.resolve({ theme: 'light', language: 'en' }) + await deletion + await expect(settings.project.all()).resolves.toEqual({ language: 'en' }) + }) + + it.each(['websocket', 'static'])('starts an absent %s store with an empty object', async (backend) => { + const { settings, snapshot } = setup(backend) + const read = settings.project.all() + await setImmediate() + snapshot.resolve(undefined) + await expect(read).resolves.toEqual({}) + await settings.project.set('theme', 'dark') + await expect(settings.project.get('theme')).resolves.toBe('dark') + }) + + it('waits for trust before requesting the initial snapshot', async () => { + const { rpc, settings, snapshot, trust } = setup('websocket', false) + const write = settings.project.set('theme', 'dark') + await setImmediate() + expect(rpc.call).not.toHaveBeenCalled() + expect(rpc.callEvent).not.toHaveBeenCalled() + Object.defineProperty(rpc, 'isTrusted', { value: true }) + trust.resolve(true) + snapshot.resolve({ language: 'en' }) + await write + await expect(settings.project.all()).resolves.toEqual({ theme: 'dark', language: 'en' }) + }) + + it('rejects a failed initial snapshot and retries on the next operation', async () => { + const { rpc, settings } = setup() + vi.mocked(rpc.call).mockRejectedValueOnce(new Error('snapshot failed')) + await expect(settings.project.get('theme')).rejects.toThrow('snapshot failed') + vi.mocked(rpc.call).mockResolvedValueOnce({ theme: 'light' }) + await expect(settings.project.get('theme')).resolves.toBe('light') + }) + + it('continues to accept remote updates after initialization', async () => { + const { settings, snapshot, handlers } = setup() + await setImmediate() + snapshot.resolve({ theme: 'light' }) + await settings.project.set('theme', 'dark') + handlers.get(DEVFRAME_EVENTS.broadcast.clientStateUpdated)!('devframe:settings:project:test', { theme: 'system' }, 'remote') + await expect(settings.project.get('theme')).resolves.toBe('system') + }) +}) diff --git a/packages/devframe/src/client/settings.ts b/packages/devframe/src/client/settings.ts index b00bf1bb3..697b4c85d 100644 --- a/packages/devframe/src/client/settings.ts +++ b/packages/devframe/src/client/settings.ts @@ -11,13 +11,20 @@ function createClientSettingsStore>( const stateKey = `devframe:settings:${scope}:${namespace}` let statePromise: Promise> | undefined - // The client mirrors the server's file-backed settings store over the - // shared-state sync protocol: providing an empty initial value lets the - // client subscribe and merge the authoritative server snapshot, and any - // local `set` is pushed back to (and persisted by) the server. + // Resolve the server snapshot before reading or changing settings so that + // initialization cannot overwrite the first local operation. function store(): Promise> { if (!statePromise) { - statePromise = (rpc.sharedState.get as any)(stateKey, { initialValue: {} }) as Promise> + statePromise = (async () => { + await rpc.ensureTrusted() + const state = await rpc.sharedState.get(stateKey) + if (state.value() === undefined) + state.mutate(() => ({} as T)) + return state + })().catch((error) => { + statePromise = undefined + throw error + }) } return statePromise }