Architecture
Three runtimes — your Solid app, an embedded JS engine, and Flutter — sharing one region of memory. Here's the whole path from a signal write to a pixel.
The three layers
- Your app (SolidJS). Plain Solid components compiled against Skal's universal renderer. Instead of creating DOM nodes, the renderer emits binary operations.
- libskal (bun + JavaScriptCore). A native library embedding a slice of bun — its JSC runtime, module loader, bytecode cache, and Skal's store engine — inside your app's process. Your bundle runs here, on a worker thread.
- The Flutter host. A thin Dart layer that drains the op ring once per frame tick and maintains a widget tree mirroring your component tree. Rendering, gestures, accessibility, and plugins are stock Flutter.
The shared region
At startup, libskal allocates a single 6 MiB block that both sides address directly for the app's whole lifetime:
| Segment | Size | Direction | Carries |
|---|---|---|---|
| Header | 64 B | both | cursors, sequence counters, reset epochs |
| Op ring | 4 MiB | JS → Dart | CREATE_NODE, INSERT_BEFORE, SET_PROP_*, SET_TEXT… |
| String heap | 768 KiB | JS → Dart | UTF-8 payloads referenced by string ops |
| Reply heap | 256 KiB | Dart → JS | RPC results (dialogs, platform calls) |
| Event ring | rest | Dart → JS | taps, scrolls, text input, lifecycle |
JS sees the block as a Uint8Array over externally-owned bytes (JSC's no-copy ArrayBuffer); Dart sees the same bytes as a typed list over an FFI pointer. There is no serialization step anywhere — "crossing the bridge" is writing integers into a ring buffer.
Life of an update
- You tap a
<Button>. Flutter's gesture arena resolves the tap and writes one small event (handler id + kind) into the event ring, then wakes the JS thread. - Skal's dispatcher reads the event and invokes your
onClick. A signal updates. - Solid's reactivity re-runs only the computations that read that signal. Each affected prop becomes one op in the op ring — a 5,000-row list where one label changed emits exactly one op.
- On the next frame tick, Dart drains the ring (measured at 0.151 ms per frame, steady state), patches its node table, and marks precisely the touched widgets dirty via per-node notifiers.
- Flutter rebuilds those widgets — and only those — and composites the frame.
Hot props (opacity, transforms) take an even shorter path: the host tweens them on its own animation ticker, so a 60 fps animation costs zero bridge traffic per frame.
Cold start & bytecode
Release builds ship the bundle twice: as source (.cjs) and as pre-compiled JavaScriptCore bytecode (.jsc). On launch, JSC attaches the bytecode directly — the parser never runs. That turns a several-hundred-millisecond parse into a 36 ms eval, and a 56 ms cold boot to first frame.
Why not a virtual DOM? Why not Flutter alone? Why not RN?
- vs. React Native: RN's architecture pays for serialization and a JS-side shadow tree. Skal's renderer writes binary ops straight into memory Flutter reads — the "bridge" is a pointer.
- vs. Flutter alone: you keep the JS ecosystem, sub-second hot reload of app code, and a language your web team already ships in — while the rendering fidelity IS Flutter.
- vs. WebViews: nothing here is a browser. Widgets are real platform-composited Flutter widgets; the DOM never existed.