Files
deepseek-harness/packages/client/web-react/src/session-provider.tsx
imccyu 90addbf53c feat(gui): useProjection — the fifth framework hook seat through the standard kit
React half of the session-projection client base: the renderer contract gains
an open-key projections face on SessionMaybeProvideInfo (cellOf(key), distinct
from the static hooks roster), web-react mints projectionHook (per-bundle cache;
per-cell uSES binding via the shared observableHook cache; unresolved keys read
undefined through the absent source so hook order stays constant), standardKit
delivers kit.useProjection, and the runtime merges UseProjection into
SessionStandardProps/SessionMaybeStandardProps (overloads mirror useSession).
3 jsdom specs (kit delivery + live re-render, selector over undefined, faceless
bundle = all absent); existing direct-prop-feed specs gain the one-line stub the
new required seat mandates.
2026-07-27 22:58:53 +08:00

160 lines
6.6 KiB
TypeScript

/** Internal React bindings for the renderer host and active session provide bundle. */
import { createContext, useContext, type ReactNode } from 'react'
import type {
HostObservable, MaybeSnapshotSelectorHook, SessionMaybeProvideInfo, SessionProvideInfo,
SlotRendererHost, SnapshotSelectorHook,
} from '@deepseek-ai/dsh-client-ui-slots'
import { bindSnapshotSelector } from './bind.ts'
/**
* A missing-provider assembly error: the shell wired the tree wrong. The slot
* error boundary rethrows this class so misassembly stays fail-loud while
* registrant errors (inject factories, entry components) are contained
* per entry.
*/
export class SlotAssemblyError extends Error {}
/** In-package renderer host context. */
export const HostContext = createContext<SlotRendererHost | null>(null)
/**
* Read the installed renderer host; throws outside the rendered root tree
* (framework components must not render detached from the renderer).
* @returns the host surface.
*/
export function useHost(): SlotRendererHost {
const host = useContext(HostContext)
if (!host) throw new SlotAssemblyError('slot machinery rendered outside the installed renderer tree')
return host
}
const BindingContext = createContext<SessionMaybeProvideInfo | null>(null)
/** Read the current-session-optional bundle supplied at the root. */
export function useSessionMaybeProvideInfo(): SessionMaybeProvideInfo {
const info = useContext(BindingContext)
if (!info) throw new SlotAssemblyError('session-aware slot rendered outside the root binding provider')
return info
}
/**
* Read the enclosing session provide bundle; throws outside a SessionProvider
* subtree (session slots must not render without a session).
* @returns the enclosing bundle.
*/
export function useSessionProvideInfo(): SessionProvideInfo {
const info = useSessionMaybeProvideInfo()
if (info.sessionId === undefined) throw new SlotAssemblyError('strict session slot rendered without a session')
return info as SessionProvideInfo
}
/**
* Identity-stable selector hook per host observable. uSES resubscribes when
* the subscribe reference changes, so the bound hook must be created once per
* source — cached here by source identity (sources are host-owned singletons).
* @param source - host-provided observable.
* @returns the cached selector hook.
*/
export function observableHook<T>(source: HostObservable<T>): SnapshotSelectorHook<T> {
let hook = hookCache.get(source)
if (hook === undefined) {
hook = bindSnapshotSelector(source)
hookCache.set(source, hook)
}
return hook as SnapshotSelectorHook<T>
}
const hookCache = new WeakMap<object, unknown>()
const absentSource: HostObservable<undefined> = {
getSnapshot: () => undefined,
subscribe: () => () => {},
}
/** Bind a source that disappears with the current session to an optional selector hook. */
export function maybeObservableHook<T>(source: HostObservable<T> | undefined): MaybeSnapshotSelectorHook<T> {
if (source !== undefined) return observableHook(source)
return useAbsentSnapshot
}
function useAbsentSnapshot<S>(_selector: (snapshot: never) => S, _equal?: (a: S, b: S) => boolean): S | undefined {
// The uSES subscription must still run (hook-order stability); the absent
// source always snapshots undefined, returned explicitly.
observableHook(absentSource)(() => undefined)
return undefined
}
/**
* The useProjection framework seat (session-projection RFC), one bound
* function per provide bundle (cached by info identity — components may hold
* it across renders). Key-addressed: the key resolves a per-session cell
* source, whose bound selector hook comes from the same per-source cache as
* every other kit hook, so exactly one uSES subscription runs per call and
* the subscribe reference stays stable while the cell lives. An unresolved
* key (no cell, no session, plugin unloaded) reads `undefined` — capability
* absence — through the absent source, keeping the hook order constant.
*/
export function projectionHook(info: SessionMaybeProvideInfo): (
key: string, selector?: (value: unknown) => unknown, eq?: (a: unknown, b: unknown) => boolean
) => unknown {
let hook = projectionHookCache.get(info)
if (hook === undefined) {
hook = (key, selector, eq) => {
const cell = info.projections?.cellOf(key)
// The absent branch binds the shared absent source so the caller's
// selector still runs over `undefined` (absence flows through the
// selector) and the uSES call count stays constant across resolution.
const useCell = observableHook(cell ?? absentSource)
// Whole values are frozen event/wire data (identical reference between
// events), so the identity selector needs no equality function.
return useCell(selector ?? (value => value), eq)
}
projectionHookCache.set(info, hook)
}
return hook
}
const projectionHookCache = new WeakMap<SessionMaybeProvideInfo, (
key: string, selector?: (value: unknown) => unknown, eq?: (a: unknown, b: unknown) => boolean
) => unknown>()
/**
* Root-level binding provider. It follows current selection without a key, so
* session-maybe entries retain their React identity while the context value
* moves between absent and definite session bundles.
*/
export function SessionMaybeProvider({ children }: { children: ReactNode }) {
const host = useHost()
const id = observableHook(host.sessions.current)(s => s)
return (
<BindingContext.Provider value={host.sessions.maybeProvideInfo(id)}>
{children}
</BindingContext.Provider>
)
}
/** SessionProvider surface: render-prop body plus the no-session branch. */
export interface SessionProviderProps {
/** No-session body (also covers a current id whose session cannot be resolved). */
empty?: (() => ReactNode) | undefined
/** Session body; remounted per session via key={sessionId}. */
children: (sessionId: string) => ReactNode
}
/**
* 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.
*/
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}</>
return (
<BindingContext.Provider value={info} key={id}>
{children(id)}
</BindingContext.Provider>
)
}