Skip to content

Commit bf5ee4e

Browse files
fix(tooltip): ship the x-tooltip runtime, which was exported and never called
`getTooltipRuntime()` was written, exported from `builtins/index.ts`, and called by nothing - two definitions and zero call sites, in stx and in `bun-plugin-stx`. `registerBuiltins()` registers ten builtins and tooltip was not one of them. So the attribute reached the DOM and nothing ever acted on it: hovering showed the native `title` tooltip, which would have shown anyway. The dead export was not merely unused. The codemod's largest and only auto-fixable rule rewrites `title=` into `x-tooltip=`, so every consuming codebase was being pointed at an attribute that had never worked - one downstream tree had 169 findings, all of which would have been no-ops. It read as an adoption problem (`codemod.d.ts` records `x-tooltip` as "delivered and used zero times") when the cause was that there was nothing to adopt. 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. Placed before the CSP pass so a nonce still reaches it, and anchored on the LAST `</body>` - the first one in a document is routinely inside a script's string content (CLAUDE.md item 24). Not a registered builtin, because it is not a component: it is one delegated listener pair on `document`, so it costs nothing when no element matches and covers elements that arrive after hydration or an SPA swap without per-element wiring. Detection requires the name to sit inside a tag, so a docs page that mentions the attribute in prose does not ship a runtime it never uses, and the scan walks to the closing quote rather than the first `>` so a tooltip reading `a > b` still counts. Closes #1922
1 parent 4b594c1 commit bf5ee4e

3 files changed

Lines changed: 214 additions & 1 deletion

File tree

packages/stx/src/process.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ import { importOnce } from './lazy-module'
7474

7575
// Re-export public API from extracted modules (preserves backwards compatibility)
7676
export { injectRouterScript } from './runtime-injection'
77-
import { injectSignalsRuntime, outputNeedsSignalsRuntime, pageShipsSignalsRuntime } from './runtime-injection'
77+
import { injectSignalsRuntime, injectTooltipRuntime, outputNeedsSignalsRuntime, pageShipsSignalsRuntime } from './runtime-injection'
7878
export { processJsonDirective, processOnceDirective } from './misc-directives'
7979
export { validateClientScript } from './script-validation'
8080

@@ -1918,6 +1918,11 @@ else {
19181918
if (!pageShipsSignalsRuntime(output) && outputNeedsSignalsRuntime(output))
19191919
output = await injectSignalsRuntime(output, opts)
19201920

1921+
// Same question, asked of the same finished output: `x-tooltip` had a complete
1922+
// runtime that nothing ever called, so the attribute shipped and no tooltip
1923+
// appeared (#1922). Before the nonce pass below, so CSP still covers it.
1924+
output = injectTooltipRuntime(output)
1925+
19211926
// Nonces must be applied last. Compiler-owned runtime, scoped, analytics,
19221927
// and appearance scripts may be introduced after @csp was processed.
19231928
if (opts.csp?.enabled && opts.csp.useNonce && typeof context.cspNonce === 'string')

packages/stx/src/runtime-injection.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ import type { StxOptions } from './types'
1111
import { getOwnedRouteMatchers } from './owned-routes'
1212
import { toScriptJson } from './script-json'
1313
import { findBodyOpenTag, replaceBodyOpenTag } from './find-body-tag'
14+
// Static: `builtins/tooltip` exports one pure function and imports nothing, so
15+
// there is no cycle and no cost to pulling it in here.
16+
import { getTooltipRuntime } from './builtins/tooltip'
1417

1518
/**
1619
* Inject an @stacksjs/browser initialization input into a template that will
@@ -270,6 +273,65 @@ export async function injectSignalsRuntime(template: string, options: StxOptions
270273
return placeRuntimeTag(template, `<script data-stx-scoped data-stx-runtime>${runtime}</script>`)
271274
}
272275

276+
/**
277+
* Whether the finished page has an element carrying `x-tooltip`.
278+
*
279+
* Asked of the OUTPUT rather than the source, for the reason
280+
* `outputNeedsSignalsRuntime` is: both the attribute and every rewrite that
281+
* could have produced it have happened by then, so no processing order can hide
282+
* the answer.
283+
*
284+
* Anchored on `<` … so the name has to sit inside a tag: documentation prose
285+
* that merely names the attribute does not pull a runtime onto the page. The
286+
* attribute value is allowed to contain `>` — a tooltip reading `a > b` is
287+
* ordinary — so the scan walks to the quote rather than to the first `>`.
288+
*/
289+
export function outputNeedsTooltipRuntime(html: string): boolean {
290+
return /<[a-z][^>]*?\sx-tooltip\s*=/i.test(html)
291+
}
292+
293+
/**
294+
* Ship the `x-tooltip` runtime on any page that uses the attribute.
295+
*
296+
* `getTooltipRuntime()` was written, exported from `builtins/index.ts`, and
297+
* called by nothing — two definitions and zero call sites, in stx and in
298+
* `bun-plugin-stx`. `registerBuiltins()` registers ten builtins and tooltip is
299+
* not one of them, so the attribute reached the DOM and nothing ever acted on
300+
* it. Hovering showed the native `title` tooltip, which would have shown anyway
301+
* (stacksjs/stx#1922).
302+
*
303+
* The dead export was not merely unused: the codemod's largest and only
304+
* auto-fixable rule rewrites `title=` into `x-tooltip=`, so every consuming
305+
* codebase was being pointed at an attribute that had never worked. It read as
306+
* an adoption problem — `codemod.d.ts` records `x-tooltip` as "delivered and
307+
* used zero times" — when the cause was that there was nothing to adopt.
308+
*
309+
* Not a registered builtin, because it is not a component: it is one delegated
310+
* listener pair on `document`, so it costs nothing when no element matches and
311+
* covers elements added later without per-element wiring. Injected only when the
312+
* attribute is present, and before the CSP pass so a nonce still reaches it.
313+
*/
314+
export function injectTooltipRuntime(html: string): string {
315+
if (html.includes('data-stx-tooltip-runtime') || !outputNeedsTooltipRuntime(html))
316+
return html
317+
318+
const tag = `<script data-stx-scoped data-stx-tooltip-runtime>${getTooltipRuntime()}</script>`
319+
320+
/*
321+
* `lastIndexOf`, never `.replace('</body>', …)`.
322+
*
323+
* The FIRST `</body>` in a document is routinely inside a script's string
324+
* content — the router runtime and the x-element runtime both contain one —
325+
* and replacing that occurrence injects this script into the middle of
326+
* another one, breaking the page. See CLAUDE.md item 24.
327+
*/
328+
const bodyIdx = html.lastIndexOf('</body>')
329+
if (bodyIdx === -1)
330+
return `${html}\n${tag}`
331+
332+
return `${html.slice(0, bodyIdx)}${tag}\n${html.slice(bodyIdx)}`
333+
}
334+
273335
/**
274336
* Inject the SPA router script into the template.
275337
* The router is provided by the canonical router in packages/router/src/client.ts.
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/**
2+
* `x-tooltip` ships the runtime that makes it work (stacksjs/stx#1922).
3+
*
4+
* `getTooltipRuntime()` was written, exported from `builtins/index.ts`, and
5+
* called by nothing — two definitions and zero call sites, in stx and in
6+
* `bun-plugin-stx`. `registerBuiltins()` registers ten builtins and tooltip was
7+
* not one of them. So the attribute reached the DOM and nothing ever acted on
8+
* it: hovering showed the native `title` tooltip, which would have shown anyway.
9+
*
10+
* The dead export was not merely unused. The codemod's largest and only
11+
* auto-fixable rule rewrites `title=` into `x-tooltip=`, so every consuming
12+
* codebase was being pointed at an attribute that had never worked — one
13+
* downstream tree had 169 findings, all of which would have been no-ops. It read
14+
* as an adoption problem (`codemod.d.ts` records `x-tooltip` as "delivered and
15+
* used zero times") when the cause was that there was nothing to adopt.
16+
*
17+
* Asked of the finished OUTPUT, for the reason `outputNeedsSignalsRuntime` is:
18+
* both the attribute and every rewrite that could have produced it have happened
19+
* by then, so no processing order can hide the answer.
20+
*/
21+
22+
import { describe, expect, it } from 'bun:test'
23+
import { getTooltipRuntime } from '../src/builtins/tooltip'
24+
import { processDirectives } from '../src/process'
25+
import { injectTooltipRuntime, outputNeedsTooltipRuntime } from '../src/runtime-injection'
26+
27+
async function render(body: string): Promise<string> {
28+
return processDirectives(
29+
`<!DOCTYPE html><html><body>${body}</body></html>`,
30+
{},
31+
`${import.meta.dir}/tooltip-fixture.stx`,
32+
{ debug: false, cache: false } as any,
33+
new Set(),
34+
)
35+
}
36+
37+
describe('a page that uses x-tooltip', () => {
38+
it('gets the runtime', async () => {
39+
const html = await render(`<button x-tooltip="Bold" title="Bold">B</button>`)
40+
41+
expect(html).toContain('data-stx-tooltip-runtime')
42+
})
43+
44+
it('gets the real runtime, not just a marker', async () => {
45+
// The whole defect was a runtime that existed and was never delivered. A
46+
// tag with the right attribute and no code in it would reproduce it exactly.
47+
const html = await render(`<button x-tooltip="Bold">B</button>`)
48+
49+
expect(html).toContain('stx-tooltip')
50+
expect(html).toContain('mouseover')
51+
expect(html).toContain('[x-tooltip]')
52+
})
53+
54+
it('keeps the attribute, since the runtime reads it from the DOM', async () => {
55+
const html = await render(`<button x-tooltip="Bold" title="Bold">B</button>`)
56+
57+
expect(html).toContain('x-tooltip="Bold"')
58+
})
59+
60+
it('injects it once, not once per element', async () => {
61+
const html = await render(
62+
`<button x-tooltip="One">1</button><button x-tooltip="Two">2</button>`,
63+
)
64+
65+
expect(html.split('data-stx-tooltip-runtime').length - 1).toBe(1)
66+
})
67+
})
68+
69+
describe('a page that does not', () => {
70+
it('gets nothing', async () => {
71+
const html = await render(`<p>no tooltips here</p>`)
72+
73+
expect(html).not.toContain('data-stx-tooltip-runtime')
74+
})
75+
})
76+
77+
describe('deciding whether the attribute is present', () => {
78+
it('requires it to be inside a tag', () => {
79+
// Documentation that names the attribute is prose, not markup — the same
80+
// distinction `blankInertHtmlRegions` draws for the signals runtime, and the
81+
// reason a docs page does not ship a runtime it never uses (#1835).
82+
expect(outputNeedsTooltipRuntime('<p>Use x-tooltip="..." to add one.</p>')).toBe(false)
83+
expect(outputNeedsTooltipRuntime('<button x-tooltip="Save">S</button>')).toBe(true)
84+
})
85+
86+
it('allows a `>` inside the tooltip text', () => {
87+
// A tooltip reading `a > b` is ordinary, and stopping the scan at the first
88+
// `>` would miss it — the same trap that silently killed bindings in #1771.
89+
expect(outputNeedsTooltipRuntime('<button x-tooltip="a > b">x</button>')).toBe(true)
90+
})
91+
})
92+
93+
describe('where the runtime is placed', () => {
94+
it('goes before the LAST </body>, never the first', () => {
95+
/*
96+
* CLAUDE.md item 24. The first `</body>` in a document is routinely inside
97+
* a script's string content — the router and x-element runtimes both carry
98+
* one — so `.replace('</body>', …)` injects into the middle of another
99+
* script and breaks the page. This fixture is that shape exactly.
100+
*/
101+
const page = `<html><body><script>var t = "</body>";</script>`
102+
+ `<button x-tooltip="Save">S</button></body></html>`
103+
104+
const out = injectTooltipRuntime(page)
105+
const runtimeAt = out.indexOf('data-stx-tooltip-runtime')
106+
const decoyAt = out.indexOf('var t =')
107+
108+
expect(runtimeAt).toBeGreaterThan(decoyAt)
109+
expect(out).toContain('var t = "</body>";')
110+
expect(runtimeAt).toBeLessThan(out.lastIndexOf('</body>'))
111+
})
112+
113+
it('appends when there is no body to close', () => {
114+
// A fragment has no `</body>`. Dropping the runtime there would make the
115+
// attribute silently dead again, which is the bug.
116+
const out = injectTooltipRuntime(`<button x-tooltip="Save">S</button>`)
117+
118+
expect(out).toContain('data-stx-tooltip-runtime')
119+
})
120+
121+
it('does not add a second copy to a page that already has one', () => {
122+
const once = injectTooltipRuntime(`<button x-tooltip="Save">S</button>`)
123+
124+
expect(injectTooltipRuntime(once)).toBe(once)
125+
})
126+
})
127+
128+
describe('the runtime source itself', () => {
129+
it('binds by delegation, so elements added later are covered', () => {
130+
// Delegation is why this is not a registered builtin: one listener pair on
131+
// `document` costs nothing when nothing matches, and needs no per-element
132+
// wiring for content that arrives after hydration or an SPA swap.
133+
const source = getTooltipRuntime()
134+
135+
expect(source).toContain('document.addEventListener')
136+
expect(source).toContain('closest("[x-tooltip]")')
137+
})
138+
139+
it('responds to keyboard focus, not only to hover', () => {
140+
// A tooltip only reachable by mouse is not reachable by everyone.
141+
const source = getTooltipRuntime()
142+
143+
expect(source).toContain('focusin')
144+
expect(source).toContain('focusout')
145+
})
146+
})

0 commit comments

Comments
 (0)