Documentation Index
Fetch the complete documentation index at: https://mintlify.com/pocket-stack/pocketjs/llms.txt
Use this file to discover all available pages before exploring further.
Every reactive signal, every Tailwind class, every animation you write in PocketJS ultimately resolves to a handful of synchronous calls across a single narrow boundary: the ui.* op surface. This page documents that surface — what the ops are, why they are shaped the way they are, and the runtime model that makes a full JSX framework fit inside a 2 MB QuickJS worker on a 333 MHz PSP.
App code never calls these ops directly. View, Text, and Image together with the Solid/Vue Vapor renderer emit them on your behalf. This page is for host implementors, anyone writing a new backend, and developers debugging unexpected rendering behavior at the native layer.
The three rules
The whole contract is defined by three invariants that must hold on every host — PSP, PPSSPP, browser WASM, and headless Bun alike.
1. Mutation-only. Every op is a fire-and-forget mutation of the native tree. There are no queries that walk the tree, no getters that return children. The one read-shaped op, measureText, is a stateless convenience — layout still measures natively.
2. Synchronous. Each op is a single blocking FFI call. There is no command buffer, no async batching at this layer. The batching that matters happens one level up: Solid runs effects only for changed signals, so in steady state the boundary is silent.
3. The reconciler never reads across FFI. The renderer keeps a JS mirror tree of the native tree. Every structural read that Solid performs — parent, first child, next sibling, “is this a text node?” — is a plain JS object walk. Only writes cross the boundary.
These three rules together are what keeps a full reactive UI framework inside the PSP’s per-frame budget.
The op table
Signatures are authoritative from src/host.ts (HostOps interface) and spec/spec.ts (OP table). Handles are generation-tagged positive i32 node ids; 0 always means “none” (append anchor for insertBefore, clear target for setFocus).
Op codes are pinned in spec/spec.ts and shared by every host and the Rust core. They are append-only and never renumbered — code 0 is reserved as invalid/nop forever.
| # | Op | Signature | Notes |
|---|
| 1 | createNode | (type: i32) → id | type is a NODE_TYPE ordinal: 0 = view, 1 = text, 2 = image. Returns a fresh generation-tagged node id. |
| 2 | destroyNode | (id) → void | Destroys the whole subtree; frees its anim tracks; clears focus if the focused node was inside. |
| 3 | insertBefore | (parent, child, anchorOr0) → void | DOM move semantics: if child is already attached anywhere it is unlinked first (core tree + taffy + JS mirror). anchor = 0 appends. Silently no-ops past MAX_TREE_DEPTH (64). |
| 4 | removeChild | (parent, child) → void | Detaches but keeps the node alive — Solid may re-insert it this frame. The renderer sweep destroys it at frame end if still detached. |
| 5 | setStyle | (id, styleId) → void | styleId indexes the compiled style table. STYLE_ID_NONE (-1) clears back to default. Triggers transitions for the animatable old→new diff. |
| 6 | setProp | (id, propId: i32, value: f64) → void | One dynamic prop. propId is a PROP id from the spec; colors and enums pass their u32 bits as the number. |
| 7 | setText | (id, str) → void | UTF-8; text nodes only. Used at node creation. |
| 8 | replaceText | (id, str) → void | UTF-8; text nodes only. Solid universal calls this on reactive text updates. |
| 9 | uploadTexture | (buf, w, h, psm) → handle | Dimensions must be power-of-two and ≤ 512; psm is a PSM code. Bytes are copied and 16-byte aligned. Returns a 0-based texture handle (so texHandle < 0 is the clear sentinel, not 0). |
| 10 | setImage | (id, texHandle) → void | Binds a texture to an image node. texHandle < 0 clears. Also clears any active sprite binding. |
| 11 | animate | (id, propId, to: f64, durMs, easing, delayMs) → animId | from is the current resolved value. easing is an ENUMS.Easing ordinal. Returns an anim id. |
| 12 | cancelAnim | (animId) → void | Stops the track and freezes the current interpolated value as a dynamic override. |
| 13 | setFocus | (idOr0) → void | Applies the focus: style variant natively. 0 clears focus. Zero JS runs on focus change. |
| 14 | loadStyles | (buf) → void | Web/test hosts only. Feeds the compiled styles.bin to the core. On PSP the native pak walker feeds core directly from include_bytes! before JS eval. |
| 15 | loadFontAtlas | (buf) → void | Web/test hosts only. One call per baked font atlas blob. Same PSP bypass as loadStyles. |
| 16 | measureText | (str, fontSlot) → width | JS-side convenience returning width in px. Layout still measures natively inside the core. |
| 17 | setSprite | (id, atlas, frames, cols, step) → void | Binds an animated sprite atlas to an image node. atlas is an uploaded texture holding a cols-wide grid of frames cells; the core auto-plays it, drawing one cell every step vblanks. frames ≤ 0 clears. Zero per-frame JS — the frame index is derived deterministically from the vblank counter. |
The TypeScript interface that backs this table is:
export interface HostOps {
createNode(type: number): number;
destroyNode(id: number): void;
insertBefore(parent: number, child: number, anchorOr0: number): void;
removeChild(parent: number, child: number): void;
setStyle(id: number, styleId: number): void;
setProp(id: number, propId: number, value: number): void;
setText(id: number, str: string): void;
replaceText(id: number, str: string): void;
uploadTexture(buf: Uint8Array, w: number, h: number, psm: number): number;
setImage(id: number, texHandle: number): void;
setSprite(id: number, atlas: number, frames: number, cols: number, step: number): void;
animate(id: number, propId: number, to: number, durMs: number, easing: number, delayMs: number): number;
cancelAnim(animId: number): void;
setFocus(idOr0: number): void;
loadStyles?(buf: Uint8Array): void;
loadFontAtlas?(buf: Uint8Array): void;
measureText(str: string, fontSlot: number): number;
}
Prop value encoding
setProp and animate carry every value as one f64 on the wire. src/host.ts encodes the JS value per the prop’s kind (PROP_VALUE_KIND in the spec):
- f32 props (dimensions, scalars, degrees) pass through as-is.
- Color props travel as their
u32 ABGR bits (0xAABBGGRR — the PSP GE COLOR_8888 layout). A '#rgb', '#rrggbb', or '#rrggbbaa' string is parsed by parseHexColor with full-string hex validation, so #ff00zz throws rather than silently painting a prefix match.
- Int/enum props travel as their
u32 ordinal.
encodePropValue(prop, value) in src/host.ts is the single choke point. A non-numeric string for a non-color prop throws loudly rather than coercing silently.
Generation-tagged node ids
Node ids are not pointers and not plain indices. Each id packs a slot index and a generation counter:
id = (generation << ID_SLOT_BITS) | slot;
// ID_SLOT_BITS = 20 → slot occupies the low 20 bits (mask 0xFFFFF)
When a node is destroyed its slot returns to the core’s free list and its generation bumps. A stale id held by JS — say a handler that fires after its node was swept — decodes to a slot whose live generation no longer matches, so the core recognizes it and the op becomes a safe no-op instead of corrupting a reused node.
Fixed invariants from spec/spec.ts:
- Bit 31 stays
0, so ids are always positive i32.
0 is “no node” — insertBefore anchor 0 = append; setFocus 0 = clear focus.
ROOT_ID = 1 (slot 1, generation 0): the pre-created full-screen root, a flex column. Your tree mounts under it.
MAX_TREE_DEPTH = 64. insertBefore past the cap is a silent no-op, which bounds every recursive tree walk (layout, paint, subtree destroy) so a runaway tree cannot overflow the small PSP thread stacks.
The JS mirror tree
src/renderer-solid.ts (Solid) and src/renderer-vue-vapor.ts (Vue Vapor) each implement the framework’s universal createRenderer over a NodeMirror kept entirely in JavaScript. src/renderer.ts re-exports whichever variant the build targets. The NodeMirror type is defined in src/native-tree.ts:
interface NodeMirror {
id: number; // native generation-tagged id
type: number; // NODE_TYPE ordinal
parent: NodeMirror | null;
children: NodeMirror[];
text?: string; // text nodes only
domNodeType?: number; // Vue Vapor DOM-compat nodeType
domTag?: string; // Vue Vapor DOM-compat tag name
domAttrs?: Record<string, unknown>; // Vue Vapor DOM-compat attribute cache
domData?: string; // Vue Vapor DOM-compat comment payload
focusable?: boolean;
onPress?: (() => void) | undefined;
}
Every reconciler read resolves against this object graph without touching the host:
| Reconciler hook | Implementation |
|---|
getParentNode | node.parent |
getFirstChild | node.children[0] |
getNextSibling | index-of in parent.children, return next |
isTextNode | node.type === NODE_TYPE.text |
Structural mutations (insertNode, removeNode, createElement, createTextNode, replaceText, setProperty) update the mirror and emit the matching op. Because the mirror mirrors the native tree exactly — including DOM move semantics on re-parenting — the two never disagree, and the reconciler’s frequent tree walks stay entirely in JS.
setProperty is a strict dispatch table, not a generic setter: class resolves to a styleId via the injected style resolver; onPress/on:press registers with the input layer; src goes through the texture registry; style={{…}} emits per-key setProp calls (prev-diffed so only changed keys cross FFI). classList, on:, bool:, prop: namespaces, and unknown props are loud errors at both host kinds.
Two host kinds
detectHost() in src/host.ts resolves which HostOps object the ops route to, and sets a strictness flag that changes behavior on bad input:
| Kind | Ops source | Strict? | On unknown class / texture |
|---|
psp | globalThis.ui installed by native/src/ffi.rs | No | Bumps a miss counter; rendering continues |
injected | A HostOps passed into render() (web / WASM / Bun) | Yes | Throws |
The asymmetry is intentional. On real hardware a thrown error means a black screen; a missing style means a slightly-wrong box. So the PSP host counts misses (missCounters.unknownClass / unknownTexture) and renders on. Web, WASM, and headless-Bun hosts are development and CI surfaces where a silent wrong-color pixel is worse than a stack trace — so they throw the moment a class isn’t in the compiled table or a src key has no registered texture.
One special case: the PSP demo entries pass globalThis.ui explicitly. That object carries a __textures marker set only by native/src/ffi.rs and never by web hosts, so it is still detected as kind psp / non-strict — and render() skips re-feeding loadStyles/loadFontAtlas, because the native pak walker already fed core directly before JS eval.
Frame order on PSP
The PSP host runs one deterministic sequence per vblank. Web and Bun hosts follow the same steps under a fixed-step requestAnimationFrame / timer loop so goldens stay byte-exact:
sceCtrlRead
↓
sceGuStart begin the GE display list (main.rs owns it)
↓
frame(buttons) edge-detect input; run Solid/Vapor effects for
changed signals (the only ops emitted this frame);
runSweep() runs last inside this call
↓
drain pending jobs while JS_ExecutePendingJob(rt, &ctx) > 0
(promise microtasks — polyfilled queueMicrotask)
↓
core.tick(1/60) advance every anim/spring track at FIXED_DT
↓
layout (if dirty) taffy re-run + text re-measure, only if a
layout-dirtying prop changed this frame
↓
DrawList tree walk → flat Vec<u32> ops, CPU-clipped to
[0, 480] × [0, 272]
↓
ge::render DrawList → sceGu draw calls
↓
sceGuFinish / Sync / WaitVblank / Swap
Key properties of this ordering:
- The sweep runs inside
frame(), as the very last thing before returning. A remove-then-reinsert within one frame (a <For> reorder, a <Show> toggle) never destroys a live node.
- Fixed
dt = 1/60 s. core.tick always advances by exactly FIXED_DT, never wall-clock delta. Frame content is a pure function of tick index, which is exactly what makes byte-exact PNG goldens possible across PSP, PPSSPP, and headless Bun.
- Layout is conditional. Only a change to a
LAYOUT_DIRTYING prop (sizes, padding, flex props, fontSlot, tracking, lineHeight) re-runs taffy. Transform and color changes are paint-only. Prefer transforms for animation.
- Backends never own the display list.
sceGuStart/Finish live in native/src/main.rs; the GE backend only translates a pre-clipped DrawList into draw calls.
In steady state — no signals changed — frame() emits zero mutation ops, the sweep set is empty, and the only boundary crossing is the single frame(buttons) call itself.
Node reclamation
Solid’s reconciler calls removeChild for nodes that may be re-inserted the same frame (rows moving across a <For>, arms swapping in a <Show>). So removeChild deliberately does not destroy — it detaches and records the node in a sweep set:
function removeNodeImpl(parent, node) {
notifyDetached(node); // focus repair, before the unlink
getOps().removeChild(parent.id, node.id);
unlink(node); // drop from JS mirror parent
sweepSet.add(node); // reclaim at frame end unless re-attached
}
If the same node is inserted again before the frame ends, insertNode removes it from the sweep set and it survives untouched. Whatever is still detached when runSweep() runs at the end of frame() gets one recursive destroyNode call per orphaned subtree root.
retain() and release()
Sometimes you want to detach a subtree and keep it alive across frames — an offscreen panel cache, a pooled row. The retain/release escape hatch opts out of the sweep:
import { retain, release } from "@pocketjs/framework";
retain(node); // detached but preserved; sweep skips it and any subtree containing it
// ... frames later ...
release(node); // undo; if still detached, it re-enters the next sweep
runSweep checks subtreeHasRetained before destroying, so a retained node anywhere inside a detached subtree keeps the whole subtree pending until it is released or re-attached. A FinalizationRegistry backstop tier catches anything that slips through both the sweep and an explicit release — a safety net, not the primary mechanism.
The spec as single source of truth
spec/spec.ts is the single source of truth for every op code, prop id, enum ordinal, binary format constant, and button bitmask shared between TypeScript and Rust. Nothing is duplicated by hand.
The code-generation and drift-guard chain:
spec/gen-rust.ts reads spec/spec.ts and code-generates core/src/spec.rs deterministically.
core/src/spec.rs is committed. It is the file the Rust compiler reads.
test/contract.ts re-runs gen-rust.ts in-memory and byte-compares the result against the committed file. If they differ the CI build fails with a clear “run bun spec/gen-rust.ts and commit” message.
Because codes are append-only and never renumbered, any host compiled against an older spec will still handle the ops it knows about and safely skip anything new — the same contract as other append-only wire protocols.
When adding a new op: add it to OP at the next free code in spec/spec.ts, run bun spec/gen-rust.ts, commit core/src/spec.rs, and let test/contract.ts verify the roundtrip. The drift guard catches any mismatch before it reaches a host.
Text model
A <text> element lays out its text-node children as one concatenated inline run — a single measure and a single draw call, not N flex items. Text nodes inherit the resolved text style (font slot, color, tracking, alignment) from the nearest ancestor that sets text props.
Empty text nodes (Solid’s <Show> markers, initial reactive values) are excluded from the taffy tree until replaceText makes them non-empty. This keeps the layout engine clean — a zero-length text node does not contribute a phantom flex item that shifts siblings.
The whole design converges on a small, predictable steady-state cost:
| Budget | Target |
|---|
| FFI crossings per steady frame | One (frame(buttons); zero ops when nothing changed) |
| DrawList draw calls | ≤ ~40 sceGuDrawArray calls |
| DrawList quads | ≤ ~2 000 |
| Per-frame vertex bytes | ≈ 48 KB from a per-frame bump pool (reset after sceGuSync) |
| Solid effects | Run only on interaction / changed signals |
Two practical corollaries for app authors: animate transforms and colors rather than layout props, because layout-prop animations force a taffy relayout each frame (transforms are paint-only and incur no relayout); and keep dynamic styling to ternaries of full class literals or style={{…}} objects so the compiler can bake every style ahead of time in styles.bin.
Animating layout props (width, height, padding*, gap, etc.) marks layout dirty every frame the animation runs. For smooth 60 fps motion on PSP, prefer translateX, translateY, scale, and rotate — they are paint-only and never trigger taffy.
PSP memory model
On hardware the whole stack lives in one arena, which required fixing a rust-psp default that quietly caps out.
The default rust-psp #[global_allocator] makes one kernel memory object per allocation, and the kernel caps those at roughly 4 096. pocketjs-core allocates constantly — taffy slotmaps, children Vecs, per-pass .collect()s, the DrawList — so the default allocator crashes a real UI. The fix, in summary:
- A vendored
rust-psp fork gains an external-global-alloc feature that cfg-gates out its #[global_allocator].
native/src/alloc.rs installs the PocketJS global allocator, backed by arena::alloc/dealloc — the same single kernel block QuickJS uses. Core, QuickJS, and newlib all draw from one arena.
arena.rs’s ensure_init calls sceKernelAllocPartitionMemory / sceKernelGetBlockHeadAddr directly to avoid recursion back through alloc::alloc.
- A 2 MB margin is reserved for the GE display list and stack safety.
Other hard rules that flow from the PSP’s constraints:
- JS runs on a 2 MB
USER | VFPU worker; the main stack is 256 KB. MAX_TREE_DEPTH = 64 keeps every recursive walk inside it.
- GE buffers are 16-byte aligned with a dcache writeback per batch.
- 2D vertex coords are
i16; the core’s CPU clip stage guarantees in-range values so the GE never wraps a coordinate.
- Textures are power-of-two,
≤ 512 per side, sampled from main RAM.