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:
@@ -13,9 +13,11 @@ import type { Context } from 'cordis'
|
||||
import {
|
||||
type BoundActions, type LocaleDictOf, type LocaleNamespaceMap, type Translate, type TranslateNS,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { bindSettingsPreference, type ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
isLocaleId, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId,
|
||||
bindSettingsScope, type ClientContext, type SettingsScope,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId, type LocaleSettings,
|
||||
} from '../locale-settings.ts'
|
||||
import { en, zh, type CommonKey } from '../locales/index.ts'
|
||||
import {
|
||||
@@ -29,7 +31,7 @@ export type { LanguageRowComponentProps, LanguageRowInjected } from './LanguageR
|
||||
export type { LanguageOptionRow, LanguageRowState } from './settings-store.ts'
|
||||
export type { SettingsGeneralItemOwnerProps } from './settings-contract.ts'
|
||||
export type { CommonKey } from '../locales/index.ts'
|
||||
export type { LocaleId } from '../locale-settings.ts'
|
||||
export type { LocaleId, LocaleSettings } from '../locale-settings.ts'
|
||||
|
||||
// The translate currency lives in ui-slots (the render machinery synthesizes
|
||||
// the seat); re-exported here so dictionary owners import one package.
|
||||
@@ -114,24 +116,25 @@ export class LocaleService {
|
||||
private snapshot: LocaleSnapshot
|
||||
private listeners = new Set<() => void>()
|
||||
private readonly ctx: Context
|
||||
private persist: (id: LocaleId) => void
|
||||
private readonly host: SettingsScope<LocaleSettings> | undefined
|
||||
/** Browser-derived locale standing wherever no explicit Host selection does. */
|
||||
private readonly provisional: LocaleId
|
||||
|
||||
/**
|
||||
* @param ctx - owning context (change events are emitted on it).
|
||||
* @param persist - durable write callback for explicit locale selections.
|
||||
* @param ctx - owning context (change events are emitted on it; the scope
|
||||
* listener is released through ctx.effect on dispose).
|
||||
* @param host - durable preference scope owned by the providing plugin;
|
||||
* absent compositions (standalone dictionary registries) stay process-local.
|
||||
*/
|
||||
constructor(ctx: Context, persist: (id: LocaleId) => void = () => {}) {
|
||||
constructor(ctx: Context, host?: SettingsScope<LocaleSettings>) {
|
||||
this.ctx = ctx
|
||||
this.persist = persist
|
||||
this.snapshot = Object.freeze({ active: resolveInitialLocale(), locales: LOCALES, revision: 0 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind the owning plugin's durable writer before the service is provided.
|
||||
* @param persist - callback accepting explicit locale changes.
|
||||
*/
|
||||
bindPersistence(persist: (id: LocaleId) => void): void {
|
||||
this.persist = persist
|
||||
this.host = host
|
||||
this.provisional = resolveInitialLocale()
|
||||
this.snapshot = Object.freeze({ active: this.provisional, locales: LOCALES, revision: 0 })
|
||||
if (host !== undefined) {
|
||||
ctx.effect(() => host.subscribe(() => { this.adopt(host) }), 'locale: settings scope adoption')
|
||||
this.adopt(host)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,16 +175,20 @@ export class LocaleService {
|
||||
if (match === undefined) throw new Error(`locale "${id}" is not registered`)
|
||||
if (this.snapshot.active === match.id) return
|
||||
this.publish(match.id, true)
|
||||
this.persist(match.id)
|
||||
void this.host?.set(LOCALE_PREFERENCE_FIELD, match.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an explicit Host preference without writing it back.
|
||||
* @param id - validated shipped locale.
|
||||
* Adopt the scope's accepted durable selection without writing it back; an
|
||||
* absent selection returns to the browser-derived locale.
|
||||
* @param host - the constructor-narrowed scope driving this adoption.
|
||||
*/
|
||||
syncPreference(id: LocaleId): void {
|
||||
if (this.snapshot.active === id) return
|
||||
this.publish(id, true)
|
||||
private adopt(host: SettingsScope<LocaleSettings>): void {
|
||||
const section = host.getSnapshot().value
|
||||
if (section === undefined) return
|
||||
const target = section.preference ?? this.provisional
|
||||
if (this.snapshot.active === target) return
|
||||
this.publish(target, true)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -345,17 +352,10 @@ export const inject = ['slots', 'connection']
|
||||
* @param ctx - client cordis context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const locale = new LocaleService(ctx)
|
||||
const browserLocale = locale.getLocale().active
|
||||
const host = bindSettingsScope<LocaleSettings>(ctx, { namespace: LOCALE_SETTINGS_NAMESPACE })
|
||||
const locale = new LocaleService(ctx, host)
|
||||
locale.register(COMMON_NS, { zh, en })
|
||||
locale.register(SETTINGS_NS, { zh: settingsZh, en: settingsEn })
|
||||
const controller = bindSettingsPreference(ctx, {
|
||||
namespace: LOCALE_SETTINGS_NAMESPACE,
|
||||
field: LOCALE_PREFERENCE_FIELD,
|
||||
decode: value => isLocaleId(value) ? value : browserLocale,
|
||||
sync: (id) => { locale.syncPreference(id) },
|
||||
})
|
||||
locale.bindPersistence((id) => { void controller.persist(id) })
|
||||
ctx.provide('locale', locale)
|
||||
// The service IS the LocaleFace (bind + getSnapshot/subscribe): install it
|
||||
// so the render machinery can synthesize the `t` standard seat.
|
||||
|
||||
@@ -4,18 +4,16 @@ import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import {
|
||||
LOCALE_IDS, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId,
|
||||
LOCALE_IDS, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleSettings,
|
||||
} from './locale-settings.ts'
|
||||
|
||||
export {
|
||||
LOCALE_IDS, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId,
|
||||
LOCALE_IDS, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE,
|
||||
type LocaleId, type LocaleSettings,
|
||||
} from './locale-settings.ts'
|
||||
|
||||
interface LocaleSettings {
|
||||
preference?: LocaleId
|
||||
}
|
||||
|
||||
const LocaleSettingsSchema: z<LocaleSettings> = z.object({
|
||||
/** Durable locale schema; also the wire envelope the browser scope validates against. */
|
||||
export const LocaleSettingsSchema: z<LocaleSettings> = z.object({
|
||||
[LOCALE_PREFERENCE_FIELD]: z.union([...LOCALE_IDS]).required(false),
|
||||
})
|
||||
|
||||
|
||||
@@ -12,11 +12,8 @@ export const LOCALE_IDS = ['zh', 'en'] as const
|
||||
/** Shipped locale identifier. */
|
||||
export type LocaleId = typeof LOCALE_IDS[number]
|
||||
|
||||
/**
|
||||
* Narrow one settings-wire value to a shipped locale.
|
||||
* @param value - value crossing the settings boundary.
|
||||
* @returns whether the value names a shipped locale.
|
||||
*/
|
||||
export function isLocaleId(value: unknown): value is LocaleId {
|
||||
return LOCALE_IDS.some(locale => locale === value)
|
||||
/** Durable locale section shared by the Host schema and the browser scope. */
|
||||
export interface LocaleSettings {
|
||||
/** Explicit locale selection; absence delegates to the browser. */
|
||||
preference?: LocaleId
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user