dioxus-js-bindgen
TypeScript and JavaScript interop for Dioxus.
dioxus-js-bindgen is a compile-time FFI binding generator and runtime engine for Dioxus. It transforms standard TypeScript and JavaScript files into strongly-typed synchronous Rust commands, asynchronous RPC queries, and leak-free RAII reactive watchers.
AST parsing, type stripping, and bundling are executed entirely in-memory using a Rust-native SWC engineβrequiring zero external toolchains (no Node.js, npm, or Bun) in either local development or CI/CD pipelines.
Table of Contents
- Key Capabilities
- Interaction Models
- Quick Start
- 3-Tier Classification & Annotation Rules
- Macro Import Syntax & Identifier Matching
- Compilation Diagnostics & Unsupported Patterns
- Runtime Architecture & Fault Tolerance
- Type System & Serialization Contract
- Error Taxonomy & Diagnostic Handling
- Headless Testing & Drop Safety
- License
Key Capabilities
- π Native TypeScript Colocation: Colocate
.tsor.jsfiles directly alongside your Rust components. - β‘ Zero External Dependencies: AST parsing and type stripping run entirely in Rust via SWC. No Node.js, Bun, or npm required.
- π― Three Interaction Models:
- Commands: Trigger DOM actions (like
focus()orscroll()) synchronously withoutasync/awaitboilerplate. - Queries: Read browser measurements and data directly into strongly-typed Rust structs.
- Watchers: Listen to continuous browser events (like resizing or clicks) with automatic cleanup.
- Commands: Trigger DOM actions (like
- π Signal-Reactive Subscriptions (
use_watcher): Subscriptions automatically rebind when Dioxus signals change and clean up completely when components unmountβeliminating browser listener leaks. - π Natural Rust & JS Naming: Call JavaScript
camelCasefunctions naturally using idiomatic Rustsnake_case, with support for selective imports and renaming (as). - π©Ί Early Error Detection: Disallows unsupported JavaScript patterns (like static imports or default exports) during
cargo buildwith clear compiler errors. - π§ͺ Safe Headless Testing: Runs safely in desktop
cargo testenvironments without browser panics.
Interaction Models
Exported TypeScript declarations are mapped into three distinct interaction models in Rust:
| Model | TypeScript Signature | Classification Rule | Generated Rust Signature / Type | Behavior & Guarantees |
|---|---|---|---|---|
| Command | function doWork(...args): void |
Inferred (void return) or #[command] |
pub fn do_work(...args) |
Synchronous, fire-and-forget DOM manipulation. Serializes parameters and dispatches immediately without .await or spawn. Isolated inside browser try-catch. |
| Query | async function fetchRect(...args): Promise<T> |
Inferred (Promise/value return) or #[query] |
pub async fn fetch_rect(...args) -> Result<T, JsError> |
Asynchronous bidirectional RPC returning strongly-typed Serde data. Captures JavaScript exceptions and includes bounded 1-retry self-healing.(Note: Promise<void> generates a Query to await completion; use #[command] to discard the promise and dispatch fire-and-forget). |
| Watcher | /** #[watcher] */function watchEvents(...args, emit): () => void |
Strictly Explicit (#[watcher] required in doc comments or Rust macro) |
pub fn watch_events(..., emit) -> WatcherGuard |
Continuous event streams (e.g. ResizeObserver, pointer events). Generates a free function returning a unified WatcherGuard lifecycle handle that automatically executes the returned cleanup closure on Rust Drop or .stop(). |
Quick Start
1. Author your TypeScript module
Create a .ts file alongside your component (e.g., src/browser/dom.ts):
/**
* Command: Synchronous fire-and-forget DOM action (inferred from void return)
*/
export function focusElement(elementId: string): void {
document.getElementById(elementId)?.focus();
}
/**
* Query: Asynchronous measurement returning typed data (inferred from Promise return)
*/
export async function measureElement(
elementId: string
): Promise<[number, number, number, number] | null> {
const el = document.getElementById(elementId);
if (!el) return null;
const rect = el.getBoundingClientRect();
return [rect.left, rect.top, rect.width, rect.height];
}
/**
* Watcher: Continuous event subscription with cleanup
* #[watcher] -- MANDATORY: Watchers are NEVER auto-inferred
* (must be declared in JS/TS doc comments or in Rust bind_js!)
*/
export function watchResize(
elementId: string,
emit: (dimensions: { width: number; height: number }) => void
): () => void {
const el = document.getElementById(elementId);
if (!el) return () => {};
const observer = new ResizeObserver(([entry]) => {
emit({
width: entry.contentRect.width,
height: entry.contentRect.height,
});
});
observer.observe(el);
// Return cleanup closure: automatically called when Rust watcher drops
return () => {
observer.disconnect();
};
}
Note on JavaScript: While TypeScript is the recommended first-class authoring language, pure untyped
.jsfiles are also supported via fallback inference. See Untyped JavaScript Fallback below.
2. Bind the module in Rust
In your Rust module or component file:
use bind_js;
use ;
// Bind all exported functions from TypeScript:
bind_js!;
Or selectively import with renaming:
bind_js!;
3. Use in Dioxus Components
use *;
use use_watcher;
Command vs. Query: Choosing the Right Interaction Model
| Feature | Command (pub fn) |
Query (pub async fn) |
|---|---|---|
| Execution | Synchronous, fire-and-forget | Asynchronous bidirectional RPC |
| Return Value | () (no return value) |
Result<T, JsError> (strongly-typed deserialized data) |
| Boilerplate | Zero (.await or spawn not needed) |
Requires .await (and spawn inside UI event handlers) |
| Error Handling | Isolated inside browser try-catch |
Explicit Rust Result handling with bounded 1-retry self-healing |
| Best Used For | DOM mutations (focus(), scroll(), class changes) |
Measurements (getBoundingClientRect()), data retrieval |
- Commands offer maximum developer ergonomics for fire-and-forget UI operations without async boilerplate.
- Queries provide type-safe asynchronous data fetching with structured error handling and bounded self-healing.
- Why
spawnis used for Queries in event handlers: In Dioxus, UI event listeners (onclick,oninput, etc.) accept synchronous closures (FnMut). Because you cannot directly.awaitinside a synchronous closure, any asynchronous RPC call (such as a Query) triggered by a user click must be scheduled inside a background task viaspawn(async move { ... }). Outside event handlers (such as insideuse_resource(move || async move { ... })), queries can be awaited directly withoutspawn.
How use_watcher Works
-
Signal Read Dependencies vs. Callback Writes:
- Only signals read inside the factory closure (
element_id()) are registered as reactive dependencies that rebind the watcher. - Signals written inside the event callback closure (
dimensions.set(...)orscroll_y.set(...)) do not re-triggeruse_watcher. - High-Performance Continuous Streaming: Continuous browser events (such as scrolling, mouse movements, or resizing) do not tear down or recreate the listener on every frame. The browser listener is attached once and simply invokes the Rust callback closure with incoming stream payloads.
- Only signals read inside the factory closure (
-
Declarative On/Off with
Option<WatcherGuard>:- Return
Some(watch_*(...))to activate the watcher subscription. - Return
Noneto deactivate or pause the subscription (for example, when a dialog or popover is closed:if !is_open() { return None; }). If an existing watcher was active, returningNoneimmediately drops it and executes browser cleanup.
- Return
-
Zero-Leak Drop Lifecycle:
- Whenever a tracked dependency signal changes (e.g.
element_idchanges from"box-1"to"box-2"), the previously activeWatcherGuardis automatically droppedβwhich dispatches the browser cleanup closureβbefore the new watcher is initiated on the new element. - When the component unmounts from the DOM, the watcher guard drops and cleanly terminates the browser observer, preventing memory leaks and orphaned event listeners.
- Whenever a tracked dependency signal changes (e.g.
-
Dependency Isolation &
.peek()Best Practice:- Because
use_watcherwrapsuse_effect, any signal read (signal()or.read()) inside thefactoryclosure is registered as a reactive dependency. - Rule of Thumb: Only read signals that dictate the watcher's lifecycle boundary (such as the target element ID
element_id()or the activation gateis_open()). - Avoid Unrelated UI State: Reading unrelated state (such as input text, hover counters, or search queries) inside the
factoryclosure causes the browser watcher to disconnect, run teardown cleanup, and re-attach on every single keystroke! - Non-Reactive Reads via
.peek()(Dioxus Alternative tountrack): Unlike frameworks that provide a closure-wideuntrack(|| { ... })function (e.g. Leptos, Solid), Dioxus utilizes.peek()at the individual signal level. Callingsignal.peek()reads the underlying value without registering a subscription on the current reactive context.
// β Anti-pattern: Unintended Re-subscriptions on Unrelated State let mut search_query = use_signal; let is_tracking = use_signal; use_watcher; // β Best Practice: Read Only Lifecycle Triggers; Isolate Other State with `.peek()` use_watcher; - Because
3-Tier Classification & Annotation Rules
The procedural macro classifies each exported function using a strict 3-Tier Precedence Hierarchy:
Tier 1: Macro Invocation Attributes (#[command], #[query], #[watcher])
β (Overrides everything)
βΌ
Tier 2: TypeScript Doc Comment Attributes (/** #[command] */, etc.)
β (Overrides AST inference)
βΌ
Tier 3: AST Return Type Inference (void -> Command, Promise/val -> Query)
β
βββΊ Note: Watcher is NEVER inferred at Tier 3!
Classification Precedence Table
| Precedence | Source | Example | Rules |
|---|---|---|---|
| Tier 1 (Highest) | Rust bind_js! Macro Item |
#[watcher] watch_fn#[query] custom_rpc |
Takes absolute precedence over TypeScript comments and AST inference. |
| Tier 2 | TypeScript JSDoc / Comment | /** #[watcher] */// #[command] |
Declared directly above the exported function in .ts/.js. |
| Tier 3 (Lowest) | AST Return Type Inference | function foo(): voidasync function bar(): Promise<T> |
- void or no return $\rightarrow$ Command- Promise<T> or concrete return value $\rightarrow$ Query- Watcher is NEVER inferred. |
Invariant Rules
- Higher-Order Functions: If an exported function returns a function (
() => void) but lacks an explicit#[watcher]attribute (at Tier 1 or Tier 2), compilation halts immediately with an error:error: Higher-order functions returning functions are not supported in bind_js!. Return concrete serializable data or use a Watcher callback (annotated with #[watcher]). - Callbacks: A Watcher must take an
emit: (payload: T) => voidcallback and return a cleanup closure() => void.
Concrete Classification Examples
1. TypeScript Declarations (browser/tools.ts)
// Tier 3 (Auto-inferred Command): void return -> synchronous Command
export function scrollWindow(x: number, y: number): void {
window.scrollTo(x, y);
}
// Tier 3 (Auto-inferred Query): Promise return -> asynchronous Query
export async function fetchDocumentTitle(): Promise<string> {
return document.title;
}
// Tier 2 (Doc Comment Override): Overrides Promise<void> to generate a synchronous Command
// Note: Return value (the Promise) is completely discarded (fire-and-forget dispatch)
/** #[command] */
export async function trackAnalytics(event: string): Promise<void> {
await fetch("/api/log", { method: "POST", body: event });
}
// Tier 2 (Doc Comment Override): Promotes synchronous void to an asynchronous Query
// Useful when Rust needs to await DOM readiness or capture browser JS exceptions
/** #[query] */
export function validateElementMounted(id: string): void {
if (!document.getElementById(id)) {
throw new Error(`Element #${id} not found in DOM`);
}
}
// Tier 2 (Doc Comment Watcher): Explicitly designated continuous event stream
/** #[watcher] */
export function watchWindowScroll(emit: (scrollY: number) => void): () => void {
const handler = () => emit(window.scrollY);
window.addEventListener("scroll", handler);
return () => window.removeEventListener("scroll", handler);
}
// Unannotated function returning a closure:
// β οΈ WARNING: If targeted in bind_js! without #[watcher], compilation FAILS with a higher-order function error!
export function customResizeListener(el: HTMLElement, emit: (w: number) => void): () => void {
const ro = new ResizeObserver(([e]) => emit(e.contentRect.width));
ro.observe(el);
return () => ro.disconnect();
}
2. Rust Binding with Tier 1 Overrides (src/lib.rs)
bind_js!;
Macro Import Syntax & Identifier Matching
The bind_js! macro supports flexible path resolution and identifier mapping:
1. Path Resolution
Paths are specified relative to CARGO_MANIFEST_DIR of the invoking crate:
bind_js!;
2. Wildcard vs. Selective Imports
- Wildcard (
*): Generates Rust bindings for every exported function in the file. - Selective Import: Generates bindings only for specified exports:
bind_js!;
3. Renaming (as)
Rename exported JavaScript functions into custom Rust identifiers:
bind_js!;
4. Automatic Casing Normalization
JavaScript conventions favor camelCase, while Rust conventions require snake_case. The macro automatically reconciles casing across all binding and renaming scenarios within a single import block:
// TypeScript (dom.ts)
export function focusElement(elementId: string): void;
export function hideElement(elementId: string): void;
export async function measureElement(elementId: string): Promise<Rect>;
export async function calculateOffset(elementId: string): Promise<number>;
bind_js!;
5. Private Declarations & Module State
Non-exported functions, internal classes, top-level constants, and module-scoped variables inside the .ts file are bundled transparently into the inlined JavaScript module. You can use private helpers freely:
// Private helper (not exported, not bound to Rust)
function computeOffset(el: HTMLElement): number {
return el.scrollTop + 10;
}
// Exported Command (bound to Rust)
export function scrollAdjusted(id: string): void {
const el = document.getElementById(id);
if (el) el.scrollTop = computeOffset(el);
}
Compilation Diagnostics & Unsupported Patterns
To guarantee predictable runtime behavior and avoid hidden build-pipeline dependencies, dioxus-js-bindgen rejects unsupported JavaScript constructs at compile time with actionable diagnostics:
1. Top-Level Static Imports
- β Disallowed:
import { computePosition } from "@floating-ui/dom"; // Compile Error! - π Diagnostic:
error: Top-level static import is not supported in v1 bindable files. Use preloaded globals (window.*) or dynamic import() inside an async query. - β
Recommended Alternatives:
- Global Preload: Load external scripts via CDN or
<script>tags inindex.html, and access them viawindow.FloatingUIDOM. - Dynamic Import: Use dynamic
await import(...)inside an asynchronous Query:export async function positionDropdown(anchorId: string, menuId: string): Promise<void> { const { computePosition } = await import("https://esm.sh/@floating-ui/dom"); // ... }
- Global Preload: Load external scripts via CDN or
2. Unannotated Higher-Order Functions
- β Disallowed: Returning functions without
#[watcher]annotation on binding targets. - π Diagnostic:
error: Higher-order functions returning functions are not supported in bind_js!. Return concrete serializable data or use a Watcher callback (annotated with #[watcher]). - π― Target Scope & Non-Target Behavior:
- Private / Internal Functions: Non-exported helper functions returning functions inside the
.ts/.jsfile (e.g. curried functions or event factory closures) are never checked. They bundle transparently into the module's private JavaScript scope. - Wildcard Mode (
*): When usingbind_js!("file.ts"::*), all exported functions in the module become binding targets and are strictly validated. - Selective Mode (
::{ ... }): When selectively binding specific functions, only functions explicitly requested in the import list are validated. If the file exports unrelated higher-order functions that are not in your Rust import list, they are safely ignored and do NOT trigger a compilation error.
- Private / Internal Functions: Non-exported helper functions returning functions inside the
- β
Fix: If the target function is an event subscription, annotate it with
/** #[watcher] */. If it is a utility, return concrete serializable data.
3. Default Exports
- β Disallowed:
export default function main() { ... } // Compile Error! - π Diagnostic:
error: Default exports are not supported in bind_js!. Use named exports ('export function name()') to avoid Rust identifier conflicts. - β
Fix: Always use named exports (
export function myFunction() { ... }).
Runtime Architecture & Fault Tolerance
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Compile-Time (Proc Macro) β
β "dom.ts" βββΊ [swc_core AST] βββΊ [Type Stripper] β
β β β β
β [Classification & Diagnostics] β Inlined JS β
β β β β
β Rust AST βββ [Rust Codegen Engine] βββββ β
βββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Runtime Execution Model β
β β
β Rust Side: β
β βββ static MODULE_LOADED_EPOCH: AtomicU64 β
β βββ Command: pub fn(...) -> () β
β βββ Query: pub async fn(...) -> Result<T, JsError> β
β βββ Watcher: pub fn watch_<name>(...) -> WatcherGuardβ
β βββ use_watcher(FnMut() -> Option<WatcherGuard>) β
β β
β IPC Boundary (dioxus::document::eval) β
β β
β Browser Side: β
β βββ window.__DIOXUS_BINDGEN_MODULES__["{HASH}"] β
β βββ window.__DIOXUS_WATCHERS: Map<sub_id, cleanup> β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Zero-Build In-Memory SWC Pipeline
During compilation, bind_js! uses swc_core to:
- Parse the TypeScript AST into memory.
- Validate signatures and enforce diagnostic invariants.
- Strip type annotations, interfaces, and type aliases.
- Hash the source file content deterministically.
- Invert the module into an isolated IIFE that registers its exports into
window.__DIOXUS_BINDGEN_MODULES__["{HASH}"].
Hybrid Lazy Loader & Double-Epoch IPC Cache
To avoid re-evaluating JavaScript code on every function call:
- Rust Load Epoch: Each generated module contains a
static MODULE_LOADED_EPOCH: AtomicU64 = AtomicU64::new(0). - Fast Path (~0ns): On invocation, Rust compares
MODULE_LOADED_EPOCHwithdioxus_js_bindgen::internal::current_epoch(). If they match, the module is known to be loaded in the browser, and dispatch proceeds immediately without initialization overhead. - Slow Path: If epochs differ (first run or after a global reset), Rust evaluates the module bundle in the browser and updates the atomic epoch.
- Global Cache Invalidation: Calling
dioxus_js_bindgen::clear_js_cache()(or legacy aliasreset_module_registry()) increments the global epoch and clears the browser module cache. Note that this invalidates the module bundle evaluation cache so modules re-evaluate on next invocation; it does not automatically terminate active watchers or reset individual subscription lifecycle state (which remains owned byWatcherGuard).
Asymmetric Recovery & Bounded Self-Healing
If the browser context loses module state (e.g. following full-page navigation or hot reload):
- Query Self-Healing: If a query receives a
MODULE_NOT_FOUNDsignal from the browser, it resetsMODULE_LOADED_EPOCH, re-injects the module bundle, and retries the query at most once. If it fails a second time, it returnsJsError::ModuleUnavailable. - Command Best-Effort: Commands log a
console.warnin the browser and return immediately without blocking the Rust thread or crashing the UI. - Watcher Resilience: Watchers reset the local load epoch upon encountering an unloaded module to ensure subsequent rebind attempts succeed.
Watcher Wire Protocol & Idempotent Cleanup
- Subscription Registration: Calling
watch_x(..., emit)generates a globally unique 64-bitsubscription_idand returns aWatcherGuard. - Browser Storage: The JavaScript watcher factory runs and places its cleanup closure into
window.__DIOXUS_WATCHERS.set(sub_id, cleanup). - Continuous Streaming: The browser watcher invokes
emit(payload)whenever events occur, transmitting serialized JSON to Dioxus. - Deterministic Teardown: When the Rust watcher handle is dropped (or
.stop()is called):- The background Dioxus task is cancelled.
- A synchronous teardown eval is dispatched:
const cleanup = window.__DIOXUS_WATCHERS?.; if - Teardown is 100% idempotent: subsequent calls or drops are safe no-ops.
Reactive use_watcher Hook
Standard Dioxus hooks like use_hook require Clone, which directly conflicts with RAII cleanup structs. dioxus-js-bindgen provides use_watcher, a pure reactive hook wrapping use_effect:
- Reactive Dependency Tracking: Any Dioxus signals read inside
factory()are registered as reactive dependencies. - Dynamic Rebinding: When a signal changes (e.g.
is_opentoggles orelement_idupdates),use_effectre-runs:- Setting
new_watcherautomatically drops the previous watcher struct, immediately firing the browser cleanup. - If
factory()returnsNone, no new watcher is created.
- Setting
- Unmount Safety: When the host component unmounts,
current_watcherdrops, cleaning up all browser event listeners automatically.
Type System & Serialization Contract
1. Primitive & Built-in Mappings
| TypeScript / JavaScript | Rust Parameter Type | Rust Return Type | Serialization |
|---|---|---|---|
void / no return |
N/A | () |
Synchronous Command |
string |
&str |
String |
UTF-8 JSON string |
number |
f64 |
f64 |
Serde JSON number |
boolean |
bool |
bool |
Serde JSON boolean |
T[] or Array<T> |
&[T] |
Vec<T> |
JSON array |
T | null | undefined |
Option<T> |
Option<T> |
Nullable JSON value |
[A, B, ...] |
(A, B, ...) |
(A, B, ...) |
Fixed-size tuple |
Promise<T> |
N/A | Result<T, JsError> |
Asynchronous Query RPC |
(event: T) => void |
impl FnMut(T) + 'static |
WatcherGuard |
Event subscription stream returning unified lifecycle guard |
any / untyped JS |
serde_json::Value |
serde_json::Value |
Arbitrary JSON AST |
2. User-Defined Structures & Discriminated Unions
The macro does not synthesize Rust struct or enum definitions from TypeScript interfaces. Instead, the consumer authors the corresponding Rust types in scope with #[derive(Serialize, Deserialize)]:
Object Interfaces
export interface ElementRect {
x: number;
y: number;
width: number;
height: number;
}
export async function getRect(id: string): Promise<ElementRect> { ... }
bind_js!;
Discriminated Unions (Tagged Enums)
export type DismissEvent =
| { kind: "pointer_down"; path_ids: string[] }
| { kind: "escape" };
Error Taxonomy & Diagnostic Handling
All asynchronous queries return Result<T, JsError>. The JsError enum provides exhaustive categorization of browser and transport failures:
Comprehensive Matching Example
match get_bounding_box.await
Headless Testing & Drop Safety
In non-browser environments (such as unit tests running via cargo test on desktop/server targets):
- Runtime Presence Checks: Functions check
dioxus::core::Runtime::try_current()before attempting IPC dispatch. - Unwind Protection: Watcher
Dropand cleanup dispatch are wrapped instd::panic::catch_unwind, preventing teardown panics when dropping watcher handles outside of an active Dioxus thread. - Test Isolation:
dioxus_js_bindgen::clear_js_cache()(or legacy aliasreset_module_registry()) can be safely invoked between tests to invalidate module evaluation caches cleanly.
Untyped JavaScript Fallback
While dioxus-js-bindgen is architected as TypeScript-first for zero-cost compile-time type extraction, you can also bind pure untyped .js files. When binding JavaScript files without TypeScript types:
- Parameter Fallback: Because plain JavaScript lacks static parameter annotations, parameters fall back to
serde_json::Value:
Generates:// src/browser/tools.js export; - Return Types: Functions returning unannotated dynamic values default to Query
Result<serde_json::Value, JsError>. - Watcher Annotation: Watchers in
.jsmust declare#[watcher]explicitly in JSDoc or viabind_js!macro invocation attributes:/** * #[watcher] */ export - Best Practice: Prefer TypeScript (
.ts) whenever possible to obtain idiomatic, strongly-typed Rust signatures (&str,f64,bool,&[T],Vec<T>).
License
Dual-licensed under either of:
- Apache License, Version 2.0 (LICENSE-APACHE)
- MIT license (LICENSE-MIT)
at your option.