@barefootjs/jsx 0.31.2 → 0.31.4

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 (65) hide show
  1. package/dist/adapters/jsx-adapter.d.ts.map +1 -1
  2. package/dist/adapters/template-imports.d.ts +27 -15
  3. package/dist/adapters/template-imports.d.ts.map +1 -1
  4. package/dist/analyzer.d.ts.map +1 -1
  5. package/dist/debug.d.ts.map +1 -1
  6. package/dist/identifier-pattern.d.ts +62 -0
  7. package/dist/identifier-pattern.d.ts.map +1 -0
  8. package/dist/index.d.ts +3 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +280 -119
  11. package/dist/ir-to-client-js/collect-elements.d.ts +23 -2
  12. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/control-flow/plan/build-event-delegation.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts.map +1 -1
  15. package/dist/ir-to-client-js/csr-substitute.d.ts +12 -1
  16. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/html-template.d.ts +20 -12
  18. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/prop-handling.d.ts +25 -2
  21. package/dist/ir-to-client-js/prop-handling.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/reactivity.d.ts +30 -2
  23. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/rewrite-props-object.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  26. package/dist/jsx-to-ir.d.ts.map +1 -1
  27. package/dist/module-exports.d.ts.map +1 -1
  28. package/dist/prop-rewrite.d.ts +1 -1
  29. package/dist/relocate.d.ts.map +1 -1
  30. package/dist/scope/binding-scope.d.ts +179 -0
  31. package/dist/scope/binding-scope.d.ts.map +1 -0
  32. package/dist/types.d.ts +8 -0
  33. package/dist/types.d.ts.map +1 -1
  34. package/package.json +2 -2
  35. package/src/__tests__/binding-scope-preamble-shadowing.test.ts +115 -0
  36. package/src/__tests__/binding-scope-ratchet.test.ts +194 -0
  37. package/src/__tests__/binding-scope.test.ts +200 -0
  38. package/src/__tests__/csr-materialize-loop-preamble-shadow.test.ts +76 -0
  39. package/src/__tests__/csr-substitute-enclosing-scope.test.ts +77 -0
  40. package/src/__tests__/identifier-pattern.test.ts +170 -0
  41. package/src/__tests__/let-type-annotation.test.ts +208 -0
  42. package/src/__tests__/loop-child-reactive-attr-const-shadow.test.ts +107 -0
  43. package/src/__tests__/rewrite-dynamic-imports.test.ts +98 -0
  44. package/src/adapters/jsx-adapter.ts +34 -5
  45. package/src/adapters/template-imports.ts +93 -0
  46. package/src/analyzer.ts +20 -3
  47. package/src/debug.ts +4 -3
  48. package/src/identifier-pattern.ts +79 -0
  49. package/src/index.ts +5 -1
  50. package/src/ir-to-client-js/collect-elements.ts +44 -15
  51. package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +2 -1
  52. package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +2 -1
  53. package/src/ir-to-client-js/csr-substitute.ts +19 -2
  54. package/src/ir-to-client-js/html-template.ts +37 -31
  55. package/src/ir-to-client-js/imports.ts +2 -1
  56. package/src/ir-to-client-js/prop-handling.ts +28 -1
  57. package/src/ir-to-client-js/reactivity.ts +54 -4
  58. package/src/ir-to-client-js/rewrite-props-object.ts +2 -1
  59. package/src/ir-to-client-js/utils.ts +9 -8
  60. package/src/jsx-to-ir.ts +187 -73
  61. package/src/module-exports.ts +3 -2
  62. package/src/prop-rewrite.ts +1 -1
  63. package/src/relocate.ts +2 -1
  64. package/src/scope/binding-scope.ts +238 -0
  65. package/src/types.ts +8 -0
@@ -0,0 +1,76 @@
1
+ /**
2
+ * #2482 Stage 1b — `generateCsrTemplateWithOpts`'s (`html-template.ts`)
3
+ * CSR "materialize" template lambda (the `hydrate(..., { template })`
4
+ * function used to render a loop's rows client-side with no SSR markup to
5
+ * hydrate against) const-folded a `.map()` callback preamble local (#2447)
6
+ * into an outer, same-named module-level const, instead of leaving the
7
+ * row-local binding unresolved.
8
+ *
9
+ * Root cause: the `loop` case's `opts.loopBoundNames` — a flat
10
+ * `Set<string>` re-unioned per recursion level — only ever accumulated
11
+ * the loop's item / index / destructured-binding names, never the
12
+ * callback's preamble-declared locals (#2447 postdates the original
13
+ * `loopBoundNames` shadow fix, #2222). Migrating onto `BindingScope`'s
14
+ * `enterLoopRow` (which binds item ∪ index ∪ destructure ∪ preamble
15
+ * `declaredNames` uniformly) closes the gap.
16
+ *
17
+ * Observable failure mode before the fix: every row's branch condition
18
+ * evaluated the constant's fixed truthiness instead of the row's own
19
+ * preamble-computed value — e.g. every `<li>` rendered its "true" branch
20
+ * regardless of the actual per-item condition.
21
+ *
22
+ * Modeled on `csr-template-loop-shadowing.test.ts` (the pre-#2447
23
+ * item/index/destructured-param shadowing pins for this same template
24
+ * lambda) and `binding-scope-preamble-shadowing.test.ts` (the IR-level
25
+ * preamble-shadowing analogue this test is the CSR-codegen-level sibling
26
+ * of).
27
+ */
28
+
29
+ import { describe, test, expect } from 'bun:test'
30
+ import { compileJSX } from '../compiler'
31
+ import { TestAdapter } from '../adapters/test-adapter'
32
+
33
+ const adapter = new TestAdapter()
34
+
35
+ function clientJsFor(source: string): string {
36
+ const result = compileJSX(source, 'Repro.tsx', { adapter })
37
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
38
+ const clientJs = result.files.find(f => f.type === 'clientJs')
39
+ expect(clientJs).toBeDefined()
40
+ return clientJs!.content
41
+ }
42
+
43
+ function templateLambda(content: string): string {
44
+ const line = content.split('\n').find(l => l.includes('hydrate('))
45
+ expect(line).toBeDefined()
46
+ return line!
47
+ }
48
+
49
+ describe('CSR materialize template vs .map() preamble-local shadowing a module const (#2482)', () => {
50
+ test('a preamble local shadowing a module const stays row-local in the hydrate template lambda', () => {
51
+ const tpl = templateLambda(clientJsFor(`
52
+ 'use client'
53
+ import { createSignal } from '@barefootjs/client'
54
+ export function Widget({ items }: { items: { name: string; active: boolean }[] }) {
55
+ const label = 'MODULE_CONST'
56
+ const [on, setOn] = createSignal(true)
57
+ return (
58
+ <div onClick={() => setOn(false)}>
59
+ <ul>
60
+ {items.map((item) => {
61
+ const label = item.active && on()
62
+ return <li key={item.name}>{label ? <span>yes</span> : <span>no</span>}</li>
63
+ })}
64
+ </ul>
65
+ </div>
66
+ )
67
+ }
68
+ `))
69
+
70
+ // The row-local `label` (the preamble's OWN computed value) must
71
+ // drive the branch — never the outer module const's literal.
72
+ expect(tpl).toContain('label ? `<span>yes</span>`')
73
+ expect(tpl).not.toContain("('MODULE_CONST')")
74
+ expect(tpl).not.toContain('MODULE_CONST')
75
+ })
76
+ })
@@ -0,0 +1,77 @@
1
+ /**
2
+ * #2482 Stage 1b — `csrSubstitute`'s `enclosingScope` parameter.
3
+ *
4
+ * `csrSubstitute`'s `boundStack` only ever learns about bindings found
5
+ * while walking the substituted expression's OWN AST (nested arrow/
6
+ * function parameters and block-scoped locals inside `value` itself) — it
7
+ * has no way to see a binding introduced OUTSIDE that expression, e.g. an
8
+ * enclosing `.map()` row's item/index/destructured/preamble binding.
9
+ * `enclosingScope` (a `BindingScope`) seeds those outer frames so
10
+ * `isBound` treats them identically to an in-expression arrow param.
11
+ *
12
+ * Direct unit coverage, not a full-pipeline conformance fixture: every
13
+ * current call site (`html-template.ts`'s `generateCsrTemplateWithOpts`)
14
+ * already pre-filters `env.substitutions` to remove loop-shadowed names
15
+ * BEFORE calling in (see the `loop` case's `childEnv` construction, itself
16
+ * migrated onto `BindingScope.enterLoopRow` in this same stage) — so by
17
+ * the time `csrSubstitute` runs inside a loop body today, the shadowed
18
+ * name is already absent from `env.substitutions` and `enclosingScope`
19
+ * never gets a chance to matter. This test exercises the module function
20
+ * directly, bypassing that pre-filtering, to pin the parameter's own
21
+ * contract in isolation: SHOULD a future call site ever hand `csrSubstitute`
22
+ * an unfiltered env alongside a loop's `BindingScope` (e.g. a caller that
23
+ * wants substitution and shadow-guarding as one step instead of two), the
24
+ * shadow guard already works correctly.
25
+ */
26
+
27
+ import { describe, test, expect } from 'bun:test'
28
+ import { csrSubstitute, type CsrEnv } from '../ir-to-client-js/csr-substitute.ts'
29
+ import { BindingScope } from '../scope/binding-scope.ts'
30
+
31
+ describe('csrSubstitute enclosingScope (#2482)', () => {
32
+ test('a name bound by the enclosing loop scope is left unsubstituted, even though the env still has an entry for it', () => {
33
+ // Deliberately UNFILTERED env — in real call sites this would already
34
+ // have `label` removed by the caller's own loop-scope filtering; here
35
+ // we keep it to isolate what `enclosingScope` alone contributes.
36
+ const env: CsrEnv = {
37
+ substitutions: new Map([
38
+ ['label', { kind: 'identifier', replacement: "'MODULE_CONST'", freeIdentifiers: new Set() }],
39
+ ]),
40
+ propsObjectName: null,
41
+ }
42
+ const scope = BindingScope.EMPTY.enterLoopRow({ param: 'label' })
43
+
44
+ const withoutScope = csrSubstitute('label', env)
45
+ const withScope = csrSubstitute('label', env, scope)
46
+
47
+ expect(withoutScope.rewritten).toBe("('MODULE_CONST')")
48
+ expect(withScope.rewritten).toBe('label')
49
+ })
50
+
51
+ test('a name NOT bound by the enclosing scope still substitutes normally', () => {
52
+ const env: CsrEnv = {
53
+ substitutions: new Map([
54
+ ['label', { kind: 'identifier', replacement: "'MODULE_CONST'", freeIdentifiers: new Set() }],
55
+ ]),
56
+ propsObjectName: null,
57
+ }
58
+ // Scope binds a DIFFERENT name (`item`) — `label` stays substitutable.
59
+ const scope = BindingScope.EMPTY.enterLoopRow({ param: 'item' })
60
+
61
+ const { rewritten } = csrSubstitute('label', env, scope)
62
+ expect(rewritten).toBe("('MODULE_CONST')")
63
+ })
64
+
65
+ test('a preamble-bound name (not item/index/destructure) is also guarded', () => {
66
+ const env: CsrEnv = {
67
+ substitutions: new Map([
68
+ ['label', { kind: 'identifier', replacement: "'MODULE_CONST'", freeIdentifiers: new Set() }],
69
+ ]),
70
+ propsObjectName: null,
71
+ }
72
+ const scope = BindingScope.EMPTY.enterLoopRow({ param: 'item', preamble: { declaredNames: ['label'] } })
73
+
74
+ const { rewritten } = csrSubstitute('label', env, scope)
75
+ expect(rewritten).toBe('label')
76
+ })
77
+ })
@@ -0,0 +1,170 @@
1
+ /**
2
+ * #2592 — `$`-containing identifiers broke the compiler's
3
+ * `new RegExp(`\\b${name}\\b`)` "does this expression reference identifier
4
+ * X?" heuristic in two ways: an unescaped `$` acts as a regex end anchor
5
+ * (false negative anywhere but the very end of the pattern), and even once
6
+ * escaped, `\b` treats `$` as a non-word character, so it fails to find a
7
+ * boundary between e.g. `(` and a leading `$` (both non-word — no
8
+ * transition). See `../identifier-pattern.ts` for the fix.
9
+ */
10
+ import { describe, test, expect } from 'bun:test'
11
+ import { identifierPattern, identifierCallPattern } from '../identifier-pattern.ts'
12
+ import { compileJSX } from '../compiler.ts'
13
+ import { TestAdapter } from '../adapters/test-adapter.ts'
14
+
15
+ describe('identifierPattern (#2592)', () => {
16
+ describe.each(['$item', 'item$', 'a$b', 'item'])('name = %p', (name) => {
17
+ test('matches a standalone parenthesized reference', () => {
18
+ expect(identifierPattern(name).test(`(${name})`)).toBe(true)
19
+ })
20
+
21
+ test('matches a reference after a binary operator', () => {
22
+ expect(identifierPattern(name).test(`x + ${name}`)).toBe(true)
23
+ })
24
+
25
+ test('matches as the base of a member-access expression', () => {
26
+ expect(identifierPattern(name).test(`${name}.foo`)).toBe(true)
27
+ })
28
+
29
+ test('does not match when it is a substring of a longer identifier (suffix)', () => {
30
+ expect(identifierPattern(name).test(`my${name}`)).toBe(false)
31
+ })
32
+
33
+ test('does not match when it is a substring of a longer identifier (prefix)', () => {
34
+ expect(identifierPattern(name).test(`${name}s`)).toBe(false)
35
+ })
36
+
37
+ test('does not match when it appears as a substring inside another identifier', () => {
38
+ // e.g. name="item" inside "xitem" / name="$item" inside "x$item"
39
+ expect(identifierPattern(name).test(`x${name}`)).toBe(false)
40
+ })
41
+ })
42
+
43
+ // Literal spellings from the issue, so the fixture reads without having
44
+ // to mentally substitute the parameterised %p name above.
45
+ test('$item: matches ($item), x + $item, $item.foo', () => {
46
+ const re = identifierPattern('$item')
47
+ expect(re.test('($item)')).toBe(true)
48
+ expect(re.test('x + $item')).toBe(true)
49
+ expect(re.test('$item.foo')).toBe(true)
50
+ })
51
+
52
+ test('$item: does not match my$item, $items, item$s, aitem, xitem', () => {
53
+ const re = identifierPattern('$item')
54
+ expect(re.test('my$item')).toBe(false)
55
+ expect(re.test('$items')).toBe(false)
56
+ expect(re.test('item$s')).toBe(false)
57
+ expect(re.test('aitem')).toBe(false)
58
+ expect(re.test('xitem')).toBe(false)
59
+ })
60
+
61
+ test('item (plain, no $) still matches only standalone references', () => {
62
+ const re = identifierPattern('item')
63
+ expect(re.test('(item)')).toBe(true)
64
+ expect(re.test('x + item')).toBe(true)
65
+ expect(re.test('item.foo')).toBe(true)
66
+ expect(re.test('my$item')).toBe(false)
67
+ expect(re.test('$items')).toBe(false)
68
+ expect(re.test('item$s')).toBe(false)
69
+ expect(re.test('aitem')).toBe(false)
70
+ expect(re.test('xitem')).toBe(false)
71
+ })
72
+
73
+ test('the `g` flag supports substitution scanning across multiple matches', () => {
74
+ const re = identifierPattern('$x', 'g')
75
+ expect('$x + $x'.replace(re, () => 'Y')).toBe('Y + Y')
76
+ })
77
+
78
+ test('regex-metacharacter identifiers are escaped, not interpreted', () => {
79
+ // Not a realistic JS identifier, but guards the escape step directly:
80
+ // an unescaped '.' would match any character.
81
+ const re = identifierPattern('a.b')
82
+ expect(re.test('a.b')).toBe(true)
83
+ expect(re.test('axb')).toBe(false)
84
+ })
85
+ })
86
+
87
+ describe('identifierCallPattern (#2592)', () => {
88
+ test('matches call syntax for a $-prefixed getter, with or without whitespace', () => {
89
+ expect(identifierCallPattern('$count').test('$count()')).toBe(true)
90
+ expect(identifierCallPattern('$count').test('$count ()')).toBe(true)
91
+ expect(identifierCallPattern('$count').test('1 + $count()')).toBe(true)
92
+ })
93
+
94
+ test('does not match a bare (non-call) reference', () => {
95
+ expect(identifierCallPattern('$count').test('$count')).toBe(false)
96
+ })
97
+
98
+ test('does not match when the name is a substring of a longer call', () => {
99
+ expect(identifierCallPattern('$count').test('my$count()')).toBe(false)
100
+ expect(identifierCallPattern('$count').test('$counter()')).toBe(false)
101
+ })
102
+
103
+ test('plain (non-$) getter names keep matching call syntax as before', () => {
104
+ expect(identifierCallPattern('count').test('count()')).toBe(true)
105
+ expect(identifierCallPattern('count').test('acount()')).toBe(false)
106
+ expect(identifierCallPattern('count').test('counter()')).toBe(false)
107
+ })
108
+ })
109
+
110
+ // -----------------------------------------------------------------------------
111
+ // End-to-end: a `.map()` callback whose param is `$item` must classify
112
+ // identically to one named `item` — same slotId allocation, same
113
+ // `$item() ` accessor-wrapping in the emitted client JS. Before the fix,
114
+ // `referencesLoopParam` / `wrapLoopParamAsAccessor`'s `\b$item\b` pattern
115
+ // never matched, so the loop body silently lost slotId/reactive
116
+ // classification for the `$item`-named param (wrong-but-silent: `bun test`
117
+ // still passed because nothing asserted the accessor wrap for a `$`-param
118
+ // until now). Verified red-without/green-with: reverting `identifier-
119
+ // pattern.ts` to a plain `` new RegExp(`\\b${name}\\b`) ``-style
120
+ // implementation fails this describe block while leaving the plain-`item`
121
+ // sibling test green — see PR description for the local repro.
122
+ // -----------------------------------------------------------------------------
123
+ describe('$-prefixed loop param compiles identically to a plain-named one (#2592)', () => {
124
+ const adapter = new TestAdapter()
125
+
126
+ function compileMapBody(param: string): string {
127
+ const source = `
128
+ 'use client'
129
+ import { createSignal } from '@barefootjs/client'
130
+ type Row = { id: number; label: string }
131
+ export function List() {
132
+ const [rows] = createSignal<Row[]>([])
133
+ return (
134
+ <ul>
135
+ {rows().map((${param}) => (
136
+ <Card key={${param}.id}>
137
+ <CardHeader>{${param}.label}</CardHeader>
138
+ </Card>
139
+ ))}
140
+ </ul>
141
+ )
142
+ }
143
+ `
144
+ const result = compileJSX(source, 'List.tsx', { adapter })
145
+ const errors = result.errors.filter(e => e.severity === 'error')
146
+ if (errors.length > 0) throw new Error(errors.map(e => e.message).join('\n'))
147
+ return result.files.find(f => f.type === 'clientJs')!.content
148
+ }
149
+
150
+ test('a plain `item` param is wrapped as an accessor in the child component prop', () => {
151
+ const js = compileMapBody('item')
152
+ expect(js).toContain('item().label')
153
+ })
154
+
155
+ test('a `$item` param is wrapped as an accessor identically (was previously left bare)', () => {
156
+ const js = compileMapBody('$item')
157
+ expect(js).toContain('$item().label')
158
+ expect(js).toContain('$item().id')
159
+ })
160
+ })
161
+
162
+ describe('flags handling', () => {
163
+ test("passing flags that already include 'u' does not throw (no duplicate flag)", () => {
164
+ const p = identifierPattern('item', 'gu')
165
+ expect(p.flags).toBe('gu')
166
+ const c = identifierCallPattern('item', 'u')
167
+ expect(c.flags).toBe('u')
168
+ expect('a item b item'.replace(identifierPattern('item', 'gu'), 'x')).toBe('a x b x')
169
+ })
170
+ })
@@ -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,107 @@
1
+ /**
2
+ * #2482 Stage 1b — `prop-handling.ts`'s `expandConstantForReactivity` /
3
+ * `expandDynamicPropValue` run on `ClientJsContext`, a per-component object
4
+ * with no loop-scope field at all. Both do a flat
5
+ * `ctx.localConstants.find((c) => c.name === trimmedValue)` lookup with no
6
+ * awareness that the expression being expanded might be sitting inside a
7
+ * `.map()` row whose OWN item/index/destructured/preamble binding shadows
8
+ * that same-named module/component-level const — the lookup resolves to
9
+ * the OUTER const's value regardless.
10
+ *
11
+ * This test pins the reachable, currently-shipping instance: a per-item
12
+ * reactive DOM attribute (forced via `/* @client *\/`, so it bypasses the
13
+ * loop-param-only reactivity classifier and always reaches
14
+ * `collectLoopChildReactiveAttrs` → `expandConstantForReactivity`) whose
15
+ * value is a bare reference to the loop's OWN item parameter, which
16
+ * shares a name with a module-level const. Before the fix, the per-item
17
+ * `createEffect`-equivalent (`applyItem`/`createRow` in the lazy-row
18
+ * runtime) baked in the outer const's fixed literal for every row instead
19
+ * of reading the row's own item value — every row rendered the SAME
20
+ * `data-label`, and it never diverged from that one baked value.
21
+ *
22
+ * Modeled on `csr-template-loop-shadowing.test.ts` (the sibling shadowing
23
+ * fix for the CSR *template* lambda, #2222) — this is the analogous fix
24
+ * for the per-item *reactive attribute effect* body, a different codegen
25
+ * path (`reactivity.ts`'s `collectLoopChildReactiveAttrs`, not
26
+ * `html-template.ts`).
27
+ *
28
+ * A second describe block below pins the INDEX-param shadowing case
29
+ * (Copilot review on PR #2595): `buildLoopRowScope` initially omitted
30
+ * the loop's second callback param (`.map((item, i) => ...)`'s `i`) from
31
+ * the `BindingScope` it builds, so an `i`-shadows-a-module-const attr
32
+ * const-folded the outer value exactly like the item-param case above,
33
+ * empirically confirmed reachable through the same
34
+ * `collectLoopChildReactiveAttrs` → `expandConstantForReactivity` path.
35
+ * Fixed by threading the IR loop's `index` field through
36
+ * `collectLoopChildBindings` / `collectLoopChildConditionals` /
37
+ * `summarizeLoopChildBranch` / `collectLoopChildReactiveAttrs` /
38
+ * `collectLoopChildReactiveTexts` into `buildLoopRowScope`.
39
+ */
40
+
41
+ import { describe, test, expect } from 'bun:test'
42
+ import { compileJSX } from '../compiler'
43
+ import { TestAdapter } from '../adapters/test-adapter'
44
+
45
+ const adapter = new TestAdapter()
46
+
47
+ function clientJsFor(source: string): string {
48
+ const result = compileJSX(source, 'Repro.tsx', { adapter })
49
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
50
+ const clientJs = result.files.find(f => f.type === 'clientJs')
51
+ expect(clientJs).toBeDefined()
52
+ return clientJs!.content
53
+ }
54
+
55
+ describe('loop-child reactive attr vs a loop-param shadowing a module const (#2482)', () => {
56
+ test('a /* @client */ attr reading the shadowing item param reads the row value, not the outer const', () => {
57
+ const js = clientJsFor(`
58
+ 'use client'
59
+ import { createSignal } from '@barefootjs/client'
60
+ export function Widget({ items }: { items: string[] }) {
61
+ const label = 'MODULE_CONST'
62
+ const [n, setN] = createSignal(0)
63
+ return (
64
+ <div onClick={() => setN(n() + 1)}>
65
+ <ul>
66
+ {items.map((label) => (
67
+ <li key={label} data-label={/* @client */ label}>{n()}</li>
68
+ ))}
69
+ </ul>
70
+ </div>
71
+ )
72
+ }
73
+ `)
74
+
75
+ // The per-item attribute effect must read the row's OWN item
76
+ // accessor — never the outer module const's baked literal.
77
+ expect(js).toContain('const __x = label()')
78
+ expect(js).not.toContain("const __x = 'MODULE_CONST'")
79
+ })
80
+ })
81
+
82
+ describe('loop-child reactive attr vs a loop INDEX param shadowing a module const (#2482 / #2595)', () => {
83
+ test('a /* @client */ attr reading the shadowing index param reads the row index, not the outer const', () => {
84
+ const js = clientJsFor(`
85
+ 'use client'
86
+ import { createSignal } from '@barefootjs/client'
87
+ export function Widget({ items }: { items: string[] }) {
88
+ const i = 'MODULE_CONST'
89
+ const [n, setN] = createSignal(0)
90
+ return (
91
+ <div onClick={() => setN(n() + 1)}>
92
+ <ul>
93
+ {items.map((item, i) => (
94
+ <li key={item} data-idx={/* @client */ i}>{n()}</li>
95
+ ))}
96
+ </ul>
97
+ </div>
98
+ )
99
+ }
100
+ `)
101
+
102
+ // The per-item attribute effect must read the row's OWN index
103
+ // closure variable — never the outer module const's baked literal.
104
+ expect(js).toContain("const __v = i;")
105
+ expect(js).not.toContain("const __v = 'MODULE_CONST';")
106
+ })
107
+ })