refactor(client): extract Store and renderer Slot infrastructure

This commit is contained in:
imccyu
2026-08-23 16:28:16 +08:00
parent 0ea9a456c0
commit 1b535f611c
36 changed files with 1464 additions and 858 deletions
+46
View File
@@ -0,0 +1,46 @@
{
"name": "@deepseek-ai/dsh-client-store",
"description": "React-free observable and snapshot-store contracts with the shared Zustand/Immer engine",
"version": "0.1.1-rc.2",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/client/store"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"license": "MIT",
"dependencies": {
"immer": "^10.1.1",
"zustand": "~4.4.7"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts"
]
}
+144
View File
@@ -0,0 +1,144 @@
/** Framework-neutral snapshot and store contracts. */
/** Minimal observable snapshot source shared by controllers, stores, and render adapters. */
export interface ObservableSnapshot<T> {
/** Read the cached snapshot reference. */
getSnapshot(): T
/**
* Subscribe to snapshot invalidation.
* @param fn - invalidation callback.
* @returns unsubscribe function.
*/
subscribe(fn: () => void): () => void
}
/**
* Typed selector hook over a snapshot source. Canonical shape for the whole
* slot system (ui-renderer's engine hook is structurally identical; the
* framework is the only party that ever constructs one).
*/
export type SnapshotSelectorHook<T> = <S>(sel: (s: T) => S, eq?: (a: S, b: S) => boolean) => S
/**
* Selector hook over a source that follows the current session. The hook is
* always present, while its selected value is absent whenever no session is
* current. This keeps hook call sites stable across no-session/session
* transitions without pretending that a session snapshot exists.
*/
export type MaybeSnapshotSelectorHook<T> =
<S>(sel: (s: T) => S, eq?: (a: S, b: S) => boolean) => S | undefined
/**
* Action declaration table: pure immer-draft transforms over the store state,
* declared as the store's complete write set (the audit face — components can
* only write through these).
*/
/* oxlint-disable-next-line typescript/no-explicit-any --
* any[] (not unknown[]): each action carries its own parameter list, and
* unknown[] would reject every concrete signature under strict parameter
* contravariance. Params are re-inferred per action by BakedActions. */
export type ActionsDecl<T> = Record<string, (draft: T, ...params: any[]) => void>
/**
* Draft-stripped callback form of an actions table: what components
* (`props.actions`) and inject factories receive — the framework bakes the
* draft parameter away by binding each action to the resolved instance.
*/
export type BakedActions<T, A extends ActionsDecl<T>> = {
[K in keyof A]: A[K] extends (draft: T, ...params: infer P) => void ? (...params: P) => void : never
}
/**
* Store declaration spec: initial-state factory (a lambda so every instance
* gets a fresh state), optional persistence key (mechanical, framework-run),
* and the actions write set.
*/
export interface StoreSpec<T, A extends ActionsDecl<T>> {
init: () => T
persist?: string
actions: A
}
/**
* Live engine instance: the create() product consumed by the render machinery
* and by tests. A bare snapshot source plus the baked write set — no React
* hook rides the engine product (the engine lives in this React-free package);
* the render machinery binds the `useStore` hook from this source on its own
* side, cached per instance. Production components and render paths never
* call create() themselves — instance lifecycle is the framework's.
*/
export interface StoreInstance<T, A extends ActionsDecl<T>> {
readonly actions: BakedActions<T, A>
getSnapshot(): T
/**
* Subscribe to state changes (uSES subscribe side).
* @param fn - change callback.
* @returns unsubscribe.
*/
subscribe(fn: () => void): () => void
/**
* Drop this instance's persisted value (no-op for non-persist specs). The
* framework calls it when the owning scope dies for good — a pruned session
* must not leave orphaned storage keys behind.
*/
clearPersisted(): void
}
/**
* Store handle: spec + state/actions types + shared identity + instance
* factory in one value. Handles are constructed in apply world (shared across
* registrations of one plugin) or by the framework from a registrant's
* factory (exclusive). Never export a handle at module level — module-cache
* identity is a disguised singleton across plugin reloads.
*/
export interface StoreHandle<T, A extends ActionsDecl<T>> {
readonly spec: StoreSpec<T, A>
/**
* Create a live engine instance (framework machinery and tests only).
* @param scopeKey - session id for session-scope instances; suffixes the
* persist key so per-session instances persist independently (root-scope
* instances omit it).
* @returns a fresh instance seeded from `spec.init()`.
*/
create(scopeKey?: string): StoreInstance<T, A>
}
/**
* Exclusive-store registration form: the registrant passes the factory itself
* and the framework calls it per entry x scope (no shared identity exists).
*/
/* oxlint-disable-next-line typescript/no-explicit-any --
* erased position accepting every StoreHandle instantiation; T/A are
* recovered per use site by conditional inference (HandleOf/BoundActions/
* PropsStore). */
export type StoreFactory = () => StoreHandle<any, any>
/** The register `store` option position: a shared handle or an exclusive factory. */
// oxlint-disable-next-line typescript/no-explicit-any -- same erased-constraint position as StoreFactory (see above).
export type StoreDecl = StoreHandle<any, any> | StoreFactory
/** Normalize a store declaration to its handle type (factories yield their return). */
export type HandleOf<H> = H extends () => infer R ? R : H
/**
* Handle-keyed baked actions: the `actions` parameter of an inject factory
* whose registration declared a store — the same baked callback set the
* component receives via {@link PropsStore}.
*/
export type BoundActions<H> = H extends StoreHandle<infer T, infer A> ? BakedActions<T, A> : never
/**
* The store props share, derived from the declared handle: a typed selector
* hook plus the baked write set. Components never see the instance itself
* (no update/set — reads via useStore, writes via the declared actions only).
*/
export type PropsStore<H> = H extends StoreHandle<infer T, infer A>
? { useStore: SnapshotSelectorHook<T>; actions: BakedActions<T, A> }
: object
/**
* The defineStore contract (implementation lives beside this declaration,
* bound to the snapshot-store engine): spec in, handle out, with T inferred
* from `init` and the actions table constrained by T.
*/
export type DefineStore = <T, A extends ActionsDecl<T>>(spec: StoreSpec<T, A>) => StoreHandle<T, A>
@@ -1,30 +1,27 @@
/**
* Snapshot store engine (zustand vanilla + immer + subscribeWithSelector +
* React-free snapshot store engine (zustand vanilla + immer + subscribeWithSelector +
* rafFlush middleware + opt-in persist + dev freeze) plus the declarative
* shell over it: {@link defineStore} bakes an init/persist/actions literal
* into a {@link StoreHandle}, the registration-side store seat of slot
* terminals. Lives in the React-free runtime (the data layer owns its
* engine; ui-renderer is shell-only React
* glue): engine products are bare observables subscribe/getSnapshot/
* terminals. Engine products are bare observables subscribe/getSnapshot/
* update/set, NO selector hook. Hook synthesis is ui-renderer's (the one
* uSES bridge, cached per source at the binding site).
*/
import { createStore, type StoreApi } from 'zustand/vanilla'
import { subscribeWithSelector } from 'zustand/middleware'
import { shallow } from 'zustand/shallow'
import { produce } from 'immer'
import { freeze, produce } from 'immer'
import type {
ActionsDecl, BakedActions, StoreHandle, StoreInstance, StoreSpec,
} from '@deepseek-ai/dsh-client-ui-slots'
ActionsDecl, BakedActions, ObservableSnapshot, StoreHandle, StoreInstance, StoreSpec,
} from './contract.ts'
// Store contract types are ui-slots authority; re-exported beside the engine
// so store consumers get one import path.
export type {
ActionsDecl, BakedActions, BoundActions, StoreFactory, StoreHandle, StoreInstance, StoreSpec,
} from '@deepseek-ai/dsh-client-ui-slots'
/** Minimal observable snapshot source: Session objects and snapshot stores both satisfy it. */
export interface ObservableSnapshot<T> { getSnapshot(): T; subscribe(fn: () => void): () => void }
ActionsDecl, BakedActions, BoundActions, DefineStore, HandleOf, MaybeSnapshotSelectorHook,
ObservableSnapshot, PropsStore, SnapshotSelectorHook, StoreDecl, StoreFactory,
StoreHandle, StoreInstance, StoreSpec,
} from './contract.ts'
/** Writable snapshot store (bare data face; React selector hooks are synthesized in ui-renderer). */
export interface SnapshotStore<T> extends ObservableSnapshot<T> {
@@ -40,6 +37,26 @@ export interface SnapshotStore<T> extends ObservableSnapshot<T> {
set(next: T): void
}
/**
* Notify an observer set without allowing one callback to starve the rest.
* @param listeners - current observer callbacks; copied before dispatch.
* @param label - diagnostic owner prefix.
* @param args - callback arguments.
*/
export function notifySubscribers<Args extends readonly unknown[]>(
listeners: Iterable<(...args: Args) => void>,
label: string,
...args: Args
): void {
for (const listener of [...listeners]) {
try {
listener(...args)
} catch (error) {
console.error(`${label} subscriber failed:`, error)
}
}
}
/**
* Shallow equality for selector slices (zustand/shallow semantics; travels
* with the engine so hook consumers need no zustand dependency).
@@ -91,10 +108,12 @@ export function createSnapshotStore<T>(
const api: StoreApi<T> = createStore<T>()(withSelector)
if (opts?.persist) attachPersistence(api, opts.persist.name)
let subscribe = (fn: () => void) => api.subscribe(fn)
let subscribe = (fn: () => void) => api.subscribe(() => {
notifySubscribers([fn], '[client-store]')
})
if (opts?.flush === 'raf') {
const listeners = new Set<() => void>()
const flush = rafBatch(() => { for (const fn of [...listeners]) fn() })
const flush = rafBatch(() => { notifySubscribers(listeners, '[client-store]') })
api.subscribe(flush)
subscribe = (fn: () => void) => {
listeners.add(fn)
@@ -146,19 +165,10 @@ function attachPersistence<T>(api: StoreApi<T>, name: string): void {
})
}
/** Deep-freeze wholesale-set state outside production: set() bypasses immer's freeze. */
/** Deep-freeze draftable wholesale-set state outside production: set() bypasses immer's freeze. */
function devFreeze<T>(value: T): T {
if (process.env.NODE_ENV === 'production') return value
deepFreeze(value)
return value
}
function deepFreeze(value: unknown): void {
if (typeof value !== 'object' || value === null || Object.isFrozen(value)) return
Object.freeze(value)
for (const key of Reflect.ownKeys(value)) {
deepFreeze((value as Record<PropertyKey, unknown>)[key])
}
return freeze(value, true)
}
// ui-slots owns the contract; this module supplies the engine implementation.
+30
View File
@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-store`.
* @module @deepseek-ai/dsh-client-store/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-store'
/** Cordis companion plugin name. */
export const name = 'client-store-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the package exports a library engine and creates no
* process-global state; each store instance is covered by its owning tests.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */
@@ -0,0 +1,13 @@
import { Context } from '@deepseek-ai/cordis'
import InvariantRegistry from '@deepseek-ai/dsh-invariants'
import { describe, expect, it } from 'vitest'
import * as StoreInvariant from '../src/invariant.ts'
describe('store invariant companion', () => {
it('registers the package-owned empty installer', async () => {
const ctx = new Context()
await ctx.plugin(InvariantRegistry, { enabled: true })
await expect(ctx.plugin(StoreInvariant).await()).resolves.toBeDefined()
})
})
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createSnapshotStore, defineStore, shallowEqual } from '../src/client/contract/store.ts'
import { createSnapshotStore, defineStore, shallowEqual } from '../src/index.ts'
interface State {
a: { n: number }
@@ -10,6 +10,8 @@ const init = (): State => ({ a: { n: 1 }, b: { list: ['x'] } })
afterEach(() => {
vi.unstubAllGlobals()
vi.unstubAllEnvs()
vi.restoreAllMocks()
})
describe('createSnapshotStore', () => {
@@ -90,12 +92,37 @@ describe('createSnapshotStore', () => {
expect(() => { (store.getSnapshot().a).n = 9 }).toThrow()
})
it('freezes the owned envelope without traversing opaque object handles', () => {
class OpaqueHandle {
readonly value = { n: 1 }
}
const handle = new OpaqueHandle()
const store = createSnapshotStore({ handle, values: new Set(['x']) })
store.set({ handle, values: new Set(['y']) })
const snapshot = store.getSnapshot()
expect(Object.isFrozen(snapshot)).toBe(true)
expect(Object.isFrozen(snapshot.handle)).toBe(false)
expect(() => { snapshot.values.add('z') }).toThrow()
})
it('freezes update produce output outside production (immer dev freeze)', () => {
const store = createSnapshotStore(init())
store.update((d) => { d.a.n = 2 })
expect(() => { (store.getSnapshot().a).n = 9 }).toThrow()
})
it('does not deep-freeze wholesale state in production', () => {
vi.stubEnv('NODE_ENV', 'production')
const store = createSnapshotStore(init())
const next = init()
store.set(next)
next.a.n = 9
expect(store.getSnapshot().a.n).toBe(9)
})
it('rehydrates primitive state whole, not spread into index keys', () => {
const backing = new Map<string, string>()
vi.stubGlobal('localStorage', {
@@ -122,6 +149,42 @@ describe('createSnapshotStore', () => {
const revived = createSnapshotStore(init(), { persist: { name: 'spec-store' } })
expect(revived.getSnapshot().a.n).toBe(42)
})
it('reports rehydration failures without preventing store creation', () => {
const failure = new Error('storage read failed')
vi.stubGlobal('localStorage', {
getItem: () => { throw failure },
setItem: () => {},
removeItem: () => {},
})
const report = vi.spyOn(console, 'error').mockImplementation(() => {})
const store = createSnapshotStore(init(), { persist: { name: 'spec-broken-read' } })
expect(store.getSnapshot()).toEqual(init())
expect(report).toHaveBeenCalledWith(
"snapshot store 'spec-broken-read' rehydration failed:",
failure,
)
})
it('reports persistence failures without rejecting the write', () => {
const failure = new Error('storage write failed')
vi.stubGlobal('localStorage', {
getItem: () => null,
setItem: () => { throw failure },
removeItem: () => {},
})
const report = vi.spyOn(console, 'error').mockImplementation(() => {})
const store = createSnapshotStore(init(), { persist: { name: 'spec-broken-write' } })
expect(() => { store.update((draft) => { draft.a.n = 7 }) }).not.toThrow()
expect(store.getSnapshot().a.n).toBe(7)
expect(report).toHaveBeenCalledWith(
"snapshot store 'spec-broken-write' persistence failed:",
failure,
)
})
})
describe('defineStore', () => {
@@ -136,12 +199,17 @@ describe('defineStore', () => {
it('create() yields a live instance: fresh init state, selector-visible action writes', () => {
const inst = declare().create()
expect(inst.store.getSnapshot()).toEqual({ selection: null, draft: '' })
expect(inst.getSnapshot()).toEqual({ selection: null, draft: '' })
const listener = vi.fn()
const unsubscribe = inst.subscribe(listener)
inst.actions.setDraft('hello')
inst.actions.select('m1')
expect(inst.store.getSnapshot()).toEqual({ selection: 'm1', draft: 'hello' })
expect(listener).toHaveBeenCalledTimes(2)
unsubscribe()
inst.actions.clearDraft()
expect(inst.store.getSnapshot().draft).toBe('')
expect(listener).toHaveBeenCalledTimes(2)
})
it('bakes draft-stripped actions that write through update (draft mutation, not replacement)', () => {
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../runtime-diagnostics/invariants"
}
]
}
+6
View File
@@ -0,0 +1,6 @@
import { staticLinked } from '../tsdown.client.ts'
export default staticLinked(
'@deepseek-ai/dsh-client-store',
['lib/types/index.js', 'lib/types/invariant.js'],
)
-5
View File
@@ -31,9 +31,6 @@
},
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-runtime"
],
"platform": "web",
"immediately": true
}
@@ -47,13 +44,11 @@
"use-sync-external-store": "1.2.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
+2 -20
View File
@@ -4,13 +4,10 @@
*/
import type { ReactNode } from 'react'
import type { Context } from '@deepseek-ai/cordis'
import { bindSnapshotSelector } from './bind.ts'
import { DocumentTitle } from './DocumentTitle.tsx'
import type {} from '@deepseek-ai/dsh-client-runtime/client'
/** Inputs available after the UI renderer's inject set activates. */
export interface AssemblyDeps {
/** Client context carrying the slots and sessions services. */
/** Client context carrying the renderer-owned Slot registry. */
ctx: Context
}
@@ -21,20 +18,5 @@ export interface AssemblyDeps {
*/
export function buildRenderApp(deps: AssemblyDeps): () => ReactNode {
const { ctx } = deps
const sessions = ctx.get('sessions')
if (sessions === undefined) throw new Error('ui renderer: sessions service unavailable')
const useSessions = bindSnapshotSelector(sessions.list)
const SessionDocumentTitle = (): ReactNode => {
const title = useSessions((state) => {
const id = state.current
return id === undefined ? undefined : state.byId[id]?.title
})
return <DocumentTitle {...title === undefined ? {} : { title }} />
}
return () => (
<>
<SessionDocumentTitle />
{ctx.slots.renderSlot('root', {})}
</>
)
return () => ctx.slots.renderSlot('root', {})
}
@@ -0,0 +1,143 @@
/** Internal React bindings for renderer hosts and standard-source scopes. */
import { createContext, useContext, type ReactNode } from 'react'
import type {
HostObservable,
KeyedStandardSource,
MaybeSnapshotSelectorHook,
SlotRendererHost,
SnapshotSelectorHook,
StandardSourceBinding,
} from '@deepseek-ai/dsh-client-ui-slots'
import { bindSnapshotSelector } from './bind.ts'
/** Missing renderer assembly dependency. */
export class SlotAssemblyError extends Error {}
/** In-package renderer host context. */
export const HostContext = createContext<SlotRendererHost | null>(null)
/**
* Read the installed renderer host.
* @returns the host API.
*/
export function useHost(): SlotRendererHost {
const host = useContext(HostContext)
if (host === null) throw new SlotAssemblyError('slot machinery rendered outside the installed renderer tree')
return host
}
const RootBindingContext = createContext<StandardSourceBinding | null>(null)
const ScopeBindingContext = createContext<StandardSourceBinding | null>(null)
/**
* Read the root standard-source binding.
* @returns the current root binding.
*/
export function useRootBinding(): StandardSourceBinding {
const binding = useContext(RootBindingContext)
if (binding === null) throw new SlotAssemblyError('slot rendered outside the root standard-source provider')
return binding
}
/**
* Read the current-session-optional binding.
* @returns a binding whose key is absent when no Session is selected.
*/
export function useScopeBinding(): StandardSourceBinding {
const binding = useContext(ScopeBindingContext)
if (binding === null) throw new SlotAssemblyError('scoped slot rendered outside its scope provider')
return binding
}
/**
* Bind one observable source to an identity-stable selector Hook.
* @param source - observable source.
* @returns 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 an optional source without changing Hook call order.
* @param source - current source, or absence.
* @returns selector Hook returning `undefined` while absent.
*/
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?: (left: S, right: S) => boolean,
): S | undefined {
observableHook(absentSource)(() => undefined)
return undefined
}
/** Erased open-key selector Hook synthesized from one keyed source family. */
export type KeyedSnapshotHook = (
key: string,
selector?: (value: unknown) => unknown,
equal?: (left: unknown, right: unknown) => boolean,
) => unknown
/**
* Bind an open-key source family.
* @param source - keyed resolver, or absence for an optional scope.
* @returns cached keyed selector Hook.
*/
export function keyedObservableHook(source: KeyedStandardSource | undefined): KeyedSnapshotHook {
if (source === undefined) return absentKeyedHook
let hook = keyedHookCache.get(source)
if (hook === undefined) {
hook = (key, selector, equal) => {
const useValue = observableHook(source(key) ?? absentSource)
return useValue(selector ?? identity, equal)
}
keyedHookCache.set(source, hook)
}
return hook
}
const keyedHookCache = new WeakMap<KeyedStandardSource, KeyedSnapshotHook>()
const identity = (value: unknown): unknown => value
const absentKeyedHook: KeyedSnapshotHook = (_key, selector, equal) =>
observableHook(absentSource)(selector ?? identity, equal)
/** Subscribe the tree to the atomically assembled root standard-source roster. */
export function RootStandardProvider({ children }: { children: ReactNode }) {
const host = useHost()
const binding = observableHook(host.root)(value => value)
return <RootBindingContext.Provider value={binding}>{children}</RootBindingContext.Provider>
}
/** Subscribe to the scope roster before resolving and binding its current adapter. */
export function ScopeProvider({
scope,
children,
}: {
scope: 'session' | 'session-maybe'
children: ReactNode
}) {
const host = useHost()
observableHook(host.scopeRevision)(value => value)
const adapter = host.scope(scope)
if (adapter === undefined) throw new SlotAssemblyError(`scope '${scope}' rendered without an installed adapter`)
const binding = observableHook(adapter.current)(value => value)
return <ScopeBindingContext.Provider value={binding}>{children}</ScopeBindingContext.Provider>
}
@@ -7,18 +7,18 @@ import { createElement, useLayoutEffect, useState, type ReactNode } from 'react'
import { flushSync } from 'react-dom'
import { createRoot, hydrateRoot, type Root } from 'react-dom/client'
import type { Context } from '@deepseek-ai/cordis'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { createSlotRenderer } from './scoped-slots.tsx'
import { buildRenderApp } from './app.tsx'
import { SlotRegistry } from './registry.ts'
/** Selector hook over a session's conversation snapshot. */
export type UseSession<Snap extends object = object> = SnapshotSelectorHook<Snap>
export { SlotRegistry } from './registry.ts'
export type { RootOwnerProps } from './registry.ts'
export type {
ChainRenderOpts, HostObservable, RenderOpts, SessionProvideInfo, SnapshotSelectorHook,
SlotRenderer, SlotRendererHost, StoreInstanceLike,
ChainRenderOpts, HostObservable, RenderOpts, SnapshotSelectorHook, SlotRenderer,
ScopedStandardSourceBinding, SlotRendererHost, SlotScopeAdapter,
StandardSourceBinding, StoreInstanceLike,
} from '@deepseek-ai/dsh-client-ui-slots'
export type { SessionProviderProps } from './session-provider.tsx'
/** Mount operation exposed to the framework-free boot kernel. */
export interface UiRendererService {
@@ -31,14 +31,24 @@ export interface UiRendererService {
}
declare module '@deepseek-ai/cordis' {
interface Events {
/**
* A slot declaration or registration set changed.
* @mode emit
* @param key - mutated SlotMap key.
*/
'slots/changed'(key: string): void
}
interface Context {
/** Renderer-owned UI composition registry. */
slots: SlotRegistry
/** Mount face provided after the UI renderer activates. */
uiRenderer: UiRendererService
}
}
/** Services required before application assembly. */
export const inject = ['slots', 'sessions']
export const inject: string[] = []
interface BootSnapshot {
className: string
@@ -76,7 +86,8 @@ function mountApp(container: HTMLElement, app: () => ReactNode): Root {
* @param ctx - Plugin context.
*/
export function apply(ctx: Context): void {
ctx.slots.install(createSlotRenderer())
const slots = new SlotRegistry(ctx)
slots.install(createSlotRenderer())
ctx.reflect.provide('uiRenderer', {
mount: (container: HTMLElement): (() => void) => {
const root = mountApp(container, buildRenderApp({ ctx }))
@@ -1,8 +1,8 @@
/**
* SlotRegistry: the cordis Service layer of the slot system over the pure
* SlotRegistry: the renderer-owned Cordis service over the pure
* SlotCore (ui-slots owns registration semantics, the declaration ledger,
* the load-time validations, and the unload cascade). This layer owns what
* needs the runtime: the 'slots/changed' event bridge, register and
* needs a live application: the 'slots/changed' event bridge, register and
* declaration injection through the caller's ctx.effect (fiber unload
* collects both), the renderer installation contract (install()/renderSlot('root') +
* the SlotRendererHost face), and the store INSTANCE axis handle x scope
@@ -16,10 +16,12 @@
* redundancy. */
import { Service } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotCore, standardHookPropName } from '@deepseek-ai/dsh-client-ui-slots'
import type {
LiveSlotNode, LocaleFace, OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost,
SlotScope, SlotSpec, StoreDecl, StoreFactory, StoredEntry, StoreInstanceLike,
HostObservable, LiveSlotNode, LocaleFace, OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost,
RootStandardSourceContribution, ScopedStandardSourceBinding, SlotScope, SlotScopeAdapter, SlotSpec,
StandardSourceBinding,
StoreDecl, StoreFactory, StoredEntry, StoreInstanceLike,
} from '@deepseek-ai/dsh-client-ui-slots'
declare module '@deepseek-ai/dsh-client-ui-slots' {
@@ -62,6 +64,8 @@ interface StoreAxisRecord {
refs: number
/** Root scope: the single instance under {@link ROOT_INSTANCE_KEY}; session scope: one per session id. */
instances: Map<string, EngineStoreInstance>
/** Scope-lifetime registrations for Session instances. */
lifetimes: Map<string, () => void>
}
/** Type-erased options view the implementation works with (the typed overloads proved the shares). */
@@ -97,6 +101,31 @@ export class SlotRegistry extends Service {
private _renderer: SlotRenderer | undefined
private _locale: LocaleFace | undefined
private _host: SlotRendererHost | undefined
private readonly _rootContributions: RootStandardSourceContribution[] = []
private readonly _rootListeners = new Set<() => void>()
private _rootBinding: StandardSourceBinding = {
key: undefined,
hooks: {},
keyedHooks: {},
props: {},
}
private readonly _rootSource = {
getSnapshot: (): StandardSourceBinding => this._rootBinding,
subscribe: (listener: () => void): (() => void) => {
this._rootListeners.add(listener)
return () => { this._rootListeners.delete(listener) }
},
}
private readonly _scopes = new Map<Exclude<SlotScope, 'root' | 'session-maybe'>, SlotScopeAdapter>()
private _scopeRevision = 0
private readonly _scopeListeners = new Set<() => void>()
private readonly _scopeRevisionSource: HostObservable<number> = {
getSnapshot: () => this._scopeRevision,
subscribe: (listener) => {
this._scopeListeners.add(listener)
return () => { this._scopeListeners.delete(listener) }
},
}
/**
* @param ctx - owning root context.
@@ -237,6 +266,54 @@ export class SlotRegistry extends Service {
}, 'slots.installLocale()')
}
/**
* Contribute domain-owned root data. Hook names must be globally unique;
* registration and disposal republish one atomic root binding.
* @param contribution - bare sources and stable props.
* @returns disposer owned by the caller's Cordis fiber.
*/
provideRoot(contribution: RootStandardSourceContribution): () => void {
const dispose = this.ctx.effect(() => {
this._rootContributions.push(contribution)
try {
this.rebuildRootBinding()
} catch (error) {
this._rootContributions.pop()
throw error
}
return () => {
const index = this._rootContributions.indexOf(contribution)
if (index === -1) return
this._rootContributions.splice(index, 1)
this.rebuildRootBinding()
}
}, 'slots.provideRoot()')
return () => { void dispose() }
}
/**
* Install the owner adapter for one strict scope. Its optional counterpart
* resolves through the same adapter.
* @param scope - strict scope name.
* @param adapter - current/resolved binding source and release notifications.
*/
installScope(
scope: Exclude<SlotScope, 'root' | 'session-maybe'>,
adapter: SlotScopeAdapter,
): void {
if (this._scopes.has(scope)) throw new Error(`slot scope '${scope}' already has an adapter`)
this.ctx.effect(() => {
this._scopes.set(scope, adapter)
this.publishScopeRevision()
return () => {
if (this._scopes.get(scope) === adapter) {
this._scopes.delete(scope)
this.publishScopeRevision()
}
}
}, `slots.installScope(${JSON.stringify(scope)})`)
}
/**
* The single ctx-level render entry: the shell renders 'root'; every other
* key renders inside components through the props renderSlot face. All
@@ -261,23 +338,6 @@ export class SlotRegistry extends Service {
return this._renderer.renderRoot(this.hostFace(), owner)
}
/**
* Drop the per-session store instances of a dead session (the sessions
* service calls this on scope teardown; root-scoped records are untouched).
* Persisted state goes with the session a never-rendered dead session can
* still own keys from an earlier page load, so the instance is materialized
* transiently just to clear storage (no-op for unpersisted stores).
* @param sessionId - the torn-down session.
*/
pruneStoreScope(sessionId: string): void {
for (const [handle, record] of this._stores) {
if (record.scope !== 'session') continue
const instance = record.instances.get(sessionId) ?? handle.create(sessionId)
instance.clearPersisted()
record.instances.delete(sessionId)
}
}
/**
* Snapshot entries for a key (render-erased view; stable reference between mutations).
* @param key - SlotMap key.
@@ -381,17 +441,9 @@ export class SlotRegistry extends Service {
}
}
/** Build once after both object-layer services mount; per-session provide bundles still resolve lazily. */
/** Build the domain-neutral host face once; installed adapters remain live through getters. */
private hostFace(): SlotRendererHost {
if (this._host !== undefined) return this._host
const sessions = this.ctx.get('sessions')
if (sessions === undefined) {
throw new Error("renderSlot('root') before the sessions service mounted — boot order puts runtime apply first")
}
const workspaces = this.ctx.get('workspaces')
if (workspaces === undefined) {
throw new Error("renderSlot('root') before the workspaces service mounted — boot order puts runtime apply first")
}
// `locale` is a live getter: the face installs (and, under HMR, swaps)
// on the locale plugin's own fiber lifetime, while this host object is
// built once — a captured value would strand renders on a dead face. The
@@ -406,23 +458,59 @@ export class SlotRegistry extends Service {
reportEntryError: (key, entry, error, info) => { this._core.reportEntryError(key, entry, error, info) },
specOf: key => this._core.specDynamic(key),
isLive: entry => this._core.isLive(entry),
storeOf: (entry, scopeKey) =>
entry.store === undefined ? undefined : this.resolveStore(entry.store as unknown as EngineStoreHandle, scopeKey),
sessions: {
list: sessions.list,
provideInfo: sessions.currentProvideInfo,
},
workspaces: { list: workspaces.list },
storeOf: (entry, scopeBinding) =>
entry.store === undefined
? undefined
: this.resolveStore(entry.store as unknown as EngineStoreHandle, scopeBinding),
root: this._rootSource,
scopeRevision: this._scopeRevisionSource,
scope: scope => service._scopes.get(scope === 'session-maybe' ? 'session' : scope),
get locale() { return service._locale },
}
return this._host
}
/** Validate and atomically publish the current root contribution roster. */
private rebuildRootBinding(): void {
const hooks: Record<string, HostObservable<unknown>> = {}
const keyedHooks: Record<string, import('@deepseek-ai/dsh-client-ui-slots').KeyedStandardSource> = {}
const props: Record<string, unknown> = {}
const finalProps = new Set<string>()
for (const contribution of this._rootContributions) {
copyUnique('hook', hooks, contribution.hooks, finalProps, standardHookPropName)
copyUnique('keyed hook', keyedHooks, contribution.keyedHooks, finalProps, standardHookPropName)
copyUnique('prop', props, contribution.props, finalProps, name => name)
}
this._rootBinding = { key: undefined, hooks, keyedHooks, props }
for (const listener of [...this._rootListeners]) {
try {
listener()
} catch (error) {
console.error('root standard-source subscriber failed:', error)
}
}
}
/** Publish one installed-scope roster transition after the map is authoritative. */
private publishScopeRevision(): void {
this._scopeRevision += 1
for (const listener of [...this._scopeListeners]) {
try {
listener()
} catch (error) {
console.error('scope-adapter subscriber failed:', error)
}
}
}
/** Resolve (create or reuse) the store instance for a registered handle under a scope key. */
private resolveStore(handle: EngineStoreHandle, sessionId: string | undefined): StoreInstanceLike {
private resolveStore(
handle: EngineStoreHandle,
scopeBinding: ScopedStandardSourceBinding | undefined,
): StoreInstanceLike {
const record = this._stores.get(handle)
if (record === undefined) throw new Error('store handle is not registered (entry unloaded, or the handle never went through register)')
const key = record.scope === 'root' ? ROOT_INSTANCE_KEY : sessionId
const key = record.scope === 'root' ? ROOT_INSTANCE_KEY : scopeBinding?.key
if (key === undefined) throw new Error(`${record.scope} store resolution requires a session id`)
let instance = record.instances.get(key)
if (instance === undefined) {
@@ -430,6 +518,25 @@ export class SlotRegistry extends Service {
// key per session); root instances stay keyless.
instance = record.scope === 'root' ? handle.create() : handle.create(key)
record.instances.set(key, instance)
if (record.scope !== 'root') {
const scopeCtx = scopeBinding?.ctx
if (scopeCtx === undefined) {
record.instances.delete(key)
throw new Error(`${record.scope} store resolution requires a scope lifetime`)
}
const owned = instance
const dispose = scopeCtx.effect(
() => () => {
if (this._stores.get(handle) !== record || record.instances.get(key) !== owned) return
owned.clearPersisted()
record.instances.delete(key)
record.lifetimes.delete(key)
},
`slots: store scope ${key}`,
)
const release = (): void => { void dispose() }
record.lifetimes.set(key, release)
}
}
return instance
}
@@ -438,7 +545,7 @@ export class SlotRegistry extends Service {
private _acquire(handle: EngineStoreHandle, scope: SlotScope): void {
const record = this._stores.get(handle)
if (record === undefined) {
this._stores.set(handle, { scope, refs: 1, instances: new Map() })
this._stores.set(handle, { scope, refs: 1, instances: new Map(), lifetimes: new Map() })
return
}
record.refs += 1
@@ -452,7 +559,27 @@ export class SlotRegistry extends Service {
* future call site cannot underflow the axis. */
if (record === undefined) return
record.refs -= 1
if (record.refs === 0) this._stores.delete(handle)
if (record.refs !== 0) return
this._stores.delete(handle)
for (const release of record.lifetimes.values()) release()
}
}
function copyUnique<T>(
kind: string,
target: Record<string, T>,
values: Readonly<Record<string, T>> | undefined,
finalProps: Set<string>,
propNameOf: (name: string) => string,
): void {
if (values === undefined) return
for (const [name, value] of Object.entries(values)) {
const propName = propNameOf(name)
if (finalProps.has(propName)) {
throw new Error(`duplicate root standard ${kind} '${name}' at prop '${propName}'`)
}
finalProps.add(propName)
target[name] = value
}
}
@@ -4,15 +4,17 @@
*/
import { Component, useMemo, useState, useSyncExternalStore, type FC, type ReactNode } from 'react'
import {
SlotOwnershipError, StaleAuthorizationError,
SlotOwnershipError, StaleAuthorizationError, standardHookPropName,
type ChainRenderOpts, type HostObservable, type LocaleFace, type RenderOpts,
type SessionMaybeProvideInfo, type SessionProvideInfo, type SlotRenderer, type SlotRendererHost,
type SlotScope, type StoredEntry, type Translate,
type ScopedStandardSourceBinding, type SessionAreaProps, type SessionProviderComponent, type SlotRenderer,
type SlotRendererHost, type SlotScope, type SlotScopeAdapter, type StandardSourceBinding,
type StoredEntry, type Translate,
} from '@deepseek-ai/dsh-client-ui-slots'
import {
HostContext, SessionMaybeProvider, SessionProvider, SlotAssemblyError, maybeObservableHook,
observableHook, projectionHook, useHost, useSessionMaybeProvideInfo,
} from './session-provider.tsx'
HostContext, RootStandardProvider, ScopeProvider, SlotAssemblyError,
keyedObservableHook, maybeObservableHook, observableHook, useHost, useRootBinding,
useScopeBinding,
} from './bindings.tsx'
type InjectedProps = Record<string, unknown>
@@ -89,23 +91,23 @@ function boundRenderSlotChain(host: SlotRendererHost, entry: StoredEntry): Rende
/**
* Inject results cache: root entries per entry, session entries per
* (entry x provide bundle). WeakMap keys are entry/info objects (both
* (entry x scope binding). WeakMap keys are entry/binding 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<SessionProvideInfo, InjectedProps>>()
const sessionMaybeInjectCache = new WeakMap<StoredEntry, WeakMap<SessionMaybeProvideInfo, InjectedProps>>()
const sessionInjectCache = new WeakMap<StoredEntry, WeakMap<StandardSourceBinding, InjectedProps>>()
const sessionMaybeInjectCache = new WeakMap<StoredEntry, WeakMap<StandardSourceBinding, InjectedProps>>()
const EMPTY_INJECTED_PROPS: InjectedProps = {}
function runInject(entry: StoredEntry, info: SessionMaybeProvideInfo | undefined, actions: object | undefined): InjectedProps {
function runInject(entry: StoredEntry, binding: StandardSourceBinding | undefined, actions: object | undefined): InjectedProps {
const inject = entry.inject
if (!inject) return EMPTY_INJECTED_PROPS
// Declaration-derived positional arguments: sessionId for session scope,
// baked actions when a store is declared.
const args: unknown[] = []
if (info !== undefined) args.push(info.sessionId)
if (binding !== undefined) args.push(binding.key)
if (actions !== undefined) args.push(actions)
return bindInjectHooks((inject as (...args: unknown[]) => InjectedProps)(...args))
}
@@ -120,7 +122,7 @@ function bindInjectHooks(face: InjectedProps): InjectedProps {
const { hooks: _hooks, ...rest } = face
const bound: InjectedProps = rest
for (const [name, source] of Object.entries(sources as Record<string, HostObservable<unknown>>)) {
const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}`
const hookName = standardHookPropName(name)
bound[hookName] = observableHook(source)
}
return bound
@@ -144,7 +146,7 @@ function cachedSlotInject(face: object | undefined): BoundSlotInject {
const props: InjectedProps = rest
let factories: Record<string, SlotHookFactory> | undefined
for (const [name, definition] of Object.entries(definitions as Record<string, unknown>)) {
const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}`
const hookName = standardHookPropName(name)
if (typeof definition === 'function') {
factories ??= {}
factories[name] = definition as SlotHookFactory
@@ -167,7 +169,7 @@ function bindSlotHookFactories(
): InjectedProps {
const hooks: InjectedProps = {}
for (const [name, factory] of Object.entries(factories)) {
const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}`
const hookName = standardHookPropName(name)
hooks[hookName] = factory(standard, hookContext)
}
return hooks
@@ -182,34 +184,34 @@ function cachedRootInject(entry: StoredEntry, actions: object | undefined): Inje
return props
}
function cachedSessionInject(entry: StoredEntry, info: SessionProvideInfo, actions: object | undefined): InjectedProps {
let perInfo = sessionInjectCache.get(entry)
if (!perInfo) {
perInfo = new WeakMap()
sessionInjectCache.set(entry, perInfo)
function cachedSessionInject(entry: StoredEntry, binding: StandardSourceBinding, actions: object | undefined): InjectedProps {
let perBinding = sessionInjectCache.get(entry)
if (!perBinding) {
perBinding = new WeakMap()
sessionInjectCache.set(entry, perBinding)
}
let props = perInfo.get(info)
let props = perBinding.get(binding)
if (!props) {
props = runInject(entry, info, actions)
perInfo.set(info, props)
props = runInject(entry, binding, actions)
perBinding.set(binding, props)
}
return props
}
function cachedSessionMaybeInject(
entry: StoredEntry,
info: SessionMaybeProvideInfo,
binding: StandardSourceBinding,
actions: object | undefined,
): InjectedProps {
let perInfo = sessionMaybeInjectCache.get(entry)
if (!perInfo) {
perInfo = new WeakMap()
sessionMaybeInjectCache.set(entry, perInfo)
let perBinding = sessionMaybeInjectCache.get(entry)
if (!perBinding) {
perBinding = new WeakMap()
sessionMaybeInjectCache.set(entry, perBinding)
}
let props = perInfo.get(info)
let props = perBinding.get(binding)
if (!props) {
props = runInject(entry, info, actions)
perInfo.set(info, props)
props = runInject(entry, binding, actions)
perBinding.set(binding, props)
}
return props
}
@@ -332,54 +334,76 @@ class SlotErrorBoundary extends Component<
}
}
interface StandardPropsCache {
readonly root: InjectedProps
readonly session: WeakMap<SessionMaybeProvideInfo, InjectedProps>
readonly sessionMaybe: WeakMap<SessionMaybeProvideInfo, InjectedProps>
}
const rootStandardCache = new WeakMap<StandardSourceBinding, InjectedProps>()
const sessionStandardCache = new WeakMap<StandardSourceBinding, WeakMap<StandardSourceBinding, InjectedProps>>()
const sessionMaybeStandardCache = new WeakMap<StandardSourceBinding, WeakMap<StandardSourceBinding, InjectedProps>>()
const standardPropsCache = new WeakMap<SlotRendererHost, StandardPropsCache>()
/** Materialize one binding into stable framework Hook and plain-prop seats. */
function materializeStandardBinding(binding: StandardSourceBinding, optional: boolean): InjectedProps {
const standard: InjectedProps = { ...binding.props }
for (const [name, source] of Object.entries(binding.hooks)) {
if (source === undefined && !optional) {
throw new SlotAssemblyError(`strict standard hook '${name}' has no source`)
}
standard[standardHookPropName(name)] = optional
? maybeObservableHook(source)
: observableHook(source as HostObservable<unknown>)
}
for (const [name, source] of Object.entries(binding.keyedHooks)) {
if (source === undefined && !optional) {
throw new SlotAssemblyError(`strict keyed standard hook '${name}' has no source resolver`)
}
standard[standardHookPropName(name)] = keyedObservableHook(source)
}
return standard
}
/** Stable official-props object used by contextual Hook factories. */
function standardProps(
host: SlotRendererHost,
scope: SlotScope,
info: SessionMaybeProvideInfo | undefined,
rootBinding: StandardSourceBinding,
scopeBinding: StandardSourceBinding | undefined,
): InjectedProps {
let cache = standardPropsCache.get(host)
if (cache === undefined) {
cache = {
root: {
useSessions: observableHook(host.sessions.list),
useWorkspaces: observableHook(host.workspaces.list),
},
session: new WeakMap(),
sessionMaybe: new WeakMap(),
}
standardPropsCache.set(host, cache)
let root = rootStandardCache.get(rootBinding)
if (root === undefined) {
root = materializeStandardBinding(rootBinding, false)
rootStandardCache.set(rootBinding, root)
}
if (scope === 'root') return cache.root
if (info === undefined) throw new SlotAssemblyError(`scope '${scope}' rendered without session provide info`)
const byInfo = scope === 'session' ? cache.session : cache.sessionMaybe
let standard = byInfo.get(info)
if (scope === 'root') return root
if (scopeBinding === undefined) throw new SlotAssemblyError(`scope '${scope}' rendered without a standard-source binding`)
const cache = scope === 'session' ? sessionStandardCache : sessionMaybeStandardCache
let perScope = cache.get(rootBinding)
if (perScope === undefined) {
perScope = new WeakMap()
cache.set(rootBinding, perScope)
}
let standard = perScope.get(scopeBinding)
if (standard !== undefined) return standard
standard = { ...cache.root }
for (const [name, source] of Object.entries(info.hooks)) {
const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}`
if (scope === 'session-maybe') {
standard[hookName] = maybeObservableHook(source)
} else {
if (source === undefined) throw new SlotAssemblyError(`strict session hook '${name}' has no source`)
standard[hookName] = observableHook(source)
}
standard = {
...root,
...materializeStandardBinding(scopeBinding, scope === 'session-maybe'),
}
Object.assign(standard, info.props)
standard['sessionId'] = info.sessionId
standard['useProjection'] = projectionHook(info)
byInfo.set(info, standard)
perScope.set(scopeBinding, standard)
return standard
}
const scopeAreaCache = new WeakMap<SlotScopeAdapter, SessionProviderComponent>()
/** Bind one domain-owned scope area renderer to the current scope binding. */
function scopeAreaProvider(adapter: SlotScopeAdapter): SessionProviderComponent {
let Provider = scopeAreaCache.get(adapter)
if (Provider !== undefined) return Provider
if (adapter.renderArea === undefined) {
throw new SlotAssemblyError("scope 'session' adapter does not provide its area renderer")
}
const renderArea = adapter.renderArea.bind(adapter)
Provider = function ScopeAreaProvider(props: SessionAreaProps): ReactNode {
return renderArea(useScopeBinding(), props)
}
scopeAreaCache.set(adapter, Provider)
return Provider
}
/**
* Standard-kit synthesis shared by both scope branches: the global
* useSessions/useWorkspaces hooks, the per-session provide bundle (every
@@ -396,13 +420,14 @@ function standardKit(
host: SlotRendererHost,
entry: StoredEntry,
scope: SlotScope,
info: SessionMaybeProvideInfo | undefined,
rootBinding: StandardSourceBinding,
scopeBinding: StandardSourceBinding | undefined,
): {
kit: InjectedProps
standard: InjectedProps
actions: object | undefined
} {
const standard = standardProps(host, scope, info)
const standard = standardProps(scope, rootBinding, scopeBinding)
const kit: InjectedProps = { ...standard }
if (entry.locale !== undefined) {
const face = host.locale
@@ -414,9 +439,10 @@ function standardKit(
}
kit['t'] = localeSeat(face, entry.locale)
}
const store = scope === 'session-maybe' && info?.sessionId === undefined
const scopedStoreBinding = scopeBinding?.key === undefined
? undefined
: host.storeOf(entry, info?.sessionId)
: scopeBinding as ScopedStandardSourceBinding
const store = host.storeOf(entry, scopedStoreBinding)
if (store !== undefined) {
// The instance IS an observable snapshot source (contract getSnapshot/
// subscribe); the useStore hook binds here, cached per instance.
@@ -430,11 +456,14 @@ function standardKit(
if (Object.values(entry.children).some(spec => spec.kind === 'chain')) {
kit['renderSlotChain'] = boundRenderSlotChain(host, entry)
}
// SessionProvider standard seat: entries declaring a session-scope child
// render the session area, so the framework hands them the self-wired
// provider (module-level component = stable reference; no value import).
// The session owner supplies area semantics; the renderer only binds its
// adapter to the current generic scope source.
if (Object.values(entry.children).some(spec => spec.scope === 'session')) {
kit['SessionProvider'] = SessionProvider
const adapter = host.scope('session')
if (adapter === undefined) {
throw new SlotAssemblyError("entry declares a session child without an installed 'session' scope adapter")
}
kit['SessionProvider'] = scopeAreaProvider(adapter)
}
}
return { kit, standard, actions: store?.actions }
@@ -499,35 +528,37 @@ function renderEntry(
)
}
function SessionEntry({ entry, ownerProps, info, slotKey, slotInjected, hookContext, hasHookContext }: {
function SessionEntry({ entry, ownerProps, binding, slotKey, slotInjected, hookContext, hasHookContext }: {
entry: StoredEntry
ownerProps: object
info: SessionProvideInfo
binding: StandardSourceBinding & { readonly key: string }
slotKey: string
slotInjected: BoundSlotInject
hookContext: unknown
hasHookContext: boolean
}) {
const host = useHost()
const rootBinding = useRootBinding()
const Comp = entry.component as FC<InjectedProps>
const { kit, standard, actions } = standardKit(host, entry, 'session', info)
const injected = cachedSessionInject(entry, info, actions)
const { kit, standard, actions } = standardKit(host, entry, 'session', rootBinding, binding)
const injected = cachedSessionInject(entry, binding, actions)
return renderEntry(slotKey, Comp, kit, standard, injected, slotInjected, ownerProps, hookContext, hasHookContext)
}
function SessionMaybeEntryBody({ entry, ownerProps, info, slotKey, slotInjected, hookContext, hasHookContext }: {
function SessionMaybeEntryBody({ entry, ownerProps, binding, slotKey, slotInjected, hookContext, hasHookContext }: {
entry: StoredEntry
ownerProps: object
info: SessionMaybeProvideInfo
binding: StandardSourceBinding
slotKey: string
slotInjected: BoundSlotInject
hookContext: unknown
hasHookContext: boolean
}) {
const host = useHost()
const rootBinding = useRootBinding()
const Comp = entry.component as FC<InjectedProps>
const { kit, standard, actions } = standardKit(host, entry, 'session-maybe', info)
const injected = cachedSessionMaybeInject(entry, info, actions)
const { kit, standard, actions } = standardKit(host, entry, 'session-maybe', rootBinding, binding)
const injected = cachedSessionMaybeInject(entry, binding, actions)
return renderEntry(slotKey, Comp, kit, standard, injected, slotInjected, ownerProps, hookContext, hasHookContext)
}
@@ -552,7 +583,7 @@ function SessionMaybeEntry({ entry, ownerProps, slotKey, slotInjected, hookConte
hookContext: unknown
hasHookContext: boolean
}) {
const info = useSessionMaybeProvideInfo()
const binding = useScopeBinding()
// The child key is an incarnation counter, NOT the session id: adoption
// must keep the key constant across undefined → first id. Bookkeeping
// lives in this stable (unkeyed) wrapper via the render-phase setState
@@ -561,16 +592,16 @@ function SessionMaybeEntry({ entry, ownerProps, slotKey, slotInjected, hookConte
// guard conditions make it convergent — StrictMode-safe).
const [state, setState] = useState<MaybeIncarnation>(FIRST_INCARNATION)
let { adopted, epoch } = state
if (info.sessionId !== undefined && adopted === undefined) {
if (binding.key !== undefined && adopted === undefined) {
// Adoption: same epoch — no remount.
adopted = info.sessionId
adopted = binding.key
setState({ adopted, epoch })
} else if (adopted !== undefined && info.sessionId !== undefined && info.sessionId !== adopted) {
} else if (adopted !== undefined && binding.key !== undefined && binding.key !== adopted) {
// Post-adoption session switch: next incarnation, born already adopted.
adopted = info.sessionId
adopted = binding.key
epoch += 1
setState({ adopted, epoch })
} else if (adopted !== undefined && info.sessionId === undefined) {
} else if (adopted !== undefined && binding.key === undefined) {
// Back to no-session: next incarnation, born blank (adopts anew later).
adopted = undefined
epoch += 1
@@ -581,7 +612,7 @@ function SessionMaybeEntry({ entry, ownerProps, slotKey, slotInjected, hookConte
key={epoch}
entry={entry}
ownerProps={ownerProps}
info={info}
binding={binding}
slotKey={slotKey}
slotInjected={slotInjected}
hookContext={hookContext}
@@ -609,8 +640,9 @@ function RootEntry({ entry, ownerProps, slotKey, slotInjected, hookContext, hasH
hasHookContext: boolean
}) {
const host = useHost()
const rootBinding = useRootBinding()
const Comp = entry.component as FC<InjectedProps>
const { kit, standard, actions } = standardKit(host, entry, 'root', undefined)
const { kit, standard, actions } = standardKit(host, entry, 'root', rootBinding, undefined)
const injected = cachedRootInject(entry, actions)
return renderEntry(slotKey, Comp, kit, standard, injected, slotInjected, ownerProps, hookContext, hasHookContext)
}
@@ -624,16 +656,18 @@ function StrictSessionEntry({ slotKey, entry, ownerProps, slotInjected, hookCont
hasHookContext: boolean
onEntryError: (error: unknown) => void
}) {
const info = useSessionMaybeProvideInfo()
if (info.sessionId === undefined) return null
const binding = useScopeBinding()
if (binding.key === undefined) {
throw new SlotAssemblyError(`strict session slot '${slotKey}' rendered without a scope binding`)
}
// Per-session remount rides this key; per-entry remount rides the outer
// element's entry-identity key (the outlet's guarded() call).
return (
<SlotErrorBoundary slotKey={slotKey} key={info.sessionId} onEntryError={onEntryError}>
<SlotErrorBoundary slotKey={slotKey} key={binding.key} onEntryError={onEntryError}>
<SessionEntry
entry={entry}
ownerProps={ownerProps}
info={info as SessionProvideInfo}
binding={binding as StandardSourceBinding & { readonly key: string }}
slotKey={slotKey}
slotInjected={slotInjected}
hookContext={hookContext}
@@ -665,7 +699,7 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
// Locale revision tick: a locale switch re-renders every outlet, and entry
// bodies re-derive their `t` seat at the new revision (fresh identity).
useLocaleRevision(host.locale)
const sessionInfo = useSessionMaybeProvideInfo()
const scopeBinding = useScopeBinding()
// Anchor contract: every slot render site exposes a stable
// `[data-slot="<key>"]` wrapper — the addressable seam dynamic styles
// target — and `display:contents` keeps it layout-neutral. The wrapper
@@ -674,7 +708,7 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
// never flickers with registration churn.
return (
<div data-slot={slotKey} style={ANCHOR_STYLE}>
{renderOutletContent(host, slotKey, ownerProps, opts, sessionInfo)}
{renderOutletContent(host, slotKey, ownerProps, opts, scopeBinding)}
</div>
)
}
@@ -685,20 +719,20 @@ function renderOutletContent(
slotKey: string,
ownerProps: object,
opts: (RenderOpts & ChainRenderOpts) | undefined,
sessionInfo: SessionMaybeProvideInfo,
scopeBinding: StandardSourceBinding,
): ReactNode {
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.
if (!spec) return null
const strictSessionAbsent = spec.scope === 'session' && sessionInfo.sessionId === undefined
if (strictSessionAbsent && (spec.kind !== 'chain' || !opts?.overlay)) {
return <>{opts?.fallback ?? null}</>
if (spec.kind === 'chain' && opts?.fallbackOnly === true) {
return renderChainResult(slotKey, null, opts)
}
// 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)
if (spec.scope === 'session' && scopeBinding.key === undefined) {
throw new SlotAssemblyError(`strict session slot '${slotKey}' rendered without a scope binding`)
}
const entries = host.entriesOf(slotKey)
const slotInjected = cachedSlotInject(spec.inject)
// The boundary must wrap the Entry ELEMENT, not live inside it: inject
@@ -799,25 +833,7 @@ function renderOutletContent(
break
}
}
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}</>
return renderChainResult(slotKey, elected, opts)
}
// list: one row per id cell — the cell's shadowing winner, or the crash
// face once every entry of the cell abdicated (a dry cell must not
@@ -850,6 +866,26 @@ function renderOutletContent(
)
}
/** Render a chain election while preserving the overlay fallback's tree position. */
function renderChainResult(
slotKey: string,
elected: ReactNode,
opts: (RenderOpts & ChainRenderOpts) | undefined,
): ReactNode {
if (!opts?.overlay) return elected ?? <>{opts?.fallback ?? null}</>
return (
<>
<div
data-chain-overlay-fallback={slotKey}
style={{ display: elected === null ? 'contents' : 'none' }}
>
{opts.fallback ?? null}
</div>
{elected}
</>
)
}
/** Root outlet: the shell's single ctx-level render entry — an unregistered 'root' is a boot-order failure, never a silent blank. */
function RootOutlet({ ownerProps }: { ownerProps: object }) {
const host = useHost()
@@ -889,7 +925,7 @@ function RootOutlet({ ownerProps }: { ownerProps: object }) {
}
/**
* Build the renderer the shell installs into the runtime SlotRegistry
* Build the renderer installed into the `ui-renderer` SlotRegistry
* (ctx.slots.install(createSlotRenderer()) at boot; the service owns the
* install/renderSlot contract and the double-install/not-installed throws).
* @returns the renderer.
@@ -899,9 +935,11 @@ export function createSlotRenderer(): SlotRenderer {
renderRoot(host, ownerProps) {
return (
<HostContext.Provider value={host}>
<SessionMaybeProvider>
<RootOutlet ownerProps={ownerProps} />
</SessionMaybeProvider>
<RootStandardProvider>
<ScopeProvider scope="session-maybe">
<RootOutlet ownerProps={ownerProps} />
</ScopeProvider>
</RootStandardProvider>
</HostContext.Provider>
)
},
@@ -1,160 +0,0 @@
/** 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 API.
*/
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 (docs/subsystems/session-projection.md), one bound
* function per provide bundle (cached by info identity — components may hold
* it across renders). Key-addressed: the key resolves a per-session value
* face off the projection store; the 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 per key. A key no
* baseline or frame has carried (or a no-session bundle) reads `undefined` —
* capability absence — 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) => {
// The no-session (faceless) 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.
const useValue = observableHook(info.projections?.faceOf(key) ?? absentSource)
// Whole values are finished wire payloads (reference changes only when
// a frame or baseline lands), so the identity selector needs no
// equality function.
return useValue(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;
* per-entry identity is the outlet's adoption bookkeeping (SessionMaybeEntry):
* a blank-born incarnation adopts the first session without remounting, and
* every later transition (switch or loss) remounts like a strict entry.
*/
export function SessionMaybeProvider({ children }: { children: ReactNode }) {
const host = useHost()
const info = observableHook(host.sessions.provideInfo)(s => s)
return (
<BindingContext.Provider value={info}>
{children}
</BindingContext.Provider>
)
}
/** SessionProvider API: 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 provide
* source 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 info = observableHook(host.sessions.provideInfo)(s => s)
const id = info.sessionId
if (id === undefined) return <>{empty?.() ?? null}</>
return (
<BindingContext.Provider value={info} key={id}>
{children(id)}
</BindingContext.Provider>
)
}
+20 -3
View File
@@ -4,7 +4,11 @@
*/
/* jscpd:ignore-start */
/* oxlint-disable typescript/no-redundant-type-constituents --
* `keyof SlotMap & string` is the declaration-merge key pattern: SlotMap is
* empty in this compilation unit but consumers merge concrete keys into it. */
import type { Context } from '@deepseek-ai/cordis'
import type { SlotMap } from '@deepseek-ai/dsh-client-ui-slots'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-renderer'
@@ -15,10 +19,23 @@ export const name = 'client-ui-renderer-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: the package installs the render adapter and provides a
* mount callback but owns no event stream or mutable cross-plugin data relation.
* Verify that each `slots/changed` dispatch observes its mutation already
* applied to the renderer-owned slot registry.
*/
const install: InvariantInstaller = () => {}
const install: InvariantInstaller = (ctx, fail) => {
ctx.on('internal/dispatch', (_mode, eventName, args) => {
if (eventName !== 'slots/changed') return
const key: unknown = args[0]
if (typeof key !== 'string' || key === '') {
fail("'slots/changed' dispatched without a slot key argument")
return
}
const slots = ctx.get('slots')
if (slots !== undefined && slots.getVersion(key as keyof SlotMap & string) === 0) {
fail(`'slots/changed' fired for "${key}" before any mutation bumped its version — emission must follow the applied mutation`)
}
}, { global: true })
}
/**
* Register this package's invariant companion.
@@ -1,9 +1,8 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { Context } from '@deepseek-ai/cordis'
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { buildRenderApp } from '../src/client/app.tsx'
let runtime: SlotTestRuntime | undefined
@@ -12,8 +11,6 @@ afterEach(async () => {
cleanup()
await runtime?.dispose()
runtime = undefined
document.title = ''
vi.unstubAllEnvs()
})
async function bench() {
@@ -23,8 +20,9 @@ async function bench() {
}
describe('buildRenderApp', () => {
it('fails loud when the sessions service is unavailable', () => {
expect(() => buildRenderApp({ ctx: new Context() })).toThrow('sessions service unavailable')
it('fails loud when the slot registry is unavailable', () => {
const renderApp = buildRenderApp({ ctx: new Context() })
expect(() => renderApp()).toThrow()
})
it('renders the root slot tree', async () => {
@@ -33,29 +31,4 @@ describe('buildRenderApp', () => {
expect(view.getByTestId('frame')).toBeTruthy()
})
it('projects the selected durable session title', async () => {
vi.stubEnv('DSH_CLIENT_TITLE', 'Product')
document.title = 'stale title'
const b = await bench()
render(<>{b.renderApp()}</>)
expect(document.title).toBe('Product')
await b.runtime.sessions.add({ id: 's1', summary: { title: 'First' } })
expect(document.title).toBe('First — Product')
await b.runtime.sessions.setCurrent(undefined)
expect(document.title).toBe('Product')
await b.runtime.sessions.add({ id: 's2' })
expect(document.title).toBe('Product')
})
it('falls back when the selected id has no list row', async () => {
vi.stubEnv('DSH_CLIENT_TITLE', 'Product')
document.title = 'stale title'
const b = await bench()
await b.runtime.sessions.add({ id: 's1', summary: { title: 'First' } })
render(<>{b.renderApp()}</>)
expect(document.title).toBe('First — Product')
b.runtime.sessions.list.update((draft) => { draft.current = 'ghost' as SessionId })
await b.runtime.flush()
expect(document.title).toBe('Product')
})
})
@@ -1,26 +1,26 @@
/**
* Runtime invariant companion: the 'slots/changed' emission-order audit
* Renderer invariant companion: the 'slots/changed' emission-order audit
* a fired key must already carry a bumped version (emission follows the
* applied mutation), bogus payloads fail loud, foreign events pass.
*/
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import InvariantRegistry from '@deepseek-ai/dsh-invariants'
import * as RuntimeInvariant from '../src/invariant.ts'
import { SlotRegistry } from '../src/client/slots.ts'
import * as RendererInvariant from '../src/invariant.ts'
import { SlotRegistry } from '../src/client/registry.ts'
async function setup(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(InvariantRegistry, { enabled: true })
await ctx.plugin(RuntimeInvariant).await()
await ctx.plugin(RendererInvariant).await()
return ctx
}
const emit = (ctx: Context, event: string, ...args: unknown[]): void => {
;(ctx.emit as (event: string, ...args: unknown[]) => void)(event, ...args)
Reflect.apply(ctx.emit.bind(ctx), undefined, [event, ...args])
}
describe('runtime slots/changed invariant', () => {
describe('renderer slots/changed invariant', () => {
it('passes foreign events and a legitimate mutation-then-emission sequence', async () => {
const ctx = await setup()
expect(() => { emit(ctx, 'unrelated/event', 'x') }).not.toThrow()
@@ -8,13 +8,14 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import type { FC } from 'react'
import type { SlotRendererHost } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotRegistry } from '../src/client/slots.ts'
import type { ScopedStandardSourceBinding, SlotRendererHost } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotRegistry } from '../src/client/registry.ts'
// Test-only slot keys (merged so the typed entries/spec faces accept them).
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
't.host': { kind: 'single'; scope: 'root' }
't.maybe': { kind: 'single'; scope: 'session-maybe' }
't.panel': { kind: 'single'; scope: 'session' }
't.rows': { kind: 'list'; scope: 'root' }
}
@@ -85,27 +86,21 @@ function captureHost(bench: Bench, children?: object): SlotRendererHost {
renderRoot: (h: SlotRendererHost) => { host = h; return 'rendered' },
})
bench.erased.register({ name: 'root', ...(children !== undefined ? { children } : {}) }, C)
bench.ctx.reflect.provide('sessions', fakeSessions())
bench.ctx.reflect.provide('workspaces', fakeWorkspaces())
bench.erased.renderSlot('root', {})
if (host === undefined) throw new Error('renderer never received the host')
return host
}
/** Minimal independent Workspace list source for the renderer host contract. */
function fakeWorkspaces() {
const state = { items: [], phase: 'ready' as const }
return { list: { getSnapshot: () => state, subscribe: () => () => undefined } }
}
/** Minimal sessions face for the host contract (list observable + current provide projection). */
function fakeSessions() {
const state = { ids: [], byId: {}, current: undefined as string | undefined }
const absentInfo = { sessionId: undefined, hooks: { session: undefined }, props: {} }
return {
list: { getSnapshot: () => state, subscribe: () => () => undefined },
currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => undefined },
function scopedBinding(_ctx: Context, key: string) {
const ctx = new Context()
const binding: ScopedStandardSourceBinding = {
key,
ctx,
hooks: {},
keyedHooks: {},
props: {},
}
return { binding, fiber: ctx.fiber }
}
describe("built-in 'root'", () => {
@@ -404,8 +399,6 @@ describe('declaration injection', () => {
const bench = await boot()
let host: SlotRendererHost | undefined
bench.erased.install({ renderRoot: (value: SlotRendererHost) => { host = value; return null } })
bench.ctx.reflect.provide('sessions', fakeSessions())
bench.ctx.reflect.provide('workspaces', fakeWorkspaces())
const disposeFrame = bench.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)
@@ -422,8 +415,10 @@ describe('declaration injection', () => {
}, C)
bench.erased.register({ name: 't.panel', store: handle }, C)
const panelEntry = host.entriesOf('t.panel')[0]
expect(host.storeOf(panelEntry as never, 's1')).toBeDefined()
const scope = scopedBinding(bench.ctx, 's1')
expect(host.storeOf(panelEntry as never, scope.binding)).toBeDefined()
expect(handle.create).toHaveBeenLastCalledWith('s1')
await scope.fiber.dispose()
})
})
@@ -456,19 +451,9 @@ describe('renderer install seam', () => {
const renderRoot = vi.fn(() => 'tree')
bench.erased.install({ renderRoot })
bench.erased.register({ name: 'root' }, C)
bench.ctx.reflect.provide('sessions', fakeSessions())
bench.ctx.reflect.provide('workspaces', fakeWorkspaces())
expect(bench.erased.renderSlot('root', {})).toBe('tree')
expect(renderRoot).toHaveBeenCalledTimes(1)
})
it('fails before rendering when the Workspace object layer is absent', async () => {
const bench = await boot()
bench.erased.install({ renderRoot: () => null })
bench.erased.register({ name: 'root' }, C)
bench.ctx.reflect.provide('sessions', fakeSessions())
expect(() => bench.erased.renderSlot('root', {})).toThrow(/workspaces service mounted/)
})
})
describe('host face', () => {
@@ -488,17 +473,65 @@ describe('host face', () => {
expect(host.entriesOf('t.host')).toHaveLength(0)
})
it('exposes the session list and the atomic current provide projection', async () => {
it('publishes and retracts domain-owned root standard sources atomically', async () => {
const bench = await boot()
const host = captureHost(bench)
expect(host.sessions.list.getSnapshot()).toMatchObject({ ids: [] })
expect(host.sessions.provideInfo.getSnapshot()).toMatchObject({ sessionId: undefined })
const source = { getSnapshot: () => 1, subscribe: () => () => undefined }
const changed = vi.fn()
host.root.subscribe(changed)
const dispose = bench.svc.provideRoot({ hooks: { sessions: source }, props: { ready: true } })
expect(host.root.getSnapshot()).toEqual({
key: undefined,
hooks: { sessions: source },
keyedHooks: {},
props: { ready: true },
})
expect(changed).toHaveBeenCalledOnce()
dispose()
expect(host.root.getSnapshot()).toEqual({ key: undefined, hooks: {}, keyedHooks: {}, props: {} })
expect(changed).toHaveBeenCalledTimes(2)
})
it('exposes the independent Workspace list source', async () => {
it('rejects duplicate final root prop names without publishing a partial binding', async () => {
const bench = await boot()
const host = captureHost(bench)
expect(host.workspaces.list.getSnapshot()).toEqual({ items: [], phase: 'ready' })
const source = { getSnapshot: () => 1, subscribe: () => () => undefined }
bench.svc.provideRoot({ hooks: { feature: source } })
const before = host.root.getSnapshot()
expect(() => bench.svc.provideRoot({ hooks: { feature: source } }))
.toThrow("duplicate root standard hook 'feature' at prop 'useFeature'")
expect(() => bench.svc.provideRoot({ keyedHooks: { feature: () => source } }))
.toThrow("duplicate root standard keyed hook 'feature' at prop 'useFeature'")
expect(() => bench.svc.provideRoot({ props: { useFeature: true } }))
.toThrow("duplicate root standard prop 'useFeature' at prop 'useFeature'")
expect(host.root.getSnapshot()).toBe(before)
})
it('publishes scope-adapter install and disposal revisions', async () => {
const bench = await boot()
const host = captureHost(bench)
const absent = { key: undefined, hooks: {}, keyedHooks: {}, props: {} }
const adapter = {
current: { getSnapshot: () => absent, subscribe: () => () => undefined },
resolve: () => undefined,
}
const changed = vi.fn()
host.scopeRevision.subscribe(changed)
const owner = bench.ctx.plugin({
name: 'session-scope-owner',
inject: ['slots'],
apply: (ctx: Context) => { ctx.slots.installScope('session', adapter) },
})
await owner.await()
expect(host.scopeRevision.getSnapshot()).toBe(1)
expect(changed).toHaveBeenCalledOnce()
expect(host.scope('session')).toBe(adapter)
expect(host.scope('session-maybe')).toBe(adapter)
expect(() => { bench.svc.installScope('session', adapter) }).toThrow(/already has an adapter/)
await owner.dispose()
expect(host.scopeRevision.getSnapshot()).toBe(2)
expect(changed).toHaveBeenCalledTimes(2)
expect(host.scope('session')).toBeUndefined()
})
})
@@ -508,6 +541,7 @@ describe('store instance axis', () => {
const bench = await boot()
const host = captureHost(bench, {
't.host': { kind: 'single', scope: 'root' },
't.maybe': { kind: 'single', scope: 'session-maybe' },
't.rows': { kind: 'list', scope: 'root' },
't.panel': { kind: 'single', scope: 'session' },
})
@@ -534,13 +568,16 @@ describe('store instance axis', () => {
const { handle } = fakeHandle()
bench.erased.register({ name: 't.panel', store: handle }, C)
const [entry] = host.entriesOf('t.panel')
const s1 = host.storeOf(entry as never, 's1')
const s2 = host.storeOf(entry as never, 's2')
const scope1 = scopedBinding(bench.ctx, 's1')
const scope2 = scopedBinding(bench.ctx, 's2')
const s1 = host.storeOf(entry as never, scope1.binding)
const s2 = host.storeOf(entry as never, scope2.binding)
expect(s1).not.toBe(s2)
expect(host.storeOf(entry as never, 's1')).toBe(s1) // cached per key
expect(host.storeOf(entry as never, scope1.binding)).toBe(s1) // cached per key
expect(handle.create).toHaveBeenCalledWith('s1')
expect(handle.create).toHaveBeenCalledWith('s2')
expect(() => host.storeOf(entry as never, undefined)).toThrow(/requires a session id/)
await Promise.all([scope1.fiber.dispose(), scope2.fiber.dispose()])
})
it('mints a fresh handle per register for the factory (exclusive) form', async () => {
@@ -569,21 +606,44 @@ describe('store instance axis', () => {
// resolution is covered through the cascade spec below.
})
it('pruneStoreScope clears persisted state per dead session, including never-materialized ones', async () => {
it('clears a materialized per-session instance with its binding lifetime', async () => {
const { bench, host } = await storeBench()
const { handle, created } = fakeHandle()
bench.erased.register({ name: 't.panel', store: handle }, C)
const [entry] = host.entriesOf('t.panel')
const s1 = host.storeOf(entry as never, 's1')
const scope = scopedBinding(bench.ctx, 's1')
const s1 = host.storeOf(entry as never, scope.binding)
expect(s1).toBe(created[0]) // the resolved instance is the fake the handle minted
bench.svc.pruneStoreScope('s1')
await scope.fiber.dispose()
expect(created[0]?.clearPersisted).toHaveBeenCalledTimes(1)
expect(host.storeOf(entry as never, 's1')).not.toBe(s1) // instance dropped, next resolve mints anew
// Never-rendered dead session: a transient instance is created just to clear storage.
const before = created.length
bench.svc.pruneStoreScope('s-never')
expect(created.length).toBe(before + 1)
expect(created[created.length - 1]?.clearPersisted).toHaveBeenCalledTimes(1)
const replacement = scopedBinding(bench.ctx, 's1')
expect(host.storeOf(entry as never, replacement.binding)).not.toBe(s1)
await replacement.fiber.dispose()
})
it('clears session-maybe state through binding disposal and creates a fresh instance on reuse', async () => {
const { bench, host } = await storeBench()
bench.svc.installScope('session', {
current: {
getSnapshot: () => ({ key: undefined, hooks: {}, keyedHooks: {}, props: {} }),
subscribe: () => () => undefined,
},
resolve: () => undefined,
})
const { handle, created } = fakeHandle()
bench.erased.register({ name: 't.maybe', store: handle }, C)
const [entry] = host.entriesOf('t.maybe')
const scope = scopedBinding(bench.ctx, 's1')
const before = host.storeOf(entry as never, scope.binding)
await scope.fiber.dispose()
expect(created[0]?.clearPersisted).toHaveBeenCalledOnce()
const replacement = scopedBinding(bench.ctx, 's1')
const after = host.storeOf(entry as never, replacement.binding)
expect(after).not.toBe(before)
expect(handle.create).toHaveBeenLastCalledWith('s1')
await replacement.fiber.dispose()
})
})
@@ -594,8 +654,6 @@ describe('entry-unload cascade', () => {
bench.erased.install({
renderRoot: (h: SlotRendererHost) => { host = h; return 'rendered' },
})
bench.ctx.reflect.provide('sessions', fakeSessions())
bench.ctx.reflect.provide('workspaces', fakeWorkspaces())
// The declarer here is NOT the root occupant: root stays occupied by a
// separate entry so disposing the declarer only kills its children.
const disposeRoot = bench.erased.register({ name: 'root' }, C)
@@ -11,6 +11,7 @@ import { describe, expect, it, vi } from 'vitest'
import { act, render } from '@testing-library/react'
import {
SlotCore, StaleAuthorizationError, type PropsRenderSlots, type SlotRendererHost,
type SlotScopeAdapter, type StandardSourceBinding,
} from '@deepseek-ai/dsh-client-ui-slots'
import { createSlotRenderer } from '../src/client/scoped-slots.tsx'
@@ -28,7 +29,20 @@ type FrameSlots = PropsRenderSlots<'spec.single' | 'spec.list'>
/** Passthrough host over the real core (store/session seats unused here). */
function hostOver(core: SlotCore): SlotRendererHost {
const absentInfo = { sessionId: undefined, hooks: {}, props: {} }
const absentBinding: StandardSourceBinding = {
key: undefined,
hooks: {},
keyedHooks: {},
props: {},
}
const bindingSource = {
getSnapshot: () => absentBinding,
subscribe: () => () => {},
}
const sessionAdapter: SlotScopeAdapter = {
current: bindingSource,
resolve: () => undefined,
}
return {
subscribe: (key, fn) => core.subscribe(key, fn),
getVersion: key => core.getVersion(key),
@@ -38,13 +52,9 @@ function hostOver(core: SlotCore): SlotRendererHost {
specOf: key => core.specDynamic(key),
isLive: entry => core.isLive(entry),
storeOf: () => undefined,
sessions: {
list: { getSnapshot: () => ({}), subscribe: () => () => {} },
provideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} },
},
workspaces: {
list: { getSnapshot: () => ({}), subscribe: () => () => {} },
},
root: bindingSource,
scopeRevision: { getSnapshot: () => 0, subscribe: () => () => {} },
scope: () => sessionAdapter,
}
}
@@ -5,26 +5,31 @@
* binding, session pair, global useSessions, store pair), inject execution
* point (inside component bodies, contained per entry) and parameter
* derivation, and cache granularity (entry x scope key). Ledger semantics
* (declaration conflicts, store instance accounting) belong to the runtime
* (declaration conflicts, store instance accounting) belong to the renderer
* SlotRegistry suite, not here.
*/
import { describe, expect, it, vi } from 'vitest'
import { act, fireEvent, render } from '@testing-library/react'
import { useEffect, useState, type ReactNode } from 'react'
import { Context } from '@deepseek-ai/cordis'
import {
SlotOwnershipError, StaleAuthorizationError,
type ActionsDecl, type SlotEntryDef, type SlotSpec, type StoreHandle, type StoredEntry,
type ActionsDecl, type SessionProviderComponent, type SlotEntryDef,
type SlotSpec, type StoreHandle, type StoredEntry,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
import type {
RenderOpts, SessionProvideInfo, SlotRendererHost, StoreInstanceLike,
RenderOpts, ScopedStandardSourceBinding, SlotRendererHost, SlotScopeAdapter,
StandardSourceBinding, StoreInstanceLike,
} from '@deepseek-ai/dsh-client-ui-renderer/client'
import { createSlotRenderer } from '../src/client/scoped-slots.tsx'
import { SessionProvider } from '../src/client/session-provider.tsx'
type AnyProps = Record<string, unknown>
type RenderSlotFn = (key: string, owner: object, opts?: RenderOpts) => ReactNode
type RenderSlotChainFn = (key: string, owner: object, opts?: { fallback?: ReactNode; overlay?: boolean }) => ReactNode
type RenderSlotChainFn = (
key: string,
owner: object,
opts?: { fallback?: ReactNode; fallbackOnly?: boolean; 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 =>
@@ -35,7 +40,7 @@ const entryOf = (partial: Omit<StoredEntry, 'options'> & { options?: StoredEntry
* create(scopeKey?) + instance with clearPersisted): the machinery consumes
* only the StoreInstanceLike face (bare snapshot source + baked actions),
* but entry.store is typed to the full contract — the real defineStore lives
* in runtime, which UI-renderer tests must not import (dependency direction).
* in client-store, which UI-renderer tests must not import (dependency direction).
*/
function miniStore<T extends object>(
init: () => T,
@@ -76,11 +81,12 @@ function observable<T>(initial: T) {
/**
* Behavioral SlotRendererHost fake: registration mutates entries, bumps the
* key version, and notifies synchronously (batching semantics belong to the
* runtime host, not this package's outlets). Store instances resolve through
* registry host, not this package's outlets). Store instances resolve through
* the entry's real handle, cached per (entry x scope key) like the real
* ledger; session cells are identity-stable per id.
* ledger; session bindings are identity-stable per id.
*/
function makeHost() {
const scopeCtx = new Context()
const entries = new Map<string, StoredEntry[]>()
const specs = new Map<string, DeclaredSpec>()
const versions = new Map<string, number>()
@@ -90,11 +96,31 @@ function makeHost() {
const storeCache = new Map<StoredEntry, Map<string, StoreInstanceLike>>()
const list = observable<{ ids: string[] }>({ ids: [] })
const workspaces = observable<{ ids: string[] }>({ ids: [] })
const absentInfo: SessionMaybeProvideInfo = { sessionId: undefined, hooks: {}, props: {} }
const provide = observable<SessionMaybeProvideInfo>(absentInfo)
const absentBinding: StandardSourceBinding = {
key: undefined,
hooks: { session: undefined },
keyedHooks: {},
props: { sessionId: undefined },
}
const currentBinding = observable<StandardSourceBinding>(absentBinding)
let currentId: string | undefined
const infos = new Map<string, SessionProvideInfo>()
const bindings = new Map<string, ScopedStandardSourceBinding>()
const sessionSources = new Map<string, ReturnType<typeof observable<unknown>>>()
const root = observable<StandardSourceBinding>({
key: undefined,
hooks: { sessions: list, workspaces },
keyedHooks: {},
props: {},
})
const sessionAdapter: SlotScopeAdapter = {
current: currentBinding,
resolve: key => bindings.get(key),
renderArea: (binding, { empty, children }) => binding.key === undefined
? <>{empty?.() ?? null}</>
: <>{children}</>,
}
const scopeRevision = observable(0)
let activeScopeAdapter = sessionAdapter
const bump = (key: string) => {
versions.set(key, (versions.get(key) ?? 0) + 1)
@@ -133,40 +159,38 @@ function makeHost() {
},
specOf: key => specs.get(key),
isLive: entry => live.has(entry),
storeOf: (entry, scopeKey) => {
storeOf: (entry, scopeBinding) => {
if (entry.store === undefined) return undefined
let perScope = storeCache.get(entry)
if (!perScope) {
perScope = new Map()
storeCache.set(entry, perScope)
}
const cacheKey = scopeKey ?? ''
const cacheKey = scopeBinding?.key ?? ''
let instance = perScope.get(cacheKey)
if (!instance) {
// Fake entries always carry engine handles (never factories), and the
// engine create() takes the scope key (persist suffixing).
const handle = entry.store as { create(scopeKey?: string): StoreInstanceLike }
instance = handle.create(scopeKey)
instance = handle.create(scopeBinding?.key)
perScope.set(cacheKey, instance)
}
return instance
},
sessions: {
list,
provideInfo: provide,
},
workspaces: { list: workspaces },
root,
scopeRevision,
scope: () => activeScopeAdapter,
}
return {
host,
list,
workspaces,
// Driver surface: set(id) publishes the resolved bundle (or the absent
// projection) through the provide source.
// Driver surface: set(id) publishes the resolved binding (or the absent
// projection) through the scope adapter.
current: {
set: (id: string | undefined) => {
currentId = id
provide.set((id === undefined ? undefined : infos.get(id)) ?? absentInfo)
currentBinding.set((id === undefined ? undefined : bindings.get(id)) ?? absentBinding)
},
},
declare: (key: string, spec: DeclaredSpec) => { specs.set(key, spec); bump(key) },
@@ -188,33 +212,46 @@ function makeHost() {
bump(key)
}
},
addSession: (id: string, initial: unknown = { sid: id }): SessionProvideInfo => {
// Bare source per bundle (identity-stable): the machinery binds useSession from it.
addSession: (id: string, initial: unknown = { sid: id }): ScopedStandardSourceBinding => {
// Bare source per binding (identity-stable): the machinery binds useSession from it.
const session = observable<unknown>(initial)
const info: SessionProvideInfo = {
sessionId: id,
const binding: ScopedStandardSourceBinding = {
key: id,
ctx: scopeCtx,
hooks: { session },
props: {},
keyedHooks: {},
props: { sessionId: id },
}
sessionSources.set(id, session)
infos.set(id, info)
if (currentId === id) provide.set(info)
return info
bindings.set(id, binding)
if (currentId === id) currentBinding.set(binding)
return binding
},
setSession: (id: string, snapshot: unknown) => {
const source = sessionSources.get(id)
if (source === undefined) throw new Error(`unknown test session: ${id}`)
source.set(snapshot)
},
replaceScope: (adapter: SlotScopeAdapter) => {
activeScopeAdapter = adapter
scopeRevision.set(scopeRevision.getSnapshot() + 1)
},
}
}
type Fake = ReturnType<typeof makeHost>
/** Mount a root entry whose component renders `body` with its kit renderSlot. */
function mountRoot(h: Fake, children: Record<string, DeclaredSpec>, body: (renderSlot: RenderSlotFn) => ReactNode) {
function mountRoot(
h: Fake,
children: Record<string, DeclaredSpec>,
body: (renderSlot: RenderSlotFn, SessionProvider: SessionProviderComponent) => ReactNode,
) {
const dispose = h.add('root', {
component: (props: { renderSlot: RenderSlotFn }) => <>{body(props.renderSlot)}</>,
component: (props: {
renderSlot: RenderSlotFn
SessionProvider: SessionProviderComponent
}) => <>{body(props.renderSlot, props.SessionProvider)}</>,
children,
})
const renderer = createSlotRenderer()
@@ -225,6 +262,7 @@ function mountRoot(h: Fake, children: Record<string, DeclaredSpec>, body: (rende
const SINGLE_ROOT: DeclaredSpec = { kind: 'single', scope: 'root' }
const SINGLE_SESSION: DeclaredSpec = { kind: 'single', scope: 'session' }
const CHAIN_ROOT: DeclaredSpec = { kind: 'chain', scope: 'root' }
const CHAIN_SESSION: DeclaredSpec = { kind: 'chain', scope: 'session' }
/** Chain entry literal: top-level select, priority in the options bag (the StoredEntry chain shape). */
const chainEntryOf = (partial: {
@@ -566,6 +604,31 @@ describe('overlay chains (ChainRenderOpts.overlay)', () => {
expect(mounted).toHaveBeenCalledTimes(1)
})
it('keeps an explicit fallback-only strict chain mounted until its Session scope exists', () => {
const h = makeHost()
h.declare('k.chain', CHAIN_SESSION)
h.addSession('s1')
const select = vi.fn(() => null)
h.add('k.chain', chainEntryOf({ component: () => <b>never</b>, select }))
let fallbackOnly = true
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_SESSION },
renderSlotChain => renderSlotChain(
'k.chain',
{},
{ fallback: <input aria-label="resident" />, fallbackOnly, overlay: true },
))
const input = view.getByRole('textbox', { name: 'resident' })
expect(select).not.toHaveBeenCalled()
fallbackOnly = false
act(() => {
h.current.set('s1')
h.add('root', { component: () => null })
})
expect(view.getByRole('textbox', { name: 'resident' })).toBe(input)
expect(select).toHaveBeenCalledOnce()
})
it('leaves non-overlay chains on the unmount path: a takeover discards fallback state', () => {
const h = makeHost()
h.declare('k.chain', CHAIN_ROOT)
@@ -656,9 +719,9 @@ describe('standard-kit synthesis', () => {
return null
},
})
mountRoot(h, { 'k.session': SINGLE_SESSION }, renderSlot => (
mountRoot(h, { 'k.session': SINGLE_SESSION }, (renderSlot, SessionProvider) => (
<SessionProvider empty={() => <i>empty</i>}>
{() => renderSlot('k.session', {})}
{renderSlot('k.session', {})}
</SessionProvider>
))
act(() => { h.current.set('s1') })
@@ -698,11 +761,11 @@ describe('standard-kit synthesis', () => {
return <b data-turn={label}>{String(useTurnData('tail'))}</b>
},
})
const { view } = mountRoot(h, { 'k.session': sessionSpec }, renderSlot => (
<SessionProvider>{() => <>
const { view } = mountRoot(h, { 'k.session': sessionSpec }, (renderSlot, SessionProvider) => (
<SessionProvider><>
{renderSlot('k.session', { label: 'one' }, { hookContext: 1 })}
{renderSlot('k.session', { label: 'two' }, { hookContext: 2 })}
</>}
</>
</SessionProvider>
))
act(() => { h.current.set('s1') })
@@ -746,11 +809,11 @@ describe('standard-kit synthesis', () => {
h.add('root', {
component: (props: AnyProps) => {
rootSeen.push(props)
const Provider = props['SessionProvider'] as typeof SessionProvider
const Provider = props['SessionProvider'] as SessionProviderComponent
const renderSlot = props['renderSlot'] as RenderSlotFn
return (
<Provider empty={() => <i>empty</i>}>
{() => renderSlot('k.session', {})}
{renderSlot('k.session', {})}
</Provider>
)
},
@@ -773,15 +836,15 @@ describe('standard-kit synthesis', () => {
expect(seen2.at(-1)!['SessionProvider']).toBeUndefined()
})
it('renders nothing for a strict session slot while no session is current', () => {
// Strict session entries decline (render null) without a session; the
// loud path is reserved for a missing root binding provider.
it('fails loud for a strict session slot while no session is current', () => {
const h = makeHost()
h.declare('k.session', SINGLE_SESSION)
h.add('k.session', { component: () => <b>x</b> })
const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION },
renderSlot => renderSlot('k.session', {}))
expect(view.container.querySelector('b')).toBeNull()
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => mountRoot(h, { 'k.session': SINGLE_SESSION },
renderSlot => renderSlot('k.session', {})))
.toThrow("strict session slot 'k.session' rendered without a scope binding")
spy.mockRestore()
})
it('delivers the store pair for store-declaring entries and writes through baked actions', () => {
@@ -822,8 +885,8 @@ describe('standard-kit synthesis', () => {
},
store: handle,
})
const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION }, renderSlot => (
<SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>
const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION }, (renderSlot, SessionProvider) => (
<SessionProvider>{renderSlot('k.session', {})}</SessionProvider>
))
act(() => { h.current.set('s1') })
act(() => { setDraft('draft-one') })
@@ -877,8 +940,8 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
component: ({ sid }: { sid?: string }) => <b>{sid}</b>,
inject: inject,
})
const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION }, renderSlot => (
<SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>
const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION }, (renderSlot, SessionProvider) => (
<SessionProvider>{renderSlot('k.session', {})}</SessionProvider>
))
act(() => { h.current.set('s1') })
expect(view.container.textContent).toBe('s1')
@@ -912,9 +975,9 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
inject: sessionInject,
store: handle,
})
mountRoot(h, { 'k.single': SINGLE_ROOT, 'k.session': SINGLE_SESSION }, renderSlot => <>
mountRoot(h, { 'k.single': SINGLE_ROOT, 'k.session': SINGLE_SESSION }, (renderSlot, SessionProvider) => <>
{renderSlot('k.single', {})}
<SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>
<SessionProvider>{renderSlot('k.session', {})}</SessionProvider>
</>)
act(() => { h.current.set('s1') })
// The inject-received actions are the same baked callbacks the component
@@ -1029,4 +1092,24 @@ describe('session-maybe adoption identity', () => {
act(() => { h.current.set('s1') })
expect(view.container.textContent).toBe('s1#1')
})
it('rebinds mounted scope consumers when the installed adapter changes', () => {
const h = makeHost()
const { view } = mountMaybeCounter(h)
expect(view.container.textContent).toBe('blank#1')
const binding: ScopedStandardSourceBinding = {
key: 'replacement',
ctx: new Context(),
hooks: { session: observable({ sid: 'replacement' }) },
keyedHooks: {},
props: { sessionId: 'replacement' },
}
act(() => {
h.replaceScope({
current: observable(binding),
resolve: key => key === binding.key ? binding : undefined,
})
})
expect(view.container.textContent).toBe('replacement#1')
})
})
@@ -1,11 +1,17 @@
// @vitest-environment jsdom
import { useEffect, useRef } from 'react'
import { Fragment, useEffect, useRef } from 'react'
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import { act, render } from '@testing-library/react'
import type { SessionMaybeProvideInfo, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionProvideInfo, SlotRendererHost } from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {
SessionProviderComponent, StoredEntry,
} from '@deepseek-ai/dsh-client-ui-slots'
import type {
ScopedStandardSourceBinding, SlotRendererHost, SlotScopeAdapter, StandardSourceBinding,
} from '@deepseek-ai/dsh-client-ui-renderer/client'
import { createSlotRenderer } from '../src/client/scoped-slots.tsx'
import { SessionProvider } from '../src/client/session-provider.tsx'
type SessionBinding = ScopedStandardSourceBinding
function observable<T>(initial: T) {
let value = initial
@@ -18,19 +24,52 @@ function observable<T>(initial: T) {
}
/**
* Minimal host: SessionProvider only reads sessions.provideInfo, but it must
* Minimal host: SessionProvider only reads the Session scope adapter, but it must
* render inside the renderer tree (HostContext), so the harness mounts a real
* root entry whose body is the test's render-prop provider.
*/
function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.ReactNode) => React.ReactNode }) {
const absentInfo: SessionMaybeProvideInfo = { sessionId: undefined, hooks: { session: undefined }, props: {} }
const provide = observable<SessionMaybeProvideInfo>(absentInfo)
function makeHost(
bodies: {
root: (
rp: (key: string, owner: object) => React.ReactNode,
SessionProvider: SessionProviderComponent,
) => React.ReactNode
},
options: { installRenderArea?: boolean } = {},
) {
const scopeCtx = new Context()
const absentBinding: StandardSourceBinding = {
key: undefined,
hooks: { session: undefined },
keyedHooks: {},
props: { sessionId: undefined },
}
const currentBinding = observable<StandardSourceBinding>(absentBinding)
let currentId: string | undefined
const infos = new Map<string, SessionProvideInfo>()
const bindings = new Map<string, SessionBinding>()
const sessionEntries: StoredEntry[] = []
const root = observable<StandardSourceBinding>({
key: undefined,
hooks: {},
keyedHooks: {},
props: {},
})
const sessionAdapter: SlotScopeAdapter = {
current: currentBinding,
resolve: key => bindings.get(key),
...(options.installRenderArea === false
? {}
: {
renderArea: (binding, { empty, children }) => binding.key === undefined
? <>{empty?.() ?? null}</>
: <Fragment key={binding.key}>{children}</Fragment>,
}),
}
const rootEntry: StoredEntry = {
component: (props: { renderSlot: (key: string, owner: object) => React.ReactNode }) =>
<>{bodies.root(props.renderSlot)}</>,
component: (props: {
renderSlot: (key: string, owner: object) => React.ReactNode
SessionProvider: SessionProviderComponent
}) => <>{bodies.root(props.renderSlot, props.SessionProvider)}</>,
options: {},
children: { 'k.session': { kind: 'single', scope: 'session' } },
}
@@ -45,37 +84,37 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea
specOf: key => key === 'k.session' ? { kind: 'single', scope: 'session' } : undefined,
isLive: () => true,
storeOf: () => undefined,
sessions: {
list: observable<unknown>({ ids: [] }),
provideInfo: provide,
},
workspaces: { list: observable<unknown>({ items: [] }) },
root,
scopeRevision: observable(0),
scope: () => sessionAdapter,
}
return {
host,
// Driver surface: set(id) publishes the resolved bundle (or the absent
// projection) through the provide source.
// Driver surface: set(id) publishes the resolved binding (or the absent
// projection) through the scope adapter.
current: {
set: (id: string | undefined) => {
currentId = id
provide.set((id === undefined ? undefined : infos.get(id)) ?? absentInfo)
currentBinding.set((id === undefined ? undefined : bindings.get(id)) ?? absentBinding)
},
},
addSession: (id: string) => {
// Bare source per bundle (identity-stable): the machinery binds useSession from it.
const info: SessionProvideInfo = {
sessionId: id,
// Bare source per binding (identity-stable): the machinery binds useSession from it.
const binding: SessionBinding = {
key: id,
ctx: scopeCtx,
hooks: { session: { getSnapshot: () => ({ sid: id }), subscribe: () => () => {} } },
props: {},
keyedHooks: {},
props: { sessionId: id },
}
infos.set(id, info)
if (currentId === id) provide.set(info)
return info
bindings.set(id, binding)
if (currentId === id) currentBinding.set(binding)
return binding
},
/** Swap one session's bundle in place (roster-change stand-in); republish when current. */
replaceSession: (info: SessionProvideInfo) => {
infos.set(info.sessionId, info)
if (currentId === info.sessionId) provide.set(info)
/** Swap one session's binding in place (roster-change stand-in); republish when current. */
replaceSession: (binding: SessionBinding) => {
bindings.set(binding.key, binding)
if (currentId === binding.key) currentBinding.set(binding)
},
registerSession: (entry: StoredEntry) => { sessionEntries.push(entry) },
}
@@ -84,9 +123,9 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea
describe('SessionProvider', () => {
it('renders empty without a current session, switches to the body on select, falls back on an unresolvable id', () => {
const h = makeHost({
root: () => (
root: (_renderSlot, SessionProvider) => (
<SessionProvider empty={() => <span>empty</span>}>
{id => <div data-testid="body">{id}</div>}
<div data-testid="body">session</div>
</SessionProvider>
),
})
@@ -94,14 +133,14 @@ describe('SessionProvider', () => {
const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
expect(view.container.textContent).toBe('empty')
act(() => { h.current.set('s1') })
expect(view.container.textContent).toBe('s1')
expect(view.container.textContent).toBe('session')
act(() => { h.current.set('ghost') }) // listed nowhere: cell() misses
expect(view.container.textContent).toBe('empty')
})
it('renders null empty state when the empty prop is omitted', () => {
const h = makeHost({
root: () => <SessionProvider>{id => <b>{id}</b>}</SessionProvider>,
root: (_renderSlot, SessionProvider) => <SessionProvider><b>session</b></SessionProvider>,
})
const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
expect(view.container.textContent).toBe('')
@@ -118,7 +157,13 @@ describe('SessionProvider', () => {
return <div>{id}</div>
}
const h = makeHost({
root: () => <SessionProvider>{id => <Body id={id} />}</SessionProvider>,
root: (renderSlot, SessionProvider) => (
<SessionProvider>{renderSlot('k.session', {})}</SessionProvider>
),
})
h.registerSession({
component: (props: { sessionId?: string }) => <Body id={props.sessionId ?? 'missing'} />,
options: {},
})
h.addSession('s1')
h.addSession('s2')
@@ -135,7 +180,9 @@ describe('SessionProvider', () => {
it('delivers the resolved cell to session slots under it (observable behavior, not context internals)', () => {
const seen: Record<string, unknown>[] = []
const h = makeHost({
root: renderSlot => <SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>,
root: (renderSlot, SessionProvider) => (
<SessionProvider>{renderSlot('k.session', {})}</SessionProvider>
),
})
h.addSession('s1')
h.addSession('s2')
@@ -160,7 +207,9 @@ describe('SessionProvider', () => {
it('republishes a mounted session entry when its provide bundle changes under the same id', () => {
const seen: unknown[] = []
const h = makeHost({
root: renderSlot => <SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>,
root: (renderSlot, SessionProvider) => (
<SessionProvider>{renderSlot('k.session', {})}</SessionProvider>
),
})
const original = h.addSession('s1')
h.registerSession({
@@ -173,17 +222,17 @@ describe('SessionProvider', () => {
render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
act(() => { h.current.set('s1') })
expect(seen.at(-1)).toBeUndefined()
// A provider-roster change rematerializes the bundle; the provide source
// A provider-roster change rematerializes the binding; the scope source
// must carry it to already-mounted entries without a selection change.
act(() => { h.replaceSession({ ...original, props: { feature: 'now-live' } }) })
expect(seen.at(-1)).toBe('now-live')
})
it('fails loud when mounted outside the renderer tree (no host channel)', () => {
it('fails loud when the Session scope owner omits its area renderer', () => {
const h = makeHost({ root: () => null }, { installRenderArea: false })
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => render(
<SessionProvider>{id => <b>{id}</b>}</SessionProvider>,
)).toThrow(/outside the installed renderer tree/)
expect(() => render(<>{createSlotRenderer().renderRoot(h.host, {})}</>))
.toThrow(/does not provide its area renderer/)
spy.mockRestore()
})
})
@@ -9,7 +9,9 @@ import type { ReactNode } from 'react'
import {
StaleAuthorizationError, type SlotEntryDef, type SlotSpec, type StoredEntry,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { RenderOpts, SlotRendererHost } from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {
RenderOpts, SlotRendererHost, SlotScopeAdapter, StandardSourceBinding,
} from '@deepseek-ai/dsh-client-ui-renderer/client'
import { createSlotRenderer } from '../src/client/scoped-slots.tsx'
type RenderSlotFn = (key: string, owner: object, opts?: RenderOpts) => ReactNode
@@ -21,7 +23,20 @@ function makeHost() {
const versions = new Map<string, number>()
const subs = new Map<string, Set<() => void>>()
const live = new Set<StoredEntry>()
const absentInfo = { sessionId: undefined, hooks: {}, props: {} }
const absentBinding: StandardSourceBinding = {
key: undefined,
hooks: {},
keyedHooks: {},
props: {},
}
const bindingSource = {
getSnapshot: () => absentBinding,
subscribe: () => () => {},
}
const sessionAdapter: SlotScopeAdapter = {
current: bindingSource,
resolve: () => undefined,
}
const bump = (key: string) => {
versions.set(key, (versions.get(key) ?? 0) + 1)
for (const fn of [...(subs.get(key) ?? [])]) fn()
@@ -42,13 +57,9 @@ function makeHost() {
specOf: () => ({ kind: 'single', scope: 'root' }),
isLive: entry => live.has(entry),
storeOf: () => undefined,
sessions: {
list: { getSnapshot: () => ({}), subscribe: () => () => {} },
provideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} },
},
workspaces: {
list: { getSnapshot: () => ({}), subscribe: () => () => {} },
},
root: bindingSource,
scopeRevision: { getSnapshot: () => 0, subscribe: () => () => {} },
scope: () => sessionAdapter,
}
return {
host,
@@ -2,9 +2,8 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup } from '@testing-library/react'
import { Context } from '@deepseek-ai/cordis'
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import { TestSessions, TestWorkspaces } from '@deepseek-ai/dsh-client-test-runtime'
import type { Stabilizer } from '@deepseek-ai/dsh-client-test-runtime'
import { SlotRegistry } from '../src/client/registry.ts'
import type { SlotScopeAdapter, StandardSourceBinding } from '../src/client/index.ts'
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-renderer'
import * as UiRenderer from '../src/client/index.ts'
@@ -17,16 +16,30 @@ afterEach(() => {
document.body.innerHTML = ''
})
const stabilize: Stabilizer = async (fn) => { await act(async () => { await fn() }) }
const stabilize = async (fn: () => void | Promise<void>): Promise<void> => {
await act(async () => { await fn() })
}
async function bench() {
const ctx = new Context()
await ctx.plugin(SlotRegistry).await()
const slots = ctx.get('slots') as SlotRegistry
ctx.provide('sessions', new TestSessions(stabilize, ctx))
ctx.provide('workspaces', new TestWorkspaces(stabilize))
const fiber = ctx.plugin({ inject: [...UiRenderer.inject], apply: UiRenderer.apply })
await fiber.await()
const slots = ctx.get('slots') as SlotRegistry
const absentBinding: StandardSourceBinding = {
key: undefined,
hooks: {},
keyedHooks: {},
props: {},
}
const current = {
getSnapshot: () => absentBinding,
subscribe: () => () => {},
}
const adapter: SlotScopeAdapter = {
current,
resolve: () => undefined,
}
slots.installScope('session', adapter)
return { ctx, slots, fiber }
}
@@ -4,16 +4,21 @@
* docs/subsystems/session-projection.md): 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 value, no face, no
* session) uniformly read `undefined`; live value changes re-render; the
* selector overload runs over the whole value.
* over the binding's projection source family; unresolved keys and absent
* sessions read `undefined`; live value 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 { SessionMaybeProvideInfo, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
import type { SlotRendererHost } from '@deepseek-ai/dsh-client-ui-renderer/client'
import { Context } from '@deepseek-ai/cordis'
import type { StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
import type {
ScopedStandardSourceBinding, SlotRendererHost, SlotScopeAdapter, StandardSourceBinding,
} from '@deepseek-ai/dsh-client-ui-renderer/client'
import { createSlotRenderer } from '../src/client/scoped-slots.tsx'
type SessionBinding = ScopedStandardSourceBinding
function observable<T>(initial: T) {
let value = initial
const subs = new Set<() => void>()
@@ -27,25 +32,51 @@ function observable<T>(initial: T) {
type UseProjectionProp = (key: string, selector?: (v: unknown) => unknown) => unknown
function makeHost() {
const absentInfo: SessionMaybeProvideInfo = { sessionId: undefined, hooks: { session: undefined }, props: {} }
const provide = observable<SessionMaybeProvideInfo>(absentInfo)
const scopeCtx = new Context()
const absentBinding: StandardSourceBinding = {
key: undefined,
hooks: { session: undefined },
keyedHooks: { projection: undefined },
props: { sessionId: undefined },
}
const currentBinding = observable<StandardSourceBinding>(absentBinding)
const cells = new Map<string, ReturnType<typeof observable<unknown>>>()
/** Store-parallel face: always defined per key; an unseen key snapshots undefined. */
/** Store-parallel source family: an unseen key snapshots undefined. */
const absent = { getSnapshot: () => undefined, subscribe: () => () => {} }
const sessionEntries: StoredEntry[] = []
let withFace = true
const bindings = new Map<string, SessionBinding>()
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): SessionMaybeProvideInfo => ({
sessionId: id,
hooks: { session: { getSnapshot: () => ({ sid: id }), subscribe: () => () => {} } },
const binding = (id: string): SessionBinding => {
const cached = bindings.get(id)
if (cached !== undefined) return cached
const value: SessionBinding = {
key: id,
ctx: scopeCtx,
hooks: { session: { getSnapshot: () => ({ sid: id }), subscribe: () => () => {} } },
keyedHooks: { projection: key => cells.get(key) ?? absent },
props: { sessionId: id },
}
bindings.set(id, value)
return value
}
const root = observable<StandardSourceBinding>({
key: undefined,
hooks: {},
keyedHooks: {},
props: {},
...(withFace ? { projections: { faceOf: (key: string) => cells.get(key) ?? absent } } : {}),
})
const sessionAdapter: SlotScopeAdapter = {
current: currentBinding,
resolve: binding,
renderArea: (scopeBinding, { empty, children }) => scopeBinding.key === undefined
? <>{empty?.() ?? null}</>
: <>{children}</>,
}
const host: SlotRendererHost = {
subscribe: () => () => {},
getVersion: () => 0,
@@ -57,19 +88,19 @@ function makeHost() {
specOf: key => key === 'k.session' ? { kind: 'single', scope: 'session' } : undefined,
isLive: () => true,
storeOf: () => undefined,
sessions: {
list: observable<unknown>({ ids: [] }),
provideInfo: provide,
},
workspaces: { list: observable<unknown>({ items: [] }) },
root,
scopeRevision: observable(0),
scope: () => sessionAdapter,
}
return {
host,
cells,
// Same driver surface as before the atomic provide source: set(id)
// publishes the resolved bundle (or the absent projection) through it.
current: { set: (id: string | undefined) => { provide.set(id === undefined ? absentInfo : info(id)) } },
dropFace: () => { withFace = false },
// The driver publishes the resolved binding or the absent projection.
current: {
set: (id: string | undefined) => {
currentBinding.set(id === undefined ? absentBinding : binding(id))
},
},
registerSession: (entry: StoredEntry) => { sessionEntries.push(entry) },
}
}
@@ -90,8 +121,8 @@ describe('useProjection standard-kit delivery', () => {
},
options: {},
})
h.current.set('s1')
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'] }) })
@@ -110,25 +141,8 @@ describe('useProjection standard-kit delivery', () => {
},
options: {},
})
h.current.set('s1')
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()
})
})
@@ -14,9 +14,6 @@
{
"path": "../ui-slots"
},
{
"path": "../runtime"
},
{
"path": "../../runtime-diagnostics/invariants"
}
+1
View File
@@ -27,6 +27,7 @@
},
"license": "MIT",
"devDependencies": {
"@deepseek-ai/dsh-client-store": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"@deepseek-ai/cordis": "workspace:^"
+16 -20
View File
@@ -177,28 +177,29 @@ export type ScopeOf<K extends keyof SlotMap & string> = SlotMap[K]['scope']
/**
* Framework standard kit delivered to every session-scope slot component.
* Declared EMPTY here (zero-dependency layer): the runtime package merges the
* real members (`useSession` bound to the conversation snapshot and the
* framework-supplied `sessionId`) exactly as consumers merge SlotMap keys.
* Declared empty here (zero-dependency layer): `ui-session` merges the
* Session lifecycle hook, projection hook, and Session identity; domain UI
* adapters merge their own standard hooks exactly as consumers merge SlotMap keys.
*/
export interface SessionStandardProps {}
/**
* Framework standard kit delivered to current-session-optional slots. Its
* hooks stay callable while no session is selected and return `undefined`
* until one becomes current; concrete members merge in at runtime packages.
* until one becomes current; `ui-session` and domain UI adapters merge the
* concrete members.
*/
export interface SessionMaybeStandardProps {}
/**
* Framework standard kit delivered to EVERY slot component (the global seat).
* Declared empty here; the runtime package merges the global object-layer
* selector hooks that shared page composition consumes.
* Declared empty here; each owning UI adapter merges the global selector hooks
* that shared page composition consumes.
*/
export interface GlobalStandardProps {}
/**
* The session id type as the runtime's SessionStandardProps merge declares it
* The session id type as `ui-session`'s SessionStandardProps merge declares it
* (branded); falls back to `string` in programs without the merge (this
* package's own tests).
*/
@@ -233,6 +234,8 @@ export interface RenderOpts<EntryKey extends string = string> {
export interface ChainRenderOpts {
/** The owner's fallback body, rendered when every entry's selector declines. */
fallback?: ReactNode
/** Render only the owner fallback without resolving or dispatching the chain's scope. */
fallbackOnly?: boolean
/**
* Keep the fallback permanently mounted: an election hides it (wrapped,
* display:none) instead of unmounting it, and the all-decline case shows it
@@ -302,25 +305,18 @@ type RenderSlotFn<S extends keyof SlotMap & string> =
export type MatchedShare<E extends SlotEntryDef, M> =
E['kind'] extends 'chain' ? { matched: M } : object
/**
* Conversation-session selector hook alias for props contracts. Wide by
* default at this dependency-inverted layer; the runtime narrows at its
* export outlet (`UseSession<ConversationSnapshot>`).
*/
export type UseSession<Snap extends object = object> = SnapshotSelectorHook<Snap>
/** Props of the standard-kit SessionProvider seat (render-prop form). */
/** Props of the standard-kit SessionProvider seat. */
export interface SessionAreaProps {
/** No-session body (also covers a current id whose session cannot be resolved). */
empty?: (() => ReactNode) | undefined
/** Session body; the framework remounts it per session (key=sessionId). */
children: (sessionId: SessionIdOf) => ReactNode
/** Session body; the framework remounts it per session identity. */
children: ReactNode
}
/**
* Framework-wired session area component. It subscribes to runtime-owned
* session selection and is injected into entries that declare session-scoped
* children; business code does not import it directly.
* Framework-wired session area component. `ui-session` supplies the current
* Controller binding through the renderer scope adapter; entries that declare
* session-scoped children receive this component without importing it.
*/
export type SessionProviderComponent = (props: SessionAreaProps) => ReactNode
+1 -1
View File
@@ -16,7 +16,7 @@ export const inject = ['invariants']
/**
* No runtime invariant: a zero-dependency pure registry core — it emits no
* cordis events itself (the runtime SlotRegistry wrapper owns the event
* cordis events itself (the `ui-renderer` SlotRegistry owns the event
* bridge and its invariants); define/register/dispose sequencing is asserted
* directly by this package's behavior specs.
*/
+78 -59
View File
@@ -1,6 +1,10 @@
/** React-free contracts between the slot host and an installed renderer. */
import type { Context } from '@deepseek-ai/cordis'
import type { ReactNode } from 'react'
import type { SlotEntryDef, SlotSpec, StoredEntry, Translate } from './index.ts'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import type {
SessionAreaProps, SlotEntryDef, SlotScope, SlotSpec, StoredEntry, Translate,
} from './index.ts'
/**
* The locale face the render machinery consumes: namespace binding plus an
@@ -9,7 +13,7 @@ import type { SlotEntryDef, SlotSpec, StoredEntry, Translate } from './index.ts'
* active-locale or registry change; the renderer re-derives each entry's `t`
* from (namespace, revision), so a locale switch hands out NEW function
* references and memoized components re-render naturally. Implemented by the
* locale plugin, installed through the runtime SlotRegistry (installLocale).
* locale plugin, installed through the `ui-renderer` SlotRegistry.
* Install before the first render that needs the seat: outlets bind their
* revision subscription at mount, and a face appearing later has no channel
* to notify already-mounted outlets (the locale plugin is immediately-tier
@@ -27,10 +31,16 @@ export interface LocaleFace extends HostObservable<{ revision: number }> {
bind(ns: string): Translate
}
/** Minimal observable API for host-provided standard-kit data sources. */
export interface HostObservable<T> {
getSnapshot(): T
subscribe(fn: () => void): () => void
/** Observable currency shared by domain sources, stores, and the renderer. */
export type HostObservable<T> = ObservableSnapshot<T>
/**
* Convert one standard source name to its rendered Hook prop name.
* @param name - registered fixed or keyed source name.
* @returns the `use<Name>` prop exposed to Slot components.
*/
export function standardHookPropName(name: string): string {
return `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}`
}
/**
@@ -51,41 +61,57 @@ export interface StoreInstanceLike {
readonly actions: Record<string, (...params: never[]) => void>
}
/** Resolve one member of an open-key standard hook family. */
export type KeyedStandardSource = (key: string) => HostObservable<unknown> | undefined
/**
* Per-session standard props resolved per session id (identity-stable per
* session scope; a recreated scope yields a new info). Plugins contribute
* members through the runtime `sessions.provide` contract; the render side binds
* every `hooks` source into a `use<Name>` selector hook (hooks never appear
* on the host contract) and spreads `props` verbatim. The runtime itself
* contributes the first entry (`'session'` → `useSession`).
* Framework-neutral inputs from which the renderer materializes standard
* props. A scope adapter keeps member names present while its binding is
* absent so optional slots retain a stable Hook call order.
*/
export interface SessionMaybeProvideInfo {
/** Current session id, absent while the application is in no-session mode. */
sessionId: string | undefined
/**
* Static hook roster. Each value is absent with the session; keys remain so
* session-maybe entries always receive the same hook-shaped standard kit.
*/
hooks: Record<string, HostObservable<unknown> | undefined>
/** Static plain-member roster; values are undefined with the session. */
props: Record<string, unknown>
/**
* Key-addressed projection value sources (the useProjection framework seat;
* session-projection subsystem page: docs/subsystems/session-projection.md).
* Unlike `hooks`, the key space is open — values
* arrive from host-computed push frames — so the render side binds per
* resolved key instead of per static roster member. Faces are always
* defined per key (absence is an `undefined` snapshot); the whole member is
* absent with the session.
*/
projections?: { faceOf(key: string): HostObservable<unknown> } | undefined
export interface StandardSourceBinding {
/** Scope identity; absent for root data and an optional scope with no selection. */
readonly key: string | undefined
/** Fixed-name sources; each `name` becomes a `useName` selector Hook. */
readonly hooks: Readonly<Record<string, HostObservable<unknown> | undefined>>
/** Open-key source families; each `name` becomes a `useName(key, selector)` Hook. */
readonly keyedHooks: Readonly<Record<string, KeyedStandardSource | undefined>>
/** Stable plain values copied into standard props. */
readonly props: Readonly<Record<string, unknown>>
}
/** Definite per-session standard props resolved for strict session slots. */
export interface SessionProvideInfo extends SessionMaybeProvideInfo {
sessionId: string
/** Bare observable sources, keyed by hook base name ('session' → useSession). */
hooks: Record<string, HostObservable<unknown>>
/** Materialized binding for one live non-root scope. */
export interface ScopedStandardSourceBinding extends StandardSourceBinding {
readonly key: string
readonly ctx: Context
}
/** One installed source of bindings for a non-root Slot scope. */
export interface SlotScopeAdapter {
/** Binding that follows the current selection, including its absent projection. */
readonly current: HostObservable<StandardSourceBinding>
/**
* Resolve an already-materialized binding.
* @param key - scope identity.
* @returns the binding, or `undefined` when the identity is unavailable.
*/
resolve(key: string): ScopedStandardSourceBinding | undefined
/**
* Render the scope owner's area seat over the current binding. The renderer
* binds this function to the standard `SessionProvider` prop without owning
* Session selection semantics.
* @param binding - current scope binding, including its absent projection.
* @param props - render-prop body and empty branch.
* @returns rendered scope area.
*/
renderArea?(binding: StandardSourceBinding, props: SessionAreaProps): ReactNode
}
/** Root standard-source contribution installed by one domain UI package. */
export interface RootStandardSourceContribution {
readonly hooks?: Readonly<Record<string, HostObservable<unknown>>>
readonly keyedHooks?: Readonly<Record<string, KeyedStandardSource>>
readonly props?: Readonly<Record<string, unknown>>
}
/** renderSlot dispatch options at the machinery level. */
@@ -97,7 +123,7 @@ export interface RenderOpts {
hookContext?: unknown
}
/** Host API the runtime SlotRegistry presents to the installed renderer. */
/** Host API the `ui-renderer` SlotRegistry presents to its React renderer. */
export interface SlotRendererHost {
/**
* Subscribe to a key's registration changes (microtask-batched).
@@ -155,28 +181,21 @@ export interface SlotRendererHost {
* Resolve (create or return cached) the store instance for an entry's
* declared handle under a scope key; lifecycle rides the ledger axis.
* @param entry - entry whose declaration carries the handle.
* @param scopeKey - session id for session-scope slots, undefined for root scope.
* @param scopeBinding - exact Session binding for scoped slots, undefined for root scope.
* @returns the instance, or undefined when the entry declares no store.
*/
storeOf(entry: StoredEntry, scopeKey: string | undefined): StoreInstanceLike | undefined
/** Session-side standard-kit sources. */
sessions: {
/** Session list source backing the useSessions standard hook. */
list: HostObservable<unknown>
/**
* Atomic current-session provide projection used by SessionProvider:
* selection changes and provider-roster changes publish through this one
* source, so a stable current id cannot strand mounted entries on an
* obsolete hook/prop schema. Carries the static roster with sessionId
* undefined while no current session resolves.
*/
provideInfo: HostObservable<SessionMaybeProvideInfo>
}
/** Workspace-side standard-kit sources. */
workspaces: {
/** Workspace list source backing the useWorkspaces standard hook. */
list: HostObservable<unknown>
}
storeOf(entry: StoredEntry, scopeBinding: ScopedStandardSourceBinding | undefined): StoreInstanceLike | undefined
/** Root standard data assembled from domain-owned contributions. */
readonly root: HostObservable<StandardSourceBinding>
/** Monotonic source updated whenever the installed scope-adapter roster changes. */
readonly scopeRevision: HostObservable<number>
/**
* Resolve the adapter installed for one non-root scope.
* `session` and `session-maybe` intentionally resolve the same adapter.
* @param scope - Slot scope.
* @returns adapter, or `undefined` when the composition omitted its owner.
*/
scope(scope: Exclude<SlotScope, 'root'>): SlotScopeAdapter | undefined
/**
* Installed locale face backing the `t` standard seat (absent until the
* locale plugin installs one; rendering an entry that declared `locale:`
@@ -185,7 +204,7 @@ export interface SlotRendererHost {
locale?: LocaleFace | undefined
}
/** The installation contract: runtime owns install()/renderSlot(); ui-renderer implements rendering. */
/** The installation contract between the `ui-renderer` SlotRegistry and its React renderer. */
export interface SlotRenderer {
/**
* Render the root slot tree over the host API (the only ctx-level entry).
+16 -131
View File
@@ -1,132 +1,17 @@
/** Framework-neutral store contracts for slot registrations and the runtime engine. */
/** Slot-facing re-exports of the React-free store contracts. */
/**
* Typed selector hook over a snapshot source. Canonical shape for the whole
* slot system (ui-renderer's engine hook is structurally identical; the
* framework is the only party that ever constructs one).
*/
export type SnapshotSelectorHook<T> = <S>(sel: (s: T) => S, eq?: (a: S, b: S) => boolean) => S
/**
* Selector hook over a source that follows the current session. The hook is
* always present, while its selected value is absent whenever no session is
* current. This keeps hook call sites stable across no-session/session
* transitions without pretending that a session snapshot exists.
*/
export type MaybeSnapshotSelectorHook<T> =
<S>(sel: (s: T) => S, eq?: (a: S, b: S) => boolean) => S | undefined
/**
* Action declaration table: pure immer-draft transforms over the store state,
* declared as the store's complete write set (the audit face — components can
* only write through these).
*/
/* oxlint-disable-next-line typescript/no-explicit-any --
* any[] (not unknown[]): each action carries its own parameter list, and
* unknown[] would reject every concrete signature under strict parameter
* contravariance. Params are re-inferred per action by BakedActions. */
export type ActionsDecl<T> = Record<string, (draft: T, ...params: any[]) => void>
/**
* Draft-stripped callback form of an actions table: what components
* (`props.actions`) and inject factories receive — the framework bakes the
* draft parameter away by binding each action to the resolved instance.
*/
export type BakedActions<T, A extends ActionsDecl<T>> = {
[K in keyof A]: A[K] extends (draft: T, ...params: infer P) => void ? (...params: P) => void : never
}
/**
* Store declaration spec: initial-state factory (a lambda so every instance
* gets a fresh state), optional persistence key (mechanical, framework-run),
* and the actions write set.
*/
export interface StoreSpec<T, A extends ActionsDecl<T>> {
init: () => T
persist?: string
actions: A
}
/**
* Live engine instance: the create() product consumed by the render machinery
* and by tests. A bare snapshot source plus the baked write set — no React
* hook rides the engine product (the engine lives in the React-free runtime);
* the render machinery binds the `useStore` hook from this source on its own
* side, cached per instance. Production components and render paths never
* call create() themselves — instance lifecycle is the framework's.
*/
export interface StoreInstance<T, A extends ActionsDecl<T>> {
readonly actions: BakedActions<T, A>
getSnapshot(): T
/**
* Subscribe to state changes (uSES subscribe side).
* @param fn - change callback.
* @returns unsubscribe.
*/
subscribe(fn: () => void): () => void
/**
* Drop this instance's persisted value (no-op for non-persist specs). The
* framework calls it when the owning scope dies for good — a pruned session
* must not leave orphaned storage keys behind.
*/
clearPersisted(): void
}
/**
* Store handle: spec + state/actions types + shared identity + instance
* factory in one value. Handles are constructed in apply world (shared across
* registrations of one plugin) or by the framework from a registrant's
* factory (exclusive). Never export a handle at module level — module-cache
* identity is a disguised singleton across plugin reloads.
*/
export interface StoreHandle<T, A extends ActionsDecl<T>> {
readonly spec: StoreSpec<T, A>
/**
* Create a live engine instance (framework machinery and tests only).
* @param scopeKey - session id for session-scope instances; suffixes the
* persist key so per-session instances persist independently (root-scope
* instances omit it).
* @returns a fresh instance seeded from `spec.init()`.
*/
create(scopeKey?: string): StoreInstance<T, A>
}
/**
* Exclusive-store registration form: the registrant passes the factory itself
* and the framework calls it per entry x scope (no shared identity exists).
*/
/* oxlint-disable-next-line typescript/no-explicit-any --
* erased position accepting every StoreHandle instantiation; T/A are
* recovered per use site by conditional inference (HandleOf/BoundActions/
* PropsStore). */
export type StoreFactory = () => StoreHandle<any, any>
/** The register `store` option position: a shared handle or an exclusive factory. */
// oxlint-disable-next-line typescript/no-explicit-any -- same erased-constraint position as StoreFactory (see above).
export type StoreDecl = StoreHandle<any, any> | StoreFactory
/** Normalize a store declaration to its handle type (factories yield their return). */
export type HandleOf<H> = H extends () => infer R ? R : H
/**
* Handle-keyed baked actions: the `actions` parameter of an inject factory
* whose registration declared a store — the same baked callback set the
* component receives via {@link PropsStore}.
*/
export type BoundActions<H> = H extends StoreHandle<infer T, infer A> ? BakedActions<T, A> : never
/**
* The store props share, derived from the declared handle: a typed selector
* hook plus the baked write set. Components never see the instance itself
* (no update/set — reads via useStore, writes via the declared actions only).
*/
export type PropsStore<H> = H extends StoreHandle<infer T, infer A>
? { useStore: SnapshotSelectorHook<T>; actions: BakedActions<T, A> }
: object
/**
* The defineStore contract (implementation lives in the runtime package,
* bound to the snapshot-store engine): spec in, handle out, with T inferred
* from `init` and the actions table constrained by T.
*/
export type DefineStore = <T, A extends ActionsDecl<T>>(spec: StoreSpec<T, A>) => StoreHandle<T, A>
export type {
ActionsDecl,
BakedActions,
BoundActions,
DefineStore,
HandleOf,
MaybeSnapshotSelectorHook,
PropsStore,
SnapshotSelectorHook,
StoreDecl,
StoreFactory,
StoreHandle,
StoreInstance,
StoreSpec,
} from '@deepseek-ai/dsh-client-store'
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import type { SlotComponent, StoreHandle } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
// 'root' is NOT merged here: the runtime package owns the built-in row, and
// 'root' is NOT merged here: ui-renderer owns the built-in row, and
// the client aggregate program would see both merges collide.
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
@@ -11,7 +11,7 @@ import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
// Only package-unique SlotMap keys are merged here. The standard-kit
// interfaces (SessionStandardProps/GlobalStandardProps) are NOT re-merged:
// the runtime package owns the real members, and in the client aggregate
// the owning UI adapters provide the real members, and in the client aggregate
// program a toy merge would collide with them — samples below stay
// shape-agnostic about kit member payloads for the same reason.
declare module '@deepseek-ai/dsh-client-ui-slots' {
@@ -137,8 +137,8 @@ describe('terminal-design type chain', () => {
core.register({ name: 'chain.conv', store: chat }, Details)
// Owner + store shares arrive typed on the component face. Standard-kit
// member payloads are the runtime merge's property — not probed here
// (the runtime package's own tests cover them).
// member payloads are the owning adapters' property — not probed here
// (their package tests cover them).
fp.renderSlot('chain.side', { collapsed: false, width: 280 })
const draft: string = cp.useStore(s => s.draft)
cp.actions.select({ id: 'm1' })
@@ -283,7 +283,7 @@ describe('terminal-design type chain', () => {
acts.setDraft(1)
// SessionProvider seat: derives from a session-scope child declaration.
fp.SessionProvider({ empty: () => null, children: () => null })
fp.SessionProvider({ empty: () => null, children: null })
const sideOnly: PropsRenderSlots<'chain.side'> = null as never
// @ts-expect-error only root-scope children declared → no SessionProvider seat
void sideOnly.SessionProvider
+3
View File
@@ -8,6 +8,9 @@
"src"
],
"references": [
{
"path": "../store"
},
{
"path": "../../runtime-diagnostics/invariants"
}
@@ -372,7 +372,13 @@ export class SlotTestRuntime {
}
const entry = this.host.entriesOf(key)[0]
if (entry === undefined) throw new Error(`storeOf('${key}'): no registration on the ledger`)
const instance = this.host.storeOf(entry, scopeKey)
const scopeBinding = scopeKey === undefined
? undefined
: this.host.scope('session')?.resolve(scopeKey)
if (scopeKey !== undefined && scopeBinding === undefined) {
throw new Error(`storeOf('${key}'): no live Session binding for '${scopeKey}'`)
}
const instance = this.host.storeOf(entry, scopeBinding)
if (instance === undefined) throw new Error(`storeOf('${key}'): the entry declares no store`)
return instance
}
@@ -33,7 +33,7 @@ function Frame({ renderSlot, SessionProvider }: FrameProps) {
<>
{renderSlot('trt.panel', { label: 'from-owner' }, { fallback: <i>no panel</i> })}
<SessionProvider empty={() => <i>no session</i>}>
{() => renderSlot('trt.chat', {})}
{renderSlot('trt.chat', {})}
</SessionProvider>
{renderSlot('trt.rows', {})}
</>