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

How an nsh app runs: the nsh CLI builds App.tsx into bytecode; on the engine thread, QuickJS runs a handler, the reactive graph in nsh-js queues ops, and nsh-core commits one MountBatch; it crosses to the main thread in one hop, which applies it to UIKit or Android views; touches go back as events. Native animations run on the main thread without JavaScript. How an nsh app runs: the nsh CLI builds App.tsx into bytecode; on the engine thread, QuickJS runs a handler, the reactive graph in nsh-js queues ops, and nsh-core commits one MountBatch; it crosses to the main thread in one hop, which applies it to UIKit or Android views; touches go back as events. Native animations run on the main thread without JavaScript.

#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):

  1. Oxc removes the TypeScript types.
  2. 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.
  3. 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.
  4. 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 .tsx lines.

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/animated are driven frame by frame (a CADisplayLink on iOS, a UI-thread animator on Android) and written to the views directly;
  • gestures and scrolling: touches become events sent to the engine thread; scrollValue and pan feed 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:

  1. prepare: a <Text> and its nested spans become one attributed string (CoreText on Apple platforms, PrecomputedText / StaticLayout on Android);
  2. measure at the widths layout asks for; measurements are cached by content and width, so a color-only change does not measure again;
  3. 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: fetch and 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:

CrateWhat it is
nsh-coreThe 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-mobileThe components and modules of nsh/ui/mobile (Button, Card, SafeAreaView, Alert, …), written in Rust.
nsh-animThe native animation graph: values, interpolation, timing / spring / decay.
nsh-jsThe 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-engineThe engine thread: drives nsh-js and nsh-core and hands one batch per frame to the platform.
nsh-packageSigned packages: keys, certificates, revocation, the trust store.
nsh-bundlerThe bundler: rolldown, Oxc, nsh’s JSX compiler, source maps, Tailwind class checks.
nsh-devThe dev server and its client.
nsh-platform-iosThe UIKit backend (iOS, simulator, Mac Catalyst): view mounting, the CoreText text engine, input; the embedding SDK.
nsh-platform-androidThe Android backend: libnsh.so and the Java runtime (NshView, the views, images, networking).
nsh-cliThe 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.

#See also