Frameworks
PocketJS supports three app frameworks over the same native tree and Rust core:
| Framework | Build id | JSX transform | Runtime renderer | Output suffix |
|---|---|---|---|---|
| Solid | solid |
babel-preset-solid universal mode |
renderer-solid.ts |
none |
| Vue Vapor | vue-vapor |
vue-jsx-vapor |
renderer-vue-vapor.ts |
.vue-vapor |
| Octane | octane |
Octane universal compiler (host plans + slots) | renderer-octane.ts (pocket universal driver over the native tree) |
.octane |
Solid is the default so existing apps keep building to dist/<app>.js and
dist/<app>.pak. Vue Vapor and Octane build next to it:
bun tools/build.ts hero-main # dist/hero-main.js
bun tools/build.ts hero-vue-vapor-main --framework=vue-vapor
# dist/hero-vue-vapor-main.vue-vapor.js
bun tools/build.ts hero-main --framework=octane
# dist/hero-main.octane.jsSibling variant files select on their own: an app.octane.tsx (or
app.vue-vapor.tsx) next to app.tsx is picked up when building with the
matching --framework, which is how one demo directory carries all of its
ports. The showcase demos each ship an app.octane.tsx and a
main.octane.tsx beside the Solid originals; ls apps/*/app.octane.tsx names
the current set.
There is no environment-variable switch for framework selection. Product
builds declare it in pocket.json; low-level compiler work can still use a
project config or one-command override.
Manifest selection
{
"app": {
"framework": "solid"
}
}Use "vue-vapor" for the Vue adapter and "octane" for Octane.
pocket check|compile|build --target …
resolves this value once and all framework/compiler/native stages consume the same plan.
Do not also put framework in pocket.config.ts for a manifest build.
Low-level project config
pocket.config.ts is the low-level script default:
import { definePocketConfig } from "@pocketjs/framework/config";
export default definePocketConfig({
framework: "solid",
});framework takes the same three build ids as the manifest.
The direct framework/compiler/dev scripts read the config by default. Use
--framework=solid, --framework=vue-vapor, or --framework=octane to
override it for one invocation. --config=<path> selects a different config
file, and --no-config ignores config entirely.
The same flag works through the dev and PSP entry points:
bun tools/dev.ts --framework=vue-vapor hero-vue-vapor-main
bun tools/dev.ts --framework=octane hero-main
bun tools/psp.ts hero-vue-vapor --framework=vue-vapor --releaseFramework app imports
Apps import state and component lifecycle from the selected framework directly.
PocketJS does not wrap createSignal, ref, useState, onMount,
onMounted, or useEffect.
A Solid app:
import { mount, frameworkName } from "@pocketjs/framework/solid";
import { View, Text, type NodeMirror } from "@pocketjs/framework/solid/components";
import { createSignal, onMount, Show } from "solid-js";
export default function App() {
const [count, setCount] = createSignal(0);
let marker: NodeMirror | undefined;
onMount(() => {
console.log(frameworkName(), marker?.id);
});
return (
<View class="p-4 flex-col gap-2">
<Text class="text-base text-slate-950">Framework: {frameworkName()}</Text>
<View nodeRef={(node) => (marker = node ?? undefined)} focusable onPress={() => setCount(count() + 1)}>
<Text class="text-sm text-blue-600">Count: {count()}</Text>
</View>
<Show when={count() > 2}>
<Text class="text-sm text-emerald-600">Solid, native tree.</Text>
</Show>
</View>
);
}
mount(() => <App />);The Vue Vapor and Octane ports of that app render the same tree from the same
View/Text components. What changes is the state syntax and where it is
imported from:
| Solid | Vue Vapor | Octane | |
|---|---|---|---|
| State | const [c, setC] = createSignal(0) |
const c = ref(0) |
const [c, setC] = useState(0) |
| Read in JSX | {c()} |
{c.value} |
{`${c}`} — mixed static + dynamic text is one template literal |
| Mount hook | onMount |
onMounted |
useLayoutEffect(fn, []) |
| State import | solid-js |
vue |
octane |
| Runtime import | @pocketjs/framework/solid |
@pocketjs/framework/vue-vapor |
@pocketjs/framework/octane |
| Entry call | mount(() => <App />) |
mount(App) |
mount(App) |
nodeRef takes a callback on all three. Solid also supports ref; nodeRef
avoids framework-specific ref semantics in examples meant to read the same way
across frameworks.
The generic public subpaths remain Solid-first defaults. Use explicit framework subpaths when an example or app is tied to a framework:
| Import | Solid build | Vue Vapor build | Octane build |
|---|---|---|---|
@pocketjs/framework |
framework/src/index.ts |
framework/src/index-vue-vapor.ts |
framework/src/index-octane.ts |
@pocketjs/framework/components |
framework/src/components.ts |
framework/src/components-vue-vapor.ts |
framework/src/components-octane.tsx |
@pocketjs/framework/lifecycle |
Solid lifecycle hooks | Vue Vapor lifecycle hooks | Octane lifecycle hooks (useFrame, useButtonPress, useSpriteAnimation) |
Not every subpath resolves under every framework. framework/compiler/subpaths.ts
is the registry, and it declares:
gestureandkineticsresolve under Solid and Vue Vapor. The recognizer and scroller machinery lives in framework-neutral core modules; each of the two frameworks gets a thin shim that binds disposal to its own lifecycle.oskandvirtual-listresolve under Solid only. Nothing else is meant to reach their Solid-flavored implementations.- What a framework does not resolve is not walked in pass 1, so class
strings from another framework's module never enter this build's
styles.bin. The bare specifier still carries an npm export pointing at the Solid file, so an Octane build importing@pocketjs/framework/gesturecompiles against the Solid module — which the Octane entry never pumps (see What stays shared).
Reactivity on PocketJS
PocketJS wraps no reactive system: each framework brings its own, and the runtime adds four rules on top — when a handler's write commits, which imports the Solid lint rejects, the scheduler globals QuickJS lacks, and why continuous motion does not come from per-frame state. Reactivity puts the three side by side and states all four.
Octane notes
Octane is React's programming model, compiled: hooks and JSX, no VDOM. Hooks
(useState, useEffect, useMemo, useRef, useLayoutEffect,
useEffectEvent, …) import from octane. Dependency arrays may be omitted —
the compiler infers them from captures. Hooks are tracked by call site, so a
hook inside an if block is fine, but hooks in loops are not. PocketJS's
per-frame hooks follow the same rule: they are useFrame, useButtonPress,
and useSpriteAnimation (from @pocketjs/framework/octane/lifecycle or the
generic lifecycle subpath), use-prefixed because the Octane compiler
slot-keys custom hooks by the use[A-Z] naming convention.
(pushButtonHandlerBlock is not a hook and keeps its name.)
At build time, Octane's universal compiler lowers JSX to static host plans
plus dynamic slots against the "pocket" renderer; at runtime the compiled
imports retarget to @pocketjs/framework/octane/renderer, whose driver maps
host command batches onto the native ui.* tree. Vue Vapor gets a micro-DOM
shim; Octane gets none.
Authoring rules specific to Octane apps:
- The entry passes the component itself.
main.octane.tsxmust callmount(App)— JSX inside a call-argument arrow (mount(() => <App />)) is a universal-target compile error. - Mixed static + dynamic text is one template literal. Write
<Text>{`Count: ${count}`}</Text>, not<Text>Count: {count}</Text>— the compiler drops trailing whitespace on a static segment that precedes an expression. classstays full literals or ternaries of full literals, as in the other frameworks.- Counters driven from
useFrameuse functional updates (setX((v) => v + 1)): a same-frame handler's state write would otherwise be clobbered by a stale read. - Keep natively
animate()d properties out of astyleobject whose value changes across re-renders. Re-applying a changed style value cancels the running tween (unchanged values are diffed away and are safe); drive such properties from an effect withanimate()/jump()and anodeRefinstead. - An Octane state commit replays the whole root, which is where the
no-per-frame-state rule bites hardest. The replay costs the same whether the
state lives in the root or in a one-node leaf — leaf state changes how
often you replay, not what a replay costs, and on the PSP one replay is a
multi-frame stall. The showcase demos put every continuous visual on a native
channel instead:
<Sprite>atlases for the hero/gallery/library spinners (sprites.json), a baked keyframe timeline for the music equalizer (apps/music/pocket.config.ts),animate()/jump()for the stats bars and notification rows, andsetTextContentfor count-ups and percentages (StatTiles,ProgressLine). - Frame counters that only time a phase live in refs, committing state once at the boundary they're waiting for (the notifications dismiss/rise timers, the library loading screen). Counting in state replays the root every frame of the phase for pixels that never change.
- Each replay retains a residue the collector cannot reclaim on the pinned QuickJS revision, and the slab allocator amplifies it on the fixed arena (the host's arena-pressure GC absorbs the churn). Keeping continuous motion on native channels holds replays down to interaction rate — button presses, track changes — which bounds the residue.
What stays shared
All three frameworks use the same Tailwind-subset compiler, generated style
table, font atlas baker, .pak asset container, host detection, button/focus
input, overlay layer, animation API, and every native build and capture path a
target registers in contracts/spec/platforms.ts. Switching frameworks changes
the JS component/reactivity layer and the renderer adapter.
One exception: touch under Octane is raw contacts only. The Solid and Vue
Vapor entries call installTouchActivation() and run the gesture pump each
frame; framework/src/index-octane.ts does neither, so an Octane app gets no
tap-to-press on onPress and no recognizers — it reads touches() itself.