Răsfoiți Sursa

refactor(client): shared declaration-aware registration deferral

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.
imccyu 2 luni în urmă
părinte
comite
a04ce7afbb

+ 5 - 18
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')
 }

+ 8 - 24
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')
 }

+ 12 - 34
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')
 }

+ 66 - 0
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?.()
+    },
+  }
+}

+ 1 - 0
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 {}

+ 5 - 18
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')
 }