@barefootjs/jsx 0.26.1 → 0.26.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/dist/adapters/interface.d.ts +29 -0
  2. package/dist/adapters/interface.d.ts.map +1 -1
  3. package/dist/adapters/jsx-adapter.d.ts +9 -0
  4. package/dist/adapters/jsx-adapter.d.ts.map +1 -1
  5. package/dist/adapters/loop-bound-names.d.ts.map +1 -1
  6. package/dist/adapters/parsed-expr-emitter.d.ts +0 -10
  7. package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
  8. package/dist/adapters/test-adapter.d.ts.map +1 -1
  9. package/dist/analyzer-context.d.ts +11 -1
  10. package/dist/analyzer-context.d.ts.map +1 -1
  11. package/dist/analyzer.d.ts +40 -1
  12. package/dist/analyzer.d.ts.map +1 -1
  13. package/dist/expression-parser.d.ts.map +1 -1
  14. package/dist/index.js +662 -223
  15. package/dist/ir-to-client-js/build-references.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts +2 -0
  17. package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/plan/build-branch-loop.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/control-flow/plan/build-component-loop.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/plan/build-composite-loop.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/control-flow/plan/build-event-delegation.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +12 -0
  25. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  26. package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts +1 -1
  28. package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts.map +1 -1
  29. package/dist/ir-to-client-js/control-flow.d.ts.map +1 -1
  30. package/dist/ir-to-client-js/html-template.d.ts +19 -1
  31. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  32. package/dist/ir-to-client-js/plan/build-static-array-child-init.d.ts.map +1 -1
  33. package/dist/ir-to-client-js/types.d.ts +4 -4
  34. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  35. package/dist/jsx-to-ir.d.ts.map +1 -1
  36. package/dist/loop-destructure.d.ts.map +1 -1
  37. package/dist/strip-types.d.ts +18 -0
  38. package/dist/strip-types.d.ts.map +1 -1
  39. package/dist/types.d.ts +96 -32
  40. package/dist/types.d.ts.map +1 -1
  41. package/package.json +2 -2
  42. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +94 -0
  43. package/src/__tests__/client-js-generation.test.ts +10 -3
  44. package/src/__tests__/compiler-stress-1244.test.ts +12 -1
  45. package/src/__tests__/event-delegation-scope-2367.test.ts +108 -0
  46. package/src/__tests__/flatmap-segments.test.ts +80 -0
  47. package/src/__tests__/map-arbitrary-body.test.ts +226 -0
  48. package/src/__tests__/map-body-no-silent-divergence.test.ts +360 -0
  49. package/src/__tests__/map-multi-return-body.test.ts +172 -0
  50. package/src/__tests__/unsupported-expression.test.ts +20 -2
  51. package/src/adapters/interface.ts +40 -0
  52. package/src/adapters/jsx-adapter.ts +10 -0
  53. package/src/adapters/loop-bound-names.ts +6 -2
  54. package/src/adapters/parsed-expr-emitter.ts +5 -10
  55. package/src/adapters/test-adapter.ts +10 -0
  56. package/src/analyzer-context.ts +35 -1
  57. package/src/analyzer.ts +162 -24
  58. package/src/compiler.ts +2 -2
  59. package/src/expression-parser.ts +27 -19
  60. package/src/ir-to-client-js/build-references.ts +3 -2
  61. package/src/ir-to-client-js/collect-elements.ts +3 -3
  62. package/src/ir-to-client-js/control-flow/plan/branch-loop.ts +2 -0
  63. package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +7 -2
  64. package/src/ir-to-client-js/control-flow/plan/build-component-loop.ts +4 -0
  65. package/src/ir-to-client-js/control-flow/plan/build-composite-loop.ts +15 -2
  66. package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +16 -3
  67. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +26 -3
  68. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +17 -2
  69. package/src/ir-to-client-js/control-flow/plan/loop.ts +12 -0
  70. package/src/ir-to-client-js/control-flow/shared.ts +2 -1
  71. package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +9 -2
  72. package/src/ir-to-client-js/control-flow.ts +11 -0
  73. package/src/ir-to-client-js/html-template.ts +133 -37
  74. package/src/ir-to-client-js/plan/build-static-array-child-init.ts +22 -10
  75. package/src/ir-to-client-js/types.ts +4 -3
  76. package/src/jsx-to-ir.ts +690 -164
  77. package/src/loop-destructure.ts +9 -5
  78. package/src/rich-type-refusal.ts +6 -2
  79. package/src/strip-types.ts +47 -0
  80. package/src/types.ts +110 -35
@@ -29,6 +29,7 @@ import {
29
29
  attrValueToString,
30
30
  } from '../../utils.ts'
31
31
  import { buildChildRefBindings, buildStaticChildRefBindings } from '../shared.ts'
32
+ import { renderPreamble, irToHtmlTemplate } from '../../html-template.ts'
32
33
 
33
34
  /**
34
35
  * Mirror of the helper in `build-loop-child-arm.ts` — kept local to avoid
@@ -115,7 +116,7 @@ export function buildInnerLoopsPlan(args: BuildInnerLoopsArgs): InnerLoopsPlan {
115
116
  const useReactive = refsParent && !!inner.template
116
117
 
117
118
  const emit: InnerLoopReactiveEmit | InnerLoopStaticEmit = useReactive
118
- ? buildReactiveEmit(inner, level, wrapOuter, uidSuffix)
119
+ ? buildReactiveEmit(inner, level, wrapOuter, uidSuffix, outerLoopParam, outerLoopParamBindings)
119
120
  : buildStaticEmit(inner, level, uidSuffix)
120
121
 
121
122
  const arrayExpr = useReactive ? wrapOuter(inner.array) : inner.array
@@ -155,6 +156,8 @@ function buildReactiveEmit(
155
156
  level: DepthLevel,
156
157
  wrapOuter: (expr: string) => string,
157
158
  uidSuffix: string,
159
+ outerLoopParam?: string,
160
+ outerLoopParamBindings?: readonly LoopParamBinding[],
158
161
  ): InnerLoopReactiveEmit {
159
162
  const wrapInner = (expr: string) => wrapLoopParamAsAccessor(expr, inner.param, inner.paramBindings)
160
163
  const { head: paramHead, unwrap: paramUnwrap } = destructureLoopParam(inner.param, inner.paramBindings)
@@ -229,7 +232,20 @@ function buildReactiveEmit(
229
232
  const indexAlias = nestedLoopIndexAlias(inner, `__innerIdx${uidSuffix}`, paramHead, level.comps, level.events)
230
233
  if (indexAlias) preludeStatements.push(indexAlias)
231
234
  if (paramUnwrap) preludeStatements.push(paramUnwrap)
232
- if (inner.mapPreamble) preludeStatements.push(wrapInner(wrapOuter(inner.mapPreamble)))
235
+ if (inner.preamble) {
236
+ // Leaf JSX renders under both param contexts, mirroring the
237
+ // wrapInner(wrapOuter(...)) applied to the js text.
238
+ const leafLoopParams = outerLoopParam
239
+ ? [
240
+ { param: outerLoopParam, bindings: outerLoopParamBindings },
241
+ { param: inner.param, bindings: inner.paramBindings },
242
+ ]
243
+ : [{ param: inner.param, bindings: inner.paramBindings }]
244
+ preludeStatements.push(renderPreamble(inner.preamble, {
245
+ transformJs: (t) => wrapInner(wrapOuter(t)),
246
+ renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, leafLoopParams, undefined, true),
247
+ }))
248
+ }
233
249
 
234
250
  const childRefs = buildChildRefBindings(inner.bindings.refs, inner.param, inner.paramBindings)
235
251
 
@@ -265,7 +281,14 @@ function buildStaticEmit(inner: NestedLoop, level: DepthLevel, uidSuffix: string
265
281
  const preludeStatements: string[] = []
266
282
  const indexAlias = nestedLoopIndexAlias(inner, `__innerIdx${uidSuffix}`, inner.param, level.comps, level.events)
267
283
  if (indexAlias) preludeStatements.push(indexAlias)
268
- if (inner.mapPreamble) preludeStatements.push(inner.mapPreamble)
284
+ // Static `forEach` receives the literal item, so neither the js text nor a
285
+ // leaf's param reads get accessor-wrapped (mirrors the verbatim emission
286
+ // of the js segments above).
287
+ if (inner.preamble) {
288
+ preludeStatements.push(renderPreamble(inner.preamble, {
289
+ renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, undefined, undefined, true),
290
+ }))
291
+ }
269
292
  return {
270
293
  mode: 'static',
271
294
  rawKey: inner.key ?? null,
@@ -39,6 +39,7 @@ import {
39
39
  import { buildLoopReactiveEffectsPlan } from './build-reactive-effects.ts'
40
40
  import { buildComponentLoopPlan } from './build-component-loop.ts'
41
41
  import { buildTopLevelCompositePlan } from './build-composite-loop.ts'
42
+ import { renderPreamble, irToHtmlTemplate } from '../../html-template.ts'
42
43
  import type {
43
44
  LoopPlan,
44
45
  PlainLoopPlan,
@@ -92,6 +93,7 @@ export function buildPlainLoopPlan(elem: TopLevelLoop, profileComponentName?: st
92
93
 
93
94
  return {
94
95
  kind: 'plain',
96
+ rowConstruction: 'string-template',
95
97
  containerVar: `_${varSlotId(elem.slotId)}`,
96
98
  markerId: elem.markerId,
97
99
  profileLoopId: profileComponentName ? `${profileComponentName}#binding:${elem.slotId}` : undefined,
@@ -100,7 +102,15 @@ export function buildPlainLoopPlan(elem: TopLevelLoop, profileComponentName?: st
100
102
  paramHead,
101
103
  paramUnwrap,
102
104
  indexParam: elem.index || '__idx',
103
- mapPreambleWrapped: elem.mapPreamble ? wrap(elem.mapPreamble) : '',
105
+ // Stage 3 / D4 — js segments get the loop-param accessor wrap; jsx leaves
106
+ // render as HTML-string templates under this loop's param context so a
107
+ // leaf that reads the item (`r`) becomes `r()`.
108
+ mapPreambleWrapped: elem.preamble
109
+ ? renderPreamble(elem.preamble, {
110
+ transformJs: wrap,
111
+ renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, [{ param: elem.param, bindings: elem.paramBindings }], undefined, true),
112
+ })
113
+ : '',
104
114
  template: elem.template,
105
115
  skeletonTemplate: elem.skeletonTemplate,
106
116
  skeletonPaths: elem.skeletonPaths,
@@ -139,6 +149,7 @@ export function buildStaticLoopPlan(elem: TopLevelLoop, unsafeLocalNames: Set<st
139
149
 
140
150
  return {
141
151
  kind: 'static',
152
+ rowConstruction: 'string-template',
142
153
  containerVar: `_${varSlotId(elem.slotId)}`,
143
154
  arrayExpr: elem.array,
144
155
  param: elem.param,
@@ -188,7 +199,11 @@ function buildStaticLoopMaterialize(
188
199
  if (!setIntersects(elem.arrayFreeIdentifiers, unsafeLocalNames)) return null
189
200
  return {
190
201
  itemTemplate: elem.staticItemTemplate,
191
- mapPreamble: elem.mapPreamble ?? '',
202
+ mapPreamble: elem.preamble
203
+ ? renderPreamble(elem.preamble, {
204
+ renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, [{ param: elem.param, bindings: elem.paramBindings }], undefined, true),
205
+ })
206
+ : '',
192
207
  bodyIsMultiRoot: elem.bodyIsMultiRoot ?? false,
193
208
  }
194
209
  }
@@ -45,6 +45,18 @@ interface LoopPlanCommon {
45
45
  * — initial mount, SSR hydration, and same-key remount after unmount.
46
46
  */
47
47
  childRefs: readonly LoopChildRefBinding[]
48
+ /**
49
+ * How this variant constructs each row's DOM (Stage 3 root cure,
50
+ * spec/callback-fidelity.md). `'string-template'` = rows are built from
51
+ * interpolated innerHTML template literals, so a `.map()` preamble carrying
52
+ * lowered JSX leaves can be spliced via `renderPreamble()`. `'dom-ops'` =
53
+ * rows are built imperatively (createElement/reconcile paths) and CANNOT
54
+ * host a string-lowered preamble — the plan dispatcher refuses a JSX-bearing
55
+ * preamble on such a variant instead of letting it drop silently. Required
56
+ * on every variant (like `childRefs`) so a new loop shape must declare its
57
+ * capability — the compile error is the point.
58
+ */
59
+ rowConstruction: 'string-template' | 'dom-ops'
48
60
  }
49
61
 
50
62
  /** Fields shared by every dynamic (`mapArray`-driven) loop variant. */
@@ -14,6 +14,7 @@
14
14
 
15
15
  import type { LoopChildEvent, LoopChildRef, TopLevelLoop, NestedLoop, CollectedLoop } from '../types.ts'
16
16
  import type { IRLoopChildComponent, LoopParamBinding } from '../../types.ts'
17
+ import { preambleAnalysisText } from '../../types.ts'
17
18
  import { quotePropName, wrapLoopParamAsAccessor, irChildrenFreeIds, attrValueToString } from '../utils.ts'
18
19
  import { irChildrenToJsExpr } from '../html-template.ts'
19
20
  import { emitListenerBlock } from './stringify/event-listener.ts'
@@ -83,7 +84,7 @@ export function nestedLoopReferencesIndex(
83
84
  for (const a of inner.bindings.reactiveAttrs) {
84
85
  if (exprRefs(a.expression, a.freeIdentifiers)) return true
85
86
  }
86
- if (inner.mapPreamble && extractFreeIdentifiersFromStatementText(inner.mapPreamble).has(index)) return true
87
+ if (inner.preamble && extractFreeIdentifiersFromStatementText(preambleAnalysisText(inner.preamble)).has(index)) return true
87
88
  if (inner.template && extractFreeIdentifiersFromTemplateText(inner.template).has(index)) return true
88
89
  for (const ev of events) {
89
90
  if (exprRefs(ev.handler)) return true
@@ -6,7 +6,7 @@
6
6
  * if (<container>) <container>.addEventListener('<eventName>', (__bfEvt) => {
7
7
  * const target = __bfEvt.target
8
8
  * const <slot>El = target.closest('[bf="<slotId>"]')
9
- * if (<slot>El) {
9
+ * if (<slot>El && <container>.contains(<slot>El)) {
10
10
  * <item-lookup specific>
11
11
  * <handlerCall>
12
12
  * return
@@ -94,8 +94,15 @@ export function stringifyEventDelegation(lines: string[], plan: EventDelegationP
94
94
  lines.push(` const target = __bfEvt.target`)
95
95
  for (const ev of evs) {
96
96
  const childVar = varSlotId(ev.childSlotId)
97
+ // Bound the slot match to the delegating container (#2367). `bf` ids are
98
+ // unique only *within* a component, so an unscoped
99
+ // `target.closest('[bf="sN"]')` can match a same-id element in an ancestor
100
+ // component and silently take the wrong branch. The container holds every
101
+ // slot it delegates on, so a same-id ancestor is never a descendant of it —
102
+ // the `container.contains(...)` guard rejects the foreign match and the
103
+ // handler falls through to the correct branch.
97
104
  lines.push(` const ${childVar}El = target.closest('[bf="${ev.childSlotId}"]')`)
98
- lines.push(` if (${childVar}El) {`)
105
+ lines.push(` if (${childVar}El && ${containerVar}.contains(${childVar}El)) {`)
99
106
  const handlerCall = withTurn(`(${ev.handler.trim()})(__bfEvt)`, profileComponentName, ev.childSlotId, ev.eventName)
100
107
  switch (itemLookup.kind) {
101
108
  case 'keyed':
@@ -21,6 +21,7 @@
21
21
  */
22
22
 
23
23
  import type { ClientJsContext, TopLevelLoop } from './types.ts'
24
+ import { internalInvariant } from '../errors.ts'
24
25
  import { buildInsertPlan } from './control-flow/plan/build-insert.ts'
25
26
  import { stringifyInsert } from './control-flow/stringify/insert.ts'
26
27
  import { buildLoopPlan } from './control-flow/plan/build-loop.ts'
@@ -70,6 +71,16 @@ export function emitLoopUpdates(lines: string[], ctx: ClientJsContext, unsafeLoc
70
71
  unsafeLocalNames,
71
72
  profileComponentName: ctx.profile ? ctx.componentName : undefined,
72
73
  })
74
+ // Stage 3 root cure — a JSX-bearing preamble can only be spliced into a
75
+ // string-templated row (renderPreamble). Every shape that reaches a
76
+ // 'dom-ops' variant today is already refused in Phase 1 with a proper
77
+ // source location; this backstop exists for FUTURE variants, so a new
78
+ // plan kind that declares 'dom-ops' cannot silently drop the preamble —
79
+ // it fails the build here instead.
80
+ internalInvariant(
81
+ !(elem.preamble && elem.preamble.builderNames.length > 0 && plan.rowConstruction === 'dom-ops'),
82
+ `loop variant '${plan.kind}' declares dom-ops row construction but received a JSX-bearing preamble — add a Phase-1 refusal (or wire renderPreamble support) for this shape`,
83
+ )
73
84
  stringifyLoop(lines, plan)
74
85
  emitLoopEventDelegation(lines, elem, plan.kind, ctx.profile ? ctx.componentName : undefined)
75
86
  }
@@ -2,7 +2,7 @@
2
2
  * IR → HTML template string generation and validation.
3
3
  */
4
4
 
5
- import type { AttrValue, IRAttribute, IRNode, IRProp } from '../types.ts'
5
+ import type { AttrValue, IRAttribute, IRNode, IRProp, MapCallbackPreamble } from '../types.ts'
6
6
  import { isBooleanAttr } from '../html-constants.ts'
7
7
  import { toHtmlAttrName, attrValueToString, quotePropName, PROPS_PARAM, DATA_BF_PH, keyAttrName, loopStartMarker, loopEndMarker, loopItemMarker, freeIdsFromRefs, setIntersects, wrapExprWithLoopParams } from './utils.ts'
8
8
  import type { LoopParamSpec } from './utils.ts'
@@ -556,6 +556,71 @@ function itemAnchorTemplate(keyExpr: string): string {
556
556
  return `<!--${loopItemMarker('${' + keyExpr + '}')}-->`
557
557
  }
558
558
 
559
+ /**
560
+ * The ONLY door from a structured `.map()` preamble to emitted text (Stage 3
561
+ * root cure, spec/callback-fidelity.md). `js` segments contribute their JS
562
+ * text (template variant when requested, then the caller's context transform —
563
+ * e.g. the loop-param accessor wrap); `jsx` segments render their compiled IR
564
+ * as a template-literal HTML string. There is no sentinel substitution because
565
+ * there is no sentinel: mixed content stays structured until this function.
566
+ * Emitters that cannot host a string-templated preamble must not call this —
567
+ * they refuse at plan dispatch instead of splicing.
568
+ */
569
+ export function renderPreamble(
570
+ preamble: Pick<MapCallbackPreamble, 'segments'>,
571
+ opts: {
572
+ /** Pick `templateText` on js segments (hydrate/CSR template contexts). */
573
+ textVariant?: 'client' | 'template'
574
+ /** Context transform for js text (e.g. wrapLoopParamAsAccessor). */
575
+ transformJs?: (text: string) => string
576
+ /** Context-appropriate leaf renderer (an irToHtmlTemplate variant). */
577
+ renderLeaf: (ir: IRNode) => string
578
+ },
579
+ ): string {
580
+ let out = ''
581
+ for (const seg of preamble.segments) {
582
+ if (seg.kind === 'js') {
583
+ const text = opts.textVariant === 'template' ? (seg.templateText ?? seg.text) : seg.text
584
+ out += opts.transformJs ? opts.transformJs(text) : text
585
+ } else {
586
+ out += '`' + opts.renderLeaf(escapeLeafTextExpressions(seg.ir)) + '`'
587
+ }
588
+ }
589
+ return out
590
+ }
591
+
592
+ /**
593
+ * SSR/CSR escaping parity for preamble leaves, decided once at the door: a
594
+ * JSX-runtime SSR adapter renders the leaf's raw JSX and auto-escapes text
595
+ * interpolations (`{c}`), so the client's HTML-string lowering must escape the
596
+ * same positions — wrap every text-position expression in `escapeText(...)`.
597
+ * Pure (returns a transformed copy); the neutral IR is never mutated with a
598
+ * client-only concern. Attribute values already flow through the template
599
+ * emitters' own attr escaping.
600
+ */
601
+ function escapeLeafTextExpressions(ir: IRNode): IRNode {
602
+ switch (ir.type) {
603
+ case 'element':
604
+ return { ...ir, children: ir.children.map(escapeLeafTextExpressions) }
605
+ case 'fragment':
606
+ return { ...ir, children: ir.children.map(escapeLeafTextExpressions) }
607
+ case 'expression': {
608
+ if (ir.expr === 'null' || ir.expr === 'undefined') return ir
609
+ // Already-wrapped or slotted expressions keep their existing handling.
610
+ if (ir.slotId || ir.expr.trimStart().startsWith('escapeText(')) return ir
611
+ return { ...ir, expr: `escapeText((${ir.expr}))`, templateExpr: ir.templateExpr ? `escapeText((${ir.templateExpr}))` : ir.templateExpr }
612
+ }
613
+ case 'conditional':
614
+ return {
615
+ ...ir,
616
+ whenTrue: escapeLeafTextExpressions(ir.whenTrue),
617
+ whenFalse: ir.whenFalse ? escapeLeafTextExpressions(ir.whenFalse) : ir.whenFalse,
618
+ }
619
+ default:
620
+ return ir
621
+ }
622
+ }
623
+
559
624
  export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, loopDepth = 0, loopParams?: ReadonlyArray<string | LoopParamSpec>, branchSlotsVar?: string, insideLoop = false, inHoistedChildren = false): string {
560
625
  const recurse = (n: IRNode): string => irToHtmlTemplate(n, restSpreadNames, loopDepth, loopParams, branchSlotsVar, insideLoop, inHoistedChildren)
561
626
  const wrapExpr = (expr: string) => wrapExprWithLoopParams(expr, loopParams)
@@ -624,20 +689,29 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, lo
624
689
  // as HTML (template.innerHTML), so re-escape for the HTML parser.
625
690
  return escapeHtml(node.value)
626
691
 
627
- case 'expression':
692
+ case 'expression': {
628
693
  if (node.expr === 'null' || node.expr === 'undefined') return ''
694
+ const inner = wrapInterpolation(wrapExpr(node.expr))
695
+ // Stage 3 / D4 — an element-array child ({out}) built by an arbitrary
696
+ // .map() preamble is an array of HTML strings; join it rather than let
697
+ // `${[...]}` `String`-comma-collapse it. Only reached on a JS-runtime
698
+ // adapter (the flag is set in Phase 1 only there); a plain local can be
699
+ // read twice safely.
700
+ const valueExpr = node.joinArrayChild
701
+ ? `Array.isArray(${inner}) ? ${inner}.join('') : (${inner} ?? '')`
702
+ : inner
629
703
  if (node.slotId) {
630
- const inner = wrapInterpolation(wrapExpr(node.expr))
631
704
  // In branch-slot context `wrapInterpolation` routes the value
632
705
  // through `__bfSlot`, which returns raw `<!--bf-slot:N-->` markers
633
706
  // for live `Node` values (spliced back by `insert()`). Escaping
634
707
  // would corrupt those markers and drop slotted content (#1694
635
708
  // regression). `__bfSlot` owns coercion of its own value, so the
636
709
  // text-escape applies only to the non-slot (plain text) form.
637
- const slotted = branchSlotsVar ? inner : escapeTextSlotExpr(inner)
710
+ const slotted = branchSlotsVar || node.joinArrayChild ? valueExpr : escapeTextSlotExpr(valueExpr)
638
711
  return `<!--bf:${node.slotId}-->\${${slotted}}<!--/-->`
639
712
  }
640
- return `\${${wrapInterpolation(wrapExpr(node.expr))}}`
713
+ return `\${${valueExpr}}`
714
+ }
641
715
 
642
716
  case 'conditional': {
643
717
  const trueBranch = recurse(node.whenTrue)
@@ -740,15 +814,17 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, lo
740
814
  let mapExpr: string
741
815
 
742
816
  if (node.flatMapCallback) {
743
- // Complex flatMap: use pre-compiled body with JSX placeholders
744
- let body = node.flatMapCallback.templateBody ?? node.flatMapCallback.body
745
- for (const frag of node.flatMapCallback.fragments) {
746
- const renderedIr = irToHtmlTemplate(frag.ir, restSpreadNames, loopDepth + 1, loopParams, branchSlotsVar, insideLoop)
747
- body = body.replace(frag.placeholder, `\`${renderedIr}\``)
748
- }
817
+ // Complex flatMap: the body is structured segments, rendered through
818
+ // the same single door as map preambles.
819
+ const body = renderPreamble(node.flatMapCallback, {
820
+ renderLeaf: (ir) => irToHtmlTemplate(ir, restSpreadNames, loopDepth + 1, loopParams, branchSlotsVar, insideLoop),
821
+ })
749
822
  mapExpr = `\${${wrappedArray}.flatMap(${node.flatMapCallback.params} => ${body}).join('')}`
750
- } else if (node.mapPreamble) {
751
- mapExpr = `\${${wrappedArray}.${iterMethod}(${callbackParam} => { ${node.mapPreamble} return \`${childTemplate}\` }).join('')}`
823
+ } else if (node.preamble) {
824
+ // Stage 3 / D4 render JSX leaves in an arbitrary array-builder
825
+ // preamble (the hydrate-template context uses the bare loop param).
826
+ const preamble = renderPreamble(node.preamble, { textVariant: 'client', renderLeaf: (ir) => irToHtmlTemplate(ir, restSpreadNames, loopDepth + 1, loopParams, branchSlotsVar, insideLoop) })
827
+ mapExpr = `\${${wrappedArray}.${iterMethod}(${callbackParam} => { ${preamble} return \`${childTemplate}\` }).join('')}`
752
828
  } else {
753
829
  mapExpr = `\${${wrappedArray}.${iterMethod}(${callbackParam} => \`${childTemplate}\`).join('')}`
754
830
  }
@@ -1138,12 +1214,18 @@ export function irToPlaceholderTemplate(node: IRNode, restSpreadNames?: Set<stri
1138
1214
  // as HTML (template.innerHTML), so re-escape for the HTML parser.
1139
1215
  return escapeHtml(node.value)
1140
1216
 
1141
- case 'expression':
1217
+ case 'expression': {
1142
1218
  if (node.expr === 'null' || node.expr === 'undefined') return ''
1219
+ const wrapped = wrapExpr(node.expr)
1220
+ // Stage 3 / D4 — join an element-array child ({out}) built by the preamble.
1221
+ const value = node.joinArrayChild
1222
+ ? `Array.isArray(${wrapped}) ? ${wrapped}.join('') : (${wrapped} ?? '')`
1223
+ : wrapped
1143
1224
  if (node.slotId) {
1144
- return `<!--bf:${node.slotId}-->\${${escapeTextSlotExpr(wrapExpr(node.expr))}}<!--/-->`
1225
+ return `<!--bf:${node.slotId}-->\${${node.joinArrayChild ? value : escapeTextSlotExpr(wrapped)}}<!--/-->`
1145
1226
  }
1146
- return `\${${wrapExpr(node.expr)}}`
1227
+ return `\${${value}}`
1228
+ }
1147
1229
 
1148
1230
  case 'conditional': {
1149
1231
  const trueBranch = recurse(node.whenTrue)
@@ -1180,14 +1262,14 @@ export function irToPlaceholderTemplate(node: IRNode, restSpreadNames?: Set<stri
1180
1262
  const iterMethod = node.method ?? 'map'
1181
1263
  let mapExpr: string
1182
1264
  if (node.flatMapCallback) {
1183
- let body = node.flatMapCallback.templateBody ?? node.flatMapCallback.body
1184
- for (const frag of node.flatMapCallback.fragments) {
1185
- const renderedIr = irToPlaceholderTemplate(frag.ir, restSpreadNames, loopDepth + 1, loopParams)
1186
- body = body.replace(frag.placeholder, `\`${renderedIr}\``)
1187
- }
1265
+ const body = renderPreamble(node.flatMapCallback, {
1266
+ renderLeaf: (ir) => irToPlaceholderTemplate(ir, restSpreadNames, loopDepth + 1, loopParams),
1267
+ })
1188
1268
  mapExpr = `\${${wrappedArray}.flatMap(${node.flatMapCallback.params} => ${body}).join('')}`
1189
- } else if (node.mapPreamble) {
1190
- mapExpr = `\${${wrappedArray}.${iterMethod}(${callbackParam} => { ${node.mapPreamble} return \`${childTemplate}\` }).join('')}`
1269
+ } else if (node.preamble) {
1270
+ // Stage 3 / D4 render JSX leaves in an arbitrary array-builder preamble.
1271
+ const preamble = renderPreamble(node.preamble, { textVariant: 'client', renderLeaf: (ir) => irToPlaceholderTemplate(ir, restSpreadNames, loopDepth + 1, loopParams) })
1272
+ mapExpr = `\${${wrappedArray}.${iterMethod}(${callbackParam} => { ${preamble} return \`${childTemplate}\` }).join('')}`
1191
1273
  } else {
1192
1274
  mapExpr = `\${${wrappedArray}.${iterMethod}(${callbackParam} => \`${childTemplate}\`).join('')}`
1193
1275
  }
@@ -1553,12 +1635,18 @@ function irToComponentTemplateWithOpts(node: IRNode, opts: TemplateOptions): str
1553
1635
  // as HTML (template.innerHTML), so re-escape for the HTML parser.
1554
1636
  return escapeHtml(node.value)
1555
1637
 
1556
- case 'expression':
1638
+ case 'expression': {
1557
1639
  if (node.expr === 'null' || node.expr === 'undefined') return ''
1640
+ const wrapped = transformExpr(node.expr, node.templateExpr)
1641
+ // Stage 3 / D4 — join an element-array child ({out}) built by the preamble.
1642
+ const value = node.joinArrayChild
1643
+ ? `Array.isArray(${wrapped}) ? ${wrapped}.join('') : (${wrapped} ?? '')`
1644
+ : wrapped
1558
1645
  if (node.slotId) {
1559
- return `<!--bf:${node.slotId}-->\${${escapeTextSlotExpr(transformExpr(node.expr, node.templateExpr))}}<!--/-->`
1646
+ return `<!--bf:${node.slotId}-->\${${node.joinArrayChild ? value : escapeTextSlotExpr(wrapped)}}<!--/-->`
1560
1647
  }
1561
- return `\${${transformExpr(node.expr, node.templateExpr)}}`
1648
+ return `\${${value}}`
1649
+ }
1562
1650
 
1563
1651
  case 'conditional': {
1564
1652
  // A client-only conditional (auto-deferred brand read or manual
@@ -2134,10 +2222,14 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
2134
2222
  // before init's createEffect overwrites the slot (#1128). Emit
2135
2223
  // an empty placeholder instead.
2136
2224
  const expr = transformed === UNSAFE_TEMPLATE_EXPR ? "''" : transformed
2225
+ // Stage 3 / D4 — join an element-array child ({out}) built by the preamble.
2226
+ const value = node.joinArrayChild
2227
+ ? `Array.isArray(${expr}) ? ${expr}.join('') : (${expr} ?? '')`
2228
+ : expr
2137
2229
  if (node.slotId) {
2138
- return `<!--bf:${node.slotId}-->\${${escapeTextSlotExpr(expr)}}<!--/-->`
2230
+ return `<!--bf:${node.slotId}-->\${${node.joinArrayChild ? value : escapeTextSlotExpr(expr)}}<!--/-->`
2139
2231
  }
2140
- return `\${${expr}}`
2232
+ return `\${${value}}`
2141
2233
  }
2142
2234
 
2143
2235
  case 'conditional': {
@@ -2287,16 +2379,20 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
2287
2379
  const iterMethod = node.method ?? 'map'
2288
2380
  let mapExpr: string
2289
2381
  if (node.flatMapCallback) {
2290
- let body = node.flatMapCallback.templateBody ?? node.flatMapCallback.body
2291
- for (const frag of node.flatMapCallback.fragments) {
2292
- const renderedIr = recurseInLoopBody(frag.ir)
2293
- body = body.replace(frag.placeholder, `\`${renderedIr}\``)
2294
- }
2295
- body = applyPropsRewrite(body, propsObjectName ?? null)
2382
+ // Module-scope template context: pick the templateText variant
2383
+ // (destructured-prop refs rewritten to _p.xxx) and apply the
2384
+ // props-object rewrite to js segments only — a rendered leaf handles
2385
+ // its own props context via recurseInLoopBody.
2386
+ const body = renderPreamble(node.flatMapCallback, {
2387
+ textVariant: 'template',
2388
+ transformJs: (t) => applyPropsRewrite(t, propsObjectName ?? null),
2389
+ renderLeaf: (ir) => recurseInLoopBody(ir),
2390
+ })
2296
2391
  mapExpr = `\${${iterArrayExpr}.flatMap(${node.flatMapCallback.params} => ${body}).join('')}`
2297
- } else if (node.mapPreamble) {
2298
- const rawPreamble = node.templateMapPreamble ?? node.mapPreamble
2299
- const preamble = applyPropsRewrite(rawPreamble, propsObjectName ?? null)
2392
+ } else if (node.preamble) {
2393
+ // Stage 3 / D4 — template-variant js text with the props rewrite
2394
+ // applied per segment; JSX leaves render via the loop-body recursion.
2395
+ const preamble = renderPreamble(node.preamble, { textVariant: 'template', transformJs: (t) => applyPropsRewrite(t, propsObjectName ?? null), renderLeaf: (ir) => recurseInLoopBody(ir) })
2300
2396
  mapExpr = `\${${iterArrayExpr}.${iterMethod}(${callbackParam} => { ${preamble} return \`${childTemplate}\` }).join('')}`
2301
2397
  } else {
2302
2398
  mapExpr = `\${${iterArrayExpr}.${iterMethod}(${callbackParam} => \`${childTemplate}\`).join('')}`
@@ -17,11 +17,23 @@
17
17
  * stringifier never inspects raw IR.
18
18
  */
19
19
 
20
- import type { IRLoopChildComponent } from '../../types.ts'
21
- import type { NestedLoop, TopLevelLoop } from '../types.ts'
22
- import type { ClientJsContext } from '../types.ts'
20
+ import type { IRLoopChildComponent, MapCallbackPreamble } from '../../types.ts'
21
+ import type { ClientJsContext, NestedLoop, TopLevelLoop } from '../types.ts'
23
22
  import { quotePropName, varSlotId, attrValueToString, buildLoopChildIndexExpr } from '../utils.ts'
24
- import { irChildrenToJsExpr } from '../html-template.ts'
23
+ import { irChildrenToJsExpr, renderPreamble, irToHtmlTemplate } from '../html-template.ts'
24
+
25
+ /**
26
+ * Render a loop preamble for the static-array init context. Static `forEach`
27
+ * binds params as plain values (see `buildStaticPropsExpr` note), so neither
28
+ * js text nor leaf-JSX param reads get accessor-wrapped.
29
+ */
30
+ function staticPreludeStatements(preamble: MapCallbackPreamble | undefined): string[] {
31
+ return preamble
32
+ ? [renderPreamble(preamble, {
33
+ renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, undefined, undefined, true),
34
+ })]
35
+ : []
36
+ }
25
37
  import { buildCompSelector } from '../control-flow/shared.ts'
26
38
 
27
39
  /** The inline prop shape carried on `IRLoopChildComponent.props`. */
@@ -94,7 +106,7 @@ function buildSingleCompPlan(
94
106
  arrayExpr: elem.array,
95
107
  param: elem.param,
96
108
  indexParam: elem.index || '__idx',
97
- outerPreludeStatements: elem.mapPreamble ? [elem.mapPreamble] : [],
109
+ outerPreludeStatements: staticPreludeStatements(elem.preamble),
98
110
  propsExpr: buildStaticPropsExpr(props),
99
111
  }
100
112
  }
@@ -113,7 +125,7 @@ function buildOuterNestedPlan(
113
125
  param: elem.param,
114
126
  indexParam,
115
127
  offsetExpr: buildLoopChildIndexExpr(indexParam, elem.offset),
116
- outerPreludeStatements: elem.mapPreamble ? [elem.mapPreamble] : [],
128
+ outerPreludeStatements: staticPreludeStatements(elem.preamble),
117
129
  propsExpr: buildStaticPropsExpr(comp.props),
118
130
  }
119
131
  }
@@ -142,13 +154,13 @@ function buildInnerLoopNestedPlan(
142
154
  outerParam: elem.param,
143
155
  outerIndexParam,
144
156
  outerOffsetExpr: buildLoopChildIndexExpr(outerIndexParam, elem.offset),
145
- outerPreludeStatements: elem.mapPreamble ? [elem.mapPreamble] : [],
157
+ outerPreludeStatements: staticPreludeStatements(elem.preamble),
146
158
  innerContainerSlotId: innerLoop.containerSlotId ?? null,
147
159
  innerArrayExpr: innerLoop.array,
148
160
  innerParam: innerLoop.param,
149
161
  innerIndexParam,
150
162
  innerOffsetExpr: buildLoopChildIndexExpr(innerIndexParam, innerLoop.offset),
151
- innerPreludeStatements: innerLoop.mapPreamble ? [innerLoop.mapPreamble] : [],
163
+ innerPreludeStatements: staticPreludeStatements(innerLoop.preamble),
152
164
  depth: innerLoop.depth,
153
165
  comps,
154
166
  }
@@ -189,11 +201,11 @@ function buildComponentRootedInnerLoopPlan(
189
201
  // position, so there's no synthetic fallback and index-less loops keep
190
202
  // byte-identical output.
191
203
  outerIndexParam: elem.index,
192
- outerPreludeStatements: elem.mapPreamble ? [elem.mapPreamble] : [],
204
+ outerPreludeStatements: staticPreludeStatements(elem.preamble),
193
205
  innerArrayExpr: innerLoop.array,
194
206
  innerParam: innerLoop.param,
195
207
  innerIndexParam: innerLoop.index,
196
- innerPreludeStatements: innerLoop.mapPreamble ? [innerLoop.mapPreamble] : [],
208
+ innerPreludeStatements: staticPreludeStatements(innerLoop.preamble),
197
209
  depth: innerLoop.depth,
198
210
  comps,
199
211
  }
@@ -22,6 +22,7 @@ import type {
22
22
  ImportInfo,
23
23
  TypeInfo,
24
24
  TypeDefinition,
25
+ MapCallbackPreamble,
25
26
  } from '../types.ts'
26
27
  import type { CsrInlinabilityMap } from './csr-substitute.ts'
27
28
  import type { SkeletonSlotPaths } from './html-template.ts'
@@ -356,7 +357,7 @@ export interface BranchLoop extends LoopCore {
356
357
  index: string | null // Index parameter (e.g., 'i')
357
358
  template: string // HTML template for each item
358
359
  containerSlotId: string // bf slot ID of the container element (e.g., 's1' for <ul bf="s1">)
359
- mapPreamble: string | null
360
+ preamble?: MapCallbackPreamble
360
361
  // Composite loop fields (loops whose body contains child components)
361
362
  nestedComponents?: IRLoopChildComponent[]
362
363
  innerLoops?: NestedLoop[]
@@ -442,7 +443,7 @@ export interface NestedLoop extends LoopCore {
442
443
  * `renderItem` callback so locals referenced by the cloned-template IIFE
443
444
  * (and any subsequent reads) are in scope (#1052).
444
445
  */
445
- mapPreamble?: string
446
+ preamble?: MapCallbackPreamble
446
447
  /** Whether the inner array references the outer loop param (needs reactive mapArray) */
447
448
  refsOuterParam?: boolean
448
449
  /** Child components inside inner loop items (for initChild/createComponent) */
@@ -599,7 +600,7 @@ export interface TopLevelLoop extends LoopCore {
599
600
  raw: string // Full comparator body for client JS
600
601
  }
601
602
  chainOrder?: 'filter-sort' | 'sort-filter'
602
- mapPreamble?: string
603
+ preamble?: MapCallbackPreamble
603
604
  }
604
605
 
605
606
  /**