feat(client): inject slot declaration lifetimes
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-slots/README.md
|
||||
README.md: ed6f052b3a47e08d693928b6763e32427b829467
|
||||
README.zh.md: e12e4bdad738c70657927b62d4a7bbd46d4b1519
|
||||
README.md: bb489dea0c3848cf3d501dcf095a65fe1cef9ef6
|
||||
README.zh.md: b4a2915b9d85c45ef7c6dccf27761794e21aa65f
|
||||
|
||||
@@ -19,7 +19,7 @@ The standard-kit interfaces (`SessionStandardProps`, `GlobalStandardProps`) are
|
||||
|
||||
The store family (`defineStore` spec in / `StoreHandle<T, A>` out) types the store seat: `init` infers the state schema, `actions` is the complete draft-transform write set, `BakedActions` strips the draft parameter into the callbacks components and inject factories receive. The `defineStore` value implementation lives in the runtime package (the engine's home) and satisfies the `DefineStore` contract exported here. Engine products and the renderer host contract carry bare snapshot sources (`getSnapshot`/`subscribe`), never React hooks — hook binding is the render machinery's side of the seam; only the props-contract hook type (`SnapshotSelectorHook`) lives here.
|
||||
|
||||
`SlotCore` seeds the a-priori `'root'` slot at construction and enforces load-time validation (undeclared-slot registration, duplicate child declaration, one shared handle under two scopes, a chain registration without `select` — all throw at register). An entry's disposer collapses its declared child slots recursively: ledger rows, contributions, and store mounts die on one lifecycle axis. `renderer.ts` carries the install seam (`SlotRenderer`, `SlotRendererHost`) plus `StaleAuthorizationError`/`SlotOwnershipError`; the implementation lives in web-react, the installation in the shell boot.
|
||||
`SlotCore` seeds the a-priori `'root'` slot at construction and enforces load-time validation (undeclared-slot registration, duplicate child declaration, one shared handle under two scopes, a chain registration without `select` — all throw at register). An entry's disposer collapses its declared child slots recursively: ledger rows, contributions, and store mounts die on one lifecycle axis. Each key also carries a declaration epoch that advances only on declaration and collapse; the runtime uses it for [`ctx.slots.inject`](../runtime/README.md#slot-declaration-injection), independently from ordinary entry versions. `renderer.ts` carries the install seam (`SlotRenderer`, `SlotRendererHost`) plus `StaleAuthorizationError`/`SlotOwnershipError`; the implementation lives in web-react, the installation in the shell boot.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ chain-kind slot 会反转键控路由:条目自行提名,而不是由分发
|
||||
|
||||
store 家族(输入 `defineStore` 规范/输出 `StoreHandle<T, A>`)为 store seat 建模:`init` 推断状态 schema;`actions` 是完整的 draft-transform 写入集合;`BakedActions` 移除 draft 参数,成为组件和 inject factory 收到的回调。`defineStore` 值实现位于 runtime 包(引擎所属位置),并满足这里导出的 `DefineStore` 契约。引擎产物与 renderer host 契约携带裸快照 source(`getSnapshot`/`subscribe`),绝不携带 React hook;hook 绑定属于渲染机制这一侧的 seam,只有 props 契约 hook 类型(`SnapshotSelectorHook`)位于这里。
|
||||
|
||||
`SlotCore` 在构造时预置 `'root'` slot,并强制执行加载时验证(注册未声明 slot、重复声明子项、在两个 scope 下使用同一个共享 handle、chain 注册缺少 `select`,这些情况都在 register 时抛出)。条目的 disposer 会递归移除其声明的子 slot:账本行、贡献和 store 挂载都会随同一生命周期结束而移除。`renderer.ts` 携带安装 seam(`SlotRenderer`、`SlotRendererHost`)以及 `StaleAuthorizationError`/`SlotOwnershipError`;实现在 web-react 中,安装则在外壳启动中完成。
|
||||
`SlotCore` 在构造时预置 `'root'` slot,并强制执行加载时验证(注册未声明 slot、重复声明子项、在两个 scope 下使用同一个共享 handle、chain 注册缺少 `select`,这些情况都在 register 时抛出)。条目的 disposer 会递归移除其声明的子 slot:账本行、贡献和 store 挂载都会随同一生命周期结束而移除。每个 key 还携带一个 declaration epoch(声明代次),它只在声明与折叠时递增;运行时将其用于 [`ctx.slots.inject`](../runtime/README.md#slot-declaration-injection),且与普通条目版本相互独立。`renderer.ts` 携带安装 seam(`SlotRenderer`、`SlotRendererHost`)以及 `StaleAuthorizationError`/`SlotOwnershipError`;实现在 web-react 中,安装则在外壳启动中完成。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
/**
|
||||
* 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.
|
||||
* @param onFailure - owns a registration failure that fires from a LATER
|
||||
* ledger flush (a declaration landing after two providers deferred, say):
|
||||
* the deferral first removes its own subscription, then hands the error
|
||||
* over instead of throwing through the flush — the callback's chance to
|
||||
* roll back sibling deferrals and surface the conflict on a loud channel.
|
||||
* Absent, a late failure rethrows out of the flush.
|
||||
* @returns the deferral handle (dispose in the owning effect's disposer).
|
||||
* @throws the immediate registration's failure, after removing the
|
||||
* just-installed subscription — a throwing construction leaves nothing live.
|
||||
*/
|
||||
export function deferRegistration(
|
||||
registry: DeferralRegistry,
|
||||
name: string,
|
||||
component: unknown,
|
||||
register: () => () => void,
|
||||
onFailure?: (error: unknown) => 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, () => {
|
||||
try {
|
||||
tryRegister()
|
||||
} catch (error) {
|
||||
unsubscribe()
|
||||
if (onFailure === undefined) throw error
|
||||
onFailure(error)
|
||||
}
|
||||
})
|
||||
try {
|
||||
tryRegister()
|
||||
} catch (error) {
|
||||
// A synchronous registration failure (the declared slot is already
|
||||
// occupied) must not leave the just-installed subscription behind: the
|
||||
// caller receives no handle to dispose it through.
|
||||
unsubscribe()
|
||||
throw error
|
||||
}
|
||||
return {
|
||||
refresh() {
|
||||
dispose?.()
|
||||
dispose = undefined
|
||||
tryRegister()
|
||||
},
|
||||
dispose() {
|
||||
unsubscribe()
|
||||
dispose?.()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Defer ONE occupant into several holes as a unit. Construction that throws
|
||||
* partway (a declared hole already occupied registers synchronously) rolls
|
||||
* every earlier deferral back before rethrowing; a failure surfacing from a
|
||||
* LATER ledger flush (holes declared after rival providers activated) rolls
|
||||
* the whole group back the same way and re-raises the wrapped error on the
|
||||
* global channel the boot's fail-loud handler owns — never a throw through
|
||||
* the slot flush, never partial occupancy from the group's owner.
|
||||
* @param registry - the slot registry face.
|
||||
* @param names - the target holes (one registration per name).
|
||||
* @param component - the occupant whose ledger presence marks "registered".
|
||||
* @param register - performs one hole's registration; returns its disposer.
|
||||
* @returns the group handle (dispose in the owning effect's disposer).
|
||||
* @throws the immediate registration's failure, after rolling the group back.
|
||||
*/
|
||||
export function deferGroupRegistration<K extends string>(
|
||||
registry: DeferralRegistry,
|
||||
names: readonly K[],
|
||||
component: unknown,
|
||||
register: (name: K) => () => void,
|
||||
): { dispose: () => void } {
|
||||
const deferred: DeferredRegistration[] = []
|
||||
const lateFailure = (error: unknown): void => {
|
||||
for (const entry of deferred) entry.dispose()
|
||||
queueMicrotask(() => { throw error instanceof Error ? error : new Error(String(error)) })
|
||||
}
|
||||
try {
|
||||
for (const name of names) {
|
||||
deferred.push(deferRegistration(registry, name, component, () => register(name), lateFailure))
|
||||
}
|
||||
} catch (error) {
|
||||
for (const entry of deferred) entry.dispose()
|
||||
throw error
|
||||
}
|
||||
return { dispose: () => { for (const entry of deferred) entry.dispose() } }
|
||||
}
|
||||
@@ -19,7 +19,6 @@ 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 {}
|
||||
@@ -457,9 +456,12 @@ interface SlotRecord {
|
||||
spec: SlotSpec<SlotEntryDef> | undefined
|
||||
/** Diagnostics: which slot's entry declared this key ('(built-in)' for root). */
|
||||
declaredBy: string | undefined
|
||||
/** Monotonic declaration lifetime, distinct from ordinary entry mutations. */
|
||||
declarationEpoch: number
|
||||
entries: readonly StoredEntry[]
|
||||
version: number
|
||||
listeners: Set<() => void>
|
||||
declarationListeners: Set<() => void>
|
||||
}
|
||||
|
||||
const NO_ENTRIES: readonly StoredEntry[] = Object.freeze([])
|
||||
@@ -473,8 +475,10 @@ const NO_ENTRIES: readonly StoredEntry[] = Object.freeze([])
|
||||
*
|
||||
* Change propagation contract: versions bump and {@link SlotCore.onMutate}
|
||||
* fires synchronously per mutation (registry state is consistent when they
|
||||
* fire); {@link SlotCore.subscribe} notifications batch per microtask, so N
|
||||
* same-tick mutations produce one notification per touched key.
|
||||
* fire); {@link SlotCore.subscribeDeclaration} fires synchronously for each
|
||||
* declaration lifetime boundary; {@link SlotCore.subscribe} notifications
|
||||
* batch per microtask, so N same-tick mutations produce one notification per
|
||||
* touched key.
|
||||
*/
|
||||
export class SlotCore {
|
||||
private records = new Map<string, SlotRecord>()
|
||||
@@ -491,6 +495,7 @@ export class SlotCore {
|
||||
const root = this.record('root')
|
||||
root.spec = { kind: 'single', scope: 'root' }
|
||||
root.declaredBy = '(built-in)'
|
||||
root.declarationEpoch = 1
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -631,12 +636,22 @@ export class SlotCore {
|
||||
rec.entries = next
|
||||
this.markDirty(options.name, rec)
|
||||
if (options.children) {
|
||||
const declarations: [key: string, record: SlotRecord][] = []
|
||||
for (const [childKey, childSpec] of Object.entries(options.children)) {
|
||||
const childRec = this.record(childKey)
|
||||
childRec.spec = childSpec
|
||||
childRec.declaredBy = `an entry in "${options.name}"${options.registrant ? ` (${options.registrant})` : ''}`
|
||||
childRec.declarationEpoch += 1
|
||||
declarations.push([childKey, childRec])
|
||||
}
|
||||
// Synchronous listeners may register into or try to redeclare a sibling;
|
||||
// publish only after the whole children table owns its declarations.
|
||||
for (const [childKey, childRec] of declarations) {
|
||||
this.markDirty(childKey, childRec)
|
||||
}
|
||||
for (const [, childRec] of declarations) {
|
||||
this.notifyDeclaration(childRec)
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
if (!rec.entries.includes(entry)) return
|
||||
@@ -692,6 +707,16 @@ export class SlotCore {
|
||||
return this.records.get(key)?.spec
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the declaration lifetime of a key. Entry additions and removals do
|
||||
* not change it; declaration creation and collapse each advance it.
|
||||
* @param key - slot key.
|
||||
* @returns monotonic epoch (0 before the first declaration).
|
||||
*/
|
||||
declarationEpoch(key: string): number {
|
||||
return this.records.get(key)?.declarationEpoch ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to registration changes for a key (microtask-batched).
|
||||
* Subscribing ahead of declaration is allowed; the declaration notifies.
|
||||
@@ -705,6 +730,22 @@ export class SlotCore {
|
||||
return () => { rec.listeners.delete(fn) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to declaration lifetime boundaries for a key. Notifications
|
||||
* are synchronous so declaration teardown finishes before a subsequent
|
||||
* same-tick registration can observe stale resources. Ordinary entry
|
||||
* mutations do not notify this surface. A children table commits every
|
||||
* sibling declaration before its first notification.
|
||||
* @param key - slot key.
|
||||
* @param fn - declaration or collapse callback.
|
||||
* @returns unsubscribe.
|
||||
*/
|
||||
subscribeDeclaration(key: string, fn: () => void): () => void {
|
||||
const rec = this.record(key)
|
||||
rec.declarationListeners.add(fn)
|
||||
return () => { rec.declarationListeners.delete(fn) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Monotonic version for a key, bumped synchronously per mutation so a
|
||||
* uSES getSnapshot read is never stale when its batched notification lands.
|
||||
@@ -746,8 +787,10 @@ export class SlotCore {
|
||||
const doomed = childRec.entries
|
||||
childRec.spec = undefined
|
||||
childRec.declaredBy = undefined
|
||||
childRec.declarationEpoch += 1
|
||||
childRec.entries = NO_ENTRIES
|
||||
this.markDirty(childKey, childRec)
|
||||
this.notifyDeclaration(childRec)
|
||||
for (const dead of doomed) this.releaseEntry(dead)
|
||||
}
|
||||
}
|
||||
@@ -755,7 +798,15 @@ export class SlotCore {
|
||||
private record(key: string): SlotRecord {
|
||||
let rec = this.records.get(key)
|
||||
if (!rec) {
|
||||
rec = { spec: undefined, declaredBy: undefined, entries: NO_ENTRIES, version: 0, listeners: new Set() }
|
||||
rec = {
|
||||
spec: undefined,
|
||||
declaredBy: undefined,
|
||||
declarationEpoch: 0,
|
||||
entries: NO_ENTRIES,
|
||||
version: 0,
|
||||
listeners: new Set(),
|
||||
declarationListeners: new Set(),
|
||||
}
|
||||
this.records.set(key, rec)
|
||||
}
|
||||
return rec
|
||||
@@ -771,6 +822,10 @@ export class SlotCore {
|
||||
}
|
||||
}
|
||||
|
||||
private notifyDeclaration(rec: SlotRecord): void {
|
||||
for (const fn of [...rec.declarationListeners]) fn()
|
||||
}
|
||||
|
||||
private flush(): void {
|
||||
// Reset before iterating so a mutation from inside a listener re-schedules.
|
||||
this.flushScheduled = false
|
||||
|
||||
@@ -231,6 +231,22 @@ describe('store scope pinning', () => {
|
||||
})
|
||||
|
||||
describe('subscription surface', () => {
|
||||
it('tracks declaration epochs separately from ordinary entry mutations', () => {
|
||||
const core = new SlotCore()
|
||||
expect(core.declarationEpoch('root')).toBe(1)
|
||||
expect(core.declarationEpoch('test.list')).toBe(0)
|
||||
const disposeFrame = mountFrame(core)
|
||||
const declared = core.declarationEpoch('test.list')
|
||||
expect(declared).toBe(1)
|
||||
const disposeEntry = core.register({ name: 'test.list', id: 'a' }, Comp)
|
||||
disposeEntry()
|
||||
expect(core.declarationEpoch('test.list')).toBe(declared)
|
||||
disposeFrame()
|
||||
expect(core.declarationEpoch('test.list')).toBe(declared + 1)
|
||||
mountFrame(core)
|
||||
expect(core.declarationEpoch('test.list')).toBe(declared + 2)
|
||||
})
|
||||
|
||||
it('entries() returns a stable cached reference between mutations', () => {
|
||||
const core = new SlotCore()
|
||||
mountFrame(core)
|
||||
@@ -267,6 +283,44 @@ describe('subscription surface', () => {
|
||||
expect(fn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('notifies declaration subscribers synchronously, excluding entries, until unsubscribe', () => {
|
||||
const core = new SlotCore()
|
||||
const fn = vi.fn()
|
||||
const unsubscribe = core.subscribeDeclaration('test.list', fn)
|
||||
const disposeFrame = mountFrame(core)
|
||||
expect(fn).toHaveBeenCalledTimes(1)
|
||||
core.register({ name: 'test.list', id: 'ordinary' }, Comp)
|
||||
expect(fn).toHaveBeenCalledTimes(1)
|
||||
disposeFrame()
|
||||
expect(fn).toHaveBeenCalledTimes(2)
|
||||
unsubscribe()
|
||||
mountFrame(core)
|
||||
expect(fn).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('commits sibling declarations before notifying declaration subscribers', () => {
|
||||
const core = new SlotCore()
|
||||
let duplicateDeclaration: unknown
|
||||
const unsubscribe = core.subscribeDeclaration('test.single', () => {
|
||||
core.register({ name: 'test.list', id: 'from-listener' }, Comp)
|
||||
try {
|
||||
core.register({
|
||||
name: 'test.single',
|
||||
children: { 'test.list': { kind: 'list', scope: 'root' } },
|
||||
}, Comp as never)
|
||||
} catch (error) {
|
||||
duplicateDeclaration = error
|
||||
}
|
||||
})
|
||||
|
||||
const disposeFrame = mountFrame(core)
|
||||
expect(core.entries('test.list')).toHaveLength(1)
|
||||
expect(String(duplicateDeclaration)).toContain('already declared')
|
||||
unsubscribe()
|
||||
disposeFrame()
|
||||
expect(core.specDynamic('test.list')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('notifies only subscribers of the touched key; unsubscribe stops delivery', async () => {
|
||||
const core = new SlotCore()
|
||||
mountFrame(core)
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
// deferRegistration lifecycle: declaration-aware registration, HMR
|
||||
// re-registration, and — the failure contract — no subscription survives a
|
||||
// construction that throws synchronously (an already-occupied single slot).
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { deferGroupRegistration, deferRegistration, SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
// Shares the merges declared by core.spec.ts (same program); reuse its keys.
|
||||
const HOLE = 'test.single' as const
|
||||
|
||||
function declared(): SlotCore {
|
||||
const core = new SlotCore()
|
||||
core.register({ name: 'root', children: { [HOLE]: { kind: 'single', scope: 'root' } } } as never, (() => null) as never)
|
||||
return core
|
||||
}
|
||||
|
||||
describe('deferRegistration', () => {
|
||||
it('registers immediately under an existing declaration and disposes cleanly', () => {
|
||||
const core = declared()
|
||||
const component = (): null => null
|
||||
const handle = deferRegistration(core, HOLE, component, () =>
|
||||
core.register({ name: HOLE } as never, component as never))
|
||||
expect(core.entries(HOLE)).toHaveLength(1)
|
||||
handle.dispose()
|
||||
expect(core.entries(HOLE)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('hands a late registration failure to onFailure after unsubscribing itself', async () => {
|
||||
const core = new SlotCore()
|
||||
const component = (): null => null
|
||||
const foreign = (): null => null
|
||||
const failures: unknown[] = []
|
||||
// Nothing is declared yet: the deferral just subscribes and waits.
|
||||
const register = vi.fn(() => core.register({ name: HOLE } as never, component as never))
|
||||
deferRegistration(core, HOLE, component, register, (error) => { failures.push(error) })
|
||||
// The declaration lands with a foreign occupant racing in first: the
|
||||
// deferral's flush-time attempt fails, unsubscribes itself, and reports
|
||||
// through onFailure instead of throwing out of the flush.
|
||||
core.register({ name: 'root', children: { [HOLE]: { kind: 'single', scope: 'root' } } } as never, (() => null) as never)
|
||||
const disposeForeign = core.register({ name: HOLE } as never, foreign as never)
|
||||
await Promise.resolve()
|
||||
expect(failures.map(String).join('')).toContain('already has a registration')
|
||||
// Unsubscribed: freeing the hole must not resurrect the loser.
|
||||
disposeForeign()
|
||||
await Promise.resolve()
|
||||
expect(core.entries(HOLE)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('drops its subscription when the immediate registration throws', async () => {
|
||||
const core = declared()
|
||||
const foreign = (): null => null
|
||||
const disposeForeign = core.register({ name: HOLE } as never, foreign as never)
|
||||
const component = (): null => null
|
||||
const register = vi.fn(() => core.register({ name: HOLE } as never, component as never))
|
||||
// The single hole is occupied: the immediate attempt throws out of the
|
||||
// constructor, and the caller never receives a handle to dispose.
|
||||
expect(() => deferRegistration(core, HOLE, component, register)).toThrow(/already has a registration/)
|
||||
expect(register).toHaveBeenCalledOnce()
|
||||
// The subscription rolled back with it: freeing the hole flushes a
|
||||
// notification that must not resurrect the failed registration.
|
||||
disposeForeign()
|
||||
await Promise.resolve()
|
||||
expect(register).toHaveBeenCalledOnce()
|
||||
expect(core.entries(HOLE)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deferGroupRegistration', () => {
|
||||
const HOLES = ['test.single', 'test.grandchild'] as const
|
||||
|
||||
function declaredPair(): SlotCore {
|
||||
const core = new SlotCore()
|
||||
core.register({
|
||||
name: 'root',
|
||||
children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])),
|
||||
} as never, (() => null) as never)
|
||||
return core
|
||||
}
|
||||
|
||||
it('registers the whole group and disposes it as a unit', () => {
|
||||
const core = declaredPair()
|
||||
const component = (): null => null
|
||||
const group = deferGroupRegistration(core, HOLES, component, name =>
|
||||
core.register({ name } as never, component as never))
|
||||
for (const name of HOLES) expect(core.entries(name)).toHaveLength(1)
|
||||
group.dispose()
|
||||
for (const name of HOLES) expect(core.entries(name)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rolls the group back when construction fails partway', () => {
|
||||
const core = declaredPair()
|
||||
const component = (): null => null
|
||||
core.register({ name: HOLES[1] } as never, (() => null) as never)
|
||||
expect(() => deferGroupRegistration(core, HOLES, component, name =>
|
||||
core.register({ name } as never, component as never))).toThrow(/already has a registration/)
|
||||
// The first hole's registration and subscription rolled back with it.
|
||||
expect(core.entries(HOLES[0])).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rolls the group back and re-raises loudly on a late conflict', async () => {
|
||||
const core = new SlotCore()
|
||||
const component = (): null => null
|
||||
const failures: unknown[] = []
|
||||
const onLoud = (reason: unknown): void => { failures.push(reason) }
|
||||
process.on('uncaughtException', onLoud)
|
||||
try {
|
||||
const group = deferGroupRegistration(core, HOLES, component, name =>
|
||||
core.register({ name } as never, component as never))
|
||||
// Declaration lands with a rival racing in ahead of the flush.
|
||||
core.register({
|
||||
name: 'root',
|
||||
children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])),
|
||||
} as never, (() => null) as never)
|
||||
core.register({ name: HOLES[0] } as never, (() => null) as never)
|
||||
core.register({ name: HOLES[1] } as never, (() => null) as never)
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
expect(failures.map(String).join('')).toContain('already has a registration')
|
||||
// No partial occupancy from the group's owner survives.
|
||||
for (const name of HOLES) {
|
||||
expect(core.entries(name).filter(entry => entry.component === component)).toHaveLength(0)
|
||||
}
|
||||
group.dispose()
|
||||
} finally {
|
||||
process.off('uncaughtException', onLoud)
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user