ReviewOS

stacks/stx

x-tooltip on a component is rewritten to :tooltip and evaluated as an expression — hydration invariant failure on stx's own attribute

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

x-tooltip is stx's own attribute — the signals runtime implements the tooltip and lists it as handled. But putting it on a component silently converts it into a signal binding over an undefined identifier, which kills hydration for the whole page.

Reproduction

<StxLink to="/settings" class="icon-btn" x-tooltip="Settings" aria-label="Settings">
  <Icon name="lucide:settings" size="17" />
</StxLink>

Rendered HTML:

<a data-stx-parent-bindings="tooltip" :tooltip="Settings" href="/settings" …>

Browser console:

[stx] hydration invariant failed after DOMContentLoaded: 1 expression(s) never evaluated — Settings → Settings is not defined.

The x-tooltip attribute the runtime looks for is gone, so the tooltip never works, and Settings is evaluated as a bare identifier.

Cause — two lists that disagree

packages/stx/src/component-renderer.ts:154-159 rewrites any x-* attribute on a component to :* unless the name is in ALPINE_PASSTHROUGH:

const attrName = (rawAttrName.startsWith('x-')
  && !ALPINE_PASSTHROUGH.has(rawAttrName)
  && !rawAttrName.startsWith('x-bind:')
  && !rawAttrName.startsWith('x-on:'))
  ? `:${rawAttrName.slice(2)}`
  : rawAttrName

ALPINE_PASSTHROUGH (component-renderer.ts:111-115) has 16 entries. The signals runtime keeps its own list of attributes it consumes, X_HANDLED (signals.ts:2295):

var X_HANDLED = {'x-text':1,'x-html':1,'x-model':1,'x-show':1,'x-if':1,'x-for':1,'x-cloak':1,
                 'x-ref':1,'x-data':1,'x-bind':1,'x-class':1,'x-style':1,
                 'x-tooltip':1,'x-tooltip-position':1};

x-tooltip and x-tooltip-position are in X_HANDLED and not in ALPINE_PASSTHROUGH. They are the only two in that gap, and they are the two the runtime added most recently (#1673).

Why it is worse than a dead tooltip

The failure is not "the tooltip doesn't show". :tooltip="Settings" becomes a real binding, so the page reports a hydration invariant failure — the class of bug where content silently fails to materialise after an SPA swap. On a page whose value is behind hydration, one mistyped tooltip takes the page down with a console message that names the tooltip text, not the tooltip.

It is also unguessable from the authoring side: the same attribute on a plain <button> works perfectly. Only components mangle it, and every stx builtin is a component — <Icon x-tooltip="…"> fails the same way.

Suggested fix

Derive one list from the other rather than maintaining two. Minimally, add 'x-tooltip', 'x-tooltip-position' to ALPINE_PASSTHROUGH; better, export the runtime's handled-attribute set and have component-renderer consult it, so the next runtime-handled x- attribute cannot reopen this.

A second guard worth having independently: when the rewrite produces a : binding whose expression is a bare identifier that resolves to nothing, that is almost always this mistake rather than intent.

Environment

Reproduced on the fork at f539929dfe (rebuilt dist), driving a real app (loghq) headlessly over CDP. Worked around app-side by hosting x-tooltip on a plain <span> wrapper, which is the only safe host today.

9 days ago

Fixed in 3525160823, taking the "better" option — the lists are derived from one source rather than both maintained.

Confirmed exactly as diagnosed

Your reading of the two lists was right down to the pair. At the parent commit, your repro renders:

<a data-stx-parent-bindings="tooltip" :tooltip="Settings" href="/settings" class="icon-btn" …>

and now:

<a href="/settings" class="icon-btn" data-stx-link … x-tooltip="Settings" aria-label="Settings">

x-tooltip preserved, no :tooltip, and no data-stx-parent-bindings — so the tooltip works and hydration is intact. x-tooltip-position too.

What it derives from

RUNTIME_HANDLED_X_ATTRS now lives in runtime-globals.ts — a true leaf, so the renderer, the runtime generator and events.ts can all reach it. The generated X_HANDLED is interpolated from it (verified byte-identical to the literal it replaced; this is the first interpolation into that template literal, so runtime-parses.test.ts was the thing to watch).

One detail worth naming: the passthrough set is not simply X_HANDLED. Deriving from it alone would have dropped x-init, x-transition, x-effect and x-on — Alpine-bridge attributes the signals runtime never sees but which must still survive on a component. So it is RUNTIME_HANDLED_X_ATTRS ∪ ALPINE_BRIDGE_X_ATTRS, and there is a test pinning that second half specifically, since the obvious refactor silently loses it.

The drift guard

This is the fourth time two hand-kept lists of the same thing have disagreed here (#1804, #1819, #1820, #1824), so the durable half is a guard that pulls X_HANDLED out of the generated runtime and asserts every name in it may appear on a component. It throws rather than reporting nothing if the shape changes — a guard that quietly matches no names is a test that passes because it checked nothing.

On your second suggestion

when the rewrite produces a : binding whose expression is a bare identifier that resolves to nothing, that is almost always this mistake rather than intent

I did not add it, and I think it is now mostly redundant: with the lists unified, the rewrite can only produce a binding for an attribute nothing consumes. As a general diagnostic it would also have to run after evaluation, where an unresolved identifier is already reported by the hydration audit — the message you quoted. If you still want it as a compile-time warning, worth its own issue; it is a different mechanism from this fix.

Three of the four behavioural tests fail at the parent commit (the fourth is the plain-element control, which always worked). Full suite 10734 pass in a clean worktree; the two failures are the pre-existing crosswind-arbitrary-values case and a package-exports timeout that passes in isolation.

Sign in to comment on this issue.