Guide
Testing with nsh trace
nsh trace runs an app headless: the real runtime (the same QuickJS VM, reactive graph, renderer, components,
layout and list virtualization as on a device), but no simulator, no device and no window. Instead of drawing, it
prints every operation the runtime would send to the native views, with timings. It runs anywhere the CLI runs,
including Linux CI machines.
nsh trace App.tsx
Use it to:
- check that an app (or a demo) builds, evaluates and renders without errors;
- see exactly which native views a change creates, moves or updates, and how many;
- measure the cost of the first frame, of a tap and of each scrolling frame;
- catch regressions in CI.
#What it does
- Bundles the entry (development settings, not minified) and compiles it to bytecode.
- Starts an engine with a 390×844 viewport and renders the first frame.
- Optionally scrolls the first scroll view or list.
- Waits while the app is idle (by default 1.2 s, so the engine’s idle garbage collection runs first and retention bugs show up), printing whatever the app renders meanwhile (timers, fetches, animations).
- Taps the first pressable view (the first view with
onPress) a number of times and prints what each tap changes.
| Flag | Default | Description |
|---|---|---|
--taps <n> | 2 | Taps to send to the first pressable view. |
--idle-ms <ms> | 1200 | Idle time before tapping. |
--scroll <points> | none | Scroll the first scroll view or list by this many points, in 10 pt frames, printing each frame’s batch. |
--width <points> | 390 | Viewport width. |
--height <points> | 844 | Viewport height. |
#Reading the output
Here is the counter example (examples/counter/App.tsx), shortened:
bundled in 14.3 ms (3255 B)
info nsh::perf commit: 56 ops, 395µs (list js 6µs), 49 mount items, 21 nodes
info nsh::perf first frame: eval 271µs, total 1235µs
── initial render: 49 items
LayoutDirection { rtl: false }
Create { id: 2, kind: View }
Create { id: 4, kind: Text }
Insert { parent: 2, child: 4, before: None }
...
Events { id: 20, events: EventMask(1) }
Frame { id: 2, x: 0, y: 0, w: 390, h: 844 }
Frame { id: 4, x: 24, y: 64, w: 342, h: 34 }
...
Text { id: 4, text: "nsh counter", content: 342x34 @ 0,0 }
Text { id: 14, text: "0", content: 36x77 @ 0,0 }
info nsh::perf event Press on view 20
info nsh::perf event js: 196µs
info nsh::perf commit: 10 ops, 248µs (list js 6µs), 8 mount items, 24 nodes
── tap #1 on view 20: 8 items
Create { id: 42, kind: Text }
Insert { parent: 32, child: 42, before: None }
Frame { id: 14, x: 136, y: 49, w: 71, h: 77 }
Frame { id: 32, x: 24, y: 366, w: 342, h: 43 }
Frame { id: 42, x: 0, y: 23, w: 342, h: 20 }
Text { id: 14, text: "-1", content: 71x77 @ 0,0 }
Text { id: 42, text: "• -1", content: 342x20 @ 0,0 }
Text { id: 34, text: "Recent values: 1", content: 342x17 @ 0,0 }
#Batches and mount items
Each ── title: N items block is one mount batch: everything the platform applies to native views for one frame,
in one hop to the main thread. The lines in it are mount items:
| Item | Meaning |
|---|---|
Create { id, kind } | Create a native view (View, Text, Image, Scroll, …). |
Insert { parent, child, before } | Put a view into its parent, before a sibling (None: at the end). |
Remove / Delete | Detach a view from its parent / free it. |
ViewStyle { id, style } | The view’s decoration: background, borders, radius, shadow, opacity, transform, … |
ViewProps { id, props } | Touch and accessibility settings, testID. |
Events { id, events } | Which events the view reports (EventMask(1) is press). |
Frame { id, x, y, w, h } | Its position and size in its parent, in points. |
Text { id, text, content } | Laid-out text for a text view. |
Image { id, source, ... } | An image to load. |
Only what changed is sent: a tap that changes one number sends one Text item; views get no default style and no empty
event mask; unchanged frames are not sent. A frame of scrolling that reveals no new row produces no batch at all.
#The nsh::perf lines
| Line | Meaning |
|---|---|
bundled in 14.3 ms (3255 B) | Bundling time and bundle size (development settings). |
commit: 56 ops, 395µs (list js 6µs), 49 mount items, 21 nodes | One commit: the operations JS queued, the time the core spent (rendering components, laying out, diffing), JS time spent binding list rows, the mount items produced, and the nodes alive. |
first frame: eval 271µs, total 1235µs | Time to evaluate the bundle, and from start to the first batch. |
event Press on view 20 / event js: 196µs | An event dispatched, and the time its handler (and what it caused) took in JS. |
Single-run timings depend a lot on the CPU’s clock state (a machine that just woke from idle is slow). Compare runs on the same machine, look at the minimum of several runs, and trust the op and item counts more than the microseconds.
#Scrolling
nsh trace examples/gallery/Bench.tsx --scroll 3000 --taps 0
Scrolls the first <Scroll> or List 10 points per frame and prints each frame’s batch:
info nsh::perf commit: 26 ops, 297µs (list js 111µs), 22 mount items, 233 nodes
── scroll to 20: 22 items
Create { id: 448, kind: View }
...
Text { id: 456, text: "Photo #25", content: 298x20 @ 0,0 }
For a list, this shows the virtualization at work: which frames bind new rows, how many operations a row
costs, and that frames revealing no new row produce nothing. Use it with a release build of the CLI
(./target/release/nsh in a checkout) for meaningful timings.
#Taps
nsh trace taps the first view with onPress in the first frame. Design a trace-friendly entry by putting the
interaction you want to measure first, or write a small entry file for it:
// trace/AddItem.tsx: renders the screen and puts the button under test first
import { mount } from "nsh";
import { Cart } from "../src/Cart";
mount(() => <Cart initialItems={[]} />);
nsh trace trace/AddItem.tsx --taps 5
If a tap produces no update, nsh trace stops with Error: tap #1 produced no update and a non-zero exit code: that
is how it catches handlers that stopped working, for example because the app’s nodes were garbage-collected.
#In CI
nsh trace needs nothing but the CLI, so it runs on Linux runners. Errors in the app (a failed bundle evaluation, an
uncaught exception) are printed as error lines; a build failure, an app that renders nothing, or a tap that changes
nothing make it exit with a non-zero code. Check both:
#!/bin/sh
set -e
for entry in App.tsx trace/*.tsx; do
out=$(nsh trace "$entry" --idle-ms 300 2>&1) || { echo "$out"; exit 1; }
if echo "$out" | grep -E '^ *error' >/dev/null; then
echo "$out"
echo "errors in $entry"
exit 1
fi
done
# .github/workflows/trace.yml
jobs:
trace:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: ./scripts/trace.sh
(Install the nsh CLI on the runner the way you install it locally; see Installation.)
To guard performance, record the item counts of a few interactions (the ── … : N items lines and the ops /
mount items numbers) and fail when they grow. Counts are deterministic; timings on shared CI machines are not.
#testID for UI tests
nsh trace checks what the runtime renders. To drive the real native UI with the platform’s test tools (XCUITest,
Espresso / UI Automator, Appium, Maestro), give views a testID:
<View testID="add-to-cart" onPress={add} style={{ padding: 12 }}>
<Text testID="cart-count">{`${count()} items`}</Text>
</View>
| Platform | Where testID goes |
|---|---|
| iOS | The view’s accessibilityIdentifier (app.otherElements["add-to-cart"] in XCUITest). |
| Android | The accessibility node’s view id resource name (By.res("add-to-cart") in UI Automator). |
testID is a prop of every element and of the components in nsh/ui/mobile. It does not change what screen readers
say; use accessibilityLabel for that (see Accessibility). In nsh trace output it appears in
the view’s ViewProps item as test_id: Some("add-to-cart").
#See also
- Performance: what to look for in these numbers
- Dev server and debugging
- Architecture: ops, commits and mount batches