` shipped to production unconditionally
- conditioning on `process.env.NODE_ENV` inside the component (still
bundled in production output if your bundler doesn't dead-code-eliminate)
Prefer:
- gate via a build-time env flag the bundler can statically eliminate:
`enableDevTools={import.meta.env.DEV}` (Vite),
`enableDevTools={process.env.NODE_ENV !== "production"}` (with bundler
define), or a custom feature flag wired to a known constant
- ensure your `metaTransform` runs in production too, so even an
accidentally-enabled devtools surface gets redacted state
## Driving UI From Lifecycle Hooks
Lifecycle hooks (`onPush` / `onAmend` / `onUndo` / `onRedo` / `onClear`) are for
side-channel observers — analytics, devtools, audit logs. They are NOT a
substitute for subscribing to the snapshot.
Wrong:
- using `onPush` to update React state via `setState` from outside the React
tree
- treating the hook payload as the source of truth for "what's on the stack"
Prefer:
- `useAmnesia()` (or the scoped variant) — drives UI through normal
subscriber semantics
- `onPush` for fire-and-forget telemetry that does not feed back into the
rendered output
## Putting Side-Effecting Mutations Inside `metaTransform`
`metaTransform` runs every time the snapshot is built and every time a hook
fires. If it has side effects, they fire repeatedly with surprising timing.
Wrong:
```tsx
metaTransform: (meta) => {
if (meta.audit) sendAuditLog(meta); // ← runs N times per mutation
return meta;
};
```
Prefer:
- pure transforms only (`return { ...meta, secret: undefined }` etc.)
- emit telemetry from `onPush` / `onAmend` / `onUndo` / `onRedo` instead, which fire
exactly once per logical action
## Calling `store.push` From Inside Transaction `work`
The store is single-flight while a transaction is in flight. A bare
`store.push(...)` from inside the work function hits busy and is dropped
silently from the user's perspective.
Wrong:
```tsx
await transaction("preset", async (tx) => {
await tx.push({ redo, undo });
// BAD — second mutation is lost.
await store.push({ redo, undo });
});
```
Prefer:
- always use `tx.push(...)` inside the work function so the mutation joins
the buffer
- if you really need a "do this on its own outside the transaction" effect,
schedule it after the transaction resolves
## Holding `tx` Outside The Work Function
The `TransactionApi` handle is closed when the surrounding `transaction(...)`
call resolves. Calls to `tx.push` / `tx.label` after that point throw.
Wrong:
```tsx
let captured;
await transaction((tx) => {
captured = tx;
});
await captured.push(...); // throws
```
Prefer:
- treat `tx` as a borrow whose lifetime is the work function's call frame
- start a fresh transaction for the next batch of mutations
## Modeling Recoverable Errors As Transaction Throws
A throw inside `work` rolls back **every** buffered undo. If only some of
the work failed, you may be undoing successful steps too.
Wrong:
```tsx
await transaction(async (tx) => {
await tx.push(saveMetadata); // succeeded
try {
await tx.push(uploadAvatar); // failed
} catch {
// swallow — but tx is already aware of the failure
throw new Error("avatar failed");
}
});
```
Prefer:
- decide up-front whether each step is part of the atomic bundle
- if a step is genuinely optional, fan it out as a separate `push` after the
transaction commits, with its own retry / undo semantics
## Routing `useUndoableState` Through The Active Scope
`useUndoableState` always pins to a stable `scopeId`. It does not — and
should not — float to the active claim. React state is owned by a component
instance; the history surface it belongs to is a stable property, not a
focus-driven one.
Wrong:
- attempting to make `useUndoableState` "scope-aware" by reading
`useAmnesia().scopeId` inside the call site and passing it as `scopeId`
- expecting `useUndoableState` to migrate its entries when focus moves
Prefer:
- declare `scopeId` once at the call site as a literal: `useUndoableState(initial, { scopeId: "canvas" })`
- use `useAmnesia()` (no arg) only for read-only views (toolbar buttons,
badges) that should follow the active claim
## Mixing `useAmnesiaFocusClaim` With Inert DOM
`useAmnesiaFocusClaim` returns capture-phase handlers. They only fire when
focus or pointer-down events actually reach the element they're attached to.
Wrong:
- spreading the handlers onto a `` that has no `tabIndex` and no
focusable descendants — focus never enters, so the claim never fires
- attaching them inside a child but expecting the parent's events to bubble
through (capture-phase only catches at the bound element)
Prefer:
- attach the handlers to a focusable container (`tabIndex={-1}` is fine for
programmatic focus, `tabIndex={0}` for tab navigation)
- ensure the container has at least one focusable descendant or accepts
pointer-down itself
## Using `Command.do` When `redo` Alone Would Suffice
`do` exists for the narrow case where first-apply and replay genuinely need
different closures. Using it gratuitously means the command has two code
paths to keep in sync.
Wrong:
- `do` and `redo` are identical literal copies of each other
- `do` differs from `redo` only by adding "first time!" telemetry that could
ride on `onPush` (Workstream E) when that lands
Prefer:
- omit `do` entirely; let `redo` run on initial push
- use `do` only when first-apply produces a value (e.g. an id) that subsequent
replays must reuse, or when first-apply has a side effect that replay must not
## Capturing Mutable State In `do` Without A Stable Closure
`do` runs once. `redo` runs many times. If `redo` reads from a variable that
`do` populated, that variable must outlive both — typically a closed-over
`let` or a ref.
Wrong:
```tsx
push({
do: () => {
const id = mintId();
list.add({ id, text });
},
// `id` does not exist here.
redo: () => list.restore(id),
undo: () => list.remove(id),
});
```
Prefer:
- declare the captured value at the call-site scope so `do` and `redo` / `undo`
share it
- treat the entry as a self-contained unit: any state `redo` / `undo` needs
must be captured at push time, never recomputed inside the closures
## Ignoring The AbortSignal On Long-Running Async Handlers
The signal arrives as the only argument to every command handler (and as
the second argument to a transaction's `work`). Async handlers that pass
it to `fetch` or check `signal.aborted` in long loops cancel cleanly when
`clear()` / `dispose()` runs. Handlers that ignore it run to completion
unnecessarily and end up firing `onError({ phase: "stale" })` when their
result is dropped.
Wrong:
```tsx
push({
redo: async () => {
await api.applyTheme(next); // ignores cancellation
},
undo: async () => api.applyTheme(current),
});
```
Prefer:
```tsx
push({
redo: async (signal) => {
await api.applyTheme(next, { signal });
},
undo: async (signal) => api.applyTheme(current, { signal }),
});
```
The handler that honors the signal completes silently when cancelled (no
`onError`, no log noise). The one that ignores it still drops the commit
but produces a `phase: "stale"` event in your error handler.
## Awaiting `push` Inside Render
Calling `await store.push(...)` inside a render function, `useMemo`, or any
synchronous render-phase code suspends the render and produces an unbounded
stream of pushes.
Wrong:
- `await push(...)` inside a component body
- `await push(...)` inside a `useMemo` factory
Prefer:
- `void push(...)` from event handlers when you don't care about the resolution
- `await push(...)` from event handlers and `useEffect` callbacks when you do
- Capture the pending state from `useAmnesia().pending` to drive UI feedback
## Stacking Async Pushes Without Awaiting
The store is single-flight. A second `push` while another is pending resolves
to `null` and fires `onError({ phase: "busy" })` — the user's action is
**dropped**, not queued.
Wrong:
- Firing two un-awaited async pushes back-to-back from a button handler
- Assuming pending pushes will run sequentially after the first resolves
Prefer:
- `await` each push and let the user retry if a second click was intended
- Disable the trigger UI while `useAmnesia().pending === true`
- Compose multi-step work into one composite command rather than several
## Throwing From `onError`
The `onError` handler is invoked from inside the store. A throw is caught and
discarded so the store stays consistent.
Wrong:
- relying on a thrown `onError` to surface failures to React error boundaries
- side effects inside `onError` that themselves can throw and are not guarded
Prefer:
- forwarding to your error tracker explicitly
- guarding side effects with `try { ... } catch { /* ignore */ }` inside the handler
## Inventing Local Package Shims
Do not "fix" missing type information by shadowing the package.
Wrong:
- `react-amnesia.d.ts`
- `declare module "react-amnesia"`
- importing from unpublished internal paths
Prefer:
- `import` and `import type` from `react-amnesia`, `react-amnesia/core`, `react-amnesia/mnemonic`, or `react-amnesia/native`
- checking `src/index.ts`, `package.json`, and the API docs before assuming a surface is missing
## Treating The Provider As Optional
`useAmnesia(...)` and `useUndoableState(...)` are not global singleton hooks.
Wrong:
- calling them outside an `AmnesiaProvider`
- assuming a global window-level fallback
Prefer:
- one explicit provider per undo scope (often per document or workspace)
- `useAmnesiaScopeOptional()` only for reusable components that should silently degrade outside a provider
## AI Assistant Setup
Source: https://thirtytwobits.github.io/react-amnesia/docs/ai/assistant-setup
# AI Assistant Setup
This page explains how users and agent builders should expose `react-amnesia`
to coding assistants.
## Canonical Source
The canonical AI-oriented source is this docs section:
- `/docs/ai`
- `/docs/ai/invariants`
- `/docs/ai/decision-matrix`
- `/docs/ai/recipes`
- `/docs/ai/anti-patterns`
Everything else is a companion surface:
- repository instruction packs for Codex, Claude Code, Cursor, and Copilot
## Generated Instruction Packs
The repo also ships first-party instruction packs generated from the canonical
AI docs:
- `AGENTS.md`
- `CLAUDE.md`
- `.claude/rules/react-amnesia-undo.md`
- `.claude/rules/decision-checklist.md`
- `.cursor/rules/react-amnesia-undo.mdc`
- `.cursor/rules/react-amnesia-docs.mdc`
- `.github/copilot-instructions.md`
- `.github/instructions/react-amnesia-undo.instructions.md`
- `.github/instructions/react-amnesia-docs.instructions.md`
Those files are projections over the same undo/redo contract rather than
independent sources of truth.
## Lowest-Friction Retrieval
When an assistant has only HTTP or filesystem access, give it these paths
first:
1. `website/docs/ai/index.md`
2. `website/docs/ai/invariants.md`
3. `website/docs/ai/decision-matrix.md`
4. `website/docs/ai/recipes.md`
5. `website/docs/ai/anti-patterns.md`
That ordering keeps the first context window compact while still leaving the
recipes and anti-patterns available when the task is more complex.
## Validated MCP-Friendly Path
The lowest-friction MCP setup is exposing this repository through a
filesystem-capable MCP server or any equivalent local-docs MCP layer.
Mount these paths:
- `website/docs/ai/index.md`
- `website/docs/ai/invariants.md`
- `website/docs/ai/decision-matrix.md`
- `website/docs/ai/recipes.md`
- `website/docs/ai/anti-patterns.md`
- `src/Amnesia/history.ts`
- `src/Amnesia/provider.tsx`
- `src/Amnesia/use.ts`
- `src/Amnesia/use-undoable-state.ts`
- `src/Amnesia/shortcuts.tsx`
- `src/Amnesia/types.ts`
- `src/mnemonic.ts`
This gives an MCP client both the compact docs surfaces and the source files
that define the contract underneath them.
## Sister Library
`react-amnesia` is the sister project of
[`react-mnemonic`](https://thirtytwobits.github.io/react-mnemonic/), which
handles persistent state. The optional `react-amnesia/mnemonic` bridge is the
only place the two projects depend on each other directly. When a task spans
both libraries, mount both repositories' AI docs side-by-side: each library
owns its own canonical contract.
## Maintenance
Keep the surfaces aligned this way:
- edit the canonical prose in `website/docs/ai/*`
- regenerate instruction packs via `npm run docs:ai`
- use `npm run ai:check` in CI or before commits to catch drift in generated AI artifacts and instruction packs
The goal is simple: agents should load one canonical contract and then choose
the right undo behavior without inventing missing semantics.