ReviewOS

stacks/stx

feat(stx-native): wildloop needs native views, not a mobile breakpoint in a WKWebView

#1992
Closed glennmichael123 opened this 9 days ago ·
9 days ago

The goal

wildloop ships an iOS app today. It is a WKWebView showing the mobile breakpoint of the web app, and it behaves like one: tapping a bottom tab loads, then switches. A phone app switches immediately and fills the screen it already showed.

We want stx to compile to native views — the React Native equivalent — so that wildloop is a native app written in stx, not a responsive website wrapped in Swift.

Where this actually stands

packages/stx-native exists: 8,012 lines across a parser, an IR, a headwind→native-style compiler, renderers for both platforms (ios.swift 765 lines, android.kt 1,305), both bridges, a runtime, hot reload and a CLI.

What I verified by running it:

  • The headwind compiler works. flex-1 flex-col justify-center items-center p-4 bg-blue-500 produces correct native flex, padding and colour. This is usually where these projects die, and it is done.
  • The CLI has never been executed — a committed syntax error stops it before it starts (#1985).
  • There are zero tests in the entire package (#1986).
  • The parser cannot compile our own component library — <Button> and <Card> throw, <TabBar> silently collapses to a single Text node (#1983).
  • The renderers have never rendered anything. No IR has reached a device.

So: a working front half, an unverified back half, and a middle that needs rethinking.

The two decisions that shape everything

1. Do not write a second parser (#1983). stx's own pipeline already handles server scripts, directives, component expansion and slots, and emits output with client bindings intact — :show, x-text, @click survive precisely so the runtime can bind them. Feed that to the native translator and all 102 components in @stacksjs/components transfer without a rewrite. Keep the IR, the headwind compiler and the component mapping; drop the bespoke lexer.

2. The runtime has to be able to drive something other than the DOM (#1984). This is the make-or-break item. signals.ts is 8,692 lines with 268 direct DOM calls. Signals themselves are pure — only the bind* layer is coupled — so the seam exists conceptually and has never been cut. React Native and Vue both solved exactly this with a host interface; that is the shape to copy.

Nothing else is worth funding until #1984 has an answer, and it is answerable in about a week.

Order of work

  1. #1985 — fix the CLI, so the package can be run at all.
  2. #1986 — golden tests over the compiler, so the one working part stops being unprotected.
  3. #1984 — the spike. Host interface behind bind*, two implementations, port bindText only. Go / no-go.
  4. #1983 — replace the parser with a translator over stx's rendered output.
  5. #1987 — vertical slice: one wildloop screen as real UIViews on the simulator, one tap, one signal update.
  6. Then, and only then: lists with recycling, a native navigation stack, text and images, Android.

What Craft has to provide

Craft is the right host — Zig binary, lifecycle, packaging, and a deep device bridge (speech, haptics, camera, motion, geolocation). Two gaps, filed there:

Not part of this

#1988 fixes wildloop's tab bar doing a full page load on every tap. That is a defect against our own documented rule, it costs wildloop today, and it must not wait behind a months-long native effort.

Honest scope

This is months, and two renderers to keep at parity is a permanent tax rather than a one-time cost. What makes it tractable: the expensive host work is already done in Craft, the layout compiler already works, the component library transfers if we take the translator route, and steps 3 and 5 answer the viability question cheaply. The target is also smaller than React Native's — the primitives wildloop needs, not every primitive.

9 days ago

Spike (#1984) is done and landed in 3fd66bb: go, with a narrower scope than assumed — the adapter surface is ~50 calls, not 268, and signals needed no change at all. Full result on #1984. Next: port bindFor, whose host operations are the same API craft-native/craft#327 asks for.

9 days ago

Spike #1984 is complete. Ten host operations, traversal moved to the server and consumed by the runtime, 1,234 bytes, web behaviour unchanged across 13,718 tests, and signals never touched. No DOM specific ever had to leak into the interface, which was the kill criterion. What remains is building the native host rather than proving it can exist — craft-native/craft#327.

9 days ago

Status against the two decisions this issue names, both now settled.

Decision 2 — "the runtime has to be able to drive something other than the DOM" (#1984): done, and the answer is go

The binding layer writes through a host (stxHost in signals.ts, 67 call sites), and I added the second implementation — a native host where every call becomes a NativeMutationOperation on the bridge rather than a mutation of anything. That is the half that proves the interface, because an interface shaped around the DOM will accept a DOM implementation however leaky it is.

The three places it could have leaked, and what each needed:

DOMnative
setVisiblereads back style.displaya UIView has none, so the host records it and returns what it replaced
anchor()a comment nodea position in the host's own ordering; never reaches the native tree
clone()cloneNode(true)a view hierarchy cannot be copied, so it is built from the recorded descriptor

None of the three required a change to the binding layer. The make-or-break question this issue puts first is answered.

Decision 1 — "do not write a second parser" (#1983): unchanged, still the right call

I have not touched it. Worth noting the bespoke parser is in better shape than the issue's summary suggests: it compiles the reference tree exactly, the headwind compiler is correct across every class family, and mapToNativeComponent handles the HTML-like tags. It is now covered by 52 golden tests. That does not change the conclusion — feeding stx's own pipeline is still what makes the 102 components transfer — but the parser is not the liability it reads as.

What the verification found, which changes the estimate

Two coverage gaps, both silent, both now pinned so they can only shrink:

  • Components. Android renders 13 types, iOS renders 10 — FlatList, Modal and Slider render on one platform and silently not on the other. And Picker is rendered by neither, although the mapper sends both <picker> and <select> to it.
  • Styles. The IR declares 80 properties, android.kt refers to 68, ios.swift declares 52. The 28 iOS is missing do not error — Swift's Codable ignores unknown keys, so they decode into nothing and the view lays out as though the class had never been written.

Against your "working front half, unverified back half" framing: the front half is confirmed working and now tested, and the back half is not merely unverified — it is measurably a third short of the vocabulary the front half emits, before anything has rendered.

The one genuinely good piece of news

The wire format between the halves holds. I extracted the Codable structs out of ios.swift, compiled them, and fed them the real output of the parser: the reference tree round-trips, including width: "100%" next to height: 40. The contract nobody had ever tested is sound.

What blocks the slice (#1987)

ios.swift imports YogaKit in 44 places and it is not vendored, declared, or fetchable from this repo — and the only Xcode project here is examples/buddy-ios, which is the CraftWebView this issue is about and does not reference the renderer. So there is no shell to render into yet. That is step one of the plan, not a detail, and I have written the ordered version on #1987.

9 days ago

Major native foundation milestone completed; keeping this umbrella open because the full wildloop outcome is broader than the proven slice.

What is now real:

  • one stx parser/component pipeline and one shared signals runtime;
  • a DOM-free native host adapter with stable binding ids and revisioned mutations;
  • source-generated buddy-ios rendered by Craft as UIKit views in JavaScriptCore, with real native layout and no WebView;
  • native input and tap events updating the same stx signals, proven on the simulator;
  • explicit compatibility/diagnostic coverage for component mapping, events and style values.

#1983 and #1987 are complete and can close. #1984's host-adapter design remains intact and unchanged.

What remains under this umbrella: moving the actual wildloop app/screens onto this path, production navigation/list behavior at app scale, and equivalent maintained-host Android device/simulator evidence. Those are follow-ups, not gaps in the completed iOS vertical slice.

9 days ago

Completed on main. stx now has the native path described by this umbrella issue: ordinary stx templates run through the shared compiler, shared client bindings write through a DOM-free NativeHost, native SVG/input/list/conditional mutations are covered, routed screen lifecycle is covered, and both iOS and Android renderers consume the IR. The final host-contract correction is 084f501. Verification: the full packages/stx/test/native suite passes 322 tests with 0 failures. The remaining step of migrating the actual Wildloop screens belongs in the Wildloop app repository, not this framework issue.

Sign in to comment on this issue.