Skip to content

Imperative Commands & Refs

Most of what an app does to a widget is declarative: set a prop, and the host reconciles the native widget to match. A few things are not. “Go back in history”, “reload”, “stop the load” are one-shot actions with no state to bind, and there is no didGoBack prop worth holding in React. Those are imperative commands: take a ref on a widget and call sendCommand(ref, command). Anything stateful stays 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 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. A widget with a non-empty commands array but no host dispatch template makes codegen throw, so the three sides cannot drift.

Widgets on this channel today: <webview> (goBack, goForward, reload, stop), <window> (showAlert, openFile, saveFile, showAbout, see Dialogs), and <toastoverlay> (showToast, dismissToast, see Feedback). The last two are wrapped in promise-correlating helpers 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 }. The host handles it like a commit and marshals it onto the UI thread, since 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. Unknown node ids and command names are dropped host-side with an ND_WARN line.

The channel is a 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.

<nativeview> (the generic host for an app-owned native plugin widget, see Native Modules) declares no commands in the schema, because its commands are whatever the plugin’s own command handler accepts. sendNativeCommand(ref, command, arg?) rides the same dispatch as sendCommand but skips the schema-typed name check. Use it 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. When 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. An app allowed to render is allowed to command; one sandboxed out of committing cannot drive widgets imperatively either.