@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.
Files changed (80) hide show
  1. package/dist/adapters/interface.d.ts +29 -0
  2. package/dist/adapters/interface.d.ts.map +1 -1
  3. package/dist/adapters/jsx-adapter.d.ts +9 -0
  4. package/dist/adapters/jsx-adapter.d.ts.map +1 -1
  5. package/dist/adapters/loop-bound-names.d.ts.map +1 -1
  6. package/dist/adapters/parsed-expr-emitter.d.ts +0 -10
  7. package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
  8. package/dist/adapters/test-adapter.d.ts.map +1 -1
  9. package/dist/analyzer-context.d.ts +11 -1
  10. package/dist/analyzer-context.d.ts.map +1 -1
  11. package/dist/analyzer.d.ts +40 -1
  12. package/dist/analyzer.d.ts.map +1 -1
  13. package/dist/expression-parser.d.ts.map +1 -1
  14. package/dist/index.js +662 -223
  15. package/dist/ir-to-client-js/build-references.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts +2 -0
  17. package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/plan/build-branch-loop.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/control-flow/plan/build-component-loop.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/plan/build-composite-loop.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/control-flow/plan/build-event-delegation.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +12 -0
  25. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  26. package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts +1 -1
  28. package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts.map +1 -1
  29. package/dist/ir-to-client-js/control-flow.d.ts.map +1 -1
  30. package/dist/ir-to-client-js/html-template.d.ts +19 -1
  31. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  32. package/dist/ir-to-client-js/plan/build-static-array-child-init.d.ts.map +1 -1
  33. package/dist/ir-to-client-js/types.d.ts +4 -4
  34. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  35. package/dist/jsx-to-ir.d.ts.map +1 -1
  36. package/dist/loop-destructure.d.ts.map +1 -1
  37. package/dist/strip-types.d.ts +18 -0
  38. package/dist/strip-types.d.ts.map +1 -1
  39. package/dist/types.d.ts +96 -32
  40. package/dist/types.d.ts.map +1 -1
  41. package/package.json +2 -2
  42. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +94 -0
  43. package/src/__tests__/client-js-generation.test.ts +10 -3
  44. package/src/__tests__/compiler-stress-1244.test.ts +12 -1
  45. package/src/__tests__/event-delegation-scope-2367.test.ts +108 -0
  46. package/src/__tests__/flatmap-segments.test.ts +80 -0
  47. package/src/__tests__/map-arbitrary-body.test.ts +226 -0
  48. package/src/__tests__/map-body-no-silent-divergence.test.ts +360 -0
  49. package/src/__tests__/map-multi-return-body.test.ts +172 -0
  50. package/src/__tests__/unsupported-expression.test.ts +20 -2
  51. package/src/adapters/interface.ts +40 -0
  52. package/src/adapters/jsx-adapter.ts +10 -0
  53. package/src/adapters/loop-bound-names.ts +6 -2
  54. package/src/adapters/parsed-expr-emitter.ts +5 -10
  55. package/src/adapters/test-adapter.ts +10 -0
  56. package/src/analyzer-context.ts +35 -1
  57. package/src/analyzer.ts +162 -24
  58. package/src/compiler.ts +2 -2
  59. package/src/expression-parser.ts +27 -19
  60. package/src/ir-to-client-js/build-references.ts +3 -2
  61. package/src/ir-to-client-js/collect-elements.ts +3 -3
  62. package/src/ir-to-client-js/control-flow/plan/branch-loop.ts +2 -0
  63. package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +7 -2
  64. package/src/ir-to-client-js/control-flow/plan/build-component-loop.ts +4 -0
  65. package/src/ir-to-client-js/control-flow/plan/build-composite-loop.ts +15 -2
  66. package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +16 -3
  67. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +26 -3
  68. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +17 -2
  69. package/src/ir-to-client-js/control-flow/plan/loop.ts +12 -0
  70. package/src/ir-to-client-js/control-flow/shared.ts +2 -1
  71. package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +9 -2
  72. package/src/ir-to-client-js/control-flow.ts +11 -0
  73. package/src/ir-to-client-js/html-template.ts +133 -37
  74. package/src/ir-to-client-js/plan/build-static-array-child-init.ts +22 -10
  75. package/src/ir-to-client-js/types.ts +4 -3
  76. package/src/jsx-to-ir.ts +690 -164
  77. package/src/loop-destructure.ts +9 -5
  78. package/src/rich-type-refusal.ts +6 -2
  79. package/src/strip-types.ts +47 -0
  80. package/src/types.ts +110 -35
@@ -253,6 +253,16 @@ export class TestAdapter extends JsxAdapter {
253
253
  // parsed as a block statement (matches hono-adapter behavior).
254
254
  const safeChildren = children.startsWith('{') ? `<>${children}</>` : children
255
255
 
256
+ // A `.map()` callback preamble (a Stage-2 value-only `const`, or a Stage-3
257
+ // arbitrary array-builder) runs verbatim in the block body. Use the typed
258
+ // carrier so raw JSX leaves stay intact for the JSX runtime; `{out}`-style
259
+ // element-array children render natively. (Without this the preamble was
260
+ // dropped and its identifiers rendered unbound.)
261
+ const preamble = loop.preamble?.ssrText
262
+ if (preamble) {
263
+ return `{${loop.array}.map((${loop.param}${indexParam}) => { ${preamble} return ${safeChildren} })}`
264
+ }
265
+
256
266
  return `{${loop.array}.map((${loop.param}${indexParam}) => ${safeChildren})}`
257
267
  }
258
268
 
@@ -25,6 +25,7 @@ import type {
25
25
  DeclinedReactiveFactory,
26
26
  } from './types.ts'
27
27
  import { type ExcludeRange, collectAllTypeRanges, reconstructWithoutTypes } from './strip-types.ts'
28
+ import type { CallbackBodyAcceptor } from './adapters/interface.ts'
28
29
 
29
30
  /**
30
31
  * Deferred info for BF043 (props destructuring warning).
@@ -199,6 +200,15 @@ export interface AnalyzerContext {
199
200
 
200
201
  // Errors
201
202
  errors: CompilerError[]
203
+ /**
204
+ * Capability of the adapter this component is being compiled for: can its
205
+ * runtime render an off-subset callback body (`filter`/`sort`/… predicate)
206
+ * verbatim? Set from `TemplateAdapter.acceptsCallbackBody`; undefined for
207
+ * direct analyzer callers (no adapter) and DSL adapters. Consulted at the
208
+ * Phase-1 callback-lowering sites so a JS-runtime target isn't rejected for
209
+ * a body it could run. See `spec/callback-fidelity.md`.
210
+ */
211
+ acceptsCallbackBody?: CallbackBodyAcceptor
202
212
 
203
213
  // Directive
204
214
  hasUseClientDirective: boolean
@@ -232,11 +242,13 @@ export interface AnalyzerContext {
232
242
 
233
243
  export function createAnalyzerContext(
234
244
  sourceFile: ts.SourceFile,
235
- filePath: string
245
+ filePath: string,
246
+ acceptsCallbackBody?: CallbackBodyAcceptor
236
247
  ): AnalyzerContext {
237
248
  return {
238
249
  sourceFile,
239
250
  filePath,
251
+ acceptsCallbackBody,
240
252
 
241
253
  componentName: null,
242
254
  componentNode: null,
@@ -300,6 +312,22 @@ export function createAnalyzerContext(
300
312
  } catch {
301
313
  ownSourceFile = undefined
302
314
  }
315
+ // Test-gated trust-boundary assertion (write-side string rule,
316
+ // CLAUDE.md): getJS output is spliced into emitted artifacts, so a
317
+ // JSX-bearing node here is a leak by construction — mixed content must
318
+ // travel as structured segments, never as raw text. Every getJS call
319
+ // site shares this contract; the env gate keeps the subtree walk off
320
+ // the production hot path (the trichotomy harness enables it). Scoped
321
+ // to error-free compiles: a compile that already refused loudly may
322
+ // take degraded fallback paths whose artifacts are gated by the error —
323
+ // the invariant this trips on is the SILENT leak.
324
+ if (process.env.BF_ASSERT_NO_JSX_IN_GETJS === '1' && this.errors.length === 0 && nodeContainsJsx(node)) {
325
+ throw new Error(
326
+ 'getJS() called on a JSX-bearing node — raw JSX must never be spliced ' +
327
+ 'into emitted output. Carry mixed content as structured segments ' +
328
+ '(MapCallbackPreamble / FlatMapCallback) instead.'
329
+ )
330
+ }
303
331
  if (ownSourceFile && ownSourceFile !== sourceFile) {
304
332
  return node.getText(ownSourceFile)
305
333
  }
@@ -308,6 +336,12 @@ export function createAnalyzerContext(
308
336
  }
309
337
  }
310
338
 
339
+ /** Subtree JSX check for the test-gated getJS assertion above. */
340
+ function nodeContainsJsx(node: ts.Node): boolean {
341
+ if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) return true
342
+ return ts.forEachChild(node, nodeContainsJsx) ?? false
343
+ }
344
+
311
345
  // =============================================================================
312
346
  // Source Location Helper
313
347
  // =============================================================================
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,7 @@ 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')
155
156
  for (const attr of elem.bindings.reactiveAttrs) {
156
157
  addExprEdges(ROOT_SOURCE, attr.expression, 'template-closure')
157
158
  }
@@ -316,7 +317,7 @@ export function buildReferencesGraph(ctx: ClientJsContext, irRoot: IRNode): Refe
316
317
  addExprEdges(ROOT_SOURCE, l.array, 'template-closure')
317
318
  if (l.filterPredicate) addExprEdges(ROOT_SOURCE, l.filterPredicate.raw, 'template-closure')
318
319
  if (l.sortComparator) addExprEdges(ROOT_SOURCE, l.sortComparator.raw, 'template-closure')
319
- if (l.mapPreamble) addExprEdges(ROOT_SOURCE, l.mapPreamble, 'template-closure')
320
+ if (l.preamble) addExprEdges(ROOT_SOURCE, preambleAnalysisText(l.preamble), 'template-closure')
320
321
  descend()
321
322
  if (l.childComponent) walkChildComponent(l.childComponent)
322
323
  if (l.nestedComponents) {
@@ -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,
@@ -776,7 +776,7 @@ export function collectElements(
776
776
  raw: l.sortComparator.raw,
777
777
  } : undefined,
778
778
  chainOrder: l.chainOrder,
779
- mapPreamble: l.mapPreamble,
779
+ preamble: l.preamble,
780
780
  })
781
781
  // Don't descend — loop-scoped variables are only available inside the iteration.
782
782
  },
@@ -1111,7 +1111,7 @@ function collectBranchLoops(
1111
1111
  objectIteration: n.objectIteration,
1112
1112
  template: childTemplate,
1113
1113
  containerSlotId: containerSlot,
1114
- mapPreamble: n.mapPreamble ?? null,
1114
+ preamble: n.preamble,
1115
1115
  nestedComponents: useElementReconciliation ? n.nestedComponents : undefined,
1116
1116
  bindings: branchBindings,
1117
1117
  innerLoops: useElementReconciliation ? innerLoopsCollected : undefined,
@@ -21,6 +21,8 @@ import type { ReactiveEffectsPlan } from './reactive-effects.ts'
21
21
 
22
22
  export interface BranchPlainLoopPlan {
23
23
  kind: 'plain'
24
+ /** See LoopPlanCommon.rowConstruction — required on every variant. */
25
+ rowConstruction: 'string-template' | 'dom-ops'
24
26
  /**
25
27
  * Variable suffix for the container lookup — e.g. for slotId `s4` the
26
28
  * stringifier emits `__loop_s4` and queries via `$(__branchScope, 's4')`.
@@ -16,6 +16,7 @@ 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
18
  import { destructureLoopParam, loopKeyFn, buildChildRefBindings } from '../shared.ts'
19
+ import { renderPreamble, irToHtmlTemplate } from '../../html-template.ts'
19
20
  import type {
20
21
  BranchCompositeLoopPlan,
21
22
  BranchLoopPlan,
@@ -44,6 +45,7 @@ export function buildBranchLoopPlan(loop: BranchLoop, profileComponentName?: str
44
45
 
45
46
  const plan: BranchPlainLoopPlan = {
46
47
  kind: 'plain',
48
+ rowConstruction: 'string-template',
47
49
  containerSlotId,
48
50
  containerVar,
49
51
  markerId: loop.markerId,
@@ -54,8 +56,11 @@ export function buildBranchLoopPlan(loop: BranchLoop, profileComponentName?: str
54
56
  indexParam: loop.index || '__idx',
55
57
  // Wrap loop-param references to signal-accessor form so the preamble
56
58
  // matches the template literal's already-wrapped reads (#1065).
57
- mapPreambleWrapped: loop.mapPreamble
58
- ? wrapLoopParamAsAccessor(loop.mapPreamble, loop.param, loop.paramBindings)
59
+ mapPreambleWrapped: loop.preamble
60
+ ? renderPreamble(loop.preamble, {
61
+ transformJs: (t) => wrapLoopParamAsAccessor(t, loop.param, loop.paramBindings),
62
+ renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, [{ param: loop.param, bindings: loop.paramBindings }], undefined, true),
63
+ })
59
64
  : '',
60
65
  template: loop.template,
61
66
  reactiveEffects: hasReactiveEffects
@@ -60,6 +60,10 @@ export function buildComponentLoopPlan(elem: TopLevelLoop, profileComponentName?
60
60
 
61
61
  return {
62
62
  kind: 'component',
63
+ // Rows are createComponent-driven: a lowered HTML-string leaf passed as a
64
+ // prop would diverge from SSR (which passes real JSX elements). The plan
65
+ // dispatcher refuses a JSX-bearing preamble on this variant.
66
+ rowConstruction: 'dom-ops',
63
67
  containerVar: `_${varSlotId(elem.slotId)}`,
64
68
  markerId: elem.markerId,
65
69
  arrayExpr: buildChainedArrayExpr(elem),
@@ -23,6 +23,7 @@ import {
23
23
  } from '../shared.ts'
24
24
  import { buildReactiveEffectsPlan } from './build-reactive-effects.ts'
25
25
  import { buildInnerLoopsPlan } from './build-inner-loop.ts'
26
+ import { renderPreamble, irToHtmlTemplate } from '../../html-template.ts'
26
27
  import type { CompositeLoopPlan } from './types.ts'
27
28
 
28
29
  /** @internal — prefer `buildLoopPlan`. */
@@ -36,6 +37,7 @@ export function buildTopLevelCompositePlan(elem: TopLevelLoop, profileComponentN
36
37
 
37
38
  return {
38
39
  kind: 'composite',
40
+ rowConstruction: 'string-template',
39
41
  containerVar: `_${varSlotId(elem.slotId)}`,
40
42
  markerId: elem.markerId,
41
43
  arrayExpr: buildChainedArrayExpr(elem),
@@ -43,7 +45,12 @@ export function buildTopLevelCompositePlan(elem: TopLevelLoop, profileComponentN
43
45
  paramHead,
44
46
  paramUnwrap,
45
47
  indexParam: elem.index || '__idx',
46
- mapPreambleWrapped: elem.mapPreamble ? wrap(elem.mapPreamble) : '',
48
+ mapPreambleWrapped: elem.preamble
49
+ ? renderPreamble(elem.preamble, {
50
+ transformJs: wrap,
51
+ renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, [{ param: elem.param, bindings: elem.paramBindings }], undefined, true),
52
+ })
53
+ : '',
47
54
  template: elem.template,
48
55
  outerComps: filterCondCompsOut(outerCompsByDepth, elem.bindings.conditionals),
49
56
  outerEvents: elem.bindings.events.filter(ev => ev.nestedLoops.length === 0),
@@ -87,6 +94,7 @@ export function buildBranchCompositePlan(loop: BranchLoop, cv: string, profileCo
87
94
 
88
95
  return {
89
96
  kind: 'composite',
97
+ rowConstruction: 'string-template',
90
98
  containerVar: `__loop_${cv}`,
91
99
  markerId: loop.markerId,
92
100
  // Chain `.filter()` / `.toSorted()` onto the source array so the mapArray
@@ -97,7 +105,12 @@ export function buildBranchCompositePlan(loop: BranchLoop, cv: string, profileCo
97
105
  paramHead,
98
106
  paramUnwrap,
99
107
  indexParam: loop.index || '__idx',
100
- mapPreambleWrapped: loop.mapPreamble ? wrap(loop.mapPreamble) : '',
108
+ mapPreambleWrapped: loop.preamble
109
+ ? renderPreamble(loop.preamble, {
110
+ transformJs: wrap,
111
+ renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, [{ param: loop.param, bindings: loop.paramBindings }], undefined, true),
112
+ })
113
+ : '',
101
114
  template: loop.template,
102
115
  outerComps: filterCondCompsOut(outerCompsByDepth, loop.bindings.conditionals),
103
116
  outerEvents: childEvents.filter(ev => ev.nestedLoops.length === 0),
@@ -11,6 +11,7 @@
11
11
 
12
12
  import type { TopLevelLoop, BranchLoop, LoopChildEvent } from '../../types.ts'
13
13
  import { buildChainedArrayExpr, varSlotId, substituteLoopBindings } from '../../utils.ts'
14
+ import { renderPreamble, irToHtmlTemplate } from '../../html-template.ts'
14
15
  import type {
15
16
  EventDelegationPlan,
16
17
  ItemLookup,
@@ -35,7 +36,11 @@ export function buildDynamicLoopDelegationPlan(
35
36
  paramBindings: elem.paramBindings,
36
37
  key: elem.key,
37
38
  index: elem.index,
38
- mapPreamble: elem.mapPreamble ?? null,
39
+ mapPreamble: elem.preamble
40
+ ? renderPreamble(elem.preamble, {
41
+ renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, [{ param: elem.param, bindings: elem.paramBindings }], undefined, true),
42
+ })
43
+ : null,
39
44
  }),
40
45
  }
41
46
  }
@@ -60,7 +65,11 @@ export function buildBranchLoopDelegationPlan(
60
65
  paramBindings: loop.paramBindings,
61
66
  key: loop.key,
62
67
  index: loop.index,
63
- mapPreamble: loop.mapPreamble ?? null,
68
+ mapPreamble: loop.preamble
69
+ ? renderPreamble(loop.preamble, {
70
+ renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, [{ param: loop.param, bindings: loop.paramBindings }], undefined, true),
71
+ })
72
+ : null,
64
73
  }),
65
74
  }
66
75
  }
@@ -86,7 +95,11 @@ export function buildStaticArrayDelegationPlan(
86
95
  // array too (#1434).
87
96
  arrayExpr: buildChainedArrayExpr(elem),
88
97
  param: elem.param,
89
- mapPreamble: elem.mapPreamble ?? null,
98
+ mapPreamble: elem.preamble
99
+ ? renderPreamble(elem.preamble, {
100
+ renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, [{ param: elem.param, bindings: elem.paramBindings }], undefined, true),
101
+ })
102
+ : null,
90
103
  offset: elem.offset ?? null,
91
104
  indexParam: elem.index ?? null,
92
105
  },