@barefootjs/jsx 0.31.1 → 0.31.3

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 (80) hide show
  1. package/dist/adapters/interface.d.ts +11 -0
  2. package/dist/adapters/interface.d.ts.map +1 -1
  3. package/dist/adapters/jsx-adapter.d.ts +92 -1
  4. package/dist/adapters/jsx-adapter.d.ts.map +1 -1
  5. package/dist/adapters/test-adapter.d.ts.map +1 -1
  6. package/dist/analyzer.d.ts.map +1 -1
  7. package/dist/compiler.d.ts.map +1 -1
  8. package/dist/css-layer-prefixer.d.ts +16 -0
  9. package/dist/css-layer-prefixer.d.ts.map +1 -1
  10. package/dist/errors.d.ts +1 -0
  11. package/dist/errors.d.ts.map +1 -1
  12. package/dist/html-types.d.ts +19 -0
  13. package/dist/html-types.d.ts.map +1 -1
  14. package/dist/index.d.ts +5 -2
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +1531 -1155
  17. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/phases/props-event-handlers.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/phases/props-extraction.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/utils.d.ts +6 -4
  22. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  23. package/dist/jsx-runtime/index.d.ts +2 -8
  24. package/dist/jsx-runtime/index.d.ts.map +1 -1
  25. package/dist/jsx-to-ir.d.ts.map +1 -1
  26. package/dist/module-exports.d.ts +9 -1
  27. package/dist/module-exports.d.ts.map +1 -1
  28. package/dist/prop-rewrite.d.ts +8 -3
  29. package/dist/prop-rewrite.d.ts.map +1 -1
  30. package/dist/props-binding.d.ts +40 -0
  31. package/dist/props-binding.d.ts.map +1 -0
  32. package/dist/relocate.d.ts +9 -0
  33. package/dist/relocate.d.ts.map +1 -1
  34. package/dist/scope/binding-scope.d.ts +179 -0
  35. package/dist/scope/binding-scope.d.ts.map +1 -0
  36. package/dist/ssr-defaults.d.ts +43 -0
  37. package/dist/ssr-defaults.d.ts.map +1 -1
  38. package/dist/template-parts.d.ts +53 -0
  39. package/dist/template-parts.d.ts.map +1 -0
  40. package/dist/types.d.ts +18 -0
  41. package/dist/types.d.ts.map +1 -1
  42. package/package.json +2 -2
  43. package/src/__tests__/adapter-output.test.ts +8 -4
  44. package/src/__tests__/aliased-destructured-prop-csr.test.ts +112 -0
  45. package/src/__tests__/binding-scope-preamble-shadowing.test.ts +115 -0
  46. package/src/__tests__/binding-scope-ratchet.test.ts +194 -0
  47. package/src/__tests__/binding-scope.test.ts +200 -0
  48. package/src/__tests__/css-layer-prefixer.test.ts +72 -0
  49. package/src/__tests__/form-control-value-ssr.test.ts +48 -3
  50. package/src/__tests__/let-type-annotation.test.ts +208 -0
  51. package/src/__tests__/memo-deps-comments.test.ts +99 -0
  52. package/src/__tests__/multi-return-sibling-diagnostic.test.ts +241 -0
  53. package/src/__tests__/ssr-defaults.test.ts +124 -1
  54. package/src/__tests__/staged-ir/08-relocate-unit.test.ts +1 -0
  55. package/src/__tests__/staged-ir/11-template-primitive-registry.test.ts +1 -0
  56. package/src/adapters/interface.ts +11 -0
  57. package/src/adapters/jsx-adapter.ts +295 -5
  58. package/src/adapters/test-adapter.ts +13 -10
  59. package/src/analyzer.ts +70 -11
  60. package/src/compiler.ts +119 -18
  61. package/src/css-layer-prefixer.ts +80 -24
  62. package/src/errors.ts +18 -0
  63. package/src/html-types.ts +24 -0
  64. package/src/index.ts +11 -1
  65. package/src/ir-to-client-js/collect-elements.ts +4 -1
  66. package/src/ir-to-client-js/emit-reactive.ts +4 -2
  67. package/src/ir-to-client-js/phases/props-event-handlers.ts +4 -3
  68. package/src/ir-to-client-js/phases/props-extraction.ts +7 -4
  69. package/src/ir-to-client-js/plan/build-declaration-emit.ts +6 -3
  70. package/src/ir-to-client-js/utils.ts +5 -24
  71. package/src/jsx-runtime/index.ts +2 -7
  72. package/src/jsx-to-ir.ts +245 -107
  73. package/src/module-exports.ts +11 -2
  74. package/src/prop-rewrite.ts +26 -6
  75. package/src/props-binding.ts +70 -0
  76. package/src/relocate.ts +19 -2
  77. package/src/scope/binding-scope.ts +238 -0
  78. package/src/ssr-defaults.ts +70 -0
  79. package/src/template-parts.ts +81 -0
  80. package/src/types.ts +18 -0
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Unit tests for `BindingScope` (#2482 Stage 0). See
3
+ * `packages/jsx/src/scope/binding-scope.ts` for the service itself — this
4
+ * file pins its observable behavior, especially the destructure semantics
5
+ * mirrored from `jsx-to-ir.ts`'s `ctx.loopParams` add site and the
6
+ * immutability win over that Set-based design's nested-same-name hazard.
7
+ */
8
+
9
+ import { describe, test, expect } from 'bun:test'
10
+ import { BindingScope } from '../scope/binding-scope.ts'
11
+
12
+ describe('BindingScope', () => {
13
+ test('EMPTY: nothing bound, lookup null, empty boundNames, shadow predicate false', () => {
14
+ const scope = BindingScope.EMPTY
15
+ expect(scope.isBound('x')).toBe(false)
16
+ expect(scope.lookup('x')).toBeNull()
17
+ expect(scope.boundNames().size).toBe(0)
18
+ expect(scope.asShadowPredicate()('x')).toBe(false)
19
+ })
20
+
21
+ test('simple loop row: param and index bound with sources item/index', () => {
22
+ const scope = BindingScope.EMPTY.enterLoopRow({ param: 'item', index: 'i' })
23
+ expect(scope.isBound('item')).toBe(true)
24
+ expect(scope.isBound('i')).toBe(true)
25
+ expect(scope.lookup('item')).toEqual({
26
+ depth: 0,
27
+ frame: scope.lookup('item')!.frame,
28
+ binding: { source: 'item' },
29
+ })
30
+ expect(scope.lookup('i')?.binding).toEqual({ source: 'index' })
31
+ expect(scope.lookup('item')?.frame.kind).toBe('loop-row')
32
+ })
33
+
34
+ test('loop row with no index: index name not bound', () => {
35
+ const scope = BindingScope.EMPTY.enterLoopRow({ param: 'item', index: null })
36
+ expect(scope.isBound('item')).toBe(true)
37
+ expect(scope.isBound('i')).toBe(false)
38
+ })
39
+
40
+ test('destructured row: paramBindings names bound as destructure; raw param NOT bound', () => {
41
+ // Mirrors jsx-to-ir.ts:4330-4335 exactly: when paramBindings is
42
+ // non-empty, ONLY the destructured binding names are added to
43
+ // ctx.loopParams — the else branch that adds the raw `param` text is
44
+ // skipped entirely. For a destructured callback `param` holds the
45
+ // original pattern source text (e.g. "{ id, name }"), which is not a
46
+ // usable identifier anyway, so not binding it is correct, not just
47
+ // an accident of mirroring.
48
+ const scope = BindingScope.EMPTY.enterLoopRow({
49
+ param: '{ id, name }',
50
+ index: null,
51
+ paramBindings: [{ name: 'id' }, { name: 'name' }],
52
+ })
53
+ expect(scope.isBound('id')).toBe(true)
54
+ expect(scope.isBound('name')).toBe(true)
55
+ expect(scope.lookup('id')?.binding).toEqual({ source: 'destructure' })
56
+ expect(scope.lookup('name')?.binding).toEqual({ source: 'destructure' })
57
+ // The raw pattern text is not a bound name.
58
+ expect(scope.isBound('{ id, name }')).toBe(false)
59
+ })
60
+
61
+ test('preamble locals: declaredNames bound as preamble', () => {
62
+ const scope = BindingScope.EMPTY.enterLoopRow({
63
+ param: 'row',
64
+ index: null,
65
+ preamble: { declaredNames: ['out', 'label'] },
66
+ })
67
+ expect(scope.isBound('row')).toBe(true)
68
+ expect(scope.lookup('out')?.binding).toEqual({ source: 'preamble' })
69
+ expect(scope.lookup('label')?.binding).toEqual({ source: 'preamble' })
70
+ })
71
+
72
+ test('nested loops with the SAME param name: inner lookup is depth 0; discarding the inner scope still leaves the parent bound', () => {
73
+ // Pins the immutability win over the old `ctx.loopParams` Set design:
74
+ // that mechanism `.add`s a nested loop's param and `.delete`s it on
75
+ // the way back out, so a caller that captured "the current set"
76
+ // reference before entering the nested loop observes the SAME
77
+ // mutable object both inside and after — there is no way to ask "what
78
+ // was bound one loop level up" once the same name has been added
79
+ // twice and deleted once. BindingScope's immutable child scopes make
80
+ // that question well-formed: `outer` below is never touched by
81
+ // `enterLoopRow` on `inner`, so it still resolves `item` after we
82
+ // stop using (conceptually "pop") the inner scope by simply going
83
+ // back to holding the `outer` reference.
84
+ const outer = BindingScope.EMPTY.enterLoopRow({ param: 'item', index: null })
85
+ const inner = outer.enterLoopRow({ param: 'item', index: null })
86
+
87
+ expect(inner.lookup('item')?.depth).toBe(0)
88
+ expect(outer.lookup('item')?.depth).toBe(0)
89
+ // "Discarding" the inner scope is just no longer holding its
90
+ // reference — `outer` was never mutated and still has `item` bound.
91
+ expect(outer.isBound('item')).toBe(true)
92
+ })
93
+
94
+ test('callback frame: enterCallback binds params as source param, frame kind callback; row bindings still visible beneath', () => {
95
+ const row = BindingScope.EMPTY.enterLoopRow({ param: 'item', index: null })
96
+ const withCallback = row.enterCallback(['a', 'b'])
97
+
98
+ expect(withCallback.lookup('a')).toEqual({
99
+ depth: 0,
100
+ frame: withCallback.lookup('a')!.frame,
101
+ binding: { source: 'param' },
102
+ })
103
+ expect(withCallback.lookup('a')?.frame.kind).toBe('callback')
104
+ expect(withCallback.lookup('b')?.binding).toEqual({ source: 'param' })
105
+
106
+ // The row binding is still visible, one frame further out.
107
+ const itemLookup = withCallback.lookup('item')
108
+ expect(itemLookup?.depth).toBe(1)
109
+ expect(itemLookup?.frame.kind).toBe('loop-row')
110
+ expect(itemLookup?.binding).toEqual({ source: 'item' })
111
+ })
112
+
113
+ test('shadowing: inner binding wins lookup (depth 0) over a same-named outer binding', () => {
114
+ const outer = BindingScope.EMPTY.enterLoopRow({ param: 'x', index: null })
115
+ const inner = outer.enterCallback(['x'])
116
+
117
+ const found = inner.lookup('x')
118
+ expect(found?.depth).toBe(0)
119
+ expect(found?.binding).toEqual({ source: 'param' })
120
+ expect(found?.frame.kind).toBe('callback')
121
+ })
122
+
123
+ test('boundNames unions across frames; asShadowPredicate matches isBound', () => {
124
+ const scope = BindingScope.EMPTY
125
+ .enterLoopRow({ param: 'item', index: 'i', preamble: { declaredNames: ['out'] } })
126
+ .enterCallback(['a'])
127
+
128
+ const names = scope.boundNames()
129
+ expect(names.has('item')).toBe(true)
130
+ expect(names.has('i')).toBe(true)
131
+ expect(names.has('out')).toBe(true)
132
+ expect(names.has('a')).toBe(true)
133
+ expect(names.size).toBe(4)
134
+
135
+ const shadowed = scope.asShadowPredicate()
136
+ for (const n of ['item', 'i', 'out', 'a', 'not-bound']) {
137
+ expect(shadowed(n)).toBe(scope.isBound(n))
138
+ }
139
+ })
140
+
141
+ describe('valueBoundNames (#2482 Stage 1a Commit 2)', () => {
142
+ test('excludes preamble names that boundNames() includes', () => {
143
+ const scope = BindingScope.EMPTY.enterLoopRow({
144
+ param: 'item',
145
+ index: 'i',
146
+ preamble: { declaredNames: ['label', 'out'] },
147
+ })
148
+
149
+ // Shadow-guard query: sees everything, including preamble locals.
150
+ const all = scope.boundNames()
151
+ expect(all.has('item')).toBe(true)
152
+ expect(all.has('i')).toBe(true)
153
+ expect(all.has('label')).toBe(true)
154
+ expect(all.has('out')).toBe(true)
155
+ expect(all.size).toBe(4)
156
+
157
+ // Reactivity/slotId query: item/index/destructure only.
158
+ const values = scope.valueBoundNames()
159
+ expect(values.has('item')).toBe(true)
160
+ expect(values.has('i')).toBe(true)
161
+ expect(values.has('label')).toBe(false)
162
+ expect(values.has('out')).toBe(false)
163
+ expect(values.size).toBe(2)
164
+ })
165
+
166
+ test('destructured bindings count as value bindings', () => {
167
+ const scope = BindingScope.EMPTY.enterLoopRow({
168
+ param: '{ id, name }',
169
+ index: null,
170
+ paramBindings: [{ name: 'id' }, { name: 'name' }],
171
+ preamble: { declaredNames: ['formatted'] },
172
+ })
173
+
174
+ const values = scope.valueBoundNames()
175
+ expect(values.has('id')).toBe(true)
176
+ expect(values.has('name')).toBe(true)
177
+ expect(values.has('formatted')).toBe(false)
178
+ expect(values.size).toBe(2)
179
+ })
180
+
181
+ test('excludes enterCallback param-sourced names too', () => {
182
+ // A filter/sort/nested-arrow frame's own parameters are a distinct
183
+ // binding shape from a loop row's item/index/destructure names
184
+ // (see the class doc comment) — valueBoundNames() must not surface
185
+ // them either, only boundNames() does.
186
+ const scope = BindingScope.EMPTY
187
+ .enterLoopRow({ param: 'item', index: null })
188
+ .enterCallback(['a', 'b'])
189
+
190
+ expect(scope.boundNames().has('a')).toBe(true)
191
+ expect(scope.valueBoundNames().has('a')).toBe(false)
192
+ expect(scope.valueBoundNames().has('item')).toBe(true)
193
+ })
194
+
195
+ test('EMPTY scope: both queries return an empty set', () => {
196
+ expect(BindingScope.EMPTY.boundNames().size).toBe(0)
197
+ expect(BindingScope.EMPTY.valueBoundNames().size).toBe(0)
198
+ })
199
+ })
200
+ })
@@ -11,6 +11,9 @@ import {
11
11
  extractIdentifiers,
12
12
  } from '../css-layer-prefixer'
13
13
  import type { ComponentIR, IRElement, IRMetadata, IRTemplatePart, ConstantInfo, TemplateAttr, LiteralAttr } from '../types'
14
+ import { compileJSX } from '../compiler'
15
+ import { TestAdapter } from '../adapters/test-adapter'
16
+ import { HonoAdapter } from '../../../../packages/adapter-hono/src/adapter/hono-adapter'
14
17
 
15
18
  describe('prefixClass', () => {
16
19
  test('prefixes a simple class', () => {
@@ -452,4 +455,73 @@ describe('applyCssLayerPrefix', () => {
452
455
  const base = ir.metadata.localConstants.find(c => c.name === 'baseClasses')
453
456
  expect(base?.value).toBe("'layer-components:bg-primary'")
454
457
  })
458
+
459
+ test('multi-component file prefixes a shared const identically in every component IR (#2570)', () => {
460
+ // `sharedClasses` is used in CLASS position only by CompA; CompB reads it
461
+ // in a non-class expression. Per-IR application prefixed only CompA's
462
+ // copy, which — now that module-scope constants are hoisted to ONE
463
+ // module declaration — emitted the constant twice with different values
464
+ // ("tabsClasses has already been declared" in the site/ui build). The
465
+ // file-wide union must leave every IR's copy byte-identical.
466
+ const source = `
467
+ const sharedClasses = 'flex flex-col gap-2'
468
+
469
+ export function CompA() {
470
+ return <div className={sharedClasses + ' extra'}>a</div>
471
+ }
472
+
473
+ export function CompB() {
474
+ return <div data-cls={sharedClasses}>b</div>
475
+ }
476
+ `
477
+ const result = compileJSX(source, '/virtual/Tabs.tsx', {
478
+ adapter: new TestAdapter(),
479
+ cssLayerPrefix: 'components',
480
+ })
481
+ expect(result.errors.filter(e => e.severity === 'error')).toEqual([])
482
+ const template = result.files.find(f => f.type === 'markedTemplate')!.content
483
+
484
+ const declarations = template.match(/^const sharedClasses = .*$/gm) ?? []
485
+ expect(declarations).toHaveLength(1)
486
+ expect(declarations[0]).toContain('layer-components:flex')
487
+ })
488
+
489
+ test('prefixes a `Record`-shaped `as const` class map constant\'s typedValue (#2575)', () => {
490
+ // `preserveTypes` emitters (Hono) prefer `typedValue ?? value` when
491
+ // emitting a module-scope constant so `.tsx` output stays type-checked
492
+ // — see `generateModuleScopeDeclarations` in
493
+ // `packages/adapter-hono/src/adapter/hono-adapter.ts`'s base class.
494
+ // `applyCssLayerPrefixToFile` used to rewrite only `value`, leaving
495
+ // `typedValue` — the string Hono actually emits — unprefixed for any
496
+ // constant whose initializer carries type syntax (`as const`, a
497
+ // `satisfies`/`Record<...>` annotation, …). Regression for the fix:
498
+ // both strings must be prefixed together, and the `as const` assertion
499
+ // must survive the round trip verbatim.
500
+ const source = `
501
+ const toneClasses: Record<'info' | 'warn', string> = {
502
+ info: 'text-blue-500',
503
+ warn: 'text-amber-500',
504
+ } as const
505
+
506
+ export function Banner() {
507
+ return <div className={toneClasses.info}>hi</div>
508
+ }
509
+ `
510
+ const result = compileJSX(source, '/virtual/Banner.tsx', {
511
+ adapter: new HonoAdapter(),
512
+ cssLayerPrefix: 'components',
513
+ })
514
+ expect(result.errors.filter(e => e.severity === 'error')).toEqual([])
515
+ const template = result.files.find(f => f.type === 'markedTemplate')!.content
516
+
517
+ const declarations = [...template.matchAll(/^const toneClasses = [\s\S]*?\} as const$/gm)]
518
+ expect(declarations).toHaveLength(1)
519
+ const declaration = declarations[0][0]
520
+
521
+ // Classes are prefixed...
522
+ expect(declaration).toContain("info: 'layer-components:text-blue-500'")
523
+ expect(declaration).toContain("warn: 'layer-components:text-amber-500'")
524
+ // ...and the `as const` assertion survives the rewrite.
525
+ expect(declaration.trim().endsWith('} as const')).toBe(true)
526
+ })
455
527
  })
@@ -1,5 +1,5 @@
1
1
  /**
2
- * SSR projection of controlled form-control `value` (#2464 / #2465).
2
+ * SSR projection of controlled form-control `value` (#2464 / #2465 / #2466).
3
3
  *
4
4
  * `value` is not an attribute on `<textarea>` or `<select>` — emitting it
5
5
  * verbatim ships invalid HTML that browsers ignore, so no-JS and
@@ -9,6 +9,15 @@
9
9
  * and projects the value into element content (textarea) or per-option
10
10
  * `selected` comparisons (select — the shape `select-option-selected`
11
11
  * already proves across every adapter).
12
+ *
13
+ * `<option>`s rendered by a `.map()` loop get the same `selected`
14
+ * distribution, compared against the option's own value EXPRESSION rather
15
+ * than a literal (#2466). That makes `selected` an ordinary per-row
16
+ * reactive attribute, so it rides the loop's existing `applyItem` /
17
+ * `applyOuter` (or eager `mapArray` per-row effect) machinery — fixing the
18
+ * bug where an INDEX-KEYED reorder left `selected` attached to whichever
19
+ * physical `<option>` happened to have its `value` rewritten in place,
20
+ * instead of following the controlled signal's value.
12
21
  */
13
22
 
14
23
  import { describe, test, expect } from 'bun:test'
@@ -65,7 +74,7 @@ export function Pick() {
65
74
  expect(template).toContain(`((v()) === "b")`)
66
75
  })
67
76
 
68
- test('an authored selected wins; dynamic option loops are left to hydrate', () => {
77
+ test('an authored selected wins; a `.map()` loop option gets a distributed selected binding (#2466)', () => {
69
78
  const { template } = compiled(`
70
79
  "use client"
71
80
  import { createSignal } from '@barefootjs/client'
@@ -89,7 +98,43 @@ export function Pick() {
89
98
  expect(template).toContain('selected={(false) || undefined}')
90
99
  expect(template).not.toContain('(v()) === "a"')
91
100
  expect(template).not.toMatch(/<select[^>]*value=/)
92
- })
101
+ // The loop-rendered option compares against its own value EXPRESSION
102
+ // (the loop param `o`), not a literal — this is the #2466 half.
103
+ expect(template).toContain('((v()) === (o))')
104
+ }, 20000)
105
+
106
+ test('index-keyed .map() loop option: selected rides applyItem AND applyOuter (#2466)', () => {
107
+ const { clientJs } = compiled(`
108
+ "use client"
109
+ import { createSignal } from '@barefootjs/client'
110
+ export function ReorderSelect() {
111
+ const [opts, setOpts] = createSignal([{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }])
112
+ const [val, setVal] = createSignal('b')
113
+ return (
114
+ <div>
115
+ <select value={val()} onChange={(e) => setVal(e.target.value)}>
116
+ {opts().map((o, i) => <option key={i} value={o.id}>{o.label}</option>)}
117
+ </select>
118
+ <button onClick={() => setOpts([...opts()].reverse())}>Reverse</button>
119
+ </div>
120
+ )
121
+ }
122
+ `)
123
+ // `key={i}` is index-keyed: nothing MOVES on reorder, so `value`/text are
124
+ // rewritten in place by applyItem — `selected` must be recomputed there
125
+ // too (item changed under a stationary row), not just at row creation.
126
+ expect(clientJs).toContain('mapArrayLazy(')
127
+ expect(clientJs).toContain('applyItem:')
128
+ expect(clientJs).toContain('applyOuter:')
129
+ // Every apply body (createRow, applyItem, applyOuter) recomputes
130
+ // `selected` from the item AND the outer controlled signal…
131
+ const computed = clientJs.match(/const __x = \(val\(\)\) === \(o\(\)\.id\)/g) ?? []
132
+ expect(computed.length).toBe(3)
133
+ // …and writes it as a DOM PROPERTY (not just an HTML attribute
134
+ // presence, which would not affect a live, already-rendered <option>).
135
+ const writes = clientJs.match(/\.selected = !!\(__x\)/g) ?? []
136
+ expect(writes.length).toBe(3)
137
+ }, 20000)
93
138
 
94
139
  test('textarea with explicit children is left untouched', () => {
95
140
  const { template } = compiled(`
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Regression test for #2589: a `let` declaration's explicit type
3
+ * annotation was dropped in emitted `.tsx` SSR templates —
4
+ * `let x: HTMLTextAreaElement | null = null` emitted as `let x = null`,
5
+ * which TypeScript then infers as `null`/`never`, producing
6
+ * TS7034/TS7005 (and TS2339 via `never` narrowing) under strict mode.
7
+ * Runtime output (client JS) was always correct — this is a type-level
8
+ * emission defect in the SSR template only.
9
+ *
10
+ * `ConstantInfo.typeAnnotation` (verbatim `node.type.getText()`, only
11
+ * present when the author wrote an explicit annotation) is now threaded
12
+ * through and printed by the `HonoAdapter` (`JsxAdapter` base) for `let`
13
+ * declarations, both function-scope and module-scope, initialized and
14
+ * uninitialized. `const` declarations are deliberately left unchanged:
15
+ * their type always infers correctly from the (immutable) initializer,
16
+ * so emitting an annotation there would only churn output for no
17
+ * typecheck gain (see the design note in the class docstring / #2589).
18
+ */
19
+
20
+ import { describe, test, expect } from 'bun:test'
21
+ import { compileJSX } from '../compiler'
22
+ import { HonoAdapter } from '../../../../packages/adapter-hono/src/adapter/hono-adapter'
23
+
24
+ describe('let type annotation preservation in emitted templates (#2589)', () => {
25
+ test('function-scope initialized let keeps its explicit type annotation', () => {
26
+ const honoAdapter = new HonoAdapter()
27
+ // `status()` is called directly from the returned JSX (unlike an
28
+ // `onXxx` event-handler prop, which the SSR template stubs to a
29
+ // no-op), so its body — and transitively `textareaEl`, which the
30
+ // effect-guard shape (`syncScroll`, mirroring the issue) also reads —
31
+ // stays reachable and survives into the emitted template.
32
+ const source = `
33
+ 'use client'
34
+ import { createSignal, createEffect } from '@barefootjs/client'
35
+
36
+ export function Textarea() {
37
+ let textareaEl: HTMLTextAreaElement | null = null
38
+ const [value, setValue] = createSignal('')
39
+
40
+ const syncScroll = () => {
41
+ if (textareaEl) {
42
+ textareaEl.scrollTop = textareaEl.scrollHeight
43
+ }
44
+ }
45
+
46
+ const status = () => (textareaEl ? 'ready' : 'idle')
47
+
48
+ createEffect(() => {
49
+ value()
50
+ syncScroll()
51
+ })
52
+
53
+ return <div>{status()}</div>
54
+ }
55
+ `
56
+
57
+ const result = compileJSX(source, 'Textarea.tsx', { adapter: honoAdapter })
58
+ expect(result.errors).toHaveLength(0)
59
+
60
+ const template = result.files.find((f) => f.type === 'markedTemplate')!
61
+ expect(template).toBeDefined()
62
+ expect(template.content).toContain('let textareaEl: HTMLTextAreaElement | null = null')
63
+ })
64
+
65
+ test('module-scope initialized let keeps its explicit type annotation', () => {
66
+ const honoAdapter = new HonoAdapter()
67
+ const source = `
68
+ 'use client'
69
+ import { createSignal } from '@barefootjs/client'
70
+
71
+ let exportModulePromise: Promise<{ x: number }> | null = null
72
+
73
+ export function Loader() {
74
+ const [status, setStatus] = createSignal('idle')
75
+
76
+ const load = () => {
77
+ exportModulePromise = Promise.resolve({ x: 1 })
78
+ setStatus('loaded')
79
+ }
80
+
81
+ return <button onClick={load}>{status()}</button>
82
+ }
83
+ `
84
+
85
+ const result = compileJSX(source, 'Loader.tsx', { adapter: honoAdapter })
86
+ expect(result.errors).toHaveLength(0)
87
+
88
+ const template = result.files.find((f) => f.type === 'markedTemplate')!
89
+ expect(template).toBeDefined()
90
+ expect(template.content).toContain(
91
+ 'let exportModulePromise: Promise<{ x: number }> | null = null',
92
+ )
93
+ })
94
+
95
+ test('module-scope uninitialized let keeps its explicit type annotation', () => {
96
+ const honoAdapter = new HonoAdapter()
97
+ const source = `
98
+ 'use client'
99
+ import { createSignal } from '@barefootjs/client'
100
+
101
+ let pending: number
102
+
103
+ export function Counter() {
104
+ const [count, setCount] = createSignal(0)
105
+
106
+ const bump = () => {
107
+ pending = count() + 1
108
+ setCount(pending)
109
+ }
110
+
111
+ return <button onClick={bump}>{count()}</button>
112
+ }
113
+ `
114
+
115
+ const result = compileJSX(source, 'Counter.tsx', { adapter: honoAdapter })
116
+ expect(result.errors).toHaveLength(0)
117
+
118
+ const template = result.files.find((f) => f.type === 'markedTemplate')!
119
+ expect(template).toBeDefined()
120
+ expect(template.content).toContain('let pending: number')
121
+ })
122
+
123
+ test('unannotated let does NOT gain an inferred type annotation', () => {
124
+ const honoAdapter = new HonoAdapter()
125
+ const source = `
126
+ 'use client'
127
+ import { createSignal } from '@barefootjs/client'
128
+
129
+ export function Toggle() {
130
+ let y = null
131
+ const [open, setOpen] = createSignal(false)
132
+
133
+ const flip = () => {
134
+ y = open() ? 1 : null
135
+ setOpen(!open())
136
+ }
137
+
138
+ return <button onClick={flip}>{open() ? 'on' : 'off'}{String(y)}</button>
139
+ }
140
+ `
141
+
142
+ const result = compileJSX(source, 'Toggle.tsx', { adapter: honoAdapter })
143
+ expect(result.errors).toHaveLength(0)
144
+
145
+ const template = result.files.find((f) => f.type === 'markedTemplate')!
146
+ expect(template).toBeDefined()
147
+ // No annotation must be synthesized from inference — only an
148
+ // explicit source annotation is ever printed (`typeAnnotation`,
149
+ // never `type` for an initialized declaration).
150
+ expect(template.content).toContain('let y = null')
151
+ expect(template.content).not.toMatch(/let y\s*:/)
152
+ })
153
+
154
+ test('const with an explicit annotation is unchanged (no annotation added at emit)', () => {
155
+ const honoAdapter = new HonoAdapter()
156
+ const source = `
157
+ 'use client'
158
+ import { createSignal } from '@barefootjs/client'
159
+
160
+ export function Labelled() {
161
+ const label: string = 'hello'
162
+ const [count, setCount] = createSignal(0)
163
+ return <button onClick={() => setCount(count() + 1)}>{label}{count()}</button>
164
+ }
165
+ `
166
+
167
+ const result = compileJSX(source, 'Labelled.tsx', { adapter: honoAdapter })
168
+ expect(result.errors).toHaveLength(0)
169
+
170
+ const template = result.files.find((f) => f.type === 'markedTemplate')!
171
+ expect(template).toBeDefined()
172
+ // By design (#2589 scoping decision) only `let` gets its annotation
173
+ // re-emitted — `const` infers correctly from its initializer already,
174
+ // so this stays `const label = 'hello'` with no annotation added.
175
+ expect(template.content).toContain("const label = 'hello'")
176
+ expect(template.content).not.toContain('const label: string')
177
+ })
178
+
179
+ test('ambient `declare let` is not re-emitted as a runtime binding', () => {
180
+ const honoAdapter = new HonoAdapter()
181
+ // `declare let` is a type-only contract: it has no initializer and
182
+ // carries NodeFlags.Let, so after the uninitialized-`let` collection
183
+ // fix it would match the module-scope collector unless ambient
184
+ // statements are excluded. Re-emitting it as a runtime `let` would
185
+ // shadow the real global with `undefined` in the SSR module.
186
+ const source = `
187
+ 'use client'
188
+ import { createSignal } from '@barefootjs/client'
189
+
190
+ declare let __BF_AMBIENT__: string
191
+
192
+ export function Widget() {
193
+ const [n, setN] = createSignal(0)
194
+ const status = () => (__BF_AMBIENT__ ? 'set' : 'unset')
195
+ return <button onClick={() => setN(n() + 1)}>{status()}{n()}</button>
196
+ }
197
+ `
198
+
199
+ const result = compileJSX(source, 'Widget.tsx', { adapter: honoAdapter })
200
+ expect(result.errors).toHaveLength(0)
201
+
202
+ const template = result.files.find((f) => f.type === 'markedTemplate')!
203
+ expect(template).toBeDefined()
204
+ // The reference inside `status` may survive, but no runtime `let`
205
+ // declaration for the ambient name may be emitted.
206
+ expect(template.content).not.toMatch(/^\s*let __BF_AMBIENT__/m)
207
+ })
208
+ })
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Memo/effect dependency extraction must read the token stream, not the raw
3
+ * body text: a doc comment (or string literal) mentioning `otherSignal()`
4
+ * inside a computation body is not a read and must not become a dependency.
5
+ *
6
+ * Regression pin for the phantom deps surfaced in #2581's review: slider's
7
+ * `percentage` memo gained `internalValue`/`controlledValue` deps in the
8
+ * regenerated `ui/meta/slider.json` purely because an explanatory comment
9
+ * inside the memo body named those getters in call syntax.
10
+ */
11
+ import { describe, test, expect } from 'bun:test'
12
+ import { analyzeComponent } from '../analyzer'
13
+
14
+ describe('dependency extraction ignores comments and strings', () => {
15
+ test('a comment naming another getter in call syntax is not a dep', () => {
16
+ const source = `
17
+ "use client"
18
+ import { createSignal, createMemo } from '@barefootjs/client'
19
+ export function Slider() {
20
+ const [internalValue, setInternalValue] = createSignal(0)
21
+ const [controlledValue, setControlledValue] = createSignal<number | undefined>(undefined)
22
+ const [min, setMin] = createSignal(0)
23
+ const [max, setMax] = createSignal(100)
24
+ const currentValue = createMemo(() => controlledValue() ?? internalValue())
25
+ const percentage = createMemo(() => {
26
+ if (max() <= min()) return 0
27
+ // \`currentValue()\` mirrors \`controlledValue()\` and \`internalValue()\`
28
+ // in its type, so assert what's runtime-guaranteed.
29
+ return Math.max(0, Math.min(100, ((currentValue()! - min()) / (max() - min())) * 100))
30
+ })
31
+ return <div style={\`width: \${percentage()}%\`}>{currentValue()}</div>
32
+ }
33
+ `
34
+ const ctx = analyzeComponent(source, 'Slider.tsx')
35
+ const percentage = ctx.memos.find(m => m.name === 'percentage')
36
+ expect(percentage).toBeDefined()
37
+ expect(percentage!.deps.sort()).toEqual(['currentValue', 'max', 'min'])
38
+ })
39
+
40
+ test('a string literal naming a getter in call syntax is not a dep', () => {
41
+ const source = `
42
+ "use client"
43
+ import { createSignal, createMemo } from '@barefootjs/client'
44
+ export function Label() {
45
+ const [count, setCount] = createSignal(0)
46
+ const [label, setLabel] = createSignal('x')
47
+ const hint = createMemo(() => label() + ' (see count() for the number)')
48
+ return <span>{hint()}</span>
49
+ }
50
+ `
51
+ const ctx = analyzeComponent(source, 'Label.tsx')
52
+ const hint = ctx.memos.find(m => m.name === 'hint')
53
+ expect(hint).toBeDefined()
54
+ expect(hint!.deps).toEqual(['label'])
55
+ })
56
+
57
+ test('a read AFTER a template-literal substitution still registers', () => {
58
+ // Trap for token-scan implementations: without a parser driving
59
+ // `reScanTemplateToken`, the template tail after `\${...}` is mis-lexed
60
+ // as a new template opener and swallows the following statement —
61
+ // xyflow's `visibleClass` shape, where `animated()` follows a
62
+ // substitution line and silently lost its dep.
63
+ const source = `
64
+ "use client"
65
+ import { createSignal, createMemo } from '@barefootjs/client'
66
+ export function Edge() {
67
+ const [selected, setSelected] = createSignal(false)
68
+ const [animated, setAnimated] = createSignal(false)
69
+ const visibleClass = createMemo(() => {
70
+ let cls = 'edge'
71
+ if (selected()) cls += \` \${'edge-selected'}\`
72
+ if (animated()) cls += \` \${'edge-animated'}\`
73
+ return cls
74
+ })
75
+ return <div class={visibleClass()}>x</div>
76
+ }
77
+ `
78
+ const ctx = analyzeComponent(source, 'Edge.tsx')
79
+ const visibleClass = ctx.memos.find(m => m.name === 'visibleClass')
80
+ expect(visibleClass).toBeDefined()
81
+ expect(visibleClass!.deps.sort()).toEqual(['animated', 'selected'])
82
+ })
83
+
84
+ test('genuine reads inside template-literal substitutions still register', () => {
85
+ const source = `
86
+ "use client"
87
+ import { createSignal, createMemo } from '@barefootjs/client'
88
+ export function Badge() {
89
+ const [tone, setTone] = createSignal('info')
90
+ const cls = createMemo(() => \`badge badge-\${tone()}\`)
91
+ return <span class={cls()}>x</span>
92
+ }
93
+ `
94
+ const ctx = analyzeComponent(source, 'Badge.tsx')
95
+ const cls = ctx.memos.find(m => m.name === 'cls')
96
+ expect(cls).toBeDefined()
97
+ expect(cls!.deps).toEqual(['tone'])
98
+ })
99
+ })