mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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:
@@ -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
|
||||
|
||||
125
packages/client/web-react/tests/use-projection.spec.tsx
Normal file
125
packages/client/web-react/tests/use-projection.spec.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* useProjection standard-kit delivery (session-projection RFC): the fifth
|
||||
* framework hook seat rides the same provide channel as useSession — a
|
||||
* session slot component receives `useProjection` in its kit, key-addressed
|
||||
* over the bundle's projection face; unresolved keys (no cell, no face, no
|
||||
* session) uniformly read `undefined`; live cell changes re-render; the
|
||||
* selector overload runs over the whole value.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { act, render } from '@testing-library/react'
|
||||
import type { StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { createSlotRenderer, type SlotRendererHost } from '@deepseek-ai/dsh-client-web-react'
|
||||
|
||||
function observable<T>(initial: T) {
|
||||
let value = initial
|
||||
const subs = new Set<() => void>()
|
||||
return {
|
||||
getSnapshot: () => value,
|
||||
subscribe: (fn: () => void) => { subs.add(fn); return () => { subs.delete(fn) } },
|
||||
set: (next: T) => { value = next; for (const fn of [...subs]) fn() },
|
||||
}
|
||||
}
|
||||
|
||||
type UseProjectionProp = (key: string, selector?: (v: unknown) => unknown) => unknown
|
||||
|
||||
function makeHost() {
|
||||
const current = observable<string | undefined>(undefined)
|
||||
const cells = new Map<string, ReturnType<typeof observable<unknown>>>()
|
||||
const sessionEntries: StoredEntry[] = []
|
||||
let withFace = true
|
||||
const rootEntry: StoredEntry = {
|
||||
component: (props: { renderSlot: (key: string, owner: object) => React.ReactNode }) =>
|
||||
<>{props.renderSlot('k.session', {})}</>,
|
||||
options: {},
|
||||
children: { 'k.session': { kind: 'single', scope: 'session' } },
|
||||
}
|
||||
const info = (id: string) => ({
|
||||
sessionId: id,
|
||||
hooks: { session: { getSnapshot: () => ({ sid: id }), subscribe: () => () => {} } },
|
||||
props: {},
|
||||
...(withFace ? { projections: { cellOf: (key: string) => cells.get(key) } } : {}),
|
||||
})
|
||||
const host: SlotRendererHost = {
|
||||
subscribe: () => () => {},
|
||||
getVersion: () => 0,
|
||||
entriesOf: (key) => key === 'root' ? [rootEntry] : sessionEntries,
|
||||
specOf: (key) => key === 'k.session' ? { kind: 'single', scope: 'session' } : undefined,
|
||||
isLive: () => true,
|
||||
storeOf: () => undefined,
|
||||
sessions: {
|
||||
list: observable<unknown>({ ids: [] }),
|
||||
current,
|
||||
provideInfo: (id) => info(id),
|
||||
maybeProvideInfo: (id) => (id === undefined
|
||||
? { sessionId: undefined, hooks: { session: undefined }, props: {} }
|
||||
: info(id)),
|
||||
},
|
||||
workspaces: { list: observable<unknown>({ items: [] }) },
|
||||
}
|
||||
return {
|
||||
host, current, cells,
|
||||
dropFace: () => { withFace = false },
|
||||
registerSession: (entry: StoredEntry) => { sessionEntries.push(entry) },
|
||||
}
|
||||
}
|
||||
|
||||
describe('useProjection standard-kit delivery', () => {
|
||||
it('reads the cell value through the kit, undefined for unresolved keys, and follows live changes', () => {
|
||||
const h = makeHost()
|
||||
const cell = observable<unknown>({ marks: ['a'] })
|
||||
h.cells.set('test/marks', cell)
|
||||
const reads: Record<string, unknown>[] = []
|
||||
h.registerSession({
|
||||
component: (props: { useProjection: UseProjectionProp }) => {
|
||||
reads.push({
|
||||
marks: props.useProjection('test/marks'),
|
||||
ghost: props.useProjection('test/ghost'),
|
||||
})
|
||||
return null
|
||||
},
|
||||
options: {},
|
||||
})
|
||||
render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
|
||||
act(() => { h.current.set('s1') })
|
||||
expect(reads.at(-1)).toEqual({ marks: { marks: ['a'] }, ghost: undefined })
|
||||
// Live change re-renders with the new whole value.
|
||||
act(() => { cell.set({ marks: ['a', 'b'] }) })
|
||||
expect(reads.at(-1)).toEqual({ marks: { marks: ['a', 'b'] }, ghost: undefined })
|
||||
})
|
||||
|
||||
it('runs the selector overload over the whole value (and over undefined when absent)', () => {
|
||||
const h = makeHost()
|
||||
h.cells.set('test/marks', observable<unknown>({ marks: ['x', 'y'] }))
|
||||
const reads: unknown[] = []
|
||||
h.registerSession({
|
||||
component: (props: { useProjection: UseProjectionProp }) => {
|
||||
reads.push(props.useProjection('test/marks', v => (v as { marks: string[] } | undefined)?.marks.length ?? -1))
|
||||
reads.push(props.useProjection('test/ghost', v => (v === undefined ? 'absent' : 'present')))
|
||||
return null
|
||||
},
|
||||
options: {},
|
||||
})
|
||||
render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
|
||||
act(() => { h.current.set('s1') })
|
||||
expect(reads.slice(-2)).toEqual([2, 'absent'])
|
||||
})
|
||||
|
||||
it('treats a bundle without the projections face as all-absent (capability absence)', () => {
|
||||
const h = makeHost()
|
||||
h.cells.set('test/marks', observable<unknown>({ marks: ['a'] }))
|
||||
h.dropFace()
|
||||
const reads: unknown[] = []
|
||||
h.registerSession({
|
||||
component: (props: { useProjection: UseProjectionProp }) => {
|
||||
reads.push(props.useProjection('test/marks'))
|
||||
return null
|
||||
},
|
||||
options: {},
|
||||
})
|
||||
render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
|
||||
act(() => { h.current.set('s1') })
|
||||
expect(reads.at(-1)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user