ReviewOS

stacks/stx

x-tooltip renders but never initialises: getTooltipRuntime() is exported and called by nothing

#1922
Closed glennmichael123 opened this 14 days ago · 4 comments
14 days ago

x-tooltip is the target of the codemod's largest and only auto-fixable rule, and it does nothing at runtime. The attribute reaches the DOM, no runtime is ever installed, so no tooltip appears.

Found while migrating a downstream tree's 169 tooltip findings. Reverted the migration rather than land it, because every site would have been a no-op.

The chain

builtins/tooltip.js defines the runtime and it looks complete: creates #stx-tooltip, positions with flip handling, wires the listeners.

builtins/index.js:9 re-exports it:

export { getTooltipRuntime } from "./tooltip.js"

and that is the last thing that ever mentions it.

$ grep -rn "getTooltipRuntime" node_modules/@stacksjs/stx/dist node_modules/bun-plugin-stx/dist
dist/builtins/tooltip.js:1:export function getTooltipRuntime() {
dist/builtins/index.js:9:export { getTooltipRuntime } from "./tooltip.js";

Two definitions, zero call sites, in stx and in bun-plugin-stx.

registerBuiltins() registers ten builtins and tooltip is not one of them:

export function registerBuiltins() {
  registry.registerBuiltin(StxLinkBuiltin)
  registry.registerBuiltin(StxImageBuiltin)
  registry.registerBuiltin(SafeImageBuiltin)
  registry.registerBuiltin(StxLoadingIndicatorBuiltin)
  registry.registerBuiltin(StxToastBuiltin)
  registry.registerBuiltin(StxModalBuiltin)
  registry.registerBuiltin(StxDrawerBuiltin)
  registry.registerBuiltin(IconBuiltin)
  registry.registerBuiltin(SuspenseBuiltin)
  registry.registerBuiltin(TransitionGroupBuiltin)
}

Reproduction

<!-- pages/index.stx -->
<div class="p-10">
  <button x-tooltip="Bold" title="Bold">B</button>
  <a href="#" x-tooltip="View errors" title="View errors">errors</a>
</div>
$ stx build --pages pages --out out --no-minify
$ grep -o 'x-tooltip="[^"]*"' out/index.html
x-tooltip="Bold"
x-tooltip="View errors"

$ grep -c 'stx-tooltip\|getTooltipRuntime' out/index.html
0

The attributes are preserved. Nothing that would act on them is emitted. Hovering shows only the native title tooltip, which is what would have shown anyway.

Note the .stx-tooltip CSS and #stx-tooltips teleport target that also appear in cli.js belong to the separate @tooltip directive (<span class="stx-tooltip" data-tooltip="…">), not to this attribute. Two different features with similar names; only the directive one is wired.

Why this matters more than an unused export

codemod.d.ts already records the symptom without connecting it to the cause:

stxConfirm and x-tooltip were both delivered and used zero times.

That was read as an adoption problem, and the tooltip rule exists to drive adoption. But x-tooltip has zero uses because it has never worked. The codemod is currently pointing every title= in every consuming codebase at a dead attribute, and it is the highest-count rule by an order of magnitude.

The damage is bounded, and worth stating plainly: the rewrite is additive and keeps title, so nobody who ran --fix broke anything or lost the native tooltip or the screen-reader announcement. They just got no styled tooltip and no error explaining why.

Two smaller things found alongside

--fix is correctly idempotent and I want that on the record, since I initially misread the report output as a re-offer. A site that already has the attribute is moved to a separate bucket rather than double-inserted:

Needs a human — 1 site(s):
  B.stx:1  [tooltip]  <button x-tooltip="Bold" title="Bold">B</button>
      already has x-tooltip

useClipboard and useShare are not in STX_RUNTIME_GLOBALS. The clipboard rule tells you to "use useClipboard() / useShare()", and both are implemented (browser-composables.js, composables/use-share.js), but neither is in the injected-globals list, so following the advice verbatim in an .stx script block yields a ReferenceError. The other six names the codemod recommends (useDebounce, useThrottle, useFetch, useAsyncData, useClickOutside, useSearchParams) are all in the list. Either the globals list is missing two entries or the rule message needs to mention the import.

Suggested fix

Register the tooltip runtime the way the other builtins are registered, or emit getTooltipRuntime() into the page whenever a compiled template contains an x-tooltip attribute. Until then the tooltip rule is recommending a no-op, and it might be worth gating it behind that.

Found with stx 0.2.171.

14 days ago

Fixed in bf5ee4e. Your chain was exactly right — two definitions, zero call sites, and registerBuiltins() never touching it.

x-tooltip now ships its runtime. Injected when the finished output carries the attribute, alongside the signals-runtime decision and for the same reason: both the attribute and every rewrite that could have produced it have happened by that point, so no processing order can hide the answer.

Deliberately not a registered builtin, since it isn't a component — it's one delegated listener pair on document. That costs nothing when no element matches, and it covers elements that arrive after hydration or an SPA swap without per-element wiring. Registering it as a builtin would have bought nothing and cost a component lifecycle.

Two placement details worth naming, both pinned by tests:

  • Anchored on the last </body>, never the first — the first one in a document is routinely inside a script's string content (the router and x-element runtimes both carry one), and .replace() there injects into the middle of another script. CLAUDE.md item 24.
  • Injected before the CSP pass, so a nonce still reaches it.

Detection requires the name to sit inside a tag, so a docs page mentioning x-tooltip in prose doesn't ship a runtime it never uses. The scan walks to the closing quote rather than the first >, so x-tooltip="a > b" still counts — same trap that silently killed bindings in #1771.

The codemod rule needs no gating now. It was pointing at a dead attribute; it now points at a working one. Your call on whether to re-run that 169-finding migration, but it will do something this time.

On the two smaller findings

--fix idempotence — noted, and thank you for putting it on the record rather than filing it. The "already has x-tooltip" bucket is deliberate.

useClipboard / useShare — your observation is correct but the consequence isn't: they are not in STX_RUNTIME_GLOBALS, and they also do not throw. They're delivered by the demand-bundle path (#1805), the same one useAsyncData and useForm use — framework-composables.ts bundles the real module and merges it onto window.stx when a script references the name.

Verified rather than reasoned about. Rendering a page whose <script client> calls useClipboard() emits:

<script data-stx-framework-composables>
;(function(){
  var __s = window.stx = window.stx || {};
// use-clipboard
function useClipboard(options = {}) { ... }

So the codemod's advice works verbatim as written. There are two delivery paths, not one, and STX_RUNTIME_GLOBALS is only the first — runtime-globals.ts documents the split, and authoring-surface.test.ts enforces that every exported composable is reachable by one of them or explicitly classified as neither. That's why nothing flagged these two.

Worth knowing about the demand-bundled shape, though, and it's a real limitation rather than a nit: these are pre-unification composables — a getter plus subscribe() plus a manual stop(), not signals. useClipboard().copy(text) works and subscribe() works, but reading .copied in a template won't re-render on its own. Converting them to the Signal contract is tracked separately, and it's the thing that would actually make them feel like the rest of the surface.

Verification

12 tests, sabotage-checked twice: removing the injection call fails 3, and swapping lastIndexOf for indexOf fails the placement test specifically.

Full suite 11837 pass. The 4 failures on that run (craft binary resolution ×2, X-STX-Runtime ×2) reproduce on a clean checkout of HEAD and are unrelated.

14 days ago

Two more findings from the same downstream migration, both the same shape as the tooltip runtime: the pieces exist, they are just not connected to each other.

The wiring matrix

Cross-checking every composable the codemod recommends against the browser bundle (browser-composables.js exports) and the injected globals list (STX_RUNTIME_GLOBALS):

composablein browser bundlein STX_RUNTIME_GLOBALSresult in an .stx script
useDebounceyesyesworks
useThrottleyesyesworks
useFetchyesyesworks
useClickOutsideyesyesworks
useSearchParamsnoyesReferenceError: declared global, never shipped
useClipboardyesnonot auto-imported, needs an explicit import
useSharenononeither
useAsyncDatanononeither

Half the recommended surface is reachable. The search-params rule and the clipboard rule both point at names that fail if followed verbatim, in opposite directions: one is declared but not shipped, the other is shipped but not declared.

useSearchParams is the worse of the two, since it is in the globals list. Code using it looks correct, passes review, and throws at runtime.

(This supersedes the useClipboard/useShare note in the original report, which had it as a docs problem. It is the same class of gap as getTooltipRuntime.)

Separately: the click-outside rule has a precision problem

All three click-outside findings in the tree I was migrating are false positives, and they fail the same way:

Storefront/QuantitySelector.stx:85   document.addEventListener('click', (e) => {
Storefront/CartDrawer.stx:201        document.addEventListener('click', async (e) => {
Storefront/ProductGallery.stx:70     document.addEventListener('click', (e) => {

Every one is event delegation — the handler opens with target.closest('[data-stx-qty-dec]'), target.closest('[data-stx-add-to-cart]'), target.closest('[data-stx-gallery-thumb]') and returns early if the click did not land inside that selector. useClickOutside(ref, fn) fires when a click lands outside a target. Adopting it would invert the behaviour in all three.

A cheap discriminator: a delegation handler's first move is almost always closest(...) followed by an early return on miss, whereas a dismissal handler tests !el.contains(event.target). Matching on document.addEventListener('click' alone cannot tell them apart.

Same root as the tooltip rule matching title= on component tags: the pattern is recognised, the surrounding intent is not.

For the record, what the rules got right

Of seventeen "primitive already covers" findings, the six debounce ones were exactly right and migrated cleanly — useDebounce registers its own onDestroy, so each site shed a timer variable, a clearTimeout/setTimeout pair, and a teardown block. 37 lines removed for 12 added. The two fetch findings were reasonable flags that turned out to be correct as written (an AbortController threaded into a typed project helper, which useFetch(url) is not a drop-in for).

14 days ago

Reopening: this shipped as fixed but is not fixed on 0.2.175.

The sibling reports from the same batch clearly did land, and the improvement is large. Against the same tree that produced the original numbers, the codemod goes from 169 auto-rewritable tooltip sites to 31 — which is exactly the native-element count, so the component-prop precision fix in #1902/#1905 is working:

0.2.171                          0.2.175
Would rewrite            169  →  31
Needs a human             24  →  138
An stx primitive covers   17  →  5

click-outside and search-params findings are gone too, so those rules were fixed as well.

The runtime wiring was not. On a clean bun add @stacksjs/stx@0.2.175:

$ grep -rc "getTooltipRuntime" dist/**/*.js
dist/builtins/tooltip.js:1     <- the definition
dist/builtins/index.js:1       <- the re-export

Still two occurrences, still zero call sites. registerBuiltins() is byte-identical to 0.2.171, the same ten builtins with tooltip absent:

StxLink, StxImage, SafeImage, StxLoadingIndicator, StxToast,
StxModal, StxDrawer, Icon, Suspense, TransitionGroup

End to end on 0.2.175:

$ cat pages/index.stx
<div><button x-tooltip="Bold" title="Bold">B</button></div>

$ stx build --pages pages --out out --no-minify
$ grep -c 'x-tooltip=' out/index.html                  # 1
$ grep -cE 'stx-tooltip|getTooltipRuntime' out/index.html   # 0

Attribute reaches the DOM, nothing initialises it.

Net effect of the batch: the tooltip rule now correctly identifies 31 real native-element sites instead of 169 mixed ones, and all 31 still migrate to a no-op. The precision fix made the rule trustworthy, which raises the cost of the runtime gap rather than lowering it — anyone who now trusts the smaller number and runs --fix gets 31 dead attributes instead of 169.

The composable matrix is also unchanged on 0.2.175

                 in browser bundle   in STX_RUNTIME_GLOBALS
useDebounce            yes                   yes
useFetch               yes                   yes
useClickOutside        yes                   yes
useSearchParams        no                    yes     <- declared, never shipped
useClipboard           yes                   no      <- shipped, never declared
useShare               no                    no
useAsyncData           no                    no

useClipboard still being outside the globals list is what keeps the remaining 3 clipboard findings unactionable downstream: the rule's advice throws a ReferenceError if followed verbatim in an .stx script block.

Happy to close again if the intent was to track the runtime separately, but I could not find another issue for it and 0.2.175 is the current published build.

14 days ago

You were testing 0.2.175, and the fix landed after it — bf5ee4ee60, now released in v0.2.176. Everything in your follow-up is accurate for the build you had.

Your exact repro, on current main:

$ stx build --pages pages --out out --no-minify
$ grep -c 'x-tooltip=' out/index.html          # 1
$ grep -cE 'stx-tooltip|data-stx-tooltip-runtime' out/index.html   # 2

getTooltipRuntime now has a real call site (runtime-injection.ts:318).

One thing to expect when you re-check: registerBuiltins() is still byte-identical, and deliberately so. The tooltip is not a component — it is one delegated listener pair on document, so registering it as a builtin would buy nothing and cost a component lifecycle. It is injected from the finished output when the attribute is present, which is also how the signals runtime decides. So grep registerBuiltins will keep showing ten builtins; grep data-stx-tooltip-runtime in the emitted HTML is the check that matters.

Your point about the precision fix raising the cost rather than lowering it was the right read, and it is why this got picked up immediately rather than queued.

On the composable matrix — I measured it and it disagrees with your table

All seven deliver on current main. Two independent delivery paths exist, and the table looks like it was built from the first one only:

on window.stxbundled into the page on demandworks in <script client>
useDebounce, useFetch, useClickOutsideyes—yes
useSearchParamsyes—yes
useClipboardnoyesyes
useSharenoyesyes
useAsyncDatanoyesyes

useSearchParams is on window.stx — checked with the repo's own runtimeWindowStxSurface() helper, and runtime-globals.test.ts (18 tests, 281 assertions) enforces that every name in STX_RUNTIME_GLOBALS exists there, so "declared, never shipped" cannot happen without that test failing.

useShare and useClipboard are delivered by framework-composables.ts (#1805), which bundles the real module and merges it onto window.stx when a script references the name. Rendering a page that calls useShare() emits its actual implementation inline — I read the emitted source rather than grepping for it.

So the clipboard rule's advice does work verbatim; the 3 clipboard findings are actionable. Not being in STX_RUNTIME_GLOBALS is not the same as not being shipped, and that distinction is under-documented — runtime-globals.ts describes it but nothing points at it from the codemod's messages. Happy to take that as a docs issue if you want it filed.

The real caveat with those two is different and worth knowing: they are pre-unification shapes — a getter plus subscribe() plus a manual stop(), not signals. useClipboard().copy(text) works; reading .copied in a template will not re-render on its own.

Sign in to comment on this issue.