Skip to content

Imperative Commands & Refs

Most of what an app does to a widget is declarative: you set a prop and the host reconciles the native widget to match. A few things aren’t. “Go back in history”, “reload”, “stop the load” are one-shot actions with no state to bind; there is no didGoBack prop that makes sense to hold in React. NativeDesktop models these as imperative commands: you take a ref on a widget and call sendCommand(ref, command). This is the escape hatch for genuinely imperative operations; anything that is stateful should stay a prop.

A widget opts into the channel with a commands array in schema/widgets.json, listing the command names it accepts:

{
"name": "WebView",
"intrinsic": "webview",
"commands": ["goBack", "goForward", "reload", "stop"],
}

tools/codegen.ts turns that one declaration into every piece of the pipeline: the TypeScript WidgetCommandNames map that types sendCommand, the runtime widgetCommands validation table, and a per-backend dispatch arm on each host (Zig and Swift). A widget with a non-empty commands array that lacks a host dispatch template makes codegen throw (the same fail-loud contract the create/apply templates use), so the three sides can never drift. <webview> (goBack/goForward/reload/stop) was the first widget on this channel; <window> (showAlert/openFile/saveFile/showAbout, see Dialogs) and <toastoverlay> (showToast/dismissToast, see Feedback) followed, both wrapped in a promise-correlating helper rather than called through raw sendCommand.

Every intrinsic accepts a ref. It resolves to an NdNodeRef<T> (the node’s wire id plus its intrinsic type, { id, type }), which is the handle sendCommand addresses:

import { sendCommand, useRef } from "@nativedesktop/react";
import type { NdNodeRef } from "@nativedesktop/react";
const page = useRef<NdNodeRef<"webview">>(null);
// …later, from an event handler:
sendCommand(page.current, "goBack");
sendCommand<T>(node: NdNodeRef<T>, command: WidgetCommandNames[T], arg?: unknown): void

The command argument is typed to the commands that this widget declares, so sendCommand(page.current, "loadURL") is a compile error on a <webview>. At runtime sendCommand validates the name again against the widgetCommands table and throws if it isn’t allowed (or if it is called before render() has opened the NDP connection), so a stale string fails loudly on the app side rather than being silently dropped by the host. The optional arg is JSON-serialized and passed through to the host; no current command uses it, but the channel carries it for commands that will.

Call sendCommand from an event handler (a click, a menu selection), never from render. It is a side effect, not derived state.

When your app may run against host builds of different ages, ask before sending instead of wrapping sendCommand in try/catch:

import { hasCommand, hasWidget, sendCommand } from "@nativedesktop/react";
if (hasCommand("window", "present")) sendCommand(win.current, "present");
if (hasWidget("sourcetree")) {
/* render <sourcetree>; otherwise fall back to <sourcelist> */
}

Both answer from the host’s handshake manifest (helloAck.hostWidgets/hostCommands): the list of intrinsics and "<intrinsic>.<command>" entries that host build actually dispatches, so the answer reflects the binary you’re connected to, not the schema your JS was compiled against. Against an older host that predates the manifest, they fall back to the runtime’s own generated schema tables: exactly the pre-manifest behavior. sendCommand still throws on a JS-schema-unknown command either way (so existing try/catch call sites stay valid), and in nd dev it warns once per command that is JS-known but host-unknown.

sendCommand emits a widgetCommand NDP frame, { nodeId, command, arg }, from the runtime to the host. On the host it is handled exactly like a commit: it is marshaled onto the UI thread, because it touches live native widgets. Socket FIFO ordering guarantees a command sent right after a commit is applied after that commit, so a node created in the previous batch is always resolvable by the time its command runs. The host resolves nodeId to the widget, looks up its kind, and calls the generated widgetCommand dispatcher, which routes to the widget’s arm (goBack etc.). Unknown node ids or command names are dropped host-side with an ND_WARN line.

The channel is a dedicated widget_command entry on the nd_backend ABI vtable, so a command reaches the native widget through the same C ABI as every other host operation; there is no widget-specific side path.

<nativeview> (the generic host for an app-owned native plugin widget, see Native Modules) declares no commands in the schema, because there’s nothing to validate against: its commands are whatever the plugin’s own command handler chooses to accept. sendNativeCommand(ref, command, arg?) rides the same underlying dispatch as sendCommand, but skips the schema-typed name check and hands the command straight to the plugin. Use sendCommand for the built-in widgets above; use sendNativeCommand only for a <nativeview> ref, ideally through the send() helper defineNativeComponent returns.

A widget command mutates live UI, so it goes through the same capability gate as commit application: the runtime checks core:commit before dispatching. If the app’s grants manifest denies it, the command is refused with ND_ACL_DENY permission=core:commit and an error frame (“capability denied”) goes back to the app instead of touching the widget. An app that is allowed to render is therefore allowed to command; one that is sandboxed out of committing cannot drive widgets imperatively either.