@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/analyzer.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  import ts from 'typescript'
10
10
  import type { ImportSpecifier, TypeInfo, ParamInfo, ReactiveFactoryInfo, DeclinedReactiveFactory, RequiredFactoryImport, FactoryRenameSite, SourceLocation } from './types.ts'
11
11
  import { parseExpression, parseBlockBodyTolerant, foldBlockToExpr } from './expression-parser.ts'
12
+ import type { CallbackBodyAcceptor } from './adapters/interface.ts'
12
13
  import { rewriteBarePropRefs } from './prop-rewrite.ts'
13
14
  import { incrementCounter } from './instrumentation.ts'
14
15
  import {
@@ -158,7 +159,8 @@ export function analyzeComponent(
158
159
  source: string,
159
160
  filePath: string,
160
161
  targetComponentName?: string,
161
- program?: ts.Program
162
+ program?: ts.Program,
163
+ acceptsCallbackBody?: CallbackBodyAcceptor
162
164
  ): AnalyzerContext {
163
165
  incrementCounter('filesAnalyzed')
164
166
  // Track whether the caller supplied a shared ts.Program. Used downstream
@@ -229,7 +231,7 @@ export function analyzeComponent(
229
231
  }
230
232
  }
231
233
 
232
- const ctx = createAnalyzerContext(sourceFile, filePath)
234
+ const ctx = createAnalyzerContext(sourceFile, filePath, acceptsCallbackBody)
233
235
  ctx.checker = checker
234
236
 
235
237
  // Reactive-factory prescan results (#931, #2325 cross-file + object-
@@ -2077,18 +2079,77 @@ function extractSingleJsxReturn(
2077
2079
  return jsxReturn
2078
2080
  }
2079
2081
 
2080
- type JsxReturnNode = ts.JsxElement | ts.JsxSelfClosingElement | ts.JsxFragment
2082
+ export type JsxReturnNode = ts.JsxElement | ts.JsxSelfClosingElement | ts.JsxFragment
2083
+
2084
+ /**
2085
+ * The branch structure extracted from a multi-return JSX body — an if/else-if
2086
+ * chain or a `switch`. Shared by the helper-function fold and (Stage 2 of
2087
+ * `spec/callback-fidelity.md`) the `.map()` callback-body fold.
2088
+ */
2089
+ export interface MultiReturnJsxBranches {
2090
+ branches: Array<{
2091
+ condition: ts.Expression
2092
+ jsxReturn: JsxReturnNode | null
2093
+ /**
2094
+ * For a `switch` with fallthrough (`case 'a': case 'b': return X`), the
2095
+ * additional case expressions that share this branch's return. The fold
2096
+ * OR-joins them with `condition` into `disc === a || disc === b`. Only set
2097
+ * on switch-sourced branches.
2098
+ */
2099
+ extraCaseConditions?: ts.Expression[]
2100
+ }>
2101
+ fallback: JsxReturnNode | null
2102
+ switchDiscriminant?: ts.Expression
2103
+ /**
2104
+ * Leading `const` / `let` declarations before the branches, collected only
2105
+ * when the caller passes `allowPreamble` (the `.map()` body fold — Stage 2 of
2106
+ * `spec/callback-fidelity.md`). A JS-runtime adapter folds them into a
2107
+ * per-iteration preamble; a DSL adapter can't carry a loop-local into a
2108
+ * conditional branch template, so the `.map()` lowering refuses (BF021 +
2109
+ * `/* @client *\/`) rather than render wrong output. Empty for the
2110
+ * helper-function inliner, which passes no `allowPreamble` and still bails on
2111
+ * any variable statement.
2112
+ */
2113
+ preamble?: ts.VariableStatement[]
2114
+ }
2115
+
2116
+ /**
2117
+ * A leading-`const` preamble is only safe to fold when every branch (and the
2118
+ * fallback) returns real JSX. If any returns `null`, the loop item can be null
2119
+ * and the compiler routes the loop through the anchored conditional-item
2120
+ * runtime (`mapArrayAnchored`), where a preamble local computed before the
2121
+ * conditional interacts badly with the per-item hydration anchor — observed as
2122
+ * a keyed-reactivity break on a real component (the pivot-table demo, whose
2123
+ * `.map()` body is `const f = …; if (!f) return null; return <div key={fid}>`
2124
+ * and which carries a workaround note about exactly this compiler behavior).
2125
+ * When unsafe, bail so the body falls through to the single-return path
2126
+ * unchanged. Stage 2 of `spec/callback-fidelity.md`.
2127
+ */
2128
+ function preambleUnsafe(
2129
+ preamble: ts.VariableStatement[],
2130
+ branches: Array<{ jsxReturn: JsxReturnNode | null }>,
2131
+ fallback: JsxReturnNode | null,
2132
+ ): boolean {
2133
+ if (preamble.length === 0) return false
2134
+ return fallback === null || branches.some(b => b.jsxReturn === null)
2135
+ }
2081
2136
 
2082
2137
  /**
2083
2138
  * Extract conditional branches from a multi-return JSX helper function body.
2084
2139
  * Supports if/else if chains and switch statements where every branch
2085
2140
  * returns JSX or null. Returns null for unsupported patterns.
2086
2141
  */
2087
- function extractMultiReturnJsxBranches(
2088
- body: ts.Block
2089
- ): { branches: Array<{ condition: ts.Expression; jsxReturn: JsxReturnNode | null }>; fallback: JsxReturnNode | null; switchDiscriminant?: ts.Expression } | null {
2090
- const branches: Array<{ condition: ts.Expression; jsxReturn: JsxReturnNode | null }> = []
2142
+ export function extractMultiReturnJsxBranches(
2143
+ body: ts.Block,
2144
+ allowPreamble = false,
2145
+ ): MultiReturnJsxBranches | null {
2146
+ const branches: Array<{
2147
+ condition: ts.Expression
2148
+ jsxReturn: JsxReturnNode | null
2149
+ extraCaseConditions?: ts.Expression[]
2150
+ }> = []
2091
2151
  let fallback: JsxReturnNode | null = null
2152
+ const preamble: ts.VariableStatement[] = []
2092
2153
 
2093
2154
  const stmts = body.statements
2094
2155
  for (let i = 0; i < stmts.length; i++) {
@@ -2125,7 +2186,8 @@ function extractMultiReturnJsxBranches(
2125
2186
  return null
2126
2187
  }
2127
2188
  if (branches.length === 0) return null
2128
- return { branches, fallback }
2189
+ if (preambleUnsafe(preamble, branches, fallback)) return null
2190
+ return { branches, fallback, preamble }
2129
2191
  }
2130
2192
  break
2131
2193
  }
@@ -2151,23 +2213,49 @@ function extractMultiReturnJsxBranches(
2151
2213
  const hasDefault = stmt.caseBlock.clauses.some(c => ts.isDefaultClause(c))
2152
2214
  if (!hasDefault) return null
2153
2215
 
2216
+ // Case labels that fall through to the next clause's body — an empty
2217
+ // `case 'a':` immediately before `case 'b': return X` — share that
2218
+ // return. Accumulate their expressions and attach them to the branch of
2219
+ // the clause that finally carries the body. Stage 2 of
2220
+ // spec/callback-fidelity.md.
2221
+ let pendingCases: ts.Expression[] = []
2154
2222
  for (const clause of stmt.caseBlock.clauses) {
2223
+ if (ts.isCaseClause(clause) && clause.statements.length === 0) {
2224
+ pendingCases.push(clause.expression)
2225
+ continue
2226
+ }
2227
+
2155
2228
  const jsxReturn = findJsxReturnInCaseClause(clause)
2156
2229
  const nullReturn = findNullReturnInCaseClause(clause)
2157
2230
  if (!jsxReturn && !nullReturn) return null
2231
+ // Reject clauses that carry extra statements around the return (a
2232
+ // local `const`, an expression statement, …): folding drops them, the
2233
+ // same branch-local-preamble gap as `isDirectReturnBlock`. A bare
2234
+ // `break` is fine. (#2377 review.)
2235
+ if (!caseClauseIsDirectReturn(clause)) return null
2158
2236
 
2159
2237
  if (ts.isCaseClause(clause)) {
2160
2238
  branches.push({
2161
2239
  condition: clause.expression,
2162
2240
  jsxReturn: jsxReturn ?? null,
2241
+ extraCaseConditions: pendingCases.length > 0 ? pendingCases : undefined,
2163
2242
  })
2243
+ pendingCases = []
2164
2244
  } else {
2245
+ // `default`. A fallthrough INTO default (`case 'a': default:`) would
2246
+ // need the accumulated cases to also hit the default body — an
2247
+ // unusual shape; bail rather than guess.
2248
+ if (pendingCases.length > 0) return null
2165
2249
  fallback = jsxReturn ?? null
2166
2250
  }
2167
2251
  }
2252
+ // Trailing empty case labels with no following body (`case 'z':` last,
2253
+ // no return anywhere after) — nothing to attach them to; bail.
2254
+ if (pendingCases.length > 0) return null
2168
2255
 
2169
2256
  if (branches.length === 0) return null
2170
- return { branches, fallback, switchDiscriminant: stmt.expression }
2257
+ if (preambleUnsafe(preamble, branches, fallback)) return null
2258
+ return { branches, fallback, switchDiscriminant: stmt.expression, preamble }
2171
2259
  }
2172
2260
 
2173
2261
  // Trailing return <jsx> or return null — this is the fallback
@@ -2183,16 +2271,36 @@ function extractMultiReturnJsxBranches(
2183
2271
  continue
2184
2272
  }
2185
2273
 
2186
- // Reject bodies with variable declarationslocals referenced in
2187
- // conditions or JSX would become undefined after inlining.
2188
- if (ts.isVariableStatement(stmt)) return null
2274
+ // Leading `const`/`let` before any branchcollected as a preamble when
2275
+ // the caller allows it (the `.map()` body fold; Stage 2 of
2276
+ // spec/callback-fidelity.md). The JS-runtime fold emits it once per
2277
+ // iteration ahead of the conditional; a DSL target refuses (BF021 +
2278
+ // `/* @client */`) since it can't carry the loop-local into a branch
2279
+ // template. Without `allowPreamble`, or once branches have started, reject:
2280
+ // the helper-function inliner has no place to emit it, and a mid-chain
2281
+ // local referenced in a later condition/JSX would become undefined.
2282
+ if (ts.isVariableStatement(stmt)) {
2283
+ // Only `const` / `let` are collectable — a `var` hoists to the enclosing
2284
+ // function scope with different (redeclaration / TDZ-free) semantics that
2285
+ // the per-iteration preamble emit wouldn't preserve, so reject it.
2286
+ // (#2379 review.)
2287
+ const declFlags = stmt.declarationList.flags
2288
+ const isConstOrLet =
2289
+ (declFlags & ts.NodeFlags.Const) !== 0 || (declFlags & ts.NodeFlags.Let) !== 0
2290
+ if (allowPreamble && isConstOrLet && branches.length === 0 && fallback === null) {
2291
+ preamble.push(stmt)
2292
+ continue
2293
+ }
2294
+ return null
2295
+ }
2189
2296
 
2190
2297
  // Any other statement type → unsupported pattern
2191
2298
  return null
2192
2299
  }
2193
2300
 
2194
2301
  if (branches.length === 0) return null
2195
- return { branches, fallback }
2302
+ if (preambleUnsafe(preamble, branches, fallback)) return null
2303
+ return { branches, fallback, preamble }
2196
2304
  }
2197
2305
 
2198
2306
  /**
@@ -2208,18 +2316,17 @@ function isDirectReturnBlock(node: ts.Statement): boolean {
2208
2316
  for (const stmt of node.statements) {
2209
2317
  if (ts.isReturnStatement(stmt)) {
2210
2318
  returnCount++
2211
- } else if (
2212
- ts.isIfStatement(stmt) ||
2213
- ts.isSwitchStatement(stmt) ||
2214
- ts.isForStatement(stmt) ||
2215
- ts.isForOfStatement(stmt) ||
2216
- ts.isForInStatement(stmt) ||
2217
- ts.isWhileStatement(stmt) ||
2218
- ts.isDoStatement(stmt) ||
2219
- ts.isTryStatement(stmt)
2220
- ) {
2221
- return false
2319
+ continue
2222
2320
  }
2321
+ // Any non-return statement — a local `const`/`let`, an expression
2322
+ // statement, or nested control flow (if/switch/for/while/try) — means
2323
+ // this is not a *direct* return block. Folding it into an
2324
+ // `IRConditional` branch would silently DROP the statement: the neutral
2325
+ // IR has no per-branch carrier for a local, so a branch-local
2326
+ // `const x = …; return <A>{x}</A>` renders `x` undefined (ReferenceError
2327
+ // at SSR/hydration). Bail so the caller falls back conservatively.
2328
+ // (#2377 review; Stage 2 of spec/callback-fidelity.md.)
2329
+ return false
2223
2330
  }
2224
2331
  return returnCount === 1
2225
2332
  }
@@ -2253,6 +2360,37 @@ function findJsxReturnInCaseClause(
2253
2360
  return null
2254
2361
  }
2255
2362
 
2363
+ /**
2364
+ * True when a `switch` case/default clause contains exactly one `return` and
2365
+ * nothing else that would be dropped by the fold (a trailing `break` is
2366
+ * allowed). Rejects clauses with a local `const`/`let`, an expression
2367
+ * statement, or nested control flow — those have no per-branch preamble
2368
+ * carrier in the folded `IRConditional`. (#2377 review; Stage 2 of
2369
+ * spec/callback-fidelity.md.)
2370
+ */
2371
+ function caseClauseIsDirectReturn(clause: ts.CaseClause | ts.DefaultClause): boolean {
2372
+ let returnCount = 0
2373
+ let seenReturn = false
2374
+ for (const stmt of clause.statements) {
2375
+ if (ts.isReturnStatement(stmt)) {
2376
+ returnCount++
2377
+ seenReturn = true
2378
+ continue
2379
+ }
2380
+ if (ts.isBreakStatement(stmt)) {
2381
+ // A `break` BEFORE the return makes the return unreachable — at runtime
2382
+ // the case exits and the callback falls through to `undefined`, so
2383
+ // folding it as if it returned JSX would render the wrong branch. Only a
2384
+ // trailing break (after the return; dead but harmless) is allowed.
2385
+ // (#2378 review.)
2386
+ if (!seenReturn) return false
2387
+ continue
2388
+ }
2389
+ return false
2390
+ }
2391
+ return returnCount === 1
2392
+ }
2393
+
2256
2394
  function findNullReturnInCaseClause(
2257
2395
  clause: ts.CaseClause | ts.DefaultClause
2258
2396
  ): boolean {
package/src/compiler.ts CHANGED
@@ -119,7 +119,7 @@ function compileMultipleComponents(
119
119
  const program = options.program ?? (needsTypeBasedDetection(source) ? createProgramForFile(source, filePath)?.program : undefined)
120
120
 
121
121
  for (const componentName of componentNames) {
122
- const ctx = analyzeComponent(source, filePath, componentName, program)
122
+ const ctx = analyzeComponent(source, filePath, componentName, program, adapter.acceptsCallbackBody)
123
123
 
124
124
  if (!ctx.jsxReturn) {
125
125
  errors.push(...ctx.errors)
@@ -556,7 +556,7 @@ export function compileJSX(
556
556
  }
557
557
 
558
558
  // Single component flow
559
- const ctx = analyzeComponent(compileSource, filePath, undefined, options.program)
559
+ const ctx = analyzeComponent(compileSource, filePath, undefined, options.program, options.adapter.acceptsCallbackBody)
560
560
 
561
561
  if (!ctx.jsxReturn) {
562
562
  errors.push(...ctx.errors)
@@ -364,25 +364,22 @@ export interface SupportResult {
364
364
  // evaluate JS at runtime via hono/jsx) so this set only constrains
365
365
  // the template-language adapters.
366
366
  const UNSUPPORTED_METHODS = new Set([
367
- // Higher-order array methods. Seven of these (`filter`, `every`,
368
- // `some`, `find`, `findIndex`, `findLast`, `findLastIndex`) are
369
- // intercepted as `higher-order` IR before reaching this gate.
370
- // `map` is intercepted as an IRLoop when its callback returns JSX,
371
- // and as a `CALLBACK_METHODS` evaluator lowering (`map_eval`, #2073)
372
- // when it returns a value it stays listed here so the fall-throughs
373
- // (a bare `arr.map` reference, a function-reference callback) still
374
- // refuse loudly. `reduce` / `reduceRight` stay
375
- // listed here so the shapes the Tier C catalogue can't lower still
376
- // refuse loudly: the `convertNode` call branch intercepts a matching
377
- // `.reduce(fn, init)` / `.reduceRight(fn, init)` into the structured
378
- // `array-method` + `ReduceOp` form *before* this gate (returning
379
- // early), so only the unlowerable fall-throughs (a `.reduce(fn)` with
380
- // no initial value, or a bare method reference) reach the gate and
381
- // refuse. A 2-arg call whose reducer/init shape is off-catalogue
382
- // returns an explicit `unsupported` from the call branch with a richer
383
- // message. The rest stay refused — see #1448 Tier C for the design
384
- // questions. `forEach` carries a tailored reason (see
385
- // `UNSUPPORTED_METHOD_REASONS`).
367
+ // Higher-order array methods. `filter`, `every`, `some`, `find`,
368
+ // `findIndex`, `findLast`, `findLastIndex`, `reduce`, `reduceRight`
369
+ // and `flatMap` *with an arrow-callback argument* are recognized by
370
+ // `asCallbackMethodCall` and lower via the runtime evaluator
371
+ // (`serializeParsedExpr`) in the `call` arm ABOVE this gate — an
372
+ // off-subset callback body refuses there with a "complex callback"
373
+ // reason, not here. They stay listed in this set so the shapes that
374
+ // recognition can't catch a BARE method reference (`arr.filter`
375
+ // uncalled) or a function-reference callback (no arrow) still refuse
376
+ // loudly. (`reduce` / `reduceRight` are NOT folded into a structured
377
+ // `array-method` / `ReduceOp` form: #2018 P5 leaves them as a generic
378
+ // `call` whose reducer body + initial value serialize to the
379
+ // evaluator.) `map` is intercepted as an IRLoop when its callback
380
+ // returns JSX, and as a `CALLBACK_METHODS` evaluator lowering
381
+ // (`map_eval`, #2073) when it returns a value. `forEach` carries a
382
+ // tailored reason (see `UNSUPPORTED_METHOD_REASONS`).
386
383
  // `flat` is no longer here — `.flat(depth?)` lowers via the
387
384
  // `array-method` IR (structured `FlatDepth`) + `bf_flat` (Go) /
388
385
  // `bf->flat` (Mojo). `flatMap` stays listed as a fallback: the
@@ -395,6 +392,17 @@ const UNSUPPORTED_METHODS = new Set([
395
392
  // `IRLoop` upstream. See #1448.
396
393
  'filter', 'map', 'reduce', 'reduceRight', 'every', 'some',
397
394
  'forEach', 'flatMap',
395
+ // `fill` has no template lowering on any DSL adapter (it mutates the
396
+ // receiver in place and returns it — no ParsedExpr / evaluator form).
397
+ // Before it was listed here `isSupported` reported it "supported" and
398
+ // the adapters emitted a raw `.fill(...)` method call with no build
399
+ // diagnostic — the same silent footgun the string methods below
400
+ // documented before they were gated: it only surfaced as a crash at
401
+ // template-render time. Listing it makes the build fail loudly with
402
+ // BF101, pointing users at the `/* @client */` escape (a JS-runtime
403
+ // adapter still runs it verbatim, since those skip `isSupported`).
404
+ // See spec/callback-fidelity.md.
405
+ 'fill',
398
406
  // #1448 Tier A — Array methods. Each method PR adds the lowering
399
407
  // (typically a new `array-method` variant or runtime helper) and
400
408
  // removes its row here. See packages/adapter-tests/fixtures/methods/.
@@ -35,6 +35,7 @@ import type {
35
35
  ReferenceSource,
36
36
  ReferencesGraph,
37
37
  } from '../types.ts'
38
+ import { preambleAnalysisText } from '../types.ts'
38
39
  import type { IRVisitor as WalkerVisitor } from './walker.ts'
39
40
  import { attrValueToString } from './utils.ts'
40
41
  import type { ClientJsContext } from './types.ts'
@@ -151,7 +152,14 @@ export function buildReferencesGraph(ctx: ClientJsContext, irRoot: IRNode): Refe
151
152
  }
152
153
  if (elem.filterPredicate) addExprEdges(ROOT_SOURCE, elem.filterPredicate.raw, 'template-closure')
153
154
  if (elem.sortComparator) addExprEdges(ROOT_SOURCE, elem.sortComparator.raw, 'template-closure')
154
- if (elem.mapPreamble) addExprEdges(ROOT_SOURCE, elem.mapPreamble, 'template-closure')
155
+ if (elem.preamble) addExprEdges(ROOT_SOURCE, preambleAnalysisText(elem.preamble), 'template-closure')
156
+ // flatMap descriptor bodies (`elem.flatMapClient`) are NOT traced here:
157
+ // the rendered body embeds leaf HTML template literals, and the regex
158
+ // identifier extractor would turn tag/attr names into false-positive
159
+ // edges. The Phase 3 loop visitor traces the same content structurally
160
+ // (js segments via `preambleAnalysisText`, leaf attrs/keys/interpolations
161
+ // via `walkIR` on `flatMapCallback` segments) — that's what keeps
162
+ // `const maxTags = _p.maxTags` in the extracted props.
155
163
  for (const attr of elem.bindings.reactiveAttrs) {
156
164
  addExprEdges(ROOT_SOURCE, attr.expression, 'template-closure')
157
165
  }
@@ -316,7 +324,16 @@ export function buildReferencesGraph(ctx: ClientJsContext, irRoot: IRNode): Refe
316
324
  addExprEdges(ROOT_SOURCE, l.array, 'template-closure')
317
325
  if (l.filterPredicate) addExprEdges(ROOT_SOURCE, l.filterPredicate.raw, 'template-closure')
318
326
  if (l.sortComparator) addExprEdges(ROOT_SOURCE, l.sortComparator.raw, 'template-closure')
319
- if (l.mapPreamble) addExprEdges(ROOT_SOURCE, l.mapPreamble, 'template-closure')
327
+ if (l.preamble) addExprEdges(ROOT_SOURCE, preambleAnalysisText(l.preamble), 'template-closure')
328
+ // flatMap bodies: js segments reference init-scope names; segment
329
+ // leaves are IR nodes off `children`, so `descend()` never reaches
330
+ // them — walk them explicitly (mirrors attachParsedExpressions).
331
+ if (l.flatMapCallback) {
332
+ addExprEdges(ROOT_SOURCE, preambleAnalysisText(l.flatMapCallback), 'template-closure')
333
+ for (const seg of l.flatMapCallback.segments) {
334
+ if (seg.kind === 'jsx') walkIR(seg.ir, null, visitor)
335
+ }
336
+ }
320
337
  descend()
321
338
  if (l.childComponent) walkChildComponent(l.childComponent)
322
339
  if (l.nestedComponents) {
@@ -6,7 +6,7 @@ import { type IRNode, type IRElement, type IRComponent, type IRLoop, type IRProp
6
6
  import type { ClientJsContext, ConditionalBranchChildComponent, ConditionalBranchReactiveAttr, BranchLoop, ConditionalBranchTextEffect, ConditionalElement, LoopChildBindings, LoopChildBranchSummary, LoopChildConditional, LoopOffset, NestedLoop } from './types.ts'
7
7
  import { attrValueToString, freeIdsFromRefs, quotePropName, PROPS_PARAM } from './utils.ts'
8
8
  import { classifyReactivity, decideWrapForAttr, decideWrapForChildProp, decideWrapFromAstFlags, collectEventHandlersFromIR, collectConditionalBranchEvents, collectConditionalBranchRefs, collectConditionalBranchChildComponents, collectLoopChildEventsWithNesting, collectLoopChildReactiveAttrs, collectLoopChildReactiveTexts, collectLoopChildRefs, emptyLoopChildBindings } from './reactivity.ts'
9
- import { irToHtmlTemplate, irToPlaceholderTemplate, irChildrenToJsExpr, buildLoopSkeletonTemplate, computeSkeletonSlotPaths, type SkeletonSlotPaths } from './html-template.ts'
9
+ import { irToHtmlTemplate, irToPlaceholderTemplate, irChildrenToJsExpr, buildLoopSkeletonTemplate, computeSkeletonSlotPaths, renderFlatMapClientBody, renderFlatMapProjectionClientBody, flatMapCallbackHasKeyedLeaf, type SkeletonSlotPaths } from './html-template.ts'
10
10
  import { templateRootIsSvg } from './control-flow/stringify/template-parse.ts'
11
11
  import { expandDynamicPropValue, expandConstantForReactivity } from './prop-handling.ts'
12
12
  import { walkIR, stopAt } from './walker.ts'
@@ -393,7 +393,7 @@ export function collectInnerLoops(
393
393
  objectIteration: n.objectIteration,
394
394
  containerSlotId: scope.parentSlotId,
395
395
  template,
396
- mapPreamble: n.mapPreamble,
396
+ preamble: n.preamble,
397
397
  refsOuterParam: refsOuter,
398
398
  childComponents,
399
399
  insideConditional: !flat && scope.insideCond ? true : undefined,
@@ -650,10 +650,26 @@ export function collectElements(
650
650
  // are extracted explicitly below for the closure capture set.
651
651
  if (!l.slotId || inCond) return
652
652
 
653
+ // flatMap PROJECTION loop: the only child is a nested IRLoop lowered
654
+ // from `flatMap(it => it.tags.map(...))`. The children exist for SSR
655
+ // templatization (nested {{range}} on DSL adapters); the CLIENT
656
+ // reconciles the flattened leaves through the descriptor mapArray
657
+ // path synthesized from the same inner loop, so none of the
658
+ // children-derived client machinery (bindings, inner-loop plans,
659
+ // per-row template) applies — leaf wiring was refused at IR build.
660
+ const projectionInner =
661
+ l.method === 'flatMap' && l.children.length === 1 && l.children[0].type === 'loop'
662
+ ? l.children[0]
663
+ : undefined
664
+
653
665
  const childHandlers: string[] = []
654
- const bindings = collectLoopChildBindings(l.children, ctx, siblingOffsets, l.param, l.paramBindings)
655
- for (const child of l.children) {
656
- childHandlers.push(...collectEventHandlersFromIR(child))
666
+ const bindings = projectionInner
667
+ ? emptyLoopChildBindings()
668
+ : collectLoopChildBindings(l.children, ctx, siblingOffsets, l.param, l.paramBindings)
669
+ if (!projectionInner) {
670
+ for (const child of l.children) {
671
+ childHandlers.push(...collectEventHandlersFromIR(child))
672
+ }
657
673
  }
658
674
 
659
675
  if (l.childComponent) {
@@ -668,7 +684,9 @@ export function collectElements(
668
684
  // Determine rendering strategy for dynamic arrays:
669
685
  // Use element reconciliation when the loop body has nested components,
670
686
  // or when inner loops need their own mapArray for events/reactive text.
671
- const { useElementReconciliation, innerLoops } = decideLoopRendering(l, siblingOffsets, ctx)
687
+ const { useElementReconciliation, innerLoops } = projectionInner
688
+ ? { useElementReconciliation: false, innerLoops: undefined }
689
+ : decideLoopRendering(l, siblingOffsets, ctx)
672
690
 
673
691
  let template = ''
674
692
  let staticItemTemplate: string | undefined
@@ -691,7 +709,7 @@ export function collectElements(
691
709
  // `data-key`, mirroring the SSR template's renderChild emit.
692
710
  staticItemTemplate = irToHtmlTemplate(l.children[0], buildRestSpreadNames(ctx), 0, undefined, undefined, /* insideLoop */ true)
693
711
  }
694
- } else if (l.children[0]) {
712
+ } else if (l.children[0] && !projectionInner) {
695
713
  // Pass loopParams so expressions are wrapped at generation time,
696
714
  // avoiding post-hoc regex wrapping that corrupts literal attribute values.
697
715
  // Forward destructured bindings (#951) so references like `cfg.color`
@@ -776,7 +794,21 @@ export function collectElements(
776
794
  raw: l.sortComparator.raw,
777
795
  } : undefined,
778
796
  chainOrder: l.chainOrder,
779
- mapPreamble: l.mapPreamble,
797
+ preamble: l.preamble,
798
+ preambleRegions: l.preambleRegions,
799
+ flatMapClient: projectionInner
800
+ ? {
801
+ params: l.index ? `(${l.param}, ${l.index})` : `(${l.param})`,
802
+ body: renderFlatMapProjectionClientBody(projectionInner, buildRestSpreadNames(ctx)),
803
+ keyed: projectionInner.key !== null,
804
+ }
805
+ : l.flatMapCallback
806
+ ? {
807
+ params: l.flatMapCallback.params,
808
+ body: renderFlatMapClientBody(l.flatMapCallback, buildRestSpreadNames(ctx)),
809
+ keyed: flatMapCallbackHasKeyedLeaf(l.flatMapCallback),
810
+ }
811
+ : undefined,
780
812
  })
781
813
  // Don't descend — loop-scoped variables are only available inside the iteration.
782
814
  },
@@ -1069,8 +1101,16 @@ function collectBranchLoops(
1069
1101
  // reactive-text collection on inner loops reached from this call path
1070
1102
  // is handled at the enclosing branch-loop level below (`if (ctx)` block
1071
1103
  // around `collectLoopChildReactiveTexts`), not per inner loop.
1072
- const { useElementReconciliation, innerLoops: innerLoopsCollected } =
1073
- decideLoopRendering(n, siblingOffsets, undefined)
1104
+ // flatMap PROJECTION loop see the top-level handler's note: children
1105
+ // are SSR-only; the client rides the descriptor mapArray path.
1106
+ const projectionInner =
1107
+ n.method === 'flatMap' && n.children.length === 1 && n.children[0].type === 'loop'
1108
+ ? n.children[0]
1109
+ : undefined
1110
+
1111
+ const { useElementReconciliation, innerLoops: innerLoopsCollected } = projectionInner
1112
+ ? { useElementReconciliation: false, innerLoops: undefined }
1113
+ : decideLoopRendering(n, siblingOffsets, undefined)
1074
1114
 
1075
1115
  // Build the item template from loop children.
1076
1116
  // Use loopDepth=0: this loop gets its own reconcileElements (independent
@@ -1080,7 +1120,9 @@ function collectBranchLoops(
1080
1120
  // use `param()` to read the current item value.
1081
1121
  let childTemplate: string
1082
1122
  const branchLoopParamSpec = [{ param: n.param, bindings: n.paramBindings }]
1083
- if (useElementReconciliation && n.children[0]) {
1123
+ if (projectionInner) {
1124
+ childTemplate = '' // descriptor renderItem builds from d.h, not a row template
1125
+ } else if (useElementReconciliation && n.children[0]) {
1084
1126
  childTemplate = irToPlaceholderTemplate(n.children[0], restNames, 0, branchLoopParamSpec)
1085
1127
  } else {
1086
1128
  childTemplate = n.children.map(c => irToHtmlTemplate(c, undefined, 0, branchLoopParamSpec)).join('')
@@ -1092,7 +1134,7 @@ function collectBranchLoops(
1092
1134
  // memos). Previously these were only collected for composite loops,
1093
1135
  // which caused reactive reads inside simple loop bodies to silently
1094
1136
  // no-op for existing items.
1095
- const branchBindings = ctx
1137
+ const branchBindings = ctx && !projectionInner
1096
1138
  ? collectLoopChildBindings(n.children, ctx, siblingOffsets, n.param, n.paramBindings)
1097
1139
  : emptyLoopChildBindings()
1098
1140
 
@@ -1111,7 +1153,8 @@ function collectBranchLoops(
1111
1153
  objectIteration: n.objectIteration,
1112
1154
  template: childTemplate,
1113
1155
  containerSlotId: containerSlot,
1114
- mapPreamble: n.mapPreamble ?? null,
1156
+ preamble: n.preamble,
1157
+ preambleRegions: n.preambleRegions,
1115
1158
  nestedComponents: useElementReconciliation ? n.nestedComponents : undefined,
1116
1159
  bindings: branchBindings,
1117
1160
  innerLoops: useElementReconciliation ? innerLoopsCollected : undefined,
@@ -1126,6 +1169,19 @@ function collectBranchLoops(
1126
1169
  raw: n.sortComparator.raw,
1127
1170
  } : undefined,
1128
1171
  chainOrder: n.chainOrder,
1172
+ flatMapClient: projectionInner
1173
+ ? {
1174
+ params: n.index ? `(${n.param}, ${n.index})` : `(${n.param})`,
1175
+ body: renderFlatMapProjectionClientBody(projectionInner, restNames),
1176
+ keyed: projectionInner.key !== null,
1177
+ }
1178
+ : n.flatMapCallback
1179
+ ? {
1180
+ params: n.flatMapCallback.params,
1181
+ body: renderFlatMapClientBody(n.flatMapCallback, restNames),
1182
+ keyed: flatMapCallbackHasKeyedLeaf(n.flatMapCallback),
1183
+ }
1184
+ : undefined,
1129
1185
  })
1130
1186
  // Don't recurse into the loop — nested loops are handled by the loop's own reconciliation.
1131
1187
  },
@@ -15,12 +15,20 @@
15
15
  * pair.
16
16
  */
17
17
 
18
- import type { CompositeLoopPlan, LoopChildRefBinding } from './loop.ts'
18
+ import type { CompositeLoopPlan, LoopChildRefBinding, PreambleRegionPlan } from './loop.ts'
19
19
  import type { EventDelegationPlan } from './event-delegation.ts'
20
20
  import type { ReactiveEffectsPlan } from './reactive-effects.ts'
21
21
 
22
22
  export interface BranchPlainLoopPlan {
23
+ /**
24
+ * flatMap descriptor mode — see `PlainLoopVariant.flatMapLeafItem`
25
+ * (plan/loop.ts): `arrayExpr` already flattens through the descriptor
26
+ * body and the stringifier emits the build-or-patch renderItem.
27
+ */
28
+ flatMapLeafItem?: boolean
23
29
  kind: 'plain'
30
+ /** See LoopPlanCommon.rowConstruction — required on every variant. */
31
+ rowConstruction: 'string-template' | 'dom-ops'
24
32
  /**
25
33
  * Variable suffix for the container lookup — e.g. for slotId `s4` the
26
34
  * stringifier emits `__loop_s4` and queries via `$(__branchScope, 's4')`.
@@ -76,6 +84,11 @@ export interface BranchPlainLoopPlan {
76
84
  * Undefined off → byte-identical (SR8).
77
85
  */
78
86
  profileLoopId?: string
87
+ /**
88
+ * Preamble-patched regions (#2389) — non-empty forces the multi-line
89
+ * renderItem layout, mirroring the top-level plain plan.
90
+ */
91
+ preambleRegions: readonly PreambleRegionPlan[]
79
92
  }
80
93
 
81
94
  export interface BranchCompositeLoopPlan {
@@ -15,7 +15,8 @@ import { buildChainedArrayExpr, varSlotId, wrapLoopParamAsAccessor } from '../..
15
15
  import { buildBranchCompositePlan } from './build-composite-loop.ts'
16
16
  import { buildBranchLoopDelegationPlan } from './build-event-delegation.ts'
17
17
  import { buildReactiveEffectsPlan } from './build-reactive-effects.ts'
18
- import { destructureLoopParam, loopKeyFn, buildChildRefBindings } from '../shared.ts'
18
+ import { destructureLoopParam, loopKeyFn, buildChildRefBindings, buildPreambleRegionPlans } from '../shared.ts'
19
+ import { renderPreamble, irToHtmlTemplate } from '../../html-template.ts'
19
20
  import type {
20
21
  BranchCompositeLoopPlan,
21
22
  BranchLoopPlan,
@@ -42,20 +43,31 @@ export function buildBranchLoopPlan(loop: BranchLoop, profileComponentName?: str
42
43
  || loop.bindings.reactiveTexts.length > 0
43
44
  || loop.bindings.conditionals.length > 0
44
45
 
46
+ // flatMap descriptor mode — see buildPlainLoopPlan (build-loop.ts).
47
+ const fm = loop.flatMapClient
45
48
  const plan: BranchPlainLoopPlan = {
46
49
  kind: 'plain',
50
+ rowConstruction: 'string-template',
47
51
  containerSlotId,
48
52
  containerVar,
49
53
  markerId: loop.markerId,
50
- arrayExpr: buildChainedArrayExpr(loop),
51
- keyFn: loopKeyFn(loop),
54
+ flatMapLeafItem: fm ? true : undefined,
55
+ arrayExpr: fm
56
+ ? `(${buildChainedArrayExpr(loop)}).flatMap(${fm.params} => ${fm.body})`
57
+ : buildChainedArrayExpr(loop),
58
+ keyFn: fm
59
+ ? (fm.keyed ? '(__bfD, __bfI) => String(__bfD.k ?? __bfI)' : 'null')
60
+ : loopKeyFn(loop),
52
61
  paramHead,
53
62
  paramUnwrap,
54
63
  indexParam: loop.index || '__idx',
55
64
  // Wrap loop-param references to signal-accessor form so the preamble
56
65
  // matches the template literal's already-wrapped reads (#1065).
57
- mapPreambleWrapped: loop.mapPreamble
58
- ? wrapLoopParamAsAccessor(loop.mapPreamble, loop.param, loop.paramBindings)
66
+ mapPreambleWrapped: loop.preamble
67
+ ? renderPreamble(loop.preamble, {
68
+ transformJs: (t) => wrapLoopParamAsAccessor(t, loop.param, loop.paramBindings),
69
+ renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, [{ param: loop.param, bindings: loop.paramBindings }], undefined, true),
70
+ })
59
71
  : '',
60
72
  template: loop.template,
61
73
  reactiveEffects: hasReactiveEffects
@@ -70,6 +82,7 @@ export function buildBranchLoopPlan(loop: BranchLoop, profileComponentName?: str
70
82
  : null,
71
83
  eventDelegation: buildBranchLoopDelegationPlan(loop, cv, profileComponentName),
72
84
  childRefs: buildChildRefBindings(loop.bindings.refs, loop.param, loop.paramBindings),
85
+ preambleRegions: buildPreambleRegionPlans(loop.preambleRegions, loop.param, loop.paramBindings),
73
86
  bodyIsMultiRoot: loop.bodyIsMultiRoot ?? false,
74
87
  profileLoopId: profileComponentName ? `${profileComponentName}#binding:${containerSlotId}` : undefined,
75
88
  }