The gap
:if on an interactive node in a native screen produces a node that renders, is tappable, and whose handler is dead. No error, no warning, no diagnostic from the compiler.
Measured on stx 0.2.448, wildloop's resources/native/Notifications.stx:
<Button :if="unread() > 0" testID="mark-all" @click="markAllRead()">Mark all read</Button>What the host is sent, in order:
- The first
RENDERcontains the node —testID="mark-all"is in the document beforeunread()is ever greater than zero. - When the condition turns true, no
createNodearrives for it. Checked by filtering everyMUTATEoperation fornode.props.testID === 'mark-all': zero. - The handler the host holds for that id is the one registered against the node from step 1. Dispatching an
EVENTfor it produces nothing at all — no mutation, no capability request, no error.
So the button is on screen, the tap is accepted, and the function never runs.
Why it took a while to find
The failure has no signature. What it looked like from the outside was a hung capability: the screen's write never issued a request, so the obvious suspect was secureStorage.get, which the write calls first. That is a red herring — the bridge gives every request a new message id and holds no cache, verified by driving two reads of the same key through it. Nothing was hung. The tap simply never ran.
A rule that silently drops a handler costs more than the same rule throwing, because the cheap explanations are all wrong.
What works
:ifon aTextnode behaves correctly, including when the condition flips. Every one of wildloop's three native screens relies on this for error and empty states.- A keyed
:forcreates and keys rows correctly, and their handlers are live —:forsendscreateNodeand then anupdateNodecarryingpatch.events, which is exactly what:ifdoes not do.
So the machinery for "node arrives later, handler attaches to it" exists and is used on the list path. :if is not reaching it.
Reproducing
# wildloop, on main
bun test tests/unit/native-notifications-screen.test.ts'puts no :if on an interactive node' is a source guard standing in for the fix:
const interactive = SOURCE.match(/<(?:Button|Pressable|TouchableOpacity)[^>]*:if=/g)
expect(interactive).toBeNull()A minimal reproduction is a screen with one Button behind an :if on a signal that starts false, and a test that flips the signal and dispatches the handler id the host was given.
What would be enough
Either of:
- Make
:ifuse the same path:foralready does — remove the node when false,createNodeit when true, and register its handler against the new id. The protocol supports this; the list boundary proves it. - Refuse it at compile time. If
:ifon an interactive node is not going to work, a compiler diagnostic naming the file and the node would have cost minutes instead of an afternoon.runNativeCompilealready emitsdropped-subtreediagnostics, so there is somewhere to put it.
(1) is obviously better. (2) is worth having anyway for whatever else turns out to be unsupported — a silent drop is the expensive kind of failure, and this is the second one of those in this codebase's native work (the first was useDebounceFn, declared in browser-auto-imports.d.ts and not injected into a client block, which threw at hydration and took a whole page's setup down).
Filed from wildloop's native promotion work for stacksjs/stx#1992. Three screens in: resources/native/ManualActivity.stx, Records.stx, Notifications.stx, Leaderboard.stx.