Skip to content

State & Hot Reload

Status: landed, with one required convention for component files.

The one rule: import hooks from @nativedesktop/react

Section titled “The one rule: import hooks from @nativedesktop/react”

ND_DEV=1 runs the Bun child under bun --hot, which keeps the same OS process and NDP socket across an edit — but re-evaluates the entire module graph on every edit, react and react-reconciler included. A hook imported straight from react resolves against a fresh, re-evaluated module instance whose dispatcher was never attached to any reconciler — that’s the Invalid hook call: resolveDispatcher().useState crash this convention exists to avoid.

packages/react/src/dev-react.ts stashes the first-eval react module instance in globalThis and re-exports its hooks through wrapper functions that always dispatch to that stashed instance. The reconciler created on first boot closes over that same first-eval react instance, so a hook resolved through @nativedesktop/react always talks to the dispatcher the live reconciler actually drives.

import { useState, useEffect, useMemo } from "@nativedesktop/react"; // correct, every time

This rule is required for .tsx/.desktop.tsx component files. Shared, platform-agnostic hooks (also consumed by web/React Native code in the same monorepo) are the one exception: they can be authored the normal way, import { useState } from "react", in a plain .ts module — babel-plugin-nativedesktop rewrites that import to @nativedesktop/react automatically, both for bun run compile and for bun --hot (via a bunfig.toml-preloaded Bun plugin). The Bun dev-path rewrite only touches .ts files, never .tsx/.desktop.tsx — intercepting a component file in Bun’s onLoad would drop it from --hot’s watch set and break its hot reload — so a shared .ts hook is pinned at first eval (editing it needs a host restart) while its .tsx consumers keep hot-reloading normally. Component files still must import hooks from @nativedesktop/react directly per the rule above.

What actually preserves state across an edit

Section titled “What actually preserves state across an edit”

render()’s hot-reload path doesn’t call updateContainer again on re-eval — a fresh <App/> element’s .type is a new function reference every re-eval, which the reconciler would otherwise treat as a type change and fully remount. Instead it calls hotUpdateRoot(), which re-registers the new component type under the same react-refresh family key the first boot established, and asks react-refresh to patch the live fiber tree in place. react-refresh’s family/root registries are also pinned to the first-eval module instance via the same globalThis stash — no Bun-level module aliasing needed, since react-refresh has no internal require() calls of its own to trip over.

Two alternative mechanisms were tried and rejected before landing on the above (recorded so nobody re-attempts them): Bun.plugin’s module aliasing throws Requested module is already fetched the moment an aliased specifier is touched by both ESM import and CJS require() — which react-reconciler’s bundled CJS does internally for "react", unavoidably, since every app entry uses genuine ESM import.

React Compiler: opt-in, working, off by default

Section titled “React Compiler: opt-in, working, off by default”

Status: landed (opt-in). babel-plugin-react-compiler@1.0.0 runs cleanly as a build pre-pass and the compiled output runs correctly against @nativedesktop/react. It’s a pre-pass, not inline, because Bun’s runtime transpiler doesn’t run Babel plugins and bun --hot re-evaluates modules through Bun’s own transpiler only. The compile step runs three Babel plugins in one pass: babel-plugin-react-compiler (the memoization transform), @babel/plugin-transform-react-jsx (JSX → @nativedesktop/react/jsx-runtime calls, chosen deliberately so the compiled output contains no JSX syntax left for Bun to pragma-select on), and babel-plugin-nativedesktop (the hook-import rewrite above, applied to every extension in the compiled path).

bun run dev/nd dev (ND_DEV=1 + --hot) still points at uncompiled src/, so hot reload and react-refresh are unaffected by whether the compiler is enabled. Use the compiled path for a production-style run:

Terminal window
bun run compile && ND_SCRIPT=dist/main.tsx ./zig-out/bin/nd-hello