// @vitest-environment jsdom /** * createSlotRenderer machinery account over a behavioral fake host: root * outlet + per-kind child outlets, standard-kit synthesis (renderSlot * 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 * SlotsService suite, not here. */ import { describe, expect, it, vi } from 'vitest' import { act, fireEvent, render } from '@testing-library/react' import { useEffect, type ReactNode } from 'react' import type { ActionsDecl, SlotEntryDef, SlotSpec, StoreHandle, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots' import { createSlotRenderer, SessionProvider, SlotOwnershipError, StaleAuthorizationError, type RenderOpts, type SessionProvideInfo, type SlotRendererHost, type StoreInstanceLike, } from '@deepseek-ai/dsh-client-web-react' 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 DeclaredSpec = SlotSpec /** Entry literal helper: fake entries default the mandatory options bag. */ const entryOf = (partial: Omit & { options?: StoredEntry['options'] }): StoredEntry => ({ options: {}, ...partial }) /** * Minimal store handle satisfying the StoreDecl contract shape (spec + * 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 web-react tests must not import (dependency direction). */ function miniStore(init: () => T, mutators: Record T>): StoreHandle> { return { spec: { init, actions: {} }, create: () => { let state = init() const listeners = new Set<() => void>() const actions: Record void> = {} for (const key of Object.keys(mutators)) { actions[key] = (...params: never[]) => { state = mutators[key]!(state, ...params) for (const fn of [...listeners]) fn() } } return { getSnapshot: () => state, subscribe: (fn) => { listeners.add(fn); return () => { listeners.delete(fn) } }, actions, clearPersisted: () => {}, } as StoreInstanceLike as ReturnType>['create']> }, } } function observable(initial: T) { let value = initial const subs = new Set<() => void>() return { getSnapshot: () => value, subscribe: (fn: () => void) => { subs.add(fn); return () => { subs.delete(fn) } }, set: (next: T) => { value = next; for (const fn of [...subs]) fn() }, } } /** * 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 * the entry's real handle, cached per (entry x scope key) like the real * ledger; session cells are identity-stable per id. */ function makeHost() { const entries = new Map() const specs = new Map() const versions = new Map() const subs = new Map void>>() const live = new Set() const storeCache = new Map>() const list = observable<{ ids: string[] }>({ ids: [] }) const workspaces = observable<{ ids: string[] }>({ ids: [] }) const current = observable(undefined) const infos = new Map() const bump = (key: string) => { versions.set(key, (versions.get(key) ?? 0) + 1) for (const fn of [...(subs.get(key) ?? [])]) fn() } const host: SlotRendererHost = { subscribe: (key, fn) => { const set = subs.get(key) ?? new Set() set.add(fn) subs.set(key, set) return () => { set.delete(fn) } }, getVersion: (key) => versions.get(key) ?? 0, entriesOf: (key) => entries.get(key) ?? [], specOf: (key) => specs.get(key), isLive: (entry) => live.has(entry), storeOf: (entry, scopeKey) => { if (entry.store === undefined) return undefined let perScope = storeCache.get(entry) if (!perScope) { perScope = new Map() storeCache.set(entry, perScope) } const cacheKey = scopeKey ?? '' 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) perScope.set(cacheKey, instance) } return instance }, sessions: { list, current, provideInfo: (id) => infos.get(id), maybeProvideInfo: (id) => (id === undefined ? undefined : infos.get(id)) ?? { sessionId: undefined, hooks: {}, props: {} }, }, workspaces: { list: workspaces }, } return { host, list, workspaces, current, declare: (key: string, spec: DeclaredSpec) => { specs.set(key, spec); bump(key) }, add: (key: string, partial: Omit & { options?: StoredEntry['options'] }) => { const entry = entryOf(partial) const next = [...(entries.get(key) ?? []), entry] // Mirror the ledger contract: chain entries arrive priority-sorted // (stable, ascending) — outlets iterate entries() order as-is. if (specs.get(key)?.kind === 'chain') { next.sort((a, b) => (a.options.priority ?? 0) - (b.options.priority ?? 0)) } entries.set(key, next) live.add(entry) bump(key) return () => { entries.set(key, (entries.get(key) ?? []).filter((e) => e !== entry)) live.delete(entry) bump(key) } }, addSession: (id: string): SessionProvideInfo => { // Bare source per bundle (identity-stable): the machinery binds useSession from it. const info: SessionProvideInfo = { sessionId: id, hooks: { session: { getSnapshot: () => ({ sid: id }), subscribe: () => () => {} } }, props: {}, } infos.set(id, info) return info }, } } 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) { const dispose = h.add('root', { component: (props: { renderSlot: RenderSlotFn }) => <>{body(props.renderSlot)}, children, }) const renderer = createSlotRenderer() const view = render(<>{renderer.renderRoot(h.host, {})}) return { view, dispose } } const SINGLE_ROOT: DeclaredSpec = { kind: 'single', scope: 'root' } const SINGLE_SESSION: DeclaredSpec = { kind: 'single', scope: 'session' } const CHAIN_ROOT: DeclaredSpec = { kind: 'chain', scope: 'root' } /** Chain entry literal: top-level select, priority in the options bag (the StoredEntry chain shape). */ const chainEntryOf = (partial: { component: unknown select: (owner: object) => unknown priority?: number }): Omit & { options?: StoredEntry['options'] } => ({ component: partial.component, select: partial.select as StoredEntry['select'], ...(partial.priority !== undefined ? { options: { priority: partial.priority } } : {}), }) /** Mount a root entry whose component renders `body` with its kit renderSlotChain. */ function mountChainRoot(h: Fake, children: Record, body: (renderSlotChain: RenderSlotChainFn) => ReactNode) { const dispose = h.add('root', { component: (props: { renderSlotChain: RenderSlotChainFn }) => <>{body(props.renderSlotChain)}, children, }) const renderer = createSlotRenderer() const view = render(<>{renderer.renderRoot(h.host, {})}) return { view, dispose } } describe('root outlet', () => { it('renders the root registration and fails loud when root is unregistered (boot order)', () => { const h = makeHost() h.add('root', { component: () => shell }) const renderer = createSlotRenderer() const view = render(<>{renderer.renderRoot(h.host, {})}) expect(view.container.textContent).toBe('shell') const empty = makeHost() const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) expect(() => render(<>{createSlotRenderer().renderRoot(empty.host, {})})) .toThrow(/boot order/) spy.mockRestore() }) it('passes renderRoot owner props into the root component', () => { const h = makeHost() h.add('root', { component: ({ tag }: { tag?: string }) => {tag} }) const view = render(<>{createSlotRenderer().renderRoot(h.host, { tag: 'OWNER' })}) expect(view.container.textContent).toBe('OWNER') }) }) describe('child outlets and the renderSlot binding', () => { it('renders declared single slots live: fallback when empty, register, dispose back', () => { const h = makeHost() h.declare('k.single', SINGLE_ROOT) const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, (renderSlot) => renderSlot('k.single', {}, { fallback: none })) expect(view.container.textContent).toBe('none') let dispose = () => {} act(() => { dispose = h.add('k.single', { component: () => SB }) }) expect(view.container.textContent).toBe('SB') act(() => { dispose() }) expect(view.container.textContent).toBe('none') }) it('renders an undeclared key as empty (declaring entry unloaded = natural blank, not a crash)', () => { const h = makeHost() const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, (renderSlot) =>
{renderSlot('k.single', {}, { fallback: fb })}
) // Declared by children (authorization) but absent from the ledger (specOf // undefined): the outlet renders nothing, not even the fallback path's spec dispatch. expect(view.container.querySelector('main')!.textContent).toBe('') }) it('orders list entries, honors only-filter, dispatches keyed entries by entryKey', () => { const h = makeHost() h.declare('k.list', { kind: 'list', scope: 'root' }) h.declare('k.keyed', { kind: 'keyed', scope: 'root' }) h.add('k.list', { component: () => b, options: { id: 'b', order: 2 } }) h.add('k.list', { component: () => a, options: { id: 'a', order: 1 } }) h.add('k.keyed', { component: () => goal, options: { key: 'goal' } }) const children = { 'k.list': { kind: 'list', scope: 'root' } as DeclaredSpec, 'k.keyed': { kind: 'keyed', scope: 'root' } as DeclaredSpec } const { view } = mountRoot(h, children, (renderSlot) => <>
{renderSlot('k.list', {})}
{renderSlot('k.keyed', {}, { entryKey: 'nope', fallback: fb })}
) expect(view.container.querySelector('main')!.textContent).toBe('ab') expect(view.container.querySelector('aside')!.textContent).toBe('b') expect(view.container.querySelector('nav')!.textContent).toBe('goal') expect(view.container.querySelector('footer')!.textContent).toBe('fb') }) it('keeps the binding identity-stable across re-renders and throws SlotOwnershipError off-declaration', () => { const h = makeHost() h.declare('k.single', SINGLE_ROOT) const seen: RenderSlotFn[] = [] mountRoot(h, { 'k.single': SINGLE_ROOT }, (renderSlot) => { seen.push(renderSlot) return renderSlot('k.single', {}) }) // Bump the 'root' key to force a root-entry re-render (the single-kind // outlet only reads entries[0], so the extra entry is inert). act(() => { h.add('root', { component: () => null }) }) expect(seen.length).toBeGreaterThan(1) expect(seen.at(-1)).toBe(seen[0]) expect(() => seen[0]!('k.undeclared', {})).toThrow(SlotOwnershipError) }) it('isolates a crashing entry without collapsing siblings', () => { const h = makeHost() h.declare('k.list', { kind: 'list', scope: 'root' }) h.add('k.list', { component: () => { throw new Error('entry boom') }, options: { id: 'bad', order: 1 } }) h.add('k.list', { component: () => alive, options: { id: 'ok', order: 2 } }) const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) const { view } = mountRoot(h, { 'k.list': { kind: 'list', scope: 'root' } }, (renderSlot) => renderSlot('k.list', {})) spy.mockRestore() expect(view.container.textContent).toBe('alive') expect(view.container.querySelector('[data-slot-error]')).not.toBeNull() }) }) describe('chain outlets and the renderSlotChain binding', () => { it('elects the first non-null selector in order, injects matched, and skips decliners without mounting them', () => { const h = makeHost() h.declare('k.chain', CHAIN_ROOT) const declinerBody = vi.fn(() => never) h.add('k.chain', chainEntryOf({ component: declinerBody, select: () => null, })) h.add('k.chain', chainEntryOf({ component: ({ matched }: { matched?: { label: string } }) => {matched?.label}, select: (owner) => ({ label: `hit:${(owner as { tag: string }).tag}` }), })) const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, (renderSlotChain) => renderSlotChain('k.chain', { tag: 'T' })) // The declining entry never mounts: the routing decision is select-layer only. expect(view.container.textContent).toBe('hit:T') expect(declinerBody).not.toHaveBeenCalled() }) it('contains a throwing selector to its entry: reported, treated as declined, chain and fallback intact', () => { const h = makeHost() h.declare('k.chain', CHAIN_ROOT) h.add('k.chain', chainEntryOf({ component: () => never, select: () => { throw new Error('selector boom') }, })) h.add('k.chain', chainEntryOf({ component: ({ matched }: { matched?: string }) => {matched}, select: (owner) => (owner as { pick?: string }).pick ?? null, })) const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, (renderSlotChain) => <>
{renderSlotChain('k.chain', { pick: 'OK' })}
) // The breach never escapes to the owner region: later entries still get // tried, and an all-throw/all-null pass still lands on the fallback. expect(view.container.querySelector('main')!.textContent).toBe('OK') expect(view.container.querySelector('aside')!.textContent).toBe('fb') expect(spy.mock.calls.some(([msg]) => String(msg).includes('chain selector crashed'))).toBe(true) spy.mockRestore() }) it('remounts the boundary on re-election: a failed entry does not black out its replacement', () => { const h = makeHost() h.declare('k.chain', CHAIN_ROOT) h.add('k.chain', chainEntryOf({ component: () => { throw new Error('entry A boom') }, select: (owner) => (owner as { pick?: string }).pick === 'A' ? {} : null, })) h.add('k.chain', chainEntryOf({ component: () => B-ok, select: (owner) => (owner as { pick?: string }).pick === 'B' ? {} : null, })) let pick = 'A' const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, (renderSlotChain) => renderSlotChain('k.chain', { pick })) spy.mockRestore() expect(view.container.querySelector('[data-slot-error]')).not.toBeNull() // Re-elect entry B: the entry-keyed boundary remounts fresh instead of // holding A's failed state over the healthy replacement. pick = 'B' act(() => { h.add('root', { component: () => null }) }) // root bump re-renders the dispatch site expect(view.container.textContent).toBe('B-ok') expect(view.container.querySelector('[data-slot-error]')).toBeNull() }) it('falls to the owner fallback when every selector declines, and re-routes live', () => { const h = makeHost() h.declare('k.chain', CHAIN_ROOT) h.add('k.chain', chainEntryOf({ component: ({ matched }: { matched?: string }) => {matched}, select: (owner) => (owner as { pick?: string }).pick ?? null, })) const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, (renderSlotChain) => <>
{renderSlotChain('k.chain', {}, { fallback: bar })}
) // Same chain, two dispatch sites: all-null owner props fall back, matching ones elect. expect(view.container.querySelector('main')!.textContent).toBe('bar') expect(view.container.querySelector('aside')!.textContent).toBe('P') }) it('renders the fallback for an empty chain and elects live once an entry registers', () => { const h = makeHost() h.declare('k.chain', CHAIN_ROOT) const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, (renderSlotChain) => renderSlotChain('k.chain', {}, { fallback: none })) expect(view.container.textContent).toBe('none') let dispose = () => {} act(() => { dispose = h.add('k.chain', chainEntryOf({ component: () => IN, select: () => ({}), })) }) expect(view.container.textContent).toBe('IN') act(() => { dispose() }) expect(view.container.textContent).toBe('none') }) it('orders the chain by ascending priority with registration sequence breaking ties', () => { const h = makeHost() h.declare('k.chain', CHAIN_ROOT) // Registered first but priority 2: must yield to the later priority-1 entry. h.add('k.chain', chainEntryOf({ component: () => late, select: () => ({}), priority: 2, })) h.add('k.chain', chainEntryOf({ component: () => early, select: () => ({}), priority: 1, })) // Tie pair at priority 1: registration order decides (early wins over tie). h.add('k.chain', chainEntryOf({ component: () => tie, select: () => ({}), priority: 1, })) const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, (renderSlotChain) => renderSlotChain('k.chain', {})) expect(view.container.textContent).toBe('early') }) it('keeps the renderSlotChain binding identity-stable across re-renders', () => { const h = makeHost() h.declare('k.chain', CHAIN_ROOT) const seen: RenderSlotChainFn[] = [] mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, (renderSlotChain) => { seen.push(renderSlotChain) return renderSlotChain('k.chain', {}, { fallback: fb }) }) act(() => { h.add('root', { component: () => null }) }) // root bump re-renders the entry expect(seen.length).toBeGreaterThan(1) expect(seen.at(-1)).toBe(seen[0]) }) it('backstops off-declaration keys, kind mismatches both ways, and disposed registrations', () => { const h = makeHost() h.declare('k.chain', CHAIN_ROOT) h.declare('k.single', SINGLE_ROOT) let chainFn: RenderSlotChainFn | undefined let slotFn: RenderSlotFn | undefined const dispose = h.add('root', { component: (props: { renderSlot: RenderSlotFn; renderSlotChain: RenderSlotChainFn }) => { slotFn = props.renderSlot chainFn = props.renderSlotChain return null }, children: { 'k.chain': CHAIN_ROOT, 'k.single': SINGLE_ROOT }, }) const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}) expect(() => chainFn!('k.undeclared', {})).toThrow(SlotOwnershipError) expect(() => chainFn!('k.single', {})).toThrow(SlotOwnershipError) // non-chain key via chain face expect(() => slotFn!('k.chain', {})).toThrow(SlotOwnershipError) // chain key via plain face view.unmount() dispose() expect(() => chainFn!('k.chain', {})).toThrow(StaleAuthorizationError) }) it('withholds the renderSlotChain seat from entries declaring no chain child', () => { const h = makeHost() h.declare('k.single', SINGLE_ROOT) const seen: AnyProps[] = [] h.add('root', { component: (props: AnyProps) => { seen.push(props); return null }, children: { 'k.single': SINGLE_ROOT }, }) render(<>{createSlotRenderer().renderRoot(h.host, {})}) expect(seen.at(-1)!['renderSlotChain']).toBeUndefined() }) }) describe('overlay chains (ChainRenderOpts.overlay)', () => { /** Fallback probe: counts mounts and holds uncontrolled DOM state (the * composer-draft stand-in an unmount would wipe). */ function fallbackProbe(onMount: () => void) { return function Probe() { useEffect(onMount, []) return } } it('keeps the fallback mounted and state-holding through a takeover, hidden then restored', () => { const h = makeHost() h.declare('k.chain', CHAIN_ROOT) h.add('k.chain', chainEntryOf({ component: () => TAKEOVER, select: (owner) => (owner as { take?: boolean }).take ? {} : null, })) const mounted = vi.fn() const Probe = fallbackProbe(mounted) let take = false const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, (renderSlotChain) => renderSlotChain('k.chain', { take }, { fallback: , overlay: true })) const wrapper = () => view.container.querySelector('[data-chain-overlay-fallback="k.chain"]')! const input = () => view.container.querySelector('input[aria-label="probe"]')! // Resident phase: fallback visible through the layout-neutral wrapper. expect(wrapper().style.display).toBe('contents') fireEvent.change(input(), { target: { value: 'draft-in-flight' } }) // Election: entry overlays, fallback hides in place — same DOM node, no remount. take = true act(() => { h.add('root', { component: () => null }) }) // root bump re-renders the dispatch site expect(view.container.textContent).toContain('TAKEOVER') expect(wrapper().style.display).toBe('none') expect(input().value).toBe('draft-in-flight') // Takeover ends: fallback shows again with its state intact, still the original mount. take = false act(() => { h.add('root', { component: () => null }) }) expect(view.container.textContent).not.toContain('TAKEOVER') expect(wrapper().style.display).toBe('contents') expect(input().value).toBe('draft-in-flight') expect(mounted).toHaveBeenCalledTimes(1) }) it('leaves non-overlay chains on the unmount path: a takeover discards fallback state', () => { const h = makeHost() h.declare('k.chain', CHAIN_ROOT) h.add('k.chain', chainEntryOf({ component: () => TAKEOVER, select: (owner) => (owner as { take?: boolean }).take ? {} : null, })) const mounted = vi.fn() const Probe = fallbackProbe(mounted) let take = false const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, (renderSlotChain) => renderSlotChain('k.chain', { take }, { fallback: })) fireEvent.change(view.container.querySelector('input[aria-label="probe"]')!, { target: { value: 'gone' } }) expect(view.container.querySelector('[data-chain-overlay-fallback]')).toBeNull() take = true act(() => { h.add('root', { component: () => null }) }) expect(view.container.querySelector('input[aria-label="probe"]')).toBeNull() // unmounted take = false act(() => { h.add('root', { component: () => null }) }) const remounted = view.container.querySelector('input[aria-label="probe"]')! expect(remounted.value).toBe('') // fresh mount, state discarded expect(mounted).toHaveBeenCalledTimes(2) }) it('keeps election semantics under overlay: priority order, selector-crash decline, live dispose back to fallback', () => { const h = makeHost() h.declare('k.chain', CHAIN_ROOT) const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) h.add('k.chain', chainEntryOf({ component: () => never, select: () => { throw new Error('selector boom') }, priority: 1, })) const dispose = h.add('k.chain', chainEntryOf({ component: () => ELECTED, select: () => ({}), priority: 2, })) const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, (renderSlotChain) => renderSlotChain('k.chain', {}, { fallback: resident, overlay: true })) expect(view.container.textContent).toContain('ELECTED') expect(spy.mock.calls.some(([msg]) => String(msg).includes('chain selector crashed'))).toBe(true) spy.mockRestore() act(() => { dispose() }) const wrapper = view.container.querySelector('[data-chain-overlay-fallback="k.chain"]')! expect(wrapper.style.display).toBe('contents') expect(view.container.textContent).toBe('resident') }) }) describe('standard-kit synthesis', () => { it('delivers a live useSessions hook to every slot component', () => { const h = makeHost() h.declare('k.single', SINGLE_ROOT) h.add('k.single', { component: ({ useSessions }: { useSessions: (sel: (s: { ids: string[] }) => S) => S }) => {useSessions((s) => s.ids.length)}, }) const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, (renderSlot) => renderSlot('k.single', {})) expect(view.container.textContent).toBe('0') act(() => { h.list.set({ ids: ['a', 'b'] }) }) expect(view.container.textContent).toBe('2') }) it('delivers a live useWorkspaces hook to every slot component', () => { const h = makeHost() h.declare('k.single', SINGLE_ROOT) h.add('k.single', { component: ({ useWorkspaces }: { useWorkspaces: (sel: (s: { ids: string[] }) => S) => S }) => {useWorkspaces((s) => s.ids.length)}, }) const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, (renderSlot) => renderSlot('k.single', {})) expect(view.container.textContent).toBe('0') act(() => { h.workspaces.set({ ids: ['w1'] }) }) expect(view.container.textContent).toBe('1') }) it('delivers the session pair (bound useSession + sessionId) under SessionProvider', () => { const h = makeHost() h.declare('k.session', SINGLE_SESSION) h.addSession('s1') const seen: AnyProps[] = [] h.add('k.session', { component: (props: { useSession?: (sel: (s: { sid: string }) => S) => S; sessionId?: string }) => { seen.push({ ...props, read: props.useSession!((s) => s.sid) }) return null }, }) mountRoot(h, { 'k.session': SINGLE_SESSION }, (renderSlot) => ( empty}> {() => renderSlot('k.session', {})} )) act(() => { h.current.set('s1') }) const props = seen.at(-1)! // The hook is BOUND by the machinery from the cell's bare source: it // reads the source's snapshot and stays identity-stable across renders // (per-source cache), which the switch-back cache tests cover. expect(props['read']).toBe('s1') expect(props['sessionId']).toBe('s1') }) it('hands the SessionProvider seat to entries declaring a session-scope child', () => { const h = makeHost() h.declare('k.session', SINGLE_SESSION) h.declare('k.single', SINGLE_ROOT) h.addSession('s1') h.add('k.session', { component: ({ sessionId }: { sessionId?: string }) => {sessionId} }) const rootSeen: AnyProps[] = [] // Root entry uses its INJECTED provider seat (no value import of SessionProvider). h.add('root', { component: (props: AnyProps) => { rootSeen.push(props) const Provider = props['SessionProvider'] as typeof SessionProvider const renderSlot = props['renderSlot'] as RenderSlotFn return ( empty}> {() => renderSlot('k.session', {})} ) }, children: { 'k.session': SINGLE_SESSION }, }) const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}) expect(view.container.textContent).toBe('empty') act(() => { h.current.set('s1') }) expect(view.container.textContent).toBe('s1') // Entries whose children are all root-scope get no provider seat. const h2 = makeHost() h2.declare('k.single', SINGLE_ROOT) const seen2: AnyProps[] = [] h2.add('root', { component: (props: AnyProps) => { seen2.push(props); return null }, children: { 'k.single': SINGLE_ROOT }, }) render(<>{createSlotRenderer().renderRoot(h2.host, {})}) 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. 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() }) it('delivers the store pair for store-declaring entries and writes through baked actions', () => { const h = makeHost() h.declare('k.single', SINGLE_ROOT) const handle = miniStore(() => ({ n: 0 }), { inc: (s) => ({ n: s.n + 1 }) }) let bump = () => {} h.add('k.single', { component: ({ useStore, actions }: { useStore: (sel: (s: { n: number }) => S) => S actions: { inc: () => void } }) => { bump = actions.inc return {useStore((s) => s.n)} }, store: handle, }) const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, (renderSlot) => renderSlot('k.single', {})) expect(view.container.textContent).toBe('0') act(() => { bump() }) expect(view.container.textContent).toBe('1') }) it('resolves session-slot stores per scope key: values survive a switch-away and back', () => { const h = makeHost() h.declare('k.session', SINGLE_SESSION) h.addSession('s1') h.addSession('s2') const handle = miniStore(() => ({ draft: '' }), { setDraft: (_s, text: string) => ({ draft: text }) }) let setDraft: (text: string) => void = () => {} h.add('k.session', { component: ({ useStore, actions }: { useStore: (sel: (s: { draft: string }) => S) => S actions: { setDraft: (text: string) => void } }) => { setDraft = actions.setDraft return {useStore((s) => s.draft) || '(blank)'} }, store: handle, }) const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION }, (renderSlot) => ( {() => renderSlot('k.session', {})} )) act(() => { h.current.set('s1') }) act(() => { setDraft('draft-one') }) expect(view.container.textContent).toBe('draft-one') act(() => { h.current.set('s2') }) expect(view.container.textContent).toBe('(blank)') // distinct instance per session act(() => { h.current.set('s1') }) expect(view.container.textContent).toBe('draft-one') // same scope key = same instance }) }) describe('inject: execution point, parameter derivation, cache granularity', () => { it('root inject runs once per entry with no arguments (no store declared)', () => { const h = makeHost() h.declare('k.single', SINGLE_ROOT) const inject = vi.fn(() => ({ tag: 'FROM-INJECT' })) h.add('k.single', { component: ({ tag }: { tag?: string }) => {tag}, inject }) const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, (renderSlot) => renderSlot('k.single', {})) expect(view.container.textContent).toBe('FROM-INJECT') act(() => { h.add('k.single', { component: () => null }) }) // sibling bump re-renders the outlet expect(inject).toHaveBeenCalledTimes(1) expect(inject).toHaveBeenCalledWith() }) it('session inject receives sessionId and caches per (entry x session): switch-back reuses', () => { const h = makeHost() h.declare('k.session', SINGLE_SESSION) h.addSession('s1') h.addSession('s2') const inject = vi.fn((sessionId: string) => ({ sid: sessionId })) h.add('k.session', { component: ({ sid }: { sid?: string }) => {sid}, inject: inject as unknown as StoredEntry['inject'], }) const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION }, (renderSlot) => ( {() => renderSlot('k.session', {})} )) act(() => { h.current.set('s1') }) expect(view.container.textContent).toBe('s1') expect(inject).toHaveBeenCalledTimes(1) expect(inject).toHaveBeenLastCalledWith('s1') act(() => { h.current.set('s2') }) expect(view.container.textContent).toBe('s2') expect(inject).toHaveBeenCalledTimes(2) act(() => { h.current.set('s1') }) // back: (entry x cell) cache hit expect(view.container.textContent).toBe('s1') expect(inject).toHaveBeenCalledTimes(2) }) it('store-declaring entries get baked actions appended to the inject parameters', () => { const h = makeHost() h.declare('k.single', SINGLE_ROOT) h.declare('k.session', SINGLE_SESSION) h.addSession('s1') const handle = miniStore(() => ({ n: 0 }), { inc: (s) => ({ n: s.n + 1 }) }) const rootInject = vi.fn((actions: { inc: () => void }) => ({ viaRoot: actions })) const sessionInject = vi.fn((sessionId: string, actions: { inc: () => void }) => ({ sid: sessionId, viaSession: actions })) const seenRoot: AnyProps[] = [] const seenSession: AnyProps[] = [] h.add('k.single', { component: (props: object) => { seenRoot.push(props as AnyProps); return null }, inject: rootInject as unknown as StoredEntry['inject'], store: handle, }) h.add('k.session', { component: (props: object) => { seenSession.push(props as AnyProps); return null }, inject: sessionInject as unknown as StoredEntry['inject'], store: handle, }) mountRoot(h, { 'k.single': SINGLE_ROOT, 'k.session': SINGLE_SESSION }, (renderSlot) => <> {renderSlot('k.single', {})} {() => renderSlot('k.session', {})} ) act(() => { h.current.set('s1') }) // The inject-received actions are the same baked callbacks the component // gets as props.actions (one instance per entry x scope key). expect(rootInject).toHaveBeenCalledTimes(1) expect(seenRoot.at(-1)!['viaRoot']).toBe(seenRoot.at(-1)!['actions']) expect(sessionInject).toHaveBeenCalledTimes(1) expect(sessionInject.mock.calls[0]![0]).toBe('s1') expect(seenSession.at(-1)!['viaSession']).toBe(seenSession.at(-1)!['actions']) }) it('contains a throwing inject factory to its own entry (runs inside the component body)', () => { const h = makeHost() h.declare('k.list', { kind: 'list', scope: 'root' }) h.add('k.list', { component: () => never, options: { id: 'bad', order: 1 }, inject: () => { throw new Error('inject boom') }, }) h.add('k.list', { component: () => alive, options: { id: 'ok', order: 2 } }) const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) const { view } = mountRoot(h, { 'k.list': { kind: 'list', scope: 'root' } }, (renderSlot) =>
{renderSlot('k.list', {})}
) spy.mockRestore() // The failing entry blacks out alone; the sibling and the tree above survive. expect(view.container.querySelector('main')).not.toBeNull() expect(view.container.textContent).toBe('alive') expect(view.container.querySelector('[data-slot-error]')).not.toBeNull() }) it('merges kit, inject, and owner props with owner winning', () => { const h = makeHost() h.declare('k.single', SINGLE_ROOT) const seen: AnyProps[] = [] h.add('k.single', { component: (props: object) => { seen.push(props as AnyProps); return null }, inject: () => ({ fromInject: 'inject', shared: 'inject' }), }) mountRoot(h, { 'k.single': SINGLE_ROOT }, (renderSlot) => renderSlot('k.single', { owner: 'owner', shared: 'owner' })) const props = seen.at(-1)! expect(typeof props['useSessions']).toBe('function') // kit always present expect(typeof props['useWorkspaces']).toBe('function') expect(props['fromInject']).toBe('inject') expect(props['owner']).toBe('owner') expect(props['shared']).toBe('owner') // owner overrides inject }) })