Shared Action Registry

Shared action registry

When a UI exposes the same set of actions through multiple surfaces — command palette, global keybindings, help dialog, settings page — the surfaces will drift. The cure is one registry that all of them derive from.

The drift problem

Before mxr's web app had a registry, three independent surfaces held the same list:

Drift was inevitable and shipped real bugs. One example: g a routed to archive in the keymap but was labelled "Analytics" in the palette. Two of the three surfaces agreed; the third lied. The lie persisted because nobody owned reconciling them.

This is the M-surfaces-times-N-actions problem at small scale. Three surfaces × eighty actions = 240 entries to keep in sync. The number is small enough that "we'll just be careful" sounds plausible. It doesn't survive contact with feature work.

The registry shape

One Action type, one catalog, every consumer derives from it:

type Action = {
  id: string;                  // stable kebab, e.g. "mail.archive"
  label: string;
  description?: string;
  group: ActionGroup;
  icon?: LucideIcon;
  shortcut?: ShortcutChord;
  paletteOnly?: boolean;
  when?: (ctx: ActionContext) => boolean;
  run: (ctx: ActionContext) => void | Promise<void>;
};

Consumers:

Adding a new action means adding one entry. The four surfaces update automatically. There's no "remember to also update X" footnote because there's no X to update.

Predicates and runners split the right way

when predicates are composable pure functions: onRoute, onPane, withSelection, withFocusedThread. Multiple chained with and(...). This stays testable and serializable.

run functions never call React hooks. They reach into stores via getNavigateRef() and useModals.getState(). The reason: a runner has to fire from outside React (the tinykeys callback), from inside React (the palette), and programmatically (other code paths). Hooks would limit it to one of those.

Bundle impact and lazy escape hatch

The catalog imports every feature's runners eagerly. ~80 actions × ~200 bytes ≈ 16 KB pre-gzip. If this grows past a few percent of the main chunk, the escape hatch is to lazy-load runners with dynamic import() keyed by action.id while keeping Action metadata eager so palette filtering stays sync. The pattern survives the optimization because the registry is the seam.

Where this generalizes

Any app where the same actions appear in multiple invocation surfaces. Electron apps with a menu bar + keyboard + command palette. VS Code with the command palette + keybindings + menus. Mobile apps with toolbar + long-press menu + voice. The number of surfaces only grows; the drift cost compounds. Single registry, every surface derived.

The trigger for adopting it: the first time a surface lies and you notice it through a user bug rather than through your own testing. After that, the registry is cheaper than the next bug.

See also