Guide
Architecture
This page is for app developers who want to know what happens between count.set(1) and a label changing on screen.
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.
#The big picture
#Building: only your code becomes JS
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/list,nsh/ui/mobile, …) 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 QuickJS bytecode (
bundle.qbc), 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.
#Running: two threads per app
Each running app (an NshAppView on iOS, an NshView on Android, the window of a packaged app) is one engine with
its own QuickJS VM and its own engine thread. There are no global singletons, so several apps can run side by side
in one host app.
#The engine thread
Everything about your app’s logic happens here:
- QuickJS runs your code: components, event handlers, timers, effects.
- 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/ui/mobile), 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 main thread
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: values from
nsh/animatedare 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 native 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.
#From a tap to the screen
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 with nsh trace).
Three properties follow from this design:
- One JS 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: laid out once, off the main thread
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.
#Lists
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.
#Everything else the runtime does
- Networking:
fetchand the other web 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).
#The crates
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 Rust components’ framework and theme. |
nsh-ui-mobile | The components and modules of nsh/ui/mobile (Button, Card, SafeAreaView, Alert, …), written in Rust. |
nsh-anim | The native animation graph: values, interpolation, timing / spring / decay. |
nsh-js | The QuickJS host and nsh’s runtime: the reactive graph, the renderer, every native module, the web 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. The core depends on no platform and no JS
engine, which is what lets nsh trace run the real runtime headless on any machine.