npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@directive-run/optimistic

v0.2.1

Published

Resolver-scope optimistic update + automatic rollback for Directive.

Readme

@directive-run/optimistic

Resolver-scope optimistic update + automatic rollback for Directive.

npm install @directive-run/optimistic

What 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:

  1. Snapshots facts.values at handler entry.
  2. Runs the inner handler.
  3. On uncaught throw: restores facts.values, then rethrows.
  4. 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

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 init

See also

License

MIT OR Apache-2.0