zustandCapture.ts5.0 KBView on GitHub /**
* Zustand capture middleware for the deep debugger.
*
* Wraps the store's `set` so every `setState` lands in the timeline buffer as
* a `zustand` entry — the before/after diff of changed top-level keys plus a
* best-effort caller (so you can see which action fired the change).
*
* Replaces the old external `store.subscribe` approach: a middleware sees
* every discrete `set` call (not coalesced snapshots) and runs inside the
* caller's stack, so it can attribute the change. Apply it as the OUTERMOST
* middleware in a store's `create()` chain.
*/
import type { StateCreator, StoreMutatorIdentifier } from 'zustand';
import { push, isEnabled, isCaptureBodies } from './buffer';
import { redact } from './redact';
// Store-internal keys that are noisy or risk feedback loops — never diffed.
const SKIP_KEYS = new Set<string>([
'agentConnectionLogs',
'messages',
'voiceState',
'voiceSettings',
]);
const PREVIEW_MAX_DEPTH = 3;
const PREVIEW_MAX_KEYS = 40;
const PREVIEW_MAX_STRING = 300;
function changedTopLevelKeys(
next: Record<string, unknown>,
prev: Record<string, unknown>,
): string[] {
const keys: string[] = [];
const all = new Set([...Object.keys(next), ...Object.keys(prev)]);
for (const k of all) {
if (SKIP_KEYS.has(k)) continue;
// Functions are stable references — they never "change" and aren't data.
if (typeof next[k] === 'function') continue;
if (next[k] !== prev[k]) keys.push(k);
}
return keys;
}
/**
* Depth- and breadth-bounded preview. Crucially this does NOT traverse the
* whole value — capture runs on every setState, and store slices can hold
* hundreds of KB (threadData, aopsById, …). Long strings are sliced (O(cap),
* not O(length)) so a 350 KB email body never gets fully serialized.
*/
function boundedPreview(value: unknown, depth: number): unknown {
if (typeof value === 'string') {
return value.length > PREVIEW_MAX_STRING
? `${value.slice(0, PREVIEW_MAX_STRING)}…[+${value.length - PREVIEW_MAX_STRING} chars]`
: value;
}
if (value === null || typeof value !== 'object') return value;
if (depth <= 0) {
return Array.isArray(value) ? `[Array(${value.length})]` : '[Object]';
}
if (Array.isArray(value)) {
const head = value.slice(0, PREVIEW_MAX_KEYS).map((v) => boundedPreview(v, depth - 1));
return value.length > PREVIEW_MAX_KEYS
? [...head, `…+${value.length - PREVIEW_MAX_KEYS} more`]
: head;
}
const entries = Object.entries(value as Record<string, unknown>);
const out: Record<string, unknown> = {};
for (const [k, v] of entries.slice(0, PREVIEW_MAX_KEYS)) {
out[k] = boundedPreview(v, depth - 1);
}
if (entries.length > PREVIEW_MAX_KEYS) {
out['…'] = `+${entries.length - PREVIEW_MAX_KEYS} more keys`;
}
return out;
}
function summarize(v: unknown): unknown {
try {
// Bound first (cheap, no full traversal), then redact the small result.
return redact(boundedPreview(v, PREVIEW_MAX_DEPTH), { captureBodies: isCaptureBodies() });
} catch {
return '[unserializable]';
}
}
/**
* Best-effort caller attribution: the first stack frame outside zustand,
* immer, and this file — i.e. the action (or component) that called `set`.
*/
function callerFrame(): string | undefined {
const stack = new Error().stack;
if (!stack) return undefined;
for (const line of stack.split('\n').slice(2)) {
if (
line.includes('zustandCapture') ||
line.includes('/zustand/') ||
line.includes('/immer/')
) {
continue;
}
return line.trim().replace(/^at\s+/, '');
}
return undefined;
}
// Middleware signature that leaves the store type (and mutator chain) unchanged.
type CaptureMiddleware = <
T,
Mps extends [StoreMutatorIdentifier, unknown][] = [],
Mcs extends [StoreMutatorIdentifier, unknown][] = [],
>(
creator: StateCreator<T, Mps, Mcs>,
) => StateCreator<T, Mps, Mcs>;
type CaptureImpl = <T>(creator: StateCreator<T, [], []>) => StateCreator<T, [], []>;
const captureImpl: CaptureImpl = (creator) => (set, get, api) => {
const loggedSet: typeof set = (...args) => {
if (!isEnabled()) {
set(...(args as Parameters<typeof set>));
return;
}
const prev = get() as Record<string, unknown>;
set(...(args as Parameters<typeof set>));
const next = get() as Record<string, unknown>;
const changedKeys = changedTopLevelKeys(next, prev);
if (changedKeys.length === 0) return;
const diff: Record<string, { from?: unknown; to?: unknown }> = {};
for (const k of changedKeys) {
diff[k] = { from: summarize(prev[k]), to: summarize(next[k]) };
}
// devtools forwards an action name as the third arg (often 'anonymous').
const actionName = (args as unknown[])[2];
const named =
typeof actionName === 'string' && actionName !== 'anonymous'
? actionName
: undefined;
push({
source: 'zustand',
changedKeys,
diff,
caller: named ?? callerFrame(),
});
};
return creator(loggedSet, get, api);
};
export const captureMiddleware = captureImpl as CaptureMiddleware;