fix(client): preserve scoped UI lifecycles
This commit is contained in:
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'
|
||||
|
||||
|
||||
Reference in new issue
Block a user