fix(client): preserve scoped UI lifecycles

This commit is contained in:
imccyu committed 2026-08-23 16:28:20 +08:00
1 parent 956a72ffe0
commit a40f30a4a2
8 files changed
+130 -38

No files matched your search

@@ -11,12 +11,12 @@ import { ApprovalPanel } from './ApprovalPanel.tsx'
import { PendingApproval } from './contract/slots.ts'
import { en, zh } from './locales.ts'
export { PendingApproval } from './contract/slots.ts'
export type {
ApprovalComposerProps,
ApprovalDecision,
ApprovalDetailOwnerProps,
ApprovalPresentationRequest,
PendingApproval,
} from './contract/slots.ts'
export type { ApprovalKey } from './locales.ts'
@@ -169,7 +169,7 @@ export function apply(ctx: Context): void {
},
})
slots.register({
const registerConversationRoot = () => slots.register({
name: 'conversation',
locale: NS,
children: {
@@ -212,7 +212,7 @@ export function apply(ctx: Context): void {
}),
}, ConversationRoot)
slots.register({
const registerConversationSession = () => slots.register({
name: 'conversation.session',
children: {
'conversation.view': { kind: 'list', scope: 'session' },
@@ -224,7 +224,7 @@ export function apply(ctx: Context): void {
}),
}, ConversationSession)
slots.register({
const registerConversationHeader = () => slots.register({
name: 'conversation.session.header',
locale: NS,
children: {
@@ -239,7 +239,7 @@ export function apply(ctx: Context): void {
}),
}, ConversationSessionHeader)
slots.register({
const registerComposerBar = () => slots.register({
name: 'conversation.composer.bar',
locale: NS,
children: {
@@ -323,6 +323,13 @@ export function apply(ctx: Context): void {
},
}, InputBar)
slots.inject('conversation', function* () {
yield registerConversationRoot()
yield registerConversationSession()
yield registerConversationHeader()
yield registerComposerBar()
})
ctx.plugin(ConversationController, { input: inputHub, blocks: composerBlocks })
ctx.plugin(todoDockEntry)
ctx.plugin(queueDockEntry)
@@ -12,17 +12,19 @@ usePinnedBrowserLanguages('zh-CN')
const SID = 'session-1' as SessionId
async function bench() {
async function bench(options: { declareConversation?: boolean } = {}) {
const runtime = await SlotTestRuntime.create()
runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID) } as never)
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
const locale = new LocaleRuntime(runtime.ctx)
runtime.ctx.provide('locale', locale)
runtime.slots.installLocale(locale)
await runtime.root.declare({
'conversation': { kind: 'single', scope: 'session-maybe' },
'settings.general.item': { kind: 'list', scope: 'root' },
}, (_props: { renderSlot?: unknown }) => null)
if (options.declareConversation !== false) {
await runtime.root.declare({
'conversation': { kind: 'single', scope: 'session-maybe' },
'settings.general.item': { kind: 'list', scope: 'root' },
}, (_props: { renderSlot?: unknown }) => null)
}
const feature = await runtime.mount({ inject: [...inject], apply })
return { runtime, feature }
}
@@ -35,6 +37,22 @@ function entry(
}
describe('target-neutral Conversation apply wiring', () => {
it('waits for the layout-owned conversation declaration before registering its subtree', async () => {
const b = await bench({ declareConversation: false })
expect(b.runtime.slots.entries('conversation')).toHaveLength(0)
await b.runtime.root.declare({
'conversation': { kind: 'single', scope: 'session-maybe' },
'settings.general.item': { kind: 'list', scope: 'root' },
}, (_props: { renderSlot?: unknown }) => null)
expect(b.runtime.slots.entries('conversation')).toHaveLength(1)
expect(b.runtime.slots.entries('conversation.session')).toHaveLength(1)
expect(b.runtime.slots.entries('conversation.session.header')).toHaveLength(1)
expect(b.runtime.slots.entries('conversation.composer.bar')).toHaveLength(1)
await b.runtime.dispose()
})
it('provides both action and assembly services without installing Chat', async () => {
const b = await bench()
expect(b.runtime.ctx.get('conversation')).toBeDefined()
@@ -64,8 +64,6 @@ interface StoreAxisRecord {
refs: number
/** Root scope: the single instance under {@link ROOT_INSTANCE_KEY}; session scope: one per session id. */
instances: Map<string, EngineStoreInstance>
/** Scope-lifetime registrations for Session instances. */
lifetimes: Map<string, () => void>
}
/** Type-erased options view the implementation works with (the typed overloads proved the shares). */
@@ -98,6 +96,8 @@ export class SlotRegistry extends Service {
private readonly _core = new SlotCore()
/** Store-instance axis: handle -> mounted scope, refcount, resolved instances. */
private readonly _stores = new Map<EngineStoreHandle, StoreAxisRecord>()
/** Latest live Context generation for each scoped store key. */
private readonly _storeScopeOwners = new Map<string, Context>()
private _renderer: SlotRenderer | undefined
private _locale: LocaleFace | undefined
private _host: SlotRendererHost | undefined
@@ -314,6 +314,26 @@ export class SlotRegistry extends Service {
}, `slots.installScope(${JSON.stringify(scope)})`)
}
/**
* Bind all scoped Store handles to one owner Context lifetime. The cleanup
* materializes an otherwise-unused handle before clearing it, because a
* previous application run may have persisted state for a Slot that this
* scope never rendered. Rebinding the same key transfers cleanup ownership
* to the newest Context generation.
*
* @param binding - materialized scope identity and its owning Context.
*/
bindStoreScope(binding: Pick<ScopedStandardSourceBinding, 'key' | 'ctx'>): void {
const current = this._storeScopeOwners.get(binding.key)
if (current === binding.ctx) return
this._storeScopeOwners.set(binding.key, binding.ctx)
binding.ctx.effect(() => () => {
if (this._storeScopeOwners.get(binding.key) !== binding.ctx) return
this._storeScopeOwners.delete(binding.key)
this.clearStoreScope(binding.key)
}, `slots: store scope ${binding.key}`)
}
/**
* The single ctx-level render entry: the shell renders 'root'; every other
* key renders inside components through the props renderSlot face. All
@@ -510,42 +530,39 @@ export class SlotRegistry extends Service {
): StoreInstanceLike {
const record = this._stores.get(handle)
if (record === undefined) throw new Error('store handle is not registered (entry unloaded, or the handle never went through register)')
const key = record.scope === 'root' ? ROOT_INSTANCE_KEY : scopeBinding?.key
if (key === undefined) throw new Error(`${record.scope} store resolution requires a session id`)
let key: string
if (record.scope === 'root') {
key = ROOT_INSTANCE_KEY
} else {
if (scopeBinding === undefined) throw new Error(`${record.scope} store resolution requires a session id`)
key = scopeBinding.key
this.bindStoreScope(scopeBinding)
}
let instance = record.instances.get(key)
if (instance === undefined) {
// Session instances get the scope key (the engine suffixes the persist
// key per session); root instances stay keyless.
instance = record.scope === 'root' ? handle.create() : handle.create(key)
record.instances.set(key, instance)
if (record.scope !== 'root') {
const scopeCtx = scopeBinding?.ctx
if (scopeCtx === undefined) {
record.instances.delete(key)
throw new Error(`${record.scope} store resolution requires a scope lifetime`)
}
const owned = instance
const dispose = scopeCtx.effect(
() => () => {
if (this._stores.get(handle) !== record || record.instances.get(key) !== owned) return
owned.clearPersisted()
record.instances.delete(key)
record.lifetimes.delete(key)
},
`slots: store scope ${key}`,
)
const release = (): void => { void dispose() }
record.lifetimes.set(key, release)
}
}
return instance
}
/** Clear every live non-root Store handle for one dead scope key. */
private clearStoreScope(key: string): void {
for (const [handle, record] of this._stores) {
if (record.scope === 'root') continue
const instance = record.instances.get(key) ?? handle.create(key)
instance.clearPersisted()
record.instances.delete(key)
}
}
/** Bind (or re-reference) a handle on the axis; cross-scope conflicts already threw in the core. */
private _acquire(handle: EngineStoreHandle, scope: SlotScope): void {
const record = this._stores.get(handle)
if (record === undefined) {
this._stores.set(handle, { scope, refs: 1, instances: new Map(), lifetimes: new Map() })
this._stores.set(handle, { scope, refs: 1, instances: new Map() })
return
}
record.refs += 1
@@ -561,7 +578,6 @@ export class SlotRegistry extends Service {
record.refs -= 1
if (record.refs !== 0) return
this._stores.delete(handle)
for (const release of record.lifetimes.values()) release()
}
}
@@ -621,6 +621,41 @@ describe('store instance axis', () => {
await replacement.fiber.dispose()
})
it('clears persisted state for scoped stores that were never materialized', async () => {
const { bench } = await storeBench()
const root = fakeHandle()
const scoped = fakeHandle()
bench.erased.register({ name: 't.host', store: root.handle }, C)
bench.erased.register({ name: 't.panel', store: scoped.handle }, C)
const scope = scopedBinding(bench.ctx, 's1')
bench.svc.bindStoreScope(scope.binding)
await scope.fiber.dispose()
expect(root.handle.create).not.toHaveBeenCalled()
expect(scoped.handle.create).toHaveBeenCalledOnce()
expect(scoped.handle.create).toHaveBeenCalledWith('s1')
expect(scoped.created[0]?.clearPersisted).toHaveBeenCalledOnce()
})
it('leaves scoped Store cleanup with the newest Context generation', async () => {
const { bench } = await storeBench()
const { handle, created } = fakeHandle()
bench.erased.register({ name: 't.panel', store: handle }, C)
const first = scopedBinding(bench.ctx, 's1')
const replacement = scopedBinding(bench.ctx, 's1')
bench.svc.bindStoreScope(first.binding)
bench.svc.bindStoreScope(first.binding)
bench.svc.bindStoreScope(replacement.binding)
await first.fiber.dispose()
expect(handle.create).not.toHaveBeenCalled()
await replacement.fiber.dispose()
expect(handle.create).toHaveBeenCalledOnce()
expect(created[0]?.clearPersisted).toHaveBeenCalledOnce()
})
it('clears session-maybe state through binding disposal and creates a fresh instance on reuse', async () => {
const { bench, host } = await storeBench()
bench.svc.installScope('session', {
@@ -392,13 +392,15 @@ export class UiSession extends Service {
copyDeclared('keyed hook', keyedHooks, descriptor.keyedHooks, contribution.keyedHooks, finalProps)
copyDeclared('prop', props, descriptor.props, contribution.props, finalProps)
}
return {
const value: ScopedStandardSourceBinding = {
key: binding.sessionId,
ctx: binding.ctx,
hooks,
keyedHooks,
props,
}
this.ctx.slots.bindStoreScope(value)
return value
}
private materializeAbsent(): StandardSourceBinding {
@@ -138,6 +138,7 @@ function createSessionsBench(_ctx: Context): SessionsBench {
}
function createUiSession(ctx: Context, bench: SessionsBench): UiSession {
ctx.provide('slots', { bindStoreScope: vi.fn() } as never)
return new UiSession(ctx, bench.sessions)
}
@@ -146,6 +147,20 @@ afterEach(() => {
})
describe('UiSession bindings', () => {
it('binds each materialized Session to renderer-owned Store cleanup', () => {
const ctx = new Context()
const bench = createSessionsBench(ctx)
const bindStoreScope = vi.fn()
ctx.provide('slots', { bindStoreScope } as never)
const service = new UiSession(ctx, bench.sessions)
const binding = bench.binding(sessionId('s1'))
const materialized = service.adapter.resolve(binding.sessionId)
expect(bindStoreScope).toHaveBeenCalledOnce()
expect(bindStoreScope).toHaveBeenCalledWith(materialized)
})
it('materializes built-in sources, caches a binding, and publishes selection and release', async () => {
const ctx = new Context()
const bench = createSessionsBench(ctx)
@@ -25,9 +25,8 @@ import { PendingQuestion } from './contract/slots.ts'
import { QuestionComposer } from './QuestionComposer.tsx'
import { en, zh, type QuestionKey } from './locales.ts'
export { PendingQuestion } from './contract/slots.ts'
export type {
PlanReview, QuestionAnswer, QuestionComposerProps, QuestionWait,
PendingQuestion, PlanReview, QuestionAnswer, QuestionComposerProps, QuestionWait,
} from './contract/slots.ts'
export type { QuestionKey } from './locales.ts'