@barefootjs/jsx 0.18.5 → 0.19.0

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 (96) 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.map +1 -1
  6. package/dist/analyzer.d.ts.map +1 -1
  7. package/dist/expression-parser.d.ts +8 -0
  8. package/dist/expression-parser.d.ts.map +1 -1
  9. package/dist/index.d.ts +15 -4
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1002 -284
  12. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts.map +1 -1
  15. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts +9 -0
  17. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +9 -0
  19. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/shared.d.ts +45 -3
  21. package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts +1 -0
  24. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts +10 -0
  26. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/control-flow/stringify/skeleton-paths.d.ts +38 -0
  28. package/dist/ir-to-client-js/control-flow/stringify/skeleton-paths.d.ts.map +1 -0
  29. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts +19 -0
  30. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts.map +1 -1
  31. package/dist/ir-to-client-js/csr-substitute.d.ts +23 -0
  32. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  33. package/dist/ir-to-client-js/html-template.d.ts +42 -0
  34. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  35. package/dist/ir-to-client-js/imports.d.ts +2 -2
  36. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  37. package/dist/ir-to-client-js/phases/props-extraction.d.ts +0 -1
  38. package/dist/ir-to-client-js/phases/props-extraction.d.ts.map +1 -1
  39. package/dist/ir-to-client-js/plan/static-array-child-init.d.ts +20 -1
  40. package/dist/ir-to-client-js/plan/static-array-child-init.d.ts.map +1 -1
  41. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  42. package/dist/ir-to-client-js/stringify/static-array-child-init.d.ts +1 -1
  43. package/dist/ir-to-client-js/types.d.ts +21 -0
  44. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  45. package/dist/jsx-to-ir.d.ts.map +1 -1
  46. package/dist/signal-init-eval.d.ts +82 -0
  47. package/dist/signal-init-eval.d.ts.map +1 -0
  48. package/dist/static-literal.d.ts +45 -0
  49. package/dist/static-literal.d.ts.map +1 -0
  50. package/package.json +2 -2
  51. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +19 -13
  52. package/src/__tests__/client-js-generation.test.ts +40 -0
  53. package/src/__tests__/create-selector.test.ts +110 -0
  54. package/src/__tests__/csr-template-loop-shadowing.test.ts +214 -0
  55. package/src/__tests__/dangerous-inner-html-resolver.test.ts +197 -0
  56. package/src/__tests__/inner-loop-svg-namespace.test.ts +245 -0
  57. package/src/__tests__/loop-bound-names.test.ts +102 -0
  58. package/src/__tests__/loop-plan-classification.test.ts +2 -0
  59. package/src/__tests__/map-function-reference.test.ts +303 -0
  60. package/src/__tests__/nested-loop-index-param.test.ts +285 -0
  61. package/src/__tests__/props-destructuring.test.ts +61 -8
  62. package/src/__tests__/signal-init-eval.test.ts +138 -0
  63. package/src/__tests__/static-array-inner-loop-index-param.test.ts +180 -0
  64. package/src/__tests__/static-literal.test.ts +126 -0
  65. package/src/__tests__/string-concat-identifier.test.ts +55 -0
  66. package/src/adapters/dangerous-inner-html.ts +186 -0
  67. package/src/adapters/loop-bound-names.ts +80 -0
  68. package/src/adapters/parsed-expr-emitter.ts +9 -0
  69. package/src/analyzer.ts +36 -22
  70. package/src/expression-parser.ts +33 -0
  71. package/src/index.ts +20 -3
  72. package/src/ir-to-client-js/collect-elements.ts +15 -3
  73. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +21 -4
  74. package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +13 -1
  75. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +1 -0
  76. package/src/ir-to-client-js/control-flow/plan/loop-child-arm.ts +9 -0
  77. package/src/ir-to-client-js/control-flow/plan/loop.ts +9 -0
  78. package/src/ir-to-client-js/control-flow/shared.ts +103 -6
  79. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +11 -2
  80. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +5 -0
  81. package/src/ir-to-client-js/control-flow/stringify/loop.ts +53 -10
  82. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +25 -4
  83. package/src/ir-to-client-js/control-flow/stringify/skeleton-paths.ts +70 -0
  84. package/src/ir-to-client-js/control-flow/stringify/template-parse.ts +25 -1
  85. package/src/ir-to-client-js/csr-substitute.ts +53 -0
  86. package/src/ir-to-client-js/html-template.ts +235 -2
  87. package/src/ir-to-client-js/imports.ts +1 -0
  88. package/src/ir-to-client-js/phases/props-extraction.ts +5 -9
  89. package/src/ir-to-client-js/plan/build-static-array-child-init.ts +12 -1
  90. package/src/ir-to-client-js/plan/static-array-child-init.ts +20 -1
  91. package/src/ir-to-client-js/reactivity.ts +6 -0
  92. package/src/ir-to-client-js/stringify/static-array-child-init.ts +11 -6
  93. package/src/ir-to-client-js/types.ts +21 -0
  94. package/src/jsx-to-ir.ts +230 -39
  95. package/src/signal-init-eval.ts +165 -0
  96. package/src/static-literal.ts +128 -0
@@ -22,6 +22,7 @@ import type {
22
22
  ImportInfo,
23
23
  } from '../types.ts'
24
24
  import type { CsrInlinabilityMap } from './csr-substitute.ts'
25
+ import type { SkeletonSlotPaths } from './html-template.ts'
25
26
 
26
27
  export interface ClientJsContext {
27
28
  componentName: string
@@ -404,6 +405,18 @@ export type ConditionalBranchConditional = ConditionalElement
404
405
  export interface NestedLoop extends LoopCore {
405
406
  kind: 'nested'
406
407
  depth: number // 1 for first nesting level, 2 for second, etc.
408
+ /**
409
+ * Index parameter of the inner `.map()` callback (e.g. `i` from
410
+ * `.map((item, i) => ...)`), or `null` when the callback doesn't declare
411
+ * one. Mirrors `TopLevelLoop.index` / `BranchLoop.index` — added by #2218
412
+ * so a nested loop's `key`/reactive-text/reactive-attr/template/event/ref
413
+ * expressions that reference the index can be bound instead of throwing
414
+ * `ReferenceError` at runtime. The renderItem body only receives the
415
+ * index as a synthetic `__innerIdx<uid>` positional param (to avoid
416
+ * cross-depth name collisions), so emitters must alias it under this
417
+ * name when referenced — see `nestedLoopReferencesIndex` in `shared.ts`.
418
+ */
419
+ index: string | null
407
420
  containerSlotId: string | null // Slot ID of the parent element containing the loop (for hydration)
408
421
  /** HTML template for a single inner loop item (for mapArray CSR rendering) */
409
422
  template?: string
@@ -529,6 +542,14 @@ export interface TopLevelLoop extends LoopCore {
529
542
  * proven covered by an effect).
530
543
  */
531
544
  skeletonTemplate?: string
545
+ /**
546
+ * Compile-time child-index paths for `skeletonTemplate`'s dynamic slots
547
+ * (perf, #2143), computed by `computeSkeletonSlotPaths` from the same IR
548
+ * tree. `undefined` means "resolve every slot via `qsa`/`$t` at runtime"
549
+ * (skeletonTemplate is still hoisted/cloned — only the runtime lookups on
550
+ * top of the fresh clone become the fallback rather than the path chain).
551
+ */
552
+ skeletonPaths?: SkeletonSlotPaths
532
553
  childEventHandlers: string[] // Bare-identifier event handler names (for the reachability graph)
533
554
  childComponent?: IRLoopChildComponent // For createComponent-based rendering
534
555
  nestedComponents?: IRLoopChildComponent[] // For nested components in loop bodies
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'
@@ -291,8 +291,21 @@ function exprHasFunctionCalls(expr: ts.Expression): boolean {
291
291
  * Returns undefined if no rewriting is needed (SolidJS-style or no props).
292
292
  */
293
293
  function rewriteBarePropRefs(text: string, expr: ts.Node, ctx: TransformContext): string | undefined {
294
- const propNames = getDestructuredPropNames(ctx)
294
+ let propNames = getDestructuredPropNames(ctx)
295
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
+ }
296
309
  // #1425: union any prop refs that `expr` reaches via branch-local
297
310
  // text substitution. The AST walk in `rewriteBarePropRefsCore`
298
311
  // sees only `expr`'s original AST, so a prop ref introduced via
@@ -591,20 +604,34 @@ function parseValueExpr(trimmed: string): ParsedExpr {
591
604
  return parseExpression(trimmed.startsWith('{') ? `(${trimmed})` : trimmed)
592
605
  }
593
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
+
594
610
  /**
595
611
  * Attach `parsed` (`parseExpression(expr.trim())`) to every `expression` node
596
612
  * in the tree, so SSR adapters emit from the structured tree instead of each
597
613
  * re-parsing the string at emit time. Best-effort: a node this walk misses (or
598
614
  * an empty `expr`) simply has no `parsed`, and the adapter falls back to
599
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.
600
625
  */
601
- 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)
602
629
  if (node.type === 'expression') {
603
630
  const trimmed = node.expr.trim()
604
- if (trimmed) node.parsed = parseExpression(trimmed)
631
+ if (trimmed) node.parsed = parse(trimmed)
605
632
  } else if (node.type === 'conditional' || node.type === 'if-statement') {
606
633
  const trimmed = node.condition.trim()
607
- if (trimmed) node.parsedCondition = parseExpression(trimmed)
634
+ if (trimmed) node.parsedCondition = parse(trimmed)
608
635
  }
609
636
  // Attach `parsed` to every expression-valued attribute / prop so adapters can
610
637
  // lower from the tree instead of re-parsing the string. Element attrs,
@@ -615,23 +642,23 @@ function attachParsedExpressions(node: IRNode): void {
615
642
  for (const attr of node.attrs) {
616
643
  if (attr.value.kind === 'expression') {
617
644
  const trimmed = attr.value.expr.trim()
618
- if (trimmed) attr.value.parsed = parseValueExpr(trimmed)
645
+ if (trimmed) attr.value.parsed = parseValue(trimmed)
619
646
  } else if (attr.value.kind === 'spread') {
620
647
  const trimmed = attr.value.expr.trim()
621
- if (trimmed) attr.value.parsed = parseExpression(trimmed)
648
+ if (trimmed) attr.value.parsed = parse(trimmed)
622
649
  }
623
650
  }
624
651
  } else if (node.type === 'component') {
625
652
  for (const prop of node.props) {
626
653
  if (prop.value.kind === 'expression') {
627
654
  const trimmed = prop.value.expr.trim()
628
- if (trimmed) prop.value.parsed = parseValueExpr(trimmed)
655
+ if (trimmed) prop.value.parsed = parseValue(trimmed)
629
656
  }
630
657
  }
631
658
  } else if (node.type === 'provider') {
632
659
  if (node.valueProp.value.kind === 'expression') {
633
660
  const trimmed = node.valueProp.value.expr.trim()
634
- if (trimmed) node.valueProp.value.parsed = parseValueExpr(trimmed)
661
+ if (trimmed) node.valueProp.value.parsed = parseValue(trimmed)
635
662
  }
636
663
  }
637
664
  switch (node.type) {
@@ -639,46 +666,54 @@ function attachParsedExpressions(node: IRNode): void {
639
666
  case 'component':
640
667
  case 'fragment':
641
668
  case 'provider':
642
- for (const child of node.children) attachParsedExpressions(child)
669
+ for (const child of node.children) attachParsedExpressions(child, analyzer, bound)
643
670
  break
644
671
  case 'async':
645
- attachParsedExpressions(node.fallback)
646
- 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)
647
674
  break
648
675
  case 'loop': {
649
676
  // Attach the parse of the SAME `array` string the adapters consume
650
677
  // (the Go adapter's scalar-literal loop typing reads `loop.array` /
651
678
  // `nested.loopArray`, which is exactly `loop.array`), so it can read
652
- // 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`.
653
682
  const trimmedArray = node.array.trim()
654
- if (trimmedArray) node.arrayParsed = parseExpression(trimmedArray)
655
- 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)
656
691
  // Loops also hold expression nodes off the main `children` array.
657
692
  if (node.childComponent) {
658
- for (const child of node.childComponent.children) attachParsedExpressions(child)
693
+ for (const child of node.childComponent.children) attachParsedExpressions(child, analyzer, loopBound)
659
694
  }
660
695
  for (const nested of node.nestedComponents ?? []) {
661
- for (const child of nested.children) attachParsedExpressions(child)
696
+ for (const child of nested.children) attachParsedExpressions(child, analyzer, loopBound)
662
697
  }
663
698
  for (const frag of node.flatMapCallback?.fragments ?? []) {
664
- attachParsedExpressions(frag.ir)
699
+ attachParsedExpressions(frag.ir, analyzer, loopBound)
665
700
  }
666
701
  break
667
702
  }
668
703
  case 'conditional':
669
- attachParsedExpressions(node.whenTrue)
670
- attachParsedExpressions(node.whenFalse)
704
+ attachParsedExpressions(node.whenTrue, analyzer, bound)
705
+ attachParsedExpressions(node.whenFalse, analyzer, bound)
671
706
  break
672
707
  case 'if-statement':
673
- attachParsedExpressions(node.consequent)
674
- if (node.alternate) attachParsedExpressions(node.alternate)
708
+ attachParsedExpressions(node.consequent, analyzer, bound)
709
+ if (node.alternate) attachParsedExpressions(node.alternate, analyzer, bound)
675
710
  break
676
711
  }
677
712
  }
678
713
 
679
714
  export function jsxToIR(analyzer: AnalyzerContext): IRNode | null {
680
715
  const root = buildIRRoot(analyzer)
681
- if (root) attachParsedExpressions(root)
716
+ if (root) attachParsedExpressions(root, analyzer)
682
717
  return root
683
718
  }
684
719
 
@@ -2632,8 +2667,8 @@ function extractSortComparator(
2632
2667
  * vs. unresolved) is decided by the caller, not here.
2633
2668
  */
2634
2669
  function resolveSortComparatorIdentifier(name: string, ctx: TransformContext): ts.Expression | null {
2635
- const constInfo = findLocalConst(name, ctx)
2636
- const fnInfo = findLocalFunction(name, ctx)
2670
+ const constInfo = findLocalConst(name, ctx.analyzer)
2671
+ const fnInfo = findLocalFunction(name, ctx.analyzer)
2637
2672
  if (constInfo && fnInfo) return null
2638
2673
  if (constInfo) {
2639
2674
  const ast = parseConstInitializer(constInfo)
@@ -2646,6 +2681,149 @@ function resolveSortComparatorIdentifier(name: string, ctx: TransformContext): t
2646
2681
  return null
2647
2682
  }
2648
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
+
2649
2827
  /**
2650
2828
  * Result type for extractFilterPredicate. The predicate is always an expression:
2651
2829
  * a block body is normalized to one via `foldBlockToExpr` +
@@ -3469,14 +3647,19 @@ function transformMapCall(
3469
3647
  setArray(innerSort.array)
3470
3648
  }
3471
3649
  } else {
3472
- // Simple filter().map()
3473
- array = ctx.getJS(filterInfo.array)
3474
- 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)
3475
3656
  }
3476
3657
  }
3477
3658
  } else {
3478
- array = ctx.getJS(chainSource)
3479
- 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)
3480
3663
  }
3481
3664
 
3482
3665
  // Get callback function
@@ -4434,7 +4617,7 @@ function tryResolveTemplateSpanFromConst(
4434
4617
  ): IRTemplatePart[] | null {
4435
4618
  // ${IDENT}
4436
4619
  if (ts.isIdentifier(expr)) {
4437
- const constInfo = findLocalConst(expr.text, ctx)
4620
+ const constInfo = findLocalConst(expr.text, ctx.analyzer)
4438
4621
  if (!constInfo) return null
4439
4622
  const ast = parseConstInitializer(constInfo)
4440
4623
  if (!ast) return null
@@ -4447,7 +4630,7 @@ function tryResolveTemplateSpanFromConst(
4447
4630
  // ${IDENT[KEY]}
4448
4631
  if (ts.isElementAccessExpression(expr)) {
4449
4632
  if (!ts.isIdentifier(expr.expression)) return null
4450
- const constInfo = findLocalConst(expr.expression.text, ctx)
4633
+ const constInfo = findLocalConst(expr.expression.text, ctx.analyzer)
4451
4634
  if (!constInfo) return null
4452
4635
  const ast = parseConstInitializer(constInfo)
4453
4636
  if (!ast || !ts.isObjectLiteralExpression(ast)) return null
@@ -4504,8 +4687,8 @@ function tryResolveTemplateSpanFromConst(
4504
4687
  *
4505
4688
  * Returns undefined when no const matches.
4506
4689
  */
4507
- function findLocalConst(name: string, ctx: TransformContext) {
4508
- 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)
4509
4692
  if (matches.length === 0) return undefined
4510
4693
  // Prefer the innermost (non-module) binding; fall back to whatever's
4511
4694
  // there if every match is at module scope.
@@ -4522,8 +4705,8 @@ function findLocalConst(name: string, ctx: TransformContext) {
4522
4705
  *
4523
4706
  * Returns undefined when no local function matches.
4524
4707
  */
4525
- function findLocalFunction(name: string, ctx: TransformContext) {
4526
- 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)
4527
4710
  if (matches.length === 0) return undefined
4528
4711
  const fnScoped = matches.filter(f => !f.isModule)
4529
4712
  const pool = fnScoped.length > 0 ? fnScoped : matches
@@ -4613,7 +4796,15 @@ function tryResolveIdentifierAsTemplateLiteral(
4613
4796
  ident: ts.Identifier,
4614
4797
  ctx: TransformContext,
4615
4798
  ): IRTemplatePart[] | null {
4616
- 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)
4617
4808
  if (!constInfo) return null
4618
4809
  const ast = parseConstInitializer(constInfo)
4619
4810
  if (!ast) return null
@@ -4823,8 +5014,8 @@ function tryDesugarInterleaveTaggedTemplate(
4823
5014
  * followed, matching that precedent. Returns null when nothing resolves.
4824
5015
  */
4825
5016
  function resolveInterleaveTagIdentifier(name: string, ctx: TransformContext): ts.Expression | null {
4826
- const constInfo = findLocalConst(name, ctx)
4827
- const fnInfo = findLocalFunction(name, ctx)
5017
+ const constInfo = findLocalConst(name, ctx.analyzer)
5018
+ const fnInfo = findLocalFunction(name, ctx.analyzer)
4828
5019
  // A name bound BOTH as a const and as a `function` declaration is the
4829
5020
  // same cross-kind ambiguity `resolveSortComparatorIdentifier` refuses:
4830
5021
  // it can only occur across scopes, and `FunctionInfo.isModule` reflects
@@ -0,0 +1,165 @@
1
+ /**
2
+ * TEST-HARNESS ONLY. Evaluate a signal initializer / prop-default source
3
+ * expression against a mock `props` object by EXECUTING it (`new
4
+ * Function`, with `undefined` shadowing the globals listed below), the
5
+ * same way the Hono/CSR conformance reference produces its value — by
6
+ * actually running the component. Never call this from a build path (`bf
7
+ * build`, `compileJSX`, any adapter's `generate`); it exists only so each
8
+ * adapter's `test-render.ts` conformance harness can seed a signal/
9
+ * prop-default for a non-JS-runtime SSR render (PHP/Ruby/Python/Perl/Go)
10
+ * that matches what Hono would compute. (#2209)
11
+ *
12
+ * NOT a security sandbox — the global shadowing below is a determinism
13
+ * tripwire (catches the common accidental-nondeterminism case, e.g.
14
+ * `Date.now()`), not containment: a sufficiently creative expression can
15
+ * still reach the real global scope (`new Function` bodies execute
16
+ * unlexically-scoped, so e.g. `[].constructor.constructor('return
17
+ * Date')()` recovers `Date` even though the bare name is shadowed, and
18
+ * `eval` can't be shadowed at all — JS forbids declaring or binding a
19
+ * parameter literally named `eval` in strict-mode code). Do not widen this
20
+ * module's use to anything but first-party fixture source. The actual
21
+ * trust boundary is that the evaluated text is first-party fixture
22
+ * source, already compiled by this same process — the conformance harness
23
+ * already executes fixture-derived code far more invasively (spawning
24
+ * `ruby`/`php`/`perl`/`go run` on generated programs). This module is
25
+ * never reachable from anything a real end user's untrusted input could
26
+ * influence.
27
+ *
28
+ * Why real execution instead of a hand-rolled evaluator: the initializer
29
+ * source is an arbitrary JS-subset expression over `props` (e.g. `(props.x
30
+ * ?? []).map(t => ({ ...t, editing: false }))`, #2209's actual repro) —
31
+ * every previous approach here was a regex/pattern-match over a small
32
+ * catalogue of recognized shapes (`props.x`, `props.x ?? default`, a bare
33
+ * literal), and #2209 is literally "the catalogue missed a shape" (the
34
+ * THIRD such miss on this codebase, per the superseded
35
+ * `evaluate-signal-init.test.ts`'s own #1672 pins). A hand-written
36
+ * evaluator over `ParsedExpr` would face the same drift, and can't
37
+ * represent object spread (`{ ...t, editing: false }`) without extending
38
+ * `ParsedExpr` — a production-compiler change that ripples into every
39
+ * adapter's exhaustive switch, disproportionate for a test-only need.
40
+ * `new Function` delegates parsing to the JS engine itself — CLAUDE.md's
41
+ * regex-parsing ban exists precisely to avoid the false-match/missed-shape
42
+ * failure mode this replaces.
43
+ *
44
+ * Shadowed as `undefined` (best-effort determinism, not containment — see
45
+ * above): `globalThis`, `window`, `document`, `Date`, `Math`, `crypto`,
46
+ * `performance`, `fetch`, `setTimeout`, `setInterval`, `require`,
47
+ * `process`, `Function`. (`eval` is deliberately absent from this list —
48
+ * it cannot be shadowed as a parameter name in strict-mode code; see
49
+ * above.)
50
+ *
51
+ * `props` bare-identifier destructured params (`createSignal(count)` where
52
+ * `count` is a destructured prop, not a `props.x` member) are NOT bound —
53
+ * the evaluator only exposes `props` — so such an initializer throws
54
+ * `ReferenceError` and falls back to "unset", matching every prior
55
+ * evaluator's behavior for that shape. Extending the environment with
56
+ * `ir.metadata.propsParams` bindings is a natural follow-up if a fixture
57
+ * ever needs it.
58
+ */
59
+
60
+ const BLOCKED_GLOBALS = [
61
+ 'globalThis',
62
+ 'window',
63
+ 'document',
64
+ 'Date',
65
+ 'Math',
66
+ 'crypto',
67
+ 'performance',
68
+ 'fetch',
69
+ 'setTimeout',
70
+ 'setInterval',
71
+ 'require',
72
+ 'process',
73
+ 'Function',
74
+ ] as const
75
+
76
+ export type SignalInitEvalResult = { ok: true; value: unknown } | { ok: false }
77
+
78
+ /**
79
+ * A value the harness's downstream language serializers (JSON/Python/PHP/
80
+ * Perl/Ruby literal builders) can actually marshal: `null`, a boolean, a
81
+ * number (including non-finite — several serializers already special-case
82
+ * NaN/Infinity), a string, a dense array with no holes and no `undefined`
83
+ * elements, or a plain object (`Object.prototype` or null prototype only —
84
+ * rejects class instances, functions, Maps/Sets, etc). Rejects genuine
85
+ * cycles; a shared (non-cyclic) reference appearing more than once — e.g.
86
+ * the same object at two array indices — is fine (JSON-equivalent
87
+ * behavior just duplicates it), so `ancestors` tracks only the current
88
+ * recursion path, not every value ever visited.
89
+ */
90
+ function isTransportable(value: unknown, ancestors: Set<unknown> = new Set()): boolean {
91
+ if (value === null) return true
92
+ const t = typeof value
93
+ if (t === 'boolean' || t === 'number' || t === 'string') return true
94
+ if (t !== 'object') return false
95
+ if (ancestors.has(value)) return false // a real cycle (value is its own ancestor)
96
+ ancestors.add(value)
97
+ try {
98
+ if (Array.isArray(value)) {
99
+ // `Array.prototype.every` silently skips holes (`[1, , 3]`), so a
100
+ // sparse array would otherwise pass — compare against the own
101
+ // enumerable key count (holes aren't own keys) to catch that.
102
+ if (Object.keys(value).length !== value.length) return false
103
+ return value.every(el => el !== undefined && isTransportable(el, ancestors))
104
+ }
105
+ const proto = Object.getPrototypeOf(value)
106
+ if (proto !== Object.prototype && proto !== null) return false
107
+ return Object.values(value as Record<string, unknown>).every(v => isTransportable(v, ancestors))
108
+ } finally {
109
+ ancestors.delete(value)
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Evaluate `expr` (a JS-subset source expression) against `props`. Returns
115
+ * `{ ok: false }` when the expression fails to parse, throws at evaluation
116
+ * time (e.g. a `ReferenceError` for an unbound identifier), or evaluates to
117
+ * something the downstream serializers can't marshal (see
118
+ * {@link isTransportable}).
119
+ */
120
+ export function tryEvaluateSignalInit(
121
+ expr: string,
122
+ props?: Record<string, unknown>,
123
+ ): SignalInitEvalResult {
124
+ const src = expr.trim()
125
+ if (src === '') return { ok: false }
126
+ let fn: (props: Record<string, unknown>, ...blocked: undefined[]) => unknown
127
+ try {
128
+ // `props` plus the blocked-globals shadows are the only bindings
129
+ // passed in — best-effort determinism, not containment; see the file
130
+ // docstring for the trust-boundary rationale (test-harness-only,
131
+ // never a build path).
132
+ fn = new Function(
133
+ 'props',
134
+ ...BLOCKED_GLOBALS,
135
+ `'use strict'; return (\n${src}\n);`,
136
+ ) as typeof fn
137
+ } catch {
138
+ return { ok: false }
139
+ }
140
+ try {
141
+ const value = fn(props ?? {})
142
+ // `undefined` at the TOP level is a genuine, distinguishable result
143
+ // (e.g. an explicit `undefined` initializer, or `props.x` with no `x`
144
+ // and no `??` fallback) — not a marshal failure. `evaluateSignalInit`'s
145
+ // wrapper still collapses it to the "skip" outcome; callers that need
146
+ // the distinction use this function directly.
147
+ if (value === undefined) return { ok: true, value: undefined }
148
+ return isTransportable(value) ? { ok: true, value } : { ok: false }
149
+ } catch {
150
+ return { ok: false }
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Drop-in replacement for the harnesses' former per-adapter regex
156
+ * evaluator: `null` means "could not evaluate, or evaluated to
157
+ * `undefined` — leave the signal/default unseeded", matching every prior
158
+ * evaluator's convention (an explicit JS `null` initializer also maps to
159
+ * `null` — the same "skip" outcome, since none of these harnesses
160
+ * distinguish "explicitly null" from "unset" downstream).
161
+ */
162
+ export function evaluateSignalInit(expr: string, props?: Record<string, unknown>): unknown {
163
+ const result = tryEvaluateSignalInit(expr, props)
164
+ return result.ok && result.value !== undefined ? result.value : null
165
+ }