diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts index 2ace9e56b1..b3c149c938 100644 --- a/packages/client/ui-settings/src/client/index.ts +++ b/packages/client/ui-settings/src/client/index.ts @@ -1,35 +1,64 @@ /** * Settings domain base plugin, browser half. Provides `ctx.settingsScope`, the - * settings-namespace Host transport every preference row binds its durable - * section through, and owns the canonical slot-type contract for the settings - * surface. It depends on no `ui-*` presentation package, so any feature that - * owns a preference can reach it: the settings SHELL — the `sidebar.settings` - * occupant, its navigation, and the chrome — lives in ui-settings-general, - * because a shell dependency on ui-sidebar would close a reference cycle - * through ui-layout and ui-theme. Export discipline: packages/client/AGENTS.md. + * settings-namespace scope service every preference row binds its durable + * section through, and owns the one `settings.describe` reader in the browser: + * the describe mirror, whose invalidation subscriptions + * (`settings/document-updated`, `connection/reset`) live here so every derived + * surface refreshes from a single wire read. It depends on no `ui-*` + * presentation package, so any feature that owns a preference can reach it: + * the settings SHELL — the `sidebar.settings` occupant, its navigation, and + * the chrome — lives in ui-settings-general, because a shell dependency on + * ui-sidebar would close a reference cycle through ui-layout and ui-theme. + * Export discipline: packages/client/AGENTS.md. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client' +// Type-only pair supplying `$on` and its key face without dragging a build +// artifact into the Host graph (rationale beside the same pair in +// settings-scope.ts). +import type {} from '@deepseek-ai/dsh-api-remotes/types' +import type {} from '@deepseek-ai/dsh-settings/types' import { SettingsScopeBinder } from './settings-scope.ts' +import { SettingsDescribeMirror } from './settings-mirror.ts' export type { SettingsGeneralItemOwnerProps, SettingsHeaderOwnerProps, SettingsOnboardingOwnerProps, SettingsPluginsTabOwnerProps, SettingsSectionOwnerProps, SettingsTriggerOwnerProps, } from './contract/slots.ts' export { SettingsScopeController, SettingsScopeBinder } from './settings-scope.ts' +export { SettingsDescribeMirror } from './settings-mirror.ts' +export type { SettingsDescribeView, SettingsMirrorSnapshot } from './settings-mirror.ts' /** - * Required services: none. The transport is resolved per caller through - * `this.ctx` at `bind` time, so this plugin waits for nothing. + * Required services: the wire handle for the mirror's reads and the forwarded + * settings invalidation the mirror refreshes on. */ -export const inject = [] +export const inject = ['connection', 'remote'] /** - * Provide the settings-namespace scope service. + * Provide the settings-namespace scope service over one shared describe + * mirror, and keep that mirror fresh on the two signals that can move the + * settings document: a document commit and a (re)connect. * * Constructing the service in this plugin's fiber keeps its traced methods * bound to each consuming plugin's context. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { - new SettingsScopeBinder(ctx) + const connection = ctx.get('connection') as ConnectionHandle + const mirror = new SettingsDescribeMirror( + connection.api, + connection.isLoopback ? 'host' : 'memory', + ) + ctx.effect(() => { + const disposers = [ + (ctx.get('remote') as ClientContext['remote']).$on('settings/document-updated', () => { void mirror.load() }), + ctx.on('connection/reset', () => { void mirror.load() }), + ] + // The first connection also emits connection/reset; the in-flight fold + // makes this eager read and that reset converge to one wire call. + void mirror.ensure() + return () => { for (const dispose of disposers) dispose() } + }, 'ui-settings: describe mirror invalidations') + new SettingsScopeBinder(ctx, { mirror }) } diff --git a/packages/client/ui-settings/src/client/settings-scope.ts b/packages/client/ui-settings/src/client/settings-scope.ts index 4668c4924b..47ebb3daa3 100644 --- a/packages/client/ui-settings/src/client/settings-scope.ts +++ b/packages/client/ui-settings/src/client/settings-scope.ts @@ -1,8 +1,11 @@ /** * Host transport for the settings-namespace scope contract. The contract types * live in `dsh-client-runtime` (the common dependency of every feature that - * owns a preference); this file owns the wire behavior and the invalidation - * subscription, both of which are Settings-surface concerns. + * owns a preference); this file owns the per-namespace derivation over the + * shared {@link SettingsDescribeMirror} and the serialized write path, both of + * which are Settings-surface concerns. Reads never touch the wire here: the + * mirror is the one `settings.describe` reader, and every scope is a selector + * over its snapshot. */ import { Service } from '@deepseek-ai/cordis' @@ -22,8 +25,8 @@ import { // Client half declares `ctx.remote` with no generated import, and the // allowlist's `types` subpath is a pure-type source file, so the pair supplies // `$on` and its key face without dragging a build artifact in. The runtime -// `remote` injection belongs to whoever calls bindSettingsScope: the -// subscription is registered on the caller's own context. +// `remote` injection belongs to the providing plugin's apply, which registers +// the mirror's invalidation subscriptions. import type {} from '@deepseek-ai/dsh-api-remotes/client' import type {} from '@deepseek-ai/dsh-api-remotes/types' // The forwarded event's own declaration: `$on`'s key face is @@ -31,29 +34,39 @@ import type {} from '@deepseek-ai/dsh-api-remotes/types' // never — the owning package's client-safe, type-only subpath supplies the // cordis `Events` entry (and with it the branded `SettingsNamespace`). import type {} from '@deepseek-ai/dsh-settings/types' +import { SettingsDescribeMirror } from './settings-mirror.ts' + type SettingsFace = Pick /** - * Serializes one namespace's Host reads and writes behind a snapshot store. - * Reads never block plugin activation; writes carry the latest known - * namespace revision and teardown waits for the operation already crossing - * the wire. + * One namespace's derived view over the shared describe mirror, plus that + * namespace's serialized Host writes. Writes carry the latest known namespace + * revision, fold their answers back into the mirror, and teardown waits for + * the operation already crossing the wire. */ export class SettingsScopeController implements SettingsScope { private readonly store: SnapshotStore> private tail: Promise = Promise.resolve() - private readGeneration = 0 private writeGeneration = 0 private disposed = false + private readonly unsubscribe: (() => void) | undefined + /** + * Revision answered by a superseded write still ahead of the mirror: the + * mirror only folds the LATEST settlement in, so a queued successor takes + * its fence from here first. + */ + private pendingRevision: number | undefined /** - * @param api - settings wire face. + * @param api - settings wire face (writes only; reads ride the mirror). * @param spec - namespace identity and optional narrowing decoder. + * @param mirror - the shared describe mirror this scope derives from. * @param persistence - remote browsers remain process-local because settings RPCs are loopback-only. */ constructor( private readonly api: SettingsFace, private readonly spec: SettingsScopeSpec, + private readonly mirror: SettingsDescribeMirror, private readonly persistence: 'host' | 'memory' = 'host', ) { this.store = createSnapshotStore>({ @@ -65,6 +78,10 @@ export class SettingsScopeController implements SettingsScope { writable: false, mode: persistence, }) + if (persistence === 'host') { + this.unsubscribe = mirror.subscribe(() => { this.derive() }) + this.derive() + } } /** @returns the current sync snapshot (stable reference until the next change). */ @@ -81,15 +98,6 @@ export class SettingsScopeController implements SettingsScope { return this.store.subscribe(listener) } - /** - * Queue a Host refresh; a newer read or user write suppresses stale publication. - * @returns settlement after the queued read completes or is skipped. - */ - load(): Promise { - const generation = ++this.readGeneration - return this.enqueue(() => this.read(generation)) - } - /** * Queue one field write; see {@link SettingsScope.set} for the ordering, * revision, and recovery contract. @@ -112,10 +120,9 @@ export class SettingsScopeController implements SettingsScope { } private write(op: SettingsPathOpView): Promise { - this.readGeneration += 1 const generation = ++this.writeGeneration return this.enqueue(async () => { - const revision = this.getSnapshot().revision + const revision = this.pendingRevision ?? this.getSnapshot().revision let response: Awaited> try { response = await this.api.settings.mutate({ @@ -124,25 +131,39 @@ export class SettingsScopeController implements SettingsScope { ...(revision === undefined ? {} : { expectedRevision: revision }), }) } catch (_settingsWriteFailure) { - if (!this.disposed && generation === this.writeGeneration) await this.read(++this.readGeneration) + await this.recover(generation) return } if (!response.result.ok) { - if (!this.disposed && generation === this.writeGeneration) await this.read(++this.readGeneration) + await this.recover(generation) return } - this.accept(response.result.value, generation === this.writeGeneration) + if (this.disposed) return + if (generation === this.writeGeneration) { + this.pendingRevision = undefined + this.mirror.acceptView(response.result.value) + } else { + this.pendingRevision = response.result.value.revision + } }) } + /** Reload Host state for the latest failed write; superseded failures leave recovery to it. */ + private async recover(generation: number): Promise { + if (this.disposed || generation !== this.writeGeneration) return + this.pendingRevision = undefined + await this.mirror.load() + } + /** - * Stop queued operations and wait for the current wire call to settle. + * Stop queued operations, stop deriving, and wait for the current wire call + * to settle. * @returns settlement after the controller reaches quiescence. */ async dispose(): Promise { this.disposed = true - this.readGeneration += 1 this.writeGeneration += 1 + this.unsubscribe?.() await this.tail } @@ -158,36 +179,25 @@ export class SettingsScopeController implements SettingsScope { return task } - private async read(generation: number): Promise { - let response: Awaited> - try { - response = await this.api.settings.describe({}) - } catch (_settingsReadFailure) { - return - } - if (!response.result.ok || this.disposed) return - const { namespaces, writable } = response.result.value - const view = namespaces.find(candidate => candidate.ns === this.spec.namespace) - const publish = generation === this.readGeneration + private derive(): void { + if (this.disposed) return + const mirrored = this.mirror.getSnapshot() + if (mirrored.view === undefined) return + const { writable } = mirrored.view + const view = mirrored.view.namespaces.find(candidate => candidate.ns === this.spec.namespace) if (view === undefined) { - if (publish) { - this.store.update((draft) => { - draft.status = 'unavailable' - draft.writable = writable - }) - } + this.store.update((draft) => { + draft.status = 'unavailable' + draft.writable = writable + }) return } - this.accept(view, publish, writable) - } - - private accept(view: SettingsNamespaceView, publish: boolean, writable?: boolean): void { - const decoded = publish ? this.decode(view) : undefined + const decoded = this.decode(view) this.store.update((draft) => { draft.revision = view.revision draft.base = view.base draft.user = view.user - if (writable !== undefined) draft.writable = writable + draft.writable = writable if (decoded === undefined) return draft.status = 'ready' draft.value = decoded @@ -225,20 +235,24 @@ declare module '@deepseek-ai/cordis' { * (`packages/client/tsdown.client.ts`). */ export class SettingsScopeBinder extends Service { + private readonly mirror: SettingsDescribeMirror + /** * @param ctx - the providing plugin's context. + * @param config - the shared describe mirror every bound scope derives from. */ - constructor(ctx: Context) { + constructor(ctx: Context, config: { mirror: SettingsDescribeMirror }) { super(ctx, 'settingsScope') + this.mirror = config.mirror } /** - * Bind one namespace scope to settings and connection invalidations on the - * CALLER's plugin lifecycle — the service proxy binds `this.ctx` to the - * caller at call time, so the scope's disposer belongs to the calling fiber. - * Listeners exist before the initial background read starts, so activation - * never blocks on the settings transport. The caller injects `connection` - * for the transport and `remote` for the forwarded settings invalidation. + * Bind one namespace scope on the CALLER's plugin lifecycle — the service + * proxy binds `this.ctx` to the caller at call time, so the scope's disposer + * belongs to the calling fiber. The scope derives from the shared mirror + * (whose invalidation subscriptions live with the providing plugin), so + * binding adds no wire read of its own and activation never blocks on the + * settings transport. * @param spec - domain-owned namespace contract. * @returns the bound scope consumed by the domain's services and rows. */ @@ -248,20 +262,12 @@ export class SettingsScopeBinder extends Service { const controller = new SettingsScopeController( connection.api, spec, + this.mirror, connection.isLoopback ? 'host' : 'memory', ) ctx.effect(() => { - const refresh = (namespace?: string): void => { - if (namespace !== undefined && namespace !== spec.namespace) return - void controller.load() - } - const disposers = [ - (ctx.get('remote') as Context['remote']).$on('settings/document-updated', refresh), - ctx.on('connection/reset', () => { refresh() }), - ] - void controller.load() + void this.mirror.ensure() return async () => { - for (const dispose of disposers) dispose() await controller.dispose() } }, `ui-settings: ${spec.namespace} settings scope`) diff --git a/packages/client/ui-settings/tests/plugin.client.spec.ts b/packages/client/ui-settings/tests/plugin.client.spec.ts index 1643e9580f..c63bf1fe00 100644 --- a/packages/client/ui-settings/tests/plugin.client.spec.ts +++ b/packages/client/ui-settings/tests/plugin.client.spec.ts @@ -1,29 +1,56 @@ /** * The settings domain base plugin's own mounting behavior: it stands up - * `ctx.settingsScope` for every feature that owns a preference row, and the - * service retires with its fiber. + * `ctx.settingsScope` over one shared describe mirror, keeps that mirror + * fresh on settings-document and connection-reset invalidations, and retires + * both the service and the subscriptions with its fiber. */ import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' +import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject, SettingsScopeBinder } from '../src/client/index.ts' -/** Boot the browser half over a bare root context; it injects nothing. */ +/** Boot the browser half over a fake loopback connection and test remote. */ function bench() { + const describeCall = vi.fn().mockResolvedValue({ + rpcId: 'plugin-bench' as never, + result: { ok: true, value: { writable: true, hasDocument: true, namespaces: [] } }, + }) const ctx = new Context() - return { ctx, fiber: ctx.plugin({ inject: [...inject], apply }) } + ctx.provide('connection', { + api: { settings: { describe: describeCall } }, + isLoopback: true, + } as never) + new TestRemote(ctx) + return { ctx, describeCall, fiber: ctx.plugin({ inject: [...inject], apply }) } } describe('settings domain base plugin', () => { - it('mounts the scope service under settingsScope', async () => { - const { ctx, fiber } = bench() + it('mounts the scope service under settingsScope and reads once eagerly', async () => { + const { ctx, describeCall, fiber } = bench() await fiber.await() expect(ctx.get('settingsScope')).toBeInstanceOf(SettingsScopeBinder) + await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(1) }) }) - it('fiber disposal retires the service', async () => { - const { ctx, fiber } = bench() + it('refreshes the mirror on document commits and connection resets, once each', async () => { + const { ctx, describeCall, fiber } = bench() await fiber.await() + await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(1) }) + ctx.remote.$dispatch('settings/document-updated', ['ui-test', 0]) + await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(2) }) + ctx.emit('connection/reset') + await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(3) }) + }) + + it('fiber disposal retires the service and its invalidation subscriptions', async () => { + const { ctx, describeCall, fiber } = bench() + await fiber.await() + await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(1) }) await fiber.dispose() expect(ctx.get('settingsScope')).toBeUndefined() + ctx.remote.$dispatch('settings/document-updated', ['ui-test', 0]) + ctx.emit('connection/reset') + await Promise.resolve() + expect(describeCall).toHaveBeenCalledTimes(1) }) }) diff --git a/packages/client/ui-settings/tests/settings-scope.client.spec.ts b/packages/client/ui-settings/tests/settings-scope.client.spec.ts index 429002028a..1e4c473ef1 100644 --- a/packages/client/ui-settings/tests/settings-scope.client.spec.ts +++ b/packages/client/ui-settings/tests/settings-scope.client.spec.ts @@ -5,6 +5,7 @@ import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-re import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import type { SettingsScope } from '@deepseek-ai/dsh-client-runtime/client' import { SettingsScopeController, SettingsScopeBinder } from '../src/client/settings-scope.ts' +import { SettingsDescribeMirror } from '../src/client/settings-mirror.ts' interface UiTestSettings { preference: 'light' | 'dark' | 'system' @@ -52,6 +53,17 @@ function deferred() { return { promise, resolve, reject } } +/** A host-mode mirror plus a controller derived from it, over one fake wire. */ +function derivedScope( + api: { describe?: ReturnType; mutate?: ReturnType }, + spec: { namespace: string; decode?: (section: unknown) => UiTestSettings | undefined } = { namespace: 'ui-test' }, +) { + const wire = { settings: api } as never + const mirror = new SettingsDescribeMirror(wire) + const scope = new SettingsScopeController(wire, spec, mirror) + return { mirror, scope } +} + /** Record each distinct published section, starting from the current one. */ function trackValues(scope: SettingsScope): Array { const seen: Array = [scope.getSnapshot().value] @@ -63,16 +75,13 @@ function trackValues(scope: SettingsScope): Array { - it('starts loading and publishes a schema-valid section with revision and writability', async () => { + it('starts loading and derives a schema-valid section with revision and writability', async () => { const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'dark' }, 3)) - const scope = new SettingsScopeController( - { settings: { describe: describeCall } } as never, - { namespace: 'ui-test' }, - ) + const { mirror, scope } = derivedScope({ describe: describeCall }) expect(scope.getSnapshot()).toEqual({ status: 'loading', value: undefined, revision: undefined, writable: false, mode: 'host', }) - await scope.load() + await mirror.load() expect(scope.getSnapshot()).toEqual({ status: 'ready', value: { preference: 'dark' }, revision: 3, writable: true, mode: 'host', }) @@ -87,12 +96,9 @@ describe('SettingsScopeController', () => { .mockResolvedValueOnce(described(['queue'], 7)) .mockResolvedValueOnce(rejected()) .mockRejectedValueOnce(new Error('offline')) - const scope = new SettingsScopeController( - { settings: { describe: describeCall } } as never, - { namespace: 'ui-test' }, - ) + const { mirror, scope } = derivedScope({ describe: describeCall }) const good = trackValues(scope) - for (let i = 0; i < 7; i++) await scope.load() + for (let i = 0; i < 7; i++) await mirror.load() expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' }, revision: 7, }) @@ -103,45 +109,22 @@ describe('SettingsScopeController', () => { const broken = { ...view({ preference: 'dark' }, 2), schema: null } const describeCall = vi.fn() .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [broken] })) - const scope = new SettingsScopeController( - { settings: { describe: describeCall } } as never, - { namespace: 'ui-test' }, - ) - await scope.load() + const { mirror, scope } = derivedScope({ describe: describeCall }) + await mirror.load() expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 2 }) }) - it('suppresses a superseded read of an unexposed namespace', async () => { - const describeCall = vi.fn() - .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] })) - .mockResolvedValueOnce(described({ preference: 'dark' }, 1)) - const scope = new SettingsScopeController( - { settings: { describe: describeCall } } as never, - { namespace: 'ui-test' }, - ) - const statuses: string[] = [] - scope.subscribe(() => { statuses.push(scope.getSnapshot().status) }) - const stale = scope.load() - const fresh = scope.load() - await Promise.all([stale, fresh]) - expect(statuses).not.toContain('unavailable') - expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' } }) - }) - it('reports an unexposed namespace as unavailable and recovers when it reappears', async () => { const describeCall = vi.fn() .mockResolvedValueOnce(described({ preference: 'light' }, 1)) .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] })) .mockResolvedValueOnce(described({ preference: 'system' }, 2)) - const scope = new SettingsScopeController( - { settings: { describe: describeCall } } as never, - { namespace: 'ui-test' }, - ) - await scope.load() + const { mirror, scope } = derivedScope({ describe: describeCall }) + await mirror.load() expect(scope.getSnapshot().status).toBe('ready') - await scope.load() + await mirror.load() expect(scope.getSnapshot()).toMatchObject({ status: 'unavailable', value: { preference: 'light' } }) - await scope.load() + await mirror.load() expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'system' }, revision: 2 }) }) @@ -149,18 +132,15 @@ describe('SettingsScopeController', () => { const describeCall = vi.fn() .mockResolvedValueOnce(described({ preference: 'light' }, 1)) .mockResolvedValueOnce(described({ preference: 'dark' }, 2)) - const scope = new SettingsScopeController( - { settings: { describe: describeCall } } as never, - { - namespace: 'ui-test', - decode: section => (section as UiTestSettings).preference === 'dark' - ? section as UiTestSettings - : undefined, - }, - ) - await scope.load() + const { mirror, scope } = derivedScope({ describe: describeCall }, { + namespace: 'ui-test', + decode: section => (section as UiTestSettings).preference === 'dark' + ? section as UiTestSettings + : undefined, + }) + await mirror.load() expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 1 }) - await scope.load() + await mirror.load() expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' }, revision: 2 }) }) @@ -170,12 +150,9 @@ describe('SettingsScopeController', () => { const mutate = vi.fn() .mockReturnValueOnce(first.promise) .mockResolvedValueOnce(ok(view({ preference: 'light' }, 6))) - const scope = new SettingsScopeController( - { settings: { describe: describeCall, mutate } } as never, - { namespace: 'ui-test' }, - ) + const { mirror, scope } = derivedScope({ describe: describeCall, mutate }) const published = trackValues(scope) - await scope.load() + await mirror.load() const dark = scope.set('preference', 'dark') const light = scope.set('preference', 'light') await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() }) @@ -195,6 +172,19 @@ describe('SettingsScopeController', () => { }) }) + it('folds the latest write answer into the mirror so a sibling scope sees it', async () => { + const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'system' }, 4)) + const mutate = vi.fn().mockResolvedValueOnce(ok(view({ preference: 'dark' }, 5))) + const wire = { settings: { describe: describeCall, mutate } } as never + const mirror = new SettingsDescribeMirror(wire) + const writer = new SettingsScopeController(wire, { namespace: 'ui-test' }, mirror) + const sibling = new SettingsScopeController(wire, { namespace: 'ui-test' }, mirror) + await mirror.load() + await writer.set('preference', 'dark') + expect(describeCall).toHaveBeenCalledTimes(1) + expect(sibling.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 5 }) + }) + it('recovers the latest rejected or thrown write from Host state', async () => { const describeCall = vi.fn() .mockResolvedValueOnce(described({ preference: 'system' }, 2)) @@ -202,52 +192,45 @@ describe('SettingsScopeController', () => { const mutate = vi.fn() .mockResolvedValueOnce(rejected()) .mockRejectedValueOnce(new Error('offline')) - const scope = new SettingsScopeController( - { settings: { describe: describeCall, mutate } } as never, - { namespace: 'ui-test' }, - ) + const { mirror, scope } = derivedScope({ describe: describeCall, mutate }) const published = trackValues(scope) + await mirror.load() await scope.set('preference', 'dark') await scope.set('preference', 'system') expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light']) }) it('does not recover superseded rejected or thrown writes', async () => { - const describeCall = vi.fn() + const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'system' }, 2)) const mutate = vi.fn() .mockResolvedValueOnce(rejected()) .mockRejectedValueOnce(new Error('offline')) .mockResolvedValueOnce(ok(view({ preference: 'light' }, 3))) - const scope = new SettingsScopeController( - { settings: { describe: describeCall, mutate } } as never, - { namespace: 'ui-test' }, - ) + const { mirror, scope } = derivedScope({ describe: describeCall, mutate }) const published = trackValues(scope) + await mirror.load() await Promise.all([ scope.set('preference', 'dark'), scope.set('preference', 'system'), scope.set('preference', 'light'), ]) - expect(describeCall).not.toHaveBeenCalled() - expect(published.map(section => section?.preference)).toEqual([undefined, 'light']) + expect(describeCall).toHaveBeenCalledTimes(1) + expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light']) }) it('keeps the write queue usable when a subscriber throws', async () => { const describeCall = vi.fn() .mockResolvedValueOnce(described({ preference: 'dark' }, 1)) .mockResolvedValueOnce(described({ preference: 'light' }, 2)) - const scope = new SettingsScopeController( - { settings: { describe: describeCall } } as never, - { namespace: 'ui-test' }, - ) + const { mirror, scope } = derivedScope({ describe: describeCall }) let thrown = false scope.subscribe(() => { if (thrown) return thrown = true throw new Error('subscriber failed') }) - await expect(scope.load()).rejects.toThrow('subscriber failed') - await expect(scope.load()).resolves.toBeUndefined() + await expect(mirror.load()).rejects.toThrow('subscriber failed') + await expect(mirror.load()).resolves.toBeUndefined() expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 2 }) }) @@ -255,10 +238,7 @@ describe('SettingsScopeController', () => { const first = deferred>() const mutate = vi.fn().mockReturnValue(first.promise) const describeCall = vi.fn() - const scope = new SettingsScopeController( - { settings: { describe: describeCall, mutate } } as never, - { namespace: 'ui-test' }, - ) + const { scope } = derivedScope({ describe: describeCall, mutate }) const published = trackValues(scope) const dark = scope.set('preference', 'dark') await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() }) @@ -270,24 +250,34 @@ describe('SettingsScopeController', () => { first.resolve(ok(view({ preference: 'dark' }, 1))) await Promise.all([dark, light, stop]) await scope.set('preference', 'system') - await scope.load() expect(mutate).toHaveBeenCalledOnce() expect(describeCall).not.toHaveBeenCalled() expect(published).toEqual([undefined]) }) + it('stops deriving from the mirror after dispose', async () => { + const describeCall = vi.fn() + .mockResolvedValueOnce(described({ preference: 'dark' }, 1)) + .mockResolvedValueOnce(described({ preference: 'light' }, 2)) + const { mirror, scope } = derivedScope({ describe: describeCall }) + await mirror.load() + expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' } }) + await scope.dispose() + await mirror.load() + expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 1 }) + }) + it('keeps a remote browser in memory mode without Host calls', async () => { const describeCall = vi.fn() const mutate = vi.fn() + const wire = { settings: { describe: describeCall, mutate } } as never + const mirror = new SettingsDescribeMirror(wire, 'memory') const scope = new SettingsScopeController( - { settings: { describe: describeCall, mutate } } as never, - { namespace: 'ui-test' }, - 'memory', - ) + wire, { namespace: 'ui-test' }, mirror, 'memory') expect(scope.getSnapshot()).toEqual({ status: 'unavailable', value: undefined, revision: undefined, writable: false, mode: 'memory', }) - await scope.load() + await mirror.load() await scope.set('preference', 'dark') await scope.dispose() expect(describeCall).not.toHaveBeenCalled() @@ -302,12 +292,9 @@ describe('SettingsScopeController', () => { } const describeCall = vi.fn() .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [layered] })) - const scope = new SettingsScopeController( - { settings: { describe: describeCall } } as never, - { namespace: 'ui-test' }, - ) + const { mirror, scope } = derivedScope({ describe: describeCall }) - await scope.load() + await mirror.load() expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, @@ -320,12 +307,9 @@ describe('SettingsScopeController', () => { const inherited: SettingsNamespaceView = { ...view({ preference: 'system' }, 1), base: { preference: 'system' } } const describeCall = vi.fn() .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [inherited] })) - const scope = new SettingsScopeController( - { settings: { describe: describeCall } } as never, - { namespace: 'ui-test' }, - ) + const { mirror, scope } = derivedScope({ describe: describeCall }) - await scope.load() + await mirror.load() expect(scope.getSnapshot().user).toBeUndefined() }) @@ -333,11 +317,8 @@ describe('SettingsScopeController', () => { it('clears one field through an unset op fenced by the held revision', async () => { const mutate = vi.fn().mockResolvedValueOnce(ok(view({ preference: 'system' }, 4))) const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'dark' }, 3)) - const scope = new SettingsScopeController( - { settings: { describe: describeCall, mutate } } as never, - { namespace: 'ui-test' }, - ) - await scope.load() + const { mirror, scope } = derivedScope({ describe: describeCall, mutate }) + await mirror.load() await scope.unset('preference') @@ -354,64 +335,53 @@ describe('SettingsScopeController', () => { const describeCall = vi.fn() .mockResolvedValueOnce(described({ preference: 'dark' }, 3)) .mockResolvedValueOnce(described({ preference: 'light' }, 5)) - const scope = new SettingsScopeController( - { settings: { describe: describeCall, mutate } } as never, - { namespace: 'ui-test' }, - ) - await scope.load() + const { mirror, scope } = derivedScope({ describe: describeCall, mutate }) + await mirror.load() await scope.unset('preference') expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 5 }) }) }) + describe('SettingsScopeBinder.bind', () => { - it('subscribes before the initial read and converges to the latest queued invalidation', async () => { - const initial = deferred>() - const describeCall = vi.fn() - .mockReturnValueOnce(initial.promise) - .mockResolvedValueOnce(described({ preference: 'light' }, 2)) - .mockResolvedValueOnce(described({ preference: 'system' }, 3)) + it('shares one mirror read across bound scopes and disposes each with its fiber', async () => { + const describeCall = vi.fn().mockResolvedValue(described({ preference: 'dark' }, 1)) + const wire = { settings: { describe: describeCall } } + const mirror = new SettingsDescribeMirror(wire as never) const ctx = new Context() - ctx.provide('connection', { - api: { settings: { describe: describeCall } }, - isLoopback: true, - } as never) - let scope!: SettingsScope + ctx.provide('connection', { api: wire, isLoopback: true } as never) + let theme!: SettingsScope + let locale!: SettingsScope new TestRemote(ctx) - await ctx.plugin(SettingsScopeBinder).await() + await ctx.plugin(SettingsScopeBinder, { mirror }).await() const fiber = ctx.plugin({ inject: ['connection', 'remote', 'settingsScope'], apply: (plugin: Context) => { - scope = plugin.settingsScope.bind({ namespace: 'ui-test' }) + theme = plugin.settingsScope.bind({ namespace: 'ui-test' }) + locale = plugin.settingsScope.bind({ namespace: 'ui-test' }) }, }) await fiber.await() - await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledOnce() }) - ctx.remote.$dispatch('settings/document-updated', ['unrelated', 0]) - ctx.remote.$dispatch('settings/document-updated', ['ui-test', 0]) - ctx.emit('connection/reset') - initial.resolve(described({ preference: 'dark' }, 1)) - await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(3) }) await vi.waitFor(() => { - expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'system' }, revision: 3 }) + expect(theme.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' } }) + expect(locale.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' } }) }) + expect(describeCall).toHaveBeenCalledTimes(1) await fiber.dispose() - ctx.remote.$dispatch('settings/document-updated', ['ui-test', 0]) - await Promise.resolve() - expect(describeCall).toHaveBeenCalledTimes(3) + await mirror.load() + expect(theme.getSnapshot()).toMatchObject({ revision: 1 }) }) it('binds a remote browser in memory mode without starting a settings read', async () => { const describeCall = vi.fn() + const wire = { settings: { describe: describeCall } } + const mirror = new SettingsDescribeMirror(wire as never, 'memory') const ctx = new Context() - ctx.provide('connection', { - api: { settings: { describe: describeCall } }, - isLoopback: false, - } as never) + ctx.provide('connection', { api: wire, isLoopback: false } as never) let scope!: SettingsScope new TestRemote(ctx) - await ctx.plugin(SettingsScopeBinder).await() + await ctx.plugin(SettingsScopeBinder, { mirror }).await() const fiber = ctx.plugin({ inject: ['connection', 'remote', 'settingsScope'], apply: (plugin: Context) => {