@barefootjs/jsx 0.26.3 → 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 (54) hide show
  1. package/dist/index.js +433 -55
  2. package/dist/ir-to-client-js/build-references.d.ts.map +1 -1
  3. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  4. package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts +12 -1
  5. package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts.map +1 -1
  6. package/dist/ir-to-client-js/control-flow/plan/build-branch-loop.d.ts.map +1 -1
  7. package/dist/ir-to-client-js/control-flow/plan/build-event-delegation.d.ts.map +1 -1
  8. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  9. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts +16 -1
  10. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +27 -0
  12. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/control-flow/shared.d.ts +12 -1
  14. package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
  15. package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts +19 -0
  17. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/html-template.d.ts +49 -1
  19. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/imports.d.ts +2 -2
  21. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/types.d.ts +26 -1
  24. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  25. package/dist/jsx-to-ir.d.ts.map +1 -1
  26. package/dist/types.d.ts +47 -0
  27. package/dist/types.d.ts.map +1 -1
  28. package/package.json +2 -2
  29. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +187 -37
  30. package/src/__tests__/client-js-generation.test.ts +8 -2
  31. package/src/__tests__/compiler-runtime-contract.test.ts +4 -4
  32. package/src/__tests__/delegated-handler-preamble.test.ts +153 -0
  33. package/src/__tests__/flatmap-segments.test.ts +182 -0
  34. package/src/__tests__/map-body-no-silent-divergence.test.ts +45 -0
  35. package/src/__tests__/preamble-region-patch.test.ts +150 -0
  36. package/src/__tests__/static-loop-csr-materialize.test.ts +3 -2
  37. package/src/ir-to-client-js/build-references.ts +16 -0
  38. package/src/ir-to-client-js/collect-elements.ts +66 -10
  39. package/src/ir-to-client-js/control-flow/plan/branch-loop.ts +12 -1
  40. package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +11 -3
  41. package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +19 -3
  42. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +36 -0
  43. package/src/ir-to-client-js/control-flow/plan/event-delegation.ts +16 -1
  44. package/src/ir-to-client-js/control-flow/plan/loop.ts +28 -0
  45. package/src/ir-to-client-js/control-flow/shared.ts +26 -1
  46. package/src/ir-to-client-js/control-flow/stringify/branch-loop.ts +25 -3
  47. package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +67 -19
  48. package/src/ir-to-client-js/control-flow/stringify/loop.ts +65 -1
  49. package/src/ir-to-client-js/html-template.ts +115 -6
  50. package/src/ir-to-client-js/imports.ts +1 -1
  51. package/src/ir-to-client-js/reactivity.ts +6 -0
  52. package/src/ir-to-client-js/types.ts +24 -0
  53. package/src/jsx-to-ir.ts +374 -8
  54. package/src/types.ts +49 -0
@@ -74,6 +74,26 @@ function indexBindingLine(handler: string, indexParam: string | null, indexExpr:
74
74
  return `const ${indexParam} = ${indexExpr}`
75
75
  }
76
76
 
77
+ /**
78
+ * Splice-only-when-referenced (#3, BUG-3 fix part 2): a preamble is dead
79
+ * weight for an event whose handler never reads one of the names it
80
+ * declares — the common case (an unused array builder like `cells` in the
81
+ * BUG-3 repro). Only emit `mapPreamble` when `handler`'s free identifiers
82
+ * intersect `declaredNames`; otherwise the delegated handler pays nothing
83
+ * for a preamble it never uses. `declaredNames` is empty whenever
84
+ * `mapPreamble` is `null`, so the `!mapPreamble` short-circuit is mostly
85
+ * redundant but keeps this correct-by-construction if that ever changes.
86
+ */
87
+ function preambleLineForHandler(
88
+ mapPreamble: string | null,
89
+ declaredNames: readonly string[],
90
+ handler: string,
91
+ ): string | null {
92
+ if (!mapPreamble || declaredNames.length === 0) return null
93
+ const free = extractFreeIdentifiersFromText(handler)
94
+ return declaredNames.some((name) => free.has(name)) ? mapPreamble : null
95
+ }
96
+
77
97
  export function stringifyEventDelegation(lines: string[], plan: EventDelegationPlan): void {
78
98
  const { containerVar, events, itemLookup, profileComponentName } = plan
79
99
  const eventsByName = new Map<string, LoopChildEvent[]>()
@@ -133,7 +153,8 @@ function emitKeyedLookup(
133
153
  handlerCall: string,
134
154
  lookup: KeyedItemLookup,
135
155
  ): void {
136
- const { arrayExpr, param, keyWithItem, mapPreamble, hasBindings, indexParam } = lookup
156
+ const { arrayExpr, param, keyWithItem, mapPreamble, mapPreambleDeclaredNames, hasBindings, indexParam } = lookup
157
+ const preambleLine = preambleLineForHandler(mapPreamble, mapPreambleDeclaredNames, ev.handler)
137
158
 
138
159
  if (ev.nestedLoops.length === 0) {
139
160
  // Single-level keyed lookup.
@@ -146,15 +167,28 @@ function emitKeyedLookup(
146
167
  ls.push(` const __bfLoopItem = ${arrayExpr}.find(item => String(${keyWithItem}) === key)`)
147
168
  ls.push(` if (__bfLoopItem) {`)
148
169
  ls.push(` const ${param} = __bfLoopItem`)
149
- if (mapPreamble) ls.push(` ${mapPreamble}`)
170
+ if (preambleLine) ls.push(` ${preambleLine}`)
150
171
  if (idxLine) ls.push(` ${idxLine}`)
151
- ls.push(` ${handlerCall}`)
172
+ // Leading `;` (not just relying on the preceding line's own
173
+ // semicolon): `handlerCall` always starts with `(` — an ASI hazard
174
+ // pre-existing in this branch even with neither optional line above
175
+ // (`const ${param} = __bfLoopItem` has no trailing `;`), which glues
176
+ // the call onto it as `__bfLoopItem(...)` and throws
177
+ // `TypeError: __bfLoopItem is not a function`. Defend at the one
178
+ // emission point rather than chasing every preceding-line shape.
179
+ ls.push(` ;${handlerCall}`)
152
180
  ls.push(` }`)
153
181
  } else {
182
+ // The preamble (when referenced) and the handler call both run INSIDE
183
+ // the item null guard — a `.find()` miss (stale-DOM race, e.g. the
184
+ // clicked row's key no longer in the current array) must short-circuit
185
+ // before a preamble that dereferences the item ever runs (BUG-4).
154
186
  ls.push(` const ${param} = ${arrayExpr}.find(item => String(${keyWithItem}) === key)`)
155
- if (mapPreamble) ls.push(` ${mapPreamble}`)
156
- if (idxLine) ls.push(` if (${param}) { ${idxLine}; ${handlerCall} }`)
157
- else ls.push(` if (${param}) ${handlerCall}`)
187
+ ls.push(` if (${param}) {`)
188
+ if (preambleLine) ls.push(` ${preambleLine}`)
189
+ if (idxLine) ls.push(` ${idxLine}`)
190
+ ls.push(` ;${handlerCall}`)
191
+ ls.push(` }`)
158
192
  }
159
193
  ls.push(` }`)
160
194
  return
@@ -187,10 +221,14 @@ function emitKeyedLookup(
187
221
  }
188
222
  const outerGuard = hasBindings ? '__bfLoopItem' : param
189
223
  const allParams = [outerGuard, ...ev.nestedLoops.map(n => n.param)]
190
- if (mapPreamble) ls.push(` ${mapPreamble}`)
224
+ // Preamble and idx binding run INSIDE the combined item guard (BUG-4)
225
+ // a nested `.find()` miss must short-circuit before the preamble runs.
191
226
  const idxLine = indexBindingLine(ev.handler, indexParam, `${arrayExpr}.findIndex(item => String(${keyWithItem}) === outerKey)`)
192
- if (idxLine) ls.push(` if (${allParams.join(' && ')}) { ${idxLine}; ${handlerCall} }`)
193
- else ls.push(` if (${allParams.join(' && ')}) ${handlerCall}`)
227
+ ls.push(` if (${allParams.join(' && ')}) {`)
228
+ if (preambleLine) ls.push(` ${preambleLine}`)
229
+ if (idxLine) ls.push(` ${idxLine}`)
230
+ ls.push(` ;${handlerCall}`)
231
+ ls.push(` }`)
194
232
  }
195
233
 
196
234
  function emitDynamicIndexLookup(
@@ -199,7 +237,8 @@ function emitDynamicIndexLookup(
199
237
  handlerCall: string,
200
238
  lookup: DynamicIndexItemLookup,
201
239
  ): void {
202
- const { arrayExpr, param, mapPreamble, hasBindings, indexParam } = lookup
240
+ const { arrayExpr, param, mapPreamble, mapPreambleDeclaredNames, hasBindings, indexParam } = lookup
241
+ const preambleLine = preambleLineForHandler(mapPreamble, mapPreambleDeclaredNames, ev.handler)
203
242
  const idxLine = indexBindingLine(ev.handler, indexParam, 'idx')
204
243
  ls.push(` const li = ${varSlotId(ev.childSlotId)}El.closest('li, [bf-i]')`)
205
244
  ls.push(` if (li && li.parentElement) {`)
@@ -208,15 +247,19 @@ function emitDynamicIndexLookup(
208
247
  ls.push(` const __bfLoopItem = ${arrayExpr}[idx]`)
209
248
  ls.push(` if (__bfLoopItem) {`)
210
249
  ls.push(` const ${param} = __bfLoopItem`)
211
- if (mapPreamble) ls.push(` ${mapPreamble}`)
250
+ if (preambleLine) ls.push(` ${preambleLine}`)
212
251
  if (idxLine) ls.push(` ${idxLine}`)
213
- ls.push(` ${handlerCall}`)
252
+ ls.push(` ;${handlerCall}`)
214
253
  ls.push(` }`)
215
254
  } else {
255
+ // Preamble and idx binding run INSIDE the item null guard (BUG-4) — an
256
+ // out-of-range index (stale-DOM race) must short-circuit first.
216
257
  ls.push(` const ${param} = ${arrayExpr}[idx]`)
217
- if (mapPreamble) ls.push(` ${mapPreamble}`)
218
- if (idxLine) ls.push(` if (${param}) { ${idxLine}; ${handlerCall} }`)
219
- else ls.push(` if (${param}) ${handlerCall}`)
258
+ ls.push(` if (${param}) {`)
259
+ if (preambleLine) ls.push(` ${preambleLine}`)
260
+ if (idxLine) ls.push(` ${idxLine}`)
261
+ ls.push(` ;${handlerCall}`)
262
+ ls.push(` }`)
220
263
  }
221
264
  ls.push(` }`)
222
265
  }
@@ -228,7 +271,8 @@ function emitStaticIndexLookup(
228
271
  lookup: StaticIndexItemLookup,
229
272
  containerVar: string,
230
273
  ): void {
231
- const { arrayExpr, param, mapPreamble, offset, indexParam } = lookup
274
+ const { arrayExpr, param, mapPreamble, mapPreambleDeclaredNames, offset, indexParam } = lookup
275
+ const preambleLine = preambleLineForHandler(mapPreamble, mapPreambleDeclaredNames, ev.handler)
232
276
  const idxLine = indexBindingLine(ev.handler, indexParam, '__idx')
233
277
  ls.push(` let __el = ${varSlotId(ev.childSlotId)}El`)
234
278
  ls.push(` while (__el.parentElement && __el.parentElement !== ${containerVar}) __el = __el.parentElement`)
@@ -236,8 +280,12 @@ function emitStaticIndexLookup(
236
280
  const idxOffset = buildLoopChildIndexSubtraction(offset ?? undefined)
237
281
  ls.push(` const __idx = Array.from(${containerVar}.children).indexOf(__el)${idxOffset}`)
238
282
  ls.push(` const ${param} = ${arrayExpr}[__idx]`)
239
- if (mapPreamble) ls.push(` ${mapPreamble}`)
240
- if (idxLine) ls.push(` if (${param}) { ${idxLine}; ${handlerCall} }`)
241
- else ls.push(` if (${param}) ${handlerCall}`)
283
+ // Preamble and idx binding run INSIDE the item null guard (BUG-4) — an
284
+ // out-of-range index (stale-DOM race) must short-circuit first.
285
+ ls.push(` if (${param}) {`)
286
+ if (preambleLine) ls.push(` ${preambleLine}`)
287
+ if (idxLine) ls.push(` ${idxLine}`)
288
+ ls.push(` ;${handlerCall}`)
289
+ ls.push(` }`)
242
290
  ls.push(` }`)
243
291
  }
@@ -32,6 +32,7 @@ import { buildSkeletonPathPlan, type SkeletonPathPlan } from './skeleton-paths.t
32
32
  import { stringifyComponentLoop } from './component-loop.ts'
33
33
  import { stringifyCompositeLoop } from './composite-loop.ts'
34
34
  import type { LoopChildRefBinding, LoopPlan, PlainLoopPlan, StaticLoopPlan } from '../plan/types.ts'
35
+ import type { PreambleRegionPlan } from '../plan/loop.ts'
35
36
 
36
37
  /**
37
38
  * Emit `(callback)(__rf)` for each ref on a per-item slot, looking up the
@@ -64,6 +65,40 @@ export function emitLoopChildRefs(
64
65
  }
65
66
  }
66
67
 
68
+ /**
69
+ * Emit the region-patch effect for each preamble-patched region (#2389 —
70
+ * `arr.map(t => { const cells = []; ...; return <tr>{cells}<td>{t.name}</td></tr> })`).
71
+ * The effect re-runs the (loop-param-accessor-wrapped) preamble on every
72
+ * reactive tick so it re-reads the current per-item signal, recomputes the
73
+ * region's value, and — past the FIRST run (which only records, trusting
74
+ * the SSR/CSR mount-time content already in the DOM) — patches the DOM
75
+ * range via `patchSlotRange(__el, 'sN', html)`. The marker lookup lives
76
+ * inside `patchSlotRange`, so a row that never changes pays zero lookup
77
+ * cost at mount/adoption.
78
+ *
79
+ * Non-empty `regions` forces the caller's multi-line renderItem layout (the
80
+ * effect needs `__el` as a query root), mirroring `emitLoopChildRefs`.
81
+ */
82
+ export function emitPreambleRegionEffects(
83
+ lines: string[],
84
+ regions: readonly PreambleRegionPlan[],
85
+ mapPreambleWrapped: string,
86
+ opts: { indent: string; elVar: string },
87
+ ): void {
88
+ if (regions.length === 0) return
89
+ const { indent, elVar } = opts
90
+ for (const region of regions) {
91
+ const v = varSlotId(region.slotId)
92
+ lines.push(`${indent}{ let __last_${v}`)
93
+ lines.push(`${indent}createEffect(() => {`)
94
+ if (mapPreambleWrapped) lines.push(`${indent} ${mapPreambleWrapped}`)
95
+ lines.push(`${indent} const __html_${v} = ${region.valueExpr}`)
96
+ lines.push(`${indent} if (__last_${v} === undefined) { __last_${v} = __html_${v}; return }`)
97
+ lines.push(`${indent} if (__html_${v} !== __last_${v}) { __last_${v} = __html_${v}; patchSlotRange(${elVar}, '${region.slotId}', __html_${v}) }`)
98
+ lines.push(`${indent}}) }`)
99
+ }
100
+ }
101
+
67
102
  /**
68
103
  * Single dispatch over `LoopPlan` (#1253). Narrows on `plan.kind` and
69
104
  * delegates to the per-variant stringifier. Callers should consume this
@@ -128,8 +163,33 @@ export function stringifyPlainLoop(
128
163
  bodyIsMultiRoot,
129
164
  anchored,
130
165
  anchorKeyExpr,
166
+ preambleRegions,
131
167
  } = plan
132
168
 
169
+ // flatMap descriptor mode: the accessor flattens the source through the
170
+ // callback body (each leaf a `({ k, h })` descriptor), mapArray keys on
171
+ // `d.k`, and each item element is built from — and patched against — its
172
+ // rendered HTML. Hydration adopts the SSR leaf in place (`__last` seeds
173
+ // undefined so the first effect run records without patching — trust SSR,
174
+ // same contract as plain rows); a later same-key HTML change patches the
175
+ // element wholesale via `patchLeaf` (flatMap leaves carry no per-slot
176
+ // wiring by construction — the compiler refuses leaves that would).
177
+ if (plan.flatMapLeafItem) {
178
+ const loopBfIdArg = plan.profileLoopId ? `, ${JSON.stringify(plan.profileLoopId)}` : ''
179
+ lines.push(`${topIndent}mapArray(() => ${arrayExpr}, ${containerVar}, ${keyFn}, (__bfD, ${indexParam}, __existing) => {`)
180
+ lines.push(`${topIndent} let __el = __existing`)
181
+ lines.push(`${topIndent} if (!__el) { const __tpl = document.createElement('template'); __tpl.innerHTML = __bfD().h; __el = __tpl.content.firstElementChild }`)
182
+ lines.push(`${topIndent} let __last = __existing ? undefined : __bfD().h`)
183
+ lines.push(`${topIndent} createEffect(() => {`)
184
+ lines.push(`${topIndent} const __html = __bfD().h`)
185
+ lines.push(`${topIndent} if (__last === undefined) { __last = __html; return }`)
186
+ lines.push(`${topIndent} if (__html !== __last) { __last = __html; patchLeaf(__el, __html) }`)
187
+ lines.push(`${topIndent} })`)
188
+ lines.push(`${topIndent} return __el`)
189
+ lines.push(`${topIndent}}, '${markerId}'${loopBfIdArg})`)
190
+ return
191
+ }
192
+
133
193
  // Whole-item conditional loops (#1665) render 0-or-1 element per item, so
134
194
  // they route through `mapArrayAnchored`. The renderItem returns a fragment
135
195
  // headed by a `<!--bf-loop-i:KEY-->` anchor and seeded with the
@@ -158,7 +218,10 @@ export function stringifyPlainLoop(
158
218
  // the factory, so non-empty refs force the multi-line layout the same way
159
219
  // reactive effects do (#1244).
160
220
  const loopBfId = plan.profileLoopId ? `, ${JSON.stringify(plan.profileLoopId)}` : ''
161
- if (reactiveEffects === null && !bodyIsMultiRoot && childRefs.length === 0) {
221
+ // Preamble-patched regions (#2389) need `__el` as a query root for their
222
+ // effect, so non-empty regions force the multi-line layout — same
223
+ // precedent as `childRefs` above.
224
+ if (reactiveEffects === null && !bodyIsMultiRoot && childRefs.length === 0 && preambleRegions.length === 0) {
162
225
  // Single-line renderItem (no reactive effects, single root, no refs).
163
226
  const unwrapInline = paramUnwrap ? `${paramUnwrap} ` : ''
164
227
  const preamble = mapPreambleWrapped ? `${mapPreambleWrapped}; ` : ''
@@ -217,6 +280,7 @@ export function stringifyPlainLoop(
217
280
  })
218
281
  }
219
282
  emitLoopChildRefs(lines, childRefs, { indent: bodyIndent, elVar: '__el', bodyIsMultiRoot, elementIndexBySlot: pathPlan?.elementIndexBySlot })
283
+ emitPreambleRegionEffects(lines, preambleRegions, mapPreambleWrapped, { indent: bodyIndent, elVar: '__el' })
220
284
  lines.push(`${bodyIndent}return __el`)
221
285
  lines.push(`${topIndent}}, '${markerId}'${loopBfId})`)
222
286
  }
@@ -2,7 +2,7 @@
2
2
  * IR → HTML template string generation and validation.
3
3
  */
4
4
 
5
- import type { AttrValue, IRAttribute, IRNode, IRProp, MapCallbackPreamble } 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
  }
@@ -575,6 +580,12 @@ export function renderPreamble(
575
580
  transformJs?: (text: string) => string
576
581
  /** Context-appropriate leaf renderer (an irToHtmlTemplate variant). */
577
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
578
589
  },
579
590
  ): string {
580
591
  let out = ''
@@ -582,6 +593,8 @@ export function renderPreamble(
582
593
  if (seg.kind === 'js') {
583
594
  const text = opts.textVariant === 'template' ? (seg.templateText ?? seg.text) : seg.text
584
595
  out += opts.transformJs ? opts.transformJs(text) : text
596
+ } else if (opts.rawLeaf) {
597
+ out += opts.renderLeaf(escapeLeafTextExpressions(seg.ir))
585
598
  } else {
586
599
  out += '`' + opts.renderLeaf(escapeLeafTextExpressions(seg.ir)) + '`'
587
600
  }
@@ -589,6 +602,96 @@ export function renderPreamble(
589
602
  return out
590
603
  }
591
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
+
592
695
  /**
593
696
  * SSR/CSR escaping parity for preamble leaves, decided once at the door: a
594
697
  * JSX-runtime SSR adapter renders the leaf's raw JSX and auto-escapes text
@@ -816,8 +919,12 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, lo
816
919
  if (node.flatMapCallback) {
817
920
  // Complex flatMap: the body is structured segments, rendered through
818
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).
819
926
  const body = renderPreamble(node.flatMapCallback, {
820
- renderLeaf: (ir) => irToHtmlTemplate(ir, restSpreadNames, loopDepth + 1, loopParams, branchSlotsVar, insideLoop),
927
+ renderLeaf: (ir) => irToHtmlTemplate(stripLeafKeyAttr(ir), restSpreadNames, loopDepth + 1, loopParams, branchSlotsVar, insideLoop),
821
928
  })
822
929
  mapExpr = `\${${wrappedArray}.flatMap(${node.flatMapCallback.params} => ${body}).join('')}`
823
930
  } else if (node.preamble) {
@@ -1263,7 +1370,8 @@ export function irToPlaceholderTemplate(node: IRNode, restSpreadNames?: Set<stri
1263
1370
  let mapExpr: string
1264
1371
  if (node.flatMapCallback) {
1265
1372
  const body = renderPreamble(node.flatMapCallback, {
1266
- renderLeaf: (ir) => irToPlaceholderTemplate(ir, restSpreadNames, loopDepth + 1, loopParams),
1373
+ // Leaf `key` stripped see the irToHtmlTemplate site above.
1374
+ renderLeaf: (ir) => irToPlaceholderTemplate(stripLeafKeyAttr(ir), restSpreadNames, loopDepth + 1, loopParams),
1267
1375
  })
1268
1376
  mapExpr = `\${${wrappedArray}.flatMap(${node.flatMapCallback.params} => ${body}).join('')}`
1269
1377
  } else if (node.preamble) {
@@ -2386,7 +2494,8 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
2386
2494
  const body = renderPreamble(node.flatMapCallback, {
2387
2495
  textVariant: 'template',
2388
2496
  transformJs: (t) => applyPropsRewrite(t, propsObjectName ?? null),
2389
- renderLeaf: (ir) => recurseInLoopBody(ir),
2497
+ // Leaf `key` stripped — see the irToHtmlTemplate site above.
2498
+ renderLeaf: (ir) => recurseInLoopBody(stripLeafKeyAttr(ir)),
2390
2499
  })
2391
2500
  mapExpr = `\${${iterArrayExpr}.flatMap(${node.flatMapCallback.params} => ${body}).join('')}`
2392
2501
  } else if (node.preamble) {
@@ -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',
@@ -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
@@ -23,6 +23,7 @@ import type {
23
23
  TypeInfo,
24
24
  TypeDefinition,
25
25
  MapCallbackPreamble,
26
+ PreambleRegionSource,
26
27
  } from '../types.ts'
27
28
  import type { CsrInlinabilityMap } from './csr-substitute.ts'
28
29
  import type { SkeletonSlotPaths } from './html-template.ts'
@@ -310,6 +311,29 @@ export interface LoopCore {
310
311
  * `IRLoop.objectIteration`.
311
312
  */
312
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[]
313
337
  }
314
338
 
315
339
  /**