mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
refactor(gui): slot system standard — single register, four props shares, framework store seat
The definitive slot model for the web client, replacing the first-generation
define/register two-step, ScopedSlots whitelist faces, and binding handles:
- 'root' is the only a-priori slot (SlotsService built-in); the shell renders
exactly ctx.slots.renderSlot('root', {}).
- register is the single API: children = slot declaration + render
authorization + runtime spec in one options object; misconfiguration fails
loud at load (duplicate declaration, undeclared contribution, one store
handle under two scopes).
- Component props arrive in four auto-derived shares: PropsRuntime<K>
(owner params + session/global standard kits via declare-merge),
PropsRenderSlots<S>, PropsStore<H>, and the inject business face.
sessionId is framework-supplied; hooks are framework-made only.
- Framework store seat: defineStore factories declare schema/actions/persist;
read = useStore, write = baked actions only; store scope derives from the
mounting entry; per-session persist keys and clearPersisted lifecycle.
- inject factories read the apply closure's own ctx (binding handles retired;
root-ctx back door closed); SessionProvider is self-wired render-prop.
- Rendering sits behind the SlotRenderer install seam; runtime stays
React-free; ownership ledger keyed to the single entry axis closes the
stale-authority window (StaleAuthorizationError probes).
Docs: the slot type-chain note is refreshed in place as the slot system
standard RFC (bilingual pair re-recorded); the web client architecture RFC
defers its slot sections there; packages/client/AGENTS.md gains the slot and
props discipline; gui-testing/web-styling notes drop missions/ references.
Tests: suites rewritten to the standard (props fed directly, real store
engines via createXXXStore().create(), no render machinery); load-time
negative samples for declaration/authorization/store conflicts; verified by
real-host playwright run (three columns, empty state, collapse, keyed session
remount, cross-slot selection sharing).
docs(ui-sidebar): point contract reference at the committed slot standard RFC
missions/ is workspace-local and never committed; the README must not cite it.
This commit is contained in:
@@ -1,21 +1,27 @@
|
||||
/**
|
||||
* Browser half: the whole runtime contract surface (api-contracts v3 §4) —
|
||||
* SlotsService, SessionsService (list store + scope tree + object layer),
|
||||
* the ClientLoader interface, and the cordis Context/Events merges. apply
|
||||
* SlotsService (declaration ledger + renderer seam + store axis, built-in
|
||||
* 'root'), SessionsService (list store + current selection + scope tree +
|
||||
* object layer), the ClientLoader interface, and the cordis Context/Events
|
||||
* merges. apply
|
||||
* mounts ctx.slots + ctx.sessions and wires the connection stream loop into
|
||||
* the object layer. The loader machinery implementation is NOT in the plugin
|
||||
* bundle — it ships via the package's `./loader` subpath, statically held by
|
||||
* the web shell (a loader cannot load itself).
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionBinding as GenericSessionBinding } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SnapshotStore, UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { SlotsService } from './slots.ts'
|
||||
import { SessionsService } from './sessions/service.ts'
|
||||
import type { SessionListState } from './sessions/service.ts'
|
||||
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.ts'
|
||||
|
||||
export { SlotsService } from './slots.ts'
|
||||
// RootOwnerProps rides the 'root' SlotMap row (both migrated here from
|
||||
// ui-layout: the framework slot is declared by the framework package).
|
||||
export type { RootOwnerProps } from './slots.ts'
|
||||
export { SessionsService, scopeOf } from './sessions/service.ts'
|
||||
export type { Session } from './sessions/session.ts'
|
||||
export type { SessionBinding, SessionListState, SessionSummary } from './sessions/service.ts'
|
||||
@@ -38,9 +44,6 @@ export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
*/
|
||||
export type ClientContext = Context
|
||||
|
||||
/** SessionBinding narrowed to the client context (inject factories dot services directly). */
|
||||
export type ClientSessionBinding = GenericSessionBinding<ClientContext>
|
||||
|
||||
/** The conversation-snapshot selector hook (ConvViewProps/ToolViewProps take this). */
|
||||
export type UseConversationSession = UseSession<ConversationSnapshot>
|
||||
|
||||
@@ -51,6 +54,25 @@ export type UseConversationSession = UseSession<ConversationSnapshot>
|
||||
*/
|
||||
export type ToolCallBlock = RunningToolCall | ToolResultNode
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
/**
|
||||
* Session standard kit, real members (ui-slots declares the empty seat;
|
||||
* the runtime — where the subjects live — merges the concrete types):
|
||||
* every session-scope slot component receives these from the framework.
|
||||
*/
|
||||
interface SessionStandardProps {
|
||||
/** Selector hook over this session's conversation snapshot. */
|
||||
useSession: SnapshotSelectorHook<ConversationSnapshot>
|
||||
/** The framework-resolved session id (owners never pass it). */
|
||||
sessionId: SessionId
|
||||
}
|
||||
/** Global standard kit, real members: the session-list hook every slot component receives. */
|
||||
interface GlobalStandardProps {
|
||||
/** Selector hook over the session list snapshot (`current` included — the arbitrated selection seat). */
|
||||
useSessions: SnapshotSelectorHook<SessionListState>
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Events {
|
||||
/**
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* load one by one in inject topology.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react/store'
|
||||
import type { BootPluginEntry, ClientLoader, LoaderStatus } from '../index.ts'
|
||||
|
||||
export type { BootPluginEntry, ClientLoader, LoaderStatus } from '../index.ts'
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* SessionsService: root sessions service — list snapshot store (manager
|
||||
* projection), session scope tree (mintScope pattern: no-op plugin Fiber +
|
||||
* ctx.extend scope tag), stable SessionBinding cache, ancestry walk.
|
||||
* projection; carries `current`, the persisted selection every
|
||||
* session-scoped surface keys off — migrated here from ui-layout per the
|
||||
* slot-parity design), session scope tree (mintScope pattern: no-op plugin
|
||||
* Fiber + ctx.extend scope tag), stable SessionBinding cache, ancestry walk.
|
||||
*
|
||||
* Scope lifecycle is watch-driven: a scope is minted lazily on first
|
||||
* resolution; a session leaving the list tears its scope down only when
|
||||
@@ -13,8 +15,11 @@
|
||||
*/
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import type { IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
|
||||
// Engine reach-through: the store subpath is the framework-internal channel
|
||||
// (the public web-react face carries defineStore only).
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-web-react/store'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react/store'
|
||||
import type { SessionCell } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { SessionManager } from './manager.ts'
|
||||
import type { Session } from './session.ts'
|
||||
|
||||
@@ -28,8 +33,12 @@ export interface SessionSummary {
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/** Session list store shape. */
|
||||
export interface SessionListState { ids: SessionId[]; byId: Record<SessionId, SessionSummary> }
|
||||
/**
|
||||
* Session list store shape. `current` rides the same snapshot (arbitrated:
|
||||
* the single useSessions standard hook reads list and selection together —
|
||||
* sidebar highlighting and SessionProvider share one fact source).
|
||||
*/
|
||||
export interface SessionListState { ids: SessionId[]; byId: Record<SessionId, SessionSummary>; current: SessionId | undefined }
|
||||
|
||||
/** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */
|
||||
export interface SessionBinding {
|
||||
@@ -69,15 +78,26 @@ interface ScopeRecord {
|
||||
fiber: Fiber
|
||||
ctx: Context
|
||||
binding: SessionBinding
|
||||
/** Render-layer standard kit (identity-stable per scope; the renderer's per-cell caches key off it). */
|
||||
cell: SessionCell
|
||||
}
|
||||
|
||||
/** Root sessions service: list store, object-layer manager, scope tree, bindings, ancestry. */
|
||||
/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, ancestry. */
|
||||
export class SessionsService {
|
||||
/** List snapshot store (list RPC + host stream increments; re-pulled on reconnect). */
|
||||
/** List snapshot store (list RPC + host stream increments; re-pulled on reconnect) — the useSessions standard feed, current included. */
|
||||
readonly list: SnapshotStore<SessionListState>
|
||||
/** The object-layer instance cluster and frame dispatch entry (wired to the connection by the runtime apply). */
|
||||
readonly manager: SessionManager
|
||||
|
||||
/**
|
||||
* Persisted selection cell (the durable half of `list.current`). Private on
|
||||
* purpose: reads go through the list snapshot; writes through {@link
|
||||
* SessionsService.open}. Projection validates it against the live list
|
||||
* instead of destructively pruning, so a selection survives transient list
|
||||
* states (reconnect re-pull) and resurfaces when its session returns.
|
||||
*/
|
||||
private readonly selection: SnapshotStore<{ sessionId?: SessionId }>
|
||||
|
||||
private readonly scopes = new Map<SessionId, ScopeRecord>()
|
||||
/** Most recently resolved binding id — the watch approximation for deferred teardown. */
|
||||
private watched: SessionId | undefined
|
||||
@@ -90,13 +110,29 @@ export class SessionsService {
|
||||
*/
|
||||
constructor(private readonly rootCtx: Context, api: IApiClient) {
|
||||
this.manager = new SessionManager(api)
|
||||
this.list = createSnapshotStore<SessionListState>({ ids: [], byId: {} })
|
||||
this.selection = createSnapshotStore<{ sessionId?: SessionId }>(
|
||||
{},
|
||||
{ persist: { name: 'dsh.sessions.current' } })
|
||||
this.list = createSnapshotStore<SessionListState>({ ids: [], byId: {}, current: undefined })
|
||||
// The manager owns wire truth; the store is its projection. Manager
|
||||
// notifications are already microtask-batched.
|
||||
this.manager.subscribe(() => { this.projectList() })
|
||||
rootCtx.reflect.provide('sessions', this, undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a session as current. Unknown ids fail loud instead of navigating
|
||||
* nowhere (the sole selection write path).
|
||||
* @param id - session id (must exist in the list store).
|
||||
*/
|
||||
open(id: SessionId): void {
|
||||
if (this.list.getSnapshot().byId[id] === undefined) {
|
||||
throw new Error(`sessions.open: unknown session ${id}`)
|
||||
}
|
||||
this.selection.update((draft) => { draft.sessionId = id })
|
||||
this.list.update((draft) => { draft.current = id })
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a session on the host.
|
||||
* @param opts - creation options (project directory).
|
||||
@@ -132,6 +168,23 @@ export class SessionsService {
|
||||
return record.binding
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the render-layer session cell (SessionProvider's feed through
|
||||
* the renderer host; ctx never enters the render layer). Marks the session
|
||||
* watched, same as {@link SessionsService.binding}.
|
||||
* @param id - session id.
|
||||
* @returns cell, or undefined for a session neither listed nor already scoped.
|
||||
*/
|
||||
cell(id: string): SessionCell | undefined {
|
||||
const record = this.resolve(id as SessionId)
|
||||
if (record === undefined) return undefined
|
||||
if (this.watched !== id) {
|
||||
this.watched = id as SessionId
|
||||
this.sweepDeferred()
|
||||
}
|
||||
return record.cell
|
||||
}
|
||||
|
||||
/**
|
||||
* Breadcrumb feed: walk parentId links inside the list store.
|
||||
* @param id - session id.
|
||||
@@ -158,10 +211,12 @@ export class SessionsService {
|
||||
if (this.list.getSnapshot().byId[id] === undefined) return undefined
|
||||
const fiber = this.rootCtx.plugin(sessionScope)
|
||||
const ctx = fiber.ctx.extend({ [kScope]: id })
|
||||
const session = this.manager.get(id)
|
||||
const record: ScopeRecord = {
|
||||
fiber,
|
||||
ctx,
|
||||
binding: { sessionId: id, session: this.manager.get(id), ctx },
|
||||
binding: { sessionId: id, session, ctx },
|
||||
cell: { sessionId: id, useSession: session.useSelector },
|
||||
}
|
||||
this.scopes.set(id, record)
|
||||
return record
|
||||
@@ -183,7 +238,11 @@ export class SessionsService {
|
||||
...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
|
||||
}
|
||||
}
|
||||
this.list.set({ ids, byId })
|
||||
// current = the persisted selection, masked while its session is absent
|
||||
// (falls to the empty state; resurfaces if the session returns).
|
||||
const selected = this.selection.getSnapshot().sessionId
|
||||
const current = selected !== undefined && byId[selected] !== undefined ? selected : undefined
|
||||
this.list.set({ ids, byId, current })
|
||||
this.pruneScopes(byId)
|
||||
}
|
||||
|
||||
@@ -197,10 +256,18 @@ export class SessionsService {
|
||||
}
|
||||
this.scopes.delete(id)
|
||||
this.deferredRemovals.delete(id)
|
||||
void record.fiber.dispose()
|
||||
this.dropScope(id, record)
|
||||
}
|
||||
}
|
||||
|
||||
/** Dispose a scope fiber and its session-keyed slot-store instances together (single lifecycle axis). */
|
||||
private dropScope(id: SessionId, record: ScopeRecord): void {
|
||||
void record.fiber.dispose()
|
||||
// Optional lookup: slots and sessions are sibling services with no
|
||||
// declared dependency; a slots-less boot (object-layer tests) skips.
|
||||
this.rootCtx.get('slots')?.pruneStoreScope(id)
|
||||
}
|
||||
|
||||
/** Run deferred teardowns whose session is no longer watched (called when the watch moves). */
|
||||
private sweepDeferred(): void {
|
||||
for (const id of [...this.deferredRemovals]) {
|
||||
@@ -220,7 +287,7 @@ export class SessionsService {
|
||||
* future teardown path cannot double-dispose. */
|
||||
if (record !== undefined) {
|
||||
this.scopes.delete(id)
|
||||
void record.fiber.dispose()
|
||||
this.dropScope(id, record)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,113 @@
|
||||
/**
|
||||
* SlotsService: cordis Service wrapper over the pure SlotCore (ui-slots).
|
||||
* Every mutation re-emits as the 'slots/changed' cordis event; define/register
|
||||
* run through the caller's ctx.effect so a plugin's registrations are
|
||||
* collected when its fiber unloads (cordis-native cascade).
|
||||
* SlotsService: the cordis Service layer of the slot system 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 through the
|
||||
* caller's ctx.effect (fiber unload collects registrations), the renderer
|
||||
* install seam (install()/renderSlot('root') + the SlotRendererHost face),
|
||||
* and the store INSTANCE axis — handle x scope key -> create/cache, dropped
|
||||
* with the last holding entry, session instances cleared (with persisted
|
||||
* state) on scope death.
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/no-redundant-type-constituents --
|
||||
* `keyof SlotMap & string` is the declare-merge key pattern: SlotMap is empty
|
||||
* in this compilation unit (intersection reads `never`) but consumers merge
|
||||
* keys in; the rule fires on the empty-map view, not on real redundancy. */
|
||||
* `keyof SlotMap & string` is the declare-merge key pattern: SlotMap only
|
||||
* holds this package's 'root' row in this compilation unit, but consumers
|
||||
* merge keys in; the rule fires on the narrow-map view, not on real
|
||||
* redundancy. */
|
||||
import { Service } from 'cordis'
|
||||
import type { Context } from 'cordis'
|
||||
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ComposedProps, RegisterArgs, SlotComponent, SlotEntry, SlotEntryDef, SlotMap, SlotSpec } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ClientContext } from './index.ts'
|
||||
import type {
|
||||
ChildrenDecl, ComposedProps, HandleOf, InjectParams, KindOptions, OwnerOf,
|
||||
SlotComponent, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost,
|
||||
SlotScope, SlotSpec, StoreDecl, StoredEntry, StoreInstanceLike,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
/** cordis Service wrapper over the pure SlotCore; mutations re-emit as 'slots/changed'. */
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
/** The built-in render-tree root hole (seeded by SlotCore): rendered only by the shell, occupied by a layout entry. */
|
||||
'root': { kind: 'single'; scope: 'root'; owner: RootOwnerProps }
|
||||
}
|
||||
}
|
||||
|
||||
/** Root owner share: the shell supplies nothing — the frame is inject-assembled. */
|
||||
export interface RootOwnerProps { children?: never }
|
||||
|
||||
/** Instance key for root-scoped store records (session records key by session id, so the literal cannot collide). */
|
||||
const ROOT_INSTANCE_KEY = 'root'
|
||||
|
||||
// FIXME(slot-parity): the engine's arbitrated persist extensions — create()
|
||||
// takes the scope key (per-session localStorage suffix) and instances expose
|
||||
// clearPersisted() — are not yet on ui-slots' StoreHandle/StoreInstanceLike;
|
||||
// these local structural faces bridge until fw-slots lifts them.
|
||||
|
||||
/** Store handle face as the engine actually ships it (scope-key-aware create). */
|
||||
interface EngineStoreHandle { create(scopeKey?: string): EngineStoreInstance }
|
||||
|
||||
/** Engine instance face: the host-contract shape plus persisted-state cleanup. */
|
||||
interface EngineStoreInstance extends StoreInstanceLike { clearPersisted(): void }
|
||||
|
||||
/** Store axis record: one per live handle, dropped when the last holding entry unloads. */
|
||||
interface StoreAxisRecord {
|
||||
/** Scope of the slot the handle mounted under (the core validated cross-scope conflicts). */
|
||||
scope: SlotScope
|
||||
/** Live registrations holding the handle. */
|
||||
refs: number
|
||||
/** Root scope: the single instance under {@link ROOT_INSTANCE_KEY}; session scope: one per session id. */
|
||||
instances: Map<string, EngineStoreInstance>
|
||||
}
|
||||
|
||||
/**
|
||||
* Register options as the service face declares them (structurally the
|
||||
* core's BaseOptions, re-declared because ui-slots keeps it private).
|
||||
* FIXME(slot-parity): dedupe once ui-slots exports its options type.
|
||||
*/
|
||||
type RegisterOptions<K extends keyof SlotMap & string, D extends ChildrenDecl, H> = {
|
||||
/** Target slot key (the entry contributes INTO this slot). */
|
||||
name: K
|
||||
/** Child-slot declaration + render authorization + runtime spec, in one table. */
|
||||
children?: D
|
||||
/** Store seat: a shared handle (apply-constructed) or an exclusive factory (framework-called per entry). */
|
||||
store?: H
|
||||
/** Registrant identity label for diagnostics (defaults to the caller's fiber name). */
|
||||
registrant?: string
|
||||
} & KindOptions<SlotMap[K]>
|
||||
|
||||
/**
|
||||
* Compile-time presence check: an entry declaring children MUST consume
|
||||
* `renderSlot` (declaring is claiming). Structural copy of the core's
|
||||
* private RendersCheck; same FIXME as {@link RegisterOptions}.
|
||||
*/
|
||||
type RendersCheck<C, D> =
|
||||
[keyof D & keyof SlotMap & string] extends [never] ? unknown
|
||||
: C extends (props: infer P) => unknown
|
||||
? ('renderSlot' extends keyof P ? unknown
|
||||
: { 'children declared but the component consumes no renderSlot': keyof D & keyof SlotMap & string })
|
||||
: unknown
|
||||
|
||||
/** Type-erased options view the implementation works with (the typed overloads proved the shares). */
|
||||
interface ErasedRegisterOptions {
|
||||
name: string
|
||||
children?: Record<string, SlotSpec<SlotEntryDef>>
|
||||
store?: StoreDecl
|
||||
inject?: (...args: never[]) => Record<string, unknown>
|
||||
key?: string
|
||||
id?: string
|
||||
order?: number
|
||||
label?: string
|
||||
registrant?: string
|
||||
}
|
||||
|
||||
/** Erased core call face (the service re-erases at its own boundary; the core's typed face targets end callers). */
|
||||
interface ErasedCore { register(options: object, component: unknown): () => void }
|
||||
|
||||
/** cordis Service layer of the slot system; see the module doc for the split with SlotCore. */
|
||||
export class SlotsService extends Service {
|
||||
private readonly _core = new SlotCore()
|
||||
/** Store-instance axis: handle -> mounted scope, refcount, resolved instances. */
|
||||
private readonly _stores = new Map<EngineStoreHandle, StoreAxisRecord>()
|
||||
private _renderer: SlotRenderer | undefined
|
||||
private _host: SlotRendererHost | undefined
|
||||
|
||||
/**
|
||||
* @param ctx - owning root context.
|
||||
@@ -27,44 +118,115 @@ export class SlotsService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a slot spec (delegates to SlotCore.define; disposal follows the caller's fiber).
|
||||
* @param key - SlotMap key.
|
||||
* @param spec - kind/scope spec.
|
||||
* @returns disposer.
|
||||
* The single registration API (see SlotCore.register for the full
|
||||
* semantics: children declaration, store seat, inject face, load-time
|
||||
* validation, unload cascade). This layer adds: disposal through the
|
||||
* caller's ctx.effect (fiber unload = cascade), exclusive-factory minting
|
||||
* (`store: createXxxStore` becomes a per-entry handle), the registrant
|
||||
* diagnostics stamp, and store-instance lifecycle on the entry axis.
|
||||
* @param options - name + children + store + inject (+ kind-shaped key/id/order/label).
|
||||
* @param component - pure component typed by the four-share composed props.
|
||||
* @returns disposer (idempotent; stale calls after fiber teardown are no-ops).
|
||||
*/
|
||||
define<K extends keyof SlotMap & string>(key: K, spec: SlotSpec<SlotMap[K]>): () => void {
|
||||
register<
|
||||
K extends keyof SlotMap & string,
|
||||
const D extends ChildrenDecl = Record<never, never>,
|
||||
H extends StoreDecl | undefined = undefined,
|
||||
C extends SlotComponent<never> = SlotComponent<never>,
|
||||
>(
|
||||
options: RegisterOptions<K, D, H> & { inject?: undefined },
|
||||
component: C
|
||||
& SlotComponent<ComposedProps<K, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, object>>
|
||||
& RendersCheck<C, D>,
|
||||
): () => void
|
||||
register<
|
||||
K extends keyof SlotMap & string,
|
||||
I extends object,
|
||||
const D extends ChildrenDecl = Record<never, never>,
|
||||
H extends StoreDecl | undefined = undefined,
|
||||
C extends SlotComponent<never> = SlotComponent<never>,
|
||||
>(
|
||||
options: RegisterOptions<K, D, H> & { inject: (...args: InjectParams<K, H>) => I },
|
||||
component: C
|
||||
& SlotComponent<ComposedProps<K, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, I>>
|
||||
& RendersCheck<C, D>,
|
||||
): () => void
|
||||
register(rawOptions: object, component: unknown): () => void {
|
||||
// The typed overloads above proved the shares; the implementation works
|
||||
// on the erased view (same pattern as the core's register).
|
||||
const options = rawOptions as ErasedRegisterOptions
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return this.ctx.effect(() => this._core.define(key, spec), 'slots.define()')
|
||||
return this.ctx.effect(() => this._register(options, component), 'slots.register()')
|
||||
}
|
||||
|
||||
/**
|
||||
* Contribute a component (delegates to SlotCore.register; disposal follows the caller's fiber).
|
||||
* @param key - SlotMap key.
|
||||
* @param component - contributed component.
|
||||
* @param args - kind-shaped options (mandatory for keyed/list kinds); the
|
||||
* inject factory's binding is pinned to ClientContext.
|
||||
* @returns disposer.
|
||||
* Install the shell's renderer (web-react's createSlotRenderer product).
|
||||
* Boot-once: a second install throws. Runs through the caller's ctx.effect,
|
||||
* so shell fiber unload uninstalls the renderer.
|
||||
* @param renderer - the outlet machinery implementing SlotRenderer.
|
||||
*/
|
||||
register<K extends keyof SlotMap & string, I extends object = Record<string, unknown>>(
|
||||
// Client-context registrations have exactly one ctx shape: pin Ctx to
|
||||
// ClientContext so inject factories dot services without a cast.
|
||||
key: K, component: SlotComponent<ComposedProps<K, NoInfer<I>>>,
|
||||
...args: RegisterArgs<SlotMap[K], I, ClientContext>): () => void {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return this.ctx.effect(() => this._core.register<K, I, ClientContext>(key, component, ...args), 'slots.register()')
|
||||
install(renderer: SlotRenderer): void {
|
||||
if (this._renderer !== undefined) throw new Error('slot renderer already installed (install() is boot-once)')
|
||||
this.ctx.effect(() => {
|
||||
this._renderer = renderer
|
||||
return () => {
|
||||
if (this._renderer === renderer) this._renderer = undefined
|
||||
}
|
||||
}, 'slots.install()')
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot entries for a key.
|
||||
* @param key - SlotMap key.
|
||||
* @returns registered entries (stable reference between mutations).
|
||||
* The single ctx-level render entry: the shell renders 'root'; every other
|
||||
* key renders inside components through the props renderSlot face. All
|
||||
* three guards are fail-loud boot-order checks, no fallback.
|
||||
* @param key - must be 'root' (runtime-enforced for dynamically composed callers).
|
||||
* @param owner - owner share for the root entry (the shell supplies {}).
|
||||
* @returns the rendered root tree.
|
||||
*/
|
||||
entries<K extends keyof SlotMap & string>(key: K): readonly SlotEntry<SlotMap[K]>[] {
|
||||
renderSlot<K extends keyof SlotMap & string>(key: K, owner: OwnerOf<K>): ReturnType<SlotRenderer['renderRoot']> {
|
||||
// Widened: in this package's own program SlotMap holds only 'root', which
|
||||
// would fold the guard to constant-false; the check exists for plain-JS
|
||||
// and cross-program callers where K is wider.
|
||||
if ((key as string) !== 'root') {
|
||||
throw new Error(`ctx-level renderSlot only renders 'root' (got "${key}"); child slots render through the component props face`)
|
||||
}
|
||||
if (this._renderer === undefined) {
|
||||
throw new Error("slot renderer not installed — boot must call ctx.slots.install(createSlotRenderer()) before rendering 'root'")
|
||||
}
|
||||
if (this._core.entries('root').length === 0) {
|
||||
throw new Error("'root' has no registration — a layout entry must register into 'root' before the shell renders it")
|
||||
}
|
||||
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.
|
||||
* @returns registered entries.
|
||||
*/
|
||||
entries(key: keyof SlotMap & string): readonly StoredEntry[] {
|
||||
return this._core.entries(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a defined spec.
|
||||
* Look up a declared spec (register-declared or the built-in 'root').
|
||||
* @param key - SlotMap key.
|
||||
* @returns spec or undefined.
|
||||
*/
|
||||
@@ -72,15 +234,6 @@ export class SlotsService extends Service {
|
||||
return this._core.spec(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic-key escape hatch for spec lookup (renderer-side string keys).
|
||||
* @param key - candidate slot key.
|
||||
* @returns wide-typed spec or undefined.
|
||||
*/
|
||||
specDynamic(key: string): SlotSpec<SlotEntryDef> | undefined {
|
||||
return this._core.specDynamic(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a key's registration changes (microtask-batched).
|
||||
* @param key - SlotMap key.
|
||||
@@ -100,8 +253,106 @@ export class SlotsService extends Service {
|
||||
return this._core.getVersion(key)
|
||||
}
|
||||
|
||||
/** The wrapped pure core (web-react's scopedSlots outlet reads through this). */
|
||||
/** The wrapped pure core (invariant checks read through this). */
|
||||
get core(): SlotCore {
|
||||
return this._core
|
||||
}
|
||||
|
||||
/** Delegating registration path: factory minting + registrant stamp + core write + instance-axis bookkeeping. */
|
||||
private _register(options: ErasedRegisterOptions, component: unknown): () => void {
|
||||
// Exclusive stores pass the factory itself: minted here into a per-entry
|
||||
// handle so the stored entry always carries a resolvable handle (the
|
||||
// core's shared-handle scope pinning applies to it harmlessly).
|
||||
const store = typeof options.store === 'function' ? options.store() : options.store
|
||||
const registrant = options.registrant ?? (this.ctx.fiber as { name?: string } | undefined)?.name
|
||||
const erased: ErasedRegisterOptions = {
|
||||
...options,
|
||||
...(store !== undefined ? { store } : {}),
|
||||
...(registrant !== undefined ? { registrant } : {}),
|
||||
}
|
||||
// Core write first: all load-time validation (undeclared target,
|
||||
// duplicate declaration, kind conflicts, cross-scope handle) throws
|
||||
// there before this layer commits anything.
|
||||
const dispose = (this._core as unknown as ErasedCore).register(erased, component)
|
||||
if (store !== undefined) {
|
||||
// Register succeeded, so the target's spec is on the ledger.
|
||||
const scope = (this._core.specDynamic(options.name) as SlotSpec<never>).scope
|
||||
this._acquire(store, scope)
|
||||
}
|
||||
let disposed = false
|
||||
return () => {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
dispose()
|
||||
if (store !== undefined) this._release(store)
|
||||
}
|
||||
}
|
||||
|
||||
/** Build (once) the host face the installed renderer reads; sessions resolve lazily at first render. */
|
||||
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")
|
||||
}
|
||||
// Identity-stable view: current rides the list snapshot (arbitrated), but
|
||||
// the provider consumes it as its own observable; one cached object keeps
|
||||
// the renderer's per-source hook cache stable.
|
||||
const current = {
|
||||
getSnapshot: () => sessions.list.getSnapshot().current as string | undefined,
|
||||
subscribe: (fn: () => void) => sessions.list.subscribe(fn),
|
||||
}
|
||||
this._host = {
|
||||
subscribe: (key, fn) => this._core.subscribe(key, fn),
|
||||
getVersion: key => this._core.getVersion(key),
|
||||
entriesOf: key => this._core.entries(key),
|
||||
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,
|
||||
current,
|
||||
cell: id => sessions.cell(id),
|
||||
},
|
||||
}
|
||||
return this._host
|
||||
}
|
||||
|
||||
/** Resolve (create or reuse) the store instance for a registered handle under a scope key. */
|
||||
private resolveStore(handle: EngineStoreHandle, sessionId: string | 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 === 'session' ? sessionId : ROOT_INSTANCE_KEY
|
||||
if (key === undefined) throw new Error('session-scoped store resolution requires a session id')
|
||||
let instance = record.instances.get(key)
|
||||
if (instance === undefined) {
|
||||
// Session instances get the scope key (the engine suffixes the persist
|
||||
// key per session); root instances stay keyless.
|
||||
instance = record.scope === 'session' ? handle.create(key) : handle.create()
|
||||
record.instances.set(key, instance)
|
||||
}
|
||||
return instance
|
||||
}
|
||||
|
||||
/** Bind (or re-reference) a handle on the axis; cross-scope conflicts already threw in the core. */
|
||||
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() })
|
||||
return
|
||||
}
|
||||
record.refs += 1
|
||||
}
|
||||
|
||||
/** Drop one reference; the last holder's unload drops the record (instances go with it — engine stores need no explicit dispose). */
|
||||
private _release(handle: EngineStoreHandle): void {
|
||||
const record = this._stores.get(handle)
|
||||
/* v8 ignore next -- defensive: release only runs from a disposer whose
|
||||
* register acquired the same handle, so the record must exist; kept so a
|
||||
* future call site cannot underflow the axis. */
|
||||
if (record === undefined) return
|
||||
record.refs -= 1
|
||||
if (record.refs === 0) this._stores.delete(handle)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user