feat: slash system / input service / agent scope
This commit is contained in:
@@ -12,7 +12,7 @@ export { bindSnapshotSelector } from './bind.ts'
|
||||
export type UseSession<Snap extends object = object> = SnapshotSelectorHook<Snap>
|
||||
|
||||
export type {
|
||||
ChainRenderOpts, HostObservable, RenderOpts, SessionCell, SnapshotSelectorHook,
|
||||
ChainRenderOpts, HostObservable, RenderOpts, SessionProvideInfo, SnapshotSelectorHook,
|
||||
SlotRenderer, SlotRendererHost, StoreInstanceLike,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
export { SlotOwnershipError, StaleAuthorizationError } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
@@ -5,11 +5,12 @@
|
||||
import { Component, useSyncExternalStore, type FC, type ReactNode } from 'react'
|
||||
import {
|
||||
SlotOwnershipError, StaleAuthorizationError,
|
||||
type ChainRenderOpts, type RenderOpts, type SessionCell, type SlotRenderer,
|
||||
type SlotRendererHost, type StoredEntry,
|
||||
type ChainRenderOpts, type RenderOpts, type SessionMaybeProvideInfo, type SessionProvideInfo,
|
||||
type SlotRenderer, type SlotRendererHost, type SlotScope, type StoredEntry,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
HostContext, SessionProvider, SlotAssemblyError, observableHook, useHost, useSessionCell,
|
||||
HostContext, SessionMaybeProvider, SessionProvider, SlotAssemblyError, maybeObservableHook,
|
||||
observableHook, useHost, useSessionMaybeProvideInfo,
|
||||
} from './session-provider.tsx'
|
||||
|
||||
type InjectedProps = Record<string, unknown>
|
||||
@@ -79,20 +80,21 @@ function boundRenderSlotChain(host: SlotRendererHost, entry: StoredEntry): Rende
|
||||
|
||||
/**
|
||||
* Inject results cache: root entries per entry, session entries per
|
||||
* (entry x session cell). WeakMap keys are entry/cell objects (both
|
||||
* (entry x provide bundle). WeakMap keys are entry/info objects (both
|
||||
* identity-stable per registration/session scope), so cache lifetime rides
|
||||
* the same axes as the values it memoizes.
|
||||
*/
|
||||
const rootInjectCache = new WeakMap<StoredEntry, InjectedProps>()
|
||||
const sessionInjectCache = new WeakMap<StoredEntry, WeakMap<SessionCell, InjectedProps>>()
|
||||
const sessionInjectCache = new WeakMap<StoredEntry, WeakMap<SessionProvideInfo, InjectedProps>>()
|
||||
const sessionMaybeInjectCache = new WeakMap<StoredEntry, WeakMap<SessionMaybeProvideInfo, InjectedProps>>()
|
||||
|
||||
function runInject(entry: StoredEntry, cell: SessionCell | undefined, actions: object | undefined): InjectedProps {
|
||||
function runInject(entry: StoredEntry, info: SessionMaybeProvideInfo | undefined, actions: object | undefined): InjectedProps {
|
||||
const inject = entry.inject
|
||||
if (!inject) return {}
|
||||
// Declaration-derived positional arguments: sessionId for session scope,
|
||||
// baked actions when a store is declared.
|
||||
const args: unknown[] = []
|
||||
if (cell !== undefined) args.push(cell.sessionId)
|
||||
if (info !== undefined) args.push(info.sessionId)
|
||||
if (actions !== undefined) args.push(actions)
|
||||
return (inject as (...args: unknown[]) => InjectedProps)(...args)
|
||||
}
|
||||
@@ -106,16 +108,34 @@ function cachedRootInject(entry: StoredEntry, actions: object | undefined): Inje
|
||||
return props
|
||||
}
|
||||
|
||||
function cachedSessionInject(entry: StoredEntry, cell: SessionCell, actions: object | undefined): InjectedProps {
|
||||
let perCell = sessionInjectCache.get(entry)
|
||||
if (!perCell) {
|
||||
perCell = new WeakMap()
|
||||
sessionInjectCache.set(entry, perCell)
|
||||
function cachedSessionInject(entry: StoredEntry, info: SessionProvideInfo, actions: object | undefined): InjectedProps {
|
||||
let perInfo = sessionInjectCache.get(entry)
|
||||
if (!perInfo) {
|
||||
perInfo = new WeakMap()
|
||||
sessionInjectCache.set(entry, perInfo)
|
||||
}
|
||||
let props = perCell.get(cell)
|
||||
let props = perInfo.get(info)
|
||||
if (!props) {
|
||||
props = runInject(entry, cell, actions)
|
||||
perCell.set(cell, props)
|
||||
props = runInject(entry, info, actions)
|
||||
perInfo.set(info, props)
|
||||
}
|
||||
return props
|
||||
}
|
||||
|
||||
function cachedSessionMaybeInject(
|
||||
entry: StoredEntry,
|
||||
info: SessionMaybeProvideInfo,
|
||||
actions: object | undefined,
|
||||
): InjectedProps {
|
||||
let perInfo = sessionMaybeInjectCache.get(entry)
|
||||
if (!perInfo) {
|
||||
perInfo = new WeakMap()
|
||||
sessionMaybeInjectCache.set(entry, perInfo)
|
||||
}
|
||||
let props = perInfo.get(info)
|
||||
if (!props) {
|
||||
props = runInject(entry, info, actions)
|
||||
perInfo.set(info, props)
|
||||
}
|
||||
return props
|
||||
}
|
||||
@@ -164,25 +184,44 @@ class SlotErrorBoundary extends Component<
|
||||
|
||||
/**
|
||||
* Standard-kit synthesis shared by both scope branches: the global
|
||||
* useSessions/useWorkspaces hooks, the session pair, the store pair when declared, the
|
||||
* renderSlot binding when children are declared, and the SessionProvider
|
||||
* seat when the children declare a session-scope slot. Hosts hand out BARE
|
||||
* observable sources (hooks never cross the host contract); every hook is
|
||||
* bound HERE, cached per source (observableHook), so spreading a fresh kit
|
||||
* object per render never churns child subscriptions.
|
||||
* useSessions/useWorkspaces hooks, the per-session provide bundle (every
|
||||
* `hooks` source becomes a `use<Name>` selector hook — useSession is the
|
||||
* runtime's own 'session' contribution, no special case — and `props` spread
|
||||
* verbatim), the store pair when declared, the renderSlot binding when
|
||||
* children are declared, and the SessionProvider seat when the children
|
||||
* declare a session-scope slot. Hosts hand out BARE observable sources
|
||||
* (hooks never cross the host contract); every hook is bound HERE, cached
|
||||
* per source (observableHook), so spreading a fresh kit object per render
|
||||
* never churns child subscriptions.
|
||||
*/
|
||||
function standardKit(host: SlotRendererHost, entry: StoredEntry, cell: SessionCell | undefined): {
|
||||
function standardKit(
|
||||
host: SlotRendererHost,
|
||||
entry: StoredEntry,
|
||||
scope: SlotScope,
|
||||
info: SessionMaybeProvideInfo | undefined,
|
||||
): {
|
||||
kit: InjectedProps; actions: object | undefined
|
||||
} {
|
||||
const kit: InjectedProps = {
|
||||
useSessions: observableHook(host.sessions.list),
|
||||
useWorkspaces: observableHook(host.workspaces.list),
|
||||
}
|
||||
if (cell !== undefined) {
|
||||
kit['useSession'] = observableHook(cell.session)
|
||||
kit['sessionId'] = cell.sessionId
|
||||
if (scope !== 'root' && info !== undefined) {
|
||||
for (const [name, source] of Object.entries(info.hooks)) {
|
||||
const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}`
|
||||
if (scope === 'session-maybe') {
|
||||
kit[hookName] = maybeObservableHook(source)
|
||||
} else {
|
||||
if (source === undefined) throw new SlotAssemblyError(`strict session hook '${name}' has no source`)
|
||||
kit[hookName] = observableHook(source)
|
||||
}
|
||||
}
|
||||
Object.assign(kit, info.props)
|
||||
kit['sessionId'] = info.sessionId
|
||||
}
|
||||
const store = host.storeOf(entry, cell?.sessionId)
|
||||
const store = scope === 'session-maybe' && info?.sessionId === undefined
|
||||
? undefined
|
||||
: host.storeOf(entry, info?.sessionId)
|
||||
if (store !== undefined) {
|
||||
// The instance IS an observable snapshot source (contract getSnapshot/
|
||||
// subscribe); the useStore hook binds here, cached per instance.
|
||||
@@ -213,25 +252,47 @@ function standardKit(host: SlotRendererHost, entry: StoredEntry, cell: SessionCe
|
||||
* through a props-widened view of the component (the design-budgeted
|
||||
* composition point, one per scope branch).
|
||||
*/
|
||||
function SessionEntry({ entry, ownerProps }: { entry: StoredEntry; ownerProps: object }) {
|
||||
function SessionEntry({ entry, ownerProps, info }: {
|
||||
entry: StoredEntry; ownerProps: object; info: SessionProvideInfo
|
||||
}) {
|
||||
const host = useHost()
|
||||
const cell = useSessionCell()
|
||||
const Comp = entry.component as FC<InjectedProps>
|
||||
const { kit, actions } = standardKit(host, entry, cell)
|
||||
const injected = cachedSessionInject(entry, cell, actions)
|
||||
const { kit, actions } = standardKit(host, entry, 'session', info)
|
||||
const injected = cachedSessionInject(entry, info, actions)
|
||||
return <Comp {...kit} {...injected} {...ownerProps} />
|
||||
}
|
||||
|
||||
function SessionMaybeEntry({ entry, ownerProps }: { entry: StoredEntry; ownerProps: object }) {
|
||||
const host = useHost()
|
||||
const info = useSessionMaybeProvideInfo()
|
||||
const Comp = entry.component as FC<InjectedProps>
|
||||
const { kit, actions } = standardKit(host, entry, 'session-maybe', info)
|
||||
const injected = cachedSessionMaybeInject(entry, info, actions)
|
||||
return <Comp {...kit} {...injected} {...ownerProps} />
|
||||
}
|
||||
|
||||
function RootEntry({ entry, ownerProps }: { entry: StoredEntry; ownerProps: object }) {
|
||||
const host = useHost()
|
||||
const Comp = entry.component as FC<InjectedProps>
|
||||
const { kit, actions } = standardKit(host, entry, undefined)
|
||||
const { kit, actions } = standardKit(host, entry, 'root', undefined)
|
||||
const injected = cachedRootInject(entry, actions)
|
||||
return <Comp {...kit} {...injected} {...ownerProps} />
|
||||
}
|
||||
|
||||
function StrictSessionEntry({ slotKey, entry, ownerProps }: {
|
||||
slotKey: string; entry: StoredEntry; ownerProps: object
|
||||
}) {
|
||||
const info = useSessionMaybeProvideInfo()
|
||||
if (info.sessionId === undefined) return null
|
||||
return (
|
||||
<SlotErrorBoundary slotKey={slotKey} key={info.sessionId}>
|
||||
<SessionEntry entry={entry} ownerProps={ownerProps} info={info as SessionProvideInfo} />
|
||||
</SlotErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
function SlotOutlet({ slotKey, ownerProps, opts }: {
|
||||
slotKey: string; ownerProps: object; opts?: RenderOpts | undefined
|
||||
slotKey: string; ownerProps: object; opts?: (RenderOpts & ChainRenderOpts) | undefined
|
||||
}) {
|
||||
const host = useHost()
|
||||
// Version tick drives entries() re-read; the host batches per microtask.
|
||||
@@ -239,21 +300,33 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
|
||||
(fn) => host.subscribe(slotKey, fn),
|
||||
() => host.getVersion(slotKey),
|
||||
)
|
||||
const sessionInfo = useSessionMaybeProvideInfo()
|
||||
const spec = host.specOf(slotKey)
|
||||
// Undeclared (or no-longer-declared) keys render empty: a declaring entry's
|
||||
// unload returns the slot to the undeclared state while retained elements
|
||||
// may still be mounted — natural empty, not an ownership failure (§9).
|
||||
if (!spec) return null
|
||||
const entries = host.entriesOf(slotKey)
|
||||
const Entry = spec.scope === 'session' ? SessionEntry : RootEntry
|
||||
const strictSessionAbsent = spec.scope === 'session' && sessionInfo.sessionId === undefined
|
||||
if (strictSessionAbsent && (spec.kind !== 'chain' || !opts?.overlay)) {
|
||||
return <>{opts?.fallback ?? null}</>
|
||||
}
|
||||
// An absent strict overlay chain follows its ordinary empty-election path,
|
||||
// preserving the Fragment/fallback-wrapper shape across session arrival.
|
||||
const entries = strictSessionAbsent ? [] : host.entriesOf(slotKey)
|
||||
|
||||
// The boundary must wrap the Entry ELEMENT, not live inside it: inject
|
||||
// factories and kit synthesis run in the Entry body and must land in the
|
||||
// per-entry fallback rather than escaping to the tree above.
|
||||
const guarded = (entry: StoredEntry, key?: string | number, owner: object = ownerProps) => (
|
||||
<SlotErrorBoundary slotKey={slotKey} key={key}>
|
||||
<Entry entry={entry} ownerProps={owner} />
|
||||
</SlotErrorBoundary>
|
||||
spec.scope === 'session'
|
||||
? <StrictSessionEntry slotKey={slotKey} entry={entry} ownerProps={owner} key={key} />
|
||||
: (
|
||||
<SlotErrorBoundary slotKey={slotKey} key={key}>
|
||||
{spec.scope === 'session-maybe'
|
||||
? <SessionMaybeEntry entry={entry} ownerProps={owner} />
|
||||
: <RootEntry entry={entry} ownerProps={owner} />}
|
||||
</SlotErrorBoundary>
|
||||
)
|
||||
)
|
||||
|
||||
if (spec.kind === 'single') {
|
||||
@@ -272,6 +345,7 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
|
||||
// functions of the owner props (register-face contract), so the routing
|
||||
// pass runs per render with zero mount side effects: the first non-null
|
||||
// election renders, decliners never mount.
|
||||
let elected: ReactNode = null
|
||||
for (const entry of entries) {
|
||||
let matched: unknown
|
||||
try {
|
||||
@@ -288,9 +362,30 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
|
||||
error)
|
||||
continue
|
||||
}
|
||||
if (matched !== null) return guarded(entry, entryKeyOf(entry), { ...ownerProps, matched })
|
||||
if (matched !== null) {
|
||||
elected = guarded(entry, entryKeyOf(entry), { ...ownerProps, matched })
|
||||
break
|
||||
}
|
||||
}
|
||||
return <>{opts?.fallback ?? null}</>
|
||||
if (opts?.overlay) {
|
||||
// Overlay chain (ChainRenderOpts.overlay): the fallback stays mounted
|
||||
// through elections — hidden via inline display:none (decisive over any
|
||||
// author CSS), shown via display:contents so the wrapper never affects
|
||||
// the owner's layout. The wrapper's tree position is constant, so React
|
||||
// reconciles instead of remounting and fallback state survives takeover.
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
data-chain-overlay-fallback={slotKey}
|
||||
style={{ display: elected === null ? 'contents' : 'none' }}
|
||||
>
|
||||
{opts.fallback ?? null}
|
||||
</div>
|
||||
{elected}
|
||||
</>
|
||||
)
|
||||
}
|
||||
return elected ?? <>{opts?.fallback ?? null}</>
|
||||
}
|
||||
// list: registration order refined by explicit order, optional id filter.
|
||||
const withListOptions = entries.map((entry) => ({
|
||||
@@ -331,7 +426,9 @@ export function createSlotRenderer(): SlotRenderer {
|
||||
renderRoot(host, ownerProps) {
|
||||
return (
|
||||
<HostContext.Provider value={host}>
|
||||
<RootOutlet ownerProps={ownerProps} />
|
||||
<SessionMaybeProvider>
|
||||
<RootOutlet ownerProps={ownerProps} />
|
||||
</SessionMaybeProvider>
|
||||
</HostContext.Provider>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/** Internal React bindings for the renderer host and active session cell. */
|
||||
/** Internal React bindings for the renderer host and active session provide bundle. */
|
||||
import { createContext, useContext, type ReactNode } from 'react'
|
||||
import type {
|
||||
HostObservable, SessionCell, SlotRendererHost, SnapshotSelectorHook,
|
||||
HostObservable, MaybeSnapshotSelectorHook, SessionMaybeProvideInfo, SessionProvideInfo,
|
||||
SlotRendererHost, SnapshotSelectorHook,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { bindSnapshotSelector } from './bind.ts'
|
||||
|
||||
@@ -27,17 +28,24 @@ export function useHost(): SlotRendererHost {
|
||||
return host
|
||||
}
|
||||
|
||||
const BindingContext = createContext<SessionCell | null>(null)
|
||||
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 cell; throws outside a SessionProvider subtree
|
||||
* (session slots must not render without a session).
|
||||
* @returns the enclosing cell.
|
||||
* 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 useSessionCell(): SessionCell {
|
||||
const cell = useContext(BindingContext)
|
||||
if (!cell) throw new SlotAssemblyError('session slot rendered outside SessionProvider')
|
||||
return cell
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,6 +65,36 @@ export function observableHook<T>(source: HostObservable<T>): SnapshotSelectorHo
|
||||
}
|
||||
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 as MaybeSnapshotSelectorHook<T>
|
||||
}
|
||||
|
||||
function useAbsentSnapshot<S>(_selector: (snapshot: never) => S, _equal?: (a: S, b: S) => boolean): S | undefined {
|
||||
return observableHook(absentSource)(() => undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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). */
|
||||
@@ -75,10 +113,10 @@ export interface SessionProviderProps {
|
||||
export function SessionProvider({ empty, children }: SessionProviderProps) {
|
||||
const host = useHost()
|
||||
const id = observableHook(host.sessions.current)((s) => s)
|
||||
const cell = id === undefined ? undefined : host.sessions.cell(id)
|
||||
if (id === undefined || cell === undefined) return <>{empty?.() ?? null}</>
|
||||
const info = id === undefined ? undefined : host.sessions.provideInfo(id)
|
||||
if (id === undefined || info === undefined) return <>{empty?.() ?? null}</>
|
||||
return (
|
||||
<BindingContext.Provider value={cell} key={id}>
|
||||
<BindingContext.Provider value={info} key={id}>
|
||||
{children(id)}
|
||||
</BindingContext.Provider>
|
||||
)
|
||||
|
||||
@@ -36,7 +36,8 @@ function hostOver(core: SlotCore): SlotRendererHost {
|
||||
sessions: {
|
||||
list: { getSnapshot: () => ({}), subscribe: () => () => {} },
|
||||
current: { getSnapshot: () => undefined, subscribe: () => () => {} },
|
||||
cell: () => undefined,
|
||||
provideInfo: () => undefined,
|
||||
maybeProvideInfo: () => ({ sessionId: undefined, hooks: {}, props: {} }),
|
||||
},
|
||||
workspaces: {
|
||||
list: { getSnapshot: () => ({}), subscribe: () => () => {} },
|
||||
|
||||
@@ -9,18 +9,18 @@
|
||||
* SlotsService suite, not here.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { act, render } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
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 {
|
||||
createSlotRenderer, SessionProvider, SlotOwnershipError, StaleAuthorizationError,
|
||||
type RenderOpts, type SessionCell,
|
||||
type RenderOpts, type SessionProvideInfo,
|
||||
type SlotRendererHost, type StoreInstanceLike,
|
||||
} from '@deepseek-ai/dsh-client-web-react'
|
||||
|
||||
type AnyProps = Record<string, unknown>
|
||||
type RenderSlotFn = (key: string, owner: object, opts?: RenderOpts) => ReactNode
|
||||
type RenderSlotChainFn = (key: string, owner: object, opts?: { fallback?: ReactNode }) => ReactNode
|
||||
type RenderSlotChainFn = (key: string, owner: object, opts?: { fallback?: ReactNode; overlay?: boolean }) => ReactNode
|
||||
type DeclaredSpec = SlotSpec<SlotEntryDef>
|
||||
/** Entry literal helper: fake entries default the mandatory options bag. */
|
||||
const entryOf = (partial: Omit<StoredEntry, 'options'> & { options?: StoredEntry['options'] }): StoredEntry =>
|
||||
@@ -83,7 +83,7 @@ function makeHost() {
|
||||
const list = observable<{ ids: string[] }>({ ids: [] })
|
||||
const workspaces = observable<{ ids: string[] }>({ ids: [] })
|
||||
const current = observable<string | undefined>(undefined)
|
||||
const cells = new Map<string, SessionCell>()
|
||||
const infos = new Map<string, SessionProvideInfo>()
|
||||
|
||||
const bump = (key: string) => {
|
||||
versions.set(key, (versions.get(key) ?? 0) + 1)
|
||||
@@ -121,7 +121,9 @@ function makeHost() {
|
||||
sessions: {
|
||||
list,
|
||||
current,
|
||||
cell: (id) => cells.get(id),
|
||||
provideInfo: (id) => infos.get(id),
|
||||
maybeProvideInfo: (id) => (id === undefined ? undefined : infos.get(id))
|
||||
?? { sessionId: undefined, hooks: {}, props: {} },
|
||||
},
|
||||
workspaces: { list: workspaces },
|
||||
}
|
||||
@@ -148,14 +150,15 @@ function makeHost() {
|
||||
bump(key)
|
||||
}
|
||||
},
|
||||
addSession: (id: string): SessionCell => {
|
||||
// Bare source per cell (identity-stable): the machinery binds useSession from it.
|
||||
const cell: SessionCell = {
|
||||
addSession: (id: string): SessionProvideInfo => {
|
||||
// Bare source per bundle (identity-stable): the machinery binds useSession from it.
|
||||
const info: SessionProvideInfo = {
|
||||
sessionId: id,
|
||||
session: { getSnapshot: () => ({ sid: id }), subscribe: () => () => {} },
|
||||
hooks: { session: { getSnapshot: () => ({ sid: id }), subscribe: () => () => {} } },
|
||||
props: {},
|
||||
}
|
||||
cells.set(id, cell)
|
||||
return cell
|
||||
infos.set(id, info)
|
||||
return info
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -472,6 +475,103 @@ describe('chain outlets and the renderSlotChain binding', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('overlay chains (ChainRenderOpts.overlay)', () => {
|
||||
/** Fallback probe: counts mounts and holds uncontrolled DOM state (the
|
||||
* composer-draft stand-in an unmount would wipe). */
|
||||
function fallbackProbe(onMount: () => void) {
|
||||
return function Probe() {
|
||||
useEffect(onMount, [])
|
||||
return <input aria-label="probe" defaultValue="" />
|
||||
}
|
||||
}
|
||||
|
||||
it('keeps the fallback mounted and state-holding through a takeover, hidden then restored', () => {
|
||||
const h = makeHost()
|
||||
h.declare('k.chain', CHAIN_ROOT)
|
||||
h.add('k.chain', chainEntryOf({
|
||||
component: () => <b>TAKEOVER</b>,
|
||||
select: (owner) => (owner as { take?: boolean }).take ? {} : null,
|
||||
}))
|
||||
const mounted = vi.fn()
|
||||
const Probe = fallbackProbe(mounted)
|
||||
let take = false
|
||||
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
|
||||
(renderSlotChain) => renderSlotChain('k.chain', { take }, { fallback: <Probe />, overlay: true }))
|
||||
const wrapper = () => view.container.querySelector<HTMLElement>('[data-chain-overlay-fallback="k.chain"]')!
|
||||
const input = () => view.container.querySelector<HTMLInputElement>('input[aria-label="probe"]')!
|
||||
|
||||
// Resident phase: fallback visible through the layout-neutral wrapper.
|
||||
expect(wrapper().style.display).toBe('contents')
|
||||
fireEvent.change(input(), { target: { value: 'draft-in-flight' } })
|
||||
|
||||
// Election: entry overlays, fallback hides in place — same DOM node, no remount.
|
||||
take = true
|
||||
act(() => { h.add('root', { component: () => null }) }) // root bump re-renders the dispatch site
|
||||
expect(view.container.textContent).toContain('TAKEOVER')
|
||||
expect(wrapper().style.display).toBe('none')
|
||||
expect(input().value).toBe('draft-in-flight')
|
||||
|
||||
// Takeover ends: fallback shows again with its state intact, still the original mount.
|
||||
take = false
|
||||
act(() => { h.add('root', { component: () => null }) })
|
||||
expect(view.container.textContent).not.toContain('TAKEOVER')
|
||||
expect(wrapper().style.display).toBe('contents')
|
||||
expect(input().value).toBe('draft-in-flight')
|
||||
expect(mounted).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('leaves non-overlay chains on the unmount path: a takeover discards fallback state', () => {
|
||||
const h = makeHost()
|
||||
h.declare('k.chain', CHAIN_ROOT)
|
||||
h.add('k.chain', chainEntryOf({
|
||||
component: () => <b>TAKEOVER</b>,
|
||||
select: (owner) => (owner as { take?: boolean }).take ? {} : null,
|
||||
}))
|
||||
const mounted = vi.fn()
|
||||
const Probe = fallbackProbe(mounted)
|
||||
let take = false
|
||||
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
|
||||
(renderSlotChain) => renderSlotChain('k.chain', { take }, { fallback: <Probe /> }))
|
||||
fireEvent.change(view.container.querySelector('input[aria-label="probe"]')!, { target: { value: 'gone' } })
|
||||
expect(view.container.querySelector('[data-chain-overlay-fallback]')).toBeNull()
|
||||
|
||||
take = true
|
||||
act(() => { h.add('root', { component: () => null }) })
|
||||
expect(view.container.querySelector('input[aria-label="probe"]')).toBeNull() // unmounted
|
||||
|
||||
take = false
|
||||
act(() => { h.add('root', { component: () => null }) })
|
||||
const remounted = view.container.querySelector<HTMLInputElement>('input[aria-label="probe"]')!
|
||||
expect(remounted.value).toBe('') // fresh mount, state discarded
|
||||
expect(mounted).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps election semantics under overlay: priority order, selector-crash decline, live dispose back to fallback', () => {
|
||||
const h = makeHost()
|
||||
h.declare('k.chain', CHAIN_ROOT)
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
h.add('k.chain', chainEntryOf({
|
||||
component: () => <span>never</span>,
|
||||
select: () => { throw new Error('selector boom') },
|
||||
priority: 1,
|
||||
}))
|
||||
const dispose = h.add('k.chain', chainEntryOf({
|
||||
component: () => <b>ELECTED</b>,
|
||||
select: () => ({}),
|
||||
priority: 2,
|
||||
}))
|
||||
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
|
||||
(renderSlotChain) => renderSlotChain('k.chain', {}, { fallback: <i>resident</i>, overlay: true }))
|
||||
expect(view.container.textContent).toContain('ELECTED')
|
||||
expect(spy.mock.calls.some(([msg]) => String(msg).includes('chain selector crashed'))).toBe(true)
|
||||
spy.mockRestore()
|
||||
act(() => { dispose() })
|
||||
const wrapper = view.container.querySelector<HTMLElement>('[data-chain-overlay-fallback="k.chain"]')!
|
||||
expect(wrapper.style.display).toBe('contents')
|
||||
expect(view.container.textContent).toBe('resident')
|
||||
})
|
||||
})
|
||||
|
||||
describe('standard-kit synthesis', () => {
|
||||
it('delivers a live useSessions hook to every slot component', () => {
|
||||
const h = makeHost()
|
||||
|
||||
@@ -12,7 +12,7 @@ import { act, render } from '@testing-library/react'
|
||||
import type { StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
createSlotRenderer, SessionProvider,
|
||||
type SessionCell, type SlotRendererHost,
|
||||
type SessionProvideInfo, type SlotRendererHost,
|
||||
} from '@deepseek-ai/dsh-client-web-react'
|
||||
|
||||
function observable<T>(initial: T) {
|
||||
@@ -32,7 +32,7 @@ function observable<T>(initial: T) {
|
||||
*/
|
||||
function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.ReactNode) => React.ReactNode }) {
|
||||
const current = observable<string | undefined>(undefined)
|
||||
const cells = new Map<string, SessionCell>()
|
||||
const infos = new Map<string, SessionProvideInfo>()
|
||||
const sessionEntries: StoredEntry[] = []
|
||||
const rootEntry: StoredEntry = {
|
||||
component: (props: { renderSlot: (key: string, owner: object) => React.ReactNode }) =>
|
||||
@@ -50,7 +50,9 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea
|
||||
sessions: {
|
||||
list: observable<unknown>({ ids: [] }),
|
||||
current,
|
||||
cell: (id) => cells.get(id),
|
||||
provideInfo: (id) => infos.get(id),
|
||||
maybeProvideInfo: (id) => (id === undefined ? undefined : infos.get(id))
|
||||
?? { sessionId: undefined, hooks: { session: undefined }, props: {} },
|
||||
},
|
||||
workspaces: { list: observable<unknown>({ items: [] }) },
|
||||
}
|
||||
@@ -58,13 +60,14 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea
|
||||
host,
|
||||
current,
|
||||
addSession: (id: string) => {
|
||||
// Bare source per cell (identity-stable): the machinery binds useSession from it.
|
||||
const cell: SessionCell = {
|
||||
// Bare source per bundle (identity-stable): the machinery binds useSession from it.
|
||||
const info: SessionProvideInfo = {
|
||||
sessionId: id,
|
||||
session: { getSnapshot: () => ({ sid: id }), subscribe: () => () => {} },
|
||||
hooks: { session: { getSnapshot: () => ({ sid: id }), subscribe: () => () => {} } },
|
||||
props: {},
|
||||
}
|
||||
cells.set(id, cell)
|
||||
return cell
|
||||
infos.set(id, info)
|
||||
return info
|
||||
},
|
||||
registerSession: (entry: StoredEntry) => { sessionEntries.push(entry) },
|
||||
}
|
||||
|
||||
@@ -40,7 +40,8 @@ function makeHost() {
|
||||
sessions: {
|
||||
list: { getSnapshot: () => ({}), subscribe: () => () => {} },
|
||||
current: { getSnapshot: () => undefined, subscribe: () => () => {} },
|
||||
cell: () => undefined,
|
||||
provideInfo: () => undefined,
|
||||
maybeProvideInfo: () => ({ sessionId: undefined, hooks: {}, props: {} }),
|
||||
},
|
||||
workspaces: {
|
||||
list: { getSnapshot: () => ({}), subscribe: () => () => {} },
|
||||
|
||||
Reference in New Issue
Block a user