API index and search · Build metadata
Source snapshot
packages/narrator-react/src/index.ts
1// @rindle/narrator-react — React lifecycle integration for @rindle/narrator.23import { useEffect, useMemo, useRef } from "react";4import type { AnyQuery, ChangePhase } from "@rindle/client";5import { createNarrator } from "@rindle/narrator";6import type { NarrateContext, NarratorRegistry, SemanticEvent } from "@rindle/narrator";7import { queryCacheKey, useRindleStore } from "@rindle/react";89/** Options for {@link useNarration}. */10export interface UseNarrationOptions {11 /** The registry key for this query's templates. Defaults to a named query's `name`; pass it for an12 * ad-hoc (unnamed) query, e.g. `{ as: "deckSlides" }`. */13 as?: string;14 /** Context handed to every template (e.g. a human subject label). */15 ctx?: NarrateContext;16 /** Which delivery phases to narrate. Default `["batch"]` — the initial `snapshot` is the current17 * state (not a change), so "tell me what CHANGED" ignores it. Pass `["snapshot", "batch"]` to also18 * narrate the initial rows. */19 phases?: ChangePhase[];20 /** Cap the retained event buffer; oldest drop first. Default 200. */21 max?: number;22}2324/** A stable handle over a query's live narration buffer. */25export interface Narration {26 /** Drain the events accumulated since the last call, then clear — call this when you hand context27 * to an agent (e.g. on chat send). */28 take(): SemanticEvent[];29 /** Discard the buffer without returning it. */30 clear(): void;31}3233/** Narrate a live query's change stream into buffered {@link SemanticEvent}s — the agent-facing twin34 * of `useQuery`. It drives off the view's OWN `onChanges` channel (net of no-op rebase cycles, so a35 * correctly predicted optimistic write narrates nothing) and needs no store-global qid filter.36 *37 * The returned handle is STABLE and does NOT re-render on each change — narration feeds an agent,38 * not the DOM. Drain it with `take()` at the moment you send context. Pass a STABLE `registry` (a39 * module const): a fresh object each render re-subscribes the view. */40export function useNarration<Q extends AnyQuery>(41 query: Q,42 registry: NarratorRegistry,43 opts: UseNarrationOptions = {},44): Narration {45 // Derive + validate the registry key BEFORE any hook runs (so a throw never executes a partial46 // hook list). An ad-hoc (unnamed) query carries no `name`, so without `opts.as` the key would fall47 // back to "" — matching no registry entry and silently narrating NOTHING, indistinguishable from48 // "no changes". Fail loud instead: the fix is one option, and a silent agent is worse than a crash.49 const key = opts.as ?? (typeof query.name === "string" ? query.name : "");50 if (key === "") {51 throw new Error(52 "useNarration: could not derive a narrator registry key from this query. Ad-hoc (unnamed) " +53 'queries have no name — pass `{ as: "<registryKey>" }` naming this query\'s narrator entry.',54 );55 }5657 const store = useRindleStore();58 const viewKey = queryCacheKey(query);59 const queryRef = useRef(query);60 queryRef.current = query;6162 const narrator = useMemo(() => createNarrator(registry), [registry]);6364 // Latest ctx/phases/max read inside the subscription, so a changing `opts` object does not65 // re-subscribe the view (only the query/registry/key identity does).66 const optsRef = useRef(opts);67 optsRef.current = opts;68 const bufferRef = useRef<SemanticEvent[]>([]);6970 useEffect(() => {71 // A dedicated view for narration: `onChanges` is wired inside `materialize` (before the backend72 // registers the query), so a synchronous backend's first `snapshot` is caught too.73 const view = store.materialize(queryRef.current, {74 onChanges: (changes, phase, schema) => {75 const { phases, ctx, max = 200 } = optsRef.current;76 const want = phases ? phases.includes(phase) : phase === "batch";77 if (!want) return;78 const buf = bufferRef.current;79 // `text === null` is the narrator's suppression signal; an empty string is a real (if terse)80 // rendered line, so filter on null explicitly, not truthiness.81 for (const event of narrator.narrate(key, schema, changes, phase, ctx)) {82 if (event.text !== null) buf.push(event);83 }84 if (buf.length > max) buf.splice(0, buf.length - max);85 },86 });87 // Clear the buffer on re-subscribe (a query/registry/key identity change, or a StrictMode88 // double-mount): the buffer outlives the effect, so without this a re-materialize replays the89 // fresh snapshot/changes on top of stale events from the previous subscription.90 return () => {91 view.destroy();92 bufferRef.current = [];93 };94 }, [store, viewKey, narrator, key]);9596 return useMemo<Narration>(97 () => ({98 take: () => {99 const out = bufferRef.current;100 bufferRef.current = [];101 return out;102 },103 clear: () => {104 bufferRef.current = [];105 },106 }),107 [],108 );109}110