Execution

Prev Next

Execution

Selection triggers dispatch. Dispatch reads the result and acts. There is no decision logic in between.

Two Dispatch Paths

When a user selects a result from the command palette, exactly one of two things happens:

Image

  1. Route dispatch — the result has a route field. The executor calls navigate(route).
  2. Command dispatch — the result has a commandId field. The executor looks up the handler in the CommandRegistry and calls it.

If neither field is present, nothing happens. There is no fallback, no default route, no error. This is intentional — an IndexItem with no execution target is a data problem, not a runtime problem.

CommandExecutor

The executor is a class, not a singleton. It receives its dependencies at construction:

type ExecutorDeps = {
  navigate: (path: string) => void;
};

class CommandExecutor {
  constructor(private deps: ExecutorDeps) {}

  execute(item: CommandResult) {
    if (item.route) {
      this.deps.navigate(item.route);
      return;
    }
    if (item.commandId) {
      commandRegistry.execute(item.commandId);
    }
  }
}

Why dependency injection

The executor runs in a Next.js app where router.push is only available inside React components. Rather than importing the router directly (which would couple the executor to Next.js internals), the consumer injects navigate at construction time.

This means:

  • The executor can be tested without a router
  • The executor does not import from next/navigation
  • Swapping navigation strategies (e.g., hard navigation vs. client-side) requires changing the injection site, not the executor

Why route takes priority

The if (item.route) return check means route dispatch always wins when both route and commandId are present on a result. This is intentional — navigation is the primary use case, and dual-target items should not exist in practice. If they do, navigation is the safer default.

CommandRegistry

The registry is a Map<string, Command> that holds action handlers:

type Command = {
  id: string;
  handler: (context?: unknown) => void;
};

:::tip Register a command

commandRegistry.register({
  id: 'theme.toggle',
  handler: () => toggleTheme(),
});

:::

:::tip Execute by ID

commandRegistry.execute('theme.toggle');

:::

If the ID is not found, execute silently returns. There is no error, no warning. Missing commands are treated as unregistered, not as failures.

Separation from IndexRegistry

The CommandRegistry and IndexRegistry are separate systems with different purposes:

Registry Stores Used by Purpose
IndexRegistry IndexProvider functions Indexer Build the search index
CommandRegistry Command handlers CommandExecutor Execute actions by ID

An IndexItem with type: 'nav' needs no entry in the CommandRegistry — its route field is sufficient. Only items with commandId need a registered handler.

Execution Is Not Search

The executor receives a CommandResult, not a query. By the time execution runs:

  • Search is complete
  • Scoring is complete
  • The user has explicitly selected one result

The executor does not filter, rank, or validate. It reads route or commandId and dispatches. This keeps execution deterministic — the same result always triggers the same action regardless of the query that surfaced it.