ReviewOS

stacks/stx

fix(components): x-tooltip on a component never reaches the DOM, so the tooltip silently disappears

#2077
Closed glennmichael123 opened this 10 days ago ·
10 days ago

x-tooltip is the one attribute that a component neither forwards to its concrete root nor rewrites into a binding. It lands only inside data-stx-props, and the tooltip runtime reads it off the DOM, so putting it on a component deletes the tooltip with no error, no warning and no visible difference until you hover.

Measured on 0.2.532.

Reproduction

import { processComponents } from '@stacksjs/stx/dist/component-renderer.js'

const run = (src: string) => processComponents(src, {
  __stx_client_signal_names: [],
  __importedComponents: new Map([['button', '<path to>/@stacksjs/components/src/ui/button/Button.stx']]),
}, 'x.stx', { componentsDir: 'resources/components' }, new Set())

await run('<Button x-tooltip="Remove">Remove</Button>')
attribute on <Button>emitted on the concrete rootpresent in data-stx-props
x-tooltip="Remove"noyes
x-tooltip-position="top"noyes
title="Remove"yesyes
aria-label="Remove"yesyes
data-row="7"yesyes

The two that fail are exactly the two the runtime consumes from the DOM. dist/chunk-g5t10z04.js:

function showTip(el) {
  var text = el.getAttribute("x-tooltip");
  if (!text) return;
  ...
}

The attribute is never on the element, so showTip returns early and nothing renders.

Why this is a gap rather than the older bug

The previously reported failure was different and is fixed: component-renderer.js rewrites an x-* attribute on a component into a :name signal binding unless the name is in ALPINE_PASSTHROUGH, and x-tooltip used to be absent from that set, so x-tooltip="Settings" became :tooltip="Settings" and the page died with hydration invariant failed ... Settings is not defined.

In 0.2.532 it is in the set:

// dist/runtime-globals.js
export const RUNTIME_HANDLED_X_ATTRS = [..., "x-tooltip", "x-tooltip-position"]
export const COMPONENT_PASSTHROUGH_X_ATTRS = [...RUNTIME_HANDLED_X_ATTRS, ...ALPINE_BRIDGE_X_ATTRS]
// dist/component-renderer.js
const attrName = rawAttrName.startsWith("x-") && !ALPINE_PASSTHROUGH.has(rawAttrName) && ... ? `:${rawAttrName.slice(2)}` : rawAttrName

So it correctly escapes the rewrite. What it does not do is reach the root. Since #2063 forwards DOM, data-* and aria-* attributes to the concrete root, x-tooltip falls between the two paths: too x- to be forwarded as a DOM attribute, too passthrough to become a binding.

The crash was louder and therefore easier. This one costs a tooltip per call site and shows up in review as "the tooltip feels missing".

Impact

It blocks component adoption wherever a tooltip is involved. In loghq's settings page, nine buttons hand-roll :disabled="someBusy()" with no aria-busy; five could adopt <Button> and did, and the remaining four are blocked only by this. title works and is a different tooltip (the browser's, not the app's), which is not a trade worth making for a consistent surface.

Expected

An x-* attribute in COMPONENT_PASSTHROUGH_X_ATTRS should be emitted on the concrete root, the same way title and data-* are. x-cloak, x-ref and x-transition are presumably in the same position, since they are also read off the element by the runtime rather than evaluated as expressions.

10 days ago

Already fixed on main by 3525160823 (fix(components): derive the x- passthrough list from the runtime's own handled set), released in stx v0.2.540. The dedicated component-x-attr-passthrough regression suite passes 8/8 on current main and covers both x-tooltip and x-tooltip-position reaching the concrete DOM root without becoming reactive props.

Sign in to comment on this issue.