@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
@@ -6,15 +6,19 @@
6
6
  */
7
7
 
8
8
  import type {
9
+ AttrValue,
9
10
  ComponentIR,
10
11
  IRNode,
12
+ IRTemplatePart,
11
13
  ImportSpecifier,
12
14
  } from '../types.ts'
15
+ import { templatePartsToJsExpr } from '../template-parts.ts'
13
16
  import { BF_SCOPE, BF_SLOT, BF_COND } from '@barefootjs/shared'
14
17
  import { BaseAdapter } from './interface.ts'
15
18
  import type { CallbackBodyAcceptor } from './interface.ts'
16
19
  import { ENV_SIGNAL_CLIENT_FACTORY } from './env-signal.ts'
17
20
  import { formatParamWithType, findReachableNames } from '../module-exports.ts'
21
+ import { extractFreeIdentifiersFromText } from '../ir-to-client-js/csr-substitute.ts'
18
22
 
19
23
  export interface JsxAdapterConfig {
20
24
  /** Use typed versions (typedInitialValue, etc.) for type-safe .tsx output */
@@ -144,11 +148,15 @@ export abstract class JsxAdapter extends BaseAdapter {
144
148
  const initialValue = rawInitialValue.trim().startsWith('{') ? `(${rawInitialValue})` : rawInitialValue
145
149
 
146
150
  // When preserveTypes and typedInitialValue is absent but signal.type has a meaningful
147
- // type from a generic parameter, add a type assertion to prevent TS inference issues
151
+ // type from a generic parameter, add a type assertion to prevent TS inference issues.
152
+ // A bare `object` raw is the analyzer's coarse KIND, not a real type — asserting
153
+ // `as object` only destroys the initializer's inferred literal type (a
154
+ // `{ x: 0, y: 0 }` signal getter stops matching `() => { x: number; y: number }`).
148
155
  const needsTypeAssertion = preserveTypes
149
156
  && !signal.typedInitialValue
150
157
  && signal.type.kind !== 'unknown'
151
158
  && signal.type.kind !== 'primitive'
159
+ && signal.type.raw !== 'object'
152
160
  if (needsTypeAssertion) {
153
161
  lines.push(` const ${signal.getter} = () => ${initialValue} as ${signal.type.raw}`)
154
162
  } else {
@@ -173,12 +181,27 @@ export abstract class JsxAdapter extends BaseAdapter {
173
181
  lines.push(` const ${memo.name} = ${computation}`)
174
182
  }
175
183
 
176
- // Include local constants — skip unreachable ones (only used in event handlers)
184
+ // Include local constants — skip unreachable ones (only used in event
185
+ // handlers). Genuinely module-scope constants are NOT localised here:
186
+ // they're emitted at module scope by `generateModuleScopeDeclarations`
187
+ // so module-scope types that reference them (via `typeof`) keep
188
+ // resolving, matching the client bundle's `emitModuleLevelDeclarations`
189
+ // (#2570). `moduleScopeDeclarationNames` — not the raw `isModule` flag
190
+ // — decides the split; see its docstring.
191
+ const moduleScopeNames = this.moduleScopeDeclarationNames(ir)
177
192
  for (const constant of ir.metadata.localConstants) {
178
193
  if (constant.isExported) continue
194
+ if (moduleScopeNames.has(constant.name)) continue
179
195
  const keyword = constant.declarationKind ?? 'const'
180
196
  if (!constant.value) {
181
- lines.push(` ${keyword} ${constant.name}`)
197
+ // No initializer (e.g. `let emblaApi: EmblaCarouselType | undefined`)
198
+ // — carry the declared type annotation through so `.tsx` output
199
+ // doesn't fall back to implicit `any` (TS7034/TS7005, #2573).
200
+ const typeAnnotation =
201
+ preserveTypes && (constant.typeAnnotation ?? constant.type)
202
+ ? `: ${constant.typeAnnotation ?? constant.type?.raw}`
203
+ : ''
204
+ lines.push(` ${keyword} ${constant.name}${typeAnnotation}`)
182
205
  continue
183
206
  }
184
207
  const value = constant.value.trim()
@@ -193,11 +216,24 @@ export abstract class JsxAdapter extends BaseAdapter {
193
216
  const constValue = preserveTypes
194
217
  ? (constant.typedValue ?? constant.value)
195
218
  : constant.value
196
- 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}`)
197
230
  }
198
231
 
199
- // Include local functions — skip unreachable ones (only used in event handlers)
232
+ // Include local functions — skip unreachable ones (only used in event
233
+ // handlers). Genuinely module-scope functions stay at module scope,
234
+ // same as constants above.
200
235
  for (const func of localFunctions) {
236
+ if (moduleScopeNames.has(func.name)) continue
201
237
  if (!reachable.has(func.name)) continue
202
238
  // Prefer the source-verbatim signature when types are preserved so
203
239
  // type-predicate annotations (`element is { tag: unknown; … }`) and
@@ -219,6 +255,222 @@ export abstract class JsxAdapter extends BaseAdapter {
219
255
  return lines.join('\n')
220
256
  }
221
257
 
258
+ // ===========================================================================
259
+ // Module-Scope Declarations
260
+ // ===========================================================================
261
+
262
+ /**
263
+ * Names that genuinely live at module scope in the emitted template.
264
+ *
265
+ * The analyzer's `isModule` flag is NOT a lexical-scope oracle: the
266
+ * module walker recurses into component bodies, so a component-body
267
+ * helper (calendar's `renderMonthGrid`, xyflow's edge handlers) can
268
+ * carry `isModule: true` while closing over component state. Hoisting
269
+ * such a helper breaks every reference (TS2304/TS2552 — and worse,
270
+ * wrong runtime scope). So candidates are demoted by a forward-
271
+ * reachability fixpoint, mirroring the client bundle's
272
+ * `computeDeclarationScopes` (`ir-to-client-js/compute-scope.ts`): a
273
+ * candidate that references component scope — a signal getter/setter, a
274
+ * memo, a prop, a body const/function, or an already-demoted candidate
275
+ * — is component-scoped, transitively. References are REAL identifier
276
+ * nodes from a TS AST walk (`extractFreeIdentifiersFromText`), not text
277
+ * matches — a component-scope name occurring inside a candidate's
278
+ * string literal (`[data-state="open"]` vs a body const `open`) is not
279
+ * a reference and must not demote it.
280
+ *
281
+ * EXPORTED candidates are never demoted: `export` is only legal at
282
+ * module top level, so an exported declaration is lexically module-
283
+ * scoped by construction — and a spurious demotion would make it
284
+ * vanish entirely (the body loop skips exported declarations and
285
+ * `generateModuleExports` is told to skip value declarations).
286
+ *
287
+ * Memoized per IR: `generateModuleScopeDeclarations` (module emission)
288
+ * and `generateSignalInitializers` (body emission) must agree on the
289
+ * split or a declaration is emitted twice or not at all.
290
+ */
291
+ private readonly moduleScopeNamesCache = new WeakMap<ComponentIR, Set<string>>()
292
+
293
+ protected moduleScopeDeclarationNames(ir: ComponentIR): Set<string> {
294
+ const cached = this.moduleScopeNamesCache.get(ir)
295
+ if (cached) return cached
296
+
297
+ const componentScope = new Set<string>()
298
+ for (const sig of ir.metadata.signals) {
299
+ if (sig.isModule) continue
300
+ componentScope.add(sig.getter)
301
+ if (sig.setter) componentScope.add(sig.setter)
302
+ }
303
+ for (const memo of ir.metadata.memos) {
304
+ if (!memo.isModule) componentScope.add(memo.name)
305
+ }
306
+ for (const p of ir.metadata.propsParams) componentScope.add(p.name)
307
+ if (ir.metadata.propsObjectName) componentScope.add(ir.metadata.propsObjectName)
308
+ if (ir.metadata.restPropsName) componentScope.add(ir.metadata.restPropsName)
309
+ for (const c of ir.metadata.localConstants) {
310
+ if (!c.isModule) componentScope.add(c.name)
311
+ }
312
+ for (const f of ir.metadata.localFunctions) {
313
+ if (!f.isModule) componentScope.add(f.name)
314
+ }
315
+
316
+ // Exported declarations join the emit set unconditionally (see the
317
+ // docstring); only non-exported candidates enter the demotion
318
+ // fixpoint, each carrying its REAL free-identifier set.
319
+ const exported = new Set<string>()
320
+ const candidates = new Map<string, ReadonlySet<string>>()
321
+ for (const c of ir.metadata.localConstants) {
322
+ if (!c.isModule) continue
323
+ // JSX-valued consts are inlined at their usage sites at IR level
324
+ // (#547/#569) — same skip as the client's `classifyConstant`.
325
+ if (c.isJsx || c.isJsxFunction) continue
326
+ if (c.isExported) {
327
+ exported.add(c.name)
328
+ continue
329
+ }
330
+ // The analyzer precomputes the value's free identifiers; fall back
331
+ // to the same AST walk for values it didn't cover.
332
+ candidates.set(c.name, c.freeIdentifiers ?? extractFreeIdentifiersFromText(c.value ?? ''))
333
+ }
334
+ for (const f of ir.metadata.localFunctions) {
335
+ if (!f.isModule) continue
336
+ // JSX-returning helpers (single- and multi-return) are inlined at
337
+ // their call sites (#569/#932); emitting them too would resurrect
338
+ // them as dead module-scope declarations — and the multi-return
339
+ // bodies carry raw source JSX that must not reach the template
340
+ // verbatim. Same skips as the client's `computeDeclarationScopes`.
341
+ if (f.isJsxFunction || f.isMultiReturnJsxHelper) continue
342
+ if (f.isExported) {
343
+ exported.add(f.name)
344
+ continue
345
+ }
346
+ // Wrap as an ARROW so the extractor's parameter shadowing applies
347
+ // (it tracks arrow params only) — param references then don't count
348
+ // as free, matching the client fixpoint's `refs.delete(p.name)`.
349
+ const params = f.typedParams !== undefined
350
+ ? f.typedParams
351
+ : f.params.map(formatParamWithType).join(', ')
352
+ candidates.set(f.name, extractFreeIdentifiersFromText(`(${params}) => ${f.body}`))
353
+ }
354
+
355
+ const referencesAny = (refs: ReadonlySet<string>, names: ReadonlySet<string>): boolean => {
356
+ for (const ref of refs) {
357
+ if (names.has(ref)) return true
358
+ }
359
+ return false
360
+ }
361
+ let changed = true
362
+ while (changed) {
363
+ changed = false
364
+ for (const [name, refs] of candidates) {
365
+ if (referencesAny(refs, componentScope)) {
366
+ candidates.delete(name)
367
+ componentScope.add(name)
368
+ changed = true
369
+ }
370
+ }
371
+ }
372
+
373
+ const result = new Set([...exported, ...candidates.keys()])
374
+ this.moduleScopeNamesCache.set(ir, result)
375
+ return result
376
+ }
377
+
378
+ /**
379
+ * Module-scope type, constant, and function declarations for the
380
+ * emitted template, kept at MODULE scope in SOURCE ORDER — the emitted
381
+ * module preserves the source module's shape, as the client bundle
382
+ * already does (`emitModuleLevelDeclarations` in `ir-to-client-js`).
383
+ * Root cure for the #2570 family: type declarations are re-emitted
384
+ * verbatim at module scope, so any value they reference through
385
+ * `typeof` (a type alias's `keyof typeof strokePaths`, a props
386
+ * annotation's `keyof typeof modes`) must be declared there too.
387
+ * Localising those values into each component body — the previous
388
+ * shape — failed the query with TS2304, and an unresolved
389
+ * `keyof typeof` degrades to `keyof any`, silently widening the type
390
+ * to `string | number | symbol` and taking every downstream check
391
+ * with it.
392
+ *
393
+ * EXPORTED module declarations are emitted here too (with their
394
+ * `export` keyword), interleaved with the non-exported ones in source
395
+ * order — not split off to `generateModuleExports`' section, which is
396
+ * emitted after this one and so would put an exported const AFTER a
397
+ * non-exported const that reads it (input-otp's `patternPresets`
398
+ * reading `REGEXP_ONLY_DIGITS`): a module-load TDZ crash, not just
399
+ * TS2448. The compiler skips value declarations in
400
+ * `generateModuleExports` when `moduleConstantsIncludeExports` is set
401
+ * on the sections.
402
+ *
403
+ * Emission is deliberately UNFILTERED by per-component reachability: an
404
+ * unused module declaration in the emitted template is harmless and
405
+ * matches the source module. In a multi-component file each component's
406
+ * adapter output carries its own block (the analyzer collects the module
407
+ * declarations lexically preceding the component, so blocks can be
408
+ * unequal prefixes); the compiler merges them with top-level-STATEMENT
409
+ * dedup, so shared declarations land exactly once in source order.
410
+ *
411
+ * `new WeakMap()` bindings stay client-only, and exported
412
+ * `createContext()` bindings stay unemitted, exactly as before.
413
+ */
414
+ protected generateModuleScopeDeclarations(ir: ComponentIR): string {
415
+ const { preserveTypes } = this.jsxConfig
416
+ const moduleNames = this.moduleScopeDeclarationNames(ir)
417
+ const entries: Array<{ line: number, text: string }> = []
418
+
419
+ for (const t of ir.metadata.typeDefinitions) {
420
+ entries.push({ line: t.loc.start.line, text: t.definition })
421
+ }
422
+
423
+ for (const c of ir.metadata.localConstants) {
424
+ if (!c.isModule || !moduleNames.has(c.name)) continue
425
+ const keyword = c.declarationKind ?? 'const'
426
+ const exportKw = c.isExported ? 'export ' : ''
427
+ if (!c.value) {
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}` })
436
+ continue
437
+ }
438
+ const trimmed = c.value.trim()
439
+ if (/^new WeakMap\b/.test(trimmed)) continue
440
+ if (c.isExported && /^createContext\b/.test(trimmed)) continue
441
+ const value = preserveTypes ? (c.typedValue ?? c.value) : c.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}` })
451
+ }
452
+
453
+ for (const f of ir.metadata.localFunctions) {
454
+ if (!f.isModule || !moduleNames.has(f.name)) continue
455
+ const params = preserveTypes && f.typedParams !== undefined
456
+ ? f.typedParams
457
+ : f.params.map(formatParamWithType).join(', ')
458
+ const returnAnnotation = preserveTypes && f.typedReturnType
459
+ ? `: ${f.typedReturnType}`
460
+ : ''
461
+ const body = preserveTypes ? (f.typedBody ?? f.body) : f.body
462
+ const asyncKw = f.isAsync ? 'async ' : ''
463
+ const exportKw = f.isExported ? 'export ' : ''
464
+ entries.push({
465
+ line: f.loc.start.line,
466
+ text: `${exportKw}${asyncKw}function ${f.name}(${params})${returnAnnotation} ${body}`,
467
+ })
468
+ }
469
+
470
+ entries.sort((a, b) => a.line - b.line)
471
+ return entries.map(e => e.text).join('\n')
472
+ }
473
+
222
474
  // ===========================================================================
223
475
  // Raw Node Rendering
224
476
  // ===========================================================================
@@ -233,6 +485,44 @@ export abstract class JsxAdapter extends BaseAdapter {
233
485
  return this.renderNode(node)
234
486
  }
235
487
 
488
+ // ===========================================================================
489
+ // Template Part Rendering
490
+ // ===========================================================================
491
+
492
+ /**
493
+ * Render a structured `template` variant's parts as JS template-literal
494
+ * source for this adapter's .tsx output, carrying the `preserveTypes`
495
+ * index annotation on inlined `lookup` records (#2565 — see
496
+ * `lookupPartToJsExpr`).
497
+ */
498
+ protected renderTemplatePartsAsJs(parts: readonly IRTemplatePart[]): string {
499
+ return templatePartsToJsExpr(parts, { typed: this.jsxConfig.preserveTypes })
500
+ }
501
+
502
+ /**
503
+ * The JS source for an `expression` attribute / component-prop value.
504
+ *
505
+ * A component-prop `template` is collapsed into an `expression` at IR
506
+ * construction time (component props are runtime values, not HTML
507
+ * attribute bodies), which drops it out of `renderTemplatePartsAsJs`'s
508
+ * reach — the same inlined-record index, minus the type annotation
509
+ * (#2565). The collapse keeps its `parts`, so re-render from those when
510
+ * `expr` is still EXACTLY the neutral collapse. Any later rewrite of
511
+ * `expr` (a presence peel, a prop-ref rewrite) fails the identity check
512
+ * and wins, so this can only ever add the annotation, never undo a
513
+ * downstream edit.
514
+ */
515
+ protected expressionValueToJs(value: Extract<AttrValue, { kind: 'expression' }>): string {
516
+ if (
517
+ this.jsxConfig.preserveTypes &&
518
+ value.parts &&
519
+ value.expr === templatePartsToJsExpr(value.parts)
520
+ ) {
521
+ return this.renderTemplatePartsAsJs(value.parts)
522
+ }
523
+ return value.expr
524
+ }
525
+
236
526
  // ===========================================================================
237
527
  // Hydration Markers
238
528
  // ===========================================================================
@@ -5,6 +5,7 @@
5
5
  * Generates simple JSX output without framework-specific features.
6
6
  */
7
7
 
8
+ import ts from 'typescript'
8
9
  import type {
9
10
  ComponentIR,
10
11
  IRNode,
@@ -20,6 +21,7 @@ import type {
20
21
  import type { AdapterOutput, TemplateSections } from './interface.ts'
21
22
  import { type JsxAdapterConfig, JsxAdapter } from './jsx-adapter.ts'
22
23
  import { rewriteImportsForTemplate } from './template-imports.ts'
24
+ import { propsDestructureBinding } from '../props-binding.ts'
23
25
 
24
26
  export class TestAdapter extends JsxAdapter {
25
27
  name = 'test'
@@ -31,6 +33,7 @@ export class TestAdapter extends JsxAdapter {
31
33
  this.componentName = ir.metadata.componentName
32
34
 
33
35
  const imports = this.generateImports(ir)
36
+ const moduleConstants = this.generateModuleScopeDeclarations(ir)
34
37
  const types = this.generateTypes(ir)
35
38
  const component = this.generateComponent(ir)
36
39
 
@@ -43,10 +46,12 @@ export class TestAdapter extends JsxAdapter {
43
46
  types: types || '',
44
47
  component,
45
48
  defaultExport,
49
+ moduleConstants,
50
+ moduleConstantsIncludeExports: true,
46
51
  }
47
52
 
48
53
  // Assemble template for backward compat
49
- const template = [imports, types, component].filter(Boolean).join('\n\n') + defaultExport
54
+ const template = [imports, moduleConstants, types, component].filter(Boolean).join('\n\n') + defaultExport
50
55
 
51
56
  return {
52
57
  template,
@@ -84,9 +89,9 @@ export class TestAdapter extends JsxAdapter {
84
89
  generateTypes(ir: ComponentIR): string | null {
85
90
  const lines: string[] = []
86
91
 
87
- for (const typeDef of ir.metadata.typeDefinitions) {
88
- lines.push(typeDef.definition)
89
- }
92
+ // Source type declarations are emitted once at module scope by
93
+ // `generateModuleScopeDeclarations` (#2570) — only the synthesized
94
+ // hydration alias is per-component.
90
95
 
91
96
  // Only generate PropsWithHydration when destructured-props pattern uses it
92
97
  const propsTypeName = ir.metadata.propsType?.raw
@@ -122,8 +127,10 @@ export class TestAdapter extends JsxAdapter {
122
127
  const bodyRefText = [jsxBody, signalInits, scopeIdLine].join('\n')
123
128
  const bfScopeAlias = /\b__bfScope\b/.test(bodyRefText) ? '__bfScope' : '__bfScope: _bfScope'
124
129
 
130
+ // `key: local` rename-aware bindings, shared with the Hono SSR
131
+ // destructure — one renderer, zero drift (`propsDestructureBinding`).
125
132
  const propsParams = ir.metadata.propsParams
126
- .map((p: ParamInfo) => (p.defaultValue ? `${p.name} = ${p.defaultValue}` : p.name))
133
+ .map((p: ParamInfo) => propsDestructureBinding(p))
127
134
  .join(', ')
128
135
 
129
136
  const restPropsName = ir.metadata.restPropsName
@@ -325,11 +332,7 @@ export class TestAdapter extends JsxAdapter {
325
332
  // Simple stringifier for `template`-kind values; tests only need a
326
333
  // recognisable JSX shape, not byte-exact reproduction.
327
334
  const v = value as { kind: 'template'; parts: import('../types.ts').IRTemplatePart[] }
328
- return '`' + v.parts.map(p => {
329
- if (p.type === 'string') return p.value
330
- if (p.type === 'ternary') return `\${${p.condition} ? '${p.whenTrue}' : '${p.whenFalse}'}`
331
- return `\${(${JSON.stringify(p.cases)})[${p.key}]}`
332
- }).join('') + '`'
335
+ return this.renderTemplatePartsAsJs(v.parts)
333
336
  }
334
337
 
335
338
  private renderComponentProps(comp: IRComponent): string {
package/src/analyzer.ts CHANGED
@@ -8,9 +8,10 @@
8
8
 
9
9
  import ts from 'typescript'
10
10
  import type { ImportSpecifier, TypeInfo, ParamInfo, ReactiveFactoryInfo, DeclinedReactiveFactory, RequiredFactoryImport, FactoryRenameSite, SourceLocation } from './types.ts'
11
- import { parseExpression, parseBlockBodyTolerant, foldBlockToExpr } from './expression-parser.ts'
11
+ import { parseExpression, parseBlockBodyTolerant, foldBlockToExpr, tsNodeToParsedExpr } from './expression-parser.ts'
12
12
  import type { CallbackBodyAcceptor } from './adapters/interface.ts'
13
13
  import { rewriteBarePropRefs } from './prop-rewrite.ts'
14
+ import { buildPropAliasMap } from './props-binding.ts'
14
15
  import { incrementCounter } from './instrumentation.ts'
15
16
  import {
16
17
  type AnalyzerContext,
@@ -503,8 +504,18 @@ function visit(
503
504
  collectAmbientGlobals(node, ctx)
504
505
  }
505
506
 
506
- // Module-level constants (outside component)
507
- 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) {
508
519
  const isExported = node.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false
509
520
  const isLet = (node.declarationList.flags & ts.NodeFlags.Let) !== 0
510
521
  const isModuleClientDirective = hasLeadingClientDirectiveOnStatement(node, ctx.sourceFile)
@@ -524,9 +535,15 @@ function visit(
524
535
  }
525
536
  continue
526
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).
527
544
  if (
528
545
  ts.isIdentifier(decl.name) &&
529
- decl.initializer &&
546
+ (decl.initializer || isLet) &&
530
547
  !isArrowComponentFunction(decl)
531
548
  ) {
532
549
  collectConstant(decl, ctx, true, isLet ? 'let' : 'const', isExported)
@@ -1273,7 +1290,8 @@ function collectSignal(node: ts.VariableDeclaration, ctx: AnalyzerContext): void
1273
1290
  if (!ctx.propsObjectName && callExpr.arguments[0]) {
1274
1291
  const propNames = new Set(ctx.propsParams.map(p => p.name))
1275
1292
  if (propNames.size > 0) {
1276
- templateInitialValue = rewriteBarePropRefs(initialValue, callExpr.arguments[0], propNames)
1293
+ const propAliases = buildPropAliasMap(ctx.propsParams)
1294
+ templateInitialValue = rewriteBarePropRefs(initialValue, callExpr.arguments[0], propNames, undefined, propAliases)
1277
1295
  }
1278
1296
  }
1279
1297
 
@@ -1613,7 +1631,8 @@ function collectMemo(node: ts.VariableDeclaration, ctx: AnalyzerContext): void {
1613
1631
  if (!ctx.propsObjectName && callExpr.arguments[0]) {
1614
1632
  const propNames = new Set(ctx.propsParams.map(p => p.name))
1615
1633
  if (propNames.size > 0) {
1616
- templateComputation = rewriteBarePropRefs(computation, callExpr.arguments[0], propNames)
1634
+ const propAliases = buildPropAliasMap(ctx.propsParams)
1635
+ templateComputation = rewriteBarePropRefs(computation, callExpr.arguments[0], propNames, undefined, propAliases)
1617
1636
  }
1618
1637
  }
1619
1638
 
@@ -3129,9 +3148,15 @@ function collectConstant(
3129
3148
  let templateValue: string | undefined
3130
3149
  if (value && ctx.propsParams.length > 0 && node.initializer) {
3131
3150
  let propNames: Set<string>
3151
+ // Local → caller-facing key (#2524 CSR half) — only Case 1 propsParams
3152
+ // entries can carry `sourceName`; Case 2's alias set below is keyed by
3153
+ // the destructured-from-`props.X` local name, which by its own
3154
+ // `isPureAlias` construction always equals the source key.
3155
+ let propAliases: Map<string, string> | undefined
3132
3156
  if (!ctx.propsObjectName) {
3133
3157
  // Case 1: destructured-arg — all propsParams are bare locals.
3134
3158
  propNames = new Set(ctx.propsParams.map(p => p.name))
3159
+ propAliases = buildPropAliasMap(ctx.propsParams)
3135
3160
  } else {
3136
3161
  // Case 2: `(props)`-arg — restrict to body-destructured pure aliases
3137
3162
  // of `props.X` (entries pushed by the body-destructure expansion at
@@ -3157,7 +3182,7 @@ function collectConstant(
3157
3182
  )
3158
3183
  }
3159
3184
  if (propNames.size > 0) {
3160
- const rewritten = rewriteBarePropRefs(value, node.initializer, propNames)
3185
+ const rewritten = rewriteBarePropRefs(value, node.initializer, propNames, undefined, propAliases)
3161
3186
  if (rewritten !== undefined) {
3162
3187
  templateValue = rewritten
3163
3188
  // For the body-destructure case, also rewrite the init-body
@@ -3194,6 +3219,7 @@ function collectConstant(
3194
3219
  value,
3195
3220
  parsed,
3196
3221
  typedValue: typedValue !== value ? typedValue : undefined,
3222
+ typeAnnotation: node.type ? node.type.getText(ctx.sourceFile) : undefined,
3197
3223
  valueBranches,
3198
3224
  declarationKind,
3199
3225
  isExported,
@@ -3307,6 +3333,12 @@ function extractProps(param: ts.ParameterDeclaration, ctx: AnalyzerContext): voi
3307
3333
  const resolvedType: TypeInfo = member?.type ?? { kind: 'unknown', raw: 'unknown' }
3308
3334
 
3309
3335
  const defaultContainsArrow = element.initializer ? nodeContainsArrow(element.initializer) : false
3336
+ // Structured mirror of `defaultValue`, from the binding element's OWN
3337
+ // `initializer` node — `tsNodeToParsedExpr` converts the already-parsed
3338
+ // AST directly, no re-parse of the `defaultValue` text (same convention
3339
+ // as `SignalInfo.parsed`). An unsupported shape leaves `parsed` unset;
3340
+ // consumers fall back to `defaultValue` text.
3341
+ const parsedDefault = element.initializer ? tsNodeToParsedExpr(element.initializer) : undefined
3310
3342
  ctx.propsParams.push({
3311
3343
  name: localName,
3312
3344
  type: resolvedType,
@@ -3314,6 +3346,7 @@ function extractProps(param: ts.ParameterDeclaration, ctx: AnalyzerContext): voi
3314
3346
  // the caller may omit the prop.
3315
3347
  optional: !!member?.optional || !!element.initializer,
3316
3348
  defaultValue,
3349
+ ...(parsedDefault && parsedDefault.kind !== 'unsupported' && { parsed: parsedDefault }),
3317
3350
  defaultContainsArrow: defaultContainsArrow || undefined,
3318
3351
  // Only aliased bindings carry the source key — see ParamInfo.sourceName.
3319
3352
  ...(sourcePropName !== localName && { sourceName: sourcePropName }),
@@ -3631,21 +3664,47 @@ function inferTypeFromValue(value: string): TypeInfo {
3631
3664
  return { kind: 'unknown', raw: 'unknown' }
3632
3665
  }
3633
3666
 
3667
+ /**
3668
+ * Identifiers that appear as a direct call (`name(...)`) in `code`,
3669
+ * collected off a real TS parse — never a regex over the raw text, which
3670
+ * false-matches inside comments and string literals (the repo-wide
3671
+ * "no regex over JS syntax" rule). A doc comment mentioning
3672
+ * `otherSignal()` inside a memo body must NOT become a dependency
3673
+ * (phantom `internalValue`/`controlledValue` deps on slider's
3674
+ * `percentage`, #2581 review). A raw token scan is not enough either:
3675
+ * without a parser driving `reScanTemplateToken`, everything between a
3676
+ * substitution's closing `}` and the next backtick is mis-lexed as code
3677
+ * (and the template tail swallows real code after it), so a read
3678
+ * following a `${...}` template — xyflow's `visibleClass` reading
3679
+ * `animated()` after the `${BF_FLOW_EDGE_SELECTED}` line — would vanish.
3680
+ */
3681
+ function collectCalledIdentifiers(code: string): Set<string> {
3682
+ const called = new Set<string>()
3683
+ const sf = ts.createSourceFile('__deps__.tsx', code, ts.ScriptTarget.Latest, false, ts.ScriptKind.TSX)
3684
+ const visit = (node: ts.Node): void => {
3685
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
3686
+ called.add(node.expression.text)
3687
+ }
3688
+ ts.forEachChild(node, visit)
3689
+ }
3690
+ visit(sf)
3691
+ return called
3692
+ }
3693
+
3634
3694
  function extractDependencies(code: string, ctx: AnalyzerContext): string[] {
3695
+ const called = collectCalledIdentifiers(code)
3635
3696
  const deps: string[] = []
3636
3697
 
3637
3698
  // Find signal getter calls: signalName()
3638
3699
  for (const signal of ctx.signals) {
3639
- const pattern = new RegExp(`\\b${signal.getter}\\s*\\(`, 'g')
3640
- if (pattern.test(code)) {
3700
+ if (called.has(signal.getter)) {
3641
3701
  deps.push(signal.getter)
3642
3702
  }
3643
3703
  }
3644
3704
 
3645
3705
  // Find memo calls: memoName()
3646
3706
  for (const memo of ctx.memos) {
3647
- const pattern = new RegExp(`\\b${memo.name}\\s*\\(`, 'g')
3648
- if (pattern.test(code)) {
3707
+ if (called.has(memo.name)) {
3649
3708
  deps.push(memo.name)
3650
3709
  }
3651
3710
  }