Reference

Devtools

Inspect a Rindle app in development: mutation timeline, live query inspector, raw delta stream, and the dev-only wiring for @rindle/devtools and @rindle/react-devtools.

View as Markdown

Rindle’s in-browser devtools are split into two packages:

  • @rindle/devtools - a framework-agnostic core that attaches to a running createRindleClient app and builds a read-only inspection model.
  • @rindle/react-devtools - the floating React panel over that core.

Use both in development. They inspect state the client already holds; they do not add a daemon connection, do not hold your daemon token, and should not ship to production.

Install

pnpm add -D @rindle/devtools @rindle/react-devtools
# or npm i -D @rindle/devtools @rindle/react-devtools

Apps scaffolded with create-rindle already include this wiring.

Attach the client

After the browser client is ready, attach it behind a static dev flag:

// src/rindle-client.ts
const app = await createRindleClient({
  schema,
  mutators,
  api: { url: "" },
  daemon: { wsUrl: "ws://127.0.0.1:7601" },
});

if (import.meta.env.DEV) {
  void import("@rindle/devtools").then(({ attachDevtools }) => attachDevtools(app));
}

The dynamic import matters: in a production Vite build, import.meta.env.DEV is statically false, so the devtools core is dropped from the production bundle.

Mount the React panel

Mount the panel once near the root. In an SSR app, mount it only after the first client effect so the server markup and hydration markup match:

// src/devtools.tsx
import { lazy, Suspense, useEffect, useState } from "react";

const Panel = import.meta.env.DEV
  ? lazy(() => import("@rindle/react-devtools").then((m) => ({ default: m.RindleDevtools })))
  : null;

export function DevTools() {
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);
  if (!Panel || !mounted) return null;

  return (
    <Suspense fallback={null}>
      <Panel />
    </Suspense>
  );
}

Then render <DevTools /> beside your app provider:

<RindleApp>
  <App />
</RindleApp>
<DevTools />

The panel auto-discovers the most recently attached client through the devtools hub. It starts as a small Rindle launcher in the corner of the page.

What it shows

  • Mutation timeline - each optimistic mutation from invoke to pending to confirmed or dropped, including snap-back highlights when the server result diverges from the prediction.
  • Queries inspector - every live materialized view, its result type, row count, AST, sample rows, and whether pending mutations touch its tables.
  • Delta stream - the raw incremental change tape for each query: Add, Remove, Edit, and nested child deltas.

This is aimed at the optimistic loop: what did the browser predict, what did the authority accept, and how did the live views rebase?

Core-only use

The core has no DOM assumptions. If you are not using React, attach the client and render the state yourself:

const { attachDevtools } = await import("@rindle/devtools");

const core = attachDevtools(app);
const unsubscribe = core.subscribe(() => {
  const { timeline, queries, deltas } = core.getState();
  renderDevtools({ timeline, queries, deltas });
});

// Later:
unsubscribe();
core.detach();

@rindle/react-devtools re-exports the core helpers for convenience, but the data model lives in @rindle/devtools.

Production rules

Keep both packages dev-only and behind static dev gates. The recommended pattern is:

  • dynamically import @rindle/devtools only after createRindleClient resolves;
  • dynamically import @rindle/react-devtools only when import.meta.env.DEV;
  • in SSR apps, render no panel on the server or hydration pass;
  • never pass daemon credentials to devtools.

The client exposes small read-only inspection hooks for the core. Nothing runs unless you import the package and call attachDevtools.

Next steps