ReviewOS

stacks/stx

An element whose text is a reactive mustache is cloaked, so a primary control can ship display:none

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

stx adds x-cloak to any element whose own text content is a reactive . For a label that is fine. For a control, it means the thing the user is supposed to click does not exist until the bundle runs — and the failure is invisible in review, because it looks correct in a browser with fast JS.

This has now cost me three separate bugs in one codebase, which is what makes it worth filing rather than just working around.

What happens

<button @click="submit()" :disabled="busy()">{{ busy() ? 'Sending…' : 'Send invitation' }}</button>

yw() in the render pipeline sees a mustache as the element's text and stamps x-cloak. x-cloak is display:none. So the only control on the panel is invisible on first paint, on a slow connection, and permanently if the bundle fails to load.

The same rule applied to a container is worse. An x-for whose rows carry reactive mustaches cloaks the container, and an entire panel ships as a blank box:

<div class="panel">
  <template x-for="opt in options">
    <button>{{ opt.label }}</button>   <!-- cloaks the panel body -->
  </template>
</div>

That one shipped to production as an empty install-instructions panel, seen by exactly the users who had nothing else to look at.

Why the cloak is right and still wrong

Cloaking is the correct default for a value that genuinely cannot be known server-side — it prevents a flash of . The gap is that there is no way to say "here is the value to render until then", so the only options are "cloak it" or "do not make it reactive".

The workaround is to keep the element's text static and move reactivity to attribute bindings, which do not cloak:

<button @click="submit()" :disabled="busy()">Send invitation</button>
<span :show="busy()" style="display:none">Sending…</span>

That works. It is also non-obvious, has to be rediscovered every time, and reads like a style preference rather than the correctness requirement it is.

What would help, roughly in order of preference

  1. Server-render the initial value when the expression is evaluable at render time. with busy initialised to false has a knowable first value. Cloak only when it genuinely cannot be resolved.
  2. An explicit opt-out — x-no-cloak, or an initial-value attribute — so an author can say "render this and let hydration correct it".
  3. Failing both, warn. A build-time or dev-mode warning when x-cloak lands on an element that is or contains an interactive control (button, a[href], input) would have caught all three of mine at the moment I wrote them.

Even just documenting the rule — reactive text cloaks, attribute bindings do not — would be a real improvement; I could not find it stated anywhere.

Context

Found across three panels in one app: an install-snippet panel (whole body blanked in production), a team invite button, and an invitation accept button. All three now use the static-text workaround, and the app has a probe asserting that nothing in those panels carries x-cloak except elements deliberately hidden at rest.

10 days ago

Fixed in 9a9ae6a — but before retitling this as an API gap, the blank-panel half is a plain bug, and it is the half that reached production.

addCloakToUnresolvedExpressions claimed to read an element's text content. It actually read everything up to the first </, which walks straight through child tags. So an element got stamped whenever any descendant subtree reached a mustache before the first closing tag anywhere:

<div class="panel"><button>{{ label }}</button></div>   →  panel cloaked
<nav class="top"><a href="/x">{{ name }}</a></nav>      →  whole nav cloaked

That is broader than the x-for case you hit — a plain nav with one dynamic link label hid the entire navigation. And it was order-dependent:

<div class="panel"><h2>Install</h2><button>{{ label }}</button></div>   →  panel NOT cloaked

because </h2> arrives before the mustache. Whether your panel disappeared came down to what its first child happened to be. That is almost certainly why it cost you three bugs rather than one — it presents as flakiness, not as a rule you can learn.

So the cloak is now scoped to the element's own character data, with descendant subtrees removed (depth-tracked, void elements and comments handled). Text after a child still counts — <p><b>Hi</b> async function name() { return (await authUser())?.name; }</p> is p's own text and does need the cloak.

Your option 2 landed as x-no-cloak. It opts out of both stamping passes, since an author saying "render this anyway" should not have to know which pass would have hidden the element. It is a genuine trade rather than a free win: opting out means an unresolved paints as literal braces until the runtime binds. The docs now steer to your own workaround first — static text plus an attribute binding — and reach for the opt-out only when being visible matters more than being right for one frame.

Your option 1 is not done. extractClientSignalNames collects signal names but not their initialisers, so resolving against state(false) at render time is a feature, not part of a fix. It is the better end state — it would make the opt-out rarely necessary — so it is worth its own issue if you want it.

And you were right that it was undocumented. prefix-convention.md described x-cloak purely as something you write by hand and never mentioned that stx stamps it for you. It now states the rule that decides whether markup is visible before hydration: reactive text cloaks, attribute bindings do not, and only the element holding the expression.

13 tests cover both directions. A sabotage run against the old scan fails six of them.

On the retitle: I would keep it as a bug report and let the API-gap part be the follow-up issue for option 1. The title as written — a primary control shipping display:none — is accurate for what was happening, and "working as designed" would have been the wrong conclusion to record on it.

Sign in to comment on this issue.