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

@@ -5,8 +5,9 @@
import { Component, useSyncExternalStore, type FC, type ReactNode } from 'react'
import {
SlotOwnershipError, StaleAuthorizationError,
type ChainRenderOpts, type HostObservable, type RenderOpts, type SessionMaybeProvideInfo,
type SessionProvideInfo, type SlotRenderer, type SlotRendererHost, type SlotScope, type StoredEntry,
type ChainRenderOpts, type HostObservable, type LocaleFace, type RenderOpts,
type SessionMaybeProvideInfo, type SessionProvideInfo, type SlotRenderer, type SlotRendererHost,
type SlotScope, type StoredEntry, type Translate,
} from '@deepseek-ai/dsh-client-ui-slots'
import {
HostContext, SessionMaybeProvider, SessionProvider, SlotAssemblyError, maybeObservableHook,
@@ -159,6 +160,74 @@ function cachedSessionMaybeInject(
return props
}
/**
* Locale `t` seat bindings, cached per (face, namespace, revision). The
* revision is part of the cache key ON PURPOSE: a locale switch mints a NEW
* function reference per namespace, so `React.memo` components taking `t`
* re-render through ordinary shallow comparison — freshness rides identity,
* no extra invalidation channel. Within one revision the reference is stable
* (memoized children do not churn on unrelated re-renders).
*/
const localeSeatCache = new WeakMap<LocaleFace, Map<string, { revision: number; t: Translate }>>()
function localeSeat(face: LocaleFace, ns: string): Translate {
let perNs = localeSeatCache.get(face)
if (!perNs) {
perNs = new Map()
localeSeatCache.set(face, perNs)
}
const revision = face.getSnapshot().revision
const cached = perNs.get(ns)
if (cached && cached.revision === revision) return cached.t
const bound = face.bind(ns)
// Fresh wrapper per revision: bind() itself may return a stable reference.
const t: Translate = (key, params) => bound(key, params)
perNs.set(ns, { revision, t })
return t
}
const noopSubscribe = (): (() => void) => () => {}
const zeroRevision = (): number => 0
/**
* Per-face subscribe/getSnapshot closure pair. Cached by face identity: the
* face is one global source shared by every outlet, and uSES resubscribes
* whenever the subscribe reference changes — fresh closures per render would
* churn one unsubscribe/resubscribe pair per outlet per render.
*/
const localeSubscriptionCache = new WeakMap<LocaleFace, {
subscribe: (fn: () => void) => () => void
getRevision: () => number
}>()
function localeSubscription(face: LocaleFace): { subscribe: (fn: () => void) => () => void; getRevision: () => number } {
let cached = localeSubscriptionCache.get(face)
if (!cached) {
cached = {
subscribe: fn => face.subscribe(fn),
getRevision: () => face.getSnapshot().revision,
}
localeSubscriptionCache.set(face, cached)
}
return cached
}
/**
* Subscribe an outlet to the installed locale face's revision (0 while none
* is installed — exactly one uSES call either way, keeping hook order
* stable). Every outlet re-renders on a locale switch; entry bodies then
* re-derive their `t` seat at the new revision. The face must be installed
* before the first render that needs it — a face appearing later has no
* notification channel to already-mounted outlets.
*/
function useLocaleRevision(face: LocaleFace | undefined): number {
const subscription = face !== undefined ? localeSubscription(face) : undefined
return useSyncExternalStore(
subscription?.subscribe ?? noopSubscribe,
subscription?.getRevision ?? zeroRevision,
)
}
/**
* Entry-identity React keys for chain boundaries. A chain outlet renders ONE
* elected entry through an error boundary; without a key, a boundary that
@@ -242,6 +311,16 @@ function standardKit(
// reader, bound per provide bundle (cached by info identity).
kit['useProjection'] = projectionHook(info)
}
if (entry.locale !== undefined) {
const face = host.locale
// Loud assembly failure: locale is immediately-tier infrastructure; a
// declared namespace with no installed face is a miswired composition.
if (face === undefined) {
throw new SlotAssemblyError(
`entry declares locale namespace '${entry.locale}' but no locale face is installed (locale plugin missing from the composition?)`)
}
kit['t'] = localeSeat(face, entry.locale)
}
const store = scope === 'session-maybe' && info?.sessionId === undefined
? undefined
: host.storeOf(entry, info?.sessionId)
@@ -329,6 +408,9 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
fn => host.subscribe(slotKey, fn),
() => host.getVersion(slotKey),
)
// Locale revision tick: a locale switch re-renders every outlet, and entry
// bodies re-derive their `t` seat at the new revision (fresh identity).
useLocaleRevision(host.locale)
const sessionInfo = useSessionMaybeProvideInfo()
const spec = host.specOf(slotKey)
// Undeclared (or no-longer-declared) keys render empty: a declaring entry's
@@ -435,6 +517,7 @@ function RootOutlet({ ownerProps }: { ownerProps: object }) {
fn => host.subscribe('root', fn),
() => host.getVersion('root'),
)
useLocaleRevision(host.locale)
const entry = host.entriesOf('root')[0]
if (!entry) throw new SlotAssemblyError("renderSlot('root') before any 'root' registration (boot order)")
return (