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 1/2] 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 } From c1bedd19f7562b3b0c02df5630ab08ce9b077437 Mon Sep 17 00:00:00 2001 From: Sakana <15715093608@163.com> Date: Sun, 11 Oct 2026 12:55:37 +0800 Subject: [PATCH 2/2] fix(settings): keep empty reads from publishing state Treat absent snapshots as empty settings without sending a shared-state update. Create the state on the first write so a read cannot replace lazy file storage with an in-memory store. Add project and global regressions for read-only initialization, file loading, persistence, and restart reads. --- packages/devframe/src/client/settings.test.ts | 66 +++++++++++++++++++ packages/devframe/src/client/settings.ts | 7 +- packages/devframe/src/settings-store.ts | 12 ++-- 3 files changed, 76 insertions(+), 9 deletions(-) diff --git a/packages/devframe/src/client/settings.test.ts b/packages/devframe/src/client/settings.test.ts index c182ec228..f50468a4f 100644 --- a/packages/devframe/src/client/settings.test.ts +++ b/packages/devframe/src/client/settings.test.ts @@ -1,8 +1,12 @@ import type { DevframeRpcClient } from './rpc' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' 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 { createHostContext } from '../node/context' import { createRpcSharedStateClientHost } from './rpc-shared-state' import { createClientSettings } from './settings' @@ -65,6 +69,68 @@ describe('client settings initialization', () => { await expect(settings.project.get('theme')).resolves.toBe('dark') }) + it.each(['project', 'global'] as const)('keeps an absent %s store local until a setting is written', async (scope) => { + const { rpc, settings, snapshot } = setup() + const onChange = vi.fn() + const operations = Promise.all([ + settings[scope].get('theme'), + settings[scope].all(), + settings[scope].onChange(onChange), + settings[scope].delete('theme'), + ]) + snapshot.resolve(undefined) + const [value, all, unsubscribe] = await operations + expect(value).toBeUndefined() + expect(all).toEqual({}) + expect(vi.mocked(rpc.callEvent).mock.calls).toEqual([ + ['devframe:rpc:server-state:subscribe', `devframe:settings:${scope}:test`], + ]) + expect(onChange).not.toHaveBeenCalled() + + await settings[scope].set('theme', 'dark') + expect(rpc.callEvent).toHaveBeenLastCalledWith( + 'devframe:rpc:server-state:set', + `devframe:settings:${scope}:test`, + { theme: 'dark' }, + expect.any(String), + ) + expect(onChange).toHaveBeenCalledExactlyOnceWith({ theme: 'dark' }) + unsubscribe() + }) + + it.each(['project', 'global'] as const)('preserves file storage after a browser-side %s read', async (scope) => { + const dir = mkdtempSync(join(tmpdir(), 'devframe-settings-')) + const host = { + mountStatic: () => {}, + resolveOrigin: () => 'http://localhost', + getStorageDir: (storageScope: string) => join(dir, storageScope), + } + const filepath = join(dir, scope, 'settings/test.json') + mkdirSync(join(dir, scope, 'settings'), { recursive: true }) + writeFileSync(filepath, JSON.stringify({ theme: 'saved' })) + try { + const ctx = await createHostContext({ cwd: dir, mode: 'dev', host }) + // Dispatch RPC handlers directly without a transport session. + vi.spyOn(ctx.rpc, 'getCurrentRpcSession').mockReturnValue(undefined) + const { rpc, settings } = setup() + vi.mocked(rpc.call).mockImplementation(ctx.rpc.invokeLocal.bind(ctx.rpc)) + vi.mocked(rpc.callEvent).mockImplementation(ctx.rpc.invokeLocal.bind(ctx.rpc)) + + await settings[scope].all() + const nodeSettings = ctx.scope('test').settings[scope] + await expect(nodeSettings.get('theme')).resolves.toBe('saved') + await nodeSettings.set('theme', 'changed') + await vi.waitFor(() => { + expect(JSON.parse(readFileSync(filepath, 'utf8'))).toEqual({ theme: 'changed' }) + }) + const restarted = await createHostContext({ cwd: dir, mode: 'dev', host }) + await expect(restarted.scope('test').settings[scope].get('theme')).resolves.toBe('changed') + } + finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + 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') diff --git a/packages/devframe/src/client/settings.ts b/packages/devframe/src/client/settings.ts index 697b4c85d..8c79fad75 100644 --- a/packages/devframe/src/client/settings.ts +++ b/packages/devframe/src/client/settings.ts @@ -11,16 +11,13 @@ function createClientSettingsStore>( const stateKey = `devframe:settings:${scope}:${namespace}` let statePromise: Promise> | undefined - // Resolve the server snapshot before reading or changing settings so that + // Resolve the node-side snapshot before reading or changing settings so that // initialization cannot overwrite the first local operation. function store(): Promise> { if (!statePromise) { statePromise = (async () => { await rpc.ensureTrusted() - const state = await rpc.sharedState.get(stateKey) - if (state.value() === undefined) - state.mutate(() => ({} as T)) - return state + return rpc.sharedState.get(stateKey) })().catch((error) => { statePromise = undefined throw error diff --git a/packages/devframe/src/settings-store.ts b/packages/devframe/src/settings-store.ts index 5841a92d4..f5d431dd4 100644 --- a/packages/devframe/src/settings-store.ts +++ b/packages/devframe/src/settings-store.ts @@ -12,23 +12,27 @@ export function createSettingsStore>( ): DevframeSettingsStore { return { async get(key) { - return ((await store()).value() as T)[key] + return ((await store()).value() as T | undefined)?.[key] }, async set(key, value) { ;(await store()).mutate((draft) => { + if (draft === undefined) { + return { [key]: value } as T + } ;(draft as T)[key] = value }) }, async delete(key) { ;(await store()).mutate((draft) => { - delete (draft as T)[key] + if (draft !== undefined) + delete (draft as T)[key] }) }, async all() { - return (await store()).value() as Readonly + return ((await store()).value() ?? {}) as Readonly }, async onChange(fn) { - return (await store()).on('updated', full => fn(full as Readonly)) + return (await store()).on('updated', full => fn((full ?? {}) as Readonly)) }, } }