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:
@@ -7,6 +7,7 @@ import { SessionsService } from './sessions/service.ts'
|
|||||||
import type { SessionListState } from './sessions/service.ts'
|
import type { SessionListState } from './sessions/service.ts'
|
||||||
import { WorkspacesService } from './workspaces/service.ts'
|
import { WorkspacesService } from './workspaces/service.ts'
|
||||||
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.ts'
|
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.ts'
|
||||||
|
import type { UseProjection } from './sessions/projection-cell.ts'
|
||||||
|
|
||||||
export { SlotsService } from './slots.ts'
|
export { SlotsService } from './slots.ts'
|
||||||
export type { RootOwnerProps } from './slots.ts'
|
export type { RootOwnerProps } from './slots.ts'
|
||||||
@@ -34,6 +35,12 @@ export type {
|
|||||||
} from './sessions/conversation.ts'
|
} from './sessions/conversation.ts'
|
||||||
export { PendingWait } from './sessions/pending.ts'
|
export { PendingWait } from './sessions/pending.ts'
|
||||||
export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts'
|
export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts'
|
||||||
|
// Projection cells (session-projection RFC): domain plugins register cells at
|
||||||
|
// scope materialization via `binding.session.projections.register(spec)`.
|
||||||
|
export type {
|
||||||
|
ProjectionCell, ProjectionCellSet, ProjectionCellSpec, ProjectionSchemaLike, ProjectionsBaseline,
|
||||||
|
SessionProjectionMap, UseProjection,
|
||||||
|
} from './sessions/projection-cell.ts'
|
||||||
export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||||
|
|
||||||
/** Client-side Cordis context after declaration merging. */
|
/** Client-side Cordis context after declaration merging. */
|
||||||
@@ -59,12 +66,16 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|||||||
useSession: SnapshotSelectorHook<ConversationSnapshot>
|
useSession: SnapshotSelectorHook<ConversationSnapshot>
|
||||||
/** The framework-resolved session id (owners never pass it). */
|
/** The framework-resolved session id (owners never pass it). */
|
||||||
sessionId: SessionId
|
sessionId: SessionId
|
||||||
|
/** The fifth framework hook seat: key-addressed projection reader (undefined = capability absent). */
|
||||||
|
useProjection: UseProjection
|
||||||
}
|
}
|
||||||
/** Standard kit for slots that remain mounted while current session changes. */
|
/** Standard kit for slots that remain mounted while current session changes. */
|
||||||
interface SessionMaybeStandardProps {
|
interface SessionMaybeStandardProps {
|
||||||
useSession: MaybeSnapshotSelectorHook<ConversationSnapshot>
|
useSession: MaybeSnapshotSelectorHook<ConversationSnapshot>
|
||||||
/** Current session id; absent in the no-session state. */
|
/** Current session id; absent in the no-session state. */
|
||||||
sessionId: SessionId | undefined
|
sessionId: SessionId | undefined
|
||||||
|
/** Key-addressed projection reader; every key reads absent while no session is current. */
|
||||||
|
useProjection: UseProjection
|
||||||
}
|
}
|
||||||
/** Props injected into every global slot component. */
|
/** Props injected into every global slot component. */
|
||||||
interface GlobalStandardProps {
|
interface GlobalStandardProps {
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
|||||||
useSession: bindSnapshotSelector(source),
|
useSession: bindSnapshotSelector(source),
|
||||||
useSessions: emptySessions(),
|
useSessions: emptySessions(),
|
||||||
useWorkspaces: emptyWorkspaces(),
|
useWorkspaces: emptyWorkspaces(),
|
||||||
|
useProjection: (() => undefined),
|
||||||
useInput: (() => { throw new Error('unused') }),
|
useInput: (() => { throw new Error('unused') }),
|
||||||
inputActions: { setDraft: () => {}, submit: () => {} },
|
inputActions: { setDraft: () => {}, submit: () => {} },
|
||||||
useStore: bindSnapshotSelector(chat),
|
useStore: bindSnapshotSelector(chat),
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ describe('render branch tails', () => {
|
|||||||
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
|
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
|
||||||
useSessions={bindSnapshotSelector(emptyList)}
|
useSessions={bindSnapshotSelector(emptyList)}
|
||||||
useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
|
useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
|
||||||
|
useProjection={(() => undefined)}
|
||||||
useInput={(() => { throw new Error('unused') })}
|
useInput={(() => { throw new Error('unused') })}
|
||||||
inputActions={{ setDraft: () => {}, submit: () => {} }}
|
inputActions={{ setDraft: () => {}, submit: () => {} }}
|
||||||
useStore={bindSnapshotSelector(chat)}
|
useStore={bindSnapshotSelector(chat)}
|
||||||
@@ -111,6 +112,7 @@ describe('render branch tails', () => {
|
|||||||
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
|
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
|
||||||
useSessions={bindSnapshotSelector(emptyList)}
|
useSessions={bindSnapshotSelector(emptyList)}
|
||||||
useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
|
useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
|
||||||
|
useProjection={(() => undefined)}
|
||||||
useInput={(() => { throw new Error('unused') })}
|
useInput={(() => { throw new Error('unused') })}
|
||||||
inputActions={{ setDraft: () => {}, submit: () => {} }}
|
inputActions={{ setDraft: () => {}, submit: () => {} }}
|
||||||
useStore={bindSnapshotSelector(chat)}
|
useStore={bindSnapshotSelector(chat)}
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ function bench(over?: BenchOptions) {
|
|||||||
items: [], state: 'idle', phase: 'ready', error: null,
|
items: [], state: 'idle', phase: 'ready', error: null,
|
||||||
baselinesReady: true, recentWorkspaceId: undefined,
|
baselinesReady: true, recentWorkspaceId: undefined,
|
||||||
})),
|
})),
|
||||||
|
useProjection: (() => undefined),
|
||||||
useInput: bindSnapshotSelector(shell.state),
|
useInput: bindSnapshotSelector(shell.state),
|
||||||
inputActions: shell.actions,
|
inputActions: shell.actions,
|
||||||
keyboard: shell,
|
keyboard: shell,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
|
|||||||
items: [], state: 'idle', phase: 'ready', error: null,
|
items: [], state: 'idle', phase: 'ready', error: null,
|
||||||
baselinesReady: true, recentWorkspaceId: undefined,
|
baselinesReady: true, recentWorkspaceId: undefined,
|
||||||
})),
|
})),
|
||||||
|
useProjection: (() => undefined),
|
||||||
useInput: bindSnapshotSelector(shell.state),
|
useInput: bindSnapshotSelector(shell.state),
|
||||||
inputActions: shell.actions,
|
inputActions: shell.actions,
|
||||||
keyboard: shell,
|
keyboard: shell,
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
|||||||
items: [], state: 'idle', phase: 'ready', error: null,
|
items: [], state: 'idle', phase: 'ready', error: null,
|
||||||
baselinesReady: true, recentWorkspaceId: undefined,
|
baselinesReady: true, recentWorkspaceId: undefined,
|
||||||
})),
|
})),
|
||||||
|
useProjection: (() => undefined),
|
||||||
useInput: bindSnapshotSelector(shell.state),
|
useInput: bindSnapshotSelector(shell.state),
|
||||||
inputActions: shell.actions,
|
inputActions: shell.actions,
|
||||||
keyboard: shell,
|
keyboard: shell,
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ function kitFor(snapshot: ConversationSnapshot) {
|
|||||||
sessionId: SID,
|
sessionId: SID,
|
||||||
useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
|
useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
|
||||||
useWorkspaces: (() => { throw new Error('unused') }) as never,
|
useWorkspaces: (() => { throw new Error('unused') }) as never,
|
||||||
|
useProjection: (() => undefined) as never,
|
||||||
useInput: (() => { throw new Error('unused') }) as never,
|
useInput: (() => { throw new Error('unused') }) as never,
|
||||||
inputActions: { setDraft: () => {}, submit: () => {} } as never,
|
inputActions: { setDraft: () => {}, submit: () => {} } as never,
|
||||||
session: snapshot,
|
session: snapshot,
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ function mount(
|
|||||||
useSession={useSession}
|
useSession={useSession}
|
||||||
useSessions={props.useSessions}
|
useSessions={props.useSessions}
|
||||||
useWorkspaces={props.useWorkspaces}
|
useWorkspaces={props.useWorkspaces}
|
||||||
|
useProjection={(() => undefined) as never}
|
||||||
useInput={useInput}
|
useInput={useInput}
|
||||||
inputActions={inputActions}
|
inputActions={inputActions}
|
||||||
useStore={bindSnapshotSelector(chat)}
|
useStore={bindSnapshotSelector(chat)}
|
||||||
@@ -115,6 +116,7 @@ function mount(
|
|||||||
useSession={useSession}
|
useSession={useSession}
|
||||||
useSessions={props.useSessions}
|
useSessions={props.useSessions}
|
||||||
useWorkspaces={props.useWorkspaces}
|
useWorkspaces={props.useWorkspaces}
|
||||||
|
useProjection={(() => undefined) as never}
|
||||||
useInput={useInput}
|
useInput={useInput}
|
||||||
inputActions={inputActions}
|
inputActions={inputActions}
|
||||||
keyboard={wiring}
|
keyboard={wiring}
|
||||||
@@ -133,6 +135,7 @@ function mount(
|
|||||||
useSession,
|
useSession,
|
||||||
useSessions: bindSnapshotSelector(sessions),
|
useSessions: bindSnapshotSelector(sessions),
|
||||||
useWorkspaces: bindSnapshotSelector(workspaces),
|
useWorkspaces: bindSnapshotSelector(workspaces),
|
||||||
|
useProjection: (() => undefined) as never,
|
||||||
useInput,
|
useInput,
|
||||||
inputActions,
|
inputActions,
|
||||||
renderSlot,
|
renderSlot,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const kit = {
|
|||||||
useSession: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<ConversationSnapshot>,
|
useSession: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<ConversationSnapshot>,
|
||||||
useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
|
useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
|
||||||
useWorkspaces: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<WorkspaceListState>,
|
useWorkspaces: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<WorkspaceListState>,
|
||||||
|
useProjection: (() => undefined) as never,
|
||||||
useInput: (() => { throw new Error('unused') }) as never,
|
useInput: (() => { throw new Error('unused') }) as never,
|
||||||
inputActions: { setDraft: () => { throw new Error('unused') }, submit: () => { throw new Error('unused') } } as never,
|
inputActions: { setDraft: () => { throw new Error('unused') }, submit: () => { throw new Error('unused') } } as never,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ export interface SessionMaybeProvideInfo {
|
|||||||
hooks: Record<string, HostObservable<unknown> | undefined>
|
hooks: Record<string, HostObservable<unknown> | undefined>
|
||||||
/** Static plain-member roster; values are undefined with the session. */
|
/** Static plain-member roster; values are undefined with the session. */
|
||||||
props: Record<string, unknown>
|
props: Record<string, unknown>
|
||||||
|
/**
|
||||||
|
* Key-addressed projection-cell sources (the useProjection framework seat,
|
||||||
|
* session-projection RFC). Unlike `hooks`, the key space is open — cells
|
||||||
|
* come and go with domain plugins — so the render side binds per resolved
|
||||||
|
* cell instead of per static roster member. Absent with the session; an
|
||||||
|
* unresolved key uniformly reads as capability absent.
|
||||||
|
*/
|
||||||
|
projections?: { cellOf(key: string): HostObservable<unknown> | undefined } | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Definite per-session standard props resolved for strict session slots. */
|
/** Definite per-session standard props resolved for strict session slots. */
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps {
|
|||||||
useSession: fakeSession(nodes).useSession,
|
useSession: fakeSession(nodes).useSession,
|
||||||
useSessions: emptySessions(),
|
useSessions: emptySessions(),
|
||||||
useWorkspaces: emptyWorkspaces(),
|
useWorkspaces: emptyWorkspaces(),
|
||||||
|
useProjection: (() => undefined) as never,
|
||||||
} as unknown as ConvViewProps
|
} as unknown as ConvViewProps
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +136,7 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
|
|||||||
useSession={useSession}
|
useSession={useSession}
|
||||||
useSessions={emptySessions()}
|
useSessions={emptySessions()}
|
||||||
useWorkspaces={emptyWorkspaces()}
|
useWorkspaces={emptyWorkspaces()}
|
||||||
|
useProjection={(() => undefined) as never}
|
||||||
useStore={bindSnapshotSelector(chat)}
|
useStore={bindSnapshotSelector(chat)}
|
||||||
actions={chat.actions}
|
actions={chat.actions}
|
||||||
renderSlot={renderSlot}
|
renderSlot={renderSlot}
|
||||||
@@ -330,6 +332,7 @@ describe('deriveSubSpans (waterfall lanes)', () => {
|
|||||||
useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot>,
|
useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot>,
|
||||||
useSessions: emptySessions(),
|
useSessions: emptySessions(),
|
||||||
useWorkspaces: emptyWorkspaces(),
|
useWorkspaces: emptyWorkspaces(),
|
||||||
|
useProjection: (() => undefined) as never,
|
||||||
} as unknown as ConvViewProps
|
} as unknown as ConvViewProps
|
||||||
const view = render(createElement(WaterfallView as FC<ConvViewProps>, props))
|
const view = render(createElement(WaterfallView as FC<ConvViewProps>, props))
|
||||||
const lane = view.container.querySelector('[data-subspan]')
|
const lane = view.container.querySelector('[data-subspan]')
|
||||||
@@ -356,6 +359,7 @@ describe('deriveSubSpans (waterfall lanes)', () => {
|
|||||||
useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot>,
|
useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot>,
|
||||||
useSessions: emptySessions(),
|
useSessions: emptySessions(),
|
||||||
useWorkspaces: emptyWorkspaces(),
|
useWorkspaces: emptyWorkspaces(),
|
||||||
|
useProjection: (() => undefined) as never,
|
||||||
} as unknown as ConvViewProps
|
} as unknown as ConvViewProps
|
||||||
const view = render(createElement(WaterfallView as FC<ConvViewProps>, props))
|
const view = render(createElement(WaterfallView as FC<ConvViewProps>, props))
|
||||||
const bar = view.container.querySelector('[data-timing="unknown"]')
|
const bar = view.container.querySelector('[data-timing="unknown"]')
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||||
import {
|
import {
|
||||||
HostContext, SessionMaybeProvider, SessionProvider, SlotAssemblyError, maybeObservableHook,
|
HostContext, SessionMaybeProvider, SessionProvider, SlotAssemblyError, maybeObservableHook,
|
||||||
observableHook, useHost, useSessionMaybeProvideInfo,
|
observableHook, projectionHook, useHost, useSessionMaybeProvideInfo,
|
||||||
} from './session-provider.tsx'
|
} from './session-provider.tsx'
|
||||||
|
|
||||||
type InjectedProps = Record<string, unknown>
|
type InjectedProps = Record<string, unknown>
|
||||||
@@ -219,6 +219,9 @@ function standardKit(
|
|||||||
}
|
}
|
||||||
Object.assign(kit, info.props)
|
Object.assign(kit, info.props)
|
||||||
kit['sessionId'] = info.sessionId
|
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
|
const store = scope === 'session-maybe' && info?.sessionId === undefined
|
||||||
? undefined
|
? undefined
|
||||||
|
|||||||
@@ -83,6 +83,39 @@ function useAbsentSnapshot<S>(_selector: (snapshot: never) => S, _equal?: (a: S,
|
|||||||
return 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
|
* Root-level binding provider. It follows current selection without a key, so
|
||||||
* session-maybe entries retain their React identity while the context value
|
* 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