mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
refactor(client): extract Store and renderer Slot infrastructure
This commit is contained in:
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
+35
-25
@@ -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.
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
+70
-2
@@ -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)', () => {
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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'],
|
||||
)
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
+169
-42
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
+6
-6
@@ -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()
|
||||
+107
-49
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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:^"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', {})}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user