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.
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
* its own settings surface.
|
* its own settings surface.
|
||||||
*/
|
*/
|
||||||
import type { Context } from 'cordis'
|
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 type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
import { en } from '../locales/en.ts'
|
import { en } from '../locales/en.ts'
|
||||||
import { zh } from '../locales/zh.ts'
|
import { zh } from '../locales/zh.ts'
|
||||||
@@ -233,28 +233,15 @@ export function apply(ctx: ClientContext): void {
|
|||||||
setLocale: (id) => { locale.setLocale(id) },
|
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(() => {
|
ctx.effect(() => {
|
||||||
let dispose: (() => void) | undefined
|
const deferred = deferRegistration(ctx.slots, 'settings.general.item', LanguageRow, () =>
|
||||||
const tryRegister = (): void => {
|
ctx.slots.register({
|
||||||
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({
|
|
||||||
name: 'settings.general.item',
|
name: 'settings.general.item',
|
||||||
id: 'language',
|
id: 'language',
|
||||||
order: 0,
|
order: 0,
|
||||||
store,
|
store,
|
||||||
inject: injected,
|
inject: injected,
|
||||||
}, LanguageRow)
|
}, LanguageRow))
|
||||||
}
|
return () => { deferred.dispose() }
|
||||||
const unsubscribe = ctx.slots.subscribe('settings.general.item', () => { tryRegister() })
|
|
||||||
tryRegister()
|
|
||||||
return () => {
|
|
||||||
unsubscribe()
|
|
||||||
dispose?.()
|
|
||||||
}
|
|
||||||
}, 'locale: language settings row registration')
|
}, 'locale: language settings row registration')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
* discipline: packages/client/AGENTS.md.
|
* discipline: packages/client/AGENTS.md.
|
||||||
*/
|
*/
|
||||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
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).
|
// Type-only: pulls the shell's SlotMap merge (the 'settings.section' entry).
|
||||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
// 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() }
|
return () => { for (const dispose of disposers) dispose() }
|
||||||
}, 'ui-models: nav copy dictionaries')
|
}, '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(() => {
|
ctx.effect(() => {
|
||||||
let dispose: (() => void) | undefined
|
const deferred = deferRegistration(ctx.slots, 'settings.section', ModelsSection, () =>
|
||||||
const tryRegister = (): void => {
|
ctx.slots.register({
|
||||||
if (ctx.slots.spec('settings.section') === undefined) return
|
|
||||||
if (ctx.slots.entries('settings.section').some(e => e.component === ModelsSection)) return
|
|
||||||
dispose = ctx.slots.register({
|
|
||||||
name: 'settings.section',
|
name: 'settings.section',
|
||||||
id: 'models',
|
id: 'models',
|
||||||
order: 10,
|
order: 10,
|
||||||
label: ctx.locale.bind('settings.models')('nav'),
|
label: ctx.locale.bind('settings.models')('nav'),
|
||||||
}, ModelsSection)
|
}, ModelsSection))
|
||||||
}
|
// Nav labels are registrant-localized: refresh on locale change so the
|
||||||
// Nav labels are registrant-localized: re-register on locale change so
|
// ledger carries fresh text (the version bump re-renders the shell).
|
||||||
// the ledger carries fresh text (the version bump re-renders the shell).
|
const offLocale = ctx.on('locale/change', () => { deferred.refresh() })
|
||||||
// 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()
|
|
||||||
return () => {
|
return () => {
|
||||||
offLocale()
|
offLocale()
|
||||||
unsubscribe()
|
deferred.dispose()
|
||||||
dispose?.()
|
|
||||||
}
|
}
|
||||||
}, 'ui-models: settings section registration')
|
}, 'ui-models: settings section registration')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
* preference rows into. Export discipline: packages/client/AGENTS.md.
|
* preference rows into. Export discipline: packages/client/AGENTS.md.
|
||||||
*/
|
*/
|
||||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
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,
|
// Type-only: pulls the locale plugin's Context/Events merges (ctx.locale,
|
||||||
// 'locale/change') into this program.
|
// 'locale/change') into this program.
|
||||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
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),
|
.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(() => {
|
ctx.effect(() => {
|
||||||
let dispose: (() => void) | undefined
|
const deferred = deferRegistration(ctx.slots, 'sidebar.settings', SettingsRoot, () =>
|
||||||
const tryRegister = (): void => {
|
ctx.slots.register({
|
||||||
if (ctx.slots.spec('sidebar.settings') === undefined) return
|
|
||||||
if (ctx.slots.entries('sidebar.settings').some(e => e.component === SettingsRoot)) return
|
|
||||||
dispose = ctx.slots.register({
|
|
||||||
name: 'sidebar.settings',
|
name: 'sidebar.settings',
|
||||||
children: { 'settings.section': { kind: 'list', scope: 'root' } },
|
children: { 'settings.section': { kind: 'list', scope: 'root' } },
|
||||||
inject: injected,
|
inject: injected,
|
||||||
}, SettingsRoot)
|
}, SettingsRoot))
|
||||||
}
|
return () => { deferred.dispose() }
|
||||||
const unsubscribe = ctx.slots.subscribe('sidebar.settings', () => { tryRegister() })
|
|
||||||
tryRegister()
|
|
||||||
return () => {
|
|
||||||
unsubscribe()
|
|
||||||
dispose?.()
|
|
||||||
}
|
|
||||||
}, 'ui-settings: shell registration')
|
}, 'ui-settings: shell registration')
|
||||||
|
|
||||||
// The shell's own General section: first page, declares the item slot the
|
// The shell's own General section: first page, declares the item slot the
|
||||||
// feature plugins (locale, ui-theme, …) contribute preference rows into.
|
// feature plugins (locale, ui-theme, …) contribute preference rows into.
|
||||||
// Same ledger-judged deferral; label re-registers on locale change.
|
|
||||||
const generalInjected = (): GeneralSectionInjected => ({
|
const generalInjected = (): GeneralSectionInjected => ({
|
||||||
t: ctx.locale.bind('settings'),
|
t: ctx.locale.bind('settings'),
|
||||||
})
|
})
|
||||||
ctx.effect(() => {
|
ctx.effect(() => {
|
||||||
let dispose: (() => void) | undefined
|
const deferred = deferRegistration(ctx.slots, 'settings.section', GeneralSection, () =>
|
||||||
const tryRegister = (): void => {
|
ctx.slots.register({
|
||||||
if (ctx.slots.spec('settings.section') === undefined) return
|
|
||||||
if (ctx.slots.entries('settings.section').some(e => e.component === GeneralSection)) return
|
|
||||||
dispose = ctx.slots.register({
|
|
||||||
name: 'settings.section',
|
name: 'settings.section',
|
||||||
id: 'general',
|
id: 'general',
|
||||||
order: 0,
|
order: 0,
|
||||||
label: ctx.locale.bind('settings')('general.nav'),
|
label: ctx.locale.bind('settings')('general.nav'),
|
||||||
children: { 'settings.general.item': { kind: 'list', scope: 'root' } },
|
children: { 'settings.general.item': { kind: 'list', scope: 'root' } },
|
||||||
inject: generalInjected,
|
inject: generalInjected,
|
||||||
}, GeneralSection)
|
}, GeneralSection))
|
||||||
}
|
// Nav labels are registrant-localized: refresh on locale change so the
|
||||||
const offLocale = ctx.on('locale/change', () => {
|
// ledger carries fresh text (the version bump re-renders the shell).
|
||||||
dispose?.()
|
const offLocale = ctx.on('locale/change', () => { deferred.refresh() })
|
||||||
dispose = undefined
|
|
||||||
tryRegister()
|
|
||||||
})
|
|
||||||
const unsubscribe = ctx.slots.subscribe('settings.section', () => { tryRegister() })
|
|
||||||
tryRegister()
|
|
||||||
return () => {
|
return () => {
|
||||||
offLocale()
|
offLocale()
|
||||||
unsubscribe()
|
deferred.dispose()
|
||||||
dispose?.()
|
|
||||||
}
|
}
|
||||||
}, 'ui-settings: general section registration')
|
}, 'ui-settings: general section registration')
|
||||||
}
|
}
|
||||||
|
|||||||
66
packages/client/ui-slots/src/deferred.ts
Normal file
66
packages/client/ui-slots/src/deferred.ts
Normal file
@@ -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?.()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import type { BoundActions, HandleOf, PropsStore, SnapshotSelectorHook, StoreDec
|
|||||||
|
|
||||||
export * from './store.ts'
|
export * from './store.ts'
|
||||||
export * from './renderer.ts'
|
export * from './renderer.ts'
|
||||||
|
export * from './deferred.ts'
|
||||||
|
|
||||||
/** Slot contract table. Owners extend via declaration merging; entries are {@link SlotEntryDef}. */
|
/** Slot contract table. Owners extend via declaration merging; entries are {@link SlotEntryDef}. */
|
||||||
export interface SlotMap {}
|
export interface SlotMap {}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* section — the theme feature owns its own settings surface.
|
* section — the theme feature owns its own settings surface.
|
||||||
*/
|
*/
|
||||||
import type { Context } from 'cordis'
|
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 type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||||
@@ -262,28 +262,15 @@ export function apply(ctx: ClientContext): void {
|
|||||||
setTheme: (id) => { theme.setTheme(id) },
|
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(() => {
|
ctx.effect(() => {
|
||||||
let dispose: (() => void) | undefined
|
const deferred = deferRegistration(ctx.slots, 'settings.general.item', AppearanceRow, () =>
|
||||||
const tryRegister = (): void => {
|
ctx.slots.register({
|
||||||
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({
|
|
||||||
name: 'settings.general.item',
|
name: 'settings.general.item',
|
||||||
id: 'appearance',
|
id: 'appearance',
|
||||||
order: 10,
|
order: 10,
|
||||||
store,
|
store,
|
||||||
inject: injected,
|
inject: injected,
|
||||||
}, AppearanceRow)
|
}, AppearanceRow))
|
||||||
}
|
return () => { deferred.dispose() }
|
||||||
const unsubscribe = ctx.slots.subscribe('settings.general.item', () => { tryRegister() })
|
|
||||||
tryRegister()
|
|
||||||
return () => {
|
|
||||||
unsubscribe()
|
|
||||||
dispose?.()
|
|
||||||
}
|
|
||||||
}, 'ui-theme: appearance settings row registration')
|
}, 'ui-theme: appearance settings row registration')
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user