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.
Declaring commands in the schema
Section titled “Declaring commands in the schema”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.
Getting a ref and calling sendCommand
Section titled “Getting a ref and calling 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): voidThe 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.
Feature detection: hasCommand / hasWidget
Section titled “Feature detection: hasCommand / hasWidget”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.
On the wire
Section titled “On the wire”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.
sendNativeCommand
Section titled “sendNativeCommand”<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.
Capability gating
Section titled “Capability gating”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.