API index and search · Build metadata
Source snapshot
packages/react-devtools/src/index.ts
1// @rindle/react-devtools — a floating, dev-only panel over `@rindle/devtools`2// (DEBUG-TOOLS-BROWSER-DESIGN.md §6.1). It discovers a running client through the global hub (or an3// explicit `core` prop) and renders the §4 panes: the mutation TIMELINE (the fork/rebase loop, with4// the snap-back highlight), the QUERIES inspector, and the live DELTA stream. Pure React + inline5// styles — no CSS import. Like `@rindle/react` it uses `createElement` rather than JSX (Node's test6// runner strips TS types but not JSX). Mount it once near your app root in development:7//8// {import.meta.env.DEV && createElement(RindleDevtools)}9//10// and attach a client in dev: `import("@rindle/devtools").then(d => d.attachDevtools(app))`.1112import { createElement as h, useCallback, useMemo, useState, useSyncExternalStore } from "react";13import type { CSSProperties, ReactNode } from "react";14import {15 getDevtoolsHub,16 type DeltaEntry,17 type DeltaKind,18 type DevtoolsCore,19 type DevtoolsState,20 type MutationState,21 type QueryEntry,22 type ResultType,23 type TimelineEntry,24} from "@rindle/devtools";2526export interface RindleDevtoolsProps {27 /** Bind to a specific core. Omit to auto-discover the most recently attached client via the28 * global hub (the common case). */29 core?: DevtoolsCore;30 /** Open the panel on first mount. Default false (starts as the launcher button). */31 defaultOpen?: boolean;32}3334/** The floating Rindle devtools panel. Renders just a launcher until a client is attached via35 * `attachDevtools(app)`. */36export function RindleDevtools({ core: explicit, defaultOpen = false }: RindleDevtoolsProps): ReactNode {37 const core = useDiscoveredCore(explicit);38 const [open, setOpen] = useState(defaultOpen);39 if (!open) {40 return h(41 "div",42 { style: S.launcher },43 h("button", { type: "button", style: S.launchBtn, onClick: () => setOpen(true), title: "Open Rindle devtools" }, "🌊 Rindle"),44 );45 }46 return h(Panel, { core, onClose: () => setOpen(false) });47}4849/** Discover the active core: an explicit prop wins; otherwise track the global hub's latest core,50 * re-rendering when a client attaches/detaches (so the panel can be mounted before the app). */51function useDiscoveredCore(explicit?: DevtoolsCore): DevtoolsCore | undefined {52 const hub = useMemo(() => getDevtoolsHub(), []);53 const subscribe = useCallback((cb: () => void) => hub.subscribe(cb), [hub]);54 const latest = useCallback(() => hub.cores[hub.cores.length - 1], [hub]);55 const discovered = useSyncExternalStore(subscribe, latest, () => undefined);56 return explicit ?? discovered;57}5859type Tab = "timeline" | "queries" | "deltas";6061function Panel({ core, onClose }: { core: DevtoolsCore | undefined; onClose: () => void }): ReactNode {62 const [tab, setTab] = useState<Tab>("timeline");63 return h(64 "div",65 { style: S.panel },66 h(67 "div",68 { style: S.header },69 h("strong", { style: { fontSize: 12 } }, "🌊 Rindle devtools"),70 h("div", { style: { flex: 1 } }),71 h("button", { type: "button", style: S.iconBtn, onClick: onClose, title: "Close" }, "✕"),72 ),73 core74 ? h(CorePanel, { core, tab, setTab })75 : h(76 "div",77 { style: S.empty },78 "No Rindle client attached. ",79 h("br"),80 "Call ",81 h("code", { style: S.code }, "attachDevtools(app)"),82 " from ",83 h("code", { style: S.code }, "@rindle/devtools"),84 " in dev.",85 ),86 );87}8889function CorePanel({ core, tab, setTab }: { core: DevtoolsCore; tab: Tab; setTab: (t: Tab) => void }): ReactNode {90 const subscribe = useCallback((cb: () => void) => core.subscribe(cb), [core]);91 const getState = useCallback(() => core.getState(), [core]);92 const state = useSyncExternalStore(subscribe, getState, getState);93 const pendingCount = state.timeline.filter((t) => t.state === "pending").length;9495 return h(96 "div",97 { style: { display: "contents" } },98 h(99 "div",100 { style: S.tabs },101 h(TabButton, { active: tab === "timeline", onClick: () => setTab("timeline") }, "Timeline ", pendingCount > 0 ? h("span", { style: S.dot }, pendingCount) : null),102 h(TabButton, { active: tab === "queries", onClick: () => setTab("queries") }, "Queries ", h("span", { style: S.count }, state.queries.length)),103 h(TabButton, { active: tab === "deltas", onClick: () => setTab("deltas") }, "Deltas ", h("span", { style: S.count }, state.deltas.length)),104 ),105 h(106 "div",107 { style: S.body },108 tab === "timeline" ? h(TimelinePane, { state, core }) : null,109 tab === "queries" ? h(QueriesPane, { state }) : null,110 tab === "deltas" ? h(DeltasPane, { state, core }) : null,111 ),112 );113}114115// --- Timeline (§4.1) ---------------------------------------------------------116117function TimelinePane({ state, core }: { state: DevtoolsState; core: DevtoolsCore }): ReactNode {118 if (!state.capabilities.optimistic) {119 return h("div", { style: S.empty }, "This backend has no optimistic loop — the mutation timeline is empty.");120 }121 const rows = [...state.timeline].reverse(); // newest first122 const o = state.optimistic;123 return h(124 "div",125 null,126 h(127 "div",128 { style: S.toolbar },129 h(130 "span",131 { style: S.muted },132 `confirmed lmid ${o?.confirmedLmid ?? 0} · next mid ${o?.nextMid ?? 1}${o && o.bufferedFrames > 0 ? ` · ${o.bufferedFrames} buffered` : ""}`,133 ),134 h("div", { style: { flex: 1 } }),135 h("button", { type: "button", style: S.smallBtn, onClick: () => core.clearHistory() }, "clear settled"),136 ),137 rows.length === 0138 ? h("div", { style: S.empty }, "No mutations yet. Invoke a mutator to watch the fork/rebase loop.")139 : rows.map((t) => h(TimelineRow, { key: t.id, t })),140 );141}142143function TimelineRow({ t }: { t: TimelineEntry }): ReactNode {144 const [open, setOpen] = useState(false);145 const elapsed = (t.settledAt ?? Date.now()) - t.invokedAt;146 return h(147 "div",148 { style: S.row },149 h(150 "div",151 { style: S.rowHead, onClick: () => setOpen((v) => !v) },152 h(StateBadge, { state: t.state }),153 h("span", { style: S.mono }, t.name),154 h("span", { style: S.muted }, t.mid != null ? `mid ${t.mid}` : t.folded ? "folding…" : "—"),155 t.folded ? h("span", { style: S.tag }, "folded") : null,156 t.reconciledWithChurn157 ? h(158 "span",159 { style: S.warn, title: "View churn coincided with this confirmation — a possible snap-back (prediction diverged from the server)." },160 "⚡ snap-back?",161 )162 : null,163 h("div", { style: { flex: 1 } }),164 h("span", { style: S.muted }, fmtMs(elapsed)),165 ),166 open167 ? h(168 "div",169 { style: S.rowBody },170 h(KV, { k: "args", v: h("pre", { style: S.pre }, safeJson(t.args)) }),171 h(KV, { k: "tables", v: t.tables.join(", ") || "—" }),172 h(KV, { k: "affects queries", v: t.affectedQueries.length ? t.affectedQueries.map((q) => `#${q}`).join(", ") : "—" }),173 t.fold174 ? h(KV, {175 k: "fold",176 v: `${t.fold.foldKey} — debounce ${t.fold.debounceMs}ms${t.fold.maxWaitMs ? `, maxWait ${t.fold.maxWaitMs}ms` : ""}${177 t.fold.deferAcrossWrites ? ", deferAcrossWrites" : ""178 }${t.fold.flushed ? " (flushed)" : ""}`,179 })180 : null,181 )182 : null,183 );184}185186// --- Queries (§4.2) ----------------------------------------------------------187188function QueriesPane({ state }: { state: DevtoolsState }): ReactNode {189 if (state.queries.length === 0) return h("div", { style: S.empty }, "No materialized views are mounted.");190 return h("div", null, state.queries.map((q) => h(QueryRow, { key: q.qid, q })));191}192193function QueryRow({ q }: { q: QueryEntry }): ReactNode {194 const [open, setOpen] = useState(false);195 return h(196 "div",197 { style: S.row },198 h(199 "div",200 { style: S.rowHead, onClick: () => setOpen((v) => !v) },201 h("span", { style: S.muted }, `#${q.qid}`),202 h(ResultBadge, { rt: q.resultType }),203 q.pending ? h("span", { style: S.tagPending }, "pending") : null,204 h("span", { style: S.mono, title: q.summary }, q.summary),205 h("div", { style: { flex: 1 } }),206 h("span", { style: S.muted }, `${q.rowCount} rows`),207 ),208 open209 ? h(210 "div",211 { style: S.rowBody },212 h(KV, { k: "ast", v: h("pre", { style: S.pre }, safeJson(q.ast)) }),213 h(KV, { k: "reads tables", v: q.tables.join(", ") }),214 h(KV, { k: `sample (${q.sample.length}/${q.rowCount})`, v: h("pre", { style: S.pre }, safeJson(q.sample)) }),215 )216 : null,217 );218}219220// --- Delta stream (§4.3) -----------------------------------------------------221222const DELTA_KINDS: DeltaKind[] = ["hello", "snapshot", "add", "remove", "edit"];223224function DeltasPane({ state, core }: { state: DevtoolsState; core: DevtoolsCore }): ReactNode {225 const [paused, setPaused] = useState(false);226 const [frozen, setFrozen] = useState<DeltaEntry[]>([]);227 const [hidden, setHidden] = useState<Set<DeltaKind>>(() => new Set());228229 const live = state.deltas;230 const shown = paused ? frozen : live;231 const rows = shown.filter((d) => !hidden.has(d.kind)).slice(-300).reverse();232233 const toggleKind = (k: DeltaKind) =>234 setHidden((prev) => {235 const next = new Set(prev);236 if (next.has(k)) next.delete(k);237 else next.add(k);238 return next;239 });240241 return h(242 "div",243 null,244 h(245 "div",246 { style: S.toolbar },247 ...DELTA_KINDS.map((k) =>248 h(249 "button",250 {251 key: k,252 type: "button",253 style: { ...S.chip, ...(hidden.has(k) ? S.chipOff : null) },254 onClick: () => toggleKind(k),255 title: hidden.has(k) ? `show ${k}` : `hide ${k}`,256 },257 k,258 ),259 ),260 h("div", { style: { flex: 1 } }),261 h(262 "button",263 {264 type: "button",265 style: S.smallBtn,266 onClick: () => {267 if (!paused) setFrozen(live.slice());268 setPaused((p) => !p);269 },270 },271 paused ? "▶ resume" : "⏸ pause",272 ),273 h("button", { type: "button", style: S.smallBtn, onClick: () => core.clearDeltas() }, "clear"),274 ),275 rows.length === 0276 ? h("div", { style: S.empty }, `No deltas${hidden.size ? " match the filter" : " yet"}.`)277 : rows.map((d) =>278 h(279 "div",280 { key: d.seq, style: S.deltaRow },281 h("span", { style: S.muted }, `#${d.qid}`),282 h(DeltaBadge, { kind: d.kind }),283 h("span", { style: S.mono }, d.label),284 ),285 ),286 );287}288289// --- small components --------------------------------------------------------290291function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children?: ReactNode }): ReactNode {292 return h("button", { type: "button", style: { ...S.tab, ...(active ? S.tabActive : null) }, onClick }, children);293}294295function KV({ k, v }: { k: string; v: ReactNode }): ReactNode {296 return h("div", { style: S.kv }, h("span", { style: S.kvKey }, k), h("span", { style: S.kvVal }, v));297}298299const STATE_COLOR: Record<MutationState, string> = { pending: "#d9a106", confirmed: "#2ea043", dropped: "#cf3b3b" };300function StateBadge({ state }: { state: MutationState }): ReactNode {301 return h("span", { style: { ...S.badge, background: STATE_COLOR[state] } }, state);302}303304function ResultBadge({ rt }: { rt: ResultType }): ReactNode {305 const color = rt === "complete" ? "#2ea043" : rt === "error" ? "#cf3b3b" : "#6b7785";306 return h("span", { style: { ...S.badge, background: color } }, rt);307}308309const DELTA_COLOR: Record<DeltaKind, string> = { hello: "#6b7785", snapshot: "#8957e5", add: "#2ea043", remove: "#cf3b3b", edit: "#1f6feb" };310function DeltaBadge({ kind }: { kind: DeltaKind }): ReactNode {311 return h("span", { style: { ...S.badge, background: DELTA_COLOR[kind] } }, kind);312}313314// --- formatting --------------------------------------------------------------315316function fmtMs(ms: number): string {317 if (ms < 1000) return `${Math.round(ms)}ms`;318 if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;319 return `${Math.round(ms / 1000)}s`;320}321322function safeJson(v: unknown): string {323 try {324 return JSON.stringify(v, null, 2) ?? String(v);325 } catch {326 return String(v);327 }328}329330// --- styles (inline; no CSS dependency) --------------------------------------331332const mono = "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";333const S: Record<string, CSSProperties> = {334 launcher: { position: "fixed", bottom: 12, right: 12, zIndex: 2147483000 },335 launchBtn: { font: `600 12px ${mono}`, color: "#e6edf3", background: "#161b22", border: "1px solid #30363d", borderRadius: 8, padding: "6px 10px", cursor: "pointer", boxShadow: "0 2px 8px rgba(0,0,0,.35)" },336 panel: { position: "fixed", bottom: 12, right: 12, width: 440, maxWidth: "calc(100vw - 24px)", height: "60vh", maxHeight: 640, display: "flex", flexDirection: "column", font: `12px ${mono}`, color: "#e6edf3", background: "#0d1117", border: "1px solid #30363d", borderRadius: 10, boxShadow: "0 8px 32px rgba(0,0,0,.5)", zIndex: 2147483000, overflow: "hidden" },337 header: { display: "flex", alignItems: "center", gap: 8, padding: "8px 10px", borderBottom: "1px solid #21262d" },338 iconBtn: { background: "transparent", color: "#8b949e", border: "none", cursor: "pointer", fontSize: 13 },339 tabs: { display: "flex", gap: 2, padding: "6px 8px 0", borderBottom: "1px solid #21262d" },340 tab: { font: `600 11px ${mono}`, color: "#8b949e", background: "transparent", border: "none", borderBottom: "2px solid transparent", padding: "6px 10px", cursor: "pointer" },341 tabActive: { color: "#e6edf3", borderBottom: "2px solid #1f6feb" },342 body: { flex: 1, overflowY: "auto", padding: "6px 8px" },343 toolbar: { display: "flex", alignItems: "center", gap: 6, padding: "4px 2px 8px", flexWrap: "wrap" },344 row: { borderBottom: "1px solid #161b22" },345 rowHead: { display: "flex", alignItems: "center", gap: 6, padding: "5px 4px", cursor: "pointer" },346 rowBody: { padding: "2px 4px 8px 4px", display: "flex", flexDirection: "column", gap: 3 },347 deltaRow: { display: "flex", alignItems: "center", gap: 6, padding: "3px 4px", borderBottom: "1px solid #161b22" },348 badge: { font: `600 10px ${mono}`, color: "#fff", borderRadius: 4, padding: "1px 5px", textTransform: "uppercase" },349 tag: { font: `10px ${mono}`, color: "#c9a0ff", border: "1px solid #3b2d57", borderRadius: 4, padding: "0 4px" },350 tagPending: { font: `10px ${mono}`, color: "#f0c552", border: "1px solid #5a4a16", borderRadius: 4, padding: "0 4px" },351 warn: { font: `600 10px ${mono}`, color: "#f0883e" },352 mono: { fontFamily: mono, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },353 muted: { color: "#6e7681", fontSize: 11, whiteSpace: "nowrap" },354 count: { color: "#6e7681" },355 dot: { background: "#d9a106", color: "#0d1117", borderRadius: 8, padding: "0 5px", marginLeft: 2, fontWeight: 700 },356 empty: { color: "#6e7681", padding: 16, textAlign: "center", lineHeight: 1.6 },357 smallBtn: { font: `11px ${mono}`, color: "#c9d1d9", background: "#21262d", border: "1px solid #30363d", borderRadius: 5, padding: "2px 7px", cursor: "pointer" },358 chip: { font: `10px ${mono}`, color: "#c9d1d9", background: "#161b22", border: "1px solid #30363d", borderRadius: 10, padding: "1px 8px", cursor: "pointer" },359 chipOff: { color: "#484f58", textDecoration: "line-through" },360 kv: { display: "flex", gap: 6, alignItems: "baseline" },361 kvKey: { color: "#6e7681", minWidth: 96, flexShrink: 0 },362 kvVal: { color: "#c9d1d9", wordBreak: "break-word", flex: 1 },363 pre: { margin: 0, padding: "4px 6px", background: "#161b22", borderRadius: 5, maxHeight: 180, overflow: "auto", whiteSpace: "pre-wrap" },364 code: { background: "#161b22", borderRadius: 4, padding: "0 4px" },365};366367export { getDevtoolsCore, getDevtoolsHub, attachDevtools } from "@rindle/devtools";368export type { DevtoolsCore, DevtoolsState } from "@rindle/devtools";369