Bläddra i källkod

fix(client): publish current session provide bundle as one reactive projection

A provider roster change under a stable current id rematerialized every
scope's bundle but nothing notified React: SessionProvider resolved the
bundle from a current-id subscription only, so mounted entries kept the
obsolete hook/prop schema until an unrelated re-render. The sessions
service now owns an atomic currentProvide observable fed by both current
writes and roster changes; the renderer host exposes it as
sessions.provide, replacing the current/provideInfo/maybeProvideInfo
trio, and both providers subscribe to it.
imccyu 1 månad sedan
förälder
incheckning
a0b618abb9

+ 43 - 6
packages/client/runtime/src/client/sessions/service.ts

@@ -151,6 +151,13 @@ export class SessionsService {
   readonly list: SnapshotStore<SessionListState>
   /** The object-layer instance cluster and frame dispatch entry. */
   private readonly manager: SessionManager
+  /**
+   * Atomic current-session provide projection: selection changes and
+   * provider-roster changes publish through this one source (the renderer
+   * host's `sessions.provide` feed), so a roster change under a stable
+   * current id republishes the bundle instead of stranding mounted entries.
+   */
+  readonly currentProvide: HostObservable<SessionMaybeProvideInfo>
 
   /**
    * Persisted selection cell (the durable half of `list.current`). Private on
@@ -167,6 +174,10 @@ export class SessionsService {
   private readonly providers: SessionProvideDescriptor[] = []
   /** Static no-session projection, rebuilt only when the provider roster changes. */
   private maybeInfo: SessionMaybeProvideInfo
+  /** Latest published {@link SessionsService.currentProvide} bundle (identity comparison dedupes republish). */
+  private currentProvideSnapshot: SessionMaybeProvideInfo
+  /** currentProvide subscribers (plain cell: bundles hold live Session sources, so no store freeze may touch them). */
+  private readonly currentProvideListeners = new Set<() => void>()
   /**
    * The staged session id — follows `list.current` exactly, holding its last
    * defined value across masked gaps (a transiently absent selection blanks
@@ -198,7 +209,11 @@ export class SessionsService {
     // dedicated code path. Safe to run synchronously inside the store notify:
     // the follower writes no list state — session.open()'s synchronous prefix
     // touches only session-side state and its own microtask-batched notifier.
-    this.list.subscribe(() => { this.followCurrent() })
+    // The current-provide projection follows the same current writes.
+    this.list.subscribe(() => {
+      this.followCurrent()
+      this.projectCurrentProvide()
+    })
     // The runtime's own contribution comes first: useSession rides the same
     // provide channel every plugin uses (no renderer special case).
     this.providers.push({
@@ -206,6 +221,14 @@ export class SessionsService {
       resolve: binding => ({ hooks: { session: binding.session } }),
     })
     this.maybeInfo = this.materializeMaybeProvideInfo()
+    this.currentProvideSnapshot = this.maybeInfo
+    this.currentProvide = {
+      getSnapshot: () => this.currentProvideSnapshot,
+      subscribe: (fn) => {
+        this.currentProvideListeners.add(fn)
+        return () => { this.currentProvideListeners.delete(fn) }
+      },
+    }
     rootCtx.reflect.provide('sessions', this, undefined)
   }
 
@@ -238,6 +261,20 @@ export class SessionsService {
     for (const record of this.scopes.values()) {
       record.provideInfo = this.materializeProvideInfo(record.binding)
     }
+    this.projectCurrentProvide()
+  }
+
+  /**
+   * Publish the current selection's provide bundle when it changed. Bundles
+   * are identity-stable per (scope, roster) materialization, so an identity
+   * compare is exact; synchronous notify — both call sites (list.subscribe,
+   * provide()) already sit behind their own batching or registration edges.
+   */
+  private projectCurrentProvide(): void {
+    const next = this.maybeProvideInfo(this.list.getSnapshot().current)
+    if (next === this.currentProvideSnapshot) return
+    this.currentProvideSnapshot = next
+    for (const fn of [...this.currentProvideListeners]) fn()
   }
 
   /** Build the static no-session kit and reject duplicate declared names. */
@@ -404,11 +441,11 @@ export class SessionsService {
   }
 
   /**
-   * Resolve the render-layer standard-props bundle (SessionProvider's feed
-   * through the renderer host; ctx never enters the render layer). Pure
-   * resolution — render-safe: SessionProvider calls this during render, so no
-   * staging, no window side effects (StrictMode double-invokes and concurrent
-   * discarded passes must stay free).
+   * Resolve one session's render-layer standard-props bundle (ctx never
+   * enters the render layer; the renderer subscribes to
+   * {@link SessionsService.currentProvide}). Pure resolution — render-safe:
+   * no staging, no window side effects (StrictMode double-invokes and
+   * concurrent discarded passes must stay free).
    * @param id - session id.
    * @returns the provide info, or undefined for a session neither listed nor already scoped.
    */

+ 1 - 10
packages/client/runtime/src/client/slots.ts

@@ -246,13 +246,6 @@ export class SlotsService extends Service {
     if (workspaces === undefined) {
       throw new Error("renderSlot('root') before the workspaces service mounted — boot order puts runtime apply first")
     }
-    // Identity-stable view: current rides the list snapshot (arbitrated), but
-    // the provider consumes it as its own observable; one cached object keeps
-    // the renderer's per-source hook cache stable.
-    const current = {
-      getSnapshot: () => sessions.list.getSnapshot().current as string | undefined,
-      subscribe: (fn: () => void) => sessions.list.subscribe(fn),
-    }
     this._host = {
       subscribe: (key, fn) => this._core.subscribe(key, fn),
       getVersion: key => this._core.getVersion(key),
@@ -263,9 +256,7 @@ export class SlotsService extends Service {
         entry.store === undefined ? undefined : this.resolveStore(entry.store as unknown as EngineStoreHandle, scopeKey),
       sessions: {
         list: sessions.list,
-        current,
-        provideInfo: id => sessions.provideInfo(id),
-        maybeProvideInfo: id => sessions.maybeProvideInfo(id),
+        provide: sessions.currentProvide,
       },
       workspaces: { list: workspaces.list },
     }

+ 54 - 0
packages/client/runtime/tests/sessions-service.spec.ts

@@ -195,6 +195,60 @@ describe('cell (render-layer session kit)', () => {
     expect(b.svc.provideInfo('ghost')).toBeUndefined()
   })
 
+  it('currentProvide follows selection: absent projection ↔ definite bundle, notified on each move', async () => {
+    const b = bench()
+    await feedList(b, [{ id: 's1' }, { id: 's2' }])
+    const absent = b.svc.currentProvide.getSnapshot()
+    expect(absent.sessionId).toBeUndefined()
+    expect(Object.hasOwn(absent.hooks, 'session')).toBe(true)
+    const notified = vi.fn()
+    b.svc.currentProvide.subscribe(notified)
+    b.svc.open(sid('s1'))
+    expect(b.svc.currentProvide.getSnapshot()).toBe(b.svc.provideInfo('s1'))
+    expect(notified).toHaveBeenCalledTimes(1)
+    b.svc.open(sid('s2'))
+    expect(b.svc.currentProvide.getSnapshot()).toBe(b.svc.provideInfo('s2'))
+    expect(notified).toHaveBeenCalledTimes(2)
+    b.svc.clear()
+    await Promise.resolve() // clearSelection projects through the manager notifier
+    expect(b.svc.currentProvide.getSnapshot().sessionId).toBeUndefined()
+  })
+
+  it('a provider roster change under a stable current id republishes the bundle', async () => {
+    const b = bench()
+    await feedList(b, [{ id: 's1' }])
+    b.svc.open(sid('s1'))
+    const before = b.svc.currentProvide.getSnapshot()
+    const notified = vi.fn()
+    b.svc.currentProvide.subscribe(notified)
+    const source = { getSnapshot: () => 'live', subscribe: () => () => {} }
+    const dispose = b.svc.provide({
+      hooks: ['extra'],
+      props: ['marker'],
+      resolve: () => ({ hooks: { extra: source }, props: { marker: 7 } }),
+    })
+    const added = b.svc.currentProvide.getSnapshot()
+    expect(added).not.toBe(before)
+    expect(added).toMatchObject({ sessionId: 's1', props: { marker: 7 } })
+    expect(added.hooks['extra']).toBe(source)
+    expect(notified).toHaveBeenCalledTimes(1)
+    dispose()
+    const removed = b.svc.currentProvide.getSnapshot()
+    expect(removed).not.toBe(added)
+    expect(Object.hasOwn(removed.hooks, 'extra')).toBe(false)
+    expect(notified).toHaveBeenCalledTimes(2)
+  })
+
+  it('an unsubscribed currentProvide listener stops receiving notifications', async () => {
+    const b = bench()
+    await feedList(b, [{ id: 's1' }])
+    const notified = vi.fn()
+    const off = b.svc.currentProvide.subscribe(notified)
+    off()
+    b.svc.open(sid('s1'))
+    expect(notified).not.toHaveBeenCalled()
+  })
+
   it('provideInfo()/binding() are pure resolution: no staging, no deferred sweep', async () => {
     const b = bench()
     await feedList(b, [{ id: 's1' }, { id: 's2' }])

+ 5 - 12
packages/client/runtime/tests/slots-service.spec.ts

@@ -97,18 +97,13 @@ function fakeWorkspaces() {
   return { list: { getSnapshot: () => state, subscribe: () => () => undefined } }
 }
 
-/** Minimal sessions face for the host seam (list observable + provide bundle). */
+/** Minimal sessions face for the host seam (list observable + current provide projection). */
 function fakeSessions() {
   const state = { ids: [], byId: {}, current: undefined as string | undefined }
+  const absentInfo = { sessionId: undefined, hooks: { session: undefined }, props: {} }
   return {
     list: { getSnapshot: () => state, subscribe: () => () => undefined },
-    provideInfo: (id: string) => (id === 'known'
-      ? {
-        sessionId: id,
-        hooks: { session: { getSnapshot: () => undefined, subscribe: () => () => undefined } },
-        props: {},
-      }
-      : undefined),
+    currentProvide: { getSnapshot: () => absentInfo, subscribe: () => () => undefined },
   }
 }
 
@@ -232,13 +227,11 @@ describe('host face', () => {
     expect(host.entriesOf('t.host')).toHaveLength(0)
   })
 
-  it('exposes sessions list/current/provideInfo (current riding the list snapshot)', async () => {
+  it('exposes the session list and the atomic current provide projection', async () => {
     const bench = await boot()
     const host = captureHost(bench)
     expect(host.sessions.list.getSnapshot()).toMatchObject({ ids: [] })
-    expect(host.sessions.current.getSnapshot()).toBeUndefined()
-    expect(host.sessions.provideInfo('known')).toMatchObject({ sessionId: 'known' })
-    expect(host.sessions.provideInfo('ghost')).toBeUndefined()
+    expect(host.sessions.provide.getSnapshot()).toMatchObject({ sessionId: undefined })
   })
 
   it('exposes the independent Workspace list source', async () => {

+ 2 - 1
packages/client/ui-conversation/tests/apply-inject.spec.tsx

@@ -87,12 +87,13 @@ async function bench() {
     }
   }
   const providers: TestProvider[] = []
+  const absentInfo = { sessionId: undefined, hooks: {}, props: {} }
   const sessionsFake = {
     list: listStore,
     binding: (id: SessionId) => ({ sessionId: id, session: sessionFake, ctx: mint(id) }),
     scope: (id: SessionId) => mint(id),
     provideInfo: () => undefined,
-    maybeProvideInfo: () => ({ hooks: {}, props: {} }),
+    currentProvide: { getSnapshot: () => absentInfo, subscribe: () => () => {} },
     provide: (descriptor: TestProvider) => { providers.push(descriptor); return () => {} },
     scopeOf,
     sessionOf: (actx: Context) => (scopeOf(actx) === undefined ? undefined : sessionFake),

+ 2 - 1
packages/client/ui-conversation/tests/chat-apply.spec.tsx

@@ -32,12 +32,13 @@ async function bench() {
     current: undefined,
     phase: 'ready',
   })
+  const absentInfo = { sessionId: undefined, hooks: {}, props: {} }
   const sessionsFake = {
     list: listStore,
     binding: vi.fn(),
     scope: () => undefined,
     provideInfo: () => undefined,
-    maybeProvideInfo: () => ({ hooks: {}, props: {} }),
+    currentProvide: { getSnapshot: () => absentInfo, subscribe: () => () => {} },
     provide: vi.fn(() => () => {}),
     create: vi.fn(),
     open: vi.fn(),

+ 7 - 3
packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx

@@ -87,6 +87,9 @@ async function bench(snapshot: ConversationSnapshot) {
   // Provide-channel contributions land in this bundle the way the runtime
   // materializes them; the renderer host serves it through provideInfo.
   const provided: { hooks: Record<string, unknown>; props: Record<string, unknown> } = { hooks: {}, props: {} }
+  // Identity-stable currentProvide snapshot (uSES getSnapshot contract),
+  // materialized on first render after the provide contributions landed.
+  let infoCell: { sessionId: SessionId; hooks: Record<string, unknown>; props: Record<string, unknown> } | undefined
   const sessionsFake = {
     list,
     binding: (id: SessionId) => (id === SID
@@ -103,9 +106,10 @@ async function bench(snapshot: ConversationSnapshot) {
     provideInfo: (id: string) => (id === SID
       ? { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props }
       : undefined),
-    maybeProvideInfo: (id: string | undefined) => (id === SID
-      ? { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props }
-      : { hooks: provided.hooks, props: provided.props }),
+    currentProvide: {
+      getSnapshot: () => infoCell ??= { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props },
+      subscribe: () => () => {},
+    },
     create: vi.fn(),
     open: vi.fn(),
   }

+ 25 - 21
packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx

@@ -24,6 +24,9 @@ import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/clien
 
 const SID = 's1' as SessionId
 
+/** Identity-stable no-session bundle (uSES getSnapshot contract). */
+const ABSENT_INFO = { sessionId: undefined, hooks: {}, props: {} }
+
 afterEach(cleanup)
 // The chat store persists under its declared key; clear between cases.
 beforeEach(() => {
@@ -89,30 +92,28 @@ async function bench(nodes: ToolResultNode[]) {
       subscribe: (fn: () => void) => session.subscribe(fn),
     },
   })
+  const provideInfo = (id: string) => {
+    if (id !== SID) return undefined
+    if (info === undefined) {
+      const hooks: Record<string, unknown> = { session }
+      const props: Record<string, unknown> = {}
+      for (const provider of providers) {
+        const c = provider(bindingOf(SID))
+        Object.assign(hooks, c.hooks ?? {})
+        Object.assign(props, c.props ?? {})
+      }
+      info = { sessionId: SID, hooks, props }
+    }
+    return info
+  }
   ctx.provide('sessions', {
     list,
     binding: bindingOf,
     scope: () => actxFake,
-    provideInfo: (id: string) => {
-      if (id !== SID) return undefined
-      if (info === undefined) {
-        const hooks: Record<string, unknown> = { session }
-        const props: Record<string, unknown> = {}
-        for (const provider of providers) {
-          const c = provider(bindingOf(SID))
-          Object.assign(hooks, c.hooks ?? {})
-          Object.assign(props, c.props ?? {})
-        }
-        info = { sessionId: SID, hooks, props }
-      }
-      return info
-    },
-    maybeProvideInfo(id: string | undefined) {
-      // `this` inside an object-literal method is any under strict lint; the
-      // fake resolves through its own provideInfo above.
-      /* eslint-disable-next-line @typescript-eslint/no-unsafe-return,
-         @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
-      return (id === undefined ? undefined : this.provideInfo(id)) ?? { hooks: {}, props: {} }
+    provideInfo,
+    currentProvide: {
+      getSnapshot: () => provideInfo(SID),
+      subscribe: () => () => {},
     },
     provide: (d: { resolve: (typeof providers)[number] }) => { providers.push(d.resolve); return () => {} },
     scopeOf: () => SID,
@@ -254,7 +255,10 @@ describe('registrant load-order seam', () => {
       binding: () => undefined,
       scope: () => undefined,
       provideInfo: () => undefined,
-      maybeProvideInfo: () => ({ hooks: {}, props: {} }),
+      currentProvide: {
+        getSnapshot: () => ABSENT_INFO,
+        subscribe: () => () => {},
+      },
       provide: () => () => {},
       create: vi.fn(),
       open: vi.fn(),

+ 7 - 1
packages/client/ui-conversation/tests/selection-survival.spec.ts

@@ -11,6 +11,9 @@ import { createChatStore } from '../src/client/stores.ts'
 
 const sid = (s: string): SessionId => s as SessionId
 
+/** Identity-stable no-session bundle (uSES getSnapshot contract). */
+const ABSENT_INFO = { sessionId: undefined, hooks: {}, props: {} }
+
 interface Bench {
   slots: SlotsService
   chat: ReturnType<typeof createChatStore>
@@ -23,7 +26,10 @@ function bench(): Bench {
       ids: [], byId: {}, current: undefined, phase: 'ready',
     }),
     provideInfo: () => undefined,
-    maybeProvideInfo: () => ({ hooks: {}, props: {} }),
+    currentProvide: {
+      getSnapshot: () => ABSENT_INFO,
+      subscribe: () => () => {},
+    },
     provide: () => () => {},
   })
   ctx.provide('workspaces', {

+ 6 - 10
packages/client/ui-slots/src/renderer.ts

@@ -105,18 +105,14 @@ export interface SlotRendererHost {
   sessions: {
     /** Session list source backing the useSessions standard hook. */
     list: HostObservable<unknown>
-    /** Current-session source used by SessionProvider. */
-    current: HostObservable<string | undefined>
-    /** Resolve a definite session bundle, or undefined when the id is unknown. */
-    provideInfo(id: string): SessionProvideInfo | undefined
     /**
-     * Resolve the current-session-optional standard props bundle. The result
-     * always carries the static provider roster, even when `id` is absent or
-     * cannot resolve to a live session.
-     * @param id - current session id, when selected.
-     * @returns the optional provide info.
+     * Atomic current-session provide projection used by SessionProvider:
+     * selection changes and provider-roster changes publish through this one
+     * source, so a stable current id cannot strand mounted entries on an
+     * obsolete hook/prop schema. Carries the static roster with sessionId
+     * undefined while no current session resolves.
      */
-    maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo
+    provide: HostObservable<SessionMaybeProvideInfo>
   }
   /** Workspace-side standard-kit sources. */
   workspaces: {

+ 10 - 10
packages/client/web-react/src/session-provider.tsx

@@ -90,9 +90,9 @@ function useAbsentSnapshot<S>(_selector: (snapshot: never) => S, _equal?: (a: S,
  */
 export function SessionMaybeProvider({ children }: { children: ReactNode }) {
   const host = useHost()
-  const id = observableHook(host.sessions.current)(s => s)
+  const info = observableHook(host.sessions.provide)(s => s)
   return (
-    <BindingContext.Provider value={host.sessions.maybeProvideInfo(id)}>
+    <BindingContext.Provider value={info}>
       {children}
     </BindingContext.Provider>
   )
@@ -107,17 +107,17 @@ export interface SessionProviderProps {
 }
 
 /**
- * Framework-wired session area: subscribes to the host's current-session
- * source, resolves the session cell, and remounts the body under
- * `key={sessionId}` so a session switch rebuilds the session subtree. This
- * dependency-inverted layer uses plain string ids; `PropsRuntime` applies the
- * branded type at the component boundary.
+ * Framework-wired session area: subscribes to the host's current provide
+ * source and remounts the body under `key={sessionId}` so a session switch
+ * rebuilds the session subtree. This dependency-inverted layer uses plain
+ * string ids; `PropsRuntime` applies the branded type at the component
+ * boundary.
  */
 export function SessionProvider({ empty, children }: SessionProviderProps) {
   const host = useHost()
-  const id = observableHook(host.sessions.current)(s => s)
-  const info = id === undefined ? undefined : host.sessions.provideInfo(id)
-  if (id === undefined || info === undefined) return <>{empty?.() ?? null}</>
+  const info = observableHook(host.sessions.provide)(s => s)
+  const id = info.sessionId
+  if (id === undefined) return <>{empty?.() ?? null}</>
   return (
     <BindingContext.Provider value={info} key={id}>
       {children(id)}

+ 2 - 3
packages/client/web-react/tests/scoped-slots-real-core.spec.tsx

@@ -26,6 +26,7 @@ type FrameSlots = PropsRenderSlots<'spec.single' | 'spec.list'>
 
 /** Passthrough host over the real core (store/session seats unused here). */
 function hostOver(core: SlotCore): SlotRendererHost {
+  const absentInfo = { sessionId: undefined, hooks: {}, props: {} }
   return {
     subscribe: (key, fn) => core.subscribe(key, fn),
     getVersion: key => core.getVersion(key),
@@ -35,9 +36,7 @@ function hostOver(core: SlotCore): SlotRendererHost {
     storeOf: () => undefined,
     sessions: {
       list: { getSnapshot: () => ({}), subscribe: () => () => {} },
-      current: { getSnapshot: () => undefined, subscribe: () => () => {} },
-      provideInfo: () => undefined,
-      maybeProvideInfo: () => ({ sessionId: undefined, hooks: {}, props: {} }),
+      provide: { getSnapshot: () => absentInfo, subscribe: () => () => {} },
     },
     workspaces: {
       list: { getSnapshot: () => ({}), subscribe: () => () => {} },

+ 14 - 6
packages/client/web-react/tests/scoped-slots.spec.tsx

@@ -12,6 +12,7 @@ import { describe, expect, it, vi } from 'vitest'
 import { act, fireEvent, render } from '@testing-library/react'
 import { useEffect, type ReactNode } from 'react'
 import type { ActionsDecl, SlotEntryDef, SlotSpec, StoreHandle, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
+import type { SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
 import {
   createSlotRenderer, SessionProvider, SlotOwnershipError, StaleAuthorizationError,
   type RenderOpts, type SessionProvideInfo,
@@ -85,7 +86,9 @@ function makeHost() {
   const storeCache = new Map<StoredEntry, Map<string, StoreInstanceLike>>()
   const list = observable<{ ids: string[] }>({ ids: [] })
   const workspaces = observable<{ ids: string[] }>({ ids: [] })
-  const current = observable<string | undefined>(undefined)
+  const absentInfo: SessionMaybeProvideInfo = { sessionId: undefined, hooks: {}, props: {} }
+  const provide = observable<SessionMaybeProvideInfo>(absentInfo)
+  let currentId: string | undefined
   const infos = new Map<string, SessionProvideInfo>()
 
   const bump = (key: string) => {
@@ -123,10 +126,7 @@ function makeHost() {
     },
     sessions: {
       list,
-      current,
-      provideInfo: id => infos.get(id),
-      maybeProvideInfo: id => (id === undefined ? undefined : infos.get(id))
-        ?? { sessionId: undefined, hooks: {}, props: {} },
+      provide,
     },
     workspaces: { list: workspaces },
   }
@@ -134,7 +134,14 @@ function makeHost() {
     host,
     list,
     workspaces,
-    current,
+    // Same driver surface as the old current cell: set(id) publishes the
+    // resolved bundle (or the absent projection) through the provide source.
+    current: {
+      set: (id: string | undefined) => {
+        currentId = id
+        provide.set((id === undefined ? undefined : infos.get(id)) ?? absentInfo)
+      },
+    },
     declare: (key: string, spec: DeclaredSpec) => { specs.set(key, spec); bump(key) },
     add: (key: string, partial: Omit<StoredEntry, 'options'> & { options?: StoredEntry['options'] }) => {
       const entry = entryOf(partial)
@@ -161,6 +168,7 @@ function makeHost() {
         props: {},
       }
       infos.set(id, info)
+      if (currentId === id) provide.set(info)
       return info
     },
   }

+ 42 - 8
packages/client/web-react/tests/session-provider.spec.tsx

@@ -9,7 +9,7 @@
 import { useEffect, useRef } from 'react'
 import { describe, expect, it, vi } from 'vitest'
 import { act, render } from '@testing-library/react'
-import type { StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
+import type { SessionMaybeProvideInfo, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
 import {
   createSlotRenderer, SessionProvider,
   type SessionProvideInfo, type SlotRendererHost,
@@ -26,12 +26,14 @@ function observable<T>(initial: T) {
 }
 
 /**
- * Minimal host: SessionProvider only reads sessions.current/cell, but it must
+ * Minimal host: SessionProvider only reads sessions.provide, but it must
  * render inside the renderer tree (HostContext), so the harness mounts a real
  * root entry whose body is the test's render-prop provider.
  */
 function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.ReactNode) => React.ReactNode }) {
-  const current = observable<string | undefined>(undefined)
+  const absentInfo: SessionMaybeProvideInfo = { sessionId: undefined, hooks: { session: undefined }, props: {} }
+  const provide = observable<SessionMaybeProvideInfo>(absentInfo)
+  let currentId: string | undefined
   const infos = new Map<string, SessionProvideInfo>()
   const sessionEntries: StoredEntry[] = []
   const rootEntry: StoredEntry = {
@@ -49,16 +51,20 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea
     storeOf: () => undefined,
     sessions: {
       list: observable<unknown>({ ids: [] }),
-      current,
-      provideInfo: id => infos.get(id),
-      maybeProvideInfo: id => (id === undefined ? undefined : infos.get(id))
-        ?? { sessionId: undefined, hooks: { session: undefined }, props: {} },
+      provide,
     },
     workspaces: { list: observable<unknown>({ items: [] }) },
   }
   return {
     host,
-    current,
+    // Same driver surface as the old current cell: set(id) publishes the
+    // resolved bundle (or the absent projection) through the provide source.
+    current: {
+      set: (id: string | undefined) => {
+        currentId = id
+        provide.set((id === undefined ? undefined : infos.get(id)) ?? absentInfo)
+      },
+    },
     addSession: (id: string) => {
       // Bare source per bundle (identity-stable): the machinery binds useSession from it.
       const info: SessionProvideInfo = {
@@ -67,8 +73,14 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea
         props: {},
       }
       infos.set(id, info)
+      if (currentId === id) provide.set(info)
       return info
     },
+    /** Swap one session's bundle in place (roster-change stand-in); republish when current. */
+    replaceSession: (info: SessionProvideInfo) => {
+      infos.set(info.sessionId, info)
+      if (currentId === info.sessionId) provide.set(info)
+    },
     registerSession: (entry: StoredEntry) => { sessionEntries.push(entry) },
   }
 }
@@ -149,6 +161,28 @@ describe('SessionProvider', () => {
     expect(seen.at(-1)!['sessionId']).toBe('s2')
   })
 
+  it('republishes a mounted session entry when its provide bundle changes under the same id', () => {
+    const seen: unknown[] = []
+    const h = makeHost({
+      root: renderSlot => <SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>,
+    })
+    const original = h.addSession('s1')
+    h.registerSession({
+      component: (props: { feature?: string }) => {
+        seen.push(props.feature)
+        return null
+      },
+      options: {},
+    })
+    render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
+    act(() => { h.current.set('s1') })
+    expect(seen.at(-1)).toBeUndefined()
+    // A provider-roster change rematerializes the bundle; the provide source
+    // must carry it to already-mounted entries without a selection change.
+    act(() => { h.replaceSession({ ...original, props: { feature: 'now-live' } }) })
+    expect(seen.at(-1)).toBe('now-live')
+  })
+
   it('fails loud when mounted outside the renderer tree (no host channel)', () => {
     const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
     expect(() => render(

+ 2 - 3
packages/client/web-react/tests/stale-authorization.spec.tsx

@@ -21,6 +21,7 @@ function makeHost() {
   const versions = new Map<string, number>()
   const subs = new Map<string, Set<() => void>>()
   const live = new Set<StoredEntry>()
+  const absentInfo = { sessionId: undefined, hooks: {}, props: {} }
   const bump = (key: string) => {
     versions.set(key, (versions.get(key) ?? 0) + 1)
     for (const fn of [...(subs.get(key) ?? [])]) fn()
@@ -39,9 +40,7 @@ function makeHost() {
     storeOf: () => undefined,
     sessions: {
       list: { getSnapshot: () => ({}), subscribe: () => () => {} },
-      current: { getSnapshot: () => undefined, subscribe: () => () => {} },
-      provideInfo: () => undefined,
-      maybeProvideInfo: () => ({ sessionId: undefined, hooks: {}, props: {} }),
+      provide: { getSnapshot: () => absentInfo, subscribe: () => () => {} },
     },
     workspaces: {
       list: { getSnapshot: () => ({}), subscribe: () => () => {} },