diff --git a/packages/client/ui-approval/src/client/index.ts b/packages/client/ui-approval/src/client/index.ts index e94947793c..04efe273c6 100644 --- a/packages/client/ui-approval/src/client/index.ts +++ b/packages/client/ui-approval/src/client/index.ts @@ -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' diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index e019ec995b..b154e73f4a 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.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) diff --git a/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx b/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx index 168d0e6230..eccf4b87a0 100644 --- a/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx @@ -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() diff --git a/packages/client/ui-renderer/src/client/registry.ts b/packages/client/ui-renderer/src/client/registry.ts index 0725163e57..728cbdcb21 100644 --- a/packages/client/ui-renderer/src/client/registry.ts +++ b/packages/client/ui-renderer/src/client/registry.ts @@ -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 - /** Scope-lifetime registrations for Session instances. */ - lifetimes: Map 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() + /** Latest live Context generation for each scoped store key. */ + private readonly _storeScopeOwners = new Map() 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): 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() } } diff --git a/packages/client/ui-renderer/tests/registry.client.spec.ts b/packages/client/ui-renderer/tests/registry.client.spec.ts index 3cddec3dec..627737539b 100644 --- a/packages/client/ui-renderer/tests/registry.client.spec.ts +++ b/packages/client/ui-renderer/tests/registry.client.spec.ts @@ -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', { diff --git a/packages/client/ui-session/src/client/index.ts b/packages/client/ui-session/src/client/index.ts index 36676d1403..0f0c521b78 100644 --- a/packages/client/ui-session/src/client/index.ts +++ b/packages/client/ui-session/src/client/index.ts @@ -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 { diff --git a/packages/client/ui-session/tests/ui-session.client.spec.ts b/packages/client/ui-session/tests/ui-session.client.spec.ts index 4045e4abb9..5b9ebe6f3d 100644 --- a/packages/client/ui-session/tests/ui-session.client.spec.ts +++ b/packages/client/ui-session/tests/ui-session.client.spec.ts @@ -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) diff --git a/packages/client/ui-user-questions/src/client/index.ts b/packages/client/ui-user-questions/src/client/index.ts index 2d1aabb92e..6efc7fa956 100644 --- a/packages/client/ui-user-questions/src/client/index.ts +++ b/packages/client/ui-user-questions/src/client/index.ts @@ -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'