Rindle’s in-browser devtools are split into two packages:
@rindle/devtools- a framework-agnostic core that attaches to a runningcreateRindleClientapp 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/devtoolsonly aftercreateRindleClientresolves; - dynamically import
@rindle/react-devtoolsonly whenimport.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
- The browser client - where
createRindleClientis configured. - Server rendering - how to keep the panel client-only in an SSR app.
- Scaffold with create-rindle - a starter with devtools already wired.