ReviewOS

stacks/stx

Button: a reactive expression in the slot renders to nothing, so the button ships unnamed — #2034's `:label` fix does not cover it

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

Button: a reactive expression in the slot renders to nothing, so the button ships with no text — the :label fix for #2034 does not cover it.

#2034 and its fix in 0.2.384 address the :label prop and add ariaLabel for a button that has no slot content. There is a third shape that still fails, and it is the one that reads most naturally:

<Button type="submit" :loading="loading()" fullWidth>
  {{ loading() ? 'Signing in…' : 'Sign in' }}
</Button>

The slot is the caller's markup interpolated in the caller's scope at render time, and loading is a client signal, so the expression resolves to nothing. The served button is:

<button type="submit" class="inline-flex items-center justify-center … w-full btn-accent" aria-busy="false" …>
  <svg class="animate-spin h-5 w-5" style="display:none">…</svg>
</button>

A spinner and no text. textContent is whitespace, there is no aria-label, and it stays that way after hydration — the slot was never emitted, so there is nothing for the runtime to fill in. Six call sites across one app's auth pages shipped like that: the sign-in, sign-up, password-reset and create-app buttons all had no accessible name and nothing visible on them.

Verified identical on 0.2.382 and 0.2.395, rebuilding in between.

Why it is easy to land on

The component's own comment says the slot "cannot express" a state-dependent label and points at :label. That is correct, but nothing tells the caller: the expression is valid stx, the build reports success, typecheck and lint pass, and the page renders. The only gate that caught it was an accessibility audit noticing an unnamed control.

The contrast that makes it confusing is that a native element in the same file works:

<button :disabled="loading()">{{ loading() ? 'Redirecting…' : 'Upgrade to Pro' }}</button>

There the literal is emitted as text and hydration replaces it, so the page is correct. Same expression, same scope, opposite outcome, depending only on whether the tag is a component.

Suggested fix

Either of these would do, in order of preference:

  1. Emit the slot's literal expression as text the way a native element does, so hydration can replace it. That removes the asymmetry entirely and makes the natural spelling correct.
  2. Warn at render when a component's slot content contains an unresolved after interpolation — it means the caller referenced something the server could not see, which is almost always a client signal and almost never intended.

Even just (2) would have turned six silently broken production buttons into a build-time message.

Found on 0.2.382, still present on 0.2.395. Related: #2034, #2037.

9 days ago

Fixed in 497f354, with your preferred option 1 — the slot now behaves exactly like a native element.

The cause is broader than the report, and simpler

It is not that the slot "cannot express" a reactive label. Slot content was substituted into the component and then interpolated with the component's context, so every name the component happens to declare shadowed the caller's. <Button> alone declares label, loading, disabled, type, size, variant and className — ordinary names for a page to use.

Measured before the fix:

caller writesrenderedwhy
"Dashboard"✅ name is free
""❌ got Button's label, which is ''
""❌ called Button's boolean, threw
preserved✅ name is free

So a caller's plain server value was silently replaced too, not just a signal — in a page rendered the component's empty string. And the third row needs no :loading prop at all to break: Button declares loading itself, so <Button></Button> fails on its own.

That also explains the contrast you drew with the native <button>. The difference was never the expression, and never reactivity — it was whose scope evaluated it.

The fix

No new mechanism was needed. The caller's belong to the caller's scope, exactly like the caller's <script> and the caller's @include, which renderComponentWithSlot already hoists for precisely this reason. Resolving the slot against the parent context makes a caller's server value resolve and a caller's signal survive as a literal for the runtime.

The second half is keeping them: what the caller's pass deliberately leaves as has to be named in the component context's preserved set, or the component's own pass evaluates it a second time against the scope that shadowed it to begin with.

One sharp edge found by an existing test: a gate that is undefined now means preserve, not evaluate. A direct renderComponentWithSlot call passes a bare context, and reading that as "no signals here" blanked a named slot's — the very failure being removed. Leaving an expression alone is recoverable; evaluating one that belonged to the client is not.

Verified

Both shapes, the parity case (component and native element rendering identically from the same expression), and — the one that matters most — the runtime actually filling the preserved expression in on hydration, driven through the real router and signals runtime. Shipping a literal mustache that never resolves would have been worse than the bug.

Full suite 14709 pass.

Option 2 from the report, warning on an unresolved in slot content, is no longer needed for this: the natural spelling is now correct rather than merely diagnosed.

Sign in to comment on this issue.