@barefootjs/jsx 0.17.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/dist/adapters/interface.d.ts +20 -4
  2. package/dist/adapters/interface.d.ts.map +1 -1
  3. package/dist/adapters/parsed-expr-emitter.d.ts +3 -1
  4. package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
  5. package/dist/analyzer-context.d.ts.map +1 -1
  6. package/dist/analyzer.d.ts.map +1 -1
  7. package/dist/augment-inherited-props.d.ts +19 -0
  8. package/dist/augment-inherited-props.d.ts.map +1 -1
  9. package/dist/compiler.d.ts.map +1 -1
  10. package/dist/expression-parser.d.ts +1 -0
  11. package/dist/expression-parser.d.ts.map +1 -1
  12. package/dist/index.d.ts +19 -3
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +815 -251
  15. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/compute-inlinability.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +9 -0
  19. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts +19 -0
  22. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/emit-registration.d.ts +4 -2
  24. package/dist/ir-to-client-js/emit-registration.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/html-template.d.ts +41 -0
  26. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/types.d.ts +22 -1
  28. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  29. package/dist/jsx-to-ir.d.ts.map +1 -1
  30. package/dist/loop-destructure.d.ts +55 -18
  31. package/dist/loop-destructure.d.ts.map +1 -1
  32. package/dist/lowering-registry.d.ts +13 -0
  33. package/dist/lowering-registry.d.ts.map +1 -1
  34. package/dist/relocate.d.ts +28 -0
  35. package/dist/relocate.d.ts.map +1 -1
  36. package/dist/ssr-defaults.d.ts.map +1 -1
  37. package/dist/types.d.ts +68 -0
  38. package/dist/types.d.ts.map +1 -1
  39. package/package.json +2 -2
  40. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +284 -12
  41. package/src/__tests__/augment-inherited-props.test.ts +96 -0
  42. package/src/__tests__/compiler-runtime-contract.test.ts +11 -1
  43. package/src/__tests__/compiler-stress-1244.test.ts +13 -4
  44. package/src/__tests__/csr-substitution-safety-divergence.test.ts +137 -0
  45. package/src/__tests__/destructured-map-params.test.ts +11 -1
  46. package/src/__tests__/expression-parser.test.ts +63 -2
  47. package/src/__tests__/ir-sort-comparator.test.ts +261 -0
  48. package/src/__tests__/loop-destructure.test.ts +313 -0
  49. package/src/__tests__/loop-hoisted-template.test.ts +235 -0
  50. package/src/__tests__/props-destructuring.test.ts +110 -0
  51. package/src/__tests__/serialize-parsed-expr.test.ts +70 -3
  52. package/src/__tests__/ssr-defaults.test.ts +20 -0
  53. package/src/__tests__/staged-ir/11-template-primitive-registry.test.ts +231 -1
  54. package/src/__tests__/tagged-template-interleave.test.ts +268 -0
  55. package/src/__tests__/unsupported-expression.test.ts +194 -7
  56. package/src/adapters/interface.ts +20 -4
  57. package/src/adapters/parsed-expr-emitter.ts +19 -2
  58. package/src/analyzer-context.ts +20 -0
  59. package/src/analyzer.ts +74 -1
  60. package/src/augment-inherited-props.ts +139 -9
  61. package/src/compiler.ts +4 -0
  62. package/src/expression-parser.ts +237 -56
  63. package/src/index.ts +23 -1
  64. package/src/ir-to-client-js/collect-elements.ts +15 -1
  65. package/src/ir-to-client-js/compute-inlinability.ts +6 -1
  66. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +1 -0
  67. package/src/ir-to-client-js/control-flow/plan/loop.ts +9 -0
  68. package/src/ir-to-client-js/control-flow/stringify/loop.ts +30 -8
  69. package/src/ir-to-client-js/control-flow/stringify/template-parse.ts +30 -0
  70. package/src/ir-to-client-js/emit-registration.ts +4 -2
  71. package/src/ir-to-client-js/html-template.ts +198 -1
  72. package/src/ir-to-client-js/index.ts +1 -0
  73. package/src/ir-to-client-js/types.ts +22 -0
  74. package/src/jsx-to-ir.ts +409 -24
  75. package/src/loop-destructure.ts +89 -36
  76. package/src/lowering-registry.ts +16 -0
  77. package/src/relocate.ts +201 -14
  78. package/src/ssr-defaults.ts +34 -32
  79. package/src/types.ts +65 -0
@@ -121,8 +121,10 @@ describe('serializeParsedExpr', () => {
121
121
  test('purity gate: a folded method call in the body → null', () => {
122
122
  // `.toUpperCase()` folds to `array-method`, outside the evaluator surface.
123
123
  expect(serializeParsedExpr(parseExpression('item.name.toUpperCase()'))).toBeNull()
124
- // A higher-order call (`.filter`) likewise.
125
- expect(serializeParsedExpr(parseExpression('item.tags.filter(t => t)'))).toBeNull()
124
+ // A higher-order call OTHER than `.map`/`.filter` (#2094 widens only
125
+ // those two) still folds — `.some` returns a boolean-from-search, not a
126
+ // per-element transform, so it has no evaluator lowering.
127
+ expect(serializeParsedExpr(parseExpression('item.tags.some(t => t)'))).toBeNull()
126
128
  // An unsupported shape.
127
129
  expect(serializeParsedExpr(parseExpression('a instanceof B'))).toBeNull()
128
130
  })
@@ -159,8 +161,73 @@ describe('serializeParsedExpr', () => {
159
161
  })
160
162
 
161
163
  test('every other array-method still folds outside the evaluator surface', () => {
162
- expect(serializeParsedExpr(parseExpression('item.tags.join(",")'))).toBeNull()
164
+ // `.join` is now serializable (#2094) — see the `.join` describe block
165
+ // below. `.slice` / `.flat` are not.
163
166
  expect(serializeParsedExpr(parseExpression('item.tags.slice(0, 1)'))).toBeNull()
167
+ expect(serializeParsedExpr(parseExpression('item.tags.flat()'))).toBeNull()
168
+ })
169
+
170
+ // ----- #2094: nested `.map` / `.filter` / `.join` inside a callback body --
171
+ test('`.join(sep?)` array-method serializes', () => {
172
+ expect(evalJSON('item.tags.join(",")')).toEqual({
173
+ kind: 'array-method',
174
+ method: 'join',
175
+ object: { kind: 'member', object: { kind: 'identifier', name: 'item' }, property: 'tags' },
176
+ args: [{ kind: 'literal', value: ',' }],
177
+ })
178
+ // No-arg form keeps an empty `args` (the evaluator defaults the separator).
179
+ expect(evalJSON('item.tags.join()')).toEqual({
180
+ kind: 'array-method',
181
+ method: 'join',
182
+ object: { kind: 'member', object: { kind: 'identifier', name: 'item' }, property: 'tags' },
183
+ args: [],
184
+ })
185
+ })
186
+
187
+ test('nested `.map(cb)` serializes as an ordinary call/member/arrow — NOT a bespoke wrapper', () => {
188
+ // Deliberately the SAME shape a top-level `.filter(t => t)` parses to —
189
+ // this is what lets the `eval-vectors.json` corpus (which carries the
190
+ // genuine `ParsedExpr`, unfiltered by `toEvalNode`) exercise the exact
191
+ // encoding the compiled-template embedded body uses.
192
+ expect(evalJSON("item.tags.map(t => '#' + t)")).toEqual({
193
+ kind: 'call',
194
+ callee: {
195
+ kind: 'member',
196
+ object: { kind: 'member', object: { kind: 'identifier', name: 'item' }, property: 'tags' },
197
+ property: 'map',
198
+ computed: false,
199
+ },
200
+ args: [
201
+ {
202
+ kind: 'arrow',
203
+ params: ['t'],
204
+ body: {
205
+ kind: 'binary',
206
+ op: '+',
207
+ left: { kind: 'literal', value: '#' },
208
+ right: { kind: 'identifier', name: 't' },
209
+ },
210
+ },
211
+ ],
212
+ })
213
+ })
214
+
215
+ test('nested `.filter(cb)` composed with `.length` and a comparison (the doc/#2038 shape) serializes', () => {
216
+ expect(serializeParsedExpr(parseExpression('item.tags.filter(t => t.active).length > 0'))).not.toBeNull()
217
+ })
218
+
219
+ test('a 2-param nested arrow `(item, index)` serializes', () => {
220
+ expect(serializeParsedExpr(parseExpression('item.tags.map((t, i) => t.n + i)'))).not.toBeNull()
221
+ })
222
+
223
+ test('nested `.some`/`.find`/`.every`/`.sort`/`.reduce`/`.flat`/`.flatMap` still fold to null', () => {
224
+ expect(serializeParsedExpr(parseExpression('item.tags.filter(t => picked.some(p => p === t))'))).toBeNull()
225
+ expect(serializeParsedExpr(parseExpression('item.tags.filter(t => picked.find(p => p === t))'))).toBeNull()
226
+ expect(serializeParsedExpr(parseExpression('item.tags.filter(t => picked.every(p => p === t))'))).toBeNull()
227
+ expect(serializeParsedExpr(parseExpression('item.tags.filter(t => picked.sort((a, b) => a - b).length > 0)'))).toBeNull()
228
+ expect(serializeParsedExpr(parseExpression('item.tags.filter(t => picked.reduce((a, b) => a + b, 0) > 0)'))).toBeNull()
229
+ expect(serializeParsedExpr(parseExpression('item.tags.filter(t => picked.flat().length > 0)'))).toBeNull()
230
+ expect(serializeParsedExpr(parseExpression('item.tags.filter(t => picked.flatMap(p => p).length > 0)'))).toBeNull()
164
231
  })
165
232
 
166
233
  test('a computed member value carries `computed: true` (plain access omits it)', () => {
@@ -82,6 +82,26 @@ describe('extractSsrDefaults', () => {
82
82
  expect(defaults?.initial).toEqual({ propName: 'initial', value: null })
83
83
  })
84
84
 
85
+ test('seeds every declared bare-props prop, including direct template reads (#2126)', () => {
86
+ // Template-stash adapters flatten `props.label` to the bare scalar
87
+ // `$label` even when no signal/memo references it (`<%= $label %>` in
88
+ // the body), so a prop the caller forgets to pass must still exist in
89
+ // the stash. Every prop declared on the props type gets an entry —
90
+ // not just the ones a signal / memo initializer reads.
91
+ const metadata = metadataFor(`
92
+ 'use client'
93
+ import { createSignal } from '@barefootjs/client'
94
+ function Greeting(props: { label?: string }) {
95
+ const [n, setN] = createSignal(0)
96
+ return <div><p>{props.label}</p><button onClick={() => setN(n() + 1)}>{n()}</button></div>
97
+ }
98
+ `)
99
+
100
+ const defaults = extractSsrDefaults(metadata)
101
+ expect(defaults?.label).toEqual({ propName: 'label', value: null })
102
+ expect(defaults?.n).toEqual({ value: 0 })
103
+ })
104
+
85
105
  test('memo derived from a signal evaluates through the chain', () => {
86
106
  const metadata = metadataFor(`
87
107
  'use client'
@@ -5,14 +5,25 @@
5
5
  * rejections for callees the adapter promises it can render in template
6
6
  * scope.
7
7
  *
8
+ * #2069 extends this with two more acceptance mechanisms, tested in the
9
+ * two `describe` blocks at the bottom of this file:
10
+ * - `RelocateEnv.loweringMatchers` — a THIRD acceptance path alongside
11
+ * `templatePrimitives` / `acceptsTemplateCall`, for calls a
12
+ * `LoweringPlugin` recognises structurally (never string-keyed).
13
+ * - `RelocateEnv.aliasTargets` — one-hop alias resolution, so
14
+ * `const fmt = customSerialize; fmt(x)` keys/matches as
15
+ * `customSerialize`, not `fmt`.
16
+ *
8
17
  * No adapter populates the registry yet — the wiring is exercised here
9
- * via hand-built env objects. Phase 3 will fill the Hono predicate.
18
+ * via hand-built env objects. Phase 3 filled the Hono predicate; #2069
19
+ * adds the plugin-matcher and alias seams.
10
20
  */
11
21
 
12
22
  import { describe, test, expect } from 'bun:test'
13
23
  import type { RelocateEnv } from '../../relocate'
14
24
  import { isInlinableInTemplate } from '../../relocate'
15
25
  import type { BindingKind } from '../../types'
26
+ import type { LoweringMatcher } from '../../lowering-registry'
16
27
 
17
28
  function envWith(
18
29
  bindings: Array<[string, BindingKind]>,
@@ -28,6 +39,8 @@ function envWith(
28
39
  allowFallback: options?.allowFallback ?? true,
29
40
  templatePrimitives: options?.templatePrimitives,
30
41
  acceptsTemplateCall: options?.acceptsTemplateCall,
42
+ loweringMatchers: options?.loweringMatchers,
43
+ aliasTargets: options?.aliasTargets,
31
44
  }
32
45
  }
33
46
 
@@ -256,3 +269,220 @@ describe('isInlinableInTemplate — adapter-aware acceptance (#1187 phase 2)', (
256
269
  })
257
270
  })
258
271
  })
272
+
273
+ /** A `LoweringMatcher` that recognises a bare-identifier call to `name`. */
274
+ function matcherFor(name: string): LoweringMatcher {
275
+ return (callee, args) => {
276
+ if (callee.kind !== 'identifier' || callee.name !== name) return null
277
+ return { kind: 'helper-call', helper: 'test_helper', args }
278
+ }
279
+ }
280
+
281
+ describe('isInlinableInTemplate — loweringMatchers acceptance (#2069)', () => {
282
+ test('a call recognised by a registered matcher is accepted', () => {
283
+ const env = envWith(
284
+ [
285
+ ['customSerialize', 'module-import'],
286
+ ['config', 'prop'],
287
+ ],
288
+ {
289
+ propsObjectName: null,
290
+ loweringMatchers: [matcherFor('customSerialize')],
291
+ },
292
+ )
293
+ const r = isInlinableInTemplate('customSerialize(config)', env)
294
+ expect(r.ok).toBe(true)
295
+ expect(r.rewrittenValue).toContain('customSerialize')
296
+ })
297
+
298
+ test('without a matching matcher, the call is still rejected', () => {
299
+ const env = envWith(
300
+ [
301
+ ['customSerialize', 'module-import'],
302
+ ['config', 'prop'],
303
+ ],
304
+ {
305
+ propsObjectName: null,
306
+ loweringMatchers: [matcherFor('someOtherHelper')],
307
+ },
308
+ )
309
+ const r = isInlinableInTemplate('customSerialize(config)', env)
310
+ expect(r.ok).toBe(false)
311
+ })
312
+
313
+ test('templatePrimitives / acceptsTemplateCall / loweringMatchers are all independent acceptance paths', () => {
314
+ // Only the matcher accepts here — no string-keyed entry exists at all.
315
+ const env = envWith(
316
+ [
317
+ ['customSerialize', 'module-import'],
318
+ ['config', 'prop'],
319
+ ],
320
+ {
321
+ propsObjectName: null,
322
+ templatePrimitives: {},
323
+ acceptsTemplateCall: () => false,
324
+ loweringMatchers: [matcherFor('customSerialize')],
325
+ },
326
+ )
327
+ const r = isInlinableInTemplate('customSerialize(config)', env)
328
+ expect(r.ok).toBe(true)
329
+ })
330
+
331
+ describe('shadow guard applies to matcher acceptance too', () => {
332
+ test('a local binding shadowing the recognised name is refused', () => {
333
+ // `customSerialize` here is a component-local (e.g. reassigned from
334
+ // a prop), NOT the module import the plugin's `prepare()` resolved
335
+ // against. The matcher itself has no way to know this — the shadow
336
+ // guard in `isCallAcceptedByAdapter` is what keeps it from firing.
337
+ const env = envWith(
338
+ [
339
+ ['customSerialize', 'init-local'], // shadows the import
340
+ ['config', 'prop'],
341
+ ],
342
+ {
343
+ propsObjectName: null,
344
+ loweringMatchers: [matcherFor('customSerialize')],
345
+ },
346
+ )
347
+ const r = isInlinableInTemplate('customSerialize(config)', env)
348
+ expect(r.ok).toBe(false)
349
+ })
350
+
351
+ test('module-import / module-local bindings still activate the matcher', () => {
352
+ const env = envWith(
353
+ [
354
+ ['customSerialize', 'module-local'],
355
+ ['config', 'prop'],
356
+ ],
357
+ {
358
+ propsObjectName: null,
359
+ loweringMatchers: [matcherFor('customSerialize')],
360
+ },
361
+ )
362
+ const r = isInlinableInTemplate('customSerialize(config)', env)
363
+ expect(r.ok).toBe(true)
364
+ })
365
+ })
366
+ })
367
+
368
+ describe('isInlinableInTemplate — one-hop alias resolution (#2069 R2)', () => {
369
+ test('alias → registered builtin (templatePrimitives) is accepted', () => {
370
+ // const fmt = Math.floor; fmt(score)
371
+ const env = envWith(
372
+ [
373
+ ['fmt', 'init-local'],
374
+ ['Math', 'global'],
375
+ ['score', 'prop'],
376
+ ],
377
+ {
378
+ propsObjectName: null,
379
+ aliasTargets: new Map([['fmt', 'Math.floor']]),
380
+ templatePrimitives: {
381
+ 'Math.floor': (args) => `Math.floor(${args[0]})`,
382
+ },
383
+ },
384
+ )
385
+ const r = isInlinableInTemplate('fmt(score)', env)
386
+ expect(r.ok).toBe(true)
387
+ expect(r.rewrittenValue).toContain('Math.floor')
388
+ })
389
+
390
+ test('aliased-namespace MEMBER callee resolves too (const m = Math; m.floor)', () => {
391
+ // The leftmost segment is spliced and the `.path` tail carried over —
392
+ // `m.floor(score)` keys the registry as `Math.floor` (Copilot review
393
+ // on #2097 pinned that this is intended, not bare-identifier-only).
394
+ const env = envWith(
395
+ [
396
+ ['m', 'init-local'],
397
+ ['Math', 'global'],
398
+ ['score', 'prop'],
399
+ ],
400
+ {
401
+ propsObjectName: null,
402
+ aliasTargets: new Map([['m', 'Math']]),
403
+ templatePrimitives: {
404
+ 'Math.floor': (args) => `Math.floor(${args[0]})`,
405
+ },
406
+ },
407
+ )
408
+ const r = isInlinableInTemplate('m.floor(score)', env)
409
+ expect(r.ok).toBe(true)
410
+ })
411
+
412
+ test('alias → a loweringMatchers-recognised import is accepted', () => {
413
+ // const serialize = customSerialize; serialize(config)
414
+ const env = envWith(
415
+ [
416
+ ['serialize', 'init-local'],
417
+ ['customSerialize', 'module-import'],
418
+ ['config', 'prop'],
419
+ ],
420
+ {
421
+ propsObjectName: null,
422
+ aliasTargets: new Map([['serialize', 'customSerialize']]),
423
+ loweringMatchers: [matcherFor('customSerialize')],
424
+ },
425
+ )
426
+ const r = isInlinableInTemplate('serialize(config)', env)
427
+ expect(r.ok).toBe(true)
428
+ expect(r.rewrittenValue).toContain('customSerialize')
429
+ })
430
+
431
+ test('alias → alias chain is refused — only ONE hop is resolved', () => {
432
+ // const g = f (f is itself just another local, never resolved further)
433
+ const env = envWith(
434
+ [
435
+ ['g', 'init-local'],
436
+ ['f', 'init-local'],
437
+ ['config', 'prop'],
438
+ ],
439
+ {
440
+ propsObjectName: null,
441
+ aliasTargets: new Map([['g', 'f']]),
442
+ // Maximally permissive predicate — even this can't help, because
443
+ // the shadow guard rejects on `f`'s own (unsafe) binding kind
444
+ // before the predicate is ever consulted.
445
+ acceptsTemplateCall: () => true,
446
+ },
447
+ )
448
+ const r = isInlinableInTemplate('g(config)', env)
449
+ expect(r.ok).toBe(false)
450
+ })
451
+
452
+ test('alias → a non-import component-local helper is refused', () => {
453
+ // const h = helper (helper is a local function/const, not module-scope)
454
+ const env = envWith(
455
+ [
456
+ ['h', 'init-local'],
457
+ ['helper', 'init-local'],
458
+ ['config', 'prop'],
459
+ ],
460
+ {
461
+ propsObjectName: null,
462
+ aliasTargets: new Map([['h', 'helper']]),
463
+ acceptsTemplateCall: () => true,
464
+ },
465
+ )
466
+ const r = isInlinableInTemplate('h(config)', env)
467
+ expect(r.ok).toBe(false)
468
+ })
469
+
470
+ test('a plain non-alias init-local is unaffected by aliasTargets being present', () => {
471
+ // Baseline: aliasTargets has entries for OTHER names, but this
472
+ // reference isn't one of them — falls through to the ordinary
473
+ // init-local fallback, same as if aliasTargets were absent.
474
+ const env = envWith(
475
+ [
476
+ ['count', 'init-local'],
477
+ ['config', 'prop'],
478
+ ],
479
+ {
480
+ propsObjectName: null,
481
+ aliasTargets: new Map([['fmt', 'Math.floor']]),
482
+ },
483
+ )
484
+ const r = isInlinableInTemplate('count', env)
485
+ expect(r.ok).toBe(true) // fallback to 'undefined', not a rejection
486
+ expect(r.rewrittenValue).toBe('undefined')
487
+ })
488
+ })
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Tagged-template interleave-tag desugaring (#2092, Refs #2069).
3
+ *
4
+ * The classname-tag idiom:
5
+ *
6
+ * function cn(parts: TemplateStringsArray, ...args: unknown[]): string {
7
+ * return parts.reduce<string>((acc, p, i) => acc + p + (args[i] ?? ''), '')
8
+ * }
9
+ * className={cn`base ${tone()}`}
10
+ *
11
+ * Before #2092 the compiler treated `cn\`...\`` as an opaque scalar leaf: a
12
+ * `TaggedTemplateExpression` falls through `expression-parser.ts` as
13
+ * `unsupported`, and every non-JS template adapter refused it with BF101.
14
+ *
15
+ * #2092 resolves the tag identifier one hop through same-file scope (reusing
16
+ * #2090's `findLocalConst` / `findLocalFunction`), structurally proves the
17
+ * resolved function matches the "interleave tag" catalogue (see
18
+ * `isInterleaveTagFunction` in `jsx-to-ir.ts`), and — only then — REWRITES
19
+ * the whole tagged template to the equivalent untagged template literal
20
+ * (`\`base \${(tone()) ?? ''}\``) before the rest of the compiler ever sees
21
+ * it. Everything else (dep analysis, template-literal parts, adapter emit,
22
+ * client-JS binding) runs unchanged against the rewritten node.
23
+ *
24
+ * These tests exercise the recognizer directly against the IR (so assertions
25
+ * don't depend on any one adapter's output formatting) via `analyzeComponent`
26
+ * + `jsxToIR`, matching `ir-sort-comparator.test.ts`'s style for the sibling
27
+ * #2090 feature.
28
+ */
29
+
30
+ import { describe, test, expect } from 'bun:test'
31
+ import { analyzeComponent } from '../analyzer'
32
+ import { jsxToIR } from '../jsx-to-ir'
33
+ import type { IRElement, ExpressionAttr } from '../types'
34
+
35
+ /** Compile `source` and return the root element's `className` attribute
36
+ * value from the IR (the IR keeps the JSX name `className`; adapters
37
+ * render it as `class` later). */
38
+ function classAttrValue(source: string, filename = 'TagDemo.tsx') {
39
+ const ctx = analyzeComponent(source, filename)
40
+ const ir = jsxToIR(ctx)
41
+ expect(ir).not.toBeNull()
42
+ expect(ir!.type).toBe('element')
43
+ const el = ir as IRElement
44
+ const attr = el.attrs.find(a => a.name === 'className')
45
+ expect(attr).toBeDefined()
46
+ return attr!.value
47
+ }
48
+
49
+ describe('tagged-template interleave-tag recognition (#2092)', () => {
50
+ test('recognized cn`base ${tone()}` desugars to the untagged template literal', () => {
51
+ const source = `
52
+ 'use client'
53
+ import { createSignal } from '@barefootjs/client'
54
+ function cn(parts: TemplateStringsArray, ...args: unknown[]): string {
55
+ return parts.reduce<string>((acc, p, i) => acc + p + (args[i] ?? ''), '')
56
+ }
57
+ export function TagDemo() {
58
+ const [tone, setTone] = createSignal('primary')
59
+ return <div onClick={() => setTone('secondary')} className={cn\`base \${tone()}\`}>x</div>
60
+ }
61
+ `
62
+ const value = classAttrValue(source)
63
+ expect(value.kind).toBe('expression')
64
+ const expr = (value as ExpressionAttr).expr
65
+ // The tag call is gone — replaced by the equivalent untagged template
66
+ // literal, each span wrapped in `(span) ?? ''`.
67
+ expect(expr).not.toMatch(/\bcn`/)
68
+ expect(expr).toBe("`base ${(tone()) ?? ''}`")
69
+ })
70
+
71
+ test('const-bound arrow interleave tag also resolves (findLocalConst path)', () => {
72
+ const source = `
73
+ 'use client'
74
+ import { createSignal } from '@barefootjs/client'
75
+ const cn = (parts: TemplateStringsArray, ...args: unknown[]): string =>
76
+ parts.reduce<string>((acc, p, i) => acc + p + (args[i] ?? ''), '')
77
+ export function TagDemo() {
78
+ const [tone, setTone] = createSignal('primary')
79
+ return <div onClick={() => setTone('secondary')} className={cn\`base \${tone()}\`}>x</div>
80
+ }
81
+ `
82
+ const value = classAttrValue(source)
83
+ expect(value.kind).toBe('expression')
84
+ expect((value as ExpressionAttr).expr).toBe("`base ${(tone()) ?? ''}`")
85
+ })
86
+
87
+ test('String(...)-wrapped span still matches the catalogue', () => {
88
+ const source = `
89
+ 'use client'
90
+ import { createSignal } from '@barefootjs/client'
91
+ function cn(parts: TemplateStringsArray, ...args: unknown[]): string {
92
+ return parts.reduce<string>((acc, p, i) => acc + p + String(args[i] ?? ''), '')
93
+ }
94
+ export function TagDemo() {
95
+ const [tone] = createSignal('primary')
96
+ return <div className={cn\`base \${tone()}\`}>x</div>
97
+ }
98
+ `
99
+ const value = classAttrValue(source)
100
+ expect(value.kind).toBe('expression')
101
+ expect((value as ExpressionAttr).expr).toBe("`base ${(tone()) ?? ''}`")
102
+ })
103
+
104
+ test('no-substitution template (no interpolation) desugars cleanly', () => {
105
+ const source = `
106
+ 'use client'
107
+ function cn(parts: TemplateStringsArray, ...args: unknown[]): string {
108
+ return parts.reduce<string>((acc, p, i) => acc + p + (args[i] ?? ''), '')
109
+ }
110
+ export function TagDemo() {
111
+ return <div className={cn\`base\`}>x</div>
112
+ }
113
+ `
114
+ const value = classAttrValue(source)
115
+ // No spans at all — the rewrite produces a plain
116
+ // NoSubstitutionTemplateLiteral, not an "expression" carrying a call.
117
+ expect(value.kind).toBe('expression')
118
+ expect((value as ExpressionAttr).expr).toBe('`base`')
119
+ expect((value as ExpressionAttr).expr).not.toMatch(/\bcn`/)
120
+ })
121
+
122
+ test('escaped backtick / literal ${...} / backslash chunk survives the rewrite verbatim', () => {
123
+ // Chunk raw text (as written in source): `pre\` mid \${lit} end\\stop `
124
+ // — a literal backtick, a literal (non-substituting) `${lit}`, and a
125
+ // literal backslash, followed by the ONE real span `${tone()}`.
126
+ const source = `
127
+ 'use client'
128
+ import { createSignal } from '@barefootjs/client'
129
+ function cn(parts: TemplateStringsArray, ...args: unknown[]): string {
130
+ return parts.reduce<string>((acc, p, i) => acc + p + (args[i] ?? ''), '')
131
+ }
132
+ export function TagDemo() {
133
+ const [tone] = createSignal('primary')
134
+ return <div className={cn\`pre\\\` mid \\\${lit} end\\\\stop \${tone()} post\`}>x</div>
135
+ }
136
+ `
137
+ const value = classAttrValue(source)
138
+ expect(value.kind).toBe('expression')
139
+ const expr = (value as ExpressionAttr).expr
140
+ // The escape sequences (backtick, `${`, backslash) must survive
141
+ // byte-for-byte in the rewritten source — using the COOKED text
142
+ // instead of `rawText` would either mis-parse the reconstructed
143
+ // template or silently turn `${lit}` into a real (broken) substitution.
144
+ expect(expr).toBe("`pre\\` mid \\${lit} end\\\\stop ${(tone()) ?? ''} post`")
145
+ })
146
+
147
+ test('imported tag identifier is NOT resolved — left untouched (today\'s BF101 path)', () => {
148
+ const source = `
149
+ 'use client'
150
+ import { createSignal } from '@barefootjs/client'
151
+ import { cn } from './cn-helper'
152
+ export function TagDemo() {
153
+ const [tone] = createSignal('primary')
154
+ return <div className={cn\`base \${tone()}\`}>x</div>
155
+ }
156
+ `
157
+ const value = classAttrValue(source)
158
+ expect(value.kind).toBe('expression')
159
+ // Unresolved (no same-file binding) — the tag call is preserved verbatim.
160
+ expect((value as ExpressionAttr).expr).toContain('cn`base')
161
+ })
162
+
163
+ test('cross-kind name collision (const + function binding) is NOT resolved', () => {
164
+ // Same ambiguity rule as resolveSortComparatorIdentifier (#2091 review):
165
+ // a name bound both as a const and as a `function` declaration can only
166
+ // occur across scopes, and FunctionInfo carries emission placement (not
167
+ // lexical position), so resolution refuses rather than guessing which
168
+ // tag the call site actually sees. The node stays opaque (BF101 path).
169
+ const source = `
170
+ 'use client'
171
+ import { createSignal } from '@barefootjs/client'
172
+ function cn(parts: TemplateStringsArray, ...args: unknown[]): string {
173
+ return parts.reduce<string>((acc, p, i) => acc + p + (args[i] ?? ''), '')
174
+ }
175
+ export function TagDemo() {
176
+ const [tone] = createSignal('primary')
177
+ const cn = (parts: TemplateStringsArray, ...args: unknown[]) =>
178
+ parts.reduce<string>((acc, p, i) => acc + p + '-' + (args[i] ?? ''), '')
179
+ return <div className={cn\`base \${tone()}\`}>x</div>
180
+ }
181
+ `
182
+ const value = classAttrValue(source)
183
+ expect(value.kind).toBe('expression')
184
+ expect((value as ExpressionAttr).expr).toContain('cn`base')
185
+ })
186
+
187
+ test('off-catalogue body (joins with a separator) is NOT recognized', () => {
188
+ const source = `
189
+ 'use client'
190
+ import { createSignal } from '@barefootjs/client'
191
+ function cn(parts: TemplateStringsArray, ...args: unknown[]): string {
192
+ return parts.reduce<string>((acc, p, i) => acc + p + '-' + (args[i] ?? ''), '')
193
+ }
194
+ export function TagDemo() {
195
+ const [tone] = createSignal('primary')
196
+ return <div className={cn\`base \${tone()}\`}>x</div>
197
+ }
198
+ `
199
+ const value = classAttrValue(source)
200
+ expect(value.kind).toBe('expression')
201
+ expect((value as ExpressionAttr).expr).toContain('cn`base')
202
+ })
203
+
204
+ test('computed tag (member expression) is NOT recognized', () => {
205
+ const source = `
206
+ 'use client'
207
+ import { createSignal } from '@barefootjs/client'
208
+ const helpers = {
209
+ cn(parts: TemplateStringsArray, ...args: unknown[]): string {
210
+ return parts.reduce<string>((acc, p, i) => acc + p + (args[i] ?? ''), '')
211
+ },
212
+ }
213
+ export function TagDemo() {
214
+ const [tone] = createSignal('primary')
215
+ return <div className={helpers.cn\`base \${tone()}\`}>x</div>
216
+ }
217
+ `
218
+ const value = classAttrValue(source)
219
+ expect(value.kind).toBe('expression')
220
+ // Not even an Identifier tag — the recognizer only matches a bare
221
+ // identifier tag (`tryDesugarInterleaveTaggedTemplate` requires
222
+ // `ts.isIdentifier(expr.tag)`), so a computed tag is never attempted.
223
+ expect((value as ExpressionAttr).expr).toContain('helpers.cn`base')
224
+ })
225
+
226
+ test('non-rest second parameter is NOT recognized', () => {
227
+ const source = `
228
+ 'use client'
229
+ import { createSignal } from '@barefootjs/client'
230
+ function cn(parts: TemplateStringsArray, args: unknown[]): string {
231
+ return parts.reduce<string>((acc, p, i) => acc + p + (args[i] ?? ''), '')
232
+ }
233
+ export function TagDemo() {
234
+ const [tone] = createSignal('primary')
235
+ return <div className={cn\`base \${tone()}\`}>x</div>
236
+ }
237
+ `
238
+ const value = classAttrValue(source)
239
+ expect(value.kind).toBe('expression')
240
+ expect((value as ExpressionAttr).expr).toContain('cn`base')
241
+ })
242
+
243
+ test('/* @client */ still defers a recognized interleave tag to the client', () => {
244
+ const source = `
245
+ 'use client'
246
+ import { createSignal } from '@barefootjs/client'
247
+ function cn(parts: TemplateStringsArray, ...args: unknown[]): string {
248
+ return parts.reduce<string>((acc, p, i) => acc + p + (args[i] ?? ''), '')
249
+ }
250
+ export function TagDemo() {
251
+ const [tone, setTone] = createSignal('primary')
252
+ return <div onClick={() => setTone('secondary')} className={/* @client */ cn\`base \${tone()}\`}>x</div>
253
+ }
254
+ `
255
+ const ctx = analyzeComponent(source, 'TagDemo.tsx')
256
+ const ir = jsxToIR(ctx)
257
+ expect(ir).not.toBeNull()
258
+ const el = ir as IRElement
259
+ const attr = el.attrs.find(a => a.name === 'className')
260
+ expect(attr).toBeDefined()
261
+ // The desugar still applies (the client-only directive doesn't block
262
+ // recognition — it's read independently from the original node), and
263
+ // the attribute is still flagged clientOnly so it defers to hydrate.
264
+ expect(attr!.clientOnly).toBe(true)
265
+ expect(attr!.value.kind).toBe('expression')
266
+ expect((attr!.value as ExpressionAttr).expr).toBe("`base ${(tone()) ?? ''}`")
267
+ })
268
+ })