@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.
- package/dist/adapters/jsx-adapter.d.ts.map +1 -1
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +134 -36
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/prop-rewrite.d.ts +1 -1
- package/dist/scope/binding-scope.d.ts +179 -0
- package/dist/scope/binding-scope.d.ts.map +1 -0
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/binding-scope-preamble-shadowing.test.ts +115 -0
- package/src/__tests__/binding-scope-ratchet.test.ts +194 -0
- package/src/__tests__/binding-scope.test.ts +200 -0
- package/src/__tests__/let-type-annotation.test.ts +208 -0
- package/src/adapters/jsx-adapter.ts +32 -4
- package/src/analyzer.ts +20 -3
- package/src/index.ts +4 -0
- package/src/jsx-to-ir.ts +170 -65
- package/src/prop-rewrite.ts +1 -1
- package/src/scope/binding-scope.ts +238 -0
- package/src/types.ts +8 -0
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|