Invariants
This page is the shortest authoritative statement of what react-amnesia
guarantees.
Supported React Versions
- React
^18.0.0 || ^19.0.0. The full test suite runs under React 18.3 and React 19.2. - Component tests are wrapped in
<StrictMode>by default, so every component path exercises React's dev double-mount cycle. AmnesiaProviderdoes not auto-dispose its store on unmount. This is intentional: auto-dispose conflicts with StrictMode's simulated effect cleanup. Callstore.dispose()manually when sharing a store with non-React code.
Core Runtime Invariants
useAmnesia(...),useAmnesiaLabels(...),useUndoableState(...),useAmnesiaFocusClaim(...),useAmnesiaScopes(...), and<AmnesiaShortcuts />must run inside anAmnesiaProvider.- The history store is in-memory only. Closures are never serialized and the stack does not survive a reload.
Command.redoandCommand.undoare required and may be synchronous or return aPromise<void>.Command.dois optional. When present, it runs once at push time instead ofredo; it is consumed there and never stored on the entry.push/amend/undo/redoalways returnPromise<number | null>. Synchronous handlers resolve in the same microtask with no observablepending: truewindow.push(command)invokescommand.do ?? command.redoexactly once on insertion unless{ applied: true }is passed.push(...)always clears the future (redo) stack. No branching is supported in v0.amend(patch)updates only the last past entry. Omitted fields preserve previous values; default behavior keeps the previousundoand replaces only what the patch supplies.undo()pops the most recent past entry, calls itsundo(), and pushes it onto the future stack. Resolves to the entry id, ornullwhen the past stack is empty.redo()pops the most recent future entry, calls itsredo(), and pushes it onto the past stack. Resolves to the entry id, ornullwhen the future stack is empty.clear()is synchronous. It drops both stacks, bumpsepoch, empties the pending set, and notifies subscribers exactly once.dispose()is synchronous and idempotent. It bumpsepoch, clears state, and disconnects listeners.AmnesiaProviderdoes not call it automatically — consumers who share a store with non-React code may invoke it themselves.- Snapshots are referentially stable until the next mutation.
getSnapshot()returns the same reference for identical state. - Snapshots and their
pastandfuturearrays are frozen withObject.freeze. Consumers cannot mutate them. - Synchronous mutations notify subscribers exactly once. Asynchronous mutations notify twice: once when the await begins (
pending: true) and once on commit (pending: false). A stale-dropped op (epoch mismatch) does not notify;clear()already did. - The listener list is snapshotted before dispatch, so callbacks added or removed during a notify cycle do not affect the current tick.
- Capacity defaults to
100and is clamped to a minimum of1. When exceeded, the oldest past entry is dropped silently on the next push. Eviction happens at commit, not at schedule. - Scope-level
coalesceWindowMsdefaults to400and is clamped to a minimum of0. Coalescing timestamps are taken at commit. Coalescing across async commands is supported but fragile — recommend against it. - Two consecutive pushes coalesce when they share the same non-empty
coalesceKeyand the second arrives within the effective coalescing window. Window resolution is per push:command.coalesceWindowMs(when defined) overrides the scope value;Number.POSITIVE_INFINITYremoves the time bound;<= 0disables coalescing for that push; non-finite values other than+Infinitydo not coalesce. The merged entry keeps the earlierundoand the latestredoso a single undo reverts the whole burst. Each push'sdois invoked at its own push time; the merged entry never stores ado. - A throwing
redo()orundo()leaves the entry in place and surfaces viaonError({ phase: "undo" | "redo", recoverable: true }). The application is responsible for retry or recovery. - Concurrent operations while
pending === trueresolve tonulland surface asonError({ phase: "busy" }). The store is single-flight. - An async op whose
awaitoutlasts aclear()ordispose()resolves tonulland surfaces asonError({ phase: "stale" }). State has already been cleared by the racing call. onErrorinvocations are deferred untilpendingTokensis empty so a handler that callspush/undo/redore-entrantly always sees a quiescent store.- The default
onErrorhandler logs toconsole.errorwith the prefix[Amnesia]. A custom handler that itself throws is caught and ignored. - Provider options (
capacity,coalesceWindowMs,onError) are read once at mount. Subsequent prop changes are ignored. Remount the provider with akeyto apply new settings.
Cancellation (AbortSignal)
- Every
Commandhandler (do,redo,undo) and everytransactionworkfunction receives anAbortSignalas its (first / second) argument. - One
AbortControlleris created per operation. The signal is aborted whenclear()ordispose()runs on that scope while the operation is in flight. - Sibling operations on the same scope don't share signals; each gets its own controller.
- Nested transactions share the outer transaction's signal — they flatten into the same buffer, so a single cancellation propagates through nested work.
- Synchronous handlers receive a signal too, but it is never aborted before they return.
- Handler treatment of cancellation:
- Honored: handler observes
signal.abortedand rejects (any error). The op resolves tonull, no entry is committed, noonErrorfires. This is the silent path. - Ignored: handler completes normally despite the abort. The epoch check still drops the commit, and
onError({ phase: "stale", recoverable: false })fires. - Real failure (signal not aborted): handler throws. Same behavior as before —
onErrorwith the appropriate phase,pushrejects to caller,undo/redoleave the entry in place.
- Honored: handler observes
- Transaction rollback uses a fresh
AbortSignal(the original was already aborted), so buffered undos can do their own cleanup work even during a cancellation. - The composite entry's
redoandundo(built from a transaction) propagate their caller's signal to every buffered handler in the order they were pushed (redo) or reverse order (undo).
DevTools Registry
<AmnesiaProvider enableDevTools>lazily installswindow.__REACT_AMNESIA_DEVTOOLS__on first mount and registers the provider's inspection api underdevToolsId(auto-generatedamnesia-Nif omitted).- When no provider sets
enableDevTools, the registry is never created — no global, no overhead. - Registry shape:
providers: Record<id, AmnesiaDevToolsProviderEntry>resolve(id) -> AmnesiaDevToolsProviderApi | null(returnsnullfor GC'd or unregistered providers)list() -> AmnesiaDevToolsProviderDescriptor[](id, available, registeredAt)capabilities -> { weakRef, finalizationRegistry }__meta -> { version, lastUpdated, lastChange }(bumps on every register / unregister)
- Provider entries are stored as
WeakRefs when available;deref()returns the live api orundefined. A strong-reference fallback keeps the registry usable on runtimes withoutWeakRef. - The provider's inspection api exposes
id,getActiveScopeId(),scopes(),getSnapshot(scopeId?),pastSnapshot(scopeId?),futureSnapshot(scopeId?),dump(),triggerUndo(scopeId?),triggerRedo(scopeId?),clear(scopeId?). Methods that take an optionalscopeIdresolve to the active scope when omitted. - Triggers (
triggerUndo,triggerRedo) are async and obey the same single-flight / busy / stale rules as direct store calls. - The provider's
useEffectre-registers under the same id across StrictMode's simulated cleanup-then-setup cycle. External listeners may observe a brief gap;__meta.versionrecords each transition.
Reset Semantics
useUndoableStatereturns[value, set, reset]. The reset reference is stable across renders.reset(next?)resolves the new value as:next(ornext()for a factory) when supplied, otherwise the value captured on first render. Strict-mode double-invocation does not change the captured initial — it is set once viauseState's initializer contract.resetcallsstore.clear()on the bound scope FIRST, then writes the resolved value. The clear bumpsepochso any in-flight async op stales out cleanly; the rewrite lands in the same microtask.resetdoes not push an entry. It is intentionally not undoable — the wipe is the point.useUndoableStateclears the entire scope, not just the value owned by this hook. Sibling hooks and imperativeuseAmnesia(scopeId).push(...)calls in the same scope are also dropped.usePersistedUndoableState'sreset(next?)is composite: scope clear THEN eithermnemonic.reset()(no arg) ormnemonic.set(next)(with arg). The persistence layer's defaultValue is whatever was passed touseMnemonicKey.usePersistedUndoableState'sremove()is composite: scope clear THENmnemonic.remove()(deletes the key from storage; subsequent reads returndefaultValue).
Lifecycle Hooks
- Provider options accept
onPush(entry, scopeId),onAmend(entry, scopeId),onUndo(entry, scopeId),onRedo(entry, scopeId),onClear(scopeId). Per-scope overrides viascopes={{ x: { onPush } }}win over provider-level handlers. - The store-level shape (
AmnesiaStoreOptions) takes the scopeId-free form:onPush(entry),onAmend(entry),onUndo(entry),onRedo(entry),onClear(). The provider api binds scopeId before forwarding. - Hook events are queued during a mutation and dispatched from
notify()after subscribers fire. They never run before the snapshot is updated. - A re-entrancy guard prevents a hook that calls
push/undo/redofrom causing nested drains: the inner mutation queues its own hook event, and the outer drain picks it up. onPushfires exactly once per logical user action: never on coalesce-merge, never on rollback, exactly once per transaction commit.onAmendfires once per successful amend.onClearfires only whenclear()actually mutated state. Empty/no-op clears do not fire. Provider-levelclear()(no arg) firesonClearonce for each scope that was non-empty.- A hook that throws is caught and ignored; the rest of the queue still drains.
metaTransformfailures also do not poison the store — the failing entry'smetais stripped before the hook sees it. metaTransform(meta)runs every timemetais exposed: in the public snapshot'spast/futureentries AND in hook payloads. Returningundefinedstripsmetafrom the public form.
Transactions
transaction(label?, work)is per-scope. The store is single-flight while the transaction runs; concurrentpush/undo/redofrom outside the work function hitphase: "busy"and resolve tonull.tx.push(command)invokescommand.do ?? command.redosynchronously (or awaits if it returns a Promise) and appendscommand.redoandcommand.undoto the buffer. The composite entry storesredo(notdo) for replay.- The composite's
redoruns every bufferedredoin original order; itsundoruns every bufferedundoin reverse order. Both await async handlers. - Sync
workcommits with a single notify (no observablepending: true). Asyncworknotifies twice: at await-start and at commit / rollback / stale-resolution. - A synchronous throw from
workrolls back synchronously then re-throws. An asynchronous rejection rolls back asynchronously then re-throws.clear()/dispose()during the await rolls back and resolves tonullwithphase: "stale". - Per-buffered-undo failures during rollback fire
phase: "rollback"errors, one per failure. The originalworkrejection (when applicable) still propagates to the caller. tx.pushandtx.labelthrow synchronously when called after the surroundingtransaction(...)resolves.- Nested
transaction(...)calls flatten: the inner call'slabelis ignored, itsworkruns against the outer's buffer, and it resolves tonullimmediately when its own work completes. There is no separate nested commit. - The composite entry's
coalesceKeyis undefined; it never coalesces with neighbors. Individualtx.pushcalls do not coalesce within the buffer either. transaction(...)on a disposed store resolves tonullwithout invokingwork.transaction(label)with noworkfunction rejects synchronously with aTypeError.
Multi-Scope Routing
- A provider owns a
Map<scopeId, Amnesia>. Named scopes are created lazily on first reference. The reserved"default"scope is created on first reference like any other. - Scopes are isolated: each has its own past, future, version, epoch, pending set, capacity, and coalesce window. Cross-scope undo / redo is not possible.
- All hooks bound to the same scopeId share that scope's stack. Multiple
useUndoableState,usePersistedUndoableState, and imperativeuseAmnesia(scopeId).push(...)calls in the same scope all push entries onto one ordered history. A single Ctrl+Z pops the most recent entry regardless of which hook produced it. This is the default behavior — no explicit coordination needed: omitscopeIdeverywhere and they all share"default". - The provider tracks at most one focused-child claim at a time.
claim(scopeId)sets it;claim("default")clears it;release(scopeId)clears it only ifscopeIdcurrently holds it. - The active scope is
claim ?? "default".getActiveScopeId()reads it;subscribeActive(listener)notifies on every change. - Per-scope option overrides on the provider's
scopesprop are read at scope-creation time and frozen thereafter. Updating the prop after a scope exists has no effect. useUndoableStateandusePersistedUndoableStatepin to an explicitscopeId(default"default"). They do not track the active claim — React state lives in stable component instances and should not migrate scopes when focus moves.useAmnesia(scopeId?)does the opposite: with no arg it tracks the active claim; with an arg it pins.useAmnesiaLabels(scopeId?)shares the same scope resolution semantics asuseAmnesia, but selector-renders only when{ canUndo, canRedo, undoLabel, redoLabel, pending, scopeId }changes.<AmnesiaShortcuts />resolves the target scope at handler time so live focus claims always route the chord without a re-render.<AmnesiaShortcuts scopeId="..." />pins.useAmnesiaFocusClaim(scopeId)returns capture-phase focus / pointer-down handlers. On the component's unmount it releases its claim if it was active.clear(scopeId?)on the provider api (anduseAmnesiaScopes().clear) iterates every registered scope when called with no argument. With ascopeIdargument it clears only that scope (lazily creating it if needed). The per-scope store's ownclear()(e.g. viauseAmnesia(scopeId).clear()) clears just its own stacks and takes no argument.
Type Sourcing Rules
- Import values from
react-amnesia(orreact-amnesia/core,react-amnesia/mnemonic,react-amnesia/native), not internal package paths. - Import exported types from
react-amnesiawithimport type. - Do not create local
react-amnesia.d.tsfiles. - Do not write
declare module "react-amnesia"in consumer code. - If a type seems missing, check
src/index.ts,src/core.ts,src/mnemonic.ts,package.json, and the API docs before inventing a replacement contract.
Exact Push Lifecycle
push(command, options?) follows this order:
- If the store is disposed, resolve to
null. - If
pendingTokensis non-empty (another op is in flight), scheduleonError({ phase: "busy" })and resolve tonull. - If
options.appliedis nottrue, invokecommand.do ?? command.redo. A synchronous throw is scheduled asonError({ phase: "push" })and re-thrown to the caller; the entry is not added. - If the invoked handler returned a Promise, notify subscribers (so
pending: trueis observable), thenawaitit. A rejection schedulesonError({ phase: "push" })and re-throws. - After resume, if the store's
epochhas changed (aclear()ordispose()raced the await), scheduleonError({ phase: "stale" })and resolve tonullwithout committing. - Read the most recent past entry. If it shares a non-empty
coalesceKeywith the new command, resolve the effective coalescing window for this push (command.coalesceWindowMsoverride or scope default), then coalesce only when the effective rule allows it and the elapsed wall-clock time is within bounds. On coalesce, replace with a merged entry (latestredo, originalundo, latest label / coalesceKey / meta) and clear the future stack. - Otherwise, append a new entry with a fresh monotonic id. If the past stack now exceeds
capacity, drop the oldest entry. Clear the future stack. - Increment
version, remove the pending token, rebuild the frozen snapshot, and notify subscribers. Drain any deferredonErrorcalls now thatpendingTokensis empty.
Exact Undo / Redo Lifecycle
undo() follows this order:
- If the store is disposed, resolve to
null. - If
pendingTokensis non-empty, scheduleonError({ phase: "busy" })and resolve tonull. - Read the last past entry. If none exists, resolve to
nullwithout notifying. - Call the entry's
undo(). If it returned a Promise, notify (pending: true), thenawait. - A throw schedules
onError({ phase: "undo", recoverable: true }), leaves the entry in place, and resolves tonull. - After resume, if
epochchanged, scheduleonError({ phase: "stale" })and resolve tonull. - On success, pop the entry from past and append it to future. Increment
version, remove the pending token, rebuild the snapshot, and notify subscribers. Drain any deferredonErrorcalls.
redo() follows the symmetric order against the future stack with phase: "redo".
Exact Amend Lifecycle
amend(patch) follows this order:
- If the store is disposed, resolve to
null. - If
pendingTokensis non-empty, scheduleonError({ phase: "busy" })and resolve tonull. - Read the last past entry. If none exists, resolve to
null. - Replace only fields present in
patch(redo,undo,label,meta), preserve the rest, keep the same entry id, and keep the originalpushedAt. - Replace the last past entry with the amended entry, clear future, increment
version, rebuild snapshot, and notify subscribers.
Keyboard Shortcut Boundaries
<AmnesiaShortcuts /> is the only built-in keyboard binding. Its contract is:
- Mounts a
keydownlistener ontarget. Defaults towindow. Accepts anHTMLElement | Document | Window | "document" | "window" | null. The string forms"document"/"window"resolve insideuseEffect, so they are SSR-safe.target === nullattaches no listener. - Bindings:
Ctrl+Z/Cmd+Zfor undo;Ctrl+Shift+Z,Cmd+Shift+Z, andCtrl+Yfor redo. - Ignores any keydown whose
event.defaultPrevented === true— an upstream handler has already claimed the chord. - Ignores any keydown with
event.altKey === true. Alt-modified chords are intentionally separate from Undo / Redo. - When
skipEditableTargetsistrue(default), chords are ignored whenevent.composedPath()contains a text-like<input>type (for exampletext,email,search,tel,url,password,number),<textarea>,<select>, or acontenteditablesurface. Non-text inputs likecheckbox,radio, andrangedo not short-circuit undo routing. The composed-path walk is shadow-DOM transparent: editables inside open shadow roots are recognized even thoughevent.targethas been retargeted to the host. Falls back toevent.targetonly whencomposedPathis unavailable. - For non-keyboard triggers (native Edit menu actions in Electron/Tauri), import from
react-amnesia/native:isNativeEditableElement(target)anddispatchNativeUndo("undo" | "redo"). - When
preventDefaultistrue(default),event.preventDefault()is called whenever the chord matches and shortcuts are not skipped — regardless of whether an entry exists to undo / redo. This is required because asyncundo/redocannot synchronously decide whether to suppress the browser's native chord. - When
enabledisfalse, the listener is detached. Toggle this rather than unmounting the component if a modal needs to own the chord temporarily.
Persistence Bridge Boundaries
The optional react-amnesia/mnemonic entrypoint is a thin layer over both
libraries. Its contract is:
usePersistedUndoableState(key, options)callsuseMnemonicKey<T>(key, mnemonicOptions)for the value path and pushes one command per change for the history path.set(...)always writes through thereact-mnemonicsetter and then pushes a command with{ applied: true }.reset()andremove()from the returned object pass straight through toreact-mnemonicand bypass the undo stack. Wrap them withuseAmnesia().push(...)if your app needs them reversible.- The undo stack itself is not persisted. On reload the value is recovered from
react-mnemonicand the history starts empty.