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.
This commit is contained in:
imccyu
2026-07-27 22:58:53 +08:00
parent fbebe1757a
commit 90addbf53c
14 files changed
+196 -1

No files matched your search

@@ -10,7 +10,7 @@ import {
} from '@deepseek-ai/dsh-client-ui-slots'
import {
HostContext, SessionMaybeProvider, SessionProvider, SlotAssemblyError, maybeObservableHook,
observableHook, useHost, useSessionMaybeProvideInfo,
observableHook, projectionHook, useHost, useSessionMaybeProvideInfo,
} from './session-provider.tsx'
type InjectedProps = Record<string, unknown>
@@ -219,6 +219,9 @@ function standardKit(
}
Object.assign(kit, info.props)
kit['sessionId'] = info.sessionId
// The useProjection seat (fifth framework hook): key-addressed cell
// reader, bound per provide bundle (cached by info identity).
kit['useProjection'] = projectionHook(info)
}
const store = scope === 'session-maybe' && info?.sessionId === undefined
? undefined
@@ -83,6 +83,39 @@ function useAbsentSnapshot<S>(_selector: (snapshot: never) => S, _equal?: (a: S,
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