From a0b618abb96ae619858bbb2f852fc924bc4fda44 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:53:50 +0800 Subject: [PATCH] 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. --- .../runtime/src/client/sessions/service.ts | 49 ++++++++++++++--- packages/client/runtime/src/client/slots.ts | 11 +--- .../runtime/tests/sessions-service.spec.ts | 54 +++++++++++++++++++ .../runtime/tests/slots-service.spec.ts | 17 ++---- .../tests/apply-inject.spec.tsx | 3 +- .../ui-conversation/tests/chat-apply.spec.tsx | 3 +- .../tests/chat-code-subcalls.spec.tsx | 10 ++-- .../tests/chat-toolview-slot.spec.tsx | 46 ++++++++-------- .../tests/selection-survival.spec.ts | 8 ++- packages/client/ui-slots/src/renderer.ts | 16 +++--- .../client/web-react/src/session-provider.tsx | 20 +++---- .../tests/scoped-slots-real-core.spec.tsx | 5 +- .../web-react/tests/scoped-slots.spec.tsx | 20 ++++--- .../web-react/tests/session-provider.spec.tsx | 50 ++++++++++++++--- .../tests/stale-authorization.spec.tsx | 5 +- 15 files changed, 222 insertions(+), 95 deletions(-) diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index ec8ddc2354..03eca7724f 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -151,6 +151,13 @@ export class SessionsService { readonly list: SnapshotStore /** 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 /** * 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. */ diff --git a/packages/client/runtime/src/client/slots.ts b/packages/client/runtime/src/client/slots.ts index 74af31502a..ed10826b9d 100644 --- a/packages/client/runtime/src/client/slots.ts +++ b/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 }, } diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index 44ab4ffb4f..2f90c1c301 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/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' }]) diff --git a/packages/client/runtime/tests/slots-service.spec.ts b/packages/client/runtime/tests/slots-service.spec.ts index 97bcb50f0a..b7d6f31093 100644 --- a/packages/client/runtime/tests/slots-service.spec.ts +++ b/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 () => { diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 504d0ec8c6..37b824c906 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/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), diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index 36a25c5b34..818a6bf620 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/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(), diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index aa9451b413..d7f523b028 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/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; props: Record } = { hooks: {}, props: {} } + // Identity-stable currentProvide snapshot (uSES getSnapshot contract), + // materialized on first render after the provide contributions landed. + let infoCell: { sessionId: SessionId; hooks: Record; props: Record } | 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(), } diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index 87e188bbbd..117a7108c9 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/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 = { session } + const props: Record = {} + 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 = { session } - const props: Record = {} - 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(), diff --git a/packages/client/ui-conversation/tests/selection-survival.spec.ts b/packages/client/ui-conversation/tests/selection-survival.spec.ts index ec50a3f317..19988eeab9 100644 --- a/packages/client/ui-conversation/tests/selection-survival.spec.ts +++ b/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 @@ -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', { diff --git a/packages/client/ui-slots/src/renderer.ts b/packages/client/ui-slots/src/renderer.ts index 5b7de0d6f1..09143ca84e 100644 --- a/packages/client/ui-slots/src/renderer.ts +++ b/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 - /** Current-session source used by SessionProvider. */ - current: HostObservable - /** 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 } /** Workspace-side standard-kit sources. */ workspaces: { diff --git a/packages/client/web-react/src/session-provider.tsx b/packages/client/web-react/src/session-provider.tsx index 79cb763a3e..61212e57a3 100644 --- a/packages/client/web-react/src/session-provider.tsx +++ b/packages/client/web-react/src/session-provider.tsx @@ -90,9 +90,9 @@ function useAbsentSnapshot(_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 ( - + {children} ) @@ -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 ( {children(id)} diff --git a/packages/client/web-react/tests/scoped-slots-real-core.spec.tsx b/packages/client/web-react/tests/scoped-slots-real-core.spec.tsx index 381170527a..09d38c187f 100644 --- a/packages/client/web-react/tests/scoped-slots-real-core.spec.tsx +++ b/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: () => () => {} }, diff --git a/packages/client/web-react/tests/scoped-slots.spec.tsx b/packages/client/web-react/tests/scoped-slots.spec.tsx index 51b12d2385..dba09810f6 100644 --- a/packages/client/web-react/tests/scoped-slots.spec.tsx +++ b/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>() const list = observable<{ ids: string[] }>({ ids: [] }) const workspaces = observable<{ ids: string[] }>({ ids: [] }) - const current = observable(undefined) + const absentInfo: SessionMaybeProvideInfo = { sessionId: undefined, hooks: {}, props: {} } + const provide = observable(absentInfo) + let currentId: string | undefined const infos = new Map() 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 & { 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 }, } diff --git a/packages/client/web-react/tests/session-provider.spec.tsx b/packages/client/web-react/tests/session-provider.spec.tsx index 2e055bcee2..1dacdcec53 100644 --- a/packages/client/web-react/tests/session-provider.spec.tsx +++ b/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(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(undefined) + const absentInfo: SessionMaybeProvideInfo = { sessionId: undefined, hooks: { session: undefined }, props: {} } + const provide = observable(absentInfo) + let currentId: string | undefined const infos = new Map() 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({ 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({ 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 => {() => renderSlot('k.session', {})}, + }) + 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( diff --git a/packages/client/web-react/tests/stale-authorization.spec.tsx b/packages/client/web-react/tests/stale-authorization.spec.tsx index 6c3e5d194b..f0fa07fd44 100644 --- a/packages/client/web-react/tests/stale-authorization.spec.tsx +++ b/packages/client/web-react/tests/stale-authorization.spec.tsx @@ -21,6 +21,7 @@ function makeHost() { const versions = new Map() const subs = new Map void>>() const live = new Set() + 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: () => () => {} },