// React bindings for streaming fields.
//
// The base value is passed in explicitly rather than the hook reaching into the
// Zero query result. Two reasons: Zero query results are not mutated behind the
// application's back, and the moment of durable handoff stays visible to UI
// that wants a "saving" affordance.
//
// const [message] = useQuery(queries.message.byID({ id }))
// const content = useStreamingField(streaming.message.content({ id }), message.content)
// return {content.value}
//
// Mounting a hook is what subscribes. A component that renders the row without
// one receives no field traffic at all.
//
// Both hooks are selector-isolated: a subscription covers exactly one row's one
// field, so a value arriving for another row cannot wake this component. That
// is the property an app hand-rolls otherwise, usually as a global emitter plus
// a deep-equal projection to undo the over-broadcasting.
import { canonicalTopic } from 'orez-lite/realtime'
import { useCallback, useMemo, useRef, useSyncExternalStore } from 'react'
import type {
RealtimeStore,
StreamingFieldHandle,
StreamingFieldState,
} from 'orez-lite/realtime'
// `streaming.message.content({ id })` returns a StreamingFieldHandle: the row's
// topic plus the manifest spec for that column, so a hook needs no lookup and
// cannot be handed a field the manifest does not declare.
export type { StreamingFieldHandle }
// `handle` may be null so a component can subscribe conditionally while still
// calling the hook unconditionally, which is the common React shape: a row that
// is not streaming, or whose id is not known yet, has nothing to subscribe to.
export type UseStreamingField = (
handle: StreamingFieldHandle | null | undefined,
base: Value
) => StreamingFieldState
// One row's field, keyed by whatever the caller already uses to identify the
// row (a message id, usually), so results can be looked up without rebuilding
// a canonical topic at the call site.
export type StreamingFieldRequest = {
readonly key: string
readonly handle: StreamingFieldHandle
readonly base: Value
}
export type UseStreamingFields = (
requests: readonly StreamingFieldRequest[]
) => Readonly>>
// The store lives on the transport (or on a local realtime), so the hooks are
// created against whichever store the application installed rather than
// reaching for a module-level singleton.
export function createUseStreamingField(
getStore: () => RealtimeStore | undefined
): UseStreamingField {
return function useStreamingField(
handle: StreamingFieldHandle | null | undefined,
base: Value
): StreamingFieldState {
const store = getStore()
// the canonical topic, not object identity: a caller that rebuilds `{id}`
// inline on every render must not churn its subscription
const id = handle ? canonicalTopic(handle.spec.primaryKey, handle.topic) : ''
const subscribe = useCallback(
(onChange: () => void) => {
if (!store || !handle) return () => {}
return store.subscribe(handle, onChange)
},
[store, id]
)
// getSnapshot must hand back the SAME reference until something observable
// changes: useSyncExternalStore re-invokes it on every render and rerenders
// forever (throwing "Maximum update depth exceeded") if each call returns a
// fresh object. The store only stabilizes SUBSCRIBED topics — an
// unsubscribed read (before the subscription effect lands, after an error
// boundary unmounts the tree so it never lands, or with a null handle) has
// no entry to cache on. Each hook therefore carries its own last-state
// slot, which also survives interleaved renders of many hooks in a way a
// module-level cache cannot. `base` participates because a new durable
// value from Zero is what ends the committing phase.
const last = useRef | undefined>(undefined)
const getSnapshot = useCallback(() => {
const next = !store || !handle ? durableState(base) : store.read(handle, base)
const cached = last.current
if (cached && sameState(cached, next)) return cached
last.current = next
return next
}, [store, id, base])
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
}
}
// Many rows' fields at once, for a list that has to merge live values before it
// renders (grouping consecutive tool parts, interleaving by timestamp, and so
// on) and therefore cannot push the subscription down into each row.
//
// Scoping is structural: this component subscribes to exactly the topics in
// `requests`, so a value for a row outside the list cannot wake it. There is no
// projection to write and no deep-equal comparator to get right.
export function createUseStreamingFields(
getStore: () => RealtimeStore | undefined
): UseStreamingFields {
return function useStreamingFields(
requests: readonly StreamingFieldRequest[]
): Readonly>> {
const store = getStore()
// identity of the subscription SET, so adding or removing a row
// resubscribes but a changing base value does not
const topicsKey = useMemo(
() =>
requests
.map(
(request) =>
`${request.key}=${canonicalTopic(request.handle.spec.primaryKey, request.handle.topic)}`
)
.join('\n'),
[requests]
)
const subscribe = useCallback(
(onChange: () => void) => {
if (!store) return () => {}
const releases = requests.map((request) =>
store.subscribe(request.handle, onChange)
)
return () => {
for (const release of releases) release()
}
},
[store, topicsKey]
)
// Reference-stable across renders where nothing observable changed, both
// for useSyncExternalStore's contract and so a consuming useMemo (soot
// merges these into its transcript) is not invalidated every render.
const cache = useMemo(
() => ({ value: {} as Record> }),
[store, topicsKey]
)
const getSnapshot = useCallback(() => {
const previous = cache.value
const next: Record> = {}
let changed = false
for (const request of requests) {
const raw = store
? store.read(request.handle, request.base)
: durableState(request.base)
// stabilize per key here, not just per store entry: an unsubscribed
// topic's read is a fresh object each call, and one unstable member
// would otherwise force a fresh map from every getSnapshot — the same
// rerender loop the single-field hook guards against.
const cached = previous[request.key]
const state = cached && sameState(cached, raw) ? cached : raw
next[request.key] = state
if (state !== cached) changed = true
}
if (!changed && Object.keys(previous).length === Object.keys(next).length) {
return previous
}
cache.value = next
return next
}, [store, topicsKey, cache, requests])
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
}
}
function sameState(
a: StreamingFieldState,
b: StreamingFieldState
): boolean {
// exact: `value` is either the caller's own base reference or the store
// generation's accumulated value, both of which only change identity when
// they actually change
return Object.is(a.value, b.value) && a.phase === b.phase && a.streamID === b.streamID
}
function durableState(base: Value): StreamingFieldState {
return { value: base, phase: 'durable', streamID: null }
}