@barefootjs/jsx 0.26.2 → 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.
- package/dist/adapters/interface.d.ts +29 -0
- package/dist/adapters/interface.d.ts.map +1 -1
- package/dist/adapters/jsx-adapter.d.ts +9 -0
- package/dist/adapters/jsx-adapter.d.ts.map +1 -1
- package/dist/adapters/loop-bound-names.d.ts.map +1 -1
- package/dist/adapters/parsed-expr-emitter.d.ts +0 -10
- package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
- package/dist/adapters/test-adapter.d.ts.map +1 -1
- package/dist/analyzer-context.d.ts +11 -1
- package/dist/analyzer-context.d.ts.map +1 -1
- package/dist/analyzer.d.ts +40 -1
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/expression-parser.d.ts.map +1 -1
- package/dist/index.js +662 -223
- package/dist/ir-to-client-js/build-references.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts +2 -0
- package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-branch-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-component-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-composite-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-event-delegation.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +12 -0
- package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts +19 -1
- package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
- package/dist/ir-to-client-js/plan/build-static-array-child-init.d.ts.map +1 -1
- package/dist/ir-to-client-js/types.d.ts +4 -4
- package/dist/ir-to-client-js/types.d.ts.map +1 -1
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/loop-destructure.d.ts.map +1 -1
- package/dist/strip-types.d.ts +18 -0
- package/dist/strip-types.d.ts.map +1 -1
- package/dist/types.d.ts +96 -32
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +94 -0
- package/src/__tests__/client-js-generation.test.ts +10 -3
- package/src/__tests__/compiler-stress-1244.test.ts +12 -1
- package/src/__tests__/event-delegation-scope-2367.test.ts +108 -0
- package/src/__tests__/flatmap-segments.test.ts +80 -0
- package/src/__tests__/map-arbitrary-body.test.ts +226 -0
- package/src/__tests__/map-body-no-silent-divergence.test.ts +360 -0
- package/src/__tests__/map-multi-return-body.test.ts +172 -0
- package/src/__tests__/unsupported-expression.test.ts +20 -2
- package/src/adapters/interface.ts +40 -0
- package/src/adapters/jsx-adapter.ts +10 -0
- package/src/adapters/loop-bound-names.ts +6 -2
- package/src/adapters/parsed-expr-emitter.ts +5 -10
- package/src/adapters/test-adapter.ts +10 -0
- package/src/analyzer-context.ts +35 -1
- package/src/analyzer.ts +162 -24
- package/src/compiler.ts +2 -2
- package/src/expression-parser.ts +27 -19
- package/src/ir-to-client-js/build-references.ts +3 -2
- package/src/ir-to-client-js/collect-elements.ts +3 -3
- package/src/ir-to-client-js/control-flow/plan/branch-loop.ts +2 -0
- package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +7 -2
- package/src/ir-to-client-js/control-flow/plan/build-component-loop.ts +4 -0
- package/src/ir-to-client-js/control-flow/plan/build-composite-loop.ts +15 -2
- package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +16 -3
- package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +26 -3
- package/src/ir-to-client-js/control-flow/plan/build-loop.ts +17 -2
- package/src/ir-to-client-js/control-flow/plan/loop.ts +12 -0
- package/src/ir-to-client-js/control-flow/shared.ts +2 -1
- package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +9 -2
- package/src/ir-to-client-js/control-flow.ts +11 -0
- package/src/ir-to-client-js/html-template.ts +133 -37
- package/src/ir-to-client-js/plan/build-static-array-child-init.ts +22 -10
- package/src/ir-to-client-js/types.ts +4 -3
- package/src/jsx-to-ir.ts +690 -164
- package/src/loop-destructure.ts +9 -5
- package/src/rich-type-refusal.ts +6 -2
- package/src/strip-types.ts +47 -0
- 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
|
|
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
|
|
913
|
-
attachParsedExpressions(
|
|
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
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
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
|
-
//
|
|
2133
|
-
|
|
2134
|
-
|
|
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
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
}
|
|
2149
|
-
|
|
2150
|
-
|
|
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
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
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
|
-
|
|
3709
|
-
|
|
3710
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
4037
|
-
|
|
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
|
-
|
|
4051
|
-
|
|
4052
|
-
const
|
|
4053
|
-
|
|
4054
|
-
|
|
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
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
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 (
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
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 (
|
|
4072
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
4275
|
-
*
|
|
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
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
|
|
4288
|
-
const
|
|
4289
|
-
|
|
4290
|
-
|
|
4291
|
-
const
|
|
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
|
-
|
|
4295
|
-
|
|
4296
|
-
|
|
4297
|
-
|
|
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.
|
|
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
|
-
|
|
4308
|
-
|
|
4309
|
-
|
|
4310
|
-
|
|
4311
|
-
|
|
4312
|
-
|
|
4313
|
-
|
|
4314
|
-
|
|
4315
|
-
|
|
4316
|
-
|
|
4317
|
-
|
|
4318
|
-
|
|
4319
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4330
|
-
|
|
4331
|
-
|
|
4332
|
-
|
|
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
|
|