feat(client): inject slot declaration lifetimes
This commit is contained in:
@@ -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