@directive-run/optimistic
v0.2.1
Published
Resolver-scope optimistic update + automatic rollback for Directive.
Maintainers
Readme
@directive-run/optimistic
Resolver-scope optimistic update + automatic rollback for Directive.
npm install @directive-run/optimisticWhat it solves
The "snapshot before, restore on catch, rethrow" pattern that recurred ~3 times during a production migration. Manual version:
submit: async ({ payload, facts }) => {
const previousValues = [...facts.values];
facts.values = optimisticGuess(payload);
try {
facts.values = await deps.submit(payload);
} catch (err) {
facts.values = previousValues;
throw err;
}
}With this package:
import { withOptimistic } from '@directive-run/optimistic';
interface FormFacts { values: FormValues; /* ... */ }
submit: withOptimistic<FormFacts>(['values'])(async ({ payload, facts }) => {
facts.values = optimisticGuess(payload);
facts.values = await deps.submit(payload);
}),The single-arg outer call (withOptimistic<F>(keys)) is what makes
the keys array type-check against keyof F – a typo like
['valuess'] becomes a compile error. The inner call accepts your
mutator handler unchanged.
If the inner handler throws, facts.values snaps back to its
pre-handler value and the throw propagates upward.
Scope: deliberately tight
This package operates within a single resolver invocation. It is:
- ✅ A "try / restore on catch" macro
- ❌ NOT a system-wide transaction
- ❌ NOT a cross-module rollback
- ❌ NOT a replay-undo
If you need cross-module rollback, you're describing a distributed transaction – not what this is. The MIGRATION_FEEDBACK item this addresses (#19) is explicitly resolver-scope.
API
createSnapshot(facts, keys) → restore
Capture the current values of selected keys; return a restore
function. Use inside a try/catch:
const restore = createSnapshot(facts, ['values', 'lastSavedAt']);
try {
facts.values = optimisticGuess(payload);
facts.values = await deps.submit(payload);
facts.lastSavedAt = Date.now();
} catch (err) {
restore();
throw err;
}restore() can be called multiple times – each call writes the
captured snapshot back. Useful if your handler has multiple
mid-execution decision points.
withOptimistic<F>(keys)(handler) → wrappedHandler
Higher-order helper that wraps a handler with snapshot + automatic
rollback. The two-call signature lets TypeScript infer the keys array
against keyof F – typos are compile errors. Composes with
@directive-run/mutator:
import { defineMutator } from '@directive-run/mutator';
import { withOptimistic } from '@directive-run/optimistic';
const mut = defineMutator<FormMutations, FormFacts>({
submit: withOptimistic<FormFacts>(['values'])(
async ({ payload, facts }) => {
facts.values = optimisticGuess(payload);
facts.values = await deps.submit(payload);
},
),
cancel: ({ facts }) => { facts.values = []; },
});The wrapper:
- Snapshots
facts.valuesat handler entry. - Runs the inner handler.
- On uncaught throw: restores
facts.values, then rethrows. - On success: leaves the new values in place.
Cloning semantics
Snapshots are deep-cloned via structuredClone (Node 17+, modern
browsers – Directive's documented engine baseline). There is no
JSON-roundtrip fallback: that path silently dropped functions,
symbols, undefined values, and was the exact silent-corruption hole
optimistic rollback exists to prevent. structuredClone covers what
the JSON-roundtrip-fact contract allows, so falling back to JSON
adds zero recoverable cases and one corruption surface.
If a fact contains a function, DOM node, non-cloneable instance, or
some other shape structuredClone rejects, the snapshot throws
an OptimisticCloneError with the offending key – making the
violation loud rather than silently corrupting the rolled-back
state. Convert at the boundary (e.g. Date → number,
BigInt → string) before assigning to facts.
import { OptimisticCloneError } from '@directive-run/optimistic';
try {
const restore = createSnapshot(facts, ['weirdField']);
// ...
} catch (err) {
if (err instanceof OptimisticCloneError) {
// err.key is the fact key that couldn't be cloned
}
}Composition with mutator
When using @directive-run/mutator and a handler throws, the mutator
captures the error on pendingMutation.error and stops the constraint
from re-firing. With withOptimistic, the rollback runs before the
mutator captures the error – so by the time the UI renders the error,
the facts are already back to their pre-mutation state.
This is the right ordering for optimistic UI:
- Optimistic write happens immediately (good UX)
- Rollback happens before the error surfaces (no torn state)
- Error message is preserved on
pendingMutation.error(UI can show)
Whole handler map with withOptimisticHandlers
When several handlers in a defineMutator map need rollback, wrapping
each one individually adds a layer of nesting at every callsite. The
withOptimisticHandlers helper takes a partial per-handler key map and
returns a same-shape handler map with the listed handlers wrapped and
the rest passed through:
import { defineMutator } from "@directive-run/mutator";
import { withOptimisticHandlers } from "@directive-run/optimistic";
type Muts = {
saveDraft: { text: string };
publish: void;
trash: void;
};
const handlers = withOptimisticHandlers<typeof handlersRaw, Facts>(
{
saveDraft: ["draft"],
publish: ["draft", "published"],
// trash: omitted → passed through unwrapped
},
handlersRaw,
);
const mut = defineMutator<Muts, Facts>(handlers);Each listed handler is equivalent to wrapping it manually with
withOptimistic(rollbackKeys). Omitted handlers (and entries with an
empty key array) are returned by identity so a future === handlers.x
check stays sound. Apply this before any outer cancellable() so
a supersede-abort doesn't trip the rollback.
Layering with cancellable()
@directive-run/mutator also ships a cancellable() HOC that aborts an
in-flight handler when a fresh dispatch supersedes it (or the timeout
fires). cancellable() throws an AbortError / SupersededCancelError
out of the handler, and withOptimistic cannot tell that error apart
from a "real" failure: it rolls back the snapshot.
If the successor dispatch has already written to the same facts by then, the rollback clobbers those writes – the UI flickers back to the pre- mutation state for one tick before the new dispatch's optimistic write lands.
Wrap withOptimistic on the inside of cancellable, not the outside:
// good: withOptimistic only sees the handler that actually does the work.
// If cancellable aborts, the abort never reaches the optimistic snapshot.
const handler = cancellable(
withOptimistic<Facts>(["draft"])(async (req, ctx, signal) => {
ctx.facts.draft = req.text;
await saveDraft(req.text, { signal });
}),
);
// bad: a supersede abort throws past withOptimistic, which then rolls back
// `draft` even though the new dispatch was about to set it.
const handler = withOptimistic<Facts>(["draft"])(
cancellable(async (req, ctx, signal) => {
ctx.facts.draft = req.text;
await saveDraft(req.text, { signal });
}),
);The same rule applies to recordReplayable(): it sits at the outermost
layer so timeline frames capture the cancel event before any rollback
runs.
When to skip the helper
- Synchronous handlers. No async work means no in-flight state to protect from. Just write the value.
- Single fact mutation that's idempotent. If the only thing the
handler writes is the result of an awaited call (
facts.x = await fn()) and there's no optimistic guess, there's nothing to roll back. - Multi-fact reads/writes that aren't related. Snapshot only the facts you actually optimistically wrote.
Composes with
@directive-run/mutator—defineMutatorhandlers wrapped withwithOptimisticHandlersget rollback for free@directive-run/query— when an optimistic mutation invalidates a query tag, the rollback also reverts the invalidation@directive-run/timeline— records optimistic writes and rollbacks so test failures are reproducible byte-for-byte@directive-run/core— the underlying runtime
Use this package with your AI assistant
withOptimisticHandlers solves the optimistic-rollback pattern AI assistants get wrong by default — pair it with Directive's IDE Integration so your assistant knows how to wire snapshot + rollback alongside defineMutator.
# Claude Code
/plugin marketplace add directive-run/directive
/plugin install directive@directive-plugins
# Cursor / Copilot / Windsurf / Cline / Codex
npx directive ai-rules initSee also
License
MIT OR Apache-2.0
