From 1b535f611cee0479e8732cb169eba4fd8a406bee Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:16:13 +0800 Subject: [PATCH] refactor(client): extract Store and renderer Slot infrastructure --- packages/client/store/package.json | 46 +++ packages/client/store/src/contract.ts | 144 +++++++++ .../contract/store.ts => store/src/index.ts} | 60 ++-- packages/client/store/src/invariant.ts | 30 ++ .../store/tests/invariant.client.spec.ts | 13 + .../tests/store.client.spec.ts | 72 ++++- packages/client/store/tsconfig.json | 18 ++ packages/client/store/tsdown.config.ts | 6 + packages/client/ui-renderer/package.json | 5 - .../client/ui-renderer/src/client/app.tsx | 22 +- .../ui-renderer/src/client/bindings.tsx | 143 +++++++++ .../client/ui-renderer/src/client/index.ts | 27 +- .../src/client/registry.ts} | 211 ++++++++++--- .../ui-renderer/src/client/scoped-slots.tsx | 290 ++++++++++-------- .../src/client/session-provider.tsx | 160 ---------- packages/client/ui-renderer/src/invariant.ts | 23 +- .../ui-renderer/tests/app.client.spec.tsx | 35 +-- .../tests/invariant.client.spec.ts | 12 +- .../tests/registry.client.spec.ts} | 156 +++++++--- .../scoped-slots-real-core.client.spec.tsx | 26 +- .../tests/scoped-slots.client.spec.tsx | 187 +++++++---- .../tests/session-provider.client.spec.tsx | 133 +++++--- .../tests/stale-authorization.client.spec.tsx | 29 +- .../tests/ui-renderer.client.spec.tsx | 29 +- .../tests/use-projection.client.spec.tsx | 96 +++--- packages/client/ui-renderer/tsconfig.json | 3 - packages/client/ui-slots/package.json | 1 + packages/client/ui-slots/src/index.ts | 36 +-- packages/client/ui-slots/src/invariant.ts | 2 +- packages/client/ui-slots/src/renderer.ts | 137 +++++---- packages/client/ui-slots/src/store.ts | 147 +-------- .../client/ui-slots/tests/core.client.spec.ts | 2 +- .../ui-slots/tests/type-chain.client.spec.tsx | 8 +- packages/client/ui-slots/tsconfig.json | 3 + .../test-support/client-runtime/src/index.ts | 8 +- .../tests/runtime.client.spec.tsx | 2 +- 36 files changed, 1464 insertions(+), 858 deletions(-) create mode 100644 packages/client/store/package.json create mode 100644 packages/client/store/src/contract.ts rename packages/client/{runtime/src/client/contract/store.ts => store/src/index.ts} (85%) create mode 100644 packages/client/store/src/invariant.ts create mode 100644 packages/client/store/tests/invariant.client.spec.ts rename packages/client/{runtime => store}/tests/store.client.spec.ts (78%) create mode 100644 packages/client/store/tsconfig.json create mode 100644 packages/client/store/tsdown.config.ts create mode 100644 packages/client/ui-renderer/src/client/bindings.tsx rename packages/client/{runtime/src/client/slots.ts => ui-renderer/src/client/registry.ts} (74%) delete mode 100644 packages/client/ui-renderer/src/client/session-provider.tsx rename packages/client/{runtime => ui-renderer}/tests/invariant.client.spec.ts (84%) rename packages/client/{runtime/tests/slots-service.client.spec.ts => ui-renderer/tests/registry.client.spec.ts} (83%) diff --git a/packages/client/store/package.json b/packages/client/store/package.json new file mode 100644 index 0000000000..f2d04490ba --- /dev/null +++ b/packages/client/store/package.json @@ -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" + ] +} diff --git a/packages/client/store/src/contract.ts b/packages/client/store/src/contract.ts new file mode 100644 index 0000000000..38e39da024 --- /dev/null +++ b/packages/client/store/src/contract.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 { + /** 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 = (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 = + (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 = Record 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> = { + [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> { + 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> { + readonly actions: BakedActions + 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> { + readonly spec: StoreSpec + /** + * 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 +} + +/** + * 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 + +/** 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 | StoreFactory + +/** Normalize a store declaration to its handle type (factories yield their return). */ +export type HandleOf = 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 extends StoreHandle ? BakedActions : 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 extends StoreHandle + ? { useStore: SnapshotSelectorHook; actions: BakedActions } + : 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 = >(spec: StoreSpec) => StoreHandle diff --git a/packages/client/runtime/src/client/contract/store.ts b/packages/client/store/src/index.ts similarity index 85% rename from packages/client/runtime/src/client/contract/store.ts rename to packages/client/store/src/index.ts index 0eb7917b03..09414e95e6 100644 --- a/packages/client/runtime/src/client/contract/store.ts +++ b/packages/client/store/src/index.ts @@ -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 { 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 extends ObservableSnapshot { @@ -40,6 +37,26 @@ export interface SnapshotStore extends ObservableSnapshot { 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( + 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( const api: StoreApi = createStore()(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(api: StoreApi, 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(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)[key]) - } + return freeze(value, true) } // ui-slots owns the contract; this module supplies the engine implementation. diff --git a/packages/client/store/src/invariant.ts b/packages/client/store/src/invariant.ts new file mode 100644 index 0000000000..6c9425d097 --- /dev/null +++ b/packages/client/store/src/invariant.ts @@ -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 */ diff --git a/packages/client/store/tests/invariant.client.spec.ts b/packages/client/store/tests/invariant.client.spec.ts new file mode 100644 index 0000000000..779422ea82 --- /dev/null +++ b/packages/client/store/tests/invariant.client.spec.ts @@ -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() + }) +}) diff --git a/packages/client/runtime/tests/store.client.spec.ts b/packages/client/store/tests/store.client.spec.ts similarity index 78% rename from packages/client/runtime/tests/store.client.spec.ts rename to packages/client/store/tests/store.client.spec.ts index 3abd0483f2..7ec53a670d 100644 --- a/packages/client/runtime/tests/store.client.spec.ts +++ b/packages/client/store/tests/store.client.spec.ts @@ -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() 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)', () => { diff --git a/packages/client/store/tsconfig.json b/packages/client/store/tsconfig.json new file mode 100644 index 0000000000..66c8817e0e --- /dev/null +++ b/packages/client/store/tsconfig.json @@ -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" + } + ] +} diff --git a/packages/client/store/tsdown.config.ts b/packages/client/store/tsdown.config.ts new file mode 100644 index 0000000000..d6757c05f8 --- /dev/null +++ b/packages/client/store/tsdown.config.ts @@ -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'], +) diff --git a/packages/client/ui-renderer/package.json b/packages/client/ui-renderer/package.json index 4c21e3063b..88729acada 100644 --- a/packages/client/ui-renderer/package.json +++ b/packages/client/ui-renderer/package.json @@ -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:^", diff --git a/packages/client/ui-renderer/src/client/app.tsx b/packages/client/ui-renderer/src/client/app.tsx index 446d184c5b..df4399ccd5 100644 --- a/packages/client/ui-renderer/src/client/app.tsx +++ b/packages/client/ui-renderer/src/client/app.tsx @@ -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 - } - return () => ( - <> - - {ctx.slots.renderSlot('root', {})} - - ) + return () => ctx.slots.renderSlot('root', {}) } diff --git a/packages/client/ui-renderer/src/client/bindings.tsx b/packages/client/ui-renderer/src/client/bindings.tsx new file mode 100644 index 0000000000..fde55a366c --- /dev/null +++ b/packages/client/ui-renderer/src/client/bindings.tsx @@ -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(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(null) +const ScopeBindingContext = createContext(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(source: HostObservable): SnapshotSelectorHook { + let hook = hookCache.get(source) + if (hook === undefined) { + hook = bindSnapshotSelector(source) + hookCache.set(source, hook) + } + return hook as SnapshotSelectorHook +} + +const hookCache = new WeakMap() +const absentSource: HostObservable = { + 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( + source: HostObservable | undefined, +): MaybeSnapshotSelectorHook { + if (source !== undefined) return observableHook(source) + return useAbsentSnapshot +} + +function useAbsentSnapshot( + _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() +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 {children} +} + +/** 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 {children} +} diff --git a/packages/client/ui-renderer/src/client/index.ts b/packages/client/ui-renderer/src/client/index.ts index a7f45bd354..b559194653 100644 --- a/packages/client/ui-renderer/src/client/index.ts +++ b/packages/client/ui-renderer/src/client/index.ts @@ -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 = SnapshotSelectorHook +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 })) diff --git a/packages/client/runtime/src/client/slots.ts b/packages/client/ui-renderer/src/client/registry.ts similarity index 74% rename from packages/client/runtime/src/client/slots.ts rename to packages/client/ui-renderer/src/client/registry.ts index 9cd76b8f8e..0725163e57 100644 --- a/packages/client/runtime/src/client/slots.ts +++ b/packages/client/ui-renderer/src/client/registry.ts @@ -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 + /** Scope-lifetime registrations for Session instances. */ + lifetimes: Map 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, SlotScopeAdapter>() + private _scopeRevision = 0 + private readonly _scopeListeners = new Set<() => void>() + private readonly _scopeRevisionSource: HostObservable = { + 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, + 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> = {} + const keyedHooks: Record = {} + const props: Record = {} + const finalProps = new Set() + 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( + kind: string, + target: Record, + values: Readonly> | undefined, + finalProps: Set, + 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 } } diff --git a/packages/client/ui-renderer/src/client/scoped-slots.tsx b/packages/client/ui-renderer/src/client/scoped-slots.tsx index dea0511319..05ae2684d7 100644 --- a/packages/client/ui-renderer/src/client/scoped-slots.tsx +++ b/packages/client/ui-renderer/src/client/scoped-slots.tsx @@ -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 @@ -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() -const sessionInjectCache = new WeakMap>() -const sessionMaybeInjectCache = new WeakMap>() +const sessionInjectCache = new WeakMap>() +const sessionMaybeInjectCache = new WeakMap>() 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>)) { - 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 | undefined for (const [name, definition] of Object.entries(definitions as Record)) { - 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 - readonly sessionMaybe: WeakMap -} +const rootStandardCache = new WeakMap() +const sessionStandardCache = new WeakMap>() +const sessionMaybeStandardCache = new WeakMap>() -const standardPropsCache = new WeakMap() +/** 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) + } + 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() + +/** 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 - 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 - 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(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 - 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 ( - + - {renderOutletContent(host, slotKey, ownerProps, opts, sessionInfo)} + {renderOutletContent(host, slotKey, ownerProps, opts, scopeBinding)} ) } @@ -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 ( - <> -
- {opts.fallback ?? null} -
- {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 ( + <> +
+ {opts.fallback ?? null} +
+ {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 ( - - - + + + + + ) }, diff --git a/packages/client/ui-renderer/src/client/session-provider.tsx b/packages/client/ui-renderer/src/client/session-provider.tsx deleted file mode 100644 index 196a4d9fd9..0000000000 --- a/packages/client/ui-renderer/src/client/session-provider.tsx +++ /dev/null @@ -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(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(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(source: HostObservable): SnapshotSelectorHook { - let hook = hookCache.get(source) - if (hook === undefined) { - hook = bindSnapshotSelector(source) - hookCache.set(source, hook) - } - return hook as SnapshotSelectorHook -} -const hookCache = new WeakMap() - -const absentSource: HostObservable = { - getSnapshot: () => undefined, - subscribe: () => () => {}, -} - -/** Bind a source that disappears with the current session to an optional selector hook. */ -export function maybeObservableHook(source: HostObservable | undefined): MaybeSnapshotSelectorHook { - if (source !== undefined) return observableHook(source) - return useAbsentSnapshot -} - -function useAbsentSnapshot(_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 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 ( - - {children} - - ) -} - -/** 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 ( - - {children(id)} - - ) -} diff --git a/packages/client/ui-renderer/src/invariant.ts b/packages/client/ui-renderer/src/invariant.ts index e1272c1daf..e3ec2db324 100644 --- a/packages/client/ui-renderer/src/invariant.ts +++ b/packages/client/ui-renderer/src/invariant.ts @@ -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. diff --git a/packages/client/ui-renderer/tests/app.client.spec.tsx b/packages/client/ui-renderer/tests/app.client.spec.tsx index 0184198b77..14d5da75dc 100644 --- a/packages/client/ui-renderer/tests/app.client.spec.tsx +++ b/packages/client/ui-renderer/tests/app.client.spec.tsx @@ -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') - }) }) diff --git a/packages/client/runtime/tests/invariant.client.spec.ts b/packages/client/ui-renderer/tests/invariant.client.spec.ts similarity index 84% rename from packages/client/runtime/tests/invariant.client.spec.ts rename to packages/client/ui-renderer/tests/invariant.client.spec.ts index 6de306001c..205cd460db 100644 --- a/packages/client/runtime/tests/invariant.client.spec.ts +++ b/packages/client/ui-renderer/tests/invariant.client.spec.ts @@ -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 { 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() diff --git a/packages/client/runtime/tests/slots-service.client.spec.ts b/packages/client/ui-renderer/tests/registry.client.spec.ts similarity index 83% rename from packages/client/runtime/tests/slots-service.client.spec.ts rename to packages/client/ui-renderer/tests/registry.client.spec.ts index 8b4907a287..3cddec3dec 100644 --- a/packages/client/runtime/tests/slots-service.client.spec.ts +++ b/packages/client/ui-renderer/tests/registry.client.spec.ts @@ -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) diff --git a/packages/client/ui-renderer/tests/scoped-slots-real-core.client.spec.tsx b/packages/client/ui-renderer/tests/scoped-slots-real-core.client.spec.tsx index 68486f9430..d0887241c1 100644 --- a/packages/client/ui-renderer/tests/scoped-slots-real-core.client.spec.tsx +++ b/packages/client/ui-renderer/tests/scoped-slots-real-core.client.spec.tsx @@ -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, } } diff --git a/packages/client/ui-renderer/tests/scoped-slots.client.spec.tsx b/packages/client/ui-renderer/tests/scoped-slots.client.spec.tsx index acac449728..d4360d1914 100644 --- a/packages/client/ui-renderer/tests/scoped-slots.client.spec.tsx +++ b/packages/client/ui-renderer/tests/scoped-slots.client.spec.tsx @@ -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 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 /** Entry literal helper: fake entries default the mandatory options bag. */ const entryOf = (partial: Omit & { options?: StoredEntry['options'] }): StoredEntry => @@ -35,7 +40,7 @@ const entryOf = (partial: Omit & { 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( init: () => T, @@ -76,11 +81,12 @@ function observable(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() const specs = new Map() const versions = new Map() @@ -90,11 +96,31 @@ function makeHost() { const storeCache = new Map>() const list = observable<{ ids: string[] }>({ ids: [] }) const workspaces = observable<{ ids: string[] }>({ ids: [] }) - const absentInfo: SessionMaybeProvideInfo = { sessionId: undefined, hooks: {}, props: {} } - const provide = observable(absentInfo) + const absentBinding: StandardSourceBinding = { + key: undefined, + hooks: { session: undefined }, + keyedHooks: {}, + props: { sessionId: undefined }, + } + const currentBinding = observable(absentBinding) let currentId: string | undefined - const infos = new Map() + const bindings = new Map() const sessionSources = new Map>>() + const root = observable({ + 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(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 /** Mount a root entry whose component renders `body` with its kit renderSlot. */ -function mountRoot(h: Fake, children: Record, body: (renderSlot: RenderSlotFn) => ReactNode) { +function mountRoot( + h: Fake, + children: Record, + 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, 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: () => never, select })) + let fallbackOnly = true + const { view } = mountChainRoot(h, { 'k.chain': CHAIN_SESSION }, + renderSlotChain => renderSlotChain( + 'k.chain', + {}, + { fallback: , 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) => ( empty}> - {() => renderSlot('k.session', {})} + {renderSlot('k.session', {})} )) act(() => { h.current.set('s1') }) @@ -698,11 +761,11 @@ describe('standard-kit synthesis', () => { return {String(useTurnData('tail'))} }, }) - const { view } = mountRoot(h, { 'k.session': sessionSpec }, renderSlot => ( - {() => <> + const { view } = mountRoot(h, { 'k.session': sessionSpec }, (renderSlot, SessionProvider) => ( + <> {renderSlot('k.session', { label: 'one' }, { hookContext: 1 })} {renderSlot('k.session', { label: 'two' }, { hookContext: 2 })} - } + )) 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 ( empty}> - {() => renderSlot('k.session', {})} + {renderSlot('k.session', {})} ) }, @@ -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: () => x }) - 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 => ( - {() => renderSlot('k.session', {})} + const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION }, (renderSlot, SessionProvider) => ( + {renderSlot('k.session', {})} )) 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 }) => {sid}, inject: inject, }) - const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION }, renderSlot => ( - {() => renderSlot('k.session', {})} + const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION }, (renderSlot, SessionProvider) => ( + {renderSlot('k.session', {})} )) 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', {})} - {() => renderSlot('k.session', {})} + {renderSlot('k.session', {})} ) 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') + }) }) diff --git a/packages/client/ui-renderer/tests/session-provider.client.spec.tsx b/packages/client/ui-renderer/tests/session-provider.client.spec.tsx index e726577725..8f9398d17b 100644 --- a/packages/client/ui-renderer/tests/session-provider.client.spec.tsx +++ b/packages/client/ui-renderer/tests/session-provider.client.spec.tsx @@ -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(initial: T) { let value = initial @@ -18,19 +24,52 @@ function observable(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(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(absentBinding) let currentId: string | undefined - const infos = new Map() + const bindings = new Map() const sessionEntries: StoredEntry[] = [] + const root = observable({ + 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} + : {children}, + }), + } 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({ ids: [] }), - provideInfo: provide, - }, - workspaces: { list: observable({ 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) => ( empty}> - {id =>
{id}
} +
session
), }) @@ -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: () => {id => {id}}, + root: (_renderSlot, SessionProvider) => session, }) const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}) expect(view.container.textContent).toBe('') @@ -118,7 +157,13 @@ describe('SessionProvider', () => { return
{id}
} const h = makeHost({ - root: () => {id => }, + root: (renderSlot, SessionProvider) => ( + {renderSlot('k.session', {})} + ), + }) + h.registerSession({ + component: (props: { sessionId?: string }) => , + 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[] = [] const h = makeHost({ - root: renderSlot => {() => renderSlot('k.session', {})}, + root: (renderSlot, SessionProvider) => ( + {renderSlot('k.session', {})} + ), }) 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 => {() => renderSlot('k.session', {})}, + root: (renderSlot, SessionProvider) => ( + {renderSlot('k.session', {})} + ), }) 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( - {id => {id}}, - )).toThrow(/outside the installed renderer tree/) + expect(() => render(<>{createSlotRenderer().renderRoot(h.host, {})})) + .toThrow(/does not provide its area renderer/) spy.mockRestore() }) }) diff --git a/packages/client/ui-renderer/tests/stale-authorization.client.spec.tsx b/packages/client/ui-renderer/tests/stale-authorization.client.spec.tsx index c80fdf25dc..bfc9ec1a85 100644 --- a/packages/client/ui-renderer/tests/stale-authorization.client.spec.tsx +++ b/packages/client/ui-renderer/tests/stale-authorization.client.spec.tsx @@ -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() const subs = new Map void>>() const live = new Set() - 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, diff --git a/packages/client/ui-renderer/tests/ui-renderer.client.spec.tsx b/packages/client/ui-renderer/tests/ui-renderer.client.spec.tsx index a966938468..ef254bff8d 100644 --- a/packages/client/ui-renderer/tests/ui-renderer.client.spec.tsx +++ b/packages/client/ui-renderer/tests/ui-renderer.client.spec.tsx @@ -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): Promise => { + 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 } } diff --git a/packages/client/ui-renderer/tests/use-projection.client.spec.tsx b/packages/client/ui-renderer/tests/use-projection.client.spec.tsx index 06db9082d2..a34d139a28 100644 --- a/packages/client/ui-renderer/tests/use-projection.client.spec.tsx +++ b/packages/client/ui-renderer/tests/use-projection.client.spec.tsx @@ -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(initial: T) { let value = initial const subs = new Set<() => void>() @@ -27,25 +32,51 @@ function observable(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(absentInfo) + const scopeCtx = new Context() + const absentBinding: StandardSourceBinding = { + key: undefined, + hooks: { session: undefined }, + keyedHooks: { projection: undefined }, + props: { sessionId: undefined }, + } + const currentBinding = observable(absentBinding) const cells = new Map>>() - /** 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() 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({ + 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({ ids: [] }), - provideInfo: provide, - }, - workspaces: { list: observable({ 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({ 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() - }) }) diff --git a/packages/client/ui-renderer/tsconfig.json b/packages/client/ui-renderer/tsconfig.json index bae7af7101..e36243012c 100644 --- a/packages/client/ui-renderer/tsconfig.json +++ b/packages/client/ui-renderer/tsconfig.json @@ -14,9 +14,6 @@ { "path": "../ui-slots" }, - { - "path": "../runtime" - }, { "path": "../../runtime-diagnostics/invariants" } diff --git a/packages/client/ui-slots/package.json b/packages/client/ui-slots/package.json index 2fc6e4840f..d93b9addc9 100644 --- a/packages/client/ui-slots/package.json +++ b/packages/client/ui-slots/package.json @@ -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:^" diff --git a/packages/client/ui-slots/src/index.ts b/packages/client/ui-slots/src/index.ts index 9e443ab454..8579684fa6 100644 --- a/packages/client/ui-slots/src/index.ts +++ b/packages/client/ui-slots/src/index.ts @@ -177,28 +177,29 @@ export type ScopeOf = 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 { 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 = export type MatchedShare = 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`). - */ -export type UseSession = SnapshotSelectorHook - -/** 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 diff --git a/packages/client/ui-slots/src/invariant.ts b/packages/client/ui-slots/src/invariant.ts index 9ae576095e..a46ba4e30f 100644 --- a/packages/client/ui-slots/src/invariant.ts +++ b/packages/client/ui-slots/src/invariant.ts @@ -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. */ diff --git a/packages/client/ui-slots/src/renderer.ts b/packages/client/ui-slots/src/renderer.ts index e0afea710b..c32d03b9b4 100644 --- a/packages/client/ui-slots/src/renderer.ts +++ b/packages/client/ui-slots/src/renderer.ts @@ -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 { - getSnapshot(): T - subscribe(fn: () => void): () => void +/** Observable currency shared by domain sources, stores, and the renderer. */ +export type HostObservable = ObservableSnapshot + +/** + * Convert one standard source name to its rendered Hook prop name. + * @param name - registered fixed or keyed source name. + * @returns the `use` 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 void> } +/** Resolve one member of an open-key standard hook family. */ +export type KeyedStandardSource = (key: string) => HostObservable | 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` 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 | undefined> - /** Static plain-member roster; values are undefined with the session. */ - props: Record - /** - * 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 } | 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 | undefined>> + /** Open-key source families; each `name` becomes a `useName(key, selector)` Hook. */ + readonly keyedHooks: Readonly> + /** Stable plain values copied into standard props. */ + readonly props: Readonly> } -/** 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> +/** 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 + /** + * 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>> + readonly keyedHooks?: Readonly> + readonly props?: Readonly> } /** 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 - /** - * 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 - } - /** Workspace-side standard-kit sources. */ - workspaces: { - /** Workspace list source backing the useWorkspaces standard hook. */ - list: HostObservable - } + storeOf(entry: StoredEntry, scopeBinding: ScopedStandardSourceBinding | undefined): StoreInstanceLike | undefined + /** Root standard data assembled from domain-owned contributions. */ + readonly root: HostObservable + /** Monotonic source updated whenever the installed scope-adapter roster changes. */ + readonly scopeRevision: HostObservable + /** + * 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): 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). diff --git a/packages/client/ui-slots/src/store.ts b/packages/client/ui-slots/src/store.ts index 64e0f9b4b0..907762b7b4 100644 --- a/packages/client/ui-slots/src/store.ts +++ b/packages/client/ui-slots/src/store.ts @@ -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 = (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 = - (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 = Record 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> = { - [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> { - 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> { - readonly actions: BakedActions - 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> { - readonly spec: StoreSpec - /** - * 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 -} - -/** - * 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 - -/** 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 | StoreFactory - -/** Normalize a store declaration to its handle type (factories yield their return). */ -export type HandleOf = 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 extends StoreHandle ? BakedActions : 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 extends StoreHandle - ? { useStore: SnapshotSelectorHook; actions: BakedActions } - : 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 = >(spec: StoreSpec) => StoreHandle +export type { + ActionsDecl, + BakedActions, + BoundActions, + DefineStore, + HandleOf, + MaybeSnapshotSelectorHook, + PropsStore, + SnapshotSelectorHook, + StoreDecl, + StoreFactory, + StoreHandle, + StoreInstance, + StoreSpec, +} from '@deepseek-ai/dsh-client-store' diff --git a/packages/client/ui-slots/tests/core.client.spec.ts b/packages/client/ui-slots/tests/core.client.spec.ts index 69e283ba66..41e0d1b843 100644 --- a/packages/client/ui-slots/tests/core.client.spec.ts +++ b/packages/client/ui-slots/tests/core.client.spec.ts @@ -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 { diff --git a/packages/client/ui-slots/tests/type-chain.client.spec.tsx b/packages/client/ui-slots/tests/type-chain.client.spec.tsx index e0c4c36744..b74bdcc775 100644 --- a/packages/client/ui-slots/tests/type-chain.client.spec.tsx +++ b/packages/client/ui-slots/tests/type-chain.client.spec.tsx @@ -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 diff --git a/packages/client/ui-slots/tsconfig.json b/packages/client/ui-slots/tsconfig.json index 79d7441ae3..630e7dc65f 100644 --- a/packages/client/ui-slots/tsconfig.json +++ b/packages/client/ui-slots/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../store" + }, { "path": "../../runtime-diagnostics/invariants" } diff --git a/packages/test-support/client-runtime/src/index.ts b/packages/test-support/client-runtime/src/index.ts index 37aebf66ee..b13d87fd17 100644 --- a/packages/test-support/client-runtime/src/index.ts +++ b/packages/test-support/client-runtime/src/index.ts @@ -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 } diff --git a/packages/test-support/client-runtime/tests/runtime.client.spec.tsx b/packages/test-support/client-runtime/tests/runtime.client.spec.tsx index d7f1c3ffc7..697cd197ad 100644 --- a/packages/test-support/client-runtime/tests/runtime.client.spec.tsx +++ b/packages/test-support/client-runtime/tests/runtime.client.spec.tsx @@ -33,7 +33,7 @@ function Frame({ renderSlot, SessionProvider }: FrameProps) { <> {renderSlot('trt.panel', { label: 'from-owner' }, { fallback: no panel })} no session}> - {() => renderSlot('trt.chat', {})} + {renderSlot('trt.chat', {})} {renderSlot('trt.rows', {})}