@barefootjs/jsx 0.18.4 → 0.18.7

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 (109) hide show
  1. package/dist/adapters/dangerous-inner-html.d.ts +77 -0
  2. package/dist/adapters/dangerous-inner-html.d.ts.map +1 -0
  3. package/dist/adapters/loop-bound-names.d.ts +22 -0
  4. package/dist/adapters/loop-bound-names.d.ts.map +1 -0
  5. package/dist/adapters/parsed-expr-emitter.d.ts +2 -2
  6. package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
  7. package/dist/analyzer.d.ts.map +1 -1
  8. package/dist/expression-parser.d.ts +2 -1
  9. package/dist/expression-parser.d.ts.map +1 -1
  10. package/dist/index.d.ts +14 -4
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +1108 -307
  13. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/plan/build-event-delegation.d.ts.map +1 -1
  15. package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts +10 -0
  19. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts +9 -0
  21. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +9 -0
  23. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/control-flow/shared.d.ts +45 -3
  25. package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
  26. package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  28. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts +1 -0
  29. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  30. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts +10 -0
  31. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts.map +1 -1
  32. package/dist/ir-to-client-js/control-flow/stringify/skeleton-paths.d.ts +38 -0
  33. package/dist/ir-to-client-js/control-flow/stringify/skeleton-paths.d.ts.map +1 -0
  34. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts +19 -0
  35. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts.map +1 -1
  36. package/dist/ir-to-client-js/csr-substitute.d.ts +24 -0
  37. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  38. package/dist/ir-to-client-js/html-template.d.ts +42 -0
  39. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  40. package/dist/ir-to-client-js/imports.d.ts +2 -2
  41. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  42. package/dist/ir-to-client-js/plan/static-array-child-init.d.ts +20 -1
  43. package/dist/ir-to-client-js/plan/static-array-child-init.d.ts.map +1 -1
  44. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  45. package/dist/ir-to-client-js/stringify/static-array-child-init.d.ts +1 -1
  46. package/dist/ir-to-client-js/types.d.ts +30 -0
  47. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  48. package/dist/ir-to-client-js/utils.d.ts +25 -0
  49. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  50. package/dist/jsx-to-ir.d.ts.map +1 -1
  51. package/dist/signal-init-eval.d.ts +82 -0
  52. package/dist/signal-init-eval.d.ts.map +1 -0
  53. package/dist/static-literal.d.ts +45 -0
  54. package/dist/static-literal.d.ts.map +1 -0
  55. package/dist/types.d.ts +66 -0
  56. package/dist/types.d.ts.map +1 -1
  57. package/package.json +2 -2
  58. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +19 -13
  59. package/src/__tests__/create-selector.test.ts +110 -0
  60. package/src/__tests__/csr-template-loop-shadowing.test.ts +214 -0
  61. package/src/__tests__/dangerous-inner-html-resolver.test.ts +197 -0
  62. package/src/__tests__/event-delegation-index-param.test.ts +130 -0
  63. package/src/__tests__/expression-parser.test.ts +38 -0
  64. package/src/__tests__/inner-loop-svg-namespace.test.ts +245 -0
  65. package/src/__tests__/ir-walker.test.ts +1 -0
  66. package/src/__tests__/loop-bound-names.test.ts +102 -0
  67. package/src/__tests__/loop-plan-classification.test.ts +2 -0
  68. package/src/__tests__/map-function-reference.test.ts +303 -0
  69. package/src/__tests__/materialize-getter-calls.test.ts +1 -0
  70. package/src/__tests__/nested-loop-index-param.test.ts +285 -0
  71. package/src/__tests__/signal-init-eval.test.ts +138 -0
  72. package/src/__tests__/static-array-inner-loop-index-param.test.ts +180 -0
  73. package/src/__tests__/static-literal.test.ts +126 -0
  74. package/src/__tests__/string-concat-identifier.test.ts +55 -0
  75. package/src/adapters/dangerous-inner-html.ts +186 -0
  76. package/src/adapters/loop-bound-names.ts +80 -0
  77. package/src/adapters/parsed-expr-emitter.ts +19 -1
  78. package/src/analyzer.ts +6 -0
  79. package/src/expression-parser.ts +59 -25
  80. package/src/index.ts +19 -3
  81. package/src/ir-to-client-js/collect-elements.ts +18 -3
  82. package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +6 -0
  83. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +21 -4
  84. package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +13 -1
  85. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +1 -0
  86. package/src/ir-to-client-js/control-flow/plan/event-delegation.ts +10 -0
  87. package/src/ir-to-client-js/control-flow/plan/loop-child-arm.ts +9 -0
  88. package/src/ir-to-client-js/control-flow/plan/loop.ts +9 -0
  89. package/src/ir-to-client-js/control-flow/shared.ts +103 -6
  90. package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +31 -7
  91. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +11 -2
  92. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +5 -0
  93. package/src/ir-to-client-js/control-flow/stringify/loop.ts +53 -10
  94. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +25 -4
  95. package/src/ir-to-client-js/control-flow/stringify/skeleton-paths.ts +70 -0
  96. package/src/ir-to-client-js/control-flow/stringify/template-parse.ts +25 -1
  97. package/src/ir-to-client-js/csr-substitute.ts +54 -1
  98. package/src/ir-to-client-js/html-template.ts +292 -13
  99. package/src/ir-to-client-js/imports.ts +1 -0
  100. package/src/ir-to-client-js/plan/build-static-array-child-init.ts +12 -1
  101. package/src/ir-to-client-js/plan/static-array-child-init.ts +20 -1
  102. package/src/ir-to-client-js/reactivity.ts +6 -0
  103. package/src/ir-to-client-js/stringify/static-array-child-init.ts +11 -6
  104. package/src/ir-to-client-js/types.ts +31 -0
  105. package/src/ir-to-client-js/utils.ts +34 -1
  106. package/src/jsx-to-ir.ts +322 -48
  107. package/src/signal-init-eval.ts +165 -0
  108. package/src/static-literal.ts +128 -0
  109. package/src/types.ts +68 -0
package/src/jsx-to-ir.ts CHANGED
@@ -34,7 +34,7 @@ import {
34
34
  AttrValueOf,
35
35
  } from './types.ts'
36
36
  import { type AnalyzerContext, type MultiReturnJsxInfo, getSourceLocation, collectReactiveGetterNames } from './analyzer-context.ts'
37
- import { parseExpression, isSupported, parseBlockBody, foldBlockToExpr, predicateTernaryToLogical, tsNodeToParsedExpr, sortComparatorFromArrow, stringifyParsedExpr, cssKebabCase, type ParsedExpr } from './expression-parser.ts'
37
+ import { parseExpression, isSupported, parseBlockBody, foldBlockToExpr, predicateTernaryToLogical, tsNodeToParsedExpr, sortComparatorFromArrow, stringifyParsedExpr, cssKebabCase, CALLBACK_METHODS, type ParsedExpr } from './expression-parser.ts'
38
38
  import type { IRLoopSort, FunctionInfo } from './types.ts'
39
39
  import { formatParamWithType } from './module-exports.ts'
40
40
  import { createError, ErrorCodes, internalInvariant } from './errors.ts'
@@ -48,7 +48,7 @@ import { resolveFreeRefs, isNameBound as isNameBoundInEnv, type BindingEnvironme
48
48
  import { computeFileScope } from './ir-to-client-js/component-scope.ts'
49
49
  import { extractFreeIdentifiersFromNode, initializerShapeContainsJsx } from './analyzer.ts'
50
50
  import { iterateJsTokens, replaceInExprContexts } from './scanner/js-scanner.ts'
51
- import { toHTMLAttrName } from '@barefootjs/shared'
51
+ import { toHTMLAttrName, decodeEntities } from '@barefootjs/shared'
52
52
 
53
53
  // =============================================================================
54
54
  // Transform Context
@@ -82,6 +82,17 @@ interface TransformContext {
82
82
  _destructuredPropNames?: Set<string> | null
83
83
  /** Active loop parameter names for slotId assignment to loop-param-dependent expressions */
84
84
  loopParams: Set<string>
85
+ /**
86
+ * Count of enclosing `.map()` loops (0 = outermost), incremented/
87
+ * decremented in lockstep with entering/leaving `transformMapCall`.
88
+ * Unlike `loopParams` (a name Set that can gain several entries for
89
+ * ONE loop level via destructuring), this is a plain per-level
90
+ * counter — the single source of truth `IRLoop.depth` is stamped
91
+ * from, so every adapter's `data-key`/`data-key-N` suffix derives
92
+ * from one IR-computed value instead of each adapter re-deriving
93
+ * nesting depth its own way (#2168 nested-loop-outer-binding).
94
+ */
95
+ loopDepth: number
85
96
  /** Counter for async boundary IDs (a0, a1, ...) */
86
97
  asyncIdCounter: number
87
98
  /** Counter for <Region> structural index (0, 1, ...) within a file. */
@@ -280,8 +291,21 @@ function exprHasFunctionCalls(expr: ts.Expression): boolean {
280
291
  * Returns undefined if no rewriting is needed (SolidJS-style or no props).
281
292
  */
282
293
  function rewriteBarePropRefs(text: string, expr: ts.Node, ctx: TransformContext): string | undefined {
283
- const propNames = getDestructuredPropNames(ctx)
294
+ let propNames = getDestructuredPropNames(ctx)
284
295
  if (!propNames) return undefined
296
+ // #2222: a name bound as an enclosing loop callback's item/index param
297
+ // refers to the loop binding, not the prop, at THIS transform position —
298
+ // `ctx.loopParams` is the live loop-param set (destructured binding
299
+ // names and the index included), maintained by `transformMapCall` as it
300
+ // enters/leaves each callback, so this guard is scope-accurate rather
301
+ // than the coarse whole-component exclusion the SSR adapters use
302
+ // (#2221). Filter into a fresh set — `getDestructuredPropNames` caches
303
+ // its set on ctx and must not be mutated.
304
+ if (ctx.loopParams.size > 0) {
305
+ const filtered = new Set([...propNames].filter(n => !ctx.loopParams.has(n)))
306
+ if (filtered.size === 0) return undefined
307
+ propNames = filtered
308
+ }
285
309
  // #1425: union any prop refs that `expr` reaches via branch-local
286
310
  // text substitution. The AST walk in `rewriteBarePropRefsCore`
287
311
  // sees only `expr`'s original AST, so a prop ref introduced via
@@ -395,6 +419,7 @@ function createTransformContext(analyzer: AnalyzerContext): TransformContext {
395
419
  isRoot: true,
396
420
  insideComponentChildren: false,
397
421
  loopParams: new Set(),
422
+ loopDepth: 0,
398
423
  patterns: {
399
424
  signals: analyzer.signals.map(s => ({
400
425
  getter: s.getter,
@@ -579,20 +604,34 @@ function parseValueExpr(trimmed: string): ParsedExpr {
579
604
  return parseExpression(trimmed.startsWith('{') ? `(${trimmed})` : trimmed)
580
605
  }
581
606
 
607
+ /** Shared empty-set default for `attachParsedExpressions`/`resolveCallbackMethodFunctionReferences`'s `bound` param — avoids a fresh allocation per call when nothing is shadowed. */
608
+ const EMPTY_BOUND: ReadonlySet<string> = new Set()
609
+
582
610
  /**
583
611
  * Attach `parsed` (`parseExpression(expr.trim())`) to every `expression` node
584
612
  * in the tree, so SSR adapters emit from the structured tree instead of each
585
613
  * re-parsing the string at emit time. Best-effort: a node this walk misses (or
586
614
  * an empty `expr`) simply has no `parsed`, and the adapter falls back to
587
615
  * parsing — so under-coverage is safe, never a behavioural change.
616
+ *
617
+ * Every parse is run through {@link resolveCallbackMethodFunctionReferences}
618
+ * (#2206) so a bare-identifier `.map`/`.filter`/… callback (`tags.map(format)`)
619
+ * resolves to its declaration wherever it can appear — text/attr/prop/provider
620
+ * expressions and the loop-array expression alike — not just one hardcoded
621
+ * spot. `bound` carries the enclosing loop(s)' `param`/`index` names down
622
+ * into that resolution (Fable review, #2214) so a loop item variable that
623
+ * happens to share a name with a module-scope const/function shadows it,
624
+ * same as real JS scoping — resolution stays off within that loop's body.
588
625
  */
589
- function attachParsedExpressions(node: IRNode): void {
626
+ function attachParsedExpressions(node: IRNode, analyzer: AnalyzerContext, bound: ReadonlySet<string> = EMPTY_BOUND): void {
627
+ const parse = (trimmed: string) => resolveCallbackMethodFunctionReferences(parseExpression(trimmed), analyzer, bound)
628
+ const parseValue = (trimmed: string) => resolveCallbackMethodFunctionReferences(parseValueExpr(trimmed), analyzer, bound)
590
629
  if (node.type === 'expression') {
591
630
  const trimmed = node.expr.trim()
592
- if (trimmed) node.parsed = parseExpression(trimmed)
631
+ if (trimmed) node.parsed = parse(trimmed)
593
632
  } else if (node.type === 'conditional' || node.type === 'if-statement') {
594
633
  const trimmed = node.condition.trim()
595
- if (trimmed) node.parsedCondition = parseExpression(trimmed)
634
+ if (trimmed) node.parsedCondition = parse(trimmed)
596
635
  }
597
636
  // Attach `parsed` to every expression-valued attribute / prop so adapters can
598
637
  // lower from the tree instead of re-parsing the string. Element attrs,
@@ -603,23 +642,23 @@ function attachParsedExpressions(node: IRNode): void {
603
642
  for (const attr of node.attrs) {
604
643
  if (attr.value.kind === 'expression') {
605
644
  const trimmed = attr.value.expr.trim()
606
- if (trimmed) attr.value.parsed = parseValueExpr(trimmed)
645
+ if (trimmed) attr.value.parsed = parseValue(trimmed)
607
646
  } else if (attr.value.kind === 'spread') {
608
647
  const trimmed = attr.value.expr.trim()
609
- if (trimmed) attr.value.parsed = parseExpression(trimmed)
648
+ if (trimmed) attr.value.parsed = parse(trimmed)
610
649
  }
611
650
  }
612
651
  } else if (node.type === 'component') {
613
652
  for (const prop of node.props) {
614
653
  if (prop.value.kind === 'expression') {
615
654
  const trimmed = prop.value.expr.trim()
616
- if (trimmed) prop.value.parsed = parseValueExpr(trimmed)
655
+ if (trimmed) prop.value.parsed = parseValue(trimmed)
617
656
  }
618
657
  }
619
658
  } else if (node.type === 'provider') {
620
659
  if (node.valueProp.value.kind === 'expression') {
621
660
  const trimmed = node.valueProp.value.expr.trim()
622
- if (trimmed) node.valueProp.value.parsed = parseValueExpr(trimmed)
661
+ if (trimmed) node.valueProp.value.parsed = parseValue(trimmed)
623
662
  }
624
663
  }
625
664
  switch (node.type) {
@@ -627,46 +666,54 @@ function attachParsedExpressions(node: IRNode): void {
627
666
  case 'component':
628
667
  case 'fragment':
629
668
  case 'provider':
630
- for (const child of node.children) attachParsedExpressions(child)
669
+ for (const child of node.children) attachParsedExpressions(child, analyzer, bound)
631
670
  break
632
671
  case 'async':
633
- attachParsedExpressions(node.fallback)
634
- for (const child of node.children) attachParsedExpressions(child)
672
+ attachParsedExpressions(node.fallback, analyzer, bound)
673
+ for (const child of node.children) attachParsedExpressions(child, analyzer, bound)
635
674
  break
636
675
  case 'loop': {
637
676
  // Attach the parse of the SAME `array` string the adapters consume
638
677
  // (the Go adapter's scalar-literal loop typing reads `loop.array` /
639
678
  // `nested.loopArray`, which is exactly `loop.array`), so it can read
640
- // the tree instead of re-parsing with `ts.createSourceFile`.
679
+ // the tree instead of re-parsing with `ts.createSourceFile`. The array
680
+ // expression itself is evaluated in the OUTER scope (before the loop's
681
+ // own `param`/`index` exist), so it parses against the unextended `bound`.
641
682
  const trimmedArray = node.array.trim()
642
- if (trimmedArray) node.arrayParsed = parseExpression(trimmedArray)
643
- for (const child of node.children) attachParsedExpressions(child)
683
+ if (trimmedArray) node.arrayParsed = parse(trimmedArray)
684
+ // The loop's item/index variables shadow any same-named module-scope
685
+ // const/function for everything inside the loop body (Fable review,
686
+ // #2214) — mirrors an arrow's own params in `resolveCallbackMethodFunctionReferences`.
687
+ const loopBound = new Set(bound)
688
+ loopBound.add(node.param)
689
+ if (node.index) loopBound.add(node.index)
690
+ for (const child of node.children) attachParsedExpressions(child, analyzer, loopBound)
644
691
  // Loops also hold expression nodes off the main `children` array.
645
692
  if (node.childComponent) {
646
- for (const child of node.childComponent.children) attachParsedExpressions(child)
693
+ for (const child of node.childComponent.children) attachParsedExpressions(child, analyzer, loopBound)
647
694
  }
648
695
  for (const nested of node.nestedComponents ?? []) {
649
- for (const child of nested.children) attachParsedExpressions(child)
696
+ for (const child of nested.children) attachParsedExpressions(child, analyzer, loopBound)
650
697
  }
651
698
  for (const frag of node.flatMapCallback?.fragments ?? []) {
652
- attachParsedExpressions(frag.ir)
699
+ attachParsedExpressions(frag.ir, analyzer, loopBound)
653
700
  }
654
701
  break
655
702
  }
656
703
  case 'conditional':
657
- attachParsedExpressions(node.whenTrue)
658
- attachParsedExpressions(node.whenFalse)
704
+ attachParsedExpressions(node.whenTrue, analyzer, bound)
705
+ attachParsedExpressions(node.whenFalse, analyzer, bound)
659
706
  break
660
707
  case 'if-statement':
661
- attachParsedExpressions(node.consequent)
662
- if (node.alternate) attachParsedExpressions(node.alternate)
708
+ attachParsedExpressions(node.consequent, analyzer, bound)
709
+ if (node.alternate) attachParsedExpressions(node.alternate, analyzer, bound)
663
710
  break
664
711
  }
665
712
  }
666
713
 
667
714
  export function jsxToIR(analyzer: AnalyzerContext): IRNode | null {
668
715
  const root = buildIRRoot(analyzer)
669
- if (root) attachParsedExpressions(root)
716
+ if (root) attachParsedExpressions(root, analyzer)
670
717
  return root
671
718
  }
672
719
 
@@ -1563,7 +1610,12 @@ function transformText(node: ts.JsxText, ctx: TransformContext): IRText | null {
1563
1610
 
1564
1611
  return {
1565
1612
  type: 'text',
1566
- value: text,
1613
+ // JSX decodes character references at parse time (`&copy;` IS the
1614
+ // text `©`), so the IR carries the DECODED value — the semantics —
1615
+ // and each adapter re-escapes for its own emission context.
1616
+ // Decode AFTER whitespace normalization: `&nbsp;` yields U+00A0,
1617
+ // which `\s+` would otherwise collapse into a plain space.
1618
+ value: decodeEntities(text),
1567
1619
  loc: getSourceLocation(node, ctx.sourceFile, ctx.filePath),
1568
1620
  }
1569
1621
  }
@@ -2483,6 +2535,31 @@ function isIteratorShapeCall(
2483
2535
  return { array: node.expression.expression, shape: name }
2484
2536
  }
2485
2537
 
2538
+ /**
2539
+ * Check if a node is the STATIC `Object.entries(x)` / `Object.keys(x)` /
2540
+ * `Object.values(x)` call form (#2168 object-entries-map) — the
2541
+ * one-argument form where `x` is a plain object/Record being iterated,
2542
+ * as opposed to {@link isIteratorShapeCall}'s zero-arg instance-method
2543
+ * form (`arr.entries()`) on an actual array. Returns the object
2544
+ * expression (any expression — `props.x`, `x ?? {}`, not just a bare
2545
+ * identifier) and the iteration shape so `transformMapCall` can strip
2546
+ * the `Object.<method>(...)` wrapper and record it on the IRLoop as
2547
+ * `objectIteration` (see that field's docstring in `types.ts` for why
2548
+ * this is a distinct field from `iterationShape`, not a shared one).
2549
+ */
2550
+ function isObjectIteratorCall(
2551
+ node: ts.Expression,
2552
+ ): { object: ts.Expression; shape: 'entries' | 'keys' | 'values' } | null {
2553
+ if (!ts.isCallExpression(node)) return null
2554
+ if (!ts.isPropertyAccessExpression(node.expression)) return null
2555
+ if (!ts.isIdentifier(node.expression.expression)) return null
2556
+ if (node.expression.expression.text !== 'Object') return null
2557
+ if (node.arguments.length !== 1) return null
2558
+ const name = node.expression.name.text
2559
+ if (name !== 'entries' && name !== 'keys' && name !== 'values') return null
2560
+ return { object: node.arguments[0], shape: name }
2561
+ }
2562
+
2486
2563
  type SortExtractionResult = {
2487
2564
  result: IRLoopSort | null
2488
2565
  unsupportedReason?: string
@@ -2590,8 +2667,8 @@ function extractSortComparator(
2590
2667
  * vs. unresolved) is decided by the caller, not here.
2591
2668
  */
2592
2669
  function resolveSortComparatorIdentifier(name: string, ctx: TransformContext): ts.Expression | null {
2593
- const constInfo = findLocalConst(name, ctx)
2594
- const fnInfo = findLocalFunction(name, ctx)
2670
+ const constInfo = findLocalConst(name, ctx.analyzer)
2671
+ const fnInfo = findLocalFunction(name, ctx.analyzer)
2595
2672
  if (constInfo && fnInfo) return null
2596
2673
  if (constInfo) {
2597
2674
  const ast = parseConstInitializer(constInfo)
@@ -2604,6 +2681,149 @@ function resolveSortComparatorIdentifier(name: string, ctx: TransformContext): t
2604
2681
  return null
2605
2682
  }
2606
2683
 
2684
+ /**
2685
+ * Resolve a bare-identifier callback passed to a value-position higher-order
2686
+ * array method (`tags.map(format)`, `.filter`, `.sort`, … — any name in
2687
+ * {@link CALLBACK_METHODS}, #2206) to its underlying arrow / function-
2688
+ * expression node — the SAME one-hop, ambiguity-refuses lookup #2090
2689
+ * established for `.sort(fnref)` comparators (`resolveSortComparatorIdentifier`
2690
+ * above). Kept as its own small function (mirroring the existing
2691
+ * `resolveInterleaveTagIdentifier` precedent below) rather than sharing code
2692
+ * with the sort resolver, so each call site's refusal semantics stay
2693
+ * independently reviewable.
2694
+ *
2695
+ * Only needs `AnalyzerContext` (not a full `TransformContext`) because its
2696
+ * caller, {@link resolveCallbackMethodFunctionReferences}, runs as a
2697
+ * post-parse pass over an already-built `ParsedExpr` tree — no live
2698
+ * `ts.Node`/source-file binding is available or needed, since
2699
+ * `parseConstInitializer` / `parseFunctionInfoAsExpr` re-parse the
2700
+ * analyzer's stored declaration TEXT into a fresh, isolated source file.
2701
+ */
2702
+ function resolveCallbackMethodFunctionReferenceIdentifier(name: string, analyzer: AnalyzerContext): ts.Expression | null {
2703
+ const constInfo = findLocalConst(name, analyzer)
2704
+ const fnInfo = findLocalFunction(name, analyzer)
2705
+ if (constInfo && fnInfo) return null
2706
+ if (constInfo) {
2707
+ const ast = parseConstInitializer(constInfo)
2708
+ return ast && (ts.isArrowFunction(ast) || ts.isFunctionExpression(ast)) ? ast : null
2709
+ }
2710
+ if (fnInfo) {
2711
+ const ast = parseFunctionInfoAsExpr(fnInfo)
2712
+ return ast && (ts.isArrowFunction(ast) || ts.isFunctionExpression(ast)) ? ast : null
2713
+ }
2714
+ return null
2715
+ }
2716
+
2717
+ /**
2718
+ * Post-parse pass (#2206): walk a `ParsedExpr` tree and resolve every
2719
+ * `<recv>.<CALLBACK_METHODS method>(<bare identifier>, …rest)` call's
2720
+ * callback argument to its declaration, so `tags.map(format).join(' ')`
2721
+ * compiles exactly as if `format`'s body had been written inline
2722
+ * (`tags.map(t => '#' + t).join(' ')`) — the shape `asCallbackMethodCall`
2723
+ * (expression-parser.ts) already recognizes and every adapter's `map_eval`
2724
+ * (#2073) already lowers.
2725
+ *
2726
+ * Runs AFTER `parseExpression`, not threaded into it, because the
2727
+ * value-returning `.map(cb)` form is recognized generically downstream of
2728
+ * parsing (`asCallbackMethodCall`'s `arrow.kind !== 'arrow'` check), not
2729
+ * during it — `convertNode` already happily produces a generic `call` node
2730
+ * with an `identifier` arg for `tags.map(format)` with no special-casing
2731
+ * needed there. An unresolvable reference (import, non-function const,
2732
+ * alias chain, cross-kind ambiguity — see
2733
+ * {@link resolveCallbackMethodFunctionReferenceIdentifier}) or a resolved
2734
+ * body that can't fold to a single expression is left untouched: the
2735
+ * `identifier` arg passes through as-is, and `asCallbackMethodCall`'s
2736
+ * existing arrow-kind check still refuses it with the current BF101,
2737
+ * unchanged.
2738
+ *
2739
+ * A spliced-in resolved arrow's OWN body is not re-walked (`visit` returns
2740
+ * it as-is rather than recursing) — a bare-identifier callback declared
2741
+ * INSIDE another resolved declaration (`const outer = xs => xs.map(inner).join(',')`
2742
+ * used as `rows.map(outer)`) stays unresolved and BF101-refuses, same as
2743
+ * before #2206. This is a deliberate one-hop limit, not an oversight: naively
2744
+ * recursing risks an infinite loop on a self-referential declaration
2745
+ * (`const f = xs => xs.map(f)`), which would need a resolution stack to
2746
+ * detect — out of scope here. Fails safe (refuses loudly rather than
2747
+ * mis-rendering); see the pinned-refusal test in map-function-reference.test.ts.
2748
+ *
2749
+
2750
+ * `bound` tracks names that are lexically shadowed at the current walk
2751
+ * position — an enclosing arrow's own params (`rows.map(fn => fn.tags.map(fn)…)`
2752
+ * — so a bare identifier that refers to a PARAMETER rather than a same-file
2753
+ * const/function is never resolved against the const/function tables
2754
+ * (Fable review, #2214): it grows exactly like {@link freeVarsInBody}'s own
2755
+ * `bound` set, at the SAME `arrow` case. The initial call from
2756
+ * `attachParsedExpressions` seeds it with the enclosing loop's `param`/
2757
+ * `index` too (a loop's item variable shadows a same-named module const the
2758
+ * same way a callback arrow's param does — `origin.freeRefs` already tags
2759
+ * such a name `kind: 'render-item'`, this walk now honors it).
2760
+ */
2761
+ function resolveCallbackMethodFunctionReferences(
2762
+ expr: ParsedExpr,
2763
+ analyzer: AnalyzerContext,
2764
+ bound: ReadonlySet<string> = EMPTY_BOUND,
2765
+ ): ParsedExpr {
2766
+ function visit(e: ParsedExpr, bound: ReadonlySet<string>): ParsedExpr {
2767
+ switch (e.kind) {
2768
+ case 'literal':
2769
+ case 'identifier':
2770
+ case 'regex':
2771
+ case 'unsupported':
2772
+ return e
2773
+ case 'call': {
2774
+ const callee = visit(e.callee, bound)
2775
+ const args = e.args.map(a => visit(a, bound))
2776
+ if (
2777
+ callee.kind === 'member' && !callee.computed &&
2778
+ CALLBACK_METHODS.has(callee.property) &&
2779
+ args[0]?.kind === 'identifier' &&
2780
+ !bound.has(args[0].name)
2781
+ ) {
2782
+ const resolved = resolveCallbackMethodFunctionReferenceIdentifier(args[0].name, analyzer)
2783
+ const arrow = resolved ? tsNodeToParsedExpr(resolved) : null
2784
+ if (arrow && arrow.kind === 'arrow') args[0] = arrow
2785
+ }
2786
+ return { ...e, callee, args }
2787
+ }
2788
+ case 'member':
2789
+ return { ...e, object: visit(e.object, bound) }
2790
+ case 'index-access':
2791
+ return { ...e, object: visit(e.object, bound), index: visit(e.index, bound) }
2792
+ case 'binary':
2793
+ case 'logical':
2794
+ return { ...e, left: visit(e.left, bound), right: visit(e.right, bound) }
2795
+ case 'unary':
2796
+ return { ...e, argument: visit(e.argument, bound) }
2797
+ case 'conditional':
2798
+ return { ...e, test: visit(e.test, bound), consequent: visit(e.consequent, bound), alternate: visit(e.alternate, bound) }
2799
+ case 'template-literal':
2800
+ return { ...e, parts: e.parts.map(p => (p.type === 'expression' ? { ...p, expr: visit(p.expr, bound) } : p)) }
2801
+ case 'array-literal':
2802
+ return { ...e, elements: e.elements.map(el => visit(el, bound)) }
2803
+ case 'array-method':
2804
+ // `e.args` is a fixed-length tuple for some `method` variants (e.g.
2805
+ // `flat`'s `args: []`) — `.map(visit)` preserves length/order (a
2806
+ // callback resolution never adds/removes args), so this is a
2807
+ // same-shape cast, not a structural change. TS can't verify the
2808
+ // per-`method` tuple-length invariant across a `.map`, hence the
2809
+ // whole-object cast rather than a narrower one.
2810
+ return {
2811
+ ...e,
2812
+ object: visit(e.object, bound),
2813
+ args: e.args.map(a => visit(a, bound)),
2814
+ ...(e.method === 'flat' && e.depthExpr ? { depthExpr: visit(e.depthExpr, bound) } : {}),
2815
+ } as ParsedExpr
2816
+ case 'object-literal':
2817
+ return { ...e, properties: e.properties.map(p => ({ ...p, value: visit(p.value, bound) })) }
2818
+ case 'arrow': {
2819
+ const inner = e.params.length === 0 ? bound : new Set([...bound, ...e.params])
2820
+ return { ...e, body: visit(e.body, inner) }
2821
+ }
2822
+ }
2823
+ }
2824
+ return visit(expr, bound)
2825
+ }
2826
+
2607
2827
  /**
2608
2828
  * Result type for extractFilterPredicate. The predicate is always an expression:
2609
2829
  * a block body is normalized to one via `foldBlockToExpr` +
@@ -3246,6 +3466,10 @@ function transformMapCall(
3246
3466
  // Capture nesting depth before we register this map's own params.
3247
3467
  // ctx.loopParams is populated by the *outer* map; if non-empty we are inside one.
3248
3468
  const isNested = ctx.loopParams.size > 0
3469
+ // This loop's own depth (0 = outermost) is however many enclosing
3470
+ // loops are already active, captured before `ctx.loopDepth` below is
3471
+ // bumped for THIS loop's own descendants.
3472
+ const depth = ctx.loopDepth
3249
3473
 
3250
3474
  const propAccess = node.expression as ts.PropertyAccessExpression
3251
3475
  const mapSource = propAccess.expression
@@ -3271,6 +3495,7 @@ function transformMapCall(
3271
3495
  let templateMapPreamble: string | undefined
3272
3496
  let typedMapPreamble: string | undefined
3273
3497
  let iterationShape: 'entries' | 'keys' | undefined
3498
+ let objectIteration: 'entries' | 'keys' | 'values' | undefined
3274
3499
 
3275
3500
  // Helper to set both array and templateArray
3276
3501
  const setArray = (node: ts.Expression) => {
@@ -3284,8 +3509,11 @@ function transformMapCall(
3284
3509
  // adapters emit the right loop variable bindings. `.values()` is a
3285
3510
  // no-op (same as plain `.map()`) so it's stripped but not recorded.
3286
3511
  // The inner expression (after stripping) feeds into the standard
3287
- // filter/sort chain detection below.
3288
- let chainSource = mapSource
3512
+ // filter/sort chain detection below. Widened to `ts.Expression` (not
3513
+ // narrowed to `mapSource`'s own `LeftHandSideExpression` type) since
3514
+ // `isObjectIteratorCall`'s stripped argument can be any expression
3515
+ // (`x ?? {}`, not just a `LeftHandSideExpression`).
3516
+ let chainSource: ts.Expression = mapSource
3289
3517
  const iteratorInfo = isIteratorShapeCall(mapSource)
3290
3518
  if (iteratorInfo) {
3291
3519
  chainSource = iteratorInfo.array
@@ -3295,6 +3523,18 @@ function transformMapCall(
3295
3523
  iterationShape = 'keys'
3296
3524
  }
3297
3525
  // 'values' is a no-op — same as plain .map()
3526
+ } else {
3527
+ // Detect the STATIC `Object.entries(x)` / `.keys(x)` / `.values(x)`
3528
+ // form (#2168 object-entries-map) — see `isObjectIteratorCall`'s and
3529
+ // `IRLoop.objectIteration`'s docstrings for why this is a SEPARATE
3530
+ // shape from the array-instance-method case above, not a shared one.
3531
+ // Unlike that case, `'values'` DOES need recording here (it isn't a
3532
+ // no-op: `x` itself isn't iterable as a plain object).
3533
+ const objectIteratorInfo = isObjectIteratorCall(mapSource)
3534
+ if (objectIteratorInfo) {
3535
+ chainSource = objectIteratorInfo.object
3536
+ objectIteration = objectIteratorInfo.shape
3537
+ }
3298
3538
  }
3299
3539
 
3300
3540
  const filterInfo = isFilterCall(chainSource)
@@ -3407,14 +3647,19 @@ function transformMapCall(
3407
3647
  setArray(innerSort.array)
3408
3648
  }
3409
3649
  } else {
3410
- // Simple filter().map()
3411
- array = ctx.getJS(filterInfo.array)
3412
- arrayExpr = filterInfo.array
3650
+ // Simple filter().map(). `setArray` (not a bare `array =`
3651
+ // assignment) so `templateArray` carries the `_p.`-rewritten form —
3652
+ // the module-scope `template:` lambda can't see init's destructured
3653
+ // prop locals, and a bare destructured name there threw
3654
+ // `ReferenceError` at runtime (#2222).
3655
+ setArray(filterInfo.array)
3413
3656
  }
3414
3657
  }
3415
3658
  } else {
3416
- array = ctx.getJS(chainSource)
3417
- arrayExpr = chainSource
3659
+ // Plain `.map()` fallback — same `setArray` requirement as above
3660
+ // (#2222): this is the most common loop shape and was the primary
3661
+ // repro for the bare-destructured-name ReferenceError.
3662
+ setArray(chainSource)
3418
3663
  }
3419
3664
 
3420
3665
  // Get callback function
@@ -3445,8 +3690,12 @@ function transformMapCall(
3445
3690
  // `.entries()` synthesises `[index, value]` — when the callback
3446
3691
  // destructures exactly two array elements, extract the names into
3447
3692
  // `index` and `param` so the loop renders with proper bindings and
3448
- // the BF104 destructure-param refusal doesn't fire.
3449
- if (iterationShape === 'entries' && ts.isArrayBindingPattern(firstParam.name)) {
3693
+ // the BF104 destructure-param refusal doesn't fire. `Object.entries(x)`
3694
+ // (`objectIteration === 'entries'`) synthesises the SAME `[key,
3695
+ // value]` 2-tuple shape — `index` just holds a string key instead
3696
+ // of a numeric position — so it reuses this exact extraction.
3697
+ const isEntriesShape = iterationShape === 'entries' || objectIteration === 'entries'
3698
+ if (isEntriesShape && ts.isArrayBindingPattern(firstParam.name)) {
3450
3699
  const elements = firstParam.name.elements.filter(
3451
3700
  el => !ts.isOmittedExpression(el),
3452
3701
  )
@@ -3485,7 +3734,7 @@ function transformMapCall(
3485
3734
  }
3486
3735
  }
3487
3736
  }
3488
- if (callback.parameters.length > 1 && iterationShape !== 'entries') {
3737
+ if (callback.parameters.length > 1 && iterationShape !== 'entries' && objectIteration !== 'entries') {
3489
3738
  const secondParam = callback.parameters[1]
3490
3739
  index = secondParam.name.getText(ctx.sourceFile)
3491
3740
  if (secondParam.type) {
@@ -3504,6 +3753,7 @@ function transformMapCall(
3504
3753
  ctx.loopParams.add(param)
3505
3754
  }
3506
3755
  if (index) ctx.loopParams.add(index)
3756
+ ctx.loopDepth++
3507
3757
 
3508
3758
  // Logical control flow (`cond && <X/>`, `a ?? themeLogo()`) as the map
3509
3759
  // body. This is not a JSX literal, ternary, or block, so without this
@@ -3626,6 +3876,7 @@ function transformMapCall(
3626
3876
  ctx.loopParams.delete(param)
3627
3877
  }
3628
3878
  if (index) ctx.loopParams.delete(index)
3879
+ ctx.loopDepth--
3629
3880
  }
3630
3881
 
3631
3882
  // If no JSX children were found (e.g., callback returns a function call),
@@ -3705,6 +3956,16 @@ function transformMapCall(
3705
3956
  !isSignalOrMemoArray(array, ctx)
3706
3957
  && !isDirectPropArray
3707
3958
  && !hasCalls
3959
+ // `objectIteration` (#2168 object-entries-map): `array` here is the
3960
+ // STRIPPED object expression (`Object.entries(x)`'s `x`), which can
3961
+ // itself be a static module-scope const object literal and would
3962
+ // otherwise satisfy every check above — but a plain OBJECT has no
3963
+ // `.forEach()`/`.map()` (the static-array client codegen's own
3964
+ // methods), unlike an actual array literal. Force the dynamic
3965
+ // `mapArray()` path instead, which this shape's client-JS array-expr
3966
+ // reconstruction (`applyObjectIterationWrap`, `ir-to-client-js/utils.ts`)
3967
+ // already handles correctly.
3968
+ && !objectIteration
3708
3969
 
3709
3970
  // Collect nested components for both static and dynamic arrays.
3710
3971
  // Static arrays: needed for initChild hydration.
@@ -3744,6 +4005,8 @@ function transformMapCall(
3744
4005
  sortComparator,
3745
4006
  chainOrder,
3746
4007
  iterationShape,
4008
+ objectIteration,
4009
+ depth,
3747
4010
  clientOnly: isClientOnly || undefined,
3748
4011
  mapPreamble,
3749
4012
  templateMapPreamble,
@@ -4157,9 +4420,12 @@ function getAttributeValue(attr: ts.JsxAttribute, ctx: TransformContext): AttrVa
4157
4420
  return AttrValueOf.booleanAttr()
4158
4421
  }
4159
4422
 
4160
- // String literal: <div id="main" />
4423
+ // String literal: <div id="main" />. JSX decodes character references
4424
+ // in quoted attribute values just like in text children, so the IR
4425
+ // carries the decoded string (`title="Fish &amp; Chips"` IS the value
4426
+ // `Fish & Chips`); adapters re-escape on emit.
4161
4427
  if (ts.isStringLiteral(attr.initializer)) {
4162
- return AttrValueOf.literal(attr.initializer.text)
4428
+ return AttrValueOf.literal(decodeEntities(attr.initializer.text))
4163
4429
  }
4164
4430
 
4165
4431
  // Expression: <div class={className} />
@@ -4351,7 +4617,7 @@ function tryResolveTemplateSpanFromConst(
4351
4617
  ): IRTemplatePart[] | null {
4352
4618
  // ${IDENT}
4353
4619
  if (ts.isIdentifier(expr)) {
4354
- const constInfo = findLocalConst(expr.text, ctx)
4620
+ const constInfo = findLocalConst(expr.text, ctx.analyzer)
4355
4621
  if (!constInfo) return null
4356
4622
  const ast = parseConstInitializer(constInfo)
4357
4623
  if (!ast) return null
@@ -4364,7 +4630,7 @@ function tryResolveTemplateSpanFromConst(
4364
4630
  // ${IDENT[KEY]}
4365
4631
  if (ts.isElementAccessExpression(expr)) {
4366
4632
  if (!ts.isIdentifier(expr.expression)) return null
4367
- const constInfo = findLocalConst(expr.expression.text, ctx)
4633
+ const constInfo = findLocalConst(expr.expression.text, ctx.analyzer)
4368
4634
  if (!constInfo) return null
4369
4635
  const ast = parseConstInitializer(constInfo)
4370
4636
  if (!ast || !ts.isObjectLiteralExpression(ast)) return null
@@ -4421,8 +4687,8 @@ function tryResolveTemplateSpanFromConst(
4421
4687
  *
4422
4688
  * Returns undefined when no const matches.
4423
4689
  */
4424
- function findLocalConst(name: string, ctx: TransformContext) {
4425
- const matches = ctx.analyzer.localConstants.filter(c => c.name === name)
4690
+ function findLocalConst(name: string, analyzer: AnalyzerContext) {
4691
+ const matches = analyzer.localConstants.filter(c => c.name === name)
4426
4692
  if (matches.length === 0) return undefined
4427
4693
  // Prefer the innermost (non-module) binding; fall back to whatever's
4428
4694
  // there if every match is at module scope.
@@ -4439,8 +4705,8 @@ function findLocalConst(name: string, ctx: TransformContext) {
4439
4705
  *
4440
4706
  * Returns undefined when no local function matches.
4441
4707
  */
4442
- function findLocalFunction(name: string, ctx: TransformContext) {
4443
- const matches = ctx.analyzer.localFunctions.filter(f => f.name === name)
4708
+ function findLocalFunction(name: string, analyzer: AnalyzerContext) {
4709
+ const matches = analyzer.localFunctions.filter(f => f.name === name)
4444
4710
  if (matches.length === 0) return undefined
4445
4711
  const fnScoped = matches.filter(f => !f.isModule)
4446
4712
  const pool = fnScoped.length > 0 ? fnScoped : matches
@@ -4530,7 +4796,15 @@ function tryResolveIdentifierAsTemplateLiteral(
4530
4796
  ident: ts.Identifier,
4531
4797
  ctx: TransformContext,
4532
4798
  ): IRTemplatePart[] | null {
4533
- const constInfo = findLocalConst(ident.text, ctx)
4799
+ // #2222/#2235: at this transform position the identifier may be an
4800
+ // enclosing loop callback's own (shadowing) parameter — folding the
4801
+ // outer const's literal into the IR here bakes the same hard-coded
4802
+ // value into EVERY adapter's output (e.g. `key={label}` inside
4803
+ // `.map((label) => ...)` becoming a constant duplicate key).
4804
+ // `ctx.loopParams` is the live loop-param set (destructured binding
4805
+ // names and index included), so the guard is scope-accurate.
4806
+ if (ctx.loopParams.has(ident.text)) return null
4807
+ const constInfo = findLocalConst(ident.text, ctx.analyzer)
4534
4808
  if (!constInfo) return null
4535
4809
  const ast = parseConstInitializer(constInfo)
4536
4810
  if (!ast) return null
@@ -4740,8 +5014,8 @@ function tryDesugarInterleaveTaggedTemplate(
4740
5014
  * followed, matching that precedent. Returns null when nothing resolves.
4741
5015
  */
4742
5016
  function resolveInterleaveTagIdentifier(name: string, ctx: TransformContext): ts.Expression | null {
4743
- const constInfo = findLocalConst(name, ctx)
4744
- const fnInfo = findLocalFunction(name, ctx)
5017
+ const constInfo = findLocalConst(name, ctx.analyzer)
5018
+ const fnInfo = findLocalFunction(name, ctx.analyzer)
4745
5019
  // A name bound BOTH as a const and as a `function` declaration is the
4746
5020
  // same cross-kind ambiguity `resolveSortComparatorIdentifier` refuses:
4747
5021
  // it can only occur across scopes, and `FunctionInfo.isModule` reflects