From a04ce7afbb6dde19d5e2973794f183667f27c1c4 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 04:15:56 +0800 Subject: [PATCH] refactor(client): shared declaration-aware registration deferral MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The five settings-surface registrants carried near-identical spec-check/ledger-judge/subscribe scaffolding (three jscpd clones); ui-slots now owns deferRegistration() — ledger-judged presence, refresh for registrant-localized labels, one-call disposal — and every registrant shrinks to its registration body. --- packages/client/locale/src/client/index.ts | 23 ++----- packages/client/ui-models/src/client/index.ts | 32 +++------ .../client/ui-settings/src/client/index.ts | 46 ++++--------- packages/client/ui-slots/src/deferred.ts | 66 +++++++++++++++++++ packages/client/ui-slots/src/index.ts | 1 + packages/client/ui-theme/src/client/index.ts | 23 ++----- 6 files changed, 97 insertions(+), 94 deletions(-) create mode 100644 packages/client/ui-slots/src/deferred.ts diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index 444594f9b8..ea6f9d8da9 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -5,7 +5,7 @@ * its own settings surface. */ import type { Context } from 'cordis' -import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' +import { deferRegistration, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import { en } from '../locales/en.ts' import { zh } from '../locales/zh.ts' @@ -233,28 +233,15 @@ export function apply(ctx: ClientContext): void { setLocale: (id) => { locale.setLocale(id) }, } } - // Declaration-aware registration; the LEDGER is the has-registered judge - // (not a local flag): after an HMR collapse re-declares the slot, the - // cascade already removed our entry, and a stale disposer must not block - // the re-registration. ctx.effect(() => { - let dispose: (() => void) | undefined - const tryRegister = (): void => { - if (ctx.slots.spec('settings.general.item') === undefined) return - if (ctx.slots.entries('settings.general.item').some(e => e.component === LanguageRow)) return - dispose = ctx.slots.register({ + const deferred = deferRegistration(ctx.slots, 'settings.general.item', LanguageRow, () => + ctx.slots.register({ name: 'settings.general.item', id: 'language', order: 0, store, inject: injected, - }, LanguageRow) - } - const unsubscribe = ctx.slots.subscribe('settings.general.item', () => { tryRegister() }) - tryRegister() - return () => { - unsubscribe() - dispose?.() - } + }, LanguageRow)) + return () => { deferred.dispose() } }, 'locale: language settings row registration') } diff --git a/packages/client/ui-models/src/client/index.ts b/packages/client/ui-models/src/client/index.ts index 4f080ec928..5abcb65bcf 100644 --- a/packages/client/ui-models/src/client/index.ts +++ b/packages/client/ui-models/src/client/index.ts @@ -5,6 +5,7 @@ * discipline: packages/client/AGENTS.md. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: pulls the shell's SlotMap merge (the 'settings.section' entry). import type {} from '@deepseek-ai/dsh-client-ui-settings/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). @@ -31,37 +32,20 @@ export function apply(ctx: ClientContext): void { ] return () => { for (const dispose of disposers) dispose() } }, 'ui-models: nav copy dictionaries') - // Declaration-aware registration; the LEDGER is the has-registered judge - // (not a local flag): after an HMR collapse re-declares the slot, the - // cascade already removed our entry, and a stale disposer must not block - // the re-registration. ctx.effect(() => { - let dispose: (() => void) | undefined - const tryRegister = (): void => { - if (ctx.slots.spec('settings.section') === undefined) return - if (ctx.slots.entries('settings.section').some(e => e.component === ModelsSection)) return - dispose = ctx.slots.register({ + const deferred = deferRegistration(ctx.slots, 'settings.section', ModelsSection, () => + ctx.slots.register({ name: 'settings.section', id: 'models', order: 10, label: ctx.locale.bind('settings.models')('nav'), - }, ModelsSection) - } - // Nav labels are registrant-localized: re-register on locale change so - // the ledger carries fresh text (the version bump re-renders the shell). - // Dispose-then-requery: after an HMR collapse the disposer is stale and - // the ledger/spec re-check keeps this path an idempotent no-op. - const offLocale = ctx.on('locale/change', () => { - dispose?.() - dispose = undefined - tryRegister() - }) - const unsubscribe = ctx.slots.subscribe('settings.section', () => { tryRegister() }) - tryRegister() + }, ModelsSection)) + // Nav labels are registrant-localized: refresh on locale change so the + // ledger carries fresh text (the version bump re-renders the shell). + const offLocale = ctx.on('locale/change', () => { deferred.refresh() }) return () => { offLocale() - unsubscribe() - dispose?.() + deferred.dispose() } }, 'ui-models: settings section registration') } diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts index c360a84201..9d379c301e 100644 --- a/packages/client/ui-settings/src/client/index.ts +++ b/packages/client/ui-settings/src/client/index.ts @@ -7,6 +7,7 @@ * preference rows into. Export discipline: packages/client/AGENTS.md. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: pulls the locale plugin's Context/Events merges (ctx.locale, // 'locale/change') into this program. import type {} from '@deepseek-ai/dsh-client-locale/client' @@ -60,60 +61,37 @@ export function apply(ctx: ClientContext): void { })) .sort((a, b) => a.order - b.order), }) - // Declaration-aware registration; the LEDGER is the has-registered judge - // (not a local flag): after an HMR collapse re-declares the slot, the - // cascade already removed our entry, and a stale disposer must not block - // the re-registration. ctx.effect(() => { - let dispose: (() => void) | undefined - const tryRegister = (): void => { - if (ctx.slots.spec('sidebar.settings') === undefined) return - if (ctx.slots.entries('sidebar.settings').some(e => e.component === SettingsRoot)) return - dispose = ctx.slots.register({ + const deferred = deferRegistration(ctx.slots, 'sidebar.settings', SettingsRoot, () => + ctx.slots.register({ name: 'sidebar.settings', children: { 'settings.section': { kind: 'list', scope: 'root' } }, inject: injected, - }, SettingsRoot) - } - const unsubscribe = ctx.slots.subscribe('sidebar.settings', () => { tryRegister() }) - tryRegister() - return () => { - unsubscribe() - dispose?.() - } + }, SettingsRoot)) + return () => { deferred.dispose() } }, 'ui-settings: shell registration') // The shell's own General section: first page, declares the item slot the // feature plugins (locale, ui-theme, …) contribute preference rows into. - // Same ledger-judged deferral; label re-registers on locale change. const generalInjected = (): GeneralSectionInjected => ({ t: ctx.locale.bind('settings'), }) ctx.effect(() => { - let dispose: (() => void) | undefined - const tryRegister = (): void => { - if (ctx.slots.spec('settings.section') === undefined) return - if (ctx.slots.entries('settings.section').some(e => e.component === GeneralSection)) return - dispose = ctx.slots.register({ + const deferred = deferRegistration(ctx.slots, 'settings.section', GeneralSection, () => + ctx.slots.register({ name: 'settings.section', id: 'general', order: 0, label: ctx.locale.bind('settings')('general.nav'), children: { 'settings.general.item': { kind: 'list', scope: 'root' } }, inject: generalInjected, - }, GeneralSection) - } - const offLocale = ctx.on('locale/change', () => { - dispose?.() - dispose = undefined - tryRegister() - }) - const unsubscribe = ctx.slots.subscribe('settings.section', () => { tryRegister() }) - tryRegister() + }, GeneralSection)) + // Nav labels are registrant-localized: refresh on locale change so the + // ledger carries fresh text (the version bump re-renders the shell). + const offLocale = ctx.on('locale/change', () => { deferred.refresh() }) return () => { offLocale() - unsubscribe() - dispose?.() + deferred.dispose() } }, 'ui-settings: general section registration') } diff --git a/packages/client/ui-slots/src/deferred.ts b/packages/client/ui-slots/src/deferred.ts new file mode 100644 index 0000000000..af85c27f97 --- /dev/null +++ b/packages/client/ui-slots/src/deferred.ts @@ -0,0 +1,66 @@ +/** + * Declaration-aware registration deferral: the shared timing machinery for + * registering into a slot whose declaring entry activates in unconstrained + * order (dshClient.inject edges never sequence apply). Presence is judged on + * the LEDGER, not a local flag — after an HMR collapse re-declares the slot, + * the cascade has already removed the entry while the local disposer went + * stale, and a flag guard would block the re-registration. + */ + +/** Minimal registry face the deferral reads (SlotsService satisfies it). */ +export interface DeferralRegistry { + /** Declared spec lookup (undefined = not declared yet). */ + spec(name: string): unknown + /** Current entries of the slot (component identity is the presence judge). */ + entries(name: string): readonly { component: unknown }[] + /** Subscribe to the slot's ledger changes; returns the unsubscriber. */ + subscribe(name: string, listener: () => void): () => void +} + +/** Handle over one deferred registration. */ +export interface DeferredRegistration { + /** + * Drop the current registration (stale disposers are harmless no-ops) and + * immediately re-attempt — the refresh path for registrants whose options + * carry localized text. + */ + refresh(): void + /** Unsubscribe and unregister (idempotent through the slot core). */ + dispose(): void +} + +/** + * Register into `name` as soon as its declaration is on the ledger, and + * re-register whenever the declaration reappears after a collapse. + * @param registry - the slot registry face. + * @param name - target slot name. + * @param component - the component whose ledger presence marks "registered". + * @param register - performs the actual registration; returns its disposer. + * @returns the deferral handle (dispose in the owning effect's disposer). + */ +export function deferRegistration( + registry: DeferralRegistry, + name: string, + component: unknown, + register: () => () => void, +): DeferredRegistration { + let dispose: (() => void) | undefined + const tryRegister = (): void => { + if (registry.spec(name) === undefined) return + if (registry.entries(name).some(e => e.component === component)) return + dispose = register() + } + const unsubscribe = registry.subscribe(name, () => { tryRegister() }) + tryRegister() + return { + refresh() { + dispose?.() + dispose = undefined + tryRegister() + }, + dispose() { + unsubscribe() + dispose?.() + }, + } +} diff --git a/packages/client/ui-slots/src/index.ts b/packages/client/ui-slots/src/index.ts index 677bc4e0df..e3bc57797d 100644 --- a/packages/client/ui-slots/src/index.ts +++ b/packages/client/ui-slots/src/index.ts @@ -18,6 +18,7 @@ import type { BoundActions, HandleOf, PropsStore, SnapshotSelectorHook, StoreDec export * from './store.ts' export * from './renderer.ts' +export * from './deferred.ts' /** Slot contract table. Owners extend via declaration merging; entries are {@link SlotEntryDef}. */ export interface SlotMap {} diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index ac79913062..dd11c98f06 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -7,7 +7,7 @@ * section — the theme feature owns its own settings surface. */ import type { Context } from 'cordis' -import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' +import { deferRegistration, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' @@ -262,28 +262,15 @@ export function apply(ctx: ClientContext): void { setTheme: (id) => { theme.setTheme(id) }, } } - // Declaration-aware registration; the LEDGER is the has-registered judge - // (not a local flag): after an HMR collapse re-declares the slot, the - // cascade already removed our entry, and a stale disposer must not block - // the re-registration. ctx.effect(() => { - let dispose: (() => void) | undefined - const tryRegister = (): void => { - if (ctx.slots.spec('settings.general.item') === undefined) return - if (ctx.slots.entries('settings.general.item').some(e => e.component === AppearanceRow)) return - dispose = ctx.slots.register({ + const deferred = deferRegistration(ctx.slots, 'settings.general.item', AppearanceRow, () => + ctx.slots.register({ name: 'settings.general.item', id: 'appearance', order: 10, store, inject: injected, - }, AppearanceRow) - } - const unsubscribe = ctx.slots.subscribe('settings.general.item', () => { tryRegister() }) - tryRegister() - return () => { - unsubscribe() - dispose?.() - } + }, AppearanceRow)) + return () => { deferred.dispose() } }, 'ui-theme: appearance settings row registration') }