|
|
@@ -4,15 +4,17 @@
|
|
|
*/
|
|
|
import { Component, useMemo, useState, useSyncExternalStore, type FC, type ReactNode } from 'react'
|
|
|
import {
|
|
|
- SlotOwnershipError, StaleAuthorizationError,
|
|
|
+ SlotOwnershipError, StaleAuthorizationError, standardHookPropName,
|
|
|
type ChainRenderOpts, type HostObservable, type LocaleFace, type RenderOpts,
|
|
|
- type SessionMaybeProvideInfo, type SessionProvideInfo, type SlotRenderer, type SlotRendererHost,
|
|
|
- type SlotScope, type StoredEntry, type Translate,
|
|
|
+ type ScopedStandardSourceBinding, type SessionAreaProps, type SessionProviderComponent, type SlotRenderer,
|
|
|
+ type SlotRendererHost, type SlotScope, type SlotScopeAdapter, type StandardSourceBinding,
|
|
|
+ type StoredEntry, type Translate,
|
|
|
} from '@deepseek-ai/dsh-client-ui-slots'
|
|
|
import {
|
|
|
- HostContext, SessionMaybeProvider, SessionProvider, SlotAssemblyError, maybeObservableHook,
|
|
|
- observableHook, projectionHook, useHost, useSessionMaybeProvideInfo,
|
|
|
-} from './session-provider.tsx'
|
|
|
+ HostContext, RootStandardProvider, ScopeProvider, SlotAssemblyError,
|
|
|
+ keyedObservableHook, maybeObservableHook, observableHook, useHost, useRootBinding,
|
|
|
+ useScopeBinding,
|
|
|
+} from './bindings.tsx'
|
|
|
|
|
|
type InjectedProps = Record<string, unknown>
|
|
|
|
|
|
@@ -89,23 +91,23 @@ function boundRenderSlotChain(host: SlotRendererHost, entry: StoredEntry): Rende
|
|
|
|
|
|
/**
|
|
|
* Inject results cache: root entries per entry, session entries per
|
|
|
- * (entry x provide bundle). WeakMap keys are entry/info objects (both
|
|
|
+ * (entry x scope binding). WeakMap keys are entry/binding objects (both
|
|
|
* identity-stable per registration/session scope), so cache lifetime rides
|
|
|
* the same axes as the values it memoizes.
|
|
|
*/
|
|
|
const rootInjectCache = new WeakMap<StoredEntry, InjectedProps>()
|
|
|
-const sessionInjectCache = new WeakMap<StoredEntry, WeakMap<SessionProvideInfo, InjectedProps>>()
|
|
|
-const sessionMaybeInjectCache = new WeakMap<StoredEntry, WeakMap<SessionMaybeProvideInfo, InjectedProps>>()
|
|
|
+const sessionInjectCache = new WeakMap<StoredEntry, WeakMap<StandardSourceBinding, InjectedProps>>()
|
|
|
+const sessionMaybeInjectCache = new WeakMap<StoredEntry, WeakMap<StandardSourceBinding, InjectedProps>>()
|
|
|
|
|
|
const EMPTY_INJECTED_PROPS: InjectedProps = {}
|
|
|
|
|
|
-function runInject(entry: StoredEntry, info: SessionMaybeProvideInfo | undefined, actions: object | undefined): InjectedProps {
|
|
|
+function runInject(entry: StoredEntry, binding: StandardSourceBinding | undefined, actions: object | undefined): InjectedProps {
|
|
|
const inject = entry.inject
|
|
|
if (!inject) return EMPTY_INJECTED_PROPS
|
|
|
// Declaration-derived positional arguments: sessionId for session scope,
|
|
|
// baked actions when a store is declared.
|
|
|
const args: unknown[] = []
|
|
|
- if (info !== undefined) args.push(info.sessionId)
|
|
|
+ if (binding !== undefined) args.push(binding.key)
|
|
|
if (actions !== undefined) args.push(actions)
|
|
|
return bindInjectHooks((inject as (...args: unknown[]) => InjectedProps)(...args))
|
|
|
}
|
|
|
@@ -120,7 +122,7 @@ function bindInjectHooks(face: InjectedProps): InjectedProps {
|
|
|
const { hooks: _hooks, ...rest } = face
|
|
|
const bound: InjectedProps = rest
|
|
|
for (const [name, source] of Object.entries(sources as Record<string, HostObservable<unknown>>)) {
|
|
|
- const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}`
|
|
|
+ const hookName = standardHookPropName(name)
|
|
|
bound[hookName] = observableHook(source)
|
|
|
}
|
|
|
return bound
|
|
|
@@ -144,7 +146,7 @@ function cachedSlotInject(face: object | undefined): BoundSlotInject {
|
|
|
const props: InjectedProps = rest
|
|
|
let factories: Record<string, SlotHookFactory> | undefined
|
|
|
for (const [name, definition] of Object.entries(definitions as Record<string, unknown>)) {
|
|
|
- const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}`
|
|
|
+ const hookName = standardHookPropName(name)
|
|
|
if (typeof definition === 'function') {
|
|
|
factories ??= {}
|
|
|
factories[name] = definition as SlotHookFactory
|
|
|
@@ -167,7 +169,7 @@ function bindSlotHookFactories(
|
|
|
): InjectedProps {
|
|
|
const hooks: InjectedProps = {}
|
|
|
for (const [name, factory] of Object.entries(factories)) {
|
|
|
- const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}`
|
|
|
+ const hookName = standardHookPropName(name)
|
|
|
hooks[hookName] = factory(standard, hookContext)
|
|
|
}
|
|
|
return hooks
|
|
|
@@ -182,34 +184,34 @@ function cachedRootInject(entry: StoredEntry, actions: object | undefined): Inje
|
|
|
return props
|
|
|
}
|
|
|
|
|
|
-function cachedSessionInject(entry: StoredEntry, info: SessionProvideInfo, actions: object | undefined): InjectedProps {
|
|
|
- let perInfo = sessionInjectCache.get(entry)
|
|
|
- if (!perInfo) {
|
|
|
- perInfo = new WeakMap()
|
|
|
- sessionInjectCache.set(entry, perInfo)
|
|
|
+function cachedSessionInject(entry: StoredEntry, binding: StandardSourceBinding, actions: object | undefined): InjectedProps {
|
|
|
+ let perBinding = sessionInjectCache.get(entry)
|
|
|
+ if (!perBinding) {
|
|
|
+ perBinding = new WeakMap()
|
|
|
+ sessionInjectCache.set(entry, perBinding)
|
|
|
}
|
|
|
- let props = perInfo.get(info)
|
|
|
+ let props = perBinding.get(binding)
|
|
|
if (!props) {
|
|
|
- props = runInject(entry, info, actions)
|
|
|
- perInfo.set(info, props)
|
|
|
+ props = runInject(entry, binding, actions)
|
|
|
+ perBinding.set(binding, props)
|
|
|
}
|
|
|
return props
|
|
|
}
|
|
|
|
|
|
function cachedSessionMaybeInject(
|
|
|
entry: StoredEntry,
|
|
|
- info: SessionMaybeProvideInfo,
|
|
|
+ binding: StandardSourceBinding,
|
|
|
actions: object | undefined,
|
|
|
): InjectedProps {
|
|
|
- let perInfo = sessionMaybeInjectCache.get(entry)
|
|
|
- if (!perInfo) {
|
|
|
- perInfo = new WeakMap()
|
|
|
- sessionMaybeInjectCache.set(entry, perInfo)
|
|
|
+ let perBinding = sessionMaybeInjectCache.get(entry)
|
|
|
+ if (!perBinding) {
|
|
|
+ perBinding = new WeakMap()
|
|
|
+ sessionMaybeInjectCache.set(entry, perBinding)
|
|
|
}
|
|
|
- let props = perInfo.get(info)
|
|
|
+ let props = perBinding.get(binding)
|
|
|
if (!props) {
|
|
|
- props = runInject(entry, info, actions)
|
|
|
- perInfo.set(info, props)
|
|
|
+ props = runInject(entry, binding, actions)
|
|
|
+ perBinding.set(binding, props)
|
|
|
}
|
|
|
return props
|
|
|
}
|
|
|
@@ -332,54 +334,76 @@ class SlotErrorBoundary extends Component<
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-interface StandardPropsCache {
|
|
|
- readonly root: InjectedProps
|
|
|
- readonly session: WeakMap<SessionMaybeProvideInfo, InjectedProps>
|
|
|
- readonly sessionMaybe: WeakMap<SessionMaybeProvideInfo, InjectedProps>
|
|
|
-}
|
|
|
+const rootStandardCache = new WeakMap<StandardSourceBinding, InjectedProps>()
|
|
|
+const sessionStandardCache = new WeakMap<StandardSourceBinding, WeakMap<StandardSourceBinding, InjectedProps>>()
|
|
|
+const sessionMaybeStandardCache = new WeakMap<StandardSourceBinding, WeakMap<StandardSourceBinding, InjectedProps>>()
|
|
|
|
|
|
-const standardPropsCache = new WeakMap<SlotRendererHost, StandardPropsCache>()
|
|
|
+/** Materialize one binding into stable framework Hook and plain-prop seats. */
|
|
|
+function materializeStandardBinding(binding: StandardSourceBinding, optional: boolean): InjectedProps {
|
|
|
+ const standard: InjectedProps = { ...binding.props }
|
|
|
+ for (const [name, source] of Object.entries(binding.hooks)) {
|
|
|
+ if (source === undefined && !optional) {
|
|
|
+ throw new SlotAssemblyError(`strict standard hook '${name}' has no source`)
|
|
|
+ }
|
|
|
+ standard[standardHookPropName(name)] = optional
|
|
|
+ ? maybeObservableHook(source)
|
|
|
+ : observableHook(source as HostObservable<unknown>)
|
|
|
+ }
|
|
|
+ for (const [name, source] of Object.entries(binding.keyedHooks)) {
|
|
|
+ if (source === undefined && !optional) {
|
|
|
+ throw new SlotAssemblyError(`strict keyed standard hook '${name}' has no source resolver`)
|
|
|
+ }
|
|
|
+ standard[standardHookPropName(name)] = keyedObservableHook(source)
|
|
|
+ }
|
|
|
+ return standard
|
|
|
+}
|
|
|
|
|
|
/** Stable official-props object used by contextual Hook factories. */
|
|
|
function standardProps(
|
|
|
- host: SlotRendererHost,
|
|
|
scope: SlotScope,
|
|
|
- info: SessionMaybeProvideInfo | undefined,
|
|
|
+ rootBinding: StandardSourceBinding,
|
|
|
+ scopeBinding: StandardSourceBinding | undefined,
|
|
|
): InjectedProps {
|
|
|
- let cache = standardPropsCache.get(host)
|
|
|
- if (cache === undefined) {
|
|
|
- cache = {
|
|
|
- root: {
|
|
|
- useSessions: observableHook(host.sessions.list),
|
|
|
- useWorkspaces: observableHook(host.workspaces.list),
|
|
|
- },
|
|
|
- session: new WeakMap(),
|
|
|
- sessionMaybe: new WeakMap(),
|
|
|
- }
|
|
|
- standardPropsCache.set(host, cache)
|
|
|
- }
|
|
|
- if (scope === 'root') return cache.root
|
|
|
- if (info === undefined) throw new SlotAssemblyError(`scope '${scope}' rendered without session provide info`)
|
|
|
- const byInfo = scope === 'session' ? cache.session : cache.sessionMaybe
|
|
|
- let standard = byInfo.get(info)
|
|
|
+ let root = rootStandardCache.get(rootBinding)
|
|
|
+ if (root === undefined) {
|
|
|
+ root = materializeStandardBinding(rootBinding, false)
|
|
|
+ rootStandardCache.set(rootBinding, root)
|
|
|
+ }
|
|
|
+ if (scope === 'root') return root
|
|
|
+ if (scopeBinding === undefined) throw new SlotAssemblyError(`scope '${scope}' rendered without a standard-source binding`)
|
|
|
+ const cache = scope === 'session' ? sessionStandardCache : sessionMaybeStandardCache
|
|
|
+ let perScope = cache.get(rootBinding)
|
|
|
+ if (perScope === undefined) {
|
|
|
+ perScope = new WeakMap()
|
|
|
+ cache.set(rootBinding, perScope)
|
|
|
+ }
|
|
|
+ let standard = perScope.get(scopeBinding)
|
|
|
if (standard !== undefined) return standard
|
|
|
- standard = { ...cache.root }
|
|
|
- for (const [name, source] of Object.entries(info.hooks)) {
|
|
|
- const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}`
|
|
|
- if (scope === 'session-maybe') {
|
|
|
- standard[hookName] = maybeObservableHook(source)
|
|
|
- } else {
|
|
|
- if (source === undefined) throw new SlotAssemblyError(`strict session hook '${name}' has no source`)
|
|
|
- standard[hookName] = observableHook(source)
|
|
|
- }
|
|
|
+ standard = {
|
|
|
+ ...root,
|
|
|
+ ...materializeStandardBinding(scopeBinding, scope === 'session-maybe'),
|
|
|
}
|
|
|
- Object.assign(standard, info.props)
|
|
|
- standard['sessionId'] = info.sessionId
|
|
|
- standard['useProjection'] = projectionHook(info)
|
|
|
- byInfo.set(info, standard)
|
|
|
+ perScope.set(scopeBinding, standard)
|
|
|
return standard
|
|
|
}
|
|
|
|
|
|
+const scopeAreaCache = new WeakMap<SlotScopeAdapter, SessionProviderComponent>()
|
|
|
+
|
|
|
+/** Bind one domain-owned scope area renderer to the current scope binding. */
|
|
|
+function scopeAreaProvider(adapter: SlotScopeAdapter): SessionProviderComponent {
|
|
|
+ let Provider = scopeAreaCache.get(adapter)
|
|
|
+ if (Provider !== undefined) return Provider
|
|
|
+ if (adapter.renderArea === undefined) {
|
|
|
+ throw new SlotAssemblyError("scope 'session' adapter does not provide its area renderer")
|
|
|
+ }
|
|
|
+ const renderArea = adapter.renderArea.bind(adapter)
|
|
|
+ Provider = function ScopeAreaProvider(props: SessionAreaProps): ReactNode {
|
|
|
+ return renderArea(useScopeBinding(), props)
|
|
|
+ }
|
|
|
+ scopeAreaCache.set(adapter, Provider)
|
|
|
+ return Provider
|
|
|
+}
|
|
|
+
|
|
|
/**
|
|
|
* Standard-kit synthesis shared by both scope branches: the global
|
|
|
* useSessions/useWorkspaces hooks, the per-session provide bundle (every
|
|
|
@@ -396,13 +420,14 @@ function standardKit(
|
|
|
host: SlotRendererHost,
|
|
|
entry: StoredEntry,
|
|
|
scope: SlotScope,
|
|
|
- info: SessionMaybeProvideInfo | undefined,
|
|
|
+ rootBinding: StandardSourceBinding,
|
|
|
+ scopeBinding: StandardSourceBinding | undefined,
|
|
|
): {
|
|
|
kit: InjectedProps
|
|
|
standard: InjectedProps
|
|
|
actions: object | undefined
|
|
|
} {
|
|
|
- const standard = standardProps(host, scope, info)
|
|
|
+ const standard = standardProps(scope, rootBinding, scopeBinding)
|
|
|
const kit: InjectedProps = { ...standard }
|
|
|
if (entry.locale !== undefined) {
|
|
|
const face = host.locale
|
|
|
@@ -414,9 +439,10 @@ function standardKit(
|
|
|
}
|
|
|
kit['t'] = localeSeat(face, entry.locale)
|
|
|
}
|
|
|
- const store = scope === 'session-maybe' && info?.sessionId === undefined
|
|
|
+ const scopedStoreBinding = scopeBinding?.key === undefined
|
|
|
? undefined
|
|
|
- : host.storeOf(entry, info?.sessionId)
|
|
|
+ : scopeBinding as ScopedStandardSourceBinding
|
|
|
+ const store = host.storeOf(entry, scopedStoreBinding)
|
|
|
if (store !== undefined) {
|
|
|
// The instance IS an observable snapshot source (contract getSnapshot/
|
|
|
// subscribe); the useStore hook binds here, cached per instance.
|
|
|
@@ -430,11 +456,14 @@ function standardKit(
|
|
|
if (Object.values(entry.children).some(spec => spec.kind === 'chain')) {
|
|
|
kit['renderSlotChain'] = boundRenderSlotChain(host, entry)
|
|
|
}
|
|
|
- // SessionProvider standard seat: entries declaring a session-scope child
|
|
|
- // render the session area, so the framework hands them the self-wired
|
|
|
- // provider (module-level component = stable reference; no value import).
|
|
|
+ // The session owner supplies area semantics; the renderer only binds its
|
|
|
+ // adapter to the current generic scope source.
|
|
|
if (Object.values(entry.children).some(spec => spec.scope === 'session')) {
|
|
|
- kit['SessionProvider'] = SessionProvider
|
|
|
+ const adapter = host.scope('session')
|
|
|
+ if (adapter === undefined) {
|
|
|
+ throw new SlotAssemblyError("entry declares a session child without an installed 'session' scope adapter")
|
|
|
+ }
|
|
|
+ kit['SessionProvider'] = scopeAreaProvider(adapter)
|
|
|
}
|
|
|
}
|
|
|
return { kit, standard, actions: store?.actions }
|
|
|
@@ -499,35 +528,37 @@ function renderEntry(
|
|
|
)
|
|
|
}
|
|
|
|
|
|
-function SessionEntry({ entry, ownerProps, info, slotKey, slotInjected, hookContext, hasHookContext }: {
|
|
|
+function SessionEntry({ entry, ownerProps, binding, slotKey, slotInjected, hookContext, hasHookContext }: {
|
|
|
entry: StoredEntry
|
|
|
ownerProps: object
|
|
|
- info: SessionProvideInfo
|
|
|
+ binding: StandardSourceBinding & { readonly key: string }
|
|
|
slotKey: string
|
|
|
slotInjected: BoundSlotInject
|
|
|
hookContext: unknown
|
|
|
hasHookContext: boolean
|
|
|
}) {
|
|
|
const host = useHost()
|
|
|
+ const rootBinding = useRootBinding()
|
|
|
const Comp = entry.component as FC<InjectedProps>
|
|
|
- const { kit, standard, actions } = standardKit(host, entry, 'session', info)
|
|
|
- const injected = cachedSessionInject(entry, info, actions)
|
|
|
+ const { kit, standard, actions } = standardKit(host, entry, 'session', rootBinding, binding)
|
|
|
+ const injected = cachedSessionInject(entry, binding, actions)
|
|
|
return renderEntry(slotKey, Comp, kit, standard, injected, slotInjected, ownerProps, hookContext, hasHookContext)
|
|
|
}
|
|
|
|
|
|
-function SessionMaybeEntryBody({ entry, ownerProps, info, slotKey, slotInjected, hookContext, hasHookContext }: {
|
|
|
+function SessionMaybeEntryBody({ entry, ownerProps, binding, slotKey, slotInjected, hookContext, hasHookContext }: {
|
|
|
entry: StoredEntry
|
|
|
ownerProps: object
|
|
|
- info: SessionMaybeProvideInfo
|
|
|
+ binding: StandardSourceBinding
|
|
|
slotKey: string
|
|
|
slotInjected: BoundSlotInject
|
|
|
hookContext: unknown
|
|
|
hasHookContext: boolean
|
|
|
}) {
|
|
|
const host = useHost()
|
|
|
+ const rootBinding = useRootBinding()
|
|
|
const Comp = entry.component as FC<InjectedProps>
|
|
|
- const { kit, standard, actions } = standardKit(host, entry, 'session-maybe', info)
|
|
|
- const injected = cachedSessionMaybeInject(entry, info, actions)
|
|
|
+ const { kit, standard, actions } = standardKit(host, entry, 'session-maybe', rootBinding, binding)
|
|
|
+ const injected = cachedSessionMaybeInject(entry, binding, actions)
|
|
|
return renderEntry(slotKey, Comp, kit, standard, injected, slotInjected, ownerProps, hookContext, hasHookContext)
|
|
|
}
|
|
|
|
|
|
@@ -552,7 +583,7 @@ function SessionMaybeEntry({ entry, ownerProps, slotKey, slotInjected, hookConte
|
|
|
hookContext: unknown
|
|
|
hasHookContext: boolean
|
|
|
}) {
|
|
|
- const info = useSessionMaybeProvideInfo()
|
|
|
+ const binding = useScopeBinding()
|
|
|
// The child key is an incarnation counter, NOT the session id: adoption
|
|
|
// must keep the key constant across undefined → first id. Bookkeeping
|
|
|
// lives in this stable (unkeyed) wrapper via the render-phase setState
|
|
|
@@ -561,16 +592,16 @@ function SessionMaybeEntry({ entry, ownerProps, slotKey, slotInjected, hookConte
|
|
|
// guard conditions make it convergent — StrictMode-safe).
|
|
|
const [state, setState] = useState<MaybeIncarnation>(FIRST_INCARNATION)
|
|
|
let { adopted, epoch } = state
|
|
|
- if (info.sessionId !== undefined && adopted === undefined) {
|
|
|
+ if (binding.key !== undefined && adopted === undefined) {
|
|
|
// Adoption: same epoch — no remount.
|
|
|
- adopted = info.sessionId
|
|
|
+ adopted = binding.key
|
|
|
setState({ adopted, epoch })
|
|
|
- } else if (adopted !== undefined && info.sessionId !== undefined && info.sessionId !== adopted) {
|
|
|
+ } else if (adopted !== undefined && binding.key !== undefined && binding.key !== adopted) {
|
|
|
// Post-adoption session switch: next incarnation, born already adopted.
|
|
|
- adopted = info.sessionId
|
|
|
+ adopted = binding.key
|
|
|
epoch += 1
|
|
|
setState({ adopted, epoch })
|
|
|
- } else if (adopted !== undefined && info.sessionId === undefined) {
|
|
|
+ } else if (adopted !== undefined && binding.key === undefined) {
|
|
|
// Back to no-session: next incarnation, born blank (adopts anew later).
|
|
|
adopted = undefined
|
|
|
epoch += 1
|
|
|
@@ -581,7 +612,7 @@ function SessionMaybeEntry({ entry, ownerProps, slotKey, slotInjected, hookConte
|
|
|
key={epoch}
|
|
|
entry={entry}
|
|
|
ownerProps={ownerProps}
|
|
|
- info={info}
|
|
|
+ binding={binding}
|
|
|
slotKey={slotKey}
|
|
|
slotInjected={slotInjected}
|
|
|
hookContext={hookContext}
|
|
|
@@ -609,8 +640,9 @@ function RootEntry({ entry, ownerProps, slotKey, slotInjected, hookContext, hasH
|
|
|
hasHookContext: boolean
|
|
|
}) {
|
|
|
const host = useHost()
|
|
|
+ const rootBinding = useRootBinding()
|
|
|
const Comp = entry.component as FC<InjectedProps>
|
|
|
- const { kit, standard, actions } = standardKit(host, entry, 'root', undefined)
|
|
|
+ const { kit, standard, actions } = standardKit(host, entry, 'root', rootBinding, undefined)
|
|
|
const injected = cachedRootInject(entry, actions)
|
|
|
return renderEntry(slotKey, Comp, kit, standard, injected, slotInjected, ownerProps, hookContext, hasHookContext)
|
|
|
}
|
|
|
@@ -624,16 +656,18 @@ function StrictSessionEntry({ slotKey, entry, ownerProps, slotInjected, hookCont
|
|
|
hasHookContext: boolean
|
|
|
onEntryError: (error: unknown) => void
|
|
|
}) {
|
|
|
- const info = useSessionMaybeProvideInfo()
|
|
|
- if (info.sessionId === undefined) return null
|
|
|
+ const binding = useScopeBinding()
|
|
|
+ if (binding.key === undefined) {
|
|
|
+ throw new SlotAssemblyError(`strict session slot '${slotKey}' rendered without a scope binding`)
|
|
|
+ }
|
|
|
// Per-session remount rides this key; per-entry remount rides the outer
|
|
|
// element's entry-identity key (the outlet's guarded() call).
|
|
|
return (
|
|
|
- <SlotErrorBoundary slotKey={slotKey} key={info.sessionId} onEntryError={onEntryError}>
|
|
|
+ <SlotErrorBoundary slotKey={slotKey} key={binding.key} onEntryError={onEntryError}>
|
|
|
<SessionEntry
|
|
|
entry={entry}
|
|
|
ownerProps={ownerProps}
|
|
|
- info={info as SessionProvideInfo}
|
|
|
+ binding={binding as StandardSourceBinding & { readonly key: string }}
|
|
|
slotKey={slotKey}
|
|
|
slotInjected={slotInjected}
|
|
|
hookContext={hookContext}
|
|
|
@@ -665,7 +699,7 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
|
|
|
// Locale revision tick: a locale switch re-renders every outlet, and entry
|
|
|
// bodies re-derive their `t` seat at the new revision (fresh identity).
|
|
|
useLocaleRevision(host.locale)
|
|
|
- const sessionInfo = useSessionMaybeProvideInfo()
|
|
|
+ const scopeBinding = useScopeBinding()
|
|
|
// Anchor contract: every slot render site exposes a stable
|
|
|
// `[data-slot="<key>"]` wrapper — the addressable seam dynamic styles
|
|
|
// target — and `display:contents` keeps it layout-neutral. The wrapper
|
|
|
@@ -674,7 +708,7 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
|
|
|
// never flickers with registration churn.
|
|
|
return (
|
|
|
<div data-slot={slotKey} style={ANCHOR_STYLE}>
|
|
|
- {renderOutletContent(host, slotKey, ownerProps, opts, sessionInfo)}
|
|
|
+ {renderOutletContent(host, slotKey, ownerProps, opts, scopeBinding)}
|
|
|
</div>
|
|
|
)
|
|
|
}
|
|
|
@@ -685,20 +719,20 @@ function renderOutletContent(
|
|
|
slotKey: string,
|
|
|
ownerProps: object,
|
|
|
opts: (RenderOpts & ChainRenderOpts) | undefined,
|
|
|
- sessionInfo: SessionMaybeProvideInfo,
|
|
|
+ scopeBinding: StandardSourceBinding,
|
|
|
): ReactNode {
|
|
|
const spec = host.specOf(slotKey)
|
|
|
// Undeclared (or no-longer-declared) keys render empty: a declaring entry's
|
|
|
// unload returns the slot to the undeclared state while retained elements
|
|
|
// may still be mounted — natural empty, not an ownership failure.
|
|
|
if (!spec) return null
|
|
|
- const strictSessionAbsent = spec.scope === 'session' && sessionInfo.sessionId === undefined
|
|
|
- if (strictSessionAbsent && (spec.kind !== 'chain' || !opts?.overlay)) {
|
|
|
- return <>{opts?.fallback ?? null}</>
|
|
|
+ if (spec.kind === 'chain' && opts?.fallbackOnly === true) {
|
|
|
+ return renderChainResult(slotKey, null, opts)
|
|
|
}
|
|
|
- // An absent strict overlay chain follows its ordinary empty-election path,
|
|
|
- // preserving the Fragment/fallback-wrapper shape across session arrival.
|
|
|
- const entries = strictSessionAbsent ? [] : host.entriesOf(slotKey)
|
|
|
+ if (spec.scope === 'session' && scopeBinding.key === undefined) {
|
|
|
+ throw new SlotAssemblyError(`strict session slot '${slotKey}' rendered without a scope binding`)
|
|
|
+ }
|
|
|
+ const entries = host.entriesOf(slotKey)
|
|
|
const slotInjected = cachedSlotInject(spec.inject)
|
|
|
|
|
|
// The boundary must wrap the Entry ELEMENT, not live inside it: inject
|
|
|
@@ -799,25 +833,7 @@ function renderOutletContent(
|
|
|
break
|
|
|
}
|
|
|
}
|
|
|
- if (opts?.overlay) {
|
|
|
- // Overlay chain (ChainRenderOpts.overlay): the fallback stays mounted
|
|
|
- // through elections — hidden via inline display:none (decisive over any
|
|
|
- // author CSS), shown via display:contents so the wrapper never affects
|
|
|
- // the owner's layout. The wrapper's tree position is constant, so React
|
|
|
- // reconciles instead of remounting and fallback state survives takeover.
|
|
|
- return (
|
|
|
- <>
|
|
|
- <div
|
|
|
- data-chain-overlay-fallback={slotKey}
|
|
|
- style={{ display: elected === null ? 'contents' : 'none' }}
|
|
|
- >
|
|
|
- {opts.fallback ?? null}
|
|
|
- </div>
|
|
|
- {elected}
|
|
|
- </>
|
|
|
- )
|
|
|
- }
|
|
|
- return elected ?? <>{opts?.fallback ?? null}</>
|
|
|
+ return renderChainResult(slotKey, elected, opts)
|
|
|
}
|
|
|
// list: one row per id cell — the cell's shadowing winner, or the crash
|
|
|
// face once every entry of the cell abdicated (a dry cell must not
|
|
|
@@ -850,6 +866,26 @@ function renderOutletContent(
|
|
|
)
|
|
|
}
|
|
|
|
|
|
+/** Render a chain election while preserving the overlay fallback's tree position. */
|
|
|
+function renderChainResult(
|
|
|
+ slotKey: string,
|
|
|
+ elected: ReactNode,
|
|
|
+ opts: (RenderOpts & ChainRenderOpts) | undefined,
|
|
|
+): ReactNode {
|
|
|
+ if (!opts?.overlay) return elected ?? <>{opts?.fallback ?? null}</>
|
|
|
+ return (
|
|
|
+ <>
|
|
|
+ <div
|
|
|
+ data-chain-overlay-fallback={slotKey}
|
|
|
+ style={{ display: elected === null ? 'contents' : 'none' }}
|
|
|
+ >
|
|
|
+ {opts.fallback ?? null}
|
|
|
+ </div>
|
|
|
+ {elected}
|
|
|
+ </>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
/** Root outlet: the shell's single ctx-level render entry — an unregistered 'root' is a boot-order failure, never a silent blank. */
|
|
|
function RootOutlet({ ownerProps }: { ownerProps: object }) {
|
|
|
const host = useHost()
|
|
|
@@ -889,7 +925,7 @@ function RootOutlet({ ownerProps }: { ownerProps: object }) {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * Build the renderer the shell installs into the runtime SlotRegistry
|
|
|
+ * Build the renderer installed into the `ui-renderer` SlotRegistry
|
|
|
* (ctx.slots.install(createSlotRenderer()) at boot; the service owns the
|
|
|
* install/renderSlot contract and the double-install/not-installed throws).
|
|
|
* @returns the renderer.
|
|
|
@@ -899,9 +935,11 @@ export function createSlotRenderer(): SlotRenderer {
|
|
|
renderRoot(host, ownerProps) {
|
|
|
return (
|
|
|
<HostContext.Provider value={host}>
|
|
|
- <SessionMaybeProvider>
|
|
|
- <RootOutlet ownerProps={ownerProps} />
|
|
|
- </SessionMaybeProvider>
|
|
|
+ <RootStandardProvider>
|
|
|
+ <ScopeProvider scope="session-maybe">
|
|
|
+ <RootOutlet ownerProps={ownerProps} />
|
|
|
+ </ScopeProvider>
|
|
|
+ </RootStandardProvider>
|
|
|
</HostContext.Provider>
|
|
|
)
|
|
|
},
|