refactor(client): deferGroupRegistration owns the multi-hole flow semantics

The construction-rollback + late-conflict-rollback + loud-rethrow block was
about to be a verbatim clone across the two flow packages; ui-slots now owns
it as deferGroupRegistration (one occupant, several holes, as a unit), with
direct specs for all three arms, and the native flow consumes it.
This commit is contained in:
creatixchu
2026-07-29 03:17:01 +08:00
parent 62db16f81a
commit f56b9149e6
3 changed files with 110 additions and 24 deletions

View File

@@ -2,7 +2,7 @@
// 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 { deferRegistration, SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
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
@@ -63,3 +63,64 @@ describe('deferRegistration', () => {
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)
}
})
})