Просмотр исходного кода

fix(client): preserve scoped UI lifecycles

imccyu 1 месяц назад
Родитель
Сommit
a40f30a4a2

+ 1 - 1
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'
 

+ 11 - 4
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)

+ 23 - 5
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()

+ 41 - 25
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<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()
   }
 }
 

+ 35 - 0
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', {

+ 3 - 1
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 {

+ 15 - 0
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)

+ 1 - 2
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'