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.
This commit is contained in:
imccyu
2026-07-28 02:53:50 +08:00
parent c4df0628db
commit a0b618abb9
15 changed files with 222 additions and 95 deletions

View File

@@ -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),

View File

@@ -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(),

View File

@@ -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(),
}

View File

@@ -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(),

View File

@@ -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', {