feat(client): typed locale standard seat in the slot framework

Registrations declare a dictionary namespace (locale: NS) and the renderer
synthesizes a typed t prop for the entry's component from the installed
LocaleFace; the seat binding is re-derived per locale revision, so a language
switch hands out fresh t references and memoized consumers re-render through
ordinary shallow comparison. LocaleNamespaceMap is the declare-merge table
(namespace -> dictionary key union); TranslateNS<'ns'> is the
namespace-addressed translate type (namespace keys plus the shared common
vocabulary), carried by the t seat and by the locale service's typed bind.

LocaleService implements the face (lookup ns -> common -> zh -> key,
revision-carrying snapshots with subscriber isolation) and installs it
through the boot-once slots.installLocale seam, mirroring the renderer
install. The typed register(ns, {zh, en}) overload checks each dictionary
against the namespace's key union and requires every shipped locale, so a
missing or extra key and an unbalanced translation are compile errors.
Dictionary registration bumps the face revision without emitting
locale/change — the event now means exactly 'the active locale switched',
so registration-heavy boot cannot storm event listeners.
This commit is contained in:
imccyu
2026-07-30 01:04:56 +08:00
parent 1f242753ec
commit c317fbc489
44 changed files with 925 additions and 189 deletions

View File

@@ -69,16 +69,18 @@ describe('locale apply', () => {
// An event ahead of any inject hits the unbound-actions arm.
locale.setLocale('en')
const { instance, face } = faceOf(b.slots)
const { entry, instance, face } = faceOf(b.slots)
// The inject-time re-sync sealed the init window: the mirror is current.
expect(instance.getSnapshot().active).toBe('en')
expect(instance.getSnapshot().options.map(o => o.id)).toEqual(['zh', 'en'])
expect(face.t('language.title')).toBe('Language')
// Copy rides the standard locale seat: the entry declares the namespace.
expect(entry.locale).toBe(SETTINGS_NS)
expect(locale.bind(SETTINGS_NS)('language.title')).toBe('Language')
face.setLocale('zh')
expect(locale.getLocale().active).toBe('zh')
expect(instance.getSnapshot().active).toBe('zh')
expect(face.t('language.title')).toBe('语言')
expect(locale.bind(SETTINGS_NS)('language.title')).toBe('语言')
})
it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => {

View File

@@ -29,6 +29,24 @@ describe('LocaleService', () => {
expect(t('missing.key')).toBe('missing.key')
})
it('falls through to the common vocabulary after the namespace misses (production keys)', () => {
const { svc } = make()
// The shipped common pair is registered by apply; the bench registers it
// directly to pin the production chain: ns -> common -> zh -> key.
svc.register('common', 'zh', { retry: '重试' })
svc.register('common', 'en', { retry: 'Retry' })
svc.register('ns', 'zh', { own: '自有' })
const t = svc.bind('ns')
expect(t('retry')).toBe('重试')
svc.setLocale('en')
expect(t('retry')).toBe('Retry')
expect(t('own')).toBe('自有')
// common itself must not recurse: a miss inside common echoes the key.
// (Wide-string ns hits the untyped bind overload — the typed one rejects
// unknown keys at compile time, which is the point of the seam.)
expect(svc.bind('common' as string)('nope')).toBe('nope')
})
it('interpolates {name} params and leaves unknown placeholders intact', () => {
const { svc } = make()
svc.register('ns', 'zh', { greet: '你好,{name}!第 {n} 次', partial: '{known} 与 {unknown}' })
@@ -56,6 +74,47 @@ describe('LocaleService', () => {
expect(t('k')).toBe('v2')
})
it('serves the LocaleFace: snapshot revision moves on switch and registration, subscribers fire, unsubscribe stops them', () => {
const { svc } = make()
const seen: number[] = []
const off = svc.subscribe(() => { seen.push(svc.getSnapshot().revision) })
expect(svc.getSnapshot()).toBe(svc.getLocale())
const r0 = svc.getSnapshot().revision
svc.register('ns', 'zh', { k: 'v' })
expect(svc.getSnapshot().revision).toBe(r0 + 1)
svc.setLocale('en')
expect(seen).toEqual([r0 + 1, r0 + 2])
off()
svc.setLocale('zh')
expect(seen).toHaveLength(2)
})
it('isolates a throwing subscriber: the rest still see the new revision', () => {
const { svc } = make()
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const seen: number[] = []
svc.subscribe(() => { throw new Error('boom') })
svc.subscribe(() => { seen.push(svc.getSnapshot().revision) })
svc.setLocale('en')
expect(seen).toEqual([1])
expect(spy).toHaveBeenCalledOnce()
} finally {
spy.mockRestore()
}
})
it('register disposer republishes (mounted outlets drop the dead dictionary)', () => {
const { svc } = make()
const dispose = svc.register('ns', 'zh', { k: 'v' })
const before = svc.getSnapshot().revision
dispose()
expect(svc.getSnapshot().revision).toBe(before + 1)
// Second run hits the idempotent arm: nothing removed, no republish.
dispose()
expect(svc.getSnapshot().revision).toBe(before + 1)
})
it('setLocale persists, republishes an immutable snapshot, and no-ops on same value', () => {
const { svc, events } = make()
svc.setLocale('en')