feat(ui-slots): bind inject hooks compartments into use<Name> selector hooks
Registrant-private reactive facts previously reached components as raw observables that each component subscribed by hand (InputBar notices/ lexicon via uSES, SettingsRoot via a version/subscribe/getter triple). The inject face now carries a reserved hooks compartment of bare sources; the renderer binds each into a use<Name> selector hook through the same machinery as the provide channel, so components consume useNotices/useLexicon/useSections and never see a subscription primitive. InputBar and SettingsRoot are the first two consumers.
This commit is contained in:
@@ -11,10 +11,10 @@ The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-
|
||||
1. **One API**: a plugin composes UI only through `ctx.slots.register({ name, children?, store?, inject? }, Component)`. There is no separate slot-definition call, no whitelist face object, no face-minting helper. The shell alone renders `'root'`.
|
||||
2. **children = declaration + authorization**: the slots your component renders are exactly the keys of your register call's `children` object (spec values: `kind`/`scope`). Rendering a slot you didn't declare, or declaring one someone else declared, fails at load — do not work around it; the conflict is the design speaking. Slot names mirror the composition path: `<domain>.<entry>.<hole>` (e.g. `'conversation.chat.toolview'`).
|
||||
3. **Component props are the four shares, all derived**: `PropsRuntime<K>` (SlotMap: owner params + `useSession`/`sessionId` on session scope + global `useSessions`/`useWorkspaces`) & `PropsRenderSlots<S>` (children keys) & `PropsStore<H>` (store factory) & the inject face. Never hand-write a member a share already derives; never re-type a share locally.
|
||||
4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five seats. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.)
|
||||
4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five standing seats, plus the `use<Name>` hooks the renderer binds from provide contributions and inject `hooks` compartments. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.)
|
||||
5. **Live data has exactly three channels**: parent knows it → owner props at the renderSlot site; only the component knows it → local state; shared across entries or survives remounts → a store declared at register. Derived data is a pure function over framework-hook data (`useMemo`), never its own subscription.
|
||||
6. **Stores: read `props.useStore`, write `props.actions.*`** — the declared actions are the complete mutation surface. Write the store as an exported `createXXXStore()` factory (module-level handles are forbidden — de-facto singletons); share by passing one handle to several registers inside `apply`. Production code never calls the factory or `.create()` outside `apply`; tests do (that is the sanctioned zero-machinery path).
|
||||
7. **inject returns plain data and callbacks** from the apply closure's own ctx — no hooks, no ReactNode producers, no whole-service objects. Its capability boundary is the plugin's declared `inject` topology; there is no wider ctx to reach for.
|
||||
7. **inject returns plain data and callbacks** from the apply closure's own ctx — no hand-made hooks, no ReactNode producers, no whole-service objects. A registrant-private reactive fact rides the reserved `hooks` compartment (bare observables the renderer binds to `use<Name>`; components never see the sources). Its capability boundary is the plugin's declared `inject` topology; there is no wider ctx to reach for.
|
||||
|
||||
## Export discipline (client plugin packages)
|
||||
|
||||
|
||||
@@ -135,13 +135,15 @@ export function apply(ctx: Context): void {
|
||||
'conversation.input.model': { kind: 'single', scope: 'session' },
|
||||
},
|
||||
inject: (sessionId: SessionId): ComposerBarInjected => {
|
||||
const shell = inputHub.shell(sessionId)
|
||||
return {
|
||||
keyboard: inputHub.keyboard(sessionId),
|
||||
keyboard: shell,
|
||||
stop: () => {
|
||||
scopedConversation(sessions, sessionId).cancel().catch(() => {
|
||||
// Stop failure surfaces via snapshot.promptError; nothing to restore.
|
||||
})
|
||||
},
|
||||
hooks: { notices: shell.notices, lexicon: shell.lexicon },
|
||||
}
|
||||
},
|
||||
}, InputBar)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/** Conversation slot declarations and their composed component props. */
|
||||
import type { ReactNode, RefObject } from 'react'
|
||||
import type {
|
||||
MaybeSnapshotSelectorHook, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
|
||||
InjectFace, MaybeSnapshotSelectorHook, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ConversationSnapshot, PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSnapshot, ObservableSnapshot, PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { ComposerKeyboard, InputActions, InputState } from '../input/contract.ts'
|
||||
import type { ComposerKeyboard, InputActions, InputNotice, InputState } from '../input/contract.ts'
|
||||
import type { createChatStore } from '../stores.ts'
|
||||
import type { CallId, SelectionTarget, ViewTab } from './views.ts'
|
||||
|
||||
@@ -220,6 +220,13 @@ export interface ComposerBarInjected {
|
||||
keyboard: ComposerKeyboard
|
||||
/** Cancel the in-flight turn. */
|
||||
stop: () => void
|
||||
/** Registrant hooks compartment: the renderer binds these to useNotices/useLexicon. */
|
||||
hooks: {
|
||||
/** Latest surfaced notice (null after none; seq keys re-render of repeats). */
|
||||
notices: ObservableSnapshot<InputNotice | null>
|
||||
/** Hot plain-text reference lexicon for the decoration scan (decision 21). */
|
||||
lexicon: ObservableSnapshot<ReadonlyMap<'/' | '@', readonly string[]>>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -231,11 +238,11 @@ export interface InputControlOwnerProps {
|
||||
locked: boolean
|
||||
}
|
||||
|
||||
/** Full composer-bar component props: standard kit & owner share & control-seat render share & injected share. */
|
||||
/** Full composer-bar component props: standard kit & owner share & control-seat render share & injected share (hooks compartment bound). */
|
||||
export type ComposerBarProps =
|
||||
PropsRuntime<'conversation.composer.bar'>
|
||||
& PropsRenderSlots<'conversation.input.plan' | 'conversation.input.model'>
|
||||
& ComposerBarInjected
|
||||
& InjectFace<ComposerBarInjected>
|
||||
|
||||
/**
|
||||
* Composer chain currency: what ConversationRoot dispatches at its
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* conversation wiring layer alone sees the full SessionInput. InputMachine
|
||||
* (machine.ts) is package-private and never exported.
|
||||
*/
|
||||
import type { ClientContext, ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ClientContext, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome,
|
||||
ReferenceInsert, SubmitOutcome, TokenSpan,
|
||||
@@ -77,8 +77,6 @@ export interface InputNotice {
|
||||
* satisfies it structurally.
|
||||
*/
|
||||
export interface ComposerKeyboard {
|
||||
/** Latest surfaced notice store (null after none). */
|
||||
readonly notices: SnapshotStore<InputNotice | null>
|
||||
/** Live machine state for event-handler reads (render reads go through useInput). */
|
||||
readonly snapshot: InputState
|
||||
/** Draft write with the DOM-observed edit shape (narrows occurrence math). */
|
||||
@@ -99,8 +97,6 @@ export interface ComposerKeyboard {
|
||||
space(): boolean
|
||||
/** Dismiss the popupSelect shell (any interaction outside the box). */
|
||||
dismissPopup(): void
|
||||
/** Hot plain-text reference lexicon source for the decoration scan (decision 21; empty Map without a pipeline). */
|
||||
readonly lexicon: ObservableSnapshot<ReadonlyMap<'/' | '@', readonly string[]>>
|
||||
}
|
||||
|
||||
/** One queued-message row projected from the session/queued frames (T9 supplies the store). */
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/** The default composer body: the 'conversation.composer.bar' slot entry
|
||||
* (decision 20). Machine state arrives through the standard provide channel
|
||||
* (useInput + inputActions); the keyboard/DOM command face and stop arrive
|
||||
* through this entry's own inject; layout-phase inputs (variant, placeholder,
|
||||
* through this entry's own inject, whose hooks compartment binds
|
||||
* useNotices/useLexicon; layout-phase inputs (variant, placeholder,
|
||||
* region-slot content) ride the owner props. Session facts
|
||||
* (running/removed/promptError) are self-selected via useSession. */
|
||||
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
@@ -27,20 +28,12 @@ const READONLY_OPTIONS: readonly { id: string; label: string }[] = [
|
||||
]
|
||||
|
||||
export function InputBar({
|
||||
useSession, useInput, inputActions, keyboard, stop, renderSlot,
|
||||
useSession, useInput, inputActions, keyboard, stop, renderSlot, useNotices, useLexicon,
|
||||
variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment',
|
||||
}: InputBarProps) {
|
||||
const input = useInput(s => s)
|
||||
const noticeStore = keyboard.notices
|
||||
const notice = useSyncExternalStore(
|
||||
(fn: () => void) => noticeStore.subscribe(fn),
|
||||
() => noticeStore.getSnapshot(),
|
||||
)
|
||||
const lexiconStore = keyboard.lexicon
|
||||
const lexicon = useSyncExternalStore(
|
||||
(fn: () => void) => lexiconStore.subscribe(fn),
|
||||
() => lexiconStore.getSnapshot(),
|
||||
)
|
||||
const notice = useNotices(s => s)
|
||||
const lexicon = useLexicon(s => s)
|
||||
const promptError = useSession(s => s.promptError)
|
||||
const running = useSession(s => s.running)
|
||||
const disabled = useSession(s => s.removed)
|
||||
|
||||
@@ -91,6 +91,8 @@ function bench(over?: BenchOptions) {
|
||||
useInput: bindSnapshotSelector(shell.state),
|
||||
inputActions: shell.actions,
|
||||
keyboard: shell,
|
||||
useNotices: bindSnapshotSelector(shell.notices),
|
||||
useLexicon: bindSnapshotSelector(shell.lexicon),
|
||||
stop,
|
||||
renderSlot,
|
||||
variant: over?.variant ?? 'composer',
|
||||
|
||||
@@ -42,6 +42,8 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
|
||||
useInput: bindSnapshotSelector(shell.state),
|
||||
inputActions: shell.actions,
|
||||
keyboard: shell,
|
||||
useNotices: bindSnapshotSelector(shell.notices),
|
||||
useLexicon: bindSnapshotSelector(shell.lexicon),
|
||||
renderSlot: (() => null) as InputBarProps['renderSlot'],
|
||||
stop: vi.fn(),
|
||||
variant: 'composer',
|
||||
|
||||
@@ -128,6 +128,8 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
useInput: bindSnapshotSelector(shell.state),
|
||||
inputActions: shell.actions,
|
||||
keyboard: shell,
|
||||
useNotices: bindSnapshotSelector(shell.notices),
|
||||
useLexicon: bindSnapshotSelector(shell.lexicon),
|
||||
renderSlot: (() => null) as InputBarProps['renderSlot'],
|
||||
stop: vi.fn(),
|
||||
variant: 'composer',
|
||||
|
||||
@@ -118,6 +118,8 @@ function mount(
|
||||
useInput={useInput}
|
||||
inputActions={inputActions}
|
||||
keyboard={wiring}
|
||||
useNotices={bindSnapshotSelector(wiring.notices)}
|
||||
useLexicon={bindSnapshotSelector(wiring.lexicon)}
|
||||
stop={stop}
|
||||
renderSlot={(() => null) as InputBarProps['renderSlot']}
|
||||
{...bar}
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
* aria-labelledby the title node; close: visually-hidden slot text). Modal
|
||||
* open state and the active section id are component-local viewing state.
|
||||
*/
|
||||
import { useCallback, useEffect, useId, useRef, useState, useSyncExternalStore } from 'react'
|
||||
import { useCallback, useEffect, useId, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconCloseOutline16, IconDataOutline16, IconSettingsOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SettingsRootComponentProps } from './contract/slots.ts'
|
||||
import type { SettingsRootComponentProps, SettingsSectionRow } from './contract/slots.ts'
|
||||
import css from './SettingsRoot.module.css'
|
||||
|
||||
/** Nav glyph by section id; unknown ids fall back to the settings gear. */
|
||||
@@ -20,7 +20,7 @@ function navIcon(id: string) {
|
||||
}
|
||||
|
||||
type PanelProps = {
|
||||
rows: ReturnType<SettingsRootComponentProps['sections']>
|
||||
rows: readonly SettingsSectionRow[]
|
||||
renderSlot: SettingsRootComponentProps['renderSlot']
|
||||
onClose: () => void
|
||||
}
|
||||
@@ -92,18 +92,14 @@ function SettingsPanel({ rows, renderSlot, onClose }: PanelProps) {
|
||||
* @returns the settings shell element tree.
|
||||
*/
|
||||
export function SettingsRoot(props: SettingsRootComponentProps) {
|
||||
const { wide, subscribeSections, sectionsVersion, sections, renderSlot } = props
|
||||
const { wide, useSections, renderSlot } = props
|
||||
const [open, setOpen] = useState(false)
|
||||
const close = useCallback(() => { setOpen(false) }, [])
|
||||
|
||||
// The ledger tick keeps the nav rows fresh: registrants re-register with
|
||||
// freshly localized text on locale change, and the trigger/header/close
|
||||
// seats re-render through their own outlets' subscriptions.
|
||||
// uSES over the ledger version: same-version notifications dedupe to no
|
||||
// render, and a registration landing between render and effect
|
||||
// subscription cannot be missed.
|
||||
useSyncExternalStore(subscribeSections, sectionsVersion)
|
||||
const rows = sections()
|
||||
const rows = useSections(s => s)
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* setting never means editing the shell; copy that belongs to no single
|
||||
* feature (chrome, the General section) is owned by ui-settings-general.
|
||||
*/
|
||||
import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { HostObservable, InjectFace, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
// Type-only: pulls ui-sidebar's SlotMap merge (the 'sidebar.settings' entry)
|
||||
// into every program that sees this contract.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
||||
@@ -72,26 +72,32 @@ export interface SettingsSectionOwnerProps {
|
||||
children?: never
|
||||
}
|
||||
|
||||
/** One nav row projected from a settings.section registration's options. */
|
||||
export interface SettingsSectionRow {
|
||||
id: string
|
||||
order: number
|
||||
label: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Registrant-private injected share of the settings shell (assembled in
|
||||
* apply): ledger projections only — the shell reads no locale state.
|
||||
* apply): the ledger's nav-row projection as a hooks-compartment source —
|
||||
* the shell reads no locale state and subscribes through the bound hook.
|
||||
*/
|
||||
export type SettingsRootInjected = {
|
||||
/** Read the settings.section ledger version (nav invalidation). */
|
||||
sectionsVersion: () => number
|
||||
/** Subscribe to settings.section ledger changes. */
|
||||
subscribeSections: (listener: () => void) => () => void
|
||||
/** Project the settings.section ledger into nav rows (id/order/label). */
|
||||
sections: () => readonly { id: string; order: number; label: string }[]
|
||||
hooks: {
|
||||
/** settings.section ledger projected into ordered nav rows. */
|
||||
sections: HostObservable<readonly SettingsSectionRow[]>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full component props of the settings shell root: the sidebar owner share
|
||||
* (wide/rail state) plus the declared render shares and the injected face.
|
||||
* No store is registered — modal open state and active section id are
|
||||
* component-local viewing state.
|
||||
* (wide/rail state) plus the declared render shares and the injected face
|
||||
* (hooks compartment bound to useSections). No store is registered — modal
|
||||
* open state and active section id are component-local viewing state.
|
||||
*/
|
||||
export type SettingsRootComponentProps =
|
||||
PropsRuntime<'sidebar.settings'>
|
||||
& PropsRenderSlots<'settings.trigger' | 'settings.header' | 'settings.close' | 'settings.section'>
|
||||
& SettingsRootInjected
|
||||
& InjectFace<SettingsRootInjected>
|
||||
|
||||
@@ -10,12 +10,12 @@
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SettingsRootInjected } from './contract/slots.ts'
|
||||
import type { SettingsRootInjected, SettingsSectionRow } from './contract/slots.ts'
|
||||
import { SettingsRoot } from './SettingsRoot.tsx'
|
||||
|
||||
export type {
|
||||
SettingsHeaderOwnerProps, SettingsRootComponentProps, SettingsRootInjected,
|
||||
SettingsSectionOwnerProps, SettingsTriggerOwnerProps,
|
||||
SettingsSectionOwnerProps, SettingsSectionRow, SettingsTriggerOwnerProps,
|
||||
} from './contract/slots.ts'
|
||||
|
||||
/**
|
||||
@@ -32,17 +32,31 @@ export const inject = ['slots']
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
// Ledger → nav-row projection as an observable source (uSES contract:
|
||||
// getSnapshot returns the cached rows until the ledger version moves).
|
||||
let rowsVersion = -1
|
||||
let rows: readonly SettingsSectionRow[] = []
|
||||
const injected = (): SettingsRootInjected => ({
|
||||
sectionsVersion: () => ctx.slots.getVersion('settings.section'),
|
||||
subscribeSections: listener => ctx.slots.subscribe('settings.section', listener),
|
||||
sections: () => ctx.slots.entries('settings.section')
|
||||
.map(e => ({
|
||||
/* v8 ignore next -- list-slot registration requires id (SlotCore rejects an entry without one) */
|
||||
id: e.options.id ?? '',
|
||||
order: e.options.order ?? 0,
|
||||
label: e.options.label ?? '',
|
||||
}))
|
||||
.sort((a, b) => a.order - b.order),
|
||||
hooks: {
|
||||
sections: {
|
||||
getSnapshot: () => {
|
||||
const version = ctx.slots.getVersion('settings.section')
|
||||
if (version !== rowsVersion) {
|
||||
rowsVersion = version
|
||||
rows = ctx.slots.entries('settings.section')
|
||||
.map(e => ({
|
||||
/* v8 ignore next -- list-slot registration requires id (SlotCore rejects an entry without one) */
|
||||
id: e.options.id ?? '',
|
||||
order: e.options.order ?? 0,
|
||||
label: e.options.label ?? '',
|
||||
}))
|
||||
.sort((a, b) => a.order - b.order)
|
||||
}
|
||||
return rows
|
||||
},
|
||||
subscribe: listener => ctx.slots.subscribe('settings.section', listener),
|
||||
},
|
||||
},
|
||||
})
|
||||
ctx.effect(() => {
|
||||
const deferred = deferRegistration(ctx.slots, 'sidebar.settings', SettingsRoot, () =>
|
||||
|
||||
@@ -60,22 +60,25 @@ describe('ui-settings apply', () => {
|
||||
const b = await bench()
|
||||
declare(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const injected = injectedOf(b.slots)
|
||||
const { sections } = injectedOf(b.slots).hooks
|
||||
// The shell ships no sections of its own — registrants fill the ledger.
|
||||
expect(injected.sections()).toEqual([])
|
||||
expect(sections.getSnapshot()).toEqual([])
|
||||
b.slots.register({ name: 'settings.section', id: 'z', order: 20, label: 'Z' } as never, () => null)
|
||||
// No order and no label: both projection defaults apply.
|
||||
b.slots.register({ name: 'settings.section', id: 'a' } as never, () => null)
|
||||
expect(injected.sections()).toEqual([
|
||||
const rows = sections.getSnapshot()
|
||||
expect(rows).toEqual([
|
||||
{ id: 'a', order: 0, label: '' },
|
||||
{ id: 'z', order: 20, label: 'Z' },
|
||||
])
|
||||
expect(injected.sectionsVersion()).toBe(b.slots.getVersion('settings.section'))
|
||||
// Snapshot identity is stable until the ledger moves (uSES contract).
|
||||
expect(sections.getSnapshot()).toBe(rows)
|
||||
const listener = vi.fn()
|
||||
const off = injected.subscribeSections(listener)
|
||||
const off = sections.subscribe(listener)
|
||||
b.slots.register({ name: 'settings.section', id: 'b', order: 1, label: 'B' } as never, () => null)
|
||||
await Promise.resolve()
|
||||
expect(listener).toHaveBeenCalled()
|
||||
expect(sections.getSnapshot()).not.toBe(rows)
|
||||
off()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { SettingsRootComponentProps } from '../src/client/contract/slots.ts'
|
||||
import { SettingsRoot } from '../src/client/SettingsRoot.tsx'
|
||||
@@ -22,9 +23,9 @@ function mount({
|
||||
{ id: 'models', order: 10, label: 'Models' },
|
||||
],
|
||||
}: { wide?: boolean; rows?: Row[] } = {}) {
|
||||
// Mutable row store standing in for the ledger; bump() plays a change.
|
||||
// Mutable row source standing in for the bound useSections hook; bump()
|
||||
// plays a ledger change through the same observable contract.
|
||||
let current = rows
|
||||
let version = 0
|
||||
const listeners = new Set<() => void>()
|
||||
const renderSlot = vi.fn(
|
||||
((key: string, _owner: unknown, opts?: { only?: string }) => {
|
||||
@@ -38,19 +39,21 @@ function mount({
|
||||
useSessions: unusedHook,
|
||||
useWorkspaces: unusedHook,
|
||||
wide,
|
||||
sectionsVersion: () => version,
|
||||
subscribeSections: (listener) => {
|
||||
listeners.add(listener)
|
||||
return () => { listeners.delete(listener) }
|
||||
useSections: (select) => {
|
||||
const [, force] = useState(0)
|
||||
useEffect(() => {
|
||||
const listener = () => { force(n => n + 1) }
|
||||
listeners.add(listener)
|
||||
return () => { listeners.delete(listener) }
|
||||
}, [])
|
||||
return select(current)
|
||||
},
|
||||
sections: () => current,
|
||||
renderSlot,
|
||||
}
|
||||
const view = render(<SettingsRoot {...props} />)
|
||||
const bump = (next: Row[]) => {
|
||||
act(() => {
|
||||
current = next
|
||||
version += 1
|
||||
for (const fn of [...listeners]) fn()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* consumer merges keys in and the intersection is what keeps them string-typed.
|
||||
* The rule fires on the empty-map view, not on real redundancy. */
|
||||
import type { ReactNode } from 'react'
|
||||
import type { HostObservable } from './renderer.ts'
|
||||
import type { BoundActions, HandleOf, PropsStore, SnapshotSelectorHook, StoreDecl } from './store.ts'
|
||||
|
||||
export * from './store.ts'
|
||||
@@ -214,11 +215,40 @@ export type PropsRenderSlots<S extends keyof SlotMap & string> = {
|
||||
*/
|
||||
export type SlotComponent<P> = (props: P) => ReactNode
|
||||
|
||||
/**
|
||||
* Registrant hooks compartment: bare observable sources (getSnapshot +
|
||||
* subscribe pairs) supplied under the reserved `hooks` key of an inject
|
||||
* face. The registrant-private twin of the `sessions.provide` hooks
|
||||
* compartment: the renderer binds each source into a `use<Name>` selector
|
||||
* hook, so the sources never reach the component and plugin-private reactive
|
||||
* facts ride the same subscription machinery as the standard kit instead of
|
||||
* hand-rolled component subscriptions.
|
||||
*/
|
||||
export type HooksSources = Record<string, HostObservable<unknown>>
|
||||
|
||||
/**
|
||||
* Selector-hook share synthesized from a hooks compartment: each source
|
||||
* `name` becomes a `use<Name>` selector hook over its snapshot type.
|
||||
*/
|
||||
export type PropsHooks<HS extends HooksSources> = {
|
||||
[N in keyof HS & string as `use${Capitalize<N>}`]:
|
||||
SnapshotSelectorHook<HS[N] extends HostObservable<infer T> ? T : never>
|
||||
}
|
||||
|
||||
/**
|
||||
* The component-side view of an inject face: the reserved `hooks`
|
||||
* compartment (when declared) arrives as bound `use<Name>` selector hooks;
|
||||
* every other member passes through verbatim.
|
||||
*/
|
||||
export type InjectFace<I extends object> =
|
||||
I extends { hooks: infer HS extends HooksSources } ? Omit<I, 'hooks'> & PropsHooks<HS> : I
|
||||
|
||||
/**
|
||||
* The four-share component props intersection: runtime share (SlotMap) +
|
||||
* child-render share (children declaration) + store share (declared handle) +
|
||||
* the registrant's injected business face. Each share derives from its single
|
||||
* source of truth; components reference this composition, never re-type it.
|
||||
* the registrant's injected business face (its hooks compartment bound, see
|
||||
* {@link InjectFace}). Each share derives from its single source of truth;
|
||||
* components reference this composition, never re-type it.
|
||||
*/
|
||||
export type ComposedProps<
|
||||
K extends keyof SlotMap & string,
|
||||
@@ -226,7 +256,7 @@ export type ComposedProps<
|
||||
H,
|
||||
I extends object,
|
||||
M = never,
|
||||
> = PropsRuntime<K> & PropsRenderSlots<S> & PropsStore<H> & I & MatchedShare<SlotMap[K], M>
|
||||
> = PropsRuntime<K> & PropsRenderSlots<S> & PropsStore<H> & InjectFace<I> & MatchedShare<SlotMap[K], M>
|
||||
|
||||
/**
|
||||
* Inject factory parameter list, derived from the registration's declaration:
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
import { Component, useSyncExternalStore, type FC, type ReactNode } from 'react'
|
||||
import {
|
||||
SlotOwnershipError, StaleAuthorizationError,
|
||||
type ChainRenderOpts, type RenderOpts, type SessionMaybeProvideInfo, type SessionProvideInfo,
|
||||
type SlotRenderer, type SlotRendererHost, type SlotScope, type StoredEntry,
|
||||
type ChainRenderOpts, type HostObservable, type RenderOpts, type SessionMaybeProvideInfo,
|
||||
type SessionProvideInfo, type SlotRenderer, type SlotRendererHost, type SlotScope, type StoredEntry,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
HostContext, SessionMaybeProvider, SessionProvider, SlotAssemblyError, maybeObservableHook,
|
||||
@@ -96,7 +96,26 @@ function runInject(entry: StoredEntry, info: SessionMaybeProvideInfo | undefined
|
||||
const args: unknown[] = []
|
||||
if (info !== undefined) args.push(info.sessionId)
|
||||
if (actions !== undefined) args.push(actions)
|
||||
return (inject as (...args: unknown[]) => InjectedProps)(...args)
|
||||
return bindInjectHooks((inject as (...args: unknown[]) => InjectedProps)(...args))
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind an inject face's reserved `hooks` compartment (bare observable
|
||||
* sources, see HooksSources) into `use<Name>` selector hooks — the
|
||||
* registrant-private twin of the provide-bundle binding in standardKit.
|
||||
* Runs once per cached inject result; hook identity rides observableHook's
|
||||
* per-source cache.
|
||||
*/
|
||||
function bindInjectHooks(face: InjectedProps): InjectedProps {
|
||||
const sources = face['hooks']
|
||||
if (sources === undefined) return face
|
||||
const { hooks: _hooks, ...rest } = face
|
||||
const bound: InjectedProps = rest
|
||||
for (const [name, source] of Object.entries(sources as Record<string, HostObservable<unknown>>)) {
|
||||
const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}`
|
||||
bound[hookName] = observableHook(source)
|
||||
}
|
||||
return bound
|
||||
}
|
||||
|
||||
function cachedRootInject(entry: StoredEntry, actions: object | undefined): InjectedProps {
|
||||
|
||||
@@ -748,6 +748,25 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
|
||||
expect(inject).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('binds the inject hooks compartment into use<Name> selector hooks (sources never reach the component)', () => {
|
||||
const h = makeHost()
|
||||
h.declare('k.single', SINGLE_ROOT)
|
||||
const badge = observable('cold')
|
||||
const seen: Record<string, unknown>[] = []
|
||||
h.add('k.single', {
|
||||
component: (props: { useBadge?: <S>(sel: (s: string) => S) => S; hooks?: unknown; plain?: string }) => {
|
||||
seen.push({ hooks: props.hooks, plain: props.plain, read: props.useBadge!(s => s) })
|
||||
return null
|
||||
},
|
||||
inject: () => ({ plain: 'kept', hooks: { badge } }),
|
||||
})
|
||||
mountRoot(h, { 'k.single': SINGLE_ROOT }, renderSlot => renderSlot('k.single', {}))
|
||||
// The raw compartment is consumed by the binding; the plain member passes through.
|
||||
expect(seen.at(-1)).toEqual({ hooks: undefined, plain: 'kept', read: 'cold' })
|
||||
act(() => { badge.set('hot') })
|
||||
expect(seen.at(-1)!['read']).toBe('hot')
|
||||
})
|
||||
|
||||
it('session inject receives sessionId and caches per (entry x session): switch-back reuses', () => {
|
||||
const h = makeHost()
|
||||
h.declare('k.session', SINGLE_SESSION)
|
||||
|
||||
Reference in New Issue
Block a user