| title | Architecture |
|---|---|
| description | How an NSH app runs: the build pipeline, the engine and main threads, the path from a signal change to native views, the native reactive graph, text layout and the crates. |
| order | 63 |
| group | tools |
This page is for app developers who want to know what happens between count.set(1) and a label changing on screen,
in an app written in Rust or in TSX.
You do not need it to write apps, but it explains why NSH behaves the way it does: why components run once, why
animations stay smooth while JS is busy, why the main thread never waits for your code.
nsh build, nsh dev and the other commands bundle your app with a toolchain written in Rust (no Node.js):
- Oxc removes the TypeScript types.
- NSH's JSX compiler turns JSX into direct calls to the native renderer, in the same pass. Static props and children are set once; expressions that can change (they call a function or read a property) become bindings the renderer follows. Components become plain function calls with a props object whose changing props are getters.
- rolldown bundles your modules into one ES module. Imports of NSH's modules (
nsh,nsh/mobile,nsh/base, ...) stay imports: those modules are native, served by the runtime. The bundle contains only your app's code; the counter example is under 1 KB of JS. - The bundle is compiled to bytecode (
bundle.nshc), which loads faster than source, and a source map is composed across all steps so stack traces point at your.tsxlines.
Images and fonts you import are collected as assets, and type declarations for your editor are written to
.nsh/types.
An app written in Rust has no bundle: Cargo builds each target's runner with your crate linked in, and the root
component nsh.json names runs on the engine natively. Everything below holds for it as well, with your components'
code running in Rust where a TSX app's runs in QuickJS.
Each running app (an NshAppView on iOS, an NshView on Android, the window of a packaged app) is one engine with
its own engine thread, and, for TSX, its own QuickJS VM. There are no global singletons, so several apps can run side by side
in one host app.
Everything about your app's logic happens here:
- Your code runs here: components, event handlers, timers, effects. Rust components run natively; TSX runs in QuickJS.
- The reactive graph (signals, computed values, effects, owners, context) is native. Its nodes are QuickJS objects of native classes, so the garbage collector sees the references through them, but tracking dependencies and scheduling updates is Rust (it follows the algorithm of Solid 1.9).
- The renderer your compiled JSX calls is native. It creates nodes and turns every change into an op pushed straight into a native queue: no JSON, no messages per prop.
- The core keeps a shadow tree of all nodes. When a JS turn ends, it takes all queued ops at once and commits:
renders the Rust components (
nsh/base's and the app's own), resolvesenv()insets in padding and margin, rebuilds changed texts, lays out with Taffy (flexbox and grid, with a column default and no shrinking), diffs frames and styles, and produces one MountBatch: the list of operations the native views need.
The platform's main (UI) thread applies each MountBatch in one hop: create views, insert them, set styles and frames, hand over laid-out text. Besides that it only does what must happen on it:
- drawing text that was already laid out on the engine thread;
- native animations: animated values from
nshare driven frame by frame (aCADisplayLinkon iOS, a UI-thread animator on Android) and written to the views directly; - gestures and scrolling: touches become events sent to the engine thread;
scrollValueandpanfeed animated values in the same frame.
The main thread never waits for JS. When the system needs an answer on the spot (may this text change, may the back button pop this screen, may this sheet close), the answer was mounted beforehand as a prop, and JS hears about it afterwards through an event. A busy engine thread can delay updates, but it cannot freeze scrolling, animations or the system's gestures.
main thread engine thread main thread
─────────── ───────────────────────────────────────────────────── ───────────
tap on a view ─ event ─▶ handler runs: count.set(count() + 1)
reactive graph: the bindings that read count() run again
renderer: one op per changed binding → native queue
JS turn ends → core commit: render, text, layout, diff
→ MountBatch (e.g. 1 Text item) ─ hop ─▶ apply: draw the new text
In the counter example, a tap costs about 0.12 ms in a release build on a Mac, JS and commit together, and produces a handful of mount items (see Testing).
In a Rust app the handler is a Rust closure (count.set(count.get() + 1)), and the same graph, commit and batch
follow, with no interpreter on the way.
Three properties follow from this design:
- One turn is one commit. However many signals a handler sets, the views change once, together.
- Components run once. Only bindings re-run; there is no re-render of a component and no virtual DOM diff. The diffing that remains is the core's, over frames and styles, in Rust.
- Only changes travel. Unchanged styles and frames are not sent; new views get no default values.
Text is often the most expensive part of a UI. NSH lays it out on the engine thread with the platform's own text engine, so it looks exactly like native text:
- prepare: a
<Text>and its nested spans become one attributed string (CoreText on Apple platforms,PrecomputedText/StaticLayouton Android); - measure at the widths layout asks for; measurements are cached by content and width, so a color-only change does not measure again;
- layout at the final size.
The main thread receives the immutable result and only draws it. A text nested in a <Text> is a span, not a view;
strings outside <Text> render nothing.
List is virtualized natively. The core decides which rows are near the viewport, computes positions, columns and
sticky headers, and recycles cells. When a cell needs a different item, the runtime binds it (your row function's
item() and index() accessors change); frames that reveal no new row call no JS at all. See Lists.
- Networking:
fetchand the other global APIs are native classes. Requests go to the platform's HTTP stack (NSURLSession,HttpURLConnection), streaming in both directions. - Images download and decode off the main thread, at the size of the view, with a memory cache.
- Limits and capabilities are enforced by the runtime, in Rust: see Sandbox and capabilities.
- Hot module replacement is native too: patches are applied by the runtime in well under a millisecond (see Dev server and debugging).
NSH's UI is built in three layers, as GPUI Kit's is:
| Layer | NSH | GPUI Kit | What it is |
|---|---|---|---|
| Primitives | nsh-core and the runtime (nsh, nsh/mobile, nsh/desktop) |
gpui |
The host elements, styles, layout, rendering, reactivity, the environment |
| Base components | nsh-base (nsh/base) |
gpui-base |
Unstyled components that own behavior, state, focus, the keyboard and accessibility, and take their look from the app |
| Component library | your app's, or the community's | gpui-component |
Styled components: buttons, cards, rows with a look |
The first two layers are Native Shell. The third is yours: an app builds its own component library on nsh/base and
the primitives, in TSX or as Rust components in its own crate, uses one the community publishes, or
styles its UI directly in the app.
NSH is a Rust workspace. You never build it to write apps (the distribution ships the CLI and prebuilt runtimes), but the names show up in logs and docs:
| Crate | What it is |
|---|---|
nsh-core |
The platform-independent core: shadow tree, styles, Taffy layout, text merging, list virtualization, mount items; CSS values and Tailwind; the native animation graph (values, interpolation, timing / spring / decay); the Rust components' framework and theme; the runtime's Rust modules (nsh/mobile's BackHandler, ...). |
nsh-base |
nsh/base, the unstyled base components (NSH's gpui-base): navigation, dialogs, controls and the status bar; Rust apps use it as nsh_base. |
nsh-js |
The QuickJS host and NSH's runtime: the reactive graph, the renderer, every native module, the global APIs, hot module replacement, limits; and the TypeScript declarations of all of it. |
nsh-engine |
The engine thread: drives nsh-js and nsh-core and hands one batch per frame to the platform. |
nsh-package |
Signed packages: keys, certificates, revocation, the trust store. |
nsh-bundler |
The bundler: rolldown, Oxc, NSH's JSX compiler, source maps, Tailwind class checks. |
nsh-dev |
The dev server and its client. |
nsh-platform-ios |
The UIKit backend (iOS, simulator, Mac Catalyst): view mounting, the CoreText text engine, input; the embedding SDK. |
nsh-platform-android |
The Android backend: libnsh.so and the Java runtime (NshView, the views, images, networking). |
nsh-cli |
The nsh command. |
The dependency direction is nsh-core ← nsh-js ← nsh-engine ← platforms; nsh-base builds on nsh-core. The core depends on no platform and no JS
engine, which is what lets nsh test run the real runtime headless on any machine.