ReviewOS

stacks/stx

feat(stx): the signals runtime is welded to the DOM — native rendering needs a tree adapter behind bind*

#1984
Closed glennmichael123 opened this 9 days ago · {{ comments.length }} {{ comments.length === 1 ? 'comment' : 'comments' }}
9 days ago

Summary

Native rendering needs the signals runtime to drive a native view tree instead of the DOM. Today the runtime calls DOM APIs directly, in 268 places. Nothing else about stx-native matters until this is settled: if these calls cannot be factored behind an interface, compiling .stx to native views produces a tree that nothing can update, and the whole approach is dead.

This is the make-or-break spike, and it is answerable in about a week.

Evidence

packages/stx/src/signals.ts is 8,692 lines and reaches for the DOM throughout:

APICall sites
removeAttribute63
setAttribute43
document.querySelector38
addEventListener31
textContent29
appendChild24
createElement22
classList18

The binding functions are where it concentrates — bindText is el.textContent = evalAttrExpr(value) (signals.ts:3030), bindShow sets style.display, bindFor builds and moves real nodes.

The encouraging half: signals themselves touch nothing. state, derived, effect and the dependency graph are pure; only the bind* layer and processElement are coupled. The separation this needs already exists conceptually — it just was never given a seam.

Prior art

  • React Native — the same split: React's reconciler is renderer-agnostic, and react-native supplies a host config that turns mutations into native view operations. React DOM and React Native share the reconciler outright.
  • Vue — @vue/runtime-core takes a createRenderer({ createElement, patchProp, insert, remove, setElementText, … }); runtime-dom and NativeScript-Vue are two implementations of it.
  • Svelte Native / NativeScript — a DOM shim over native views, which is the cheaper and worse version of the same idea.

Ask

A spike, not a rewrite:

  1. Define the host interface the bind* layer needs — roughly createNode, setText, setAttribute, removeAttribute, insertBefore, remove, addListener, setStyle, query.
  2. Implement it twice: dom.ts (exactly today's behaviour, so the web path is unchanged and the existing suite proves it) and native.ts (emits batched mutations over the bridge protocol in packages/stx-native/src/bridge/protocol.ts).
  3. Port one binding end to end — bindText — and nothing else.

Go / no-go: if bindText factors cleanly and the web suite stays green, the rest is grinding, and worth funding. If the interface has to leak DOM specifics to keep the web path working, say so and stop — that answer is worth a week either way.

9 days ago

Part of #1992 (stx-native: native views for wildloop).

9 days ago

Result: go, with the scope narrower than the issue assumed

Landed in 3fd66bb.

host is now an object inside the generated runtime — setText, setAttribute, removeAttribute — defaulting to the document and replaced by assigning window.__stx_host before the runtime executes. :text and the generic :attr binding write through it.

Signals needed no change at all. They never touch a node; the coupling is entirely in the bind layer. That is the result that matters, because it means the reactive core is already renderer-agnostic and only the ~40 binding call sites are in question.

What is proven

test/signals/host-adapter.test.ts — a recording host receives setText while the document stays empty, keeps receiving on every subsequent set, gets attribute writes and removals, and carries a derived value through, so the dependency graph behind the host is intact.

  • Web behaviour unchanged: 13,701 tests pass.
  • Cost: 229 bytes on the shipped runtime (186,245 → 186,474), one property lookup ahead of each write.

What the spike found that the issue did not anticipate

1. Not all 268 calls are the bind layer, and most of them should never be adapted. Classifying what is left:

WhereCallsWhat to do
addToast, _createDialog, confirm/alert helpers~70Do not adapt. These build DOM for imperative UI. A native target replaces them outright — an alert is a UIAlertController, not a div.
bindFor, bindIf, processElement~50The real remaining work. Structural: insertBefore, removeChild, node creation.
clean, scope lookup, $refs~40Needs a different answer — see below.

So the adapter surface is roughly 50 calls, not 268.

2. bindShow reads the DOM back, and a host interface has no answer for that. signals.ts:3305 captures el.style.display to remember the original value, and :3345 reads it again to decide whether it was hidden. A host can write; it cannot be asked what a native view's display value was. This binding has to keep its own state before it can be ported. Every other leaf binding I looked at is write-only.

3. querySelector does not translate. 38 sites, used for scope lookup and $refs. Native views are not addressable by CSS selector. This needs an id→node map maintained as nodes are created, not a query method on the host — putting query in the interface would be the leak this spike was meant to detect.

Port bindFor — structural operations, the hardest case, and the one that decides whether native lists are reachable. It needs createNode, insertBefore and remove on the host, which is exactly the API craft-native/craft#327 is being asked for, so the two can be designed against each other.

If bindFor factors, the remainder is grinding rather than uncertainty.

9 days ago

bindFor ported — the structural case holds

Landed in 2eea2c8.

bindFor is where the runtime stops updating a node and starts building them, and it was the case most likely to break the interface: 9 deep clones, 7 inserts, 10 removals and 3 anchors across 605 lines. All 28 now go through the host, and four operations cover it — anchor, clone, insert, remove. Smaller than the call count suggested, because every removal site already funnelled through the same two shapes.

Tests drive a real list through a recording host: building a list emits an anchor, a clone and an insert per row; growing it emits inserts; shrinking it emits removals. 13,703 tests pass, 211 bytes.

The two operations that do not carry over literally

clone is the architectural one. A DOM subtree deep-copies; a native view hierarchy does not. A native host has to materialise a fresh subtree from the row template rather than duplicate live views — which means the host must be handed the template (or the IR for it), not a node to copy. The signature reflects that deliberately.

This is also where a native implementation gets better than the DOM one: materialising from a template is what lets rows be recycled, which is the whole point of UITableView / RecyclerView. A DOM host clones because that is cheapest for the DOM; a native host reuses a dequeued cell. Same call, different strategy — exactly the split a host interface is for.

anchor is easier. A position in the child list that survives its neighbours moving: a comment node in the DOM, an index or a zero-size sentinel natively.

Where this leaves #1984

Both categories are now proven: leaf mutations (setText, setAttribute, removeAttribute) and structural ones (anchor, clone, insert, remove). Seven operations total, 440 bytes, no change to web behaviour across 13,703 tests, and signals themselves still untouched.

What remains before a native host could actually run:

  1. bindShow reads the DOM back (signals.ts:3305, :3345) — it has to keep its own state instead of asking the node what its display was.
  2. bindIf — same structural operations as bindFor, should be mechanical now.
  3. querySelector / $refs — 38 sites. Needs an id→node map maintained at creation, not a query on the host.
  4. Event listeners — 31 sites, and the one category with no spike yet: a native tap has to arrive back in JS as a handler call.

Nothing found so far argues against the approach. The interface has not had to leak a DOM specific to keep the web path working, which was the stated kill criterion.

A pattern worth fixing separately

Four tests have now broken during this work by grepping the generated runtime for exact source text — el.textContent = evalAttrExpr, el.setAttribute(attrName, attrValue), loadingTemplate.cloneNode(true), and #1727's dispose/remove pairing. None was testing what it broke on. CLAUDE.md note 41 already says to use test-utils/runtime-surface.ts for this; the tests predate it. Worth a sweep, because every one of them taxes any future change to the runtime's internals.

9 days ago

bindIf, bindIfChain and events ported — the interface is eight operations

Landed in db357d4 and c184b53.

Both conditional binders needed no new structural operation. They use the same four bindFor does — anchor, clone, insert, remove. Three binders, one interface, no growth, which is the result worth having.

Events took one more: listen(node, event, handler, options), wired into the @event directive path and x-model's four control listeners. options is load-bearing — it carries passive/capture/once, and dropping it silently made every touch and wheel listener blocking again, which three existing tests caught at once. A native host has no scroll thread to keep clear and will ignore passive, but the parameter stays so each host decides rather than inheriting a default by omission.

The interface, complete

OperationUsed by
setText:text
setAttribute, removeAttributegeneric :attr
anchor, clone, insert, removebindFor, bindIf, bindIfChain
listen@event, x-model

Eight operations, 606 bytes total on the shipped runtime (186,245 → 186,851), web behaviour unchanged across 13,704 tests, and signals themselves never touched.

What was deliberately not ported

window/document listeners, useEventListener, useMediaQuery, focus tracking, transitionend, and the toast/dialog/confirm builders. These are browser composables and imperative DOM UI, not tree mutation. A native target replaces them — an alert is a UIAlertController — so routing them through a host would be the wrong abstraction rather than a missing one.

One trap, recorded so nobody repeats it

bindIfChain already declared two local DOM nodes named host, so inside those blocks the adapter was shadowed by an element. Every call became node.anchor is not a function, deep in a branch swap, nowhere near the line responsible: 21 tests failed, all in if-chains, none naming the cause. The adapter is now stxHost, one of those locals is dead, and the other is parentEl.

Remaining before a native host can run

  1. bindShow reads the DOM back — signals.ts:3305 and :3345 ask the node what its display value was. It needs to keep its own state. This is the only binding with the problem.
  2. querySelector / $refs — 38 sites. Needs an id→node map maintained at creation; a query method on the host would be the leak this spike exists to detect.
  3. processElement's own traversal — walks childNodes to find bindings. A native host would need the tree walkable, or the traversal moves to compile time.

None of these is a reason to stop. The interface has not had to leak a DOM specific to keep the web path working, which was the kill criterion, and it stopped growing three binders ago.

9 days ago

bindShow and ref ownership ported — and a correction

Landed in ed44791.

bindShow no longer reads the DOM back

It was the only binding that asked a node about itself: style.display at setup to remember what to restore, and again on every update to tell a reveal from a repaint. A native host can answer neither — a UIView has no display value.

setVisible(node, visible) returns the previous visibility, so the write reports what it replaced and the binder reads nothing. Restoring an explicit display — a flex row, an inline-block — is the host's business too; it records that the first time it hides the node.

Correction: $refs needed nothing

I claimed earlier in this issue that $refs was part of the 38 querySelector sites and would need an id→node map. That was wrong. scope.$refs[name] = el is already an id→node map, populated when x-ref is processed. Nothing to build.

The only coupling was working out which scope owns a ref, which used closest('[data-stx-scope]') — three sites, now scopeOf(node, from).

Deliberately not a query(selector) method: a native view tree has nothing to match a selector against. The DOM host walks with closest(); a native host reads the node→scope map it keeps as it creates views. Handing a host a selector would have been the leak this spike exists to detect.

What the 38 querySelector sites actually are

Since the earlier count was misleading, the real breakdown:

CategoryNeeds
addToast, stxConfirm, drawer/dialog buildersNothing — imperative DOM UI a native target replaces
SPA plumbing: [data-stx-content], _handleStxLoad, cleanupContainerNothing — a native runtime has no document to scan; it gets its root from the host
Boot: finding [data-stx] rootsReplaced by however a native app mounts
disposeSubtreeScopes, hydrateSubtreeTree traversal (children of a node) — a real host need, and the last one outstanding

So the remaining requirement is traversal, not querying, which is a much smaller ask of a native host.

The interface, at ten operations

setText · setAttribute · removeAttribute · anchor · clone · insert · remove · listen · setVisible · scopeOf

713 bytes on the shipped runtime, 13,706 tests passing, signals still untouched, and no host method takes a selector or answers a question about a node's own state.

Outstanding

  1. Subtree traversal for disposal and hydration — the last structural need.
  2. processElement's walk over childNodes to find bindings. Either the host exposes children, or the traversal moves to compile time, which is the more interesting option: the compiler already knows where every binding is.

Neither is a reason to stop.

9 days ago

Traversal moved to compile time — the manifest exists and agrees with the runtime

Landed in a198835.

processElement discovers its work at hydration: recurse over childNodes, read every element's attributes, dispatch on the names — 606 lines. Affordable on the DOM. Wrong for a native host, which has no attributes to read and no selector to match, and in both cases it is work repeated on every client for an answer the server already had.

binding-manifest.ts reads the rendered output once and records what carries a binding, stamping each such element with an id the runtime can resolve without searching.

Why rendered output rather than the template

By then server directives have run, components are expanded and slots are filled, so what remains is exactly the set of bindings that reach a client — no more, no fewer. It is also the same input shape the stx-native translator takes (#1983), which keeps one definition of "a binding" across both.

Drift was the whole risk, and it is closed by construction

Two classifiers in two languages — a generated runtime string and TypeScript — is precisely where implementations drift, and the symptom is a binding that silently never binds.

So DIRECTIVE_NAMES and EVENT_RE moved to runtime-globals.ts, beside the x- attribute table that was already shared there for the same reason, and the runtime now interpolates them. The two sides cannot hold different opinions about what a binding is.

What could still drift is the order the runtime checks things in, and that is pinned by comparing the manifest against what the runtime actually consumes — using the one observable it already offers: it removes each directive attribute as it binds it, so whatever is missing after hydration is what it took.

The comparison found something on its first run

The runtime consumes ::literal — strips it and renders nothing in its place — even though the escape is excluded from binding specifically so the attribute can stay literal. :: appears exactly once in the whole runtime (that exclusion), with no test and no documentation anywhere in the repo. The test records the behaviour as observed rather than asserting it is correct, because which way it should go is an open question, not something that file should pin.

What a manifest cannot cover

Rows a :for clones do not exist when the manifest runs. The row template does, so its entry is marked template: true and each clone binds from that entry rather than by walking the clone. That is the same split SolidJS and React Native make — compile the template, bind per instance — and it is why this is a manifest of the static tree rather than of every node that will ever exist.

Where #1984 now stands

The interface is ten operations, none of which takes a selector or asks a node about its own state, and the runtime no longer needs to walk a tree to find its work — it can be handed the list.

Remaining before a native host runs:

  1. Rewire hydration to consume the manifest. It is generated and proven faithful; nothing reads it yet. This is the mechanical part, and it wants doing behind a flag with both paths live until the manifest path has run against the full suite.
  2. Subtree traversal for disposal — disposeSubtreeScopes still walks to find scopes to tear down. A native host maintains its own scope registry, so this likely becomes a host op rather than a walk.
9 days ago

Hydration consumes the manifest — the walk is no longer required

Landed in 55dd1a1.

The page-root path now prefers the server's list: resolve each id, bind that element, never touch its children. An element carrying no binding is not visited at all.

Opt-in by presence. No window.__stx_bindings, no change — the walk stays the default, and every page that ships no list hydrates exactly as before. That is all 13,718 tests.

Why this is the piece that mattered

Discovery cannot happen in a view hierarchy: no attributes to read, no selector to match. Being handed the list is the difference between a native host being possible and not. On the DOM it replaces a full traversal with one lookup per binding.

How it was kept small

processElement gained a skipChildren flag rather than being split apart. The manifest already knows where every binding is, so recursion is the only thing that has to stop — the 286-line dispatch it runs per element is untouched. Not refactoring the heart of hydration in order to prove this was the point.

The test has to prove a negative

A manifest path that quietly fell back to the walk would pass any test that only checked the bindings worked. So one case ships a manifest with an entry deliberately removed and asserts that element is still unbound, with its directive attribute intact. That is the assertion that distinguishes the two paths.

One wrong turn worth recording

I hooked the wrong call site first. There are three places that call processElement on a root — component mounting, the page-root loop, and a per-scope loop — and two of them are byte-identical lines. The component-mount hook silently did nothing and the walk kept running, which the negative test caught immediately. The page-root loop is correct: a component's ids belong to its own manifest, not the page's.

Where #1984 stands now

  • Interface: ten operations, none taking a selector or asking a node about its own state.
  • Traversal: computed on the server, consumed by the runtime, opt-in.
  • Cost: 1,234 bytes total across the whole spike, web behaviour unchanged throughout.
  • Signals: never touched. The reactive core was renderer-agnostic all along.

The kill criterion was "the interface has to leak a DOM specific to keep the web path working". It never did.

What is left is no longer spike work — it is building the native host: disposeSubtreeScopes still walks to find scopes to tear down (a native host maintains its own registry, so this becomes a host op), and someone has to write a host that materialises IR and answers these ten operations. That is craft-native/craft#327.

9 days ago

Go. The second implementation exists and the interface did not have to leak. 5d1e2dd.

Where this stood before I started

Most of the spike was already done — and not by me. signals.ts already carries a stxHost adapter with 67 call sites through the binding layer, and test/signals/host-adapter.test.ts pins it with a recording host. So asks 1 and 3 were met, and more than asked: not just bindText, but structural operations, listeners, visibility and scope.

Two small corrections to the issue while I am here. There is no bindText function — the text write is inline in the attribute-binding pass, which is why signals.ts:3030 does not point at one. And the DOM call counts have barely moved (removeAttribute 65, setAttribute 46, textContent 29), because the seam covers the binding layer, while the rest of those calls are x-cloak injection, toasts, dialogs and head management — code that is legitimately the document's and has no native counterpart. Counting them as debt against this spike would overstate it.

What was missing: the half that proves it

An interface shaped around the DOM will accept a DOM implementation however leaky it is. The answer only arrives when something owning no elements satisfies the same methods — so that is what this adds: every host call becomes a NativeMutationOperation on the bridge in bridge/protocol.ts.

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

DOMnative
setVisiblereads back style.displaya UIView has no display value, so the host records it per node and returns what it replaced
anchor()a comment nodea marker in the host's own ordering; never reaches the native tree, costs no view and no operation
clone()cloneNode(true)a view hierarchy cannot be copied, so it is materialised from the recorded descriptor

None of the three required a change to the binding layer. That is the go/no-go.

Two more the DOM gets for free. A closure cannot cross a bridge, so listen() keeps the handler and sends an id the native side reports back — the binder hands over a closure either way and never learns the difference. And removeAttribute sends an explicit null rather than omitting the key, because a patch is merged natively and an absent key reads as "unchanged", which would leave the attribute in place.

The anti-drift part

The contract test reads the DOM host's method list out of the generated runtime rather than hardcoding it, so a method added to one host and not the other fails immediately. That is the difference between an interface and a convention, and it is what keeps this from rotting between now and whenever native rendering is picked up again.

One bug, found by its own test

insert() measured the target index before taking the node out of the ordering. A move is indexed against the list without the node in it, so a row reordered to the end reported a position one too far right and would have landed one short. Exactly the class of defect that only shows up once something applies the operations — which is the argument for building the second implementation now rather than when the renderer needs it.

20 tests here, full suite 14785 pass.

What this does not do

It does not wire the host into a running native app — nothing in this repo can execute Swift or Kotlin, and #1987 is where that belongs. The ask was "a spike, not a rewrite", and the question it posed is answered.

9 days ago

Closing: the spike's question is answered, and the last in-repo item on it has landed.

What the ask was, and what it got

The ask was a spike with a stated kill criterion — "if the interface has to leak DOM specifics to keep the web path working, say so and stop" — and one binding ported end to end. It never leaked, and the result went well past one binding.

The interface, final: twelve operations.

setText, setAttribute, removeAttributeleaf mutations
anchor, clone, insert, removestructural, shared by bindFor, bindIf, bindIfChain
listen@event, x-model
scopeOfref ownership, if-chain scope
setVisiblereturns the previous visibility, so nothing reads the DOM back
descendants, scopesInteardown, added in 281bc4d377

None takes a selector. None asks a node about its own state. Signals were never touched — the reactive core was renderer-agnostic the whole time, which is the finding the issue did not anticipate and the one that made everything after it cheap.

The last item

The issue's closing list had disposeSubtreeScopes still walking the DOM to find scopes to tear down. Both disposal paths did, in fact — querySelectorAll('*') for the per-node teardown state and [data-stx-scope] for the registrations. A view hierarchy has neither selectors nor attributes to match, so that was the last DOM assumption in the binding layer.

descendants and scopesIn close it. The document searches; the native host answers from the parentage it recorded while building the tree, which is cheaper for it than searching is for the DOM. scopesIn returns ids rather than nodes, because looking the scope up in the runtime's own registry is the only thing done with them — returning a node would just make the caller read back the attribute this removes.

The contract test earned its place here: adding the two methods to the DOM host failed the native side immediately, which is the difference between an interface and a convention.

Cost of the whole spike: ~1,434 bytes on the shipped runtime, web behaviour unchanged throughout, 14,988 tests passing.

What remains, and where it belongs

Not this issue:

  • A host that runs on a device. Nothing in this repo can execute Swift or Kotlin. That is #1987, where the blocker is a layout dependency and an app shell, not the interface.
  • The JavaScriptCore bundle driving the host. The bundle has its own reactivity contract — {expr} in text and prop={expr} resolved on the device by eval against the screen's scope — and stx resolves prop={expr} on the server. Those collide at the authoring syntax, and reconciling them means the bundle reading the translator's bindings field or native screens adopting signals. That is the step #1983's third ask waits on, and it is why parseSTX is still alive.

The spike itself has nothing left to answer.

Sign in to comment on this issue.