Rindle

API index and search · Build metadata

Source snapshot

packages/narrator-react/src/index.ts

Source revision 05d0bf2c2e56 · build details
Source revision: 05d0bf2c2e56.
TypeScript input SHA-256: aabe6cfcc4172b870d5e272142958e9ea8d8784c2aa23133156e5a7ee633318e
Generated 2026-09-04T23:58:25.590Z with TypeScript 6.0.3. Public TypeScript checks and declaration emit passed. Package runtime tests are separate.
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