Release note/@sharma/undoable 0.1.1/MIT

The undo button is the easy part

A 4 KB runtime for optimistic, undoable mutations — and the five defects that only showed up once a keyboard used it.

the real runtime, running here dist/undoable.global.js · 5s window
    Events on document

      Every product eventually grows the same feature. The user archives a row, the row disappears immediately, a toast says “Item archived — Undo”, and five seconds later the change is really persisted. Gmail shipped it in 2009. Everyone copied it.

      And nearly everyone reimplements it per feature, badly, three times in the same codebase — because the mechanism (the timer, the rollback, the one-at-a-time rule, the flush on navigation, the focus, the screen-reader announcement) sits tangled with the thing that actually varies: your data and your UI.

      undoable is that mechanism, extracted. One primitive, no UI, no dependencies.

      The whole API is two functions

      apply mutates your local state synchronously and returns its inverse. commit persists it and returns a promise. That is the entire seam.

      import { defineAction } from '@sharma/undoable';
      
      defineAction('archiveItem', {
        apply: (id) => {
          const index = items.findIndex((i) => i.id === id);
          const [item] = items.splice(index, 1);
          render();
          return () => {                      // ← the inverse, returned inline
            items.splice(index, 0, item);
            render();
          };
        },
        commit: (id) => fetch(`/items/${id}/archive`, { method: 'POST' }).then(assertOk),
      });

      Binding is delegated from document, so rows inserted later just work. No registration step, no init call, no component wrapper:

      <li data-undoable="archiveItem"
          data-undoable-arg="42"
          data-undoable-label="Item archived">
        <button data-undoable-trigger>Archive</button>
      </li>

      The runtime handles the undo window, the rollback on failure, the commit on timeout, the commit on pagehide, the focus, and the aria-live announcement. It ships no UI at all — the undo affordance is a listener:

      document.addEventListener('undoable:pending', (e) => {
        const { label, undo, expiresAt } = e.detail;
        showToast(label, undo, expiresAt);
      });

      If nothing listens, actions still work — they are just visually silent. The accessibility announcement still fires.

      EventdetailWhat it means
      undoable:pending{ name, arg, label, undo(), expiresAt }The window is open.
      undoable:committed{ name, arg }The server agreed.
      undoable:reverted{ name, arg }The user changed their mind.
      undoable:failed{ name, arg, error, reverted }Commit rejected, state rolled back.
      undoable:desync{ name, arg, error }Commit rejected and rollback is no longer safe. Refetch.

      Two constraints that look arbitrary and are not

      ActionDef accepts exactly apply and commit. Any other key throws — not warns. configure() has exactly one option: window, default 5000 ms, global, no per-action override.

      This is the load-bearing part of the design. Optimistic-UI libraries die by configuration growth: onSuccess, retries, toastPosition, priority, mergeStrategy. Each is individually reasonable and collectively fatal, because every key added is a decision moved out of the application and into a library with less context than the application has.

      So when a new requirement appears, the answer is one of two things: an event listener, or a second named action. Never a new key.

      Whether that holds is an empirical claim, and it was tested before a line of the runtime was written — three structurally different mutations (a removal, a reorder, a bulk action over a multi-selection) had to fit the API without adding a key, or the abstraction was at the wrong level.

      The concurrency model is one integer

      At most one action is pending at a time. Triggering a new one flushes the previous into committing immediately. No queue, no stack, no redo.

      A revert is valid iff no later apply has run since. That is literally a generation counter:

      applyGeneration += 1;      // before every apply, including ones that throw
      
      // …later, if the commit rejects:
      if (rec.generation === applyGeneration) rec.revert();   // still a real inverse
      else emit('desync', …);                                // stale — do not touch state

      desync is the interesting event: a commit failed, and the revert that would undo it is no longer safe to call, because a newer change is sitting on top of it. Calling it would silently discard that newer change. So the runtime refuses, and tells you instead. A sustained desync rate means the one-at-a-time flush model is wrong for your app — which is information worth having.

      The spec was right and the behaviour was wrong

      The runtime was built to a written spec with a 16-row acceptance matrix. It passed. Then I built an integration harness and drove it headlessly, checking things the matrix had not thought to check — mostly focus and the accessibility tree.

      First run: 19 of 22. All three failures were the runtime implementing the spec correctly, and the spec being wrong.

      Medium · Fixed · every keyboard user who archives twice

      The fallback was position-correct and role-blind

      After a row is removed, focus has to go somewhere. The spec said “the nearest following sibling containing a focusable element”. In a row shaped [checkbox] [↑] [↓] [Archive], that resolves to the checkbox — so archiving three rows costs a keyboard user three Tab presses each time to get back to the button they were actually using. The spec test said “focus on next row’s focusable element”, which this satisfies. The matrix passes; the interaction is bad.

      The fix turned out not to be a heuristic: the app has already declared which control plays that role by putting data-undoable-trigger on it. Nothing to guess, nothing to configure.

      High · Fixed · the metric read 0 while it was happening

      “Still connected” is not “still focusable”

      The spec ended focus handling with “if the trigger is still connected after the frame, leave focus alone.” Bulk-archive a selection and the trigger is still connected — and then the app disables it, because nothing is selected any more. Browsers blur a focused element the moment it becomes disabled, so focus drops to <body>.

      Because the check returned early, restoration never ran and the focus_loss counter was never incremented. The instrumentation reported zero while focus was being lost on every bulk action. A metric that is wrong in the same direction as the bug is worse than no metric.

      The fix collapsed it into one predicate — connected, not disabled, not hidden, not inside [inert] — asking did focus survive rather than did the trigger survive.

      Medium · Fixed, with a cost · screen-reader users

      The announcement said the opposite of what happened

      The spec fixed the announcement text as the label, with no variation by state. So a failed commit announced, assertively:

      “Buy milk” archived

      …at the exact moment the row visibly came back. The politeness level changed; the words did not. Fixed by appending fixed wording per outcome — “— undone”, “— could not be saved, change undone”.

      And that fix has a cost I could not design away: those strings are hardcoded English. The runtime cannot know the host application’s language, and all three escape routes are blocked by the design — per-action copy is a declared non-goal, a global message table is a second configure option, and going back to the bare label reintroduces the defect. It is the first genuine pressure on “exactly one option”, and it came from accessibility rather than feature creep. It is recorded as an open question rather than quietly resolved.

      Not fixed · not fixable inside the runtime’s scope

      The undo affordance is hard to reach by keyboard

      The runtime correctly moves focus into the list after a removal. The Undo control lives in a fixed-position toast at the bottom of the page, outside the list, and disappears after five seconds. A keyboard user has to notice the announcement, Tab out of the list, and reach the toast inside the window.

      Built-in UI and anything requiring layout measurement are declared non-goals, so this cannot be solved here. Recording it anyway, because it is the integration reality: shipping undoable does not by itself give you an accessible undo. Whoever ships the toast still has that problem.

      All of it is written up, defect by defect, in FINDINGS.md — including the fix that recovered a case the conservative staleness rule was needlessly throwing away.

      What it does not do

      Deliberately, and permanently: no redo, no multi-level undo stack, no built-in toast, no per-action config, nothing requiring layout measurement, no server-side conflict resolution.

      The one thing you must do

      SPA route changes are invisible to the runtime. Call flushPending() in your router hook, or a pending change is silently dropped when the view unmounts:

      router.beforeEach(() => undoable.flushPending());

      pagehide and tab-hide are already handled.

      Common questions

      Does it work with React, Vue, Angular or Svelte?

      All four. The runtime knows nothing about your framework — apply mutates your state and returns the inverse, which is a setState call in React and Angular signals, and a plain mutation in Vue and Svelte 5. The React path is executed end to end in the test suite; the other three are type-correct but not executed. Framework guide.

      How is this different from optimistic updates in TanStack Query or SWR?

      Different layer, and they compose. Those libraries fire the request immediately and roll the cache back if it rejects. undoable owns the window before the request is sent: it holds the commit for five seconds, hands you an undo(), moves focus, and announces the outcome. If you already have a mutation hook, it becomes the body of commit.

      Does it support multi-level undo, or redo?

      No, and it will not. One action is undoable at a time; starting a second one flushes the first into its commit. Redo, an undo stack, and per-action configuration are all declared non-goals — a stack is a different primitive with different failure modes, and bolting it on here would compromise both.

      Is it accessible out of the box?

      Partly, and it is worth being precise about which part. The runtime manages focus after the DOM changes and announces every outcome through a single aria-live region, with the politeness switched per state. What it does not give you is a reachable undo control — the toast is yours, and getting a keyboard user to it inside the window is your problem. The announcement strings are also English-only.

      How big is it, and what does it depend on?

      The ESM build is about 13 KB unminified and just over 4 KB gzipped, with zero runtime dependencies. TypeScript types ship with it. Node ≥ 20 for the tooling; any modern browser at runtime.

      Do I need a build step or a bundler?

      No. One script tag is a complete integration, because binding is delegated from document and there is no init call:

      <script src="https://cdn.jsdelivr.net/npm/@sharma/undoable@0.1.1/dist/undoable.global.js"></script>

      The /dist/undoable.global.js path matters — the bare package URL resolves to the CommonJS build and throws module is not defined in a browser.

      Is it safe to import in a server-rendered app?

      Yes. Next.js, Nuxt, SvelteKit and Angular Universal are all fine. The module binds on import, but the binding is guarded on typeof document and no-ops on the server. defineAction and configure work server-side; runAction belongs in the browser.

      Status

      0.1.1on npm
      0dependencies
      51tests, jsdom
      22/22integration probe
      11/11real Chromium
      ~3 mstime_to_apply p99

      MIT, TypeScript types included, Node ≥ 20, works with React, Vue, Angular and Svelte 5 (framework guide). The Chromium pass exists because jsdom structurally cannot answer questions about focus and the accessibility tree — and those turned out to be exactly the questions that mattered.