@barefootjs/jsx 0.26.2 → 0.26.4

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 (98) 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 +1086 -269
  15. package/dist/ir-to-client-js/build-references.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts +14 -1
  18. package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/control-flow/plan/build-branch-loop.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/plan/build-component-loop.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/control-flow/plan/build-composite-loop.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/control-flow/plan/build-event-delegation.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts +16 -1
  26. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +39 -0
  28. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  29. package/dist/ir-to-client-js/control-flow/shared.d.ts +12 -1
  30. package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
  31. package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts +1 -1
  32. package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts.map +1 -1
  33. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts +19 -0
  34. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  35. package/dist/ir-to-client-js/control-flow.d.ts.map +1 -1
  36. package/dist/ir-to-client-js/html-template.d.ts +67 -1
  37. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  38. package/dist/ir-to-client-js/imports.d.ts +2 -2
  39. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  40. package/dist/ir-to-client-js/plan/build-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/types.d.ts +29 -4
  43. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  44. package/dist/jsx-to-ir.d.ts.map +1 -1
  45. package/dist/loop-destructure.d.ts.map +1 -1
  46. package/dist/strip-types.d.ts +18 -0
  47. package/dist/strip-types.d.ts.map +1 -1
  48. package/dist/types.d.ts +143 -32
  49. package/dist/types.d.ts.map +1 -1
  50. package/package.json +2 -2
  51. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +277 -33
  52. package/src/__tests__/client-js-generation.test.ts +18 -5
  53. package/src/__tests__/compiler-runtime-contract.test.ts +4 -4
  54. package/src/__tests__/compiler-stress-1244.test.ts +12 -1
  55. package/src/__tests__/delegated-handler-preamble.test.ts +153 -0
  56. package/src/__tests__/event-delegation-scope-2367.test.ts +108 -0
  57. package/src/__tests__/flatmap-segments.test.ts +262 -0
  58. package/src/__tests__/map-arbitrary-body.test.ts +226 -0
  59. package/src/__tests__/map-body-no-silent-divergence.test.ts +405 -0
  60. package/src/__tests__/map-multi-return-body.test.ts +172 -0
  61. package/src/__tests__/preamble-region-patch.test.ts +150 -0
  62. package/src/__tests__/static-loop-csr-materialize.test.ts +3 -2
  63. package/src/__tests__/unsupported-expression.test.ts +20 -2
  64. package/src/adapters/interface.ts +40 -0
  65. package/src/adapters/jsx-adapter.ts +10 -0
  66. package/src/adapters/loop-bound-names.ts +6 -2
  67. package/src/adapters/parsed-expr-emitter.ts +5 -10
  68. package/src/adapters/test-adapter.ts +10 -0
  69. package/src/analyzer-context.ts +35 -1
  70. package/src/analyzer.ts +162 -24
  71. package/src/compiler.ts +2 -2
  72. package/src/expression-parser.ts +27 -19
  73. package/src/ir-to-client-js/build-references.ts +19 -2
  74. package/src/ir-to-client-js/collect-elements.ts +69 -13
  75. package/src/ir-to-client-js/control-flow/plan/branch-loop.ts +14 -1
  76. package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +18 -5
  77. package/src/ir-to-client-js/control-flow/plan/build-component-loop.ts +4 -0
  78. package/src/ir-to-client-js/control-flow/plan/build-composite-loop.ts +15 -2
  79. package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +32 -3
  80. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +26 -3
  81. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +53 -2
  82. package/src/ir-to-client-js/control-flow/plan/event-delegation.ts +16 -1
  83. package/src/ir-to-client-js/control-flow/plan/loop.ts +40 -0
  84. package/src/ir-to-client-js/control-flow/shared.ts +28 -2
  85. package/src/ir-to-client-js/control-flow/stringify/branch-loop.ts +25 -3
  86. package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +76 -21
  87. package/src/ir-to-client-js/control-flow/stringify/loop.ts +65 -1
  88. package/src/ir-to-client-js/control-flow.ts +11 -0
  89. package/src/ir-to-client-js/html-template.ts +244 -39
  90. package/src/ir-to-client-js/imports.ts +1 -1
  91. package/src/ir-to-client-js/plan/build-static-array-child-init.ts +22 -10
  92. package/src/ir-to-client-js/reactivity.ts +6 -0
  93. package/src/ir-to-client-js/types.ts +28 -3
  94. package/src/jsx-to-ir.ts +1059 -167
  95. package/src/loop-destructure.ts +9 -5
  96. package/src/rich-type-refusal.ts +6 -2
  97. package/src/strip-types.ts +47 -0
  98. package/src/types.ts +159 -35
package/src/jsx-to-ir.ts CHANGED
@@ -26,7 +26,10 @@ import {
26
26
  type LoopBindingPathSegment,
27
27
  type RestExcludeKey,
28
28
  type FlatMapCallback,
29
- type FlatMapJsxFragment,
29
+ type MapCallbackPreamble,
30
+ type PreambleSegment,
31
+ type PreambleRegionSource,
32
+ tsxSourceText,
30
33
  type SourceLocation,
31
34
  type TypeInfo,
32
35
  type OriginInfo,
@@ -48,11 +51,13 @@ import {
48
51
  import { resolveFreeRefs, isNameBound as isNameBoundInEnv, type BindingEnvironment } from './free-refs.ts'
49
52
  import { computeFileScope } from './ir-to-client-js/component-scope.ts'
50
53
  import { createTemplateAwareStringProtector } from './ir-to-client-js/html-template.ts'
54
+ import { extractFreeIdentifiersFromText } from './ir-to-client-js/csr-substitute.ts'
51
55
  import { datePlugin, DATE_METHODS } from './date-lowering.ts'
52
56
  import { toLocaleDatePlugin, foldedArgToClientJs } from './to-locale-date-lowering.ts'
53
57
  import type { LoweringMatcher } from './lowering-registry.ts'
54
- import { extractFreeIdentifiersFromNode, initializerShapeContainsJsx } from './analyzer.ts'
58
+ import { extractFreeIdentifiersFromNode, initializerShapeContainsJsx, extractMultiReturnJsxBranches, type MultiReturnJsxBranches } from './analyzer.ts'
55
59
  import { iterateJsTokens, replaceInExprContexts } from './scanner/js-scanner.ts'
60
+ import { reconstructAsSegments } from './strip-types.ts'
56
61
  import { toHTMLAttrName, decodeEntities } from '@barefootjs/shared'
57
62
 
58
63
  // =============================================================================
@@ -909,8 +914,12 @@ function attachParsedExpressions(node: IRNode, analyzer: AnalyzerContext, bound:
909
914
  for (const nested of node.nestedComponents ?? []) {
910
915
  for (const child of nested.children) attachParsedExpressions(child, analyzer, loopBound)
911
916
  }
912
- for (const frag of node.flatMapCallback?.fragments ?? []) {
913
- attachParsedExpressions(frag.ir, analyzer, loopBound)
917
+ for (const seg of node.flatMapCallback?.segments ?? []) {
918
+ if (seg.kind === 'jsx') attachParsedExpressions(seg.ir, analyzer, loopBound)
919
+ }
920
+ // Preamble leaves (array-builder bodies) are nested IR the same way.
921
+ for (const seg of node.preamble?.segments ?? []) {
922
+ if (seg.kind === 'jsx') attachParsedExpressions(seg.ir, analyzer, loopBound)
914
923
  }
915
924
  break
916
925
  }
@@ -2119,100 +2128,132 @@ function transformMultiReturnJsxFunctionCall(
2119
2128
 
2120
2129
  try {
2121
2130
  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: [] },
2131
+ return foldMultiReturnBranches(info, ctx, loc, substitutedGetJS)
2132
+ } finally {
2133
+ ctx.getJS = originalCtxGetJS
2134
+ ctx.analyzer.getJS = originalAnalyzerGetJS
2135
+ }
2136
+ }
2137
+
2138
+ /**
2139
+ * Fold an extracted multi-return branch structure (if/else-if chain or switch)
2140
+ * into a right-nested chain of `IRConditional` nodes, terminating in the
2141
+ * fallback (or `null`). Shared by the helper-function inliner
2142
+ * (`transformMultiReturnJsxFunctionCall`, which swaps `ctx.getJS` for a
2143
+ * param-substituting variant first) and the `.map()` callback-body fold
2144
+ * (Stage 2 of `spec/callback-fidelity.md`), which folds branches in the
2145
+ * loop-body context where the item binding is already in scope — no
2146
+ * substitution. `getText` stringifies condition / discriminant expressions
2147
+ * (the substituting variant for the former, `ctx.getJS` for the latter);
2148
+ * branch JSX is transformed via `transformNode`, which reads the (possibly
2149
+ * swapped) `ctx.getJS`.
2150
+ */
2151
+ function foldMultiReturnBranches(
2152
+ info: MultiReturnJsxBranches,
2153
+ ctx: TransformContext,
2154
+ loc: SourceLocation,
2155
+ getText: (node: ts.Node) => string,
2156
+ ): IRNode {
2157
+ const nullExpr: IRExpression = {
2158
+ type: 'expression',
2159
+ expr: 'null',
2160
+ typeInfo: { kind: 'primitive', raw: 'null', primitive: 'null' },
2161
+ reactive: false,
2162
+ slotId: null,
2163
+ loc,
2164
+ origin: { phase: 'tick', scope: 'template', effect: 'pure', freeRefs: [] },
2165
+ }
2166
+
2167
+ // Build the conditional chain from bottom up (last branch → first branch)
2168
+ let result: IRNode = info.fallback
2169
+ ? (transformNode(info.fallback, ctx) ?? nullExpr)
2170
+ : nullExpr
2171
+
2172
+ for (let i = info.branches.length - 1; i >= 0; i--) {
2173
+ const branch = info.branches[i]
2174
+
2175
+ // A switch fallthrough (`case 'a': case 'b': return X`) yields one branch
2176
+ // covering several case labels — OR-join them into `disc === a ||
2177
+ // disc === b`. A plain if-branch or a single case is just `[condition]`.
2178
+ const caseConds = info.switchDiscriminant
2179
+ ? [branch.condition, ...(branch.extraCaseConditions ?? [])]
2180
+ : [branch.condition]
2181
+
2182
+ // Build condition text (`getText` applies param substitution when the
2183
+ // helper-function inliner supplies a substituting variant).
2184
+ let conditionText: string
2185
+ if (info.switchDiscriminant) {
2186
+ const discText = getText(info.switchDiscriminant)
2187
+ // Parenthesize both operands so a low-precedence case expression
2188
+ // (`case a ?? b:`, a ternary, …) keeps strict-equality semantics rather
2189
+ // than binding as `(disc === a) ?? b`. (#2377 review.)
2190
+ conditionText = caseConds.map(c => `(${discText}) === (${getText(c)})`).join(' || ')
2191
+ } else {
2192
+ conditionText = getText(branch.condition)
2130
2193
  }
2131
2194
 
2132
- // Build the conditional chain from bottom up (last branch → first branch)
2133
- let result: IRNode = info.fallback
2134
- ? (transformNode(info.fallback, ctx) ?? nullExpr)
2195
+ // For switch-sourced conditions, merge freeRefs/reactivity from the
2196
+ // discriminant and every case expression so prop rewrites and reactivity
2197
+ // detection cover the full `disc === a || disc === b` condition.
2198
+ const env = makeBindingEnv(ctx)
2199
+ const caseFreeRefs = caseConds.flatMap(c => resolveFreeRefs(c, env))
2200
+ const discFreeRefs = info.switchDiscriminant
2201
+ ? resolveFreeRefs(info.switchDiscriminant, env)
2202
+ : []
2203
+ const conditionOrigin: OriginInfo = {
2204
+ phase: 'tick',
2205
+ scope: 'template',
2206
+ effect: 'pure',
2207
+ freeRefs: [...discFreeRefs, ...caseFreeRefs],
2208
+ }
2209
+ const reactive = isReactiveExpression(conditionText, ctx, branch.condition)
2210
+ || isReactiveOrigin(conditionOrigin)
2211
+ const loopParamReactive = !reactive && referencesLoopParam(conditionText, ctx)
2212
+ const callsReactive = caseConds.some(c => exprCallsReactiveGetters(c, ctx))
2213
+ || (info.switchDiscriminant ? exprCallsReactiveGetters(info.switchDiscriminant, ctx) : false)
2214
+ const hasCalls = caseConds.some(c => exprHasFunctionCalls(c))
2215
+ || (info.switchDiscriminant ? exprHasFunctionCalls(info.switchDiscriminant) : false)
2216
+ const needsSlot = reactive || loopParamReactive || callsReactive || hasCalls
2217
+ const slotId = needsSlot ? generateSlotId(ctx) : null
2218
+
2219
+ const whenTrue = branch.jsxReturn
2220
+ ? (transformNode(branch.jsxReturn, ctx) ?? nullExpr)
2135
2221
  : nullExpr
2136
2222
 
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
2223
+ // For switch conditions, build templateCondition from the discriminant and
2224
+ // every (prop-rewritten) case label, OR-joined for fallthrough.
2225
+ let templateCondition: string | undefined
2226
+ if (info.switchDiscriminant) {
2227
+ const discRewritten = rewriteBarePropRefs(
2228
+ getText(info.switchDiscriminant), info.switchDiscriminant, ctx
2229
+ )
2230
+ const discPart = discRewritten ?? getText(info.switchDiscriminant)
2231
+ templateCondition = caseConds.map(c => {
2232
+ const casePart = rewriteBarePropRefs(getText(c), c, ctx) ?? getText(c)
2233
+ return `(${discPart}) === (${casePart})`
2234
+ }).join(' || ')
2235
+ } else {
2236
+ templateCondition = rewriteBarePropRefs(conditionText, branch.condition, ctx)
2209
2237
  }
2210
2238
 
2211
- return result
2212
- } finally {
2213
- ctx.getJS = originalCtxGetJS
2214
- ctx.analyzer.getJS = originalAnalyzerGetJS
2239
+ const conditional: IRConditional = {
2240
+ type: 'conditional',
2241
+ condition: conditionText,
2242
+ templateCondition,
2243
+ conditionType: null,
2244
+ reactive,
2245
+ whenTrue,
2246
+ whenFalse: result,
2247
+ slotId,
2248
+ callsReactiveGetters: callsReactive || undefined,
2249
+ hasFunctionCalls: hasCalls || undefined,
2250
+ loc,
2251
+ origin: conditionOrigin,
2252
+ }
2253
+ result = conditional
2215
2254
  }
2255
+
2256
+ return result
2216
2257
  }
2217
2258
 
2218
2259
  function transformConditional(
@@ -3597,6 +3638,99 @@ function checkLoopKey(
3597
3638
  * item; multi-root bodies (e.g. `<><path/><path/></>`) need per-item
3598
3639
  * boundary markers and multi-root template cloning (#1212).
3599
3640
  */
3641
+ /**
3642
+ * Recognize a flatMap PROJECTION body: a bare map-like call
3643
+ * (`it.tags.map(...)` / nested `.flatMap(...)`) as the whole body — either
3644
+ * an expression body (parenthesized or not) or a block whose ONLY statement
3645
+ * is `return <call>`. Anything carrying additional statements does not
3646
+ * qualify (it rides the structured-segments carrier instead). Returns the
3647
+ * call expression to lower as the loop's nested-loop child, or null.
3648
+ */
3649
+ function flatMapProjectionCall(body: ts.ConciseBody): ts.CallExpression | null {
3650
+ let expr: ts.Expression | undefined
3651
+ if (ts.isBlock(body)) {
3652
+ const real = body.statements
3653
+ if (real.length !== 1 || !ts.isReturnStatement(real[0]) || !real[0].expression) return null
3654
+ expr = real[0].expression
3655
+ } else {
3656
+ expr = body
3657
+ }
3658
+ while (ts.isParenthesizedExpression(expr)) expr = expr.expression
3659
+ if (!ts.isCallExpression(expr)) return null
3660
+ if (!getMapLikeMethod(expr)) return null
3661
+ // The descriptor synthesis renders the inner callback's raw params into
3662
+ // the client accessor (`.map((tag, i) => ({ k, h }))`), which is only
3663
+ // sound for plain-identifier params — a destructure pattern rides the
3664
+ // segments carrier instead.
3665
+ const cb = expr.arguments[0]
3666
+ if (!cb || (!ts.isArrowFunction(cb) && !ts.isFunctionExpression(cb))) return null
3667
+ for (const p of cb.parameters) {
3668
+ if (!ts.isIdentifier(p.name)) return null
3669
+ }
3670
+ // The client descriptor is one element per flattened leaf, so the inner
3671
+ // callback must produce a single element root (a JSX element, or a ternary
3672
+ // whose branches are elements). Fragments / array literals ride the
3673
+ // segments carrier.
3674
+ let innerBody: ts.Node = cb.body
3675
+ if (ts.isBlock(innerBody)) {
3676
+ const ret = innerBody.statements.find(
3677
+ (s): s is ts.ReturnStatement => ts.isReturnStatement(s) && s.expression != null,
3678
+ )
3679
+ if (innerBody.statements.length !== 1 || !ret?.expression) return null
3680
+ innerBody = ret.expression
3681
+ }
3682
+ while (ts.isParenthesizedExpression(innerBody)) innerBody = innerBody.expression
3683
+ const isElementish = (n: ts.Node): boolean => {
3684
+ let m = n
3685
+ while (ts.isParenthesizedExpression(m)) m = m.expression
3686
+ if (ts.isJsxElement(m) || ts.isJsxSelfClosingElement(m)) return leafIsWirelessElement(m)
3687
+ if (ts.isConditionalExpression(m)) return isElementish(m.whenTrue) && isElementish(m.whenFalse)
3688
+ return false
3689
+ }
3690
+ if (!isElementish(innerBody)) return null
3691
+ return expr
3692
+ }
3693
+
3694
+ /**
3695
+ * Syntactic pre-check that a projection leaf carries no per-element wiring —
3696
+ * no event handlers, spreads, component tags, or nested JSX-bearing loops.
3697
+ * Runs BEFORE the leaf is transformed (the accept/reject decision must not
3698
+ * leave transform side effects — slot ids, collected events — behind when
3699
+ * the shape falls back to the segments carrier, whose own transform would
3700
+ * then double-register them).
3701
+ */
3702
+ function leafIsWirelessElement(el: ts.JsxElement | ts.JsxSelfClosingElement): boolean {
3703
+ let ok = true
3704
+ const visit = (n: ts.Node): void => {
3705
+ if (!ok) return
3706
+ if (ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n)) {
3707
+ // Only a lowercase plain identifier (or a namespaced name like
3708
+ // `svg:path`) is an intrinsic element. A member/this tag
3709
+ // (`<icons.Tag/>`, `<this.Tag/>`) is a component per JSX semantics
3710
+ // regardless of case — the case test alone would let it through.
3711
+ const tagNode = n.tagName
3712
+ const isIntrinsic = ts.isIdentifier(tagNode)
3713
+ ? !/^[A-Z]/.test(tagNode.text)
3714
+ : ts.isJsxNamespacedName(tagNode)
3715
+ if (!isIntrinsic) { ok = false; return }
3716
+ for (const attr of n.attributes.properties) {
3717
+ if (ts.isJsxSpreadAttribute(attr)) { ok = false; return }
3718
+ if (ts.isJsxAttribute(attr)) {
3719
+ const name = attr.name.getText()
3720
+ if (/^on[A-Z]/.test(name)) { ok = false; return }
3721
+ }
3722
+ }
3723
+ }
3724
+ if (ts.isCallExpression(n) && getMapLikeMethod(n) && containsJsxInExpression(n)) {
3725
+ ok = false
3726
+ return
3727
+ }
3728
+ ts.forEachChild(n, visit)
3729
+ }
3730
+ visit(el)
3731
+ return ok
3732
+ }
3733
+
3600
3734
  function loopBodyIsMultiRoot(children: IRNode[]): boolean {
3601
3735
  const real = children.filter(
3602
3736
  (c) => !(c.type === 'text' && typeof c.value === 'string' && !c.value.trim())
@@ -3680,6 +3814,10 @@ function transformMapCall(
3680
3814
  // Capture nesting depth before we register this map's own params.
3681
3815
  // ctx.loopParams is populated by the *outer* map; if non-empty we are inside one.
3682
3816
  const isNested = ctx.loopParams.size > 0
3817
+ // Diagnostic count at entry — the structural net at the scalar fallthrough
3818
+ // de-dups against refusals fired DURING this call (leaf-wiring, DSL gates),
3819
+ // never against unrelated diagnostics recorded before it.
3820
+ const diagCountAtEntry = ctx.analyzer.errors.length
3683
3821
  // This loop's own depth (0 = outermost) is however many enclosing
3684
3822
  // loops are already active, captured before `ctx.loopDepth` below is
3685
3823
  // bumped for THIS loop's own descendants.
@@ -3705,9 +3843,10 @@ function transformMapCall(
3705
3843
  let filterPredicate: FilterPredicateResult | undefined
3706
3844
  let sortComparator: IRLoopSort | undefined
3707
3845
  let chainOrder: 'filter-sort' | 'sort-filter' | undefined
3708
- let mapPreamble: string | undefined
3709
- let templateMapPreamble: string | undefined
3710
- let typedMapPreamble: string | undefined
3846
+ // Structured pre-return statements of a block-body callback (Stage 3 root
3847
+ // cure): JS text + compiled JSX leaves as segments, plus the raw TSX for
3848
+ // JSX-runtime SSR and the declared names (D5 key guard, {out} join).
3849
+ let preamble: MapCallbackPreamble | undefined
3711
3850
  let iterationShape: 'entries' | 'keys' | undefined
3712
3851
  let objectIteration: 'entries' | 'keys' | 'values' | undefined
3713
3852
 
@@ -3761,7 +3900,12 @@ function transformMapCall(
3761
3900
  // Handle sort comparator extraction
3762
3901
  const sortExtraction = extractSortComparator(sortInfo.callback, sortInfo.method, ctx)
3763
3902
  if (isClientOnly || !sortExtraction.result) {
3764
- if (!isClientOnly && sortExtraction.unsupportedReason) {
3903
+ // Off-subset comparator: keep it in the array string for client / SSR
3904
+ // evaluation. Only raise the diagnostic when the target adapter's runtime
3905
+ // can't run the comparator body verbatim (DSL); a JS-runtime adapter runs
3906
+ // it, so rejecting it would be a universal error for a DSL-only limit.
3907
+ // See spec/callback-fidelity.md.
3908
+ if (!isClientOnly && sortExtraction.unsupportedReason && !(ctx.analyzer.acceptsCallbackBody?.('sort') ?? false)) {
3765
3909
  ctx.analyzer.errors.push(
3766
3910
  createError(ErrorCodes.UNSUPPORTED_JSX_PATTERN,
3767
3911
  getSourceLocation(sortInfo.callback, ctx.sourceFile, ctx.filePath),
@@ -3784,7 +3928,11 @@ function transformMapCall(
3784
3928
  chainOrder = 'filter-sort'
3785
3929
  const filterExtraction = extractFilterPredicate(innerFilter.callback, ctx)
3786
3930
  if (isClientOnly || !filterExtraction.result) {
3787
- if (!isClientOnly && filterExtraction.unsupportedReason) {
3931
+ // Off-subset predicate: keep it in the array string for client / SSR
3932
+ // evaluation. Only raise the diagnostic when the target adapter's runtime
3933
+ // can't run the predicate body verbatim (DSL); a JS-runtime adapter runs
3934
+ // it. See spec/callback-fidelity.md.
3935
+ if (!isClientOnly && filterExtraction.unsupportedReason && !(ctx.analyzer.acceptsCallbackBody?.('filter') ?? false)) {
3788
3936
  ctx.analyzer.errors.push(
3789
3937
  createError(ErrorCodes.UNSUPPORTED_JSX_PATTERN,
3790
3938
  getSourceLocation(innerFilter.callback, ctx.sourceFile, ctx.filePath),
@@ -3818,7 +3966,11 @@ function transformMapCall(
3818
3966
  const filterExtraction = extractFilterPredicate(filterInfo.callback, ctx)
3819
3967
 
3820
3968
  if (isClientOnly || !filterExtraction.result) {
3821
- if (!isClientOnly && filterExtraction.unsupportedReason) {
3969
+ // Off-subset predicate: keep it in the array string for client / SSR
3970
+ // evaluation. Only raise the diagnostic when the target adapter's runtime
3971
+ // can't run the predicate body verbatim (DSL); a JS-runtime adapter runs
3972
+ // it. See spec/callback-fidelity.md.
3973
+ if (!isClientOnly && filterExtraction.unsupportedReason && !(ctx.analyzer.acceptsCallbackBody?.('filter') ?? false)) {
3822
3974
  ctx.analyzer.errors.push(
3823
3975
  createError(ErrorCodes.UNSUPPORTED_JSX_PATTERN,
3824
3976
  getSourceLocation(filterInfo.callback, ctx.sourceFile, ctx.filePath),
@@ -3841,7 +3993,12 @@ function transformMapCall(
3841
3993
  chainOrder = 'sort-filter'
3842
3994
  const sortExtraction = extractSortComparator(innerSort.callback, innerSort.method, ctx)
3843
3995
  if (isClientOnly || !sortExtraction.result) {
3844
- if (!isClientOnly && sortExtraction.unsupportedReason) {
3996
+ // Off-subset comparator: keep it in the array string for client / SSR
3997
+ // evaluation. Only raise the diagnostic when the target adapter's runtime
3998
+ // can't run the comparator body verbatim (DSL); a JS-runtime adapter runs
3999
+ // it, so rejecting it would be a universal error for a DSL-only limit.
4000
+ // See spec/callback-fidelity.md.
4001
+ if (!isClientOnly && sortExtraction.unsupportedReason && !(ctx.analyzer.acceptsCallbackBody?.('sort') ?? false)) {
3845
4002
  ctx.analyzer.errors.push(
3846
4003
  createError(ErrorCodes.UNSUPPORTED_JSX_PATTERN,
3847
4004
  getSourceLocation(innerSort.callback, ctx.sourceFile, ctx.filePath),
@@ -4032,10 +4189,58 @@ function transformMapCall(
4032
4189
  // flatMap arrow with array literal: items.flatMap(item => [<A/>, <B/>])
4033
4190
  children = transformArrayLiteralChildren(body, ctx)
4034
4191
  } else if (ts.isBlock(body)) {
4192
+ // Multi-return JSX block body (if/else-if chain or a `switch`, including
4193
+ // fallthrough case labels, optionally preceded by a `const`/`let`
4194
+ // preamble) — fold to a nested IRConditional so the loop renders each
4195
+ // branch's compiled template. Tried BEFORE the single-return path:
4196
+ // otherwise the trailing `return <fallback/>` would claim the
4197
+ // single-return arm and the leading `if (...) return <A/>` would leak
4198
+ // verbatim into the map preamble (the silent-verbatim-leak this fixes).
4199
+ // Stage 2 of spec/callback-fidelity.md. A branch-local `const` (inside a
4200
+ // branch block/case) or a statement-level nested loop is not folded
4201
+ // (extractMultiReturnJsxBranches returns null) and falls through below.
4202
+ const multiReturn = method !== 'flatMap' ? extractMultiReturnJsxBranches(body, true) : null
4203
+ if (multiReturn && multiReturn.branches.length > 0) {
4204
+ const loc = getSourceLocation(body, ctx.sourceFile, ctx.filePath)
4205
+ children = [foldMultiReturnBranches(multiReturn, ctx, loc, ctx.getJS)]
4206
+
4207
+ const pre = multiReturn.preamble ?? []
4208
+ if (pre.length > 0) {
4209
+ // Emit the leading const/let preamble once per iteration, ahead of
4210
+ // the conditional — the same carrier the single-return path uses. A
4211
+ // JS-runtime adapter (and the browser under /* @client */) runs it
4212
+ // verbatim.
4213
+ preamble = preambleFromValueStatements(pre, ctx)
4214
+
4215
+ // A DSL adapter can't carry a loop-local `const` into a conditional
4216
+ // branch template, so it would render the branches with the local
4217
+ // undefined (silent divergence). Refuse instead — adapter-gated like
4218
+ // the filter/sort sites: a JS-runtime target folds and runs it, a DSL
4219
+ // target errors with the /* @client */ escape (which renders the map
4220
+ // client-only, where the browser runs the preamble). Stage 2 of
4221
+ // spec/callback-fidelity.md.
4222
+ if (!isClientOnly && !(ctx.analyzer.acceptsCallbackBody?.('map') ?? false)) {
4223
+ ctx.analyzer.errors.push(
4224
+ createError(ErrorCodes.UNSUPPORTED_JSX_PATTERN, loc, {
4225
+ message:
4226
+ 'A .map() callback body with a `const`/`let` preamble before its ' +
4227
+ 'branches cannot be lowered to a template: the loop-local binding ' +
4228
+ 'cannot be carried into a conditional branch on this backend.',
4229
+ suggestion: {
4230
+ message: 'Add /* @client */ to evaluate this expression on the client only',
4231
+ },
4232
+ })
4233
+ )
4234
+ }
4235
+ }
4236
+ }
4237
+
4035
4238
  // 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
- )
4239
+ const returnStmt = children.length === 0
4240
+ ? body.statements.find(
4241
+ (s): s is ts.ReturnStatement => ts.isReturnStatement(s) && s.expression != null
4242
+ )
4243
+ : undefined
4039
4244
  if (returnStmt && returnStmt.expression) {
4040
4245
  let returnExpr = returnStmt.expression
4041
4246
  while (ts.isParenthesizedExpression(returnExpr)) {
@@ -4047,42 +4252,202 @@ function transformMapCall(
4047
4252
  children = [transformed]
4048
4253
  }
4049
4254
  }
4050
- const preambleStmts: string[] = []
4051
- const templatePreambleStmts: string[] = []
4052
- const typedPreambleStmts: string[] = []
4053
- let hasTypeDiff = false
4054
- let hasTemplateDiff = false
4255
+ // Stage 3 of spec/callback-fidelity.md — an arbitrary `.map()` body that
4256
+ // *constructs* JSX in a statement before its `return` (the classic
4257
+ // `const out = []; for (const c of it.cells) out.push(<td>{c}</td>);
4258
+ // return <tr>{out}</tr>` array-builder) cannot be lowered to a template.
4259
+ // The preamble collector below reconstructs each statement with
4260
+ // `ctx.getJS`, which strips *types* but not JSX, so the raw `<td>{c}</td>`
4261
+ // would splice verbatim into the emitted client bundle — invalid JS, a
4262
+ // silent syntax-error leak with no diagnostic. Refuse loudly instead of
4263
+ // leaking. (A later Stage-3 PR renders such bodies verbatim on JS
4264
+ // runtimes; until then it is a build error on every backend.)
4265
+ // Scan only statements the preamble collector below would reach — i.e.
4266
+ // those *before* `returnStmt` (it `break`s at the return). JSX in
4267
+ // unreachable post-return dead code is never spliced into the preamble,
4268
+ // so it is not part of the leak and must not trip the refusal.
4269
+ let jsxPreambleStmt: ts.Statement | undefined
4055
4270
  for (const stmt of body.statements) {
4056
4271
  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
4272
+ if (containsJsxInExpression(stmt)) {
4273
+ jsxPreambleStmt = stmt
4274
+ break
4275
+ }
4065
4276
  }
4066
- if (preambleStmts.length > 0) {
4067
- mapPreamble = preambleStmts.join(' ')
4068
- if (hasTemplateDiff) {
4069
- templateMapPreamble = templatePreambleStmts.join(' ')
4277
+ if (jsxPreambleStmt) {
4278
+ // Stage 3 / D4 (spec/callback-fidelity.md). A JS-runtime adapter runs
4279
+ // the callback body verbatim — each JSX leaf lowers to a template-
4280
+ // literal HTML string, the imperative control flow runs as-is, and the
4281
+ // element-array child ({out}) is joined into the row. A DSL template
4282
+ // runtime can't do this, so it refuses with the /* @client */ escape
4283
+ // (which renders the loop client-only, where the browser — always JS —
4284
+ // runs the same body). Mirrors the const-preamble gate above and the
4285
+ // Stage-1 filter/sort sites (all consult `acceptsCallbackBody`).
4286
+ const jsRuntime = isClientOnly || (ctx.analyzer.acceptsCallbackBody?.('map') ?? false)
4287
+ if (children.length === 0) {
4288
+ // The return shape produced no loop children (a bare identifier
4289
+ // `return out`, a ternary, any non-element return): there is no
4290
+ // single template root to host the built elements, so the loop IR
4291
+ // can't exist and the fallback would splice the raw callback —
4292
+ // JSX included — verbatim into an expression anchor (a silent
4293
+ // leak on EVERY tier, /* @client */ included). Refuse loudly with
4294
+ // restructuring guidance; the return-shape holes pinned by
4295
+ // map-body-no-silent-divergence.test.ts close here.
4296
+ ctx.analyzer.errors.push(
4297
+ createError(
4298
+ ErrorCodes.UNSUPPORTED_JSX_PATTERN,
4299
+ getSourceLocation(jsxPreambleStmt, ctx.sourceFile, ctx.filePath),
4300
+ {
4301
+ message:
4302
+ 'A .map() callback that builds JSX in a statement before its ' +
4303
+ '`return` must return a single JSX element that embeds the ' +
4304
+ 'built array — this return shape has no element root to host it.',
4305
+ suggestion: {
4306
+ message:
4307
+ 'Wrap the result in one element root, e.g. `return <tr key={item.id}>{out}</tr>`.',
4308
+ },
4309
+ }
4310
+ )
4311
+ )
4312
+ } else if (!jsRuntime) {
4313
+ ctx.analyzer.errors.push(
4314
+ createError(
4315
+ ErrorCodes.UNSUPPORTED_JSX_PATTERN,
4316
+ getSourceLocation(jsxPreambleStmt, ctx.sourceFile, ctx.filePath),
4317
+ {
4318
+ message:
4319
+ 'A .map() callback that builds JSX in a statement before its ' +
4320
+ '`return` (for example pushing elements into an array in a loop) ' +
4321
+ 'cannot be lowered to a template on this backend.',
4322
+ suggestion: {
4323
+ message: 'Add /* @client */ to render this loop on the client only',
4324
+ },
4325
+ }
4326
+ )
4327
+ )
4328
+ } else {
4329
+ const collected = buildPreambleSegments(body.statements, returnStmt, ctx)
4330
+ if (collected.refusalNode) {
4331
+ // A leaf carries wiring the verbatim path can't render (event
4332
+ // handler, component, nested loop, reactive slot, spread), or
4333
+ // sits inside a template literal (a segment boundary there would
4334
+ // split the literal's lexical state for downstream text
4335
+ // transforms). Refuse loudly (D-E) rather than emit a
4336
+ // silently-degraded node.
4337
+ ctx.analyzer.errors.push(
4338
+ createError(
4339
+ ErrorCodes.UNSUPPORTED_JSX_PATTERN,
4340
+ getSourceLocation(collected.refusalNode, ctx.sourceFile, ctx.filePath),
4341
+ {
4342
+ message:
4343
+ 'A JSX element built in a .map() callback preamble cannot carry ' +
4344
+ 'event handlers, components, nested loops, or reactive expressions, ' +
4345
+ 'and cannot sit inside a template literal — the verbatim render ' +
4346
+ 'path builds it once, with no reactive wiring.',
4347
+ suggestion: {
4348
+ message:
4349
+ 'Return the element directly (not through a preamble variable), ' +
4350
+ 'or add /* @client */ to render the loop on the client only.',
4351
+ },
4352
+ }
4353
+ )
4354
+ )
4355
+ } else {
4356
+ preamble = collected.preamble
4357
+ }
4070
4358
  }
4071
- if (hasTypeDiff) {
4072
- typedMapPreamble = typedPreambleStmts.join(' ')
4359
+ } else {
4360
+ const valueStmts: ts.Statement[] = []
4361
+ for (const stmt of body.statements) {
4362
+ if (stmt === returnStmt) break
4363
+ valueStmts.push(stmt)
4364
+ }
4365
+ if (valueStmts.length > 0) {
4366
+ preamble = preambleFromValueStatements(valueStmts, ctx)
4073
4367
  }
4074
4368
  }
4075
4369
  }
4076
4370
 
4077
4371
  // flatMap block body fallback: compile JSX inline when children
4078
- // couldn't be extracted via the standard single-return path.
4079
- if (method === 'flatMap' && children.length === 0) {
4372
+ // couldn't be extracted via the standard single-return path. A pure
4373
+ // single-`return <call>` projection is NOT taken here — it lowers to
4374
+ // neutral IR below (nested-loop child), which DSL adapters templatize.
4375
+ if (method === 'flatMap' && children.length === 0 && !flatMapProjectionCall(body)) {
4080
4376
  flatMapCallback = buildFlatMapCallback(callback, body, ctx)
4081
4377
  }
4082
4378
  } else {
4083
4379
  tryTransformRenderableBody(body)
4084
4380
  }
4085
4381
 
4382
+ // flatMap PROJECTION body — the canonical `flatMap(it => it.tags.map(tag
4383
+ // => <li key={...}/>))`, as an expression body or a single-`return` block
4384
+ // (parenthesized or not). This is a pure nested-loop projection, so it
4385
+ // lowers to NEUTRAL IR — an inner IRLoop as the loop's only child — which
4386
+ // every SSR adapter can templatize (nested `{{range}}` on DSL backends),
4387
+ // per spec/callback-fidelity.md's fidelity table ("single expr" row) and
4388
+ // the array-literal flatMap precedent. Only bodies carrying STATEMENTS
4389
+ // (early returns, consts) fall through to the segments carrier below and
4390
+ // its DSL gate. The client reconciles the flattened leaves through the
4391
+ // descriptor mapArray path, synthesized from this same neutral IR
4392
+ // (`renderFlatMapProjectionClientBody`); the leaf `key` renders as the
4393
+ // usual nested-loop `data-key-1` on both the SSR and client string
4394
+ // sides, and the flattened reconciliation identity (`data-key`) is
4395
+ // stamped by mapArray from the inner loop's `key` field.
4396
+ if (method === 'flatMap' && children.length === 0 && !flatMapCallback) {
4397
+ const projection = flatMapProjectionCall(body)
4398
+ if (projection) {
4399
+ const transformed = transformJsxExpression(projection, ctx, isClientOnly)
4400
+ if (transformed && transformed.type === 'loop') {
4401
+ children = [transformed]
4402
+ }
4403
+ }
4404
+ }
4405
+
4406
+ // flatMap EXPRESSION body containing JSX in a non-projection shape —
4407
+ // e.g. a call wrapping JSX. The block-body form already rides the
4408
+ // structured-segments carrier in the isBlock branch above; an unbraced
4409
+ // body is the same shape minus the braces, so route it through the same
4410
+ // door. Without this the dispatch falls to the IRExpression scalar path,
4411
+ // which splices the raw callback — JSX included — verbatim into the
4412
+ // client bundle: a silent SyntaxError that kills the whole component's
4413
+ // hydration.
4414
+ if (method === 'flatMap' && children.length === 0 && !flatMapCallback && !ts.isBlock(body)) {
4415
+ flatMapCallback = buildFlatMapCallback(callback, body, ctx)
4416
+ }
4417
+
4418
+ // A flatMapCallback carries the WHOLE body (statements included) as
4419
+ // structured segments — the generic statements-before-return `preamble`
4420
+ // the block-body path may have collected above is a duplicate carrier.
4421
+ // Worse, it isn't one: spliced into a mapArray renderItem it re-runs the
4422
+ // body's control flow against the wrong binding shape (the audited
4423
+ // `if (t().tags.length > maxTags) return [];` renderItem residue).
4424
+ // The segments carrier is the single door; drop the shadow copy.
4425
+ if (flatMapCallback) preamble = undefined
4426
+
4427
+ // Adapter gate (spec/callback-fidelity.md fidelity model): a flatMap body
4428
+ // carried as structured segments runs verbatim on a JS runtime; a DSL
4429
+ // template runtime cannot execute it at SSR — pre-gate, e.g. the Go
4430
+ // adapter emitted `{{range …}}{{end}}` with an EMPTY body (silent
4431
+ // divergence, the exact failure class the sound-or-loud invariant
4432
+ // forbids). Refuse loudly with the /* @client */ escape, mirroring the
4433
+ // const-preamble and array-builder gates above.
4434
+ if (flatMapCallback && !isClientOnly && !(ctx.analyzer.acceptsCallbackBody?.('flatMap') ?? false)) {
4435
+ ctx.analyzer.errors.push(
4436
+ createError(
4437
+ ErrorCodes.UNSUPPORTED_JSX_PATTERN,
4438
+ getSourceLocation(body, ctx.sourceFile, ctx.filePath),
4439
+ {
4440
+ message:
4441
+ 'A .flatMap() callback body with statements or a nested projection ' +
4442
+ 'cannot be lowered to a template on this backend.',
4443
+ suggestion: {
4444
+ message: 'Add /* @client */ to render this loop on the client only',
4445
+ },
4446
+ }
4447
+ )
4448
+ )
4449
+ }
4450
+
4086
4451
  // Unregister loop params
4087
4452
  if (paramBindings) {
4088
4453
  for (const b of paramBindings) ctx.loopParams.delete(b.name)
@@ -4097,6 +4462,43 @@ function transformMapCall(
4097
4462
  // fall back to treating the entire expression as an IRExpression — unless
4098
4463
  // flatMap already built a compiled callback (flatMapCallback).
4099
4464
  if (children.length === 0 && !flatMapCallback) {
4465
+ // Structural net (sound-or-loud, spec/callback-fidelity.md): a callback
4466
+ // body that carries an INLINE JSX literal but produced no loop lowering
4467
+ // must never fall through to the IRExpression scalar path — `ctx.getJS`
4468
+ // strips types, not JSX, so the raw `<li …>` would splice verbatim into
4469
+ // the emitted client bundle as a silent SyntaxError. Any recognizer gap
4470
+ // for a JSX-bearing shape becomes a loud diagnostic here instead of a
4471
+ // leak. (A JSX-helper CALL — `map(t => renderItem(t))` — carries no
4472
+ // inline JSX literal and legitimately stays on the reactive-text path.)
4473
+ const cb = node.arguments[0]
4474
+ const cbBody = cb && (ts.isArrowFunction(cb) || ts.isFunctionExpression(cb)) ? cb.body : undefined
4475
+ // Entry-count gate: a more specific refusal already fired for THIS call
4476
+ // (e.g. the flatMap leaf-wiring check) — don't stack the generic message
4477
+ // on top of it. Comparing against the count captured at entry (not
4478
+ // `=== 0`) keeps the net armed when an unrelated diagnostic was recorded
4479
+ // earlier in the file — a warning elsewhere must never silence the leak
4480
+ // guard for this callback.
4481
+ if (cbBody && containsJsxInExpression(cbBody) && ctx.analyzer.errors.length === diagCountAtEntry) {
4482
+ ctx.analyzer.errors.push(
4483
+ createError(
4484
+ ErrorCodes.UNSUPPORTED_JSX_PATTERN,
4485
+ getSourceLocation(cbBody, ctx.sourceFile, ctx.filePath),
4486
+ {
4487
+ message:
4488
+ `A .${method}() callback that builds JSX in this shape cannot be ` +
4489
+ 'compiled — the JSX would leak verbatim into the client bundle. ' +
4490
+ 'Recognized bodies: a JSX element/fragment, a ternary or ' +
4491
+ '&& / || / ?? expression, an array literal (flatMap), or a block ' +
4492
+ 'body whose return the compiler can lower.',
4493
+ suggestion: {
4494
+ message:
4495
+ 'Restructure the callback to return the JSX element directly ' +
4496
+ '(or via a block body with a plain `return`).',
4497
+ },
4498
+ }
4499
+ )
4500
+ )
4501
+ }
4100
4502
  return null
4101
4503
  }
4102
4504
 
@@ -4122,6 +4524,70 @@ function transformMapCall(
4122
4524
  ? extractItemConditionalKey(itemConditional!)
4123
4525
  : (children.length > 0 ? extractLoopKey(children[0]) : null)
4124
4526
 
4527
+ // Stage 3 / D5 (spec/callback-fidelity.md) — the keyFn is hoisted: `mapArray`
4528
+ // computes it from the raw item BEFORE the callback body runs, so the key must
4529
+ // be derivable from the item (and index), never from a value the preamble
4530
+ // computes. A key that reads a preamble-declared local would compile to an
4531
+ // unbound keyFn; refuse instead.
4532
+ const declaredNameSet = preamble && preamble.declaredNames.length > 0
4533
+ ? new Set(preamble.declaredNames)
4534
+ : undefined
4535
+ if (key && declaredNameSet) {
4536
+ const keyRefs = extractFreeIdentifiersFromText(key)
4537
+ const usesLocal = [...keyRefs].some((r) => declaredNameSet.has(r))
4538
+ if (usesLocal) {
4539
+ ctx.analyzer.errors.push(
4540
+ createError(ErrorCodes.UNSUPPORTED_JSX_PATTERN, getSourceLocation(node, ctx.sourceFile, ctx.filePath), {
4541
+ message:
4542
+ 'A .map() loop key must be derivable from the loop item — it is ' +
4543
+ 'evaluated before the callback body runs, so it cannot reference a ' +
4544
+ 'value computed in the callback preamble.',
4545
+ suggestion: {
4546
+ message: 'Derive the key directly from the loop item (e.g. key={item.id}).',
4547
+ },
4548
+ })
4549
+ )
4550
+ }
4551
+ }
4552
+
4553
+ // Stage 3 / D4 — flag element-array children ({out}) built by the preamble so
4554
+ // Phase-2 string emission joins them instead of `String([])`-collapsing them.
4555
+ // Scoped to builderNames (leaf accumulators), NOT all declared locals — a
4556
+ // value-only `{label}` keeps its historical plain interpolation.
4557
+ if (preamble && preamble.builderNames.length > 0) {
4558
+ flagArrayChildExpressions(children, new Set(preamble.builderNames))
4559
+ }
4560
+
4561
+ // Stage 3 root cure — a JSX-building preamble feeding a COMPONENT root is
4562
+ // refused: the component loop's rows are createComponent-driven ('dom-ops'
4563
+ // row construction), and a lowered HTML-string leaf passed as a prop would
4564
+ // silently diverge from SSR, where the same prop is a real JSX element.
4565
+ if (
4566
+ preamble &&
4567
+ preamble.builderNames.length > 0 &&
4568
+ children.length === 1 &&
4569
+ children[0].type === 'component'
4570
+ ) {
4571
+ ctx.analyzer.errors.push(
4572
+ createError(ErrorCodes.UNSUPPORTED_JSX_PATTERN, getSourceLocation(node, ctx.sourceFile, ctx.filePath), {
4573
+ message:
4574
+ 'A .map() callback that builds JSX in its preamble cannot return a ' +
4575
+ 'component: the built elements would reach the component as raw HTML ' +
4576
+ 'strings on the client but as JSX elements at SSR (a silent divergence).',
4577
+ suggestion: {
4578
+ message:
4579
+ 'Return a plain element root that embeds the array, or move the ' +
4580
+ 'building logic inside the component.',
4581
+ },
4582
+ })
4583
+ )
4584
+ // Refused = not carried (the old "do NOT collect" principle): codegen
4585
+ // still runs on an errored compile, and the dom-ops backstop invariant
4586
+ // must stay a FUTURE-variant tripwire, not re-fire on shapes Phase 1
4587
+ // already refused loudly.
4588
+ preamble = undefined
4589
+ }
4590
+
4125
4591
  // Extract childComponent info if the loop body is a single component
4126
4592
  // This enables createComponent-based rendering with proper prop passing
4127
4593
  let childComponent: IRLoopChildComponent | undefined
@@ -4181,6 +4647,17 @@ function transformMapCall(
4181
4647
  // already handles correctly.
4182
4648
  && !objectIteration
4183
4649
 
4650
+ // #2389 patch-on-update — a signal-backed (non-static) loop whose body has
4651
+ // a preamble may reference a preamble-declared local directly as a child
4652
+ // expression (`{cells}`, `{stateLabel}`). `mapArray` reuses the same DOM
4653
+ // node on a same-key update, re-running only the wired text/attr slots —
4654
+ // this child has neither, so without a dedicated region-patch effect it
4655
+ // freezes at its mount-time value. Static arrays are never recreated via
4656
+ // signals, so there is nothing to patch; skip them entirely.
4657
+ const preambleRegions = preamble && !isStaticArray
4658
+ ? collectPreambleRegions(children, new Set(preamble.declaredNames), ctx)
4659
+ : undefined
4660
+
4184
4661
  // Collect nested components for both static and dynamic arrays.
4185
4662
  // Static arrays: needed for initChild hydration.
4186
4663
  // Dynamic arrays with native root + component descendants: enables reconcileElements
@@ -4222,11 +4699,10 @@ function transformMapCall(
4222
4699
  objectIteration,
4223
4700
  depth,
4224
4701
  clientOnly: isClientOnly || undefined,
4225
- mapPreamble,
4226
- templateMapPreamble,
4702
+ preamble,
4703
+ preambleRegions: preambleRegions && preambleRegions.length > 0 ? preambleRegions : undefined,
4227
4704
  paramType,
4228
4705
  indexType,
4229
- typedMapPreamble,
4230
4706
  paramBindings,
4231
4707
  arrayFreeIdentifiers: extractFreeIdentifiersFromNode(arrayExpr),
4232
4708
  flatMapCallback,
@@ -4269,67 +4745,483 @@ function containsJsx(node: ts.Node): boolean {
4269
4745
  }
4270
4746
 
4271
4747
  /**
4272
- * 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.
4748
+ * Build a FlatMapCallback for complex flatMap bodies (conditional returns,
4749
+ * variable-assigned JSX, etc.). Accepts a block body OR an expression body
4750
+ * (`t => t.tags.map(tag => <li/>)`) segment reconstruction and the SSR
4751
+ * rawBody are position-based, so both shapes flow through identically (a
4752
+ * block's text keeps its braces; an expression's text is a valid arrow body
4753
+ * as-is). Walks the callback body AST and carries it as structured segments
4754
+ * — JS text (types stripped) interleaved with compiled JSX-leaf IR — never
4755
+ * as a sentinel-bearing string (the write-side rule in CLAUDE.md; same shape
4756
+ * as the map-preamble carrier).
4276
4757
  */
4277
4758
  function buildFlatMapCallback(
4278
4759
  callback: ts.ArrowFunction | ts.FunctionExpression,
4279
- body: ts.Block,
4760
+ body: ts.Block | ts.Expression,
4280
4761
  ctx: TransformContext,
4281
4762
  ): FlatMapCallback | undefined {
4282
4763
  if (!containsJsx(body)) return undefined
4283
4764
 
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 {
4765
+ // Collect all JSX leaves — don't descend into a JSX node (transformNode
4766
+ // compiles its interior). A leaf under a template literal is refused (a
4767
+ // segment boundary there would split the literal's lexical state), same as
4768
+ // the map-preamble collector.
4769
+ const leafSpans: Array<{ start: number; end: number }> = []
4770
+ const leafIrs: IRNode[] = []
4771
+ let refusalNode: ts.Node | undefined
4772
+ const collectJsx = (n: ts.Node, underTemplate: boolean): void => {
4293
4773
  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
- })
4774
+ if (underTemplate) refusalNode ??= n
4775
+ leafSpans.push({ start: n.getStart(ctx.sourceFile), end: n.getEnd() })
4776
+ const ir = transformNode(n as ts.Expression, ctx)
4777
+ leafIrs.push(ir ?? { type: 'text', value: '', loc: getSourceLocation(n, ctx.sourceFile, ctx.filePath) })
4299
4778
  return
4300
4779
  }
4301
- n.forEachChild(collectJsx)
4780
+ const inTemplate = underTemplate || ts.isTemplateExpression(n) || ts.isTaggedTemplateExpression(n)
4781
+ n.forEachChild((c) => collectJsx(c, inTemplate))
4302
4782
  }
4303
- collectJsx(body)
4304
-
4305
- if (jsxNodes.length === 0) return undefined
4783
+ collectJsx(body, false)
4306
4784
 
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
4785
+ if (leafSpans.length === 0) return undefined
4786
+ if (refusalNode) {
4787
+ ctx.analyzer.errors.push(
4788
+ createError(
4789
+ ErrorCodes.UNSUPPORTED_JSX_PATTERN,
4790
+ getSourceLocation(refusalNode, ctx.sourceFile, ctx.filePath),
4791
+ {
4792
+ message:
4793
+ 'A JSX element inside a template literal in a .flatMap() callback ' +
4794
+ 'body cannot be compiled.',
4795
+ suggestion: { message: 'Build the element outside the template literal.' },
4796
+ }
4797
+ )
4798
+ )
4799
+ return undefined
4800
+ }
4315
4801
 
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
- })
4802
+ // A leaf that carries wiring the descriptor render path can't honour —
4803
+ // an event handler, a component, a nested loop, or a spread — would be
4804
+ // silently dead DOM on the client (the leaf renders as a keyed HTML
4805
+ // string, patched wholesale on change, with no per-slot wiring). Refuse
4806
+ // loudly (sound-or-loud), mirroring `preambleFragmentNeedsWiring` for map
4807
+ // preambles — but deliberately NOT refusing reactive expressions: whole-
4808
+ // leaf patching covers those.
4809
+ for (const leafIr of leafIrs) {
4810
+ // The descriptor path renders each leaf as ONE keyed element — the
4811
+ // renderItem adopts `template.content.firstElementChild` and `patchLeaf`
4812
+ // patches a single element root. A fragment (or any non-element) root
4813
+ // would silently drop siblings client-side while SSR renders them all —
4814
+ // the exact divergence class this carrier forbids. Refuse loudly.
4815
+ if (leafIr.type !== 'element') {
4816
+ const loc = 'loc' in leafIr && leafIr.loc
4817
+ ? leafIr.loc
4818
+ : getSourceLocation(body, ctx.sourceFile, ctx.filePath)
4819
+ ctx.analyzer.errors.push(
4820
+ createError(
4821
+ ErrorCodes.UNSUPPORTED_JSX_PATTERN,
4822
+ loc,
4823
+ {
4824
+ message:
4825
+ 'A JSX leaf produced by a .flatMap() callback must be a single ' +
4826
+ 'element — a fragment or non-element root cannot ride the keyed ' +
4827
+ 'descriptor path (each leaf hydrates and patches as one element).',
4828
+ suggestion: {
4829
+ message: 'Wrap the leaf content in a single keyed element.',
4830
+ },
4831
+ }
4832
+ )
4833
+ )
4834
+ return undefined
4835
+ }
4836
+ if (flatMapLeafNeedsWiring(leafIr)) {
4837
+ const loc = 'loc' in leafIr && leafIr.loc
4838
+ ? leafIr.loc
4839
+ : getSourceLocation(body, ctx.sourceFile, ctx.filePath)
4840
+ ctx.analyzer.errors.push(
4841
+ createError(
4842
+ ErrorCodes.UNSUPPORTED_JSX_PATTERN,
4843
+ loc,
4844
+ {
4845
+ message:
4846
+ 'A JSX element produced by a .flatMap() callback cannot carry ' +
4847
+ 'event handlers, components, nested loops, or spreads — the ' +
4848
+ 'leaf renders as a keyed HTML string with no per-element wiring.',
4849
+ suggestion: {
4850
+ message:
4851
+ 'Restructure so the interactive element lives in a .map() body — ' +
4852
+ 'the descriptor path has no per-element wiring on any backend, ' +
4853
+ 'so /* @client */ does not lift this.',
4854
+ },
4855
+ }
4856
+ )
4857
+ )
4858
+ return undefined
4859
+ }
4321
4860
  }
4322
- compiledBody += bodyText.slice(lastEnd)
4323
4861
 
4324
- // Build the template body (with prop refs rewritten)
4862
+ const pieces = reconstructAsSegments(body, ctx.sourceFile, ctx.analyzer.typeExcludeRanges, leafSpans)
4863
+ const segments: PreambleSegment[] = pieces.map((piece) => {
4864
+ if ('marker' in piece) return { kind: 'jsx', ir: leafIrs[piece.marker] }
4865
+ // Template variant per segment, like buildPreambleSegments: the hydrate
4866
+ // registration template is module scope (`template: (_p) => ...`), so a
4867
+ // bare destructured-prop reference must rewrite to `_p.xxx` there —
4868
+ // `applyPropsRewrite` can't do it (destructured components have no props
4869
+ // object name).
4870
+ const tpl = rewriteBarePropRefs(piece.js, body, ctx)
4871
+ return tpl !== undefined && tpl !== piece.js
4872
+ ? { kind: 'js', text: piece.js, templateText: tpl }
4873
+ : { kind: 'js', text: piece.js }
4874
+ })
4875
+
4325
4876
  const paramsText = callback.parameters.map(p => p.getText(ctx.sourceFile)).join(', ')
4326
4877
 
4327
4878
  return {
4328
4879
  params: `(${paramsText})`,
4329
- body: compiledBody,
4330
- templateBody: compiledBody,
4331
- rawBody: bodyText,
4332
- fragments,
4880
+ segments,
4881
+ rawBody: tsxSourceText(body.getText(ctx.sourceFile)),
4882
+ }
4883
+ }
4884
+
4885
+ /**
4886
+ * Stage 3 / D4 (spec/callback-fidelity.md) — the collected JSX-bearing preamble
4887
+ * of an arbitrary `.map()` callback (an imperative array-builder), structured.
4888
+ */
4889
+ interface PreambleCollection {
4890
+ preamble: MapCallbackPreamble
4891
+ /** Set when a leaf carries wiring the verbatim path can't render (D-E). */
4892
+ refusalNode?: ts.Node
4893
+ }
4894
+
4895
+ /**
4896
+ * Does a flatMap segment leaf carry wiring the descriptor render path can't
4897
+ * honour? Narrower than {@link preambleFragmentNeedsWiring}: reactive
4898
+ * expressions are ALLOWED (the client renders each leaf as a keyed
4899
+ * `{ k, h }` descriptor and patches the whole leaf when its HTML changes),
4900
+ * but events, components, nested loops, and spreads have no wiring on that
4901
+ * path and would be silently dead.
4902
+ */
4903
+ function flatMapLeafNeedsWiring(ir: IRNode): boolean {
4904
+ switch (ir.type) {
4905
+ case 'component':
4906
+ case 'loop':
4907
+ return true
4908
+ case 'element':
4909
+ if (ir.events.length > 0) return true
4910
+ if (ir.attrs.some((a) => a.name.startsWith('...'))) return true
4911
+ return ir.children.some(flatMapLeafNeedsWiring)
4912
+ case 'conditional':
4913
+ return (
4914
+ flatMapLeafNeedsWiring(ir.whenTrue) ||
4915
+ (ir.whenFalse ? flatMapLeafNeedsWiring(ir.whenFalse) : false)
4916
+ )
4917
+ case 'fragment':
4918
+ return ir.children.some(flatMapLeafNeedsWiring)
4919
+ default:
4920
+ return false
4921
+ }
4922
+ }
4923
+
4924
+ /**
4925
+ * Does a compiled preamble JSX leaf carry wiring the verbatim render path can't
4926
+ * honour? The path builds each leaf once as an interpolated HTML string with no
4927
+ * reactive effects, event listeners, or child reconciliation — so an event
4928
+ * handler, a component, a nested loop, a reactive slot, or a spread would be
4929
+ * silently dropped. Refuse instead (D-E), never diverge silently.
4930
+ */
4931
+ function preambleFragmentNeedsWiring(ir: IRNode): boolean {
4932
+ switch (ir.type) {
4933
+ case 'component':
4934
+ case 'loop':
4935
+ return true
4936
+ case 'element':
4937
+ if (ir.events.length > 0) return true
4938
+ // JSX spread (`<x {...rest} />`) keeps `name === '...'` as its marker.
4939
+ if (ir.attrs.some((a) => a.name.startsWith('...'))) return true
4940
+ return ir.children.some(preambleFragmentNeedsWiring)
4941
+ case 'expression':
4942
+ return ir.reactive === true
4943
+ case 'conditional':
4944
+ return (
4945
+ preambleFragmentNeedsWiring(ir.whenTrue) ||
4946
+ (ir.whenFalse ? preambleFragmentNeedsWiring(ir.whenFalse) : false)
4947
+ )
4948
+ case 'fragment':
4949
+ return ir.children.some(preambleFragmentNeedsWiring)
4950
+ default:
4951
+ return false
4952
+ }
4953
+ }
4954
+
4955
+ /**
4956
+ * Stage 3 / D4 — walk loop-body children and flag each `IRExpression` child
4957
+ * whose free identifiers intersect the preamble-declared names (e.g. `{out}`).
4958
+ * Phase-2 string emission then joins the array instead of `String([])`-collapsing
4959
+ * it. JSX SSR adapters ignore the flag (their JSX runtime renders arrays).
4960
+ */
4961
+ function flagArrayChildExpressions(nodes: IRNode[], declared: ReadonlySet<string>): void {
4962
+ for (const node of nodes) {
4963
+ switch (node.type) {
4964
+ case 'expression': {
4965
+ // Only the bare `{out}` shape — the whole expression is a single
4966
+ // identifier that is itself a preamble-declared array. A broader
4967
+ // expression (`{out.length}`, `{f(out)}`) must NOT get the array-join
4968
+ // coercion: it would change the value, and the join ternary reads the
4969
+ // expression twice, so a call could double-fire its side effects.
4970
+ const name = node.expr.trim()
4971
+ const refs = extractFreeIdentifiersFromText(node.expr)
4972
+ if (refs.size === 1 && refs.has(name) && declared.has(name)) {
4973
+ node.joinArrayChild = true
4974
+ }
4975
+ break
4976
+ }
4977
+ case 'element':
4978
+ case 'fragment':
4979
+ flagArrayChildExpressions(node.children, declared)
4980
+ break
4981
+ case 'conditional':
4982
+ flagArrayChildExpressions(
4983
+ [node.whenTrue, ...(node.whenFalse ? [node.whenFalse] : [])],
4984
+ declared,
4985
+ )
4986
+ break
4987
+ }
4988
+ }
4989
+ }
4990
+
4991
+ /**
4992
+ * #2389 patch-on-update — walk loop-body children and classify each
4993
+ * `IRExpression` whose free identifiers intersect `declared` (the enclosing
4994
+ * `.map()` preamble's `declaredNames`) as a preamble-patched region. Unlike
4995
+ * {@link flagArrayChildExpressions} (which only recognizes the exact bare
4996
+ * `{out}` shape for the array-join coercion), this is deliberately broader —
4997
+ * ANY expression reading a preamble local needs the region-patch effect,
4998
+ * whether it's a builder array (`{cells}`, `joinArrayChild` already set by
4999
+ * `flagArrayChildExpressions`, called earlier for the same `children`) or a
5000
+ * plain value (`{stateLabel}`).
5001
+ *
5002
+ * Reuses an existing `slotId` when the node already has one (e.g. it also
5003
+ * reads the loop param and so already qualified via the ordinary reactive-
5004
+ * text path) rather than allocating a second slot — `node.preambleRegion`
5005
+ * is the single source of truth downstream, so `collectLoopChildReactiveTexts`
5006
+ * can unconditionally defer to it.
5007
+ */
5008
+ function collectPreambleRegions(
5009
+ nodes: IRNode[],
5010
+ declared: ReadonlySet<string>,
5011
+ ctx: TransformContext,
5012
+ ): PreambleRegionSource[] {
5013
+ const regions: PreambleRegionSource[] = []
5014
+ const visit = (list: IRNode[]): void => {
5015
+ for (const node of list) {
5016
+ switch (node.type) {
5017
+ case 'expression': {
5018
+ const refs = extractFreeIdentifiersFromText(node.expr)
5019
+ const usesPreambleLocal = [...refs].some((r) => declared.has(r))
5020
+ if (usesPreambleLocal) {
5021
+ if (!node.slotId) node.slotId = generateSlotId(ctx)
5022
+ node.preambleRegion = true
5023
+ node.reactive = true
5024
+ regions.push({
5025
+ slotId: node.slotId,
5026
+ expr: node.expr,
5027
+ joinArrayChild: node.joinArrayChild || undefined,
5028
+ })
5029
+ }
5030
+ break
5031
+ }
5032
+ case 'element':
5033
+ case 'fragment':
5034
+ visit(node.children)
5035
+ break
5036
+ case 'conditional':
5037
+ visit([node.whenTrue, ...(node.whenFalse ? [node.whenFalse] : [])])
5038
+ break
5039
+ }
5040
+ }
5041
+ }
5042
+ visit(nodes)
5043
+ return regions
5044
+ }
5045
+
5046
+ /**
5047
+ * Collect the names a binding introduces, recursing through object/array
5048
+ * destructuring patterns (`const { id: k } = r`, `const [k, ...rest] = xs`) so
5049
+ * the D5 key-derivability guard sees every preamble-scoped local — mirrors
5050
+ * `collectParamBindingNames`.
5051
+ */
5052
+ function collectBindingNames(name: ts.BindingName, out: Set<string>): void {
5053
+ if (ts.isIdentifier(name)) {
5054
+ out.add(name.text)
5055
+ return
5056
+ }
5057
+ // Object / array binding pattern; array holes are OmittedExpression, skipped.
5058
+ for (const el of name.elements) {
5059
+ if (ts.isBindingElement(el)) collectBindingNames(el.name, out)
5060
+ }
5061
+ }
5062
+
5063
+ /** Names a preamble statement declares (const/let/var/function). */
5064
+ function collectPreambleDeclaredNames(stmt: ts.Statement, out: Set<string>): void {
5065
+ if (ts.isVariableStatement(stmt)) {
5066
+ for (const decl of stmt.declarationList.declarations) {
5067
+ collectBindingNames(decl.name, out)
5068
+ }
5069
+ } else if (ts.isFunctionDeclaration(stmt) && stmt.name) {
5070
+ out.add(stmt.name.text)
5071
+ }
5072
+ }
5073
+
5074
+ /**
5075
+ * Build a {@link MapCallbackPreamble} from value-only (JSX-free) pre-return
5076
+ * statements — the Stage-2 fold preamble and the plain block-body preamble.
5077
+ * One `js` segment per statement, semicolon-normalized and space-joined
5078
+ * exactly as the former string carriers were.
5079
+ */
5080
+ function preambleFromValueStatements(
5081
+ statements: readonly ts.Statement[],
5082
+ ctx: TransformContext,
5083
+ ): MapCallbackPreamble {
5084
+ const segments: PreambleSegment[] = []
5085
+ const typedParts: string[] = []
5086
+ const declared = new Set<string>()
5087
+ for (const stmt of statements) {
5088
+ collectPreambleDeclaredNames(stmt, declared)
5089
+ const js0 = ctx.getJS(stmt)
5090
+ const tjs0 = ctx.getTemplateJS(stmt)
5091
+ const raw0 = stmt.getText(ctx.sourceFile)
5092
+ const js = (js0.endsWith(';') ? js0 : js0 + ';') + ' '
5093
+ const tjs = (tjs0.endsWith(';') ? tjs0 : tjs0 + ';') + ' '
5094
+ typedParts.push(raw0.endsWith(';') ? raw0 : raw0 + ';')
5095
+ segments.push(tjs !== js ? { kind: 'js', text: js, templateText: tjs } : { kind: 'js', text: js })
5096
+ }
5097
+ return {
5098
+ segments: trimPreambleSegments(segments),
5099
+ ssrText: tsxSourceText(typedParts.join(' ')),
5100
+ declaredNames: [...declared],
5101
+ // Value-only preambles accumulate no JSX, so no child needs the array join.
5102
+ builderNames: [],
5103
+ }
5104
+ }
5105
+
5106
+ /** Drop the trailing separator space from the final js segment. */
5107
+ function trimPreambleSegments(segments: PreambleSegment[]): PreambleSegment[] {
5108
+ const last = segments[segments.length - 1]
5109
+ if (last?.kind === 'js') {
5110
+ const text = last.text.trimEnd()
5111
+ const templateText = last.templateText?.trimEnd()
5112
+ segments[segments.length - 1] = templateText !== undefined
5113
+ ? { kind: 'js', text, templateText }
5114
+ : { kind: 'js', text }
5115
+ }
5116
+ return segments
5117
+ }
5118
+
5119
+ /**
5120
+ * Collect an arbitrary `.map()` callback's pre-return statements as structured
5121
+ * segments: JS text between JSX leaves becomes `js` segments (types stripped via
5122
+ * the same span-walk as `reconstructWithoutTypes`), each top-level JSX leaf
5123
+ * becomes a `jsx` segment carrying its compiled IR. No sentinel string ever
5124
+ * exists. Each leaf is checked against {@link preambleFragmentNeedsWiring}; a
5125
+ * leaf inside a template literal is refused (a segment boundary there would
5126
+ * split the literal's lexical state for downstream per-segment text
5127
+ * transforms).
5128
+ */
5129
+ function buildPreambleSegments(
5130
+ statements: ts.NodeArray<ts.Statement>,
5131
+ returnStmt: ts.Statement | undefined,
5132
+ ctx: TransformContext,
5133
+ ): PreambleCollection {
5134
+ const segments: PreambleSegment[] = []
5135
+ const typedParts: string[] = []
5136
+ const declared = new Set<string>()
5137
+ const builders = new Set<string>()
5138
+ let refusalNode: ts.Node | undefined
5139
+
5140
+ // Which local does this leaf accumulate into? The `push`/`unshift` receiver
5141
+ // (`out.push(<td/>)`) or the declaration target of a leaf-bearing
5142
+ // initializer (`const out = xs.map(x => <td/>)`). Only these names get the
5143
+ // `{out}` array-join child emission — a value-only local must keep its
5144
+ // plain interpolation (pinned by client-js-generation.test.ts #520).
5145
+ const recordBuilderTarget = (leaf: ts.Node, stmt: ts.Statement): void => {
5146
+ for (let n: ts.Node | undefined = leaf.parent; n && n !== stmt.parent; n = n.parent) {
5147
+ if (
5148
+ ts.isCallExpression(n) &&
5149
+ ts.isPropertyAccessExpression(n.expression) &&
5150
+ (n.expression.name.text === 'push' || n.expression.name.text === 'unshift') &&
5151
+ ts.isIdentifier(n.expression.expression)
5152
+ ) {
5153
+ builders.add(n.expression.expression.text)
5154
+ return
5155
+ }
5156
+ if (ts.isVariableDeclaration(n) && ts.isIdentifier(n.name)) {
5157
+ builders.add(n.name.text)
5158
+ return
5159
+ }
5160
+ }
5161
+ }
5162
+
5163
+ for (const stmt of statements) {
5164
+ if (stmt === returnStmt) break
5165
+ collectPreambleDeclaredNames(stmt, declared)
5166
+
5167
+ // Top-level JSX leaves in this statement — don't descend into a JSX node
5168
+ // (transformNode compiles its interior). Mirrors buildFlatMapCallback.
5169
+ const leafSpans: Array<{ start: number; end: number }> = []
5170
+ const leafIrs: IRNode[] = []
5171
+ const collect = (n: ts.Node, underTemplate: boolean): void => {
5172
+ if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n) || ts.isJsxFragment(n)) {
5173
+ if (underTemplate) refusalNode ??= n
5174
+ recordBuilderTarget(n, stmt)
5175
+ leafSpans.push({ start: n.getStart(ctx.sourceFile), end: n.getEnd() })
5176
+ const ir = transformNode(n as ts.Expression, ctx)
5177
+ if (ir && preambleFragmentNeedsWiring(ir)) refusalNode ??= n
5178
+ leafIrs.push(ir ?? { type: 'text', value: '', loc: getSourceLocation(n, ctx.sourceFile, ctx.filePath) })
5179
+ return
5180
+ }
5181
+ const inTemplate = underTemplate || ts.isTemplateExpression(n) || ts.isTaggedTemplateExpression(n)
5182
+ n.forEachChild((c) => collect(c, inTemplate))
5183
+ }
5184
+ collect(stmt, false)
5185
+
5186
+ const raw0 = stmt.getText(ctx.sourceFile)
5187
+ typedParts.push(raw0.endsWith(';') ? raw0 : raw0 + ';')
5188
+
5189
+ if (leafSpans.length === 0) {
5190
+ const js0 = ctx.getJS(stmt)
5191
+ const tjs0 = ctx.getTemplateJS(stmt)
5192
+ const js = (js0.endsWith(';') ? js0 : js0 + ';') + ' '
5193
+ const tjs = (tjs0.endsWith(';') ? tjs0 : tjs0 + ';') + ' '
5194
+ segments.push(tjs !== js ? { kind: 'js', text: js, templateText: tjs } : { kind: 'js', text: js })
5195
+ continue
5196
+ }
5197
+
5198
+ const pieces = reconstructAsSegments(stmt, ctx.sourceFile, ctx.analyzer.typeExcludeRanges, leafSpans)
5199
+ for (const piece of pieces) {
5200
+ if ('marker' in piece) {
5201
+ segments.push({ kind: 'jsx', ir: leafIrs[piece.marker] })
5202
+ } else {
5203
+ // Per-segment template variant: the destructured-prop rewrite is a text
5204
+ // transform; leaf refusal above guarantees segment boundaries never
5205
+ // split a string/template literal's lexical state.
5206
+ const tpl = rewriteBarePropRefs(piece.js, stmt, ctx)
5207
+ segments.push(tpl !== undefined && tpl !== piece.js
5208
+ ? { kind: 'js', text: piece.js, templateText: tpl }
5209
+ : { kind: 'js', text: piece.js })
5210
+ }
5211
+ }
5212
+ // Statement separator + semicolon normalization (the raw text may omit `;`).
5213
+ const sep = raw0.endsWith(';') ? ' ' : '; '
5214
+ segments.push({ kind: 'js', text: sep })
5215
+ }
5216
+
5217
+ return {
5218
+ preamble: {
5219
+ segments: trimPreambleSegments(segments),
5220
+ ssrText: tsxSourceText(typedParts.join(' ')),
5221
+ declaredNames: [...declared],
5222
+ builderNames: [...builders],
5223
+ },
5224
+ refusalNode,
4333
5225
  }
4334
5226
  }
4335
5227