ReviewOS

craft-native/craft

feat(native): flexbox is implemented twice — compute layout once in Zig, leave the hosts to mount

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

The goal

Flexbox is implemented twice in Craft — once in Swift, once in Kotlin — and the two are kept in agreement by hand. Compute layout once, in the Zig binary that already ships on every platform, and reduce each host to mounting the result.

This is the child of #327, which was deliberately left open "for a full Yoga-class layout engine and the remaining native surface". It argues for where that engine should live, not whether it is needed.

Where this actually stands

Read at bd4d64fc146e3d7422831ffc4343e4de2f343828.

lineslayout primitive
packages/ios/templates/CraftNativeScreen.swift3,619CraftNativeFlowView, a UIStackView subclass
packages/android/templates/MainActivityNative.kt.template2,549CraftNativeFlexLayout, a LinearLayout subclass (line 158)

Roughly 6,200 lines of independently written rendering and layout across two languages, computing what is meant to be one specification. The thing holding them together is scripts/native-renderer-contract.test.ts — 887 lines, 78 cases — plus the generated per-platform template tests.

The contract tests are good and this is not an argument against them. They are the reason a platform cannot silently lose a primitive, and they caught real asymmetries. The argument is that they are compensating for a structural choice rather than a hard problem.

The evidence that it costs something

Of 487 commits in the last seven days, 21 exist only because there are two implementations:

  • fix(native): align flex shorthand basis across hosts
  • fix(native): inherit parent alignment for auto align-self
  • fix(native): align text intrinsic metrics
  • fix(native): clamp negative flex growth
  • test(native): document percentage layout parity
  • docs(native): describe wrapped layout parity

Those titles are only writable in a world with two implementations to drag toward each other. In a shared-core design that class of commit does not exist, because there is nothing to align.

This is also the cost that compounds: it was tolerable while iOS was the only real target. Android became one this week (53 android-scoped commits in seven days), and macOS is in the same tree. Each new host multiplies the surface rather than adding to it.

Why Zig is the obvious home, and why this is cheap to evaluate

Craft already ships a Zig binary on every platform, and that binary already owns cross-platform logic that must not diverge. This week alone:

  • fix(ios): Zig validates every stored config key Swift decodes
  • fix(ios): Zig validates limitNavigationsToAppBoundDomains like Swift does

So the pattern — one definition in Zig, hosts conforming — is established and in use. #329 ("Track intentional iOS Zig/Swift validation divergences in a checked-in contract") is the same problem in a smaller domain, and its existence is an argument that the duplication is already recognised as something needing active management.

Layout is a better fit for Zig than config validation is: it is pure computation over a tree, it has no platform API surface, and its output is a flat list of frames. It is the part of a renderer that least needs to be native.

React Native's Fabric reached this conclusion and moved shadow tree, diffing and Yoga into shared C++, leaving the hosts to mount. Craft has already adopted Fabric's mutation protocol — createNode, updateNode, insertChild, moveChild, removeNode, batched with revisions and ACKs. This proposes adopting the half that the protocol was designed to sit on top of.

Order of work

Following the shape of stacksjs/stx#1984, which answered a comparable question cheaply before anything was committed to:

  1. The spike, with a kill criterion. A Zig function taking a tree of style properties and a viewport, returning absolute frames. Port one axis — main-axis flex-grow distribution with flex-basis — and call it from both hosts for a fixture already covered by the contract tests. Kill criterion: whether any platform-specific concept has to leak into the Zig interface. Text measurement is the one that will try to, and it should be explicitly out of scope for the spike (see below).
  2. Frames over the existing protocol. Decide whether computed frames ride the existing mutation batches or a parallel channel. The protocol already carries revisions, so this should be additive.
  3. Port the properties the contract tests already cover, one family at a time, each deleting its Swift and Kotlin counterparts rather than sitting beside them. The 78 existing cases are the regression net — this work should not need new tests so much as it should keep those green while two implementations become one.
  4. Only then: wrapping, grid, percentage resolution, writing direction.

The hard part, named honestly

Text measurement cannot move to Zig, and it is load-bearing for flex. Intrinsic size depends on the platform font stack, dynamic type, and line breaking — fix(native): align text intrinsic metrics is in this week's log precisely because it is genuinely host-specific.

The standard answer, and the one Yoga uses, is a measure callback: the shared engine owns the algorithm and calls back into the host for intrinsic sizes. That keeps the layout rules in one place while leaving measurement where it has to be. Whether that boundary stays clean under Craft's protocol is the real question, and it is what the spike should answer.

Honest scope

This is a rewrite of the most delicate part of a renderer that is currently working, in service of a cost that is real but not yet acute. It should not block #327's remaining surface, and if the spike says the measure-callback boundary leaks, the right outcome is to close this and keep investing in the contract tests instead.

What makes it worth evaluating now rather than later: the two implementations are ~6,200 lines today and growing daily, every line written from here is a line to be deleted twice, and Android's arrival this week roughly doubled the rate at which the tax accrues.

Not part of this

Nothing here affects the WebView renderer, the mutation protocol's wire format, or the component contract in docs/guides/native-stx.md. The primitive set and its documented host mappings stay exactly as they are — this changes who computes the frames, not what gets mounted.


Filed after reading the repo rather than attempting an implementation. The line counts, commit counts and file paths above are from bd4d64fc and are checkable; the judgement that Zig is the right home is an argument, not a measurement.

9 days ago

Resolved on main. Native flex measurement and placement now use the shared Zig layout ABI, with iOS/Android hosts consuming the same positions and contract/fixture coverage for intrinsic sizing, grid, wrapping, and baseline alignment.

Sign in to comment on this issue.