session-provider.tsx 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. /** Internal React bindings for the renderer host and active session provide bundle. */
  2. import { createContext, useContext, type ReactNode } from 'react'
  3. import type {
  4. HostObservable, MaybeSnapshotSelectorHook, SessionMaybeProvideInfo, SessionProvideInfo,
  5. SlotRendererHost, SnapshotSelectorHook,
  6. } from '@deepseek-ai/dsh-client-ui-slots'
  7. import { bindSnapshotSelector } from './bind.ts'
  8. /**
  9. * A missing-provider assembly error: the shell wired the tree wrong. The slot
  10. * error boundary rethrows this class so misassembly stays fail-loud while
  11. * registrant errors (inject factories, entry components) are contained
  12. * per entry.
  13. */
  14. export class SlotAssemblyError extends Error {}
  15. /** In-package renderer host context. */
  16. export const HostContext = createContext<SlotRendererHost | null>(null)
  17. /**
  18. * Read the installed renderer host; throws outside the rendered root tree
  19. * (framework components must not render detached from the renderer).
  20. * @returns the host surface.
  21. */
  22. export function useHost(): SlotRendererHost {
  23. const host = useContext(HostContext)
  24. if (!host) throw new SlotAssemblyError('slot machinery rendered outside the installed renderer tree')
  25. return host
  26. }
  27. const BindingContext = createContext<SessionMaybeProvideInfo | null>(null)
  28. /** Read the current-session-optional bundle supplied at the root. */
  29. export function useSessionMaybeProvideInfo(): SessionMaybeProvideInfo {
  30. const info = useContext(BindingContext)
  31. if (!info) throw new SlotAssemblyError('session-aware slot rendered outside the root binding provider')
  32. return info
  33. }
  34. /**
  35. * Read the enclosing session provide bundle; throws outside a SessionProvider
  36. * subtree (session slots must not render without a session).
  37. * @returns the enclosing bundle.
  38. */
  39. export function useSessionProvideInfo(): SessionProvideInfo {
  40. const info = useSessionMaybeProvideInfo()
  41. if (info.sessionId === undefined) throw new SlotAssemblyError('strict session slot rendered without a session')
  42. return info as SessionProvideInfo
  43. }
  44. /**
  45. * Identity-stable selector hook per host observable. uSES resubscribes when
  46. * the subscribe reference changes, so the bound hook must be created once per
  47. * source — cached here by source identity (sources are host-owned singletons).
  48. * @param source - host-provided observable.
  49. * @returns the cached selector hook.
  50. */
  51. export function observableHook<T>(source: HostObservable<T>): SnapshotSelectorHook<T> {
  52. let hook = hookCache.get(source)
  53. if (hook === undefined) {
  54. hook = bindSnapshotSelector(source)
  55. hookCache.set(source, hook)
  56. }
  57. return hook as SnapshotSelectorHook<T>
  58. }
  59. const hookCache = new WeakMap<object, unknown>()
  60. const absentSource: HostObservable<undefined> = {
  61. getSnapshot: () => undefined,
  62. subscribe: () => () => {},
  63. }
  64. /** Bind a source that disappears with the current session to an optional selector hook. */
  65. export function maybeObservableHook<T>(source: HostObservable<T> | undefined): MaybeSnapshotSelectorHook<T> {
  66. if (source !== undefined) return observableHook(source)
  67. return useAbsentSnapshot
  68. }
  69. function useAbsentSnapshot<S>(_selector: (snapshot: never) => S, _equal?: (a: S, b: S) => boolean): S | undefined {
  70. // The uSES subscription must still run (hook-order stability); the absent
  71. // source always snapshots undefined, returned explicitly.
  72. observableHook(absentSource)(() => undefined)
  73. return undefined
  74. }
  75. /**
  76. * The useProjection framework seat (session-projection RFC), one bound
  77. * function per provide bundle (cached by info identity — components may hold
  78. * it across renders). Key-addressed: the key resolves a per-session value
  79. * face off the projection store; the bound selector hook comes from the same
  80. * per-source cache as every other kit hook, so exactly one uSES subscription
  81. * runs per call and the subscribe reference stays stable per key. A key no
  82. * baseline or frame has carried (or a no-session bundle) reads `undefined` —
  83. * capability absence — keeping the hook order constant.
  84. */
  85. export function projectionHook(info: SessionMaybeProvideInfo): (
  86. key: string, selector?: (value: unknown) => unknown, eq?: (a: unknown, b: unknown) => boolean,
  87. ) => unknown {
  88. let hook = projectionHookCache.get(info)
  89. if (hook === undefined) {
  90. hook = (key, selector, eq) => {
  91. // The no-session (faceless) branch binds the shared absent source so
  92. // the caller's selector still runs over `undefined` (absence flows
  93. // through the selector) and the uSES call count stays constant.
  94. const useValue = observableHook(info.projections?.faceOf(key) ?? absentSource)
  95. // Whole values are finished wire payloads (reference changes only when
  96. // a frame or baseline lands), so the identity selector needs no
  97. // equality function.
  98. return useValue(selector ?? (value => value), eq)
  99. }
  100. projectionHookCache.set(info, hook)
  101. }
  102. return hook
  103. }
  104. const projectionHookCache = new WeakMap<SessionMaybeProvideInfo, (
  105. key: string, selector?: (value: unknown) => unknown, eq?: (a: unknown, b: unknown) => boolean,
  106. ) => unknown>()
  107. /**
  108. * Root-level binding provider. It follows current selection without a key;
  109. * per-entry identity is the outlet's adoption bookkeeping (SessionMaybeEntry):
  110. * a blank-born incarnation adopts the first session without remounting, and
  111. * every later transition (switch or loss) remounts like a strict entry.
  112. */
  113. export function SessionMaybeProvider({ children }: { children: ReactNode }) {
  114. const host = useHost()
  115. const info = observableHook(host.sessions.provideInfo)(s => s)
  116. return (
  117. <BindingContext.Provider value={info}>
  118. {children}
  119. </BindingContext.Provider>
  120. )
  121. }
  122. /** SessionProvider surface: render-prop body plus the no-session branch. */
  123. export interface SessionProviderProps {
  124. /** No-session body (also covers a current id whose session cannot be resolved). */
  125. empty?: (() => ReactNode) | undefined
  126. /** Session body; remounted per session via key={sessionId}. */
  127. children: (sessionId: string) => ReactNode
  128. }
  129. /**
  130. * Framework-wired session area: subscribes to the host's current provide
  131. * source and remounts the body under `key={sessionId}` so a session switch
  132. * rebuilds the session subtree. This dependency-inverted layer uses plain
  133. * string ids; `PropsRuntime` applies the branded type at the component
  134. * boundary.
  135. */
  136. export function SessionProvider({ empty, children }: SessionProviderProps) {
  137. const host = useHost()
  138. const info = observableHook(host.sessions.provideInfo)(s => s)
  139. const id = info.sessionId
  140. if (id === undefined) return <>{empty?.() ?? null}</>
  141. return (
  142. <BindingContext.Provider value={info} key={id}>
  143. {children(id)}
  144. </BindingContext.Provider>
  145. )
  146. }