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

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

View File

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

View File

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

View File

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

View File

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