@barefootjs/jsx 0.31.2 → 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.
@@ -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
+ })
@@ -197,7 +197,10 @@ export abstract class JsxAdapter extends BaseAdapter {
197
197
  // No initializer (e.g. `let emblaApi: EmblaCarouselType | undefined`)
198
198
  // — carry the declared type annotation through so `.tsx` output
199
199
  // doesn't fall back to implicit `any` (TS7034/TS7005, #2573).
200
- const typeAnnotation = preserveTypes && constant.type ? `: ${constant.type.raw}` : ''
200
+ const typeAnnotation =
201
+ preserveTypes && (constant.typeAnnotation ?? constant.type)
202
+ ? `: ${constant.typeAnnotation ?? constant.type?.raw}`
203
+ : ''
201
204
  lines.push(` ${keyword} ${constant.name}${typeAnnotation}`)
202
205
  continue
203
206
  }
@@ -213,7 +216,17 @@ export abstract class JsxAdapter extends BaseAdapter {
213
216
  const constValue = preserveTypes
214
217
  ? (constant.typedValue ?? constant.value)
215
218
  : constant.value
216
- lines.push(` ${keyword} ${constant.name} = ${constValue}`)
219
+ // Preserve an explicit `let` type annotation from source (#2589)
220
+ // without it, TS infers the initializer's (often narrower) type and
221
+ // later reassignments/reads fail under strict (TS7034/TS7005, and
222
+ // TS2339 via `never` narrowing). `const` is left alone: its type
223
+ // always infers correctly from the (immutable) initializer, so
224
+ // adding annotations there would only churn snapshots.
225
+ const letTypeAnnotation =
226
+ preserveTypes && keyword === 'let' && constant.typeAnnotation
227
+ ? `: ${constant.typeAnnotation}`
228
+ : ''
229
+ lines.push(` ${keyword} ${constant.name}${letTypeAnnotation} = ${constValue}`)
217
230
  }
218
231
 
219
232
  // Include local functions — skip unreachable ones (only used in event
@@ -412,14 +425,29 @@ export abstract class JsxAdapter extends BaseAdapter {
412
425
  const keyword = c.declarationKind ?? 'const'
413
426
  const exportKw = c.isExported ? 'export ' : ''
414
427
  if (!c.value) {
415
- entries.push({ line: c.loc.start.line, text: `${exportKw}${keyword} ${c.name}` })
428
+ // No initializer (e.g. module-scope `let pending: number`) — carry
429
+ // the declared type annotation through, mirroring the function-scope
430
+ // fix above (#2573 / #2589).
431
+ const typeAnnotation =
432
+ preserveTypes && (c.typeAnnotation ?? c.type)
433
+ ? `: ${c.typeAnnotation ?? c.type?.raw}`
434
+ : ''
435
+ entries.push({ line: c.loc.start.line, text: `${exportKw}${keyword} ${c.name}${typeAnnotation}` })
416
436
  continue
417
437
  }
418
438
  const trimmed = c.value.trim()
419
439
  if (/^new WeakMap\b/.test(trimmed)) continue
420
440
  if (c.isExported && /^createContext\b/.test(trimmed)) continue
421
441
  const value = preserveTypes ? (c.typedValue ?? c.value) : c.value
422
- entries.push({ line: c.loc.start.line, text: `${exportKw}${keyword} ${c.name} = ${value}` })
442
+ // Preserve an explicit module-scope `let` type annotation from source
443
+ // (#2589) — see the function-scope sibling above for rationale. `const`
444
+ // is left alone: its type always infers correctly from the (immutable)
445
+ // initializer.
446
+ const letTypeAnnotation =
447
+ preserveTypes && keyword === 'let' && c.typeAnnotation
448
+ ? `: ${c.typeAnnotation}`
449
+ : ''
450
+ entries.push({ line: c.loc.start.line, text: `${exportKw}${keyword} ${c.name}${letTypeAnnotation} = ${value}` })
423
451
  }
424
452
 
425
453
  for (const f of ir.metadata.localFunctions) {
package/src/analyzer.ts CHANGED
@@ -504,8 +504,18 @@ function visit(
504
504
  collectAmbientGlobals(node, ctx)
505
505
  }
506
506
 
507
- // Module-level constants (outside component)
508
- if (ts.isVariableStatement(node) && !ctx.componentNode) {
507
+ // Module-level constants (outside component). Ambient statements
508
+ // (`declare let X: T`) are type-only contracts with no runtime binding —
509
+ // collectAmbientGlobals above already tracks them for BF052, and
510
+ // re-emitting one as a runtime `let` would shadow the real global, so
511
+ // they must not reach collectConstant. (Previously excluded only by
512
+ // accident: this path required an initializer, which `declare`
513
+ // statements never have — the #2589 uninitialized-`let` fix removed
514
+ // that gate, so the exclusion is now explicit.)
515
+ const isDeclareStatement =
516
+ ts.isVariableStatement(node) &&
517
+ (node.modifiers?.some(m => m.kind === ts.SyntaxKind.DeclareKeyword) ?? false)
518
+ if (ts.isVariableStatement(node) && !ctx.componentNode && !isDeclareStatement) {
509
519
  const isExported = node.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false
510
520
  const isLet = (node.declarationList.flags & ts.NodeFlags.Let) !== 0
511
521
  const isModuleClientDirective = hasLeadingClientDirectiveOnStatement(node, ctx.sourceFile)
@@ -525,9 +535,15 @@ function visit(
525
535
  }
526
536
  continue
527
537
  }
538
+ // An initializer is required for `const` (TS grammar enforces this),
539
+ // but an uninitialized module-scope `let` (e.g. `let pending: number`)
540
+ // is legal source and must still be collected — mirroring the
541
+ // component-scope path below (line ~687), which has never gated on
542
+ // `decl.initializer` — so its declared type carries into the emitted
543
+ // template instead of the declaration vanishing outright (#2589).
528
544
  if (
529
545
  ts.isIdentifier(decl.name) &&
530
- decl.initializer &&
546
+ (decl.initializer || isLet) &&
531
547
  !isArrowComponentFunction(decl)
532
548
  ) {
533
549
  collectConstant(decl, ctx, true, isLet ? 'let' : 'const', isExported)
@@ -3203,6 +3219,7 @@ function collectConstant(
3203
3219
  value,
3204
3220
  parsed,
3205
3221
  typedValue: typedValue !== value ? typedValue : undefined,
3222
+ typeAnnotation: node.type ? node.type.getText(ctx.sourceFile) : undefined,
3206
3223
  valueBranches,
3207
3224
  declarationKind,
3208
3225
  isExported,
package/src/index.ts CHANGED
@@ -214,6 +214,10 @@ export { buildLoopChainExpr } from './loop-chain.ts'
214
214
  export type { LoopChainInputs } from './loop-chain.ts'
215
215
  export { isLowerableLoopDestructure, isLowerableObjectRestDestructure } from './loop-destructure.ts'
216
216
 
217
+ // Binding scope (#2482) — shared loop-bound-name resolution service
218
+ export { BindingScope } from './scope/binding-scope.ts'
219
+ export type { ScopeBindingSource, ScopeBinding, ScopeFrame, LoopBindingSource } from './scope/binding-scope.ts'
220
+
217
221
  // Debug analysis
218
222
  export {
219
223
  buildComponentGraph,