ReviewOS

stacks/stx

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

#2077
Closed glennmichael123 opened this 9 days ago ·
9 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.

9 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.