@barefootjs/jsx 0.26.1 → 0.26.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 +29 -0
  2. package/dist/adapters/interface.d.ts.map +1 -1
  3. package/dist/adapters/jsx-adapter.d.ts +9 -0
  4. package/dist/adapters/jsx-adapter.d.ts.map +1 -1
  5. package/dist/adapters/loop-bound-names.d.ts.map +1 -1
  6. package/dist/adapters/parsed-expr-emitter.d.ts +0 -10
  7. package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
  8. package/dist/adapters/test-adapter.d.ts.map +1 -1
  9. package/dist/analyzer-context.d.ts +11 -1
  10. package/dist/analyzer-context.d.ts.map +1 -1
  11. package/dist/analyzer.d.ts +40 -1
  12. package/dist/analyzer.d.ts.map +1 -1
  13. package/dist/expression-parser.d.ts.map +1 -1
  14. package/dist/index.js +662 -223
  15. package/dist/ir-to-client-js/build-references.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts +2 -0
  17. package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/plan/build-branch-loop.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/control-flow/plan/build-component-loop.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/plan/build-composite-loop.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/control-flow/plan/build-event-delegation.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +12 -0
  25. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  26. package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts +1 -1
  28. package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts.map +1 -1
  29. package/dist/ir-to-client-js/control-flow.d.ts.map +1 -1
  30. package/dist/ir-to-client-js/html-template.d.ts +19 -1
  31. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  32. package/dist/ir-to-client-js/plan/build-static-array-child-init.d.ts.map +1 -1
  33. package/dist/ir-to-client-js/types.d.ts +4 -4
  34. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  35. package/dist/jsx-to-ir.d.ts.map +1 -1
  36. package/dist/loop-destructure.d.ts.map +1 -1
  37. package/dist/strip-types.d.ts +18 -0
  38. package/dist/strip-types.d.ts.map +1 -1
  39. package/dist/types.d.ts +96 -32
  40. package/dist/types.d.ts.map +1 -1
  41. package/package.json +2 -2
  42. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +94 -0
  43. package/src/__tests__/client-js-generation.test.ts +10 -3
  44. package/src/__tests__/compiler-stress-1244.test.ts +12 -1
  45. package/src/__tests__/event-delegation-scope-2367.test.ts +108 -0
  46. package/src/__tests__/flatmap-segments.test.ts +80 -0
  47. package/src/__tests__/map-arbitrary-body.test.ts +226 -0
  48. package/src/__tests__/map-body-no-silent-divergence.test.ts +360 -0
  49. package/src/__tests__/map-multi-return-body.test.ts +172 -0
  50. package/src/__tests__/unsupported-expression.test.ts +20 -2
  51. package/src/adapters/interface.ts +40 -0
  52. package/src/adapters/jsx-adapter.ts +10 -0
  53. package/src/adapters/loop-bound-names.ts +6 -2
  54. package/src/adapters/parsed-expr-emitter.ts +5 -10
  55. package/src/adapters/test-adapter.ts +10 -0
  56. package/src/analyzer-context.ts +35 -1
  57. package/src/analyzer.ts +162 -24
  58. package/src/compiler.ts +2 -2
  59. package/src/expression-parser.ts +27 -19
  60. package/src/ir-to-client-js/build-references.ts +3 -2
  61. package/src/ir-to-client-js/collect-elements.ts +3 -3
  62. package/src/ir-to-client-js/control-flow/plan/branch-loop.ts +2 -0
  63. package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +7 -2
  64. package/src/ir-to-client-js/control-flow/plan/build-component-loop.ts +4 -0
  65. package/src/ir-to-client-js/control-flow/plan/build-composite-loop.ts +15 -2
  66. package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +16 -3
  67. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +26 -3
  68. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +17 -2
  69. package/src/ir-to-client-js/control-flow/plan/loop.ts +12 -0
  70. package/src/ir-to-client-js/control-flow/shared.ts +2 -1
  71. package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +9 -2
  72. package/src/ir-to-client-js/control-flow.ts +11 -0
  73. package/src/ir-to-client-js/html-template.ts +133 -37
  74. package/src/ir-to-client-js/plan/build-static-array-child-init.ts +22 -10
  75. package/src/ir-to-client-js/types.ts +4 -3
  76. package/src/jsx-to-ir.ts +690 -164
  77. package/src/loop-destructure.ts +9 -5
  78. package/src/rich-type-refusal.ts +6 -2
  79. package/src/strip-types.ts +47 -0
  80. package/src/types.ts +110 -35
package/src/jsx-to-ir.ts CHANGED
@@ -26,7 +26,9 @@ import {
26
26
  type LoopBindingPathSegment,
27
27
  type RestExcludeKey,
28
28
  type FlatMapCallback,
29
- type FlatMapJsxFragment,
29
+ type MapCallbackPreamble,
30
+ type PreambleSegment,
31
+ tsxSourceText,
30
32
  type SourceLocation,
31
33
  type TypeInfo,
32
34
  type OriginInfo,
@@ -48,11 +50,13 @@ import {
48
50
  import { resolveFreeRefs, isNameBound as isNameBoundInEnv, type BindingEnvironment } from './free-refs.ts'
49
51
  import { computeFileScope } from './ir-to-client-js/component-scope.ts'
50
52
  import { createTemplateAwareStringProtector } from './ir-to-client-js/html-template.ts'
53
+ import { extractFreeIdentifiersFromText } from './ir-to-client-js/csr-substitute.ts'
51
54
  import { datePlugin, DATE_METHODS } from './date-lowering.ts'
52
55
  import { toLocaleDatePlugin, foldedArgToClientJs } from './to-locale-date-lowering.ts'
53
56
  import type { LoweringMatcher } from './lowering-registry.ts'
54
- import { extractFreeIdentifiersFromNode, initializerShapeContainsJsx } from './analyzer.ts'
57
+ import { extractFreeIdentifiersFromNode, initializerShapeContainsJsx, extractMultiReturnJsxBranches, type MultiReturnJsxBranches } from './analyzer.ts'
55
58
  import { iterateJsTokens, replaceInExprContexts } from './scanner/js-scanner.ts'
59
+ import { reconstructAsSegments } from './strip-types.ts'
56
60
  import { toHTMLAttrName, decodeEntities } from '@barefootjs/shared'
57
61
 
58
62
  // =============================================================================
@@ -909,8 +913,12 @@ function attachParsedExpressions(node: IRNode, analyzer: AnalyzerContext, bound:
909
913
  for (const nested of node.nestedComponents ?? []) {
910
914
  for (const child of nested.children) attachParsedExpressions(child, analyzer, loopBound)
911
915
  }
912
- for (const frag of node.flatMapCallback?.fragments ?? []) {
913
- attachParsedExpressions(frag.ir, analyzer, loopBound)
916
+ for (const seg of node.flatMapCallback?.segments ?? []) {
917
+ if (seg.kind === 'jsx') attachParsedExpressions(seg.ir, analyzer, loopBound)
918
+ }
919
+ // Preamble leaves (array-builder bodies) are nested IR the same way.
920
+ for (const seg of node.preamble?.segments ?? []) {
921
+ if (seg.kind === 'jsx') attachParsedExpressions(seg.ir, analyzer, loopBound)
914
922
  }
915
923
  break
916
924
  }
@@ -2119,100 +2127,132 @@ function transformMultiReturnJsxFunctionCall(
2119
2127
 
2120
2128
  try {
2121
2129
  const loc = getSourceLocation(callExpr, ctx.sourceFile, ctx.filePath)
2122
- const nullExpr: IRExpression = {
2123
- type: 'expression',
2124
- expr: 'null',
2125
- typeInfo: { kind: 'primitive', raw: 'null', primitive: 'null' },
2126
- reactive: false,
2127
- slotId: null,
2128
- loc,
2129
- origin: { phase: 'tick', scope: 'template', effect: 'pure', freeRefs: [] },
2130
+ return foldMultiReturnBranches(info, ctx, loc, substitutedGetJS)
2131
+ } finally {
2132
+ ctx.getJS = originalCtxGetJS
2133
+ ctx.analyzer.getJS = originalAnalyzerGetJS
2134
+ }
2135
+ }
2136
+
2137
+ /**
2138
+ * Fold an extracted multi-return branch structure (if/else-if chain or switch)
2139
+ * into a right-nested chain of `IRConditional` nodes, terminating in the
2140
+ * fallback (or `null`). Shared by the helper-function inliner
2141
+ * (`transformMultiReturnJsxFunctionCall`, which swaps `ctx.getJS` for a
2142
+ * param-substituting variant first) and the `.map()` callback-body fold
2143
+ * (Stage 2 of `spec/callback-fidelity.md`), which folds branches in the
2144
+ * loop-body context where the item binding is already in scope — no
2145
+ * substitution. `getText` stringifies condition / discriminant expressions
2146
+ * (the substituting variant for the former, `ctx.getJS` for the latter);
2147
+ * branch JSX is transformed via `transformNode`, which reads the (possibly
2148
+ * swapped) `ctx.getJS`.
2149
+ */
2150
+ function foldMultiReturnBranches(
2151
+ info: MultiReturnJsxBranches,
2152
+ ctx: TransformContext,
2153
+ loc: SourceLocation,
2154
+ getText: (node: ts.Node) => string,
2155
+ ): IRNode {
2156
+ const nullExpr: IRExpression = {
2157
+ type: 'expression',
2158
+ expr: 'null',
2159
+ typeInfo: { kind: 'primitive', raw: 'null', primitive: 'null' },
2160
+ reactive: false,
2161
+ slotId: null,
2162
+ loc,
2163
+ origin: { phase: 'tick', scope: 'template', effect: 'pure', freeRefs: [] },
2164
+ }
2165
+
2166
+ // Build the conditional chain from bottom up (last branch → first branch)
2167
+ let result: IRNode = info.fallback
2168
+ ? (transformNode(info.fallback, ctx) ?? nullExpr)
2169
+ : nullExpr
2170
+
2171
+ for (let i = info.branches.length - 1; i >= 0; i--) {
2172
+ const branch = info.branches[i]
2173
+
2174
+ // A switch fallthrough (`case 'a': case 'b': return X`) yields one branch
2175
+ // covering several case labels — OR-join them into `disc === a ||
2176
+ // disc === b`. A plain if-branch or a single case is just `[condition]`.
2177
+ const caseConds = info.switchDiscriminant
2178
+ ? [branch.condition, ...(branch.extraCaseConditions ?? [])]
2179
+ : [branch.condition]
2180
+
2181
+ // Build condition text (`getText` applies param substitution when the
2182
+ // helper-function inliner supplies a substituting variant).
2183
+ let conditionText: string
2184
+ if (info.switchDiscriminant) {
2185
+ const discText = getText(info.switchDiscriminant)
2186
+ // Parenthesize both operands so a low-precedence case expression
2187
+ // (`case a ?? b:`, a ternary, …) keeps strict-equality semantics rather
2188
+ // than binding as `(disc === a) ?? b`. (#2377 review.)
2189
+ conditionText = caseConds.map(c => `(${discText}) === (${getText(c)})`).join(' || ')
2190
+ } else {
2191
+ conditionText = getText(branch.condition)
2130
2192
  }
2131
2193
 
2132
- // Build the conditional chain from bottom up (last branch → first branch)
2133
- let result: IRNode = info.fallback
2134
- ? (transformNode(info.fallback, ctx) ?? nullExpr)
2194
+ // For switch-sourced conditions, merge freeRefs/reactivity from the
2195
+ // discriminant and every case expression so prop rewrites and reactivity
2196
+ // detection cover the full `disc === a || disc === b` condition.
2197
+ const env = makeBindingEnv(ctx)
2198
+ const caseFreeRefs = caseConds.flatMap(c => resolveFreeRefs(c, env))
2199
+ const discFreeRefs = info.switchDiscriminant
2200
+ ? resolveFreeRefs(info.switchDiscriminant, env)
2201
+ : []
2202
+ const conditionOrigin: OriginInfo = {
2203
+ phase: 'tick',
2204
+ scope: 'template',
2205
+ effect: 'pure',
2206
+ freeRefs: [...discFreeRefs, ...caseFreeRefs],
2207
+ }
2208
+ const reactive = isReactiveExpression(conditionText, ctx, branch.condition)
2209
+ || isReactiveOrigin(conditionOrigin)
2210
+ const loopParamReactive = !reactive && referencesLoopParam(conditionText, ctx)
2211
+ const callsReactive = caseConds.some(c => exprCallsReactiveGetters(c, ctx))
2212
+ || (info.switchDiscriminant ? exprCallsReactiveGetters(info.switchDiscriminant, ctx) : false)
2213
+ const hasCalls = caseConds.some(c => exprHasFunctionCalls(c))
2214
+ || (info.switchDiscriminant ? exprHasFunctionCalls(info.switchDiscriminant) : false)
2215
+ const needsSlot = reactive || loopParamReactive || callsReactive || hasCalls
2216
+ const slotId = needsSlot ? generateSlotId(ctx) : null
2217
+
2218
+ const whenTrue = branch.jsxReturn
2219
+ ? (transformNode(branch.jsxReturn, ctx) ?? nullExpr)
2135
2220
  : nullExpr
2136
2221
 
2137
- for (let i = info.branches.length - 1; i >= 0; i--) {
2138
- const branch = info.branches[i]
2139
-
2140
- // Build condition text with param substitution
2141
- let conditionText: string
2142
- if (info.switchDiscriminant) {
2143
- const discText = substitutedGetJS(info.switchDiscriminant)
2144
- const caseText = substitutedGetJS(branch.condition)
2145
- conditionText = `${discText} === ${caseText}`
2146
- } else {
2147
- conditionText = substitutedGetJS(branch.condition)
2148
- }
2149
-
2150
- // For switch-sourced conditions, merge freeRefs/reactivity from
2151
- // both the discriminant and case expression so prop rewrites and
2152
- // reactivity detection cover the full `disc === case` condition.
2153
- const env = makeBindingEnv(ctx)
2154
- const caseFreeRefs = resolveFreeRefs(branch.condition, env)
2155
- const discFreeRefs = info.switchDiscriminant
2156
- ? resolveFreeRefs(info.switchDiscriminant, env)
2157
- : []
2158
- const conditionOrigin: OriginInfo = {
2159
- phase: 'tick',
2160
- scope: 'template',
2161
- effect: 'pure',
2162
- freeRefs: [...discFreeRefs, ...caseFreeRefs],
2163
- }
2164
- const reactive = isReactiveExpression(conditionText, ctx, branch.condition)
2165
- || isReactiveOrigin(conditionOrigin)
2166
- const loopParamReactive = !reactive && referencesLoopParam(conditionText, ctx)
2167
- const callsReactive = exprCallsReactiveGetters(branch.condition, ctx)
2168
- || (info.switchDiscriminant ? exprCallsReactiveGetters(info.switchDiscriminant, ctx) : false)
2169
- const hasCalls = exprHasFunctionCalls(branch.condition)
2170
- || (info.switchDiscriminant ? exprHasFunctionCalls(info.switchDiscriminant) : false)
2171
- const needsSlot = reactive || loopParamReactive || callsReactive || hasCalls
2172
- const slotId = needsSlot ? generateSlotId(ctx) : null
2173
-
2174
- const whenTrue = branch.jsxReturn
2175
- ? (transformNode(branch.jsxReturn, ctx) ?? nullExpr)
2176
- : nullExpr
2177
-
2178
- // For switch conditions, build templateCondition from both parts
2179
- let templateCondition: string | undefined
2180
- if (info.switchDiscriminant) {
2181
- const discRewritten = rewriteBarePropRefs(
2182
- substitutedGetJS(info.switchDiscriminant), info.switchDiscriminant, ctx
2183
- )
2184
- const caseRewritten = rewriteBarePropRefs(
2185
- substitutedGetJS(branch.condition), branch.condition, ctx
2186
- )
2187
- const discPart = discRewritten ?? substitutedGetJS(info.switchDiscriminant)
2188
- const casePart = caseRewritten ?? substitutedGetJS(branch.condition)
2189
- templateCondition = `${discPart} === ${casePart}`
2190
- } else {
2191
- templateCondition = rewriteBarePropRefs(conditionText, branch.condition, ctx)
2192
- }
2193
-
2194
- const conditional: IRConditional = {
2195
- type: 'conditional',
2196
- condition: conditionText,
2197
- templateCondition,
2198
- conditionType: null,
2199
- reactive,
2200
- whenTrue,
2201
- whenFalse: result,
2202
- slotId,
2203
- callsReactiveGetters: callsReactive || undefined,
2204
- hasFunctionCalls: hasCalls || undefined,
2205
- loc,
2206
- origin: conditionOrigin,
2207
- }
2208
- result = conditional
2222
+ // For switch conditions, build templateCondition from the discriminant and
2223
+ // every (prop-rewritten) case label, OR-joined for fallthrough.
2224
+ let templateCondition: string | undefined
2225
+ if (info.switchDiscriminant) {
2226
+ const discRewritten = rewriteBarePropRefs(
2227
+ getText(info.switchDiscriminant), info.switchDiscriminant, ctx
2228
+ )
2229
+ const discPart = discRewritten ?? getText(info.switchDiscriminant)
2230
+ templateCondition = caseConds.map(c => {
2231
+ const casePart = rewriteBarePropRefs(getText(c), c, ctx) ?? getText(c)
2232
+ return `(${discPart}) === (${casePart})`
2233
+ }).join(' || ')
2234
+ } else {
2235
+ templateCondition = rewriteBarePropRefs(conditionText, branch.condition, ctx)
2209
2236
  }
2210
2237
 
2211
- return result
2212
- } finally {
2213
- ctx.getJS = originalCtxGetJS
2214
- ctx.analyzer.getJS = originalAnalyzerGetJS
2238
+ const conditional: IRConditional = {
2239
+ type: 'conditional',
2240
+ condition: conditionText,
2241
+ templateCondition,
2242
+ conditionType: null,
2243
+ reactive,
2244
+ whenTrue,
2245
+ whenFalse: result,
2246
+ slotId,
2247
+ callsReactiveGetters: callsReactive || undefined,
2248
+ hasFunctionCalls: hasCalls || undefined,
2249
+ loc,
2250
+ origin: conditionOrigin,
2251
+ }
2252
+ result = conditional
2215
2253
  }
2254
+
2255
+ return result
2216
2256
  }
2217
2257
 
2218
2258
  function transformConditional(
@@ -3705,9 +3745,10 @@ function transformMapCall(
3705
3745
  let filterPredicate: FilterPredicateResult | undefined
3706
3746
  let sortComparator: IRLoopSort | undefined
3707
3747
  let chainOrder: 'filter-sort' | 'sort-filter' | undefined
3708
- let mapPreamble: string | undefined
3709
- let templateMapPreamble: string | undefined
3710
- let typedMapPreamble: string | undefined
3748
+ // Structured pre-return statements of a block-body callback (Stage 3 root
3749
+ // cure): JS text + compiled JSX leaves as segments, plus the raw TSX for
3750
+ // JSX-runtime SSR and the declared names (D5 key guard, {out} join).
3751
+ let preamble: MapCallbackPreamble | undefined
3711
3752
  let iterationShape: 'entries' | 'keys' | undefined
3712
3753
  let objectIteration: 'entries' | 'keys' | 'values' | undefined
3713
3754
 
@@ -3761,7 +3802,12 @@ function transformMapCall(
3761
3802
  // Handle sort comparator extraction
3762
3803
  const sortExtraction = extractSortComparator(sortInfo.callback, sortInfo.method, ctx)
3763
3804
  if (isClientOnly || !sortExtraction.result) {
3764
- if (!isClientOnly && sortExtraction.unsupportedReason) {
3805
+ // Off-subset comparator: keep it in the array string for client / SSR
3806
+ // evaluation. Only raise the diagnostic when the target adapter's runtime
3807
+ // can't run the comparator body verbatim (DSL); a JS-runtime adapter runs
3808
+ // it, so rejecting it would be a universal error for a DSL-only limit.
3809
+ // See spec/callback-fidelity.md.
3810
+ if (!isClientOnly && sortExtraction.unsupportedReason && !(ctx.analyzer.acceptsCallbackBody?.('sort') ?? false)) {
3765
3811
  ctx.analyzer.errors.push(
3766
3812
  createError(ErrorCodes.UNSUPPORTED_JSX_PATTERN,
3767
3813
  getSourceLocation(sortInfo.callback, ctx.sourceFile, ctx.filePath),
@@ -3784,7 +3830,11 @@ function transformMapCall(
3784
3830
  chainOrder = 'filter-sort'
3785
3831
  const filterExtraction = extractFilterPredicate(innerFilter.callback, ctx)
3786
3832
  if (isClientOnly || !filterExtraction.result) {
3787
- if (!isClientOnly && filterExtraction.unsupportedReason) {
3833
+ // Off-subset predicate: keep it in the array string for client / SSR
3834
+ // evaluation. Only raise the diagnostic when the target adapter's runtime
3835
+ // can't run the predicate body verbatim (DSL); a JS-runtime adapter runs
3836
+ // it. See spec/callback-fidelity.md.
3837
+ if (!isClientOnly && filterExtraction.unsupportedReason && !(ctx.analyzer.acceptsCallbackBody?.('filter') ?? false)) {
3788
3838
  ctx.analyzer.errors.push(
3789
3839
  createError(ErrorCodes.UNSUPPORTED_JSX_PATTERN,
3790
3840
  getSourceLocation(innerFilter.callback, ctx.sourceFile, ctx.filePath),
@@ -3818,7 +3868,11 @@ function transformMapCall(
3818
3868
  const filterExtraction = extractFilterPredicate(filterInfo.callback, ctx)
3819
3869
 
3820
3870
  if (isClientOnly || !filterExtraction.result) {
3821
- if (!isClientOnly && filterExtraction.unsupportedReason) {
3871
+ // Off-subset predicate: keep it in the array string for client / SSR
3872
+ // evaluation. Only raise the diagnostic when the target adapter's runtime
3873
+ // can't run the predicate body verbatim (DSL); a JS-runtime adapter runs
3874
+ // it. See spec/callback-fidelity.md.
3875
+ if (!isClientOnly && filterExtraction.unsupportedReason && !(ctx.analyzer.acceptsCallbackBody?.('filter') ?? false)) {
3822
3876
  ctx.analyzer.errors.push(
3823
3877
  createError(ErrorCodes.UNSUPPORTED_JSX_PATTERN,
3824
3878
  getSourceLocation(filterInfo.callback, ctx.sourceFile, ctx.filePath),
@@ -3841,7 +3895,12 @@ function transformMapCall(
3841
3895
  chainOrder = 'sort-filter'
3842
3896
  const sortExtraction = extractSortComparator(innerSort.callback, innerSort.method, ctx)
3843
3897
  if (isClientOnly || !sortExtraction.result) {
3844
- if (!isClientOnly && sortExtraction.unsupportedReason) {
3898
+ // Off-subset comparator: keep it in the array string for client / SSR
3899
+ // evaluation. Only raise the diagnostic when the target adapter's runtime
3900
+ // can't run the comparator body verbatim (DSL); a JS-runtime adapter runs
3901
+ // it, so rejecting it would be a universal error for a DSL-only limit.
3902
+ // See spec/callback-fidelity.md.
3903
+ if (!isClientOnly && sortExtraction.unsupportedReason && !(ctx.analyzer.acceptsCallbackBody?.('sort') ?? false)) {
3845
3904
  ctx.analyzer.errors.push(
3846
3905
  createError(ErrorCodes.UNSUPPORTED_JSX_PATTERN,
3847
3906
  getSourceLocation(innerSort.callback, ctx.sourceFile, ctx.filePath),
@@ -4032,10 +4091,58 @@ function transformMapCall(
4032
4091
  // flatMap arrow with array literal: items.flatMap(item => [<A/>, <B/>])
4033
4092
  children = transformArrayLiteralChildren(body, ctx)
4034
4093
  } else if (ts.isBlock(body)) {
4094
+ // Multi-return JSX block body (if/else-if chain or a `switch`, including
4095
+ // fallthrough case labels, optionally preceded by a `const`/`let`
4096
+ // preamble) — fold to a nested IRConditional so the loop renders each
4097
+ // branch's compiled template. Tried BEFORE the single-return path:
4098
+ // otherwise the trailing `return <fallback/>` would claim the
4099
+ // single-return arm and the leading `if (...) return <A/>` would leak
4100
+ // verbatim into the map preamble (the silent-verbatim-leak this fixes).
4101
+ // Stage 2 of spec/callback-fidelity.md. A branch-local `const` (inside a
4102
+ // branch block/case) or a statement-level nested loop is not folded
4103
+ // (extractMultiReturnJsxBranches returns null) and falls through below.
4104
+ const multiReturn = method !== 'flatMap' ? extractMultiReturnJsxBranches(body, true) : null
4105
+ if (multiReturn && multiReturn.branches.length > 0) {
4106
+ const loc = getSourceLocation(body, ctx.sourceFile, ctx.filePath)
4107
+ children = [foldMultiReturnBranches(multiReturn, ctx, loc, ctx.getJS)]
4108
+
4109
+ const pre = multiReturn.preamble ?? []
4110
+ if (pre.length > 0) {
4111
+ // Emit the leading const/let preamble once per iteration, ahead of
4112
+ // the conditional — the same carrier the single-return path uses. A
4113
+ // JS-runtime adapter (and the browser under /* @client */) runs it
4114
+ // verbatim.
4115
+ preamble = preambleFromValueStatements(pre, ctx)
4116
+
4117
+ // A DSL adapter can't carry a loop-local `const` into a conditional
4118
+ // branch template, so it would render the branches with the local
4119
+ // undefined (silent divergence). Refuse instead — adapter-gated like
4120
+ // the filter/sort sites: a JS-runtime target folds and runs it, a DSL
4121
+ // target errors with the /* @client */ escape (which renders the map
4122
+ // client-only, where the browser runs the preamble). Stage 2 of
4123
+ // spec/callback-fidelity.md.
4124
+ if (!isClientOnly && !(ctx.analyzer.acceptsCallbackBody?.('map') ?? false)) {
4125
+ ctx.analyzer.errors.push(
4126
+ createError(ErrorCodes.UNSUPPORTED_JSX_PATTERN, loc, {
4127
+ message:
4128
+ 'A .map() callback body with a `const`/`let` preamble before its ' +
4129
+ 'branches cannot be lowered to a template: the loop-local binding ' +
4130
+ 'cannot be carried into a conditional branch on this backend.',
4131
+ suggestion: {
4132
+ message: 'Add /* @client */ to evaluate this expression on the client only',
4133
+ },
4134
+ })
4135
+ )
4136
+ }
4137
+ }
4138
+ }
4139
+
4035
4140
  // Block body: (item) => { const label = ...; return <div>{label}</div> }
4036
- const returnStmt = body.statements.find(
4037
- (s): s is ts.ReturnStatement => ts.isReturnStatement(s) && s.expression != null
4038
- )
4141
+ const returnStmt = children.length === 0
4142
+ ? body.statements.find(
4143
+ (s): s is ts.ReturnStatement => ts.isReturnStatement(s) && s.expression != null
4144
+ )
4145
+ : undefined
4039
4146
  if (returnStmt && returnStmt.expression) {
4040
4147
  let returnExpr = returnStmt.expression
4041
4148
  while (ts.isParenthesizedExpression(returnExpr)) {
@@ -4047,29 +4154,118 @@ function transformMapCall(
4047
4154
  children = [transformed]
4048
4155
  }
4049
4156
  }
4050
- const preambleStmts: string[] = []
4051
- const templatePreambleStmts: string[] = []
4052
- const typedPreambleStmts: string[] = []
4053
- let hasTypeDiff = false
4054
- let hasTemplateDiff = false
4157
+ // Stage 3 of spec/callback-fidelity.md — an arbitrary `.map()` body that
4158
+ // *constructs* JSX in a statement before its `return` (the classic
4159
+ // `const out = []; for (const c of it.cells) out.push(<td>{c}</td>);
4160
+ // return <tr>{out}</tr>` array-builder) cannot be lowered to a template.
4161
+ // The preamble collector below reconstructs each statement with
4162
+ // `ctx.getJS`, which strips *types* but not JSX, so the raw `<td>{c}</td>`
4163
+ // would splice verbatim into the emitted client bundle — invalid JS, a
4164
+ // silent syntax-error leak with no diagnostic. Refuse loudly instead of
4165
+ // leaking. (A later Stage-3 PR renders such bodies verbatim on JS
4166
+ // runtimes; until then it is a build error on every backend.)
4167
+ // Scan only statements the preamble collector below would reach — i.e.
4168
+ // those *before* `returnStmt` (it `break`s at the return). JSX in
4169
+ // unreachable post-return dead code is never spliced into the preamble,
4170
+ // so it is not part of the leak and must not trip the refusal.
4171
+ let jsxPreambleStmt: ts.Statement | undefined
4055
4172
  for (const stmt of body.statements) {
4056
4173
  if (stmt === returnStmt) break
4057
- const js = ctx.getJS(stmt)
4058
- const tjs = ctx.getTemplateJS(stmt)
4059
- const ts = stmt.getText(ctx.sourceFile)
4060
- preambleStmts.push(js.endsWith(';') ? js : js + ';')
4061
- templatePreambleStmts.push(tjs.endsWith(';') ? tjs : tjs + ';')
4062
- typedPreambleStmts.push(ts.endsWith(';') ? ts : ts + ';')
4063
- if (js !== ts) hasTypeDiff = true
4064
- if (js !== tjs) hasTemplateDiff = true
4174
+ if (containsJsxInExpression(stmt)) {
4175
+ jsxPreambleStmt = stmt
4176
+ break
4177
+ }
4065
4178
  }
4066
- if (preambleStmts.length > 0) {
4067
- mapPreamble = preambleStmts.join(' ')
4068
- if (hasTemplateDiff) {
4069
- templateMapPreamble = templatePreambleStmts.join(' ')
4179
+ if (jsxPreambleStmt) {
4180
+ // Stage 3 / D4 (spec/callback-fidelity.md). A JS-runtime adapter runs
4181
+ // the callback body verbatim — each JSX leaf lowers to a template-
4182
+ // literal HTML string, the imperative control flow runs as-is, and the
4183
+ // element-array child ({out}) is joined into the row. A DSL template
4184
+ // runtime can't do this, so it refuses with the /* @client */ escape
4185
+ // (which renders the loop client-only, where the browser — always JS —
4186
+ // runs the same body). Mirrors the const-preamble gate above and the
4187
+ // Stage-1 filter/sort sites (all consult `acceptsCallbackBody`).
4188
+ const jsRuntime = isClientOnly || (ctx.analyzer.acceptsCallbackBody?.('map') ?? false)
4189
+ if (children.length === 0) {
4190
+ // The return shape produced no loop children (a bare identifier
4191
+ // `return out`, a ternary, any non-element return): there is no
4192
+ // single template root to host the built elements, so the loop IR
4193
+ // can't exist and the fallback would splice the raw callback —
4194
+ // JSX included — verbatim into an expression anchor (a silent
4195
+ // leak on EVERY tier, /* @client */ included). Refuse loudly with
4196
+ // restructuring guidance; the return-shape holes pinned by
4197
+ // map-body-no-silent-divergence.test.ts close here.
4198
+ ctx.analyzer.errors.push(
4199
+ createError(
4200
+ ErrorCodes.UNSUPPORTED_JSX_PATTERN,
4201
+ getSourceLocation(jsxPreambleStmt, ctx.sourceFile, ctx.filePath),
4202
+ {
4203
+ message:
4204
+ 'A .map() callback that builds JSX in a statement before its ' +
4205
+ '`return` must return a single JSX element that embeds the ' +
4206
+ 'built array — this return shape has no element root to host it.',
4207
+ suggestion: {
4208
+ message:
4209
+ 'Wrap the result in one element root, e.g. `return <tr key={item.id}>{out}</tr>`.',
4210
+ },
4211
+ }
4212
+ )
4213
+ )
4214
+ } else if (!jsRuntime) {
4215
+ ctx.analyzer.errors.push(
4216
+ createError(
4217
+ ErrorCodes.UNSUPPORTED_JSX_PATTERN,
4218
+ getSourceLocation(jsxPreambleStmt, ctx.sourceFile, ctx.filePath),
4219
+ {
4220
+ message:
4221
+ 'A .map() callback that builds JSX in a statement before its ' +
4222
+ '`return` (for example pushing elements into an array in a loop) ' +
4223
+ 'cannot be lowered to a template on this backend.',
4224
+ suggestion: {
4225
+ message: 'Add /* @client */ to render this loop on the client only',
4226
+ },
4227
+ }
4228
+ )
4229
+ )
4230
+ } else {
4231
+ const collected = buildPreambleSegments(body.statements, returnStmt, ctx)
4232
+ if (collected.refusalNode) {
4233
+ // A leaf carries wiring the verbatim path can't render (event
4234
+ // handler, component, nested loop, reactive slot, spread), or
4235
+ // sits inside a template literal (a segment boundary there would
4236
+ // split the literal's lexical state for downstream text
4237
+ // transforms). Refuse loudly (D-E) rather than emit a
4238
+ // silently-degraded node.
4239
+ ctx.analyzer.errors.push(
4240
+ createError(
4241
+ ErrorCodes.UNSUPPORTED_JSX_PATTERN,
4242
+ getSourceLocation(collected.refusalNode, ctx.sourceFile, ctx.filePath),
4243
+ {
4244
+ message:
4245
+ 'A JSX element built in a .map() callback preamble cannot carry ' +
4246
+ 'event handlers, components, nested loops, or reactive expressions, ' +
4247
+ 'and cannot sit inside a template literal — the verbatim render ' +
4248
+ 'path builds it once, with no reactive wiring.',
4249
+ suggestion: {
4250
+ message:
4251
+ 'Return the element directly (not through a preamble variable), ' +
4252
+ 'or add /* @client */ to render the loop on the client only.',
4253
+ },
4254
+ }
4255
+ )
4256
+ )
4257
+ } else {
4258
+ preamble = collected.preamble
4259
+ }
4260
+ }
4261
+ } else {
4262
+ const valueStmts: ts.Statement[] = []
4263
+ for (const stmt of body.statements) {
4264
+ if (stmt === returnStmt) break
4265
+ valueStmts.push(stmt)
4070
4266
  }
4071
- if (hasTypeDiff) {
4072
- typedMapPreamble = typedPreambleStmts.join(' ')
4267
+ if (valueStmts.length > 0) {
4268
+ preamble = preambleFromValueStatements(valueStmts, ctx)
4073
4269
  }
4074
4270
  }
4075
4271
  }
@@ -4122,6 +4318,70 @@ function transformMapCall(
4122
4318
  ? extractItemConditionalKey(itemConditional!)
4123
4319
  : (children.length > 0 ? extractLoopKey(children[0]) : null)
4124
4320
 
4321
+ // Stage 3 / D5 (spec/callback-fidelity.md) — the keyFn is hoisted: `mapArray`
4322
+ // computes it from the raw item BEFORE the callback body runs, so the key must
4323
+ // be derivable from the item (and index), never from a value the preamble
4324
+ // computes. A key that reads a preamble-declared local would compile to an
4325
+ // unbound keyFn; refuse instead.
4326
+ const declaredNameSet = preamble && preamble.declaredNames.length > 0
4327
+ ? new Set(preamble.declaredNames)
4328
+ : undefined
4329
+ if (key && declaredNameSet) {
4330
+ const keyRefs = extractFreeIdentifiersFromText(key)
4331
+ const usesLocal = [...keyRefs].some((r) => declaredNameSet.has(r))
4332
+ if (usesLocal) {
4333
+ ctx.analyzer.errors.push(
4334
+ createError(ErrorCodes.UNSUPPORTED_JSX_PATTERN, getSourceLocation(node, ctx.sourceFile, ctx.filePath), {
4335
+ message:
4336
+ 'A .map() loop key must be derivable from the loop item — it is ' +
4337
+ 'evaluated before the callback body runs, so it cannot reference a ' +
4338
+ 'value computed in the callback preamble.',
4339
+ suggestion: {
4340
+ message: 'Derive the key directly from the loop item (e.g. key={item.id}).',
4341
+ },
4342
+ })
4343
+ )
4344
+ }
4345
+ }
4346
+
4347
+ // Stage 3 / D4 — flag element-array children ({out}) built by the preamble so
4348
+ // Phase-2 string emission joins them instead of `String([])`-collapsing them.
4349
+ // Scoped to builderNames (leaf accumulators), NOT all declared locals — a
4350
+ // value-only `{label}` keeps its historical plain interpolation.
4351
+ if (preamble && preamble.builderNames.length > 0) {
4352
+ flagArrayChildExpressions(children, new Set(preamble.builderNames))
4353
+ }
4354
+
4355
+ // Stage 3 root cure — a JSX-building preamble feeding a COMPONENT root is
4356
+ // refused: the component loop's rows are createComponent-driven ('dom-ops'
4357
+ // row construction), and a lowered HTML-string leaf passed as a prop would
4358
+ // silently diverge from SSR, where the same prop is a real JSX element.
4359
+ if (
4360
+ preamble &&
4361
+ preamble.builderNames.length > 0 &&
4362
+ children.length === 1 &&
4363
+ children[0].type === 'component'
4364
+ ) {
4365
+ ctx.analyzer.errors.push(
4366
+ createError(ErrorCodes.UNSUPPORTED_JSX_PATTERN, getSourceLocation(node, ctx.sourceFile, ctx.filePath), {
4367
+ message:
4368
+ 'A .map() callback that builds JSX in its preamble cannot return a ' +
4369
+ 'component: the built elements would reach the component as raw HTML ' +
4370
+ 'strings on the client but as JSX elements at SSR (a silent divergence).',
4371
+ suggestion: {
4372
+ message:
4373
+ 'Return a plain element root that embeds the array, or move the ' +
4374
+ 'building logic inside the component.',
4375
+ },
4376
+ })
4377
+ )
4378
+ // Refused = not carried (the old "do NOT collect" principle): codegen
4379
+ // still runs on an errored compile, and the dom-ops backstop invariant
4380
+ // must stay a FUTURE-variant tripwire, not re-fire on shapes Phase 1
4381
+ // already refused loudly.
4382
+ preamble = undefined
4383
+ }
4384
+
4125
4385
  // Extract childComponent info if the loop body is a single component
4126
4386
  // This enables createComponent-based rendering with proper prop passing
4127
4387
  let childComponent: IRLoopChildComponent | undefined
@@ -4222,11 +4482,9 @@ function transformMapCall(
4222
4482
  objectIteration,
4223
4483
  depth,
4224
4484
  clientOnly: isClientOnly || undefined,
4225
- mapPreamble,
4226
- templateMapPreamble,
4485
+ preamble,
4227
4486
  paramType,
4228
4487
  indexType,
4229
- typedMapPreamble,
4230
4488
  paramBindings,
4231
4489
  arrayFreeIdentifiers: extractFreeIdentifiersFromNode(arrayExpr),
4232
4490
  flatMapCallback,
@@ -4270,9 +4528,10 @@ function containsJsx(node: ts.Node): boolean {
4270
4528
 
4271
4529
  /**
4272
4530
  * Build a FlatMapCallback for complex flatMap block bodies (conditional
4273
- * returns, variable-assigned JSX, etc.). Walks the callback body AST,
4274
- * transforms each JSX node to IR, replaces it with a `__BF_JSX_N__`
4275
- * placeholder, and returns the compiled callback descriptor.
4531
+ * returns, variable-assigned JSX, etc.). Walks the callback body AST and
4532
+ * carries it as structured segments JS text (types stripped) interleaved
4533
+ * with compiled JSX-leaf IR never as a sentinel-bearing string (the
4534
+ * write-side rule in CLAUDE.md; same shape as the map-preamble carrier).
4276
4535
  */
4277
4536
  function buildFlatMapCallback(
4278
4537
  callback: ts.ArrowFunction | ts.FunctionExpression,
@@ -4281,55 +4540,322 @@ function buildFlatMapCallback(
4281
4540
  ): FlatMapCallback | undefined {
4282
4541
  if (!containsJsx(body)) return undefined
4283
4542
 
4284
- const fragments: FlatMapJsxFragment[] = []
4285
- const sourceText = ctx.sourceFile.text
4286
- const bodyStart = body.getStart(ctx.sourceFile)
4287
- const bodyEnd = body.getEnd()
4288
- const bodyText = sourceText.slice(bodyStart, bodyEnd)
4289
-
4290
- // Collect all JSX nodes and their positions, sorted by start position
4291
- const jsxNodes: Array<{ node: ts.Node; start: number; end: number }> = []
4292
- function collectJsx(n: ts.Node): void {
4543
+ // Collect all JSX leaves — don't descend into a JSX node (transformNode
4544
+ // compiles its interior). A leaf under a template literal is refused (a
4545
+ // segment boundary there would split the literal's lexical state), same as
4546
+ // the map-preamble collector.
4547
+ const leafSpans: Array<{ start: number; end: number }> = []
4548
+ const leafIrs: IRNode[] = []
4549
+ let refusalNode: ts.Node | undefined
4550
+ const collectJsx = (n: ts.Node, underTemplate: boolean): void => {
4293
4551
  if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n) || ts.isJsxFragment(n)) {
4294
- jsxNodes.push({
4295
- node: n,
4296
- start: n.getStart(ctx.sourceFile) - bodyStart,
4297
- end: n.getEnd() - bodyStart,
4298
- })
4552
+ if (underTemplate) refusalNode ??= n
4553
+ leafSpans.push({ start: n.getStart(ctx.sourceFile), end: n.getEnd() })
4554
+ const ir = transformNode(n as ts.Expression, ctx)
4555
+ leafIrs.push(ir ?? { type: 'text', value: '', loc: getSourceLocation(n, ctx.sourceFile, ctx.filePath) })
4299
4556
  return
4300
4557
  }
4301
- n.forEachChild(collectJsx)
4558
+ const inTemplate = underTemplate || ts.isTemplateExpression(n) || ts.isTaggedTemplateExpression(n)
4559
+ n.forEachChild((c) => collectJsx(c, inTemplate))
4302
4560
  }
4303
- collectJsx(body)
4304
-
4305
- if (jsxNodes.length === 0) return undefined
4561
+ collectJsx(body, false)
4306
4562
 
4307
- // Build the body text with JSX replaced by placeholders
4308
- let compiledBody = ''
4309
- let lastEnd = 0
4310
- for (let i = 0; i < jsxNodes.length; i++) {
4311
- const { node, start, end } = jsxNodes[i]
4312
- const placeholder = `__BF_JSX_${i}__`
4313
- compiledBody += bodyText.slice(lastEnd, start) + placeholder
4314
- lastEnd = end
4315
-
4316
- const ir = transformNode(node as any, ctx)
4317
- fragments.push({
4318
- placeholder,
4319
- ir: ir ?? { type: 'text', value: '', loc: getSourceLocation(node, ctx.sourceFile, ctx.filePath) },
4320
- })
4563
+ if (leafSpans.length === 0) return undefined
4564
+ if (refusalNode) {
4565
+ ctx.analyzer.errors.push(
4566
+ createError(
4567
+ ErrorCodes.UNSUPPORTED_JSX_PATTERN,
4568
+ getSourceLocation(refusalNode, ctx.sourceFile, ctx.filePath),
4569
+ {
4570
+ message:
4571
+ 'A JSX element inside a template literal in a .flatMap() callback ' +
4572
+ 'body cannot be compiled.',
4573
+ suggestion: { message: 'Build the element outside the template literal.' },
4574
+ }
4575
+ )
4576
+ )
4577
+ return undefined
4321
4578
  }
4322
- compiledBody += bodyText.slice(lastEnd)
4323
4579
 
4324
- // Build the template body (with prop refs rewritten)
4580
+ const pieces = reconstructAsSegments(body, ctx.sourceFile, ctx.analyzer.typeExcludeRanges, leafSpans)
4581
+ const segments: PreambleSegment[] = pieces.map((piece) => {
4582
+ if ('marker' in piece) return { kind: 'jsx', ir: leafIrs[piece.marker] }
4583
+ // Template variant per segment, like buildPreambleSegments: the hydrate
4584
+ // registration template is module scope (`template: (_p) => ...`), so a
4585
+ // bare destructured-prop reference must rewrite to `_p.xxx` there —
4586
+ // `applyPropsRewrite` can't do it (destructured components have no props
4587
+ // object name).
4588
+ const tpl = rewriteBarePropRefs(piece.js, body, ctx)
4589
+ return tpl !== undefined && tpl !== piece.js
4590
+ ? { kind: 'js', text: piece.js, templateText: tpl }
4591
+ : { kind: 'js', text: piece.js }
4592
+ })
4593
+
4325
4594
  const paramsText = callback.parameters.map(p => p.getText(ctx.sourceFile)).join(', ')
4326
4595
 
4327
4596
  return {
4328
4597
  params: `(${paramsText})`,
4329
- body: compiledBody,
4330
- templateBody: compiledBody,
4331
- rawBody: bodyText,
4332
- fragments,
4598
+ segments,
4599
+ rawBody: tsxSourceText(body.getText(ctx.sourceFile)),
4600
+ }
4601
+ }
4602
+
4603
+ /**
4604
+ * Stage 3 / D4 (spec/callback-fidelity.md) — the collected JSX-bearing preamble
4605
+ * of an arbitrary `.map()` callback (an imperative array-builder), structured.
4606
+ */
4607
+ interface PreambleCollection {
4608
+ preamble: MapCallbackPreamble
4609
+ /** Set when a leaf carries wiring the verbatim path can't render (D-E). */
4610
+ refusalNode?: ts.Node
4611
+ }
4612
+
4613
+ /**
4614
+ * Does a compiled preamble JSX leaf carry wiring the verbatim render path can't
4615
+ * honour? The path builds each leaf once as an interpolated HTML string with no
4616
+ * reactive effects, event listeners, or child reconciliation — so an event
4617
+ * handler, a component, a nested loop, a reactive slot, or a spread would be
4618
+ * silently dropped. Refuse instead (D-E), never diverge silently.
4619
+ */
4620
+ function preambleFragmentNeedsWiring(ir: IRNode): boolean {
4621
+ switch (ir.type) {
4622
+ case 'component':
4623
+ case 'loop':
4624
+ return true
4625
+ case 'element':
4626
+ if (ir.events.length > 0) return true
4627
+ // JSX spread (`<x {...rest} />`) keeps `name === '...'` as its marker.
4628
+ if (ir.attrs.some((a) => a.name.startsWith('...'))) return true
4629
+ return ir.children.some(preambleFragmentNeedsWiring)
4630
+ case 'expression':
4631
+ return ir.reactive === true
4632
+ case 'conditional':
4633
+ return (
4634
+ preambleFragmentNeedsWiring(ir.whenTrue) ||
4635
+ (ir.whenFalse ? preambleFragmentNeedsWiring(ir.whenFalse) : false)
4636
+ )
4637
+ case 'fragment':
4638
+ return ir.children.some(preambleFragmentNeedsWiring)
4639
+ default:
4640
+ return false
4641
+ }
4642
+ }
4643
+
4644
+ /**
4645
+ * Stage 3 / D4 — walk loop-body children and flag each `IRExpression` child
4646
+ * whose free identifiers intersect the preamble-declared names (e.g. `{out}`).
4647
+ * Phase-2 string emission then joins the array instead of `String([])`-collapsing
4648
+ * it. JSX SSR adapters ignore the flag (their JSX runtime renders arrays).
4649
+ */
4650
+ function flagArrayChildExpressions(nodes: IRNode[], declared: ReadonlySet<string>): void {
4651
+ for (const node of nodes) {
4652
+ switch (node.type) {
4653
+ case 'expression': {
4654
+ // Only the bare `{out}` shape — the whole expression is a single
4655
+ // identifier that is itself a preamble-declared array. A broader
4656
+ // expression (`{out.length}`, `{f(out)}`) must NOT get the array-join
4657
+ // coercion: it would change the value, and the join ternary reads the
4658
+ // expression twice, so a call could double-fire its side effects.
4659
+ const name = node.expr.trim()
4660
+ const refs = extractFreeIdentifiersFromText(node.expr)
4661
+ if (refs.size === 1 && refs.has(name) && declared.has(name)) {
4662
+ node.joinArrayChild = true
4663
+ }
4664
+ break
4665
+ }
4666
+ case 'element':
4667
+ case 'fragment':
4668
+ flagArrayChildExpressions(node.children, declared)
4669
+ break
4670
+ case 'conditional':
4671
+ flagArrayChildExpressions(
4672
+ [node.whenTrue, ...(node.whenFalse ? [node.whenFalse] : [])],
4673
+ declared,
4674
+ )
4675
+ break
4676
+ }
4677
+ }
4678
+ }
4679
+
4680
+ /**
4681
+ * Collect the names a binding introduces, recursing through object/array
4682
+ * destructuring patterns (`const { id: k } = r`, `const [k, ...rest] = xs`) so
4683
+ * the D5 key-derivability guard sees every preamble-scoped local — mirrors
4684
+ * `collectParamBindingNames`.
4685
+ */
4686
+ function collectBindingNames(name: ts.BindingName, out: Set<string>): void {
4687
+ if (ts.isIdentifier(name)) {
4688
+ out.add(name.text)
4689
+ return
4690
+ }
4691
+ // Object / array binding pattern; array holes are OmittedExpression, skipped.
4692
+ for (const el of name.elements) {
4693
+ if (ts.isBindingElement(el)) collectBindingNames(el.name, out)
4694
+ }
4695
+ }
4696
+
4697
+ /** Names a preamble statement declares (const/let/var/function). */
4698
+ function collectPreambleDeclaredNames(stmt: ts.Statement, out: Set<string>): void {
4699
+ if (ts.isVariableStatement(stmt)) {
4700
+ for (const decl of stmt.declarationList.declarations) {
4701
+ collectBindingNames(decl.name, out)
4702
+ }
4703
+ } else if (ts.isFunctionDeclaration(stmt) && stmt.name) {
4704
+ out.add(stmt.name.text)
4705
+ }
4706
+ }
4707
+
4708
+ /**
4709
+ * Build a {@link MapCallbackPreamble} from value-only (JSX-free) pre-return
4710
+ * statements — the Stage-2 fold preamble and the plain block-body preamble.
4711
+ * One `js` segment per statement, semicolon-normalized and space-joined
4712
+ * exactly as the former string carriers were.
4713
+ */
4714
+ function preambleFromValueStatements(
4715
+ statements: readonly ts.Statement[],
4716
+ ctx: TransformContext,
4717
+ ): MapCallbackPreamble {
4718
+ const segments: PreambleSegment[] = []
4719
+ const typedParts: string[] = []
4720
+ const declared = new Set<string>()
4721
+ for (const stmt of statements) {
4722
+ collectPreambleDeclaredNames(stmt, declared)
4723
+ const js0 = ctx.getJS(stmt)
4724
+ const tjs0 = ctx.getTemplateJS(stmt)
4725
+ const raw0 = stmt.getText(ctx.sourceFile)
4726
+ const js = (js0.endsWith(';') ? js0 : js0 + ';') + ' '
4727
+ const tjs = (tjs0.endsWith(';') ? tjs0 : tjs0 + ';') + ' '
4728
+ typedParts.push(raw0.endsWith(';') ? raw0 : raw0 + ';')
4729
+ segments.push(tjs !== js ? { kind: 'js', text: js, templateText: tjs } : { kind: 'js', text: js })
4730
+ }
4731
+ return {
4732
+ segments: trimPreambleSegments(segments),
4733
+ ssrText: tsxSourceText(typedParts.join(' ')),
4734
+ declaredNames: [...declared],
4735
+ // Value-only preambles accumulate no JSX, so no child needs the array join.
4736
+ builderNames: [],
4737
+ }
4738
+ }
4739
+
4740
+ /** Drop the trailing separator space from the final js segment. */
4741
+ function trimPreambleSegments(segments: PreambleSegment[]): PreambleSegment[] {
4742
+ const last = segments[segments.length - 1]
4743
+ if (last?.kind === 'js') {
4744
+ const text = last.text.trimEnd()
4745
+ const templateText = last.templateText?.trimEnd()
4746
+ segments[segments.length - 1] = templateText !== undefined
4747
+ ? { kind: 'js', text, templateText }
4748
+ : { kind: 'js', text }
4749
+ }
4750
+ return segments
4751
+ }
4752
+
4753
+ /**
4754
+ * Collect an arbitrary `.map()` callback's pre-return statements as structured
4755
+ * segments: JS text between JSX leaves becomes `js` segments (types stripped via
4756
+ * the same span-walk as `reconstructWithoutTypes`), each top-level JSX leaf
4757
+ * becomes a `jsx` segment carrying its compiled IR. No sentinel string ever
4758
+ * exists. Each leaf is checked against {@link preambleFragmentNeedsWiring}; a
4759
+ * leaf inside a template literal is refused (a segment boundary there would
4760
+ * split the literal's lexical state for downstream per-segment text
4761
+ * transforms).
4762
+ */
4763
+ function buildPreambleSegments(
4764
+ statements: ts.NodeArray<ts.Statement>,
4765
+ returnStmt: ts.Statement | undefined,
4766
+ ctx: TransformContext,
4767
+ ): PreambleCollection {
4768
+ const segments: PreambleSegment[] = []
4769
+ const typedParts: string[] = []
4770
+ const declared = new Set<string>()
4771
+ const builders = new Set<string>()
4772
+ let refusalNode: ts.Node | undefined
4773
+
4774
+ // Which local does this leaf accumulate into? The `push`/`unshift` receiver
4775
+ // (`out.push(<td/>)`) or the declaration target of a leaf-bearing
4776
+ // initializer (`const out = xs.map(x => <td/>)`). Only these names get the
4777
+ // `{out}` array-join child emission — a value-only local must keep its
4778
+ // plain interpolation (pinned by client-js-generation.test.ts #520).
4779
+ const recordBuilderTarget = (leaf: ts.Node, stmt: ts.Statement): void => {
4780
+ for (let n: ts.Node | undefined = leaf.parent; n && n !== stmt.parent; n = n.parent) {
4781
+ if (
4782
+ ts.isCallExpression(n) &&
4783
+ ts.isPropertyAccessExpression(n.expression) &&
4784
+ (n.expression.name.text === 'push' || n.expression.name.text === 'unshift') &&
4785
+ ts.isIdentifier(n.expression.expression)
4786
+ ) {
4787
+ builders.add(n.expression.expression.text)
4788
+ return
4789
+ }
4790
+ if (ts.isVariableDeclaration(n) && ts.isIdentifier(n.name)) {
4791
+ builders.add(n.name.text)
4792
+ return
4793
+ }
4794
+ }
4795
+ }
4796
+
4797
+ for (const stmt of statements) {
4798
+ if (stmt === returnStmt) break
4799
+ collectPreambleDeclaredNames(stmt, declared)
4800
+
4801
+ // Top-level JSX leaves in this statement — don't descend into a JSX node
4802
+ // (transformNode compiles its interior). Mirrors buildFlatMapCallback.
4803
+ const leafSpans: Array<{ start: number; end: number }> = []
4804
+ const leafIrs: IRNode[] = []
4805
+ const collect = (n: ts.Node, underTemplate: boolean): void => {
4806
+ if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n) || ts.isJsxFragment(n)) {
4807
+ if (underTemplate) refusalNode ??= n
4808
+ recordBuilderTarget(n, stmt)
4809
+ leafSpans.push({ start: n.getStart(ctx.sourceFile), end: n.getEnd() })
4810
+ const ir = transformNode(n as ts.Expression, ctx)
4811
+ if (ir && preambleFragmentNeedsWiring(ir)) refusalNode ??= n
4812
+ leafIrs.push(ir ?? { type: 'text', value: '', loc: getSourceLocation(n, ctx.sourceFile, ctx.filePath) })
4813
+ return
4814
+ }
4815
+ const inTemplate = underTemplate || ts.isTemplateExpression(n) || ts.isTaggedTemplateExpression(n)
4816
+ n.forEachChild((c) => collect(c, inTemplate))
4817
+ }
4818
+ collect(stmt, false)
4819
+
4820
+ const raw0 = stmt.getText(ctx.sourceFile)
4821
+ typedParts.push(raw0.endsWith(';') ? raw0 : raw0 + ';')
4822
+
4823
+ if (leafSpans.length === 0) {
4824
+ const js0 = ctx.getJS(stmt)
4825
+ const tjs0 = ctx.getTemplateJS(stmt)
4826
+ const js = (js0.endsWith(';') ? js0 : js0 + ';') + ' '
4827
+ const tjs = (tjs0.endsWith(';') ? tjs0 : tjs0 + ';') + ' '
4828
+ segments.push(tjs !== js ? { kind: 'js', text: js, templateText: tjs } : { kind: 'js', text: js })
4829
+ continue
4830
+ }
4831
+
4832
+ const pieces = reconstructAsSegments(stmt, ctx.sourceFile, ctx.analyzer.typeExcludeRanges, leafSpans)
4833
+ for (const piece of pieces) {
4834
+ if ('marker' in piece) {
4835
+ segments.push({ kind: 'jsx', ir: leafIrs[piece.marker] })
4836
+ } else {
4837
+ // Per-segment template variant: the destructured-prop rewrite is a text
4838
+ // transform; leaf refusal above guarantees segment boundaries never
4839
+ // split a string/template literal's lexical state.
4840
+ const tpl = rewriteBarePropRefs(piece.js, stmt, ctx)
4841
+ segments.push(tpl !== undefined && tpl !== piece.js
4842
+ ? { kind: 'js', text: piece.js, templateText: tpl }
4843
+ : { kind: 'js', text: piece.js })
4844
+ }
4845
+ }
4846
+ // Statement separator + semicolon normalization (the raw text may omit `;`).
4847
+ const sep = raw0.endsWith(';') ? ' ' : '; '
4848
+ segments.push({ kind: 'js', text: sep })
4849
+ }
4850
+
4851
+ return {
4852
+ preamble: {
4853
+ segments: trimPreambleSegments(segments),
4854
+ ssrText: tsxSourceText(typedParts.join(' ')),
4855
+ declaredNames: [...declared],
4856
+ builderNames: [...builders],
4857
+ },
4858
+ refusalNode,
4333
4859
  }
4334
4860
  }
4335
4861