refactor(client): replace the per-field settings preference controller with a namespace settings scope

bindSettingsScope mirrors the Host-side settings owner seam in the browser:
one scope per namespace publishes a snapshot store (status, section value,
revision, writability, host/memory mode), validates sections against the
namespace's serialized wire schema via dsh-client-schema-form, and keeps the
controller's listener-before-read, revisioned serialized writes, latest-wins
publication, conflict recovery, and disposal quiescence. Theme, locale, and
busy-Enter services now take the scope as a constructor collaborator, which
removes the bindPersistence/syncPreference two-phase callback pair and the
defaulted no-op persist writers; hand-written wire guards fall away in favor
of the registered schema. test-runtime gains a stubSettingsScope double.
This commit is contained in:
Yichen Jiang
2026-08-07 23:25:42 +08:00
parent 6922a942a6
commit 638c9e4bd7
37 changed files with 926 additions and 617 deletions

View File

@@ -9,6 +9,7 @@ import {
} from '@deepseek-ai/dsh-client-locale/client'
import type { LanguageRowInjected, LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { LOCALE_SETTINGS_NAMESPACE } from '../src/locale-settings.ts'
import { LocaleSettingsSchema } from '../src/index.ts'
import { LanguageRow } from '../src/client/LanguageRow.tsx'
import type { createLanguageRowStore } from '../src/client/settings-store.ts'
@@ -21,7 +22,7 @@ async function bench() {
let revision = 0
const namespace = () => ({
ns: LOCALE_SETTINGS_NAMESPACE,
schema: {},
schema: LocaleSettingsSchema.toJSON(),
value: preference === undefined ? {} : { preference },
applies: 'live' as const,
secrets: [],

View File

@@ -1,14 +1,19 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client'
import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import type { LocaleSettings, LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
const make = (): { ctx: Context; svc: LocaleService; events: LocaleSnapshot[] } => {
const make = (host?: StubSettingsScope<LocaleSettings>): {
ctx: Context
svc: LocaleService
events: LocaleSnapshot[]
} => {
const ctx = new Context()
const events: LocaleSnapshot[] = []
ctx.on('locale/change', (snapshot) => { events.push(snapshot) })
return { ctx, svc: new LocaleService(ctx), events }
return { ctx, svc: new LocaleService(ctx, host?.scope), events }
}
/**
@@ -131,19 +136,25 @@ describe('LocaleService', () => {
expect(svc.getSnapshot().revision).toBe(before + 1)
})
it('setLocale requests persistence, republishes an immutable snapshot, and no-ops on same value', () => {
const { svc, events } = make()
const persist = vi.fn()
svc.bindPersistence(persist)
it('setLocale writes through the scope, republishes an immutable snapshot, and no-ops on same value', () => {
const host = stubSettingsScope<LocaleSettings>()
const { svc, events } = make(host)
svc.setLocale('en')
expect(svc.getLocale().active).toBe('en')
expect(persist).toHaveBeenCalledWith('en')
expect(host.set).toHaveBeenCalledWith('preference', 'en')
expect(events).toHaveLength(1)
expect(events[0]).toBe(svc.getLocale())
expect(events[0]!.revision).toBe(1)
svc.setLocale('en')
expect(events).toHaveLength(1)
expect(persist).toHaveBeenCalledOnce()
expect(host.set).toHaveBeenCalledOnce()
})
it('setLocale without a host scope stays process-local', () => {
const { svc, events } = make()
svc.setLocale('en')
expect(svc.getLocale().active).toBe('en')
expect(events).toHaveLength(1)
})
it('throws on unknown locale ids', () => {
@@ -151,18 +162,36 @@ describe('LocaleService', () => {
expect(() => { svc.setLocale('fr') }).toThrow('not registered')
})
it('syncs a Host preference over the browser language without writing it back', () => {
const { svc, events } = make()
const persist = vi.fn()
svc.bindPersistence(persist)
svc.syncPreference('en')
it('adopts a Host preference over the browser language without writing it back', () => {
const host = stubSettingsScope<LocaleSettings>()
const { svc, events } = make(host)
host.publish({ status: 'ready', value: { preference: 'en' }, revision: 1, writable: true })
expect(svc.getLocale().active).toBe('en')
expect(events).toHaveLength(1)
expect(persist).not.toHaveBeenCalled()
svc.syncPreference('en')
expect(host.set).not.toHaveBeenCalled()
host.publish({ value: { preference: 'en' }, revision: 2 })
expect(events).toHaveLength(1)
})
it('an absent Host preference returns to the browser-derived locale', () => {
const host = stubSettingsScope<LocaleSettings>()
const { svc } = make(host)
host.publish({ status: 'ready', value: { preference: 'en' }, revision: 1, writable: true })
expect(svc.getLocale().active).toBe('en')
host.publish({ value: {}, revision: 2 })
expect(svc.getLocale().active).toBe('zh')
})
it('adopts a section already standing at construction and releases its subscription on dispose', async () => {
const host = stubSettingsScope<LocaleSettings>()
host.publish({ status: 'ready', value: { preference: 'en' }, revision: 1, writable: true })
const { ctx, svc } = make(host)
expect(svc.getLocale().active).toBe('en')
expect(host.listenerCount()).toBe(1)
await ctx.fiber.dispose()
expect(host.listenerCount()).toBe(0)
})
it('opens provisionally in the browser language, matching regional variants on their primary subtag', () => {
stubLanguages('en-GB', 'zh-CN')
expect(make().svc.getLocale().active).toBe('en')