@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
@@ -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, FlatMapCallback, 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'
@@ -276,9 +276,14 @@ function templateAttrExpr(attrName: string, valExpr: string, presenceOrUndefined
276
276
  // `data-key` / `data-key-N` is a reconciliation contract — every loop item
277
277
  // must carry one. Emit unconditionally; if the user passes `key={undefined}`
278
278
  // we want it to surface as `data-key="undefined"` (and ultimately a runtime
279
- // assertion in mapArray) rather than silently fall back to "no key".
279
+ // assertion in mapArray) rather than silently fall back to "no key"
280
+ // `escapeAttr(undefined)` stringifies to exactly that. The value is escaped
281
+ // like every other dynamic attribute: SSR adapters escape it (their
282
+ // template engines do), so an unescaped `"` in a key — surfaced by the
283
+ // flatmap-expression-body fixture's adversarial keys — corrupted the
284
+ // client-assembled HTML and diverged from the SSR bytes.
280
285
  if (attrName === 'data-key' || attrName.startsWith('data-key-')) {
281
- return `${attrName}="\${${valExpr}}"`
286
+ return `${attrName}="\${${escapeAttrValueExpr(valExpr)}}"`
282
287
  }
283
288
  return `\${(${valExpr}) != null ? '${attrName}="' + ${escapeAttrValueExpr(valExpr)} + '"' : ''}`
284
289
  }
@@ -556,6 +561,169 @@ function itemAnchorTemplate(keyExpr: string): string {
556
561
  return `<!--${loopItemMarker('${' + keyExpr + '}')}-->`
557
562
  }
558
563
 
564
+ /**
565
+ * The ONLY door from a structured `.map()` preamble to emitted text (Stage 3
566
+ * root cure, spec/callback-fidelity.md). `js` segments contribute their JS
567
+ * text (template variant when requested, then the caller's context transform —
568
+ * e.g. the loop-param accessor wrap); `jsx` segments render their compiled IR
569
+ * as a template-literal HTML string. There is no sentinel substitution because
570
+ * there is no sentinel: mixed content stays structured until this function.
571
+ * Emitters that cannot host a string-templated preamble must not call this —
572
+ * they refuse at plan dispatch instead of splicing.
573
+ */
574
+ export function renderPreamble(
575
+ preamble: Pick<MapCallbackPreamble, 'segments'>,
576
+ opts: {
577
+ /** Pick `templateText` on js segments (hydrate/CSR template contexts). */
578
+ textVariant?: 'client' | 'template'
579
+ /** Context transform for js text (e.g. wrapLoopParamAsAccessor). */
580
+ transformJs?: (text: string) => string
581
+ /** Context-appropriate leaf renderer (an irToHtmlTemplate variant). */
582
+ renderLeaf: (ir: IRNode) => string
583
+ /**
584
+ * When true, `renderLeaf` output is spliced verbatim — the renderer
585
+ * supplies its own delimiters (e.g. the flatMap descriptor form
586
+ * `({ k, h })`). Default wraps each leaf in a template literal.
587
+ */
588
+ rawLeaf?: boolean
589
+ },
590
+ ): string {
591
+ let out = ''
592
+ for (const seg of preamble.segments) {
593
+ if (seg.kind === 'js') {
594
+ const text = opts.textVariant === 'template' ? (seg.templateText ?? seg.text) : seg.text
595
+ out += opts.transformJs ? opts.transformJs(text) : text
596
+ } else if (opts.rawLeaf) {
597
+ out += opts.renderLeaf(escapeLeafTextExpressions(seg.ir))
598
+ } else {
599
+ out += '`' + opts.renderLeaf(escapeLeafTextExpressions(seg.ir)) + '`'
600
+ }
601
+ }
602
+ return out
603
+ }
604
+
605
+ /**
606
+ * Project a flatMap segment leaf's `key={...}` attribute into a runtime key
607
+ * expression, or `null` when the leaf declares none. Template-literal keys
608
+ * (`key={\`${it.id}:${tag}\`}`) are first-class here — unlike the loop-level
609
+ * `extractLoopKey`, the expression is evaluated inside the flatMap body where
610
+ * the callback params are in scope, so any expression form works.
611
+ */
612
+ export function flatMapLeafKeyExpr(ir: IRNode): string | null {
613
+ if (ir.type !== 'element') return null
614
+ const keyAttr = ir.attrs.find((a) => a.name === 'key')
615
+ if (!keyAttr) return null
616
+ switch (keyAttr.value.kind) {
617
+ case 'expression':
618
+ return `(${keyAttr.value.expr})`
619
+ case 'literal':
620
+ return JSON.stringify(keyAttr.value.value)
621
+ case 'template':
622
+ return attrValueToString(keyAttr.value)
623
+ default:
624
+ return null
625
+ }
626
+ }
627
+
628
+ /** Copy of `ir` with the loop `key` attribute removed (element leaves only). */
629
+ function stripLeafKeyAttr(ir: IRNode): IRNode {
630
+ if (ir.type !== 'element') return ir
631
+ return { ...ir, attrs: ir.attrs.filter((a) => a.name !== 'key') }
632
+ }
633
+
634
+ /**
635
+ * Render a `FlatMapCallback` as the CLIENT-SIDE descriptor body for
636
+ * `mapArray` (the reconciliation twin of the string-template rendering in
637
+ * `irToHtmlTemplate`'s `'loop'` case). Each JSX leaf becomes
638
+ * `({ k: <keyExpr>, h: \`<html>\` })` — the flatMap flattens descriptors,
639
+ * `mapArray` keys on `d.k` (index fallback), and the emitted renderItem
640
+ * builds/patches the leaf element from `d.h`.
641
+ *
642
+ * Runs in the init scope where the loop SOURCE items are plain values (the
643
+ * flatMap executes inside the `mapArray` accessor, BEFORE per-item signals
644
+ * exist), so neither js segments nor leaf HTML get the accessor wrap — refs
645
+ * stay `t.title`, not `t().title`. `data-key` is deliberately NOT emitted in
646
+ * the leaf HTML: reconciliation identity is stamped by `mapArray` via
647
+ * `setAttribute`, matching the SSR side (which never emits it for flatMap
648
+ * leaves).
649
+ */
650
+ export function renderFlatMapClientBody(
651
+ cb: Pick<FlatMapCallback, 'segments'>,
652
+ restSpreadNames?: Set<string>,
653
+ ): string {
654
+ return renderPreamble(cb, {
655
+ textVariant: 'client',
656
+ rawLeaf: true,
657
+ renderLeaf: (ir) => {
658
+ const key = flatMapLeafKeyExpr(ir)
659
+ const html = irToHtmlTemplate(stripLeafKeyAttr(ir), restSpreadNames, 1, undefined, undefined, true)
660
+ return `({ k: ${key ?? 'undefined'}, h: \`${html}\` })`
661
+ },
662
+ })
663
+ }
664
+
665
+ /** True when any segment leaf declares a `key` — drives the mapArray keyFn. */
666
+ export function flatMapCallbackHasKeyedLeaf(cb: Pick<FlatMapCallback, 'segments'>): boolean {
667
+ return cb.segments.some((s) => s.kind === 'jsx' && flatMapLeafKeyExpr(s.ir) !== null)
668
+ }
669
+
670
+ /**
671
+ * Synthesize the client descriptor body for a flatMap PROJECTION loop —
672
+ * one whose only child is a nested `IRLoop` lowered from
673
+ * `flatMap(it => it.tags.map(tag => <li/>))`. The neutral IR is the single
674
+ * carrier: SSR adapters templatize the nested loop natively, and this
675
+ * derives the `mapArray` accessor's flatten projection from the SAME inner
676
+ * loop — `<chained-inner>.map((tag, i) => ({ k: <inner.key>, h: `<leaf>` }))`.
677
+ * Runs in the accessor context (plain source items, no per-item signals),
678
+ * so leaf refs stay unwrapped. Leaf `key` attrs were already stripped at IR
679
+ * build (`stripLoopLeafKeyAttrs`); the inner loop's `key` FIELD supplies
680
+ * `k`.
681
+ */
682
+ export function renderFlatMapProjectionClientBody(
683
+ inner: Extract<IRNode, { type: 'loop' }>,
684
+ restSpreadNames?: Set<string>,
685
+ ): string {
686
+ const chained = applyLoopChain(inner)
687
+ const params = inner.index ? `(${inner.param}, ${inner.index})` : `(${inner.param})`
688
+ const key = inner.key ? `(${inner.key})` : 'undefined'
689
+ const html = inner.children
690
+ .map((c) => irToHtmlTemplate(escapeLeafTextExpressions(c), restSpreadNames, 1, undefined, undefined, true))
691
+ .join('')
692
+ return `${chained}.map(${params} => ({ k: ${key}, h: \`${html}\` }))`
693
+ }
694
+
695
+ /**
696
+ * SSR/CSR escaping parity for preamble leaves, decided once at the door: a
697
+ * JSX-runtime SSR adapter renders the leaf's raw JSX and auto-escapes text
698
+ * interpolations (`{c}`), so the client's HTML-string lowering must escape the
699
+ * same positions — wrap every text-position expression in `escapeText(...)`.
700
+ * Pure (returns a transformed copy); the neutral IR is never mutated with a
701
+ * client-only concern. Attribute values already flow through the template
702
+ * emitters' own attr escaping.
703
+ */
704
+ function escapeLeafTextExpressions(ir: IRNode): IRNode {
705
+ switch (ir.type) {
706
+ case 'element':
707
+ return { ...ir, children: ir.children.map(escapeLeafTextExpressions) }
708
+ case 'fragment':
709
+ return { ...ir, children: ir.children.map(escapeLeafTextExpressions) }
710
+ case 'expression': {
711
+ if (ir.expr === 'null' || ir.expr === 'undefined') return ir
712
+ // Already-wrapped or slotted expressions keep their existing handling.
713
+ if (ir.slotId || ir.expr.trimStart().startsWith('escapeText(')) return ir
714
+ return { ...ir, expr: `escapeText((${ir.expr}))`, templateExpr: ir.templateExpr ? `escapeText((${ir.templateExpr}))` : ir.templateExpr }
715
+ }
716
+ case 'conditional':
717
+ return {
718
+ ...ir,
719
+ whenTrue: escapeLeafTextExpressions(ir.whenTrue),
720
+ whenFalse: ir.whenFalse ? escapeLeafTextExpressions(ir.whenFalse) : ir.whenFalse,
721
+ }
722
+ default:
723
+ return ir
724
+ }
725
+ }
726
+
559
727
  export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, loopDepth = 0, loopParams?: ReadonlyArray<string | LoopParamSpec>, branchSlotsVar?: string, insideLoop = false, inHoistedChildren = false): string {
560
728
  const recurse = (n: IRNode): string => irToHtmlTemplate(n, restSpreadNames, loopDepth, loopParams, branchSlotsVar, insideLoop, inHoistedChildren)
561
729
  const wrapExpr = (expr: string) => wrapExprWithLoopParams(expr, loopParams)
@@ -624,20 +792,29 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, lo
624
792
  // as HTML (template.innerHTML), so re-escape for the HTML parser.
625
793
  return escapeHtml(node.value)
626
794
 
627
- case 'expression':
795
+ case 'expression': {
628
796
  if (node.expr === 'null' || node.expr === 'undefined') return ''
797
+ const inner = wrapInterpolation(wrapExpr(node.expr))
798
+ // Stage 3 / D4 — an element-array child ({out}) built by an arbitrary
799
+ // .map() preamble is an array of HTML strings; join it rather than let
800
+ // `${[...]}` `String`-comma-collapse it. Only reached on a JS-runtime
801
+ // adapter (the flag is set in Phase 1 only there); a plain local can be
802
+ // read twice safely.
803
+ const valueExpr = node.joinArrayChild
804
+ ? `Array.isArray(${inner}) ? ${inner}.join('') : (${inner} ?? '')`
805
+ : inner
629
806
  if (node.slotId) {
630
- const inner = wrapInterpolation(wrapExpr(node.expr))
631
807
  // In branch-slot context `wrapInterpolation` routes the value
632
808
  // through `__bfSlot`, which returns raw `<!--bf-slot:N-->` markers
633
809
  // for live `Node` values (spliced back by `insert()`). Escaping
634
810
  // would corrupt those markers and drop slotted content (#1694
635
811
  // regression). `__bfSlot` owns coercion of its own value, so the
636
812
  // text-escape applies only to the non-slot (plain text) form.
637
- const slotted = branchSlotsVar ? inner : escapeTextSlotExpr(inner)
813
+ const slotted = branchSlotsVar || node.joinArrayChild ? valueExpr : escapeTextSlotExpr(valueExpr)
638
814
  return `<!--bf:${node.slotId}-->\${${slotted}}<!--/-->`
639
815
  }
640
- return `\${${wrapInterpolation(wrapExpr(node.expr))}}`
816
+ return `\${${valueExpr}}`
817
+ }
641
818
 
642
819
  case 'conditional': {
643
820
  const trueBranch = recurse(node.whenTrue)
@@ -740,15 +917,21 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, lo
740
917
  let mapExpr: string
741
918
 
742
919
  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
- }
920
+ // Complex flatMap: the body is structured segments, rendered through
921
+ // the same single door as map preambles.
922
+ // Leaf `key` is stripped from the string form: SSR (Hono rawBody)
923
+ // never emits data-key for flatMap leaves, and reconciliation
924
+ // identity is stamped by mapArray via setAttribute — emitting it
925
+ // here was the CSR/SSR data-key asymmetry (unescaped, client-only).
926
+ const body = renderPreamble(node.flatMapCallback, {
927
+ renderLeaf: (ir) => irToHtmlTemplate(stripLeafKeyAttr(ir), restSpreadNames, loopDepth + 1, loopParams, branchSlotsVar, insideLoop),
928
+ })
749
929
  mapExpr = `\${${wrappedArray}.flatMap(${node.flatMapCallback.params} => ${body}).join('')}`
750
- } else if (node.mapPreamble) {
751
- mapExpr = `\${${wrappedArray}.${iterMethod}(${callbackParam} => { ${node.mapPreamble} return \`${childTemplate}\` }).join('')}`
930
+ } else if (node.preamble) {
931
+ // Stage 3 / D4 render JSX leaves in an arbitrary array-builder
932
+ // preamble (the hydrate-template context uses the bare loop param).
933
+ const preamble = renderPreamble(node.preamble, { textVariant: 'client', renderLeaf: (ir) => irToHtmlTemplate(ir, restSpreadNames, loopDepth + 1, loopParams, branchSlotsVar, insideLoop) })
934
+ mapExpr = `\${${wrappedArray}.${iterMethod}(${callbackParam} => { ${preamble} return \`${childTemplate}\` }).join('')}`
752
935
  } else {
753
936
  mapExpr = `\${${wrappedArray}.${iterMethod}(${callbackParam} => \`${childTemplate}\`).join('')}`
754
937
  }
@@ -1138,12 +1321,18 @@ export function irToPlaceholderTemplate(node: IRNode, restSpreadNames?: Set<stri
1138
1321
  // as HTML (template.innerHTML), so re-escape for the HTML parser.
1139
1322
  return escapeHtml(node.value)
1140
1323
 
1141
- case 'expression':
1324
+ case 'expression': {
1142
1325
  if (node.expr === 'null' || node.expr === 'undefined') return ''
1326
+ const wrapped = wrapExpr(node.expr)
1327
+ // Stage 3 / D4 — join an element-array child ({out}) built by the preamble.
1328
+ const value = node.joinArrayChild
1329
+ ? `Array.isArray(${wrapped}) ? ${wrapped}.join('') : (${wrapped} ?? '')`
1330
+ : wrapped
1143
1331
  if (node.slotId) {
1144
- return `<!--bf:${node.slotId}-->\${${escapeTextSlotExpr(wrapExpr(node.expr))}}<!--/-->`
1332
+ return `<!--bf:${node.slotId}-->\${${node.joinArrayChild ? value : escapeTextSlotExpr(wrapped)}}<!--/-->`
1145
1333
  }
1146
- return `\${${wrapExpr(node.expr)}}`
1334
+ return `\${${value}}`
1335
+ }
1147
1336
 
1148
1337
  case 'conditional': {
1149
1338
  const trueBranch = recurse(node.whenTrue)
@@ -1180,14 +1369,15 @@ export function irToPlaceholderTemplate(node: IRNode, restSpreadNames?: Set<stri
1180
1369
  const iterMethod = node.method ?? 'map'
1181
1370
  let mapExpr: string
1182
1371
  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
- }
1372
+ const body = renderPreamble(node.flatMapCallback, {
1373
+ // Leaf `key` stripped — see the irToHtmlTemplate site above.
1374
+ renderLeaf: (ir) => irToPlaceholderTemplate(stripLeafKeyAttr(ir), restSpreadNames, loopDepth + 1, loopParams),
1375
+ })
1188
1376
  mapExpr = `\${${wrappedArray}.flatMap(${node.flatMapCallback.params} => ${body}).join('')}`
1189
- } else if (node.mapPreamble) {
1190
- mapExpr = `\${${wrappedArray}.${iterMethod}(${callbackParam} => { ${node.mapPreamble} return \`${childTemplate}\` }).join('')}`
1377
+ } else if (node.preamble) {
1378
+ // Stage 3 / D4 render JSX leaves in an arbitrary array-builder preamble.
1379
+ const preamble = renderPreamble(node.preamble, { textVariant: 'client', renderLeaf: (ir) => irToPlaceholderTemplate(ir, restSpreadNames, loopDepth + 1, loopParams) })
1380
+ mapExpr = `\${${wrappedArray}.${iterMethod}(${callbackParam} => { ${preamble} return \`${childTemplate}\` }).join('')}`
1191
1381
  } else {
1192
1382
  mapExpr = `\${${wrappedArray}.${iterMethod}(${callbackParam} => \`${childTemplate}\`).join('')}`
1193
1383
  }
@@ -1553,12 +1743,18 @@ function irToComponentTemplateWithOpts(node: IRNode, opts: TemplateOptions): str
1553
1743
  // as HTML (template.innerHTML), so re-escape for the HTML parser.
1554
1744
  return escapeHtml(node.value)
1555
1745
 
1556
- case 'expression':
1746
+ case 'expression': {
1557
1747
  if (node.expr === 'null' || node.expr === 'undefined') return ''
1748
+ const wrapped = transformExpr(node.expr, node.templateExpr)
1749
+ // Stage 3 / D4 — join an element-array child ({out}) built by the preamble.
1750
+ const value = node.joinArrayChild
1751
+ ? `Array.isArray(${wrapped}) ? ${wrapped}.join('') : (${wrapped} ?? '')`
1752
+ : wrapped
1558
1753
  if (node.slotId) {
1559
- return `<!--bf:${node.slotId}-->\${${escapeTextSlotExpr(transformExpr(node.expr, node.templateExpr))}}<!--/-->`
1754
+ return `<!--bf:${node.slotId}-->\${${node.joinArrayChild ? value : escapeTextSlotExpr(wrapped)}}<!--/-->`
1560
1755
  }
1561
- return `\${${transformExpr(node.expr, node.templateExpr)}}`
1756
+ return `\${${value}}`
1757
+ }
1562
1758
 
1563
1759
  case 'conditional': {
1564
1760
  // A client-only conditional (auto-deferred brand read or manual
@@ -2134,10 +2330,14 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
2134
2330
  // before init's createEffect overwrites the slot (#1128). Emit
2135
2331
  // an empty placeholder instead.
2136
2332
  const expr = transformed === UNSAFE_TEMPLATE_EXPR ? "''" : transformed
2333
+ // Stage 3 / D4 — join an element-array child ({out}) built by the preamble.
2334
+ const value = node.joinArrayChild
2335
+ ? `Array.isArray(${expr}) ? ${expr}.join('') : (${expr} ?? '')`
2336
+ : expr
2137
2337
  if (node.slotId) {
2138
- return `<!--bf:${node.slotId}-->\${${escapeTextSlotExpr(expr)}}<!--/-->`
2338
+ return `<!--bf:${node.slotId}-->\${${node.joinArrayChild ? value : escapeTextSlotExpr(expr)}}<!--/-->`
2139
2339
  }
2140
- return `\${${expr}}`
2340
+ return `\${${value}}`
2141
2341
  }
2142
2342
 
2143
2343
  case 'conditional': {
@@ -2287,16 +2487,21 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
2287
2487
  const iterMethod = node.method ?? 'map'
2288
2488
  let mapExpr: string
2289
2489
  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)
2490
+ // Module-scope template context: pick the templateText variant
2491
+ // (destructured-prop refs rewritten to _p.xxx) and apply the
2492
+ // props-object rewrite to js segments only — a rendered leaf handles
2493
+ // its own props context via recurseInLoopBody.
2494
+ const body = renderPreamble(node.flatMapCallback, {
2495
+ textVariant: 'template',
2496
+ transformJs: (t) => applyPropsRewrite(t, propsObjectName ?? null),
2497
+ // Leaf `key` stripped — see the irToHtmlTemplate site above.
2498
+ renderLeaf: (ir) => recurseInLoopBody(stripLeafKeyAttr(ir)),
2499
+ })
2296
2500
  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)
2501
+ } else if (node.preamble) {
2502
+ // Stage 3 / D4 — template-variant js text with the props rewrite
2503
+ // applied per segment; JSX leaves render via the loop-body recursion.
2504
+ const preamble = renderPreamble(node.preamble, { textVariant: 'template', transformJs: (t) => applyPropsRewrite(t, propsObjectName ?? null), renderLeaf: (ir) => recurseInLoopBody(ir) })
2300
2505
  mapExpr = `\${${iterArrayExpr}.${iterMethod}(${callbackParam} => { ${preamble} return \`${childTemplate}\` }).join('')}`
2301
2506
  } else {
2302
2507
  mapExpr = `\${${iterArrayExpr}.${iterMethod}(${callbackParam} => \`${childTemplate}\`).join('')}`
@@ -8,7 +8,7 @@ import { isClientBuiltinName } from '../builtins.ts'
8
8
  // All exports from @barefootjs/client/runtime that may be used in generated code
9
9
  export const RUNTIME_IMPORT_CANDIDATES = [
10
10
  'createSignal', 'createMemo', 'createEffect', 'onCleanup', 'onMount',
11
- 'hydrate', 'insert', 'reconcileElements', 'getLoopChildren', 'getLoopNodes', 'mapArray', 'mapArrayAnchored', 'createDisposableEffect',
11
+ 'hydrate', 'insert', 'reconcileElements', 'getLoopChildren', 'getLoopNodes', 'mapArray', 'mapArrayAnchored', 'patchLeaf', 'patchSlotRange', 'createDisposableEffect',
12
12
  'createComponent', 'renderChild', 'registerComponent', 'registerTemplate', 'initChild', 'upsertChild', 'updateClientMarker',
13
13
  'createPortal',
14
14
  'provideContext', 'createContext', 'useContext',
@@ -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
  }
@@ -570,6 +570,12 @@ export function collectLoopChildReactiveTexts(
570
570
  ...stopAt<boolean>('loop', 'async', 'ifStatement'),
571
571
  expression: ({ node: n, scope: insideConditional }) => {
572
572
  if (!n.slotId) return
573
+ // #2389 — a preamble-patched region (see `IRLoop.preambleRegions`) has
574
+ // its own `patchSlotRange`-based effect (`preambleRegions` in the
575
+ // client-JS loop plan); it must never ALSO become a `reactiveTexts`
576
+ // entry, which would patch it via `.textContent` and corrupt markup
577
+ // (a `joinArrayChild` region's value is raw HTML, not text).
578
+ if (n.preambleRegion) return
573
579
  const originFreeIds = freeIdsFromRefs(n.origin?.freeRefs)
574
580
  const expanded = expandConstantForReactivity(n.expr, ctx, originFreeIds)
575
581
  // Include if expression reads signals OR references the loop parameter
@@ -22,6 +22,8 @@ import type {
22
22
  ImportInfo,
23
23
  TypeInfo,
24
24
  TypeDefinition,
25
+ MapCallbackPreamble,
26
+ PreambleRegionSource,
25
27
  } from '../types.ts'
26
28
  import type { CsrInlinabilityMap } from './csr-substitute.ts'
27
29
  import type { SkeletonSlotPaths } from './html-template.ts'
@@ -309,6 +311,29 @@ export interface LoopCore {
309
311
  * `IRLoop.objectIteration`.
310
312
  */
311
313
  objectIteration?: 'entries' | 'keys' | 'values'
314
+
315
+ /**
316
+ * Client-side descriptor body for a flatMap loop carried as structured
317
+ * segments (`IRLoop.flatMapCallback`). `body` is the callback body
318
+ * rendered so each JSX leaf becomes a `({ k, h })` descriptor
319
+ * (`renderFlatMapClientBody`); the plan builder wires
320
+ * `mapArray(() => <chained>.flatMap(<params> => <body>), …)` so the
321
+ * runtime reconciles the FLATTENED leaves — never the un-flattened
322
+ * source items (which loses leaves at hydration and crashes on adds
323
+ * against an empty item template). `keyed` is true when any leaf
324
+ * declares a `key`, driving the `d.k`-based keyFn.
325
+ */
326
+ flatMapClient?: { params: string; body: string; keyed: boolean }
327
+
328
+ /**
329
+ * Loop-body expression children classified as preamble-patched regions
330
+ * (#2389), threaded from `IRLoop.preambleRegions`. Consumed only by the
331
+ * top-level and branch **plain** loop-plan builders (`build-loop.ts`,
332
+ * `build-branch-loop.ts`) — the composite / component / static / anchored
333
+ * shapes leave this unconsumed (stale — same pre-existing freeze, not a
334
+ * new regression) pending a follow-up.
335
+ */
336
+ preambleRegions?: readonly PreambleRegionSource[]
312
337
  }
313
338
 
314
339
  /**
@@ -356,7 +381,7 @@ export interface BranchLoop extends LoopCore {
356
381
  index: string | null // Index parameter (e.g., 'i')
357
382
  template: string // HTML template for each item
358
383
  containerSlotId: string // bf slot ID of the container element (e.g., 's1' for <ul bf="s1">)
359
- mapPreamble: string | null
384
+ preamble?: MapCallbackPreamble
360
385
  // Composite loop fields (loops whose body contains child components)
361
386
  nestedComponents?: IRLoopChildComponent[]
362
387
  innerLoops?: NestedLoop[]
@@ -442,7 +467,7 @@ export interface NestedLoop extends LoopCore {
442
467
  * `renderItem` callback so locals referenced by the cloned-template IIFE
443
468
  * (and any subsequent reads) are in scope (#1052).
444
469
  */
445
- mapPreamble?: string
470
+ preamble?: MapCallbackPreamble
446
471
  /** Whether the inner array references the outer loop param (needs reactive mapArray) */
447
472
  refsOuterParam?: boolean
448
473
  /** Child components inside inner loop items (for initChild/createComponent) */
@@ -599,7 +624,7 @@ export interface TopLevelLoop extends LoopCore {
599
624
  raw: string // Full comparator body for client JS
600
625
  }
601
626
  chainOrder?: 'filter-sort' | 'sort-filter'
602
- mapPreamble?: string
627
+ preamble?: MapCallbackPreamble
603
628
  }
604
629
 
605
630
  /**