@barefootjs/jsx 0.26.3 → 0.27.0

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 (99) hide show
  1. package/dist/compiler.d.ts.map +1 -1
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +735 -140
  5. package/dist/ir-to-client-js/build-references.d.ts.map +1 -1
  6. package/dist/ir-to-client-js/client-only-elision.d.ts +103 -0
  7. package/dist/ir-to-client-js/client-only-elision.d.ts.map +1 -0
  8. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  9. package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts +12 -1
  10. package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/control-flow/plan/build-branch-loop.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/control-flow/plan/build-event-delegation.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts +16 -1
  15. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +27 -0
  17. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/shared.d.ts +13 -2
  19. package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/stringify/claim-plan.d.ts +54 -0
  21. package/dist/ir-to-client-js/control-flow/stringify/claim-plan.d.ts.map +1 -0
  22. package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/control-flow/stringify/insert.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  26. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts +19 -3
  27. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  28. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts +63 -4
  29. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts.map +1 -1
  30. package/dist/ir-to-client-js/control-flow.d.ts +1 -1
  31. package/dist/ir-to-client-js/element-refs.d.ts +8 -3
  32. package/dist/ir-to-client-js/element-refs.d.ts.map +1 -1
  33. package/dist/ir-to-client-js/emit-reactive.d.ts +8 -1
  34. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  35. package/dist/ir-to-client-js/html-template.d.ts +73 -1
  36. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  37. package/dist/ir-to-client-js/imports.d.ts +2 -2
  38. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  39. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  40. package/dist/ir-to-client-js/types.d.ts +35 -2
  41. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  42. package/dist/ir-to-client-js/utils.d.ts +1 -1
  43. package/dist/jsx-to-ir.d.ts.map +1 -1
  44. package/dist/types.d.ts +70 -0
  45. package/dist/types.d.ts.map +1 -1
  46. package/package.json +2 -2
  47. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +555 -678
  48. package/src/__tests__/child-components-in-map.test.ts +4 -4
  49. package/src/__tests__/client-js-generation.test.ts +26 -12
  50. package/src/__tests__/compiler-runtime-contract.test.ts +4 -4
  51. package/src/__tests__/composite-branch-loop.test.ts +2 -2
  52. package/src/__tests__/cross-file-client-signal.test.ts +5 -1
  53. package/src/__tests__/delegated-handler-preamble.test.ts +153 -0
  54. package/src/__tests__/early-return-scope-var-ref.test.ts +10 -11
  55. package/src/__tests__/flatmap-segments.test.ts +182 -0
  56. package/src/__tests__/inline-jsx-callback.test.ts +6 -4
  57. package/src/__tests__/ir-jsx-props.test.ts +6 -5
  58. package/src/__tests__/loop-fallback-wrap.test.ts +3 -4
  59. package/src/__tests__/map-body-no-silent-divergence.test.ts +45 -0
  60. package/src/__tests__/module-level-signal.audit.test.ts +7 -2
  61. package/src/__tests__/nested-loop-conditional.test.ts +9 -7
  62. package/src/__tests__/nested-loop-reactive-attrs.test.ts +2 -2
  63. package/src/__tests__/preamble-region-patch.test.ts +156 -0
  64. package/src/__tests__/profile-cond-binding-ids.test.ts +3 -2
  65. package/src/__tests__/profile-loop-binding-ids.test.ts +4 -3
  66. package/src/__tests__/profile-nested-binding-ids.test.ts +11 -6
  67. package/src/__tests__/reactive-attrs-in-map.test.ts +6 -4
  68. package/src/__tests__/static-loop-csr-materialize.test.ts +3 -2
  69. package/src/__tests__/template-closure.test.ts +1 -1
  70. package/src/compiler.ts +11 -0
  71. package/src/index.ts +1 -0
  72. package/src/ir-to-client-js/build-references.ts +16 -0
  73. package/src/ir-to-client-js/client-only-elision.ts +273 -0
  74. package/src/ir-to-client-js/collect-elements.ts +74 -14
  75. package/src/ir-to-client-js/control-flow/plan/branch-loop.ts +12 -1
  76. package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +11 -3
  77. package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +19 -3
  78. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +36 -0
  79. package/src/ir-to-client-js/control-flow/plan/event-delegation.ts +16 -1
  80. package/src/ir-to-client-js/control-flow/plan/loop.ts +28 -0
  81. package/src/ir-to-client-js/control-flow/shared.ts +27 -2
  82. package/src/ir-to-client-js/control-flow/stringify/branch-loop.ts +34 -4
  83. package/src/ir-to-client-js/control-flow/stringify/claim-plan.ts +68 -0
  84. package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +67 -19
  85. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +18 -9
  86. package/src/ir-to-client-js/control-flow/stringify/insert.ts +17 -11
  87. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +35 -16
  88. package/src/ir-to-client-js/control-flow/stringify/loop.ts +80 -14
  89. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +241 -31
  90. package/src/ir-to-client-js/control-flow.ts +1 -1
  91. package/src/ir-to-client-js/element-refs.ts +9 -11
  92. package/src/ir-to-client-js/emit-reactive.ts +50 -14
  93. package/src/ir-to-client-js/html-template.ts +144 -15
  94. package/src/ir-to-client-js/imports.ts +7 -4
  95. package/src/ir-to-client-js/reactivity.ts +7 -0
  96. package/src/ir-to-client-js/types.ts +34 -2
  97. package/src/ir-to-client-js/utils.ts +1 -1
  98. package/src/jsx-to-ir.ts +382 -15
  99. package/src/types.ts +72 -0
@@ -9,6 +9,7 @@ import type { AttrMeta, IRMetadata } from '../types.ts'
9
9
  import { isBooleanAttr } from '../html-constants.ts'
10
10
  import type { ClientJsContext } from './types.ts'
11
11
  import { toHtmlAttrName, varSlotId, PROPS_PARAM } from './utils.ts'
12
+ import { claimPlanLiteral, claimWriterVarName, type ClaimSlotSpec } from './control-flow/stringify/claim-plan.ts'
12
13
  import { createTemplateAwareStringProtector } from './html-template.ts'
13
14
  import { datePlugin, DATE_METHODS } from '../date-lowering.ts'
14
15
  import { toLocaleDatePlugin, foldedArgToClientJs } from '../to-locale-date-lowering.ts'
@@ -303,24 +304,44 @@ export function emitDynamicTextUpdates(lines: string[], ctx: ClientJsContext): v
303
304
  const normalElems = elems.filter(e => !e.insideConditional)
304
305
 
305
306
  if (normalElems.length > 0 || conditionalElems.length > 0) {
306
- // Persistent slot trackers for non-conditional elements. `__bfText`
307
- // returns the node now occupying the slot; a JSX-valued expression
308
- // (`{themeLogo(id)}`) replaces the text node with a live element, so
309
- // the next reactive run must operate on that element, not the stale
310
- // text node (#1663). Primitive values keep the same text node.
311
- for (const elem of normalElems) {
312
- const v = varSlotId(elem.slotId)
313
- lines.push(` let __anchor_${v} = _${v}`)
314
- }
315
307
  const __textSlot = (normalElems[0] ?? conditionalElems[0])?.slotId
308
+
309
+ // Non-conditional elements: ONE claimed 'markup' writer covering every
310
+ // slot this expression feeds (slot unification A3, row-level claim at
311
+ // `__scope` — spec/slot-unification.md §3(a)/§5-A3). 'markup' (not
312
+ // 'text') because the value may be a live Node (e.g. a JSX-returning
313
+ // call like `{themeLogo(id)}`) — the writer splices a Node in by
314
+ // identity, exactly the contract `__bfText` used to provide (#1663).
315
+ let writer = ''
316
+ if (normalElems.length > 0) {
317
+ const slots: ClaimSlotSpec[] = normalElems.map(elem => ({ id: elem.slotId, kind: 'markup', path: [] }))
318
+ writer = claimWriterVarName(slots, varSlotId)
319
+ lines.push(` const ${writer} = lazySlots(__scope, ${claimPlanLiteral(slots)})`)
320
+ }
321
+
316
322
  lines.push(` createEffect(() => {`)
317
323
  if (normalElems.length > 0) {
318
324
  // Expression is always evaluated for non-conditional elements
319
325
  lines.push(` const __val = ${expr}`)
326
+ // `escapeTextOrNode`: `writeMarkup` inserts a string via
327
+ // `innerHTML =`, which — unlike the plain `Text.nodeValue =`
328
+ // assignment the initial SSR/CSR TEMPLATE's `escapeText(${expr})`
329
+ // was standing in for — DOES interpret HTML, so a raw un-escaped
330
+ // string here is an injection/byte-parity gap; a live Node (this
331
+ // expression may return one) must pass through untouched instead.
320
332
  for (const elem of normalElems) {
321
- const v = varSlotId(elem.slotId)
322
- lines.push(` __anchor_${v} = __bfText(__anchor_${v}, __val)`)
333
+ lines.push(` ${writer}('${elem.slotId}', escapeTextOrNode(__val))`)
323
334
  }
335
+ // Conditional elements: deferred from slot unification A3 — see
336
+ // `__bfText`'s docstring for why. `insert()` may swap the branch's
337
+ // DOM independently of this effect's own reruns, and a cached claim
338
+ // door's dedup state would go stale across that swap (unlike the
339
+ // 'text'-kind conditional cases elsewhere in the compiler, this
340
+ // slot's value may be a live Node, so it can't reuse the "just
341
+ // re-claim fresh every run" trick those use — a fresh 'markup'
342
+ // claim's dedup state always starts empty, so re-claiming per-run
343
+ // would throw away the unchanged-value skip on every single run).
344
+ // Keeps `$t` + `__bfText`.
324
345
  for (const elem of conditionalElems) {
325
346
  const v = varSlotId(elem.slotId)
326
347
  lines.push(` const [__el_${v}] = $t(__scope, '${elem.slotId}')`)
@@ -348,13 +369,28 @@ export function emitDynamicTextUpdates(lines: string[], ctx: ClientJsContext): v
348
369
  }
349
370
  }
350
371
 
351
- /** Emit createEffect blocks for client-only expressions using comment markers. */
372
+ /**
373
+ * Emit createEffect blocks for `@client` expressions. Slot unification A3:
374
+ * these are ordinary claimed 'text' slots now (never Node-valued — `@client`
375
+ * only ever evaluates a plain expression), one writer per slot at `__scope`,
376
+ * replacing `updateClientMarker`'s full-tree rescan + zero-width-space
377
+ * managed-node marker (no longer needed: a claimed Text node's identity is
378
+ * already known, so there's nothing to disambiguate from other content).
379
+ */
352
380
  export function emitClientOnlyExpressions(lines: string[], ctx: ClientJsContext): void {
353
381
  for (const elem of ctx.clientOnlyElements) {
382
+ // Slot unification Step B: `elem.elidedPath`, when present, was proven
383
+ // safe by `client-only-elision.ts` before this pass ran — use the real
384
+ // position-based path and skip the marker-scan fallback entirely.
385
+ const slots: ClaimSlotSpec[] = elem.elidedPath
386
+ ? [{ id: elem.slotId, kind: 'text', path: elem.elidedPath, markerless: true }]
387
+ : [{ id: elem.slotId, kind: 'text', path: [] }]
388
+ const writer = claimWriterVarName(slots, varSlotId)
354
389
  lines.push(` // @client: ${elem.slotId}`)
390
+ lines.push(` { const ${writer} = lazySlots(__scope, ${claimPlanLiteral(slots)})`)
355
391
  lines.push(` createEffect(() => {`)
356
- lines.push(` updateClientMarker(__scope, '${elem.slotId}', ${elem.expression})`)
357
- lines.push(` }${bindingIdArg(ctx, elem.slotId)})`)
392
+ lines.push(` ${writer}('${elem.slotId}', ${elem.expression})`)
393
+ lines.push(` }${bindingIdArg(ctx, elem.slotId)}) }`)
358
394
  lines.push('')
359
395
  }
360
396
  }
@@ -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'
@@ -126,7 +126,11 @@ export function createTemplateAwareStringProtector(): {
126
126
  return { protect, restore, replaceProtectedCall }
127
127
  }
128
128
 
129
- const VOID_ELEMENTS = new Set([
129
+ // Exported (not just module-local) so `client-only-elision.ts` (slot
130
+ // unification Step B) can reuse the exact same void-element list when it
131
+ // walks the IR tree computing markerless-eligible child-index paths —
132
+ // duplicating this list would risk the two walks silently drifting apart.
133
+ export const VOID_ELEMENTS = new Set([
130
134
  'area', 'base', 'br', 'col', 'embed', 'hr', 'img',
131
135
  'input', 'link', 'meta', 'param', 'source', 'track', 'wbr',
132
136
  ])
@@ -276,9 +280,14 @@ function templateAttrExpr(attrName: string, valExpr: string, presenceOrUndefined
276
280
  // `data-key` / `data-key-N` is a reconciliation contract — every loop item
277
281
  // must carry one. Emit unconditionally; if the user passes `key={undefined}`
278
282
  // 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".
283
+ // assertion in mapArray) rather than silently fall back to "no key"
284
+ // `escapeAttr(undefined)` stringifies to exactly that. The value is escaped
285
+ // like every other dynamic attribute: SSR adapters escape it (their
286
+ // template engines do), so an unescaped `"` in a key — surfaced by the
287
+ // flatmap-expression-body fixture's adversarial keys — corrupted the
288
+ // client-assembled HTML and diverged from the SSR bytes.
280
289
  if (attrName === 'data-key' || attrName.startsWith('data-key-')) {
281
- return `${attrName}="\${${valExpr}}"`
290
+ return `${attrName}="\${${escapeAttrValueExpr(valExpr)}}"`
282
291
  }
283
292
  return `\${(${valExpr}) != null ? '${attrName}="' + ${escapeAttrValueExpr(valExpr)} + '"' : ''}`
284
293
  }
@@ -575,6 +584,12 @@ export function renderPreamble(
575
584
  transformJs?: (text: string) => string
576
585
  /** Context-appropriate leaf renderer (an irToHtmlTemplate variant). */
577
586
  renderLeaf: (ir: IRNode) => string
587
+ /**
588
+ * When true, `renderLeaf` output is spliced verbatim — the renderer
589
+ * supplies its own delimiters (e.g. the flatMap descriptor form
590
+ * `({ k, h })`). Default wraps each leaf in a template literal.
591
+ */
592
+ rawLeaf?: boolean
578
593
  },
579
594
  ): string {
580
595
  let out = ''
@@ -582,6 +597,8 @@ export function renderPreamble(
582
597
  if (seg.kind === 'js') {
583
598
  const text = opts.textVariant === 'template' ? (seg.templateText ?? seg.text) : seg.text
584
599
  out += opts.transformJs ? opts.transformJs(text) : text
600
+ } else if (opts.rawLeaf) {
601
+ out += opts.renderLeaf(escapeLeafTextExpressions(seg.ir))
585
602
  } else {
586
603
  out += '`' + opts.renderLeaf(escapeLeafTextExpressions(seg.ir)) + '`'
587
604
  }
@@ -589,6 +606,96 @@ export function renderPreamble(
589
606
  return out
590
607
  }
591
608
 
609
+ /**
610
+ * Project a flatMap segment leaf's `key={...}` attribute into a runtime key
611
+ * expression, or `null` when the leaf declares none. Template-literal keys
612
+ * (`key={\`${it.id}:${tag}\`}`) are first-class here — unlike the loop-level
613
+ * `extractLoopKey`, the expression is evaluated inside the flatMap body where
614
+ * the callback params are in scope, so any expression form works.
615
+ */
616
+ export function flatMapLeafKeyExpr(ir: IRNode): string | null {
617
+ if (ir.type !== 'element') return null
618
+ const keyAttr = ir.attrs.find((a) => a.name === 'key')
619
+ if (!keyAttr) return null
620
+ switch (keyAttr.value.kind) {
621
+ case 'expression':
622
+ return `(${keyAttr.value.expr})`
623
+ case 'literal':
624
+ return JSON.stringify(keyAttr.value.value)
625
+ case 'template':
626
+ return attrValueToString(keyAttr.value)
627
+ default:
628
+ return null
629
+ }
630
+ }
631
+
632
+ /** Copy of `ir` with the loop `key` attribute removed (element leaves only). */
633
+ function stripLeafKeyAttr(ir: IRNode): IRNode {
634
+ if (ir.type !== 'element') return ir
635
+ return { ...ir, attrs: ir.attrs.filter((a) => a.name !== 'key') }
636
+ }
637
+
638
+ /**
639
+ * Render a `FlatMapCallback` as the CLIENT-SIDE descriptor body for
640
+ * `mapArray` (the reconciliation twin of the string-template rendering in
641
+ * `irToHtmlTemplate`'s `'loop'` case). Each JSX leaf becomes
642
+ * `({ k: <keyExpr>, h: \`<html>\` })` — the flatMap flattens descriptors,
643
+ * `mapArray` keys on `d.k` (index fallback), and the emitted renderItem
644
+ * builds/patches the leaf element from `d.h`.
645
+ *
646
+ * Runs in the init scope where the loop SOURCE items are plain values (the
647
+ * flatMap executes inside the `mapArray` accessor, BEFORE per-item signals
648
+ * exist), so neither js segments nor leaf HTML get the accessor wrap — refs
649
+ * stay `t.title`, not `t().title`. `data-key` is deliberately NOT emitted in
650
+ * the leaf HTML: reconciliation identity is stamped by `mapArray` via
651
+ * `setAttribute`, matching the SSR side (which never emits it for flatMap
652
+ * leaves).
653
+ */
654
+ export function renderFlatMapClientBody(
655
+ cb: Pick<FlatMapCallback, 'segments'>,
656
+ restSpreadNames?: Set<string>,
657
+ ): string {
658
+ return renderPreamble(cb, {
659
+ textVariant: 'client',
660
+ rawLeaf: true,
661
+ renderLeaf: (ir) => {
662
+ const key = flatMapLeafKeyExpr(ir)
663
+ const html = irToHtmlTemplate(stripLeafKeyAttr(ir), restSpreadNames, 1, undefined, undefined, true)
664
+ return `({ k: ${key ?? 'undefined'}, h: \`${html}\` })`
665
+ },
666
+ })
667
+ }
668
+
669
+ /** True when any segment leaf declares a `key` — drives the mapArray keyFn. */
670
+ export function flatMapCallbackHasKeyedLeaf(cb: Pick<FlatMapCallback, 'segments'>): boolean {
671
+ return cb.segments.some((s) => s.kind === 'jsx' && flatMapLeafKeyExpr(s.ir) !== null)
672
+ }
673
+
674
+ /**
675
+ * Synthesize the client descriptor body for a flatMap PROJECTION loop —
676
+ * one whose only child is a nested `IRLoop` lowered from
677
+ * `flatMap(it => it.tags.map(tag => <li/>))`. The neutral IR is the single
678
+ * carrier: SSR adapters templatize the nested loop natively, and this
679
+ * derives the `mapArray` accessor's flatten projection from the SAME inner
680
+ * loop — `<chained-inner>.map((tag, i) => ({ k: <inner.key>, h: `<leaf>` }))`.
681
+ * Runs in the accessor context (plain source items, no per-item signals),
682
+ * so leaf refs stay unwrapped. Leaf `key` attrs were already stripped at IR
683
+ * build (`stripLoopLeafKeyAttrs`); the inner loop's `key` FIELD supplies
684
+ * `k`.
685
+ */
686
+ export function renderFlatMapProjectionClientBody(
687
+ inner: Extract<IRNode, { type: 'loop' }>,
688
+ restSpreadNames?: Set<string>,
689
+ ): string {
690
+ const chained = applyLoopChain(inner)
691
+ const params = inner.index ? `(${inner.param}, ${inner.index})` : `(${inner.param})`
692
+ const key = inner.key ? `(${inner.key})` : 'undefined'
693
+ const html = inner.children
694
+ .map((c) => irToHtmlTemplate(escapeLeafTextExpressions(c), restSpreadNames, 1, undefined, undefined, true))
695
+ .join('')
696
+ return `${chained}.map(${params} => ({ k: ${key}, h: \`${html}\` }))`
697
+ }
698
+
592
699
  /**
593
700
  * SSR/CSR escaping parity for preamble leaves, decided once at the door: a
594
701
  * JSX-runtime SSR adapter renders the leaf's raw JSX and auto-escapes text
@@ -691,6 +798,16 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, lo
691
798
 
692
799
  case 'expression': {
693
800
  if (node.expr === 'null' || node.expr === 'undefined') return ''
801
+ // Slot unification Step B (`markerless`, decided once by
802
+ // `client-only-elision.ts` before this CSR pass runs): drop the
803
+ // marker pair and fall through to the same bare `${...}` shape the
804
+ // no-slotId branch below already uses — `elidedPath` (not this
805
+ // string) is what the claim plan resolves against, so no marker
806
+ // anchor is needed here at all.
807
+ if (node.markerless) {
808
+ const bare = wrapInterpolation(wrapExpr(node.expr))
809
+ return `\${${bare}}`
810
+ }
694
811
  const inner = wrapInterpolation(wrapExpr(node.expr))
695
812
  // Stage 3 / D4 — an element-array child ({out}) built by an arbitrary
696
813
  // .map() preamble is an array of HTML strings; join it rather than let
@@ -805,8 +922,8 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, lo
805
922
  // (Go's `bf_sort`, etc.) applied the sort separately and
806
923
  // hydration only needed to match, but broken on Hono / CSR
807
924
  // where the template is the only source of truth. The chain
808
- // mirrors `buildChainedArrayExpr` so reconcileList sees the
809
- // same array shape this template emits.
925
+ // mirrors `buildChainedArrayExpr` so mapArray/mapArrayAnchored sees
926
+ // the same array shape this template emits.
810
927
  const rawChainedArray = applyLoopChain(node)
811
928
  const { array: iterArray, callbackParam } = applyIterationShape(node, rawChainedArray, indexParam)
812
929
  const wrappedArray = wrapExpr(iterArray)
@@ -816,8 +933,12 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, lo
816
933
  if (node.flatMapCallback) {
817
934
  // Complex flatMap: the body is structured segments, rendered through
818
935
  // the same single door as map preambles.
936
+ // Leaf `key` is stripped from the string form: SSR (Hono rawBody)
937
+ // never emits data-key for flatMap leaves, and reconciliation
938
+ // identity is stamped by mapArray via setAttribute — emitting it
939
+ // here was the CSR/SSR data-key asymmetry (unescaped, client-only).
819
940
  const body = renderPreamble(node.flatMapCallback, {
820
- renderLeaf: (ir) => irToHtmlTemplate(ir, restSpreadNames, loopDepth + 1, loopParams, branchSlotsVar, insideLoop),
941
+ renderLeaf: (ir) => irToHtmlTemplate(stripLeafKeyAttr(ir), restSpreadNames, loopDepth + 1, loopParams, branchSlotsVar, insideLoop),
821
942
  })
822
943
  mapExpr = `\${${wrappedArray}.flatMap(${node.flatMapCallback.params} => ${body}).join('')}`
823
944
  } else if (node.preamble) {
@@ -1013,7 +1134,9 @@ export interface SkeletonSlotPaths {
1013
1134
  * the loop keeps its hoisted-clone fast path, but every slot lookup falls
1014
1135
  * back to `qsa`/`$t`.
1015
1136
  */
1016
- const SKELETON_PATH_HAZARD_TAGS = new Set([
1137
+ // Exported for reuse by `client-only-elision.ts` (Step B) — same hazard
1138
+ // class, same reasoning, must never drift out of sync with a second copy.
1139
+ export const SKELETON_PATH_HAZARD_TAGS = new Set([
1017
1140
  'table', 'thead', 'tbody', 'tfoot', 'caption', 'colgroup', 'col',
1018
1141
  'select', 'optgroup',
1019
1142
  'p',
@@ -1029,7 +1152,7 @@ const SKELETON_PATH_HAZARD_TAGS = new Set([
1029
1152
  * `<dt>` closes the `<dt>`). A skeleton hitting this bails on path
1030
1153
  * computation entirely (see `SKELETON_PATH_HAZARD_TAGS` doc).
1031
1154
  */
1032
- const SKELETON_PATH_FORCE_CLOSE_GROUPS: ReadonlyArray<ReadonlySet<string>> = [
1155
+ export const SKELETON_PATH_FORCE_CLOSE_GROUPS: ReadonlyArray<ReadonlySet<string>> = [
1033
1156
  new Set(['a']),
1034
1157
  new Set(['button']),
1035
1158
  new Set(['form']),
@@ -1039,7 +1162,7 @@ const SKELETON_PATH_FORCE_CLOSE_GROUPS: ReadonlyArray<ReadonlySet<string>> = [
1039
1162
  new Set(['li']),
1040
1163
  ]
1041
1164
 
1042
- function skeletonForceCloseGroup(tag: string): number {
1165
+ export function skeletonForceCloseGroup(tag: string): number {
1043
1166
  return SKELETON_PATH_FORCE_CLOSE_GROUPS.findIndex(group => group.has(tag))
1044
1167
  }
1045
1168
 
@@ -1097,7 +1220,7 @@ function walkSkeletonPathNode(
1097
1220
  }
1098
1221
 
1099
1222
  /** True if `children` (already flattened) contains anything the HTML parser would foster-parent out of a `<tr>`. */
1100
- function hasForeignTableRowContent(children: readonly IRNode[]): boolean {
1223
+ export function hasForeignTableRowContent(children: readonly IRNode[]): boolean {
1101
1224
  for (const child of children) {
1102
1225
  if (child.type === 'text') {
1103
1226
  if (child.value.trim() !== '') return true
@@ -1111,7 +1234,7 @@ function hasForeignTableRowContent(children: readonly IRNode[]): boolean {
1111
1234
  }
1112
1235
 
1113
1236
  /** Splice fragment children inline — a fragment contributes no DOM node of its own. */
1114
- function flattenSkeletonChildren(children: readonly IRNode[]): IRNode[] {
1237
+ export function flattenSkeletonChildren(children: readonly IRNode[]): IRNode[] {
1115
1238
  const out: IRNode[] = []
1116
1239
  for (const child of children) {
1117
1240
  if (child.type === 'fragment') {
@@ -1263,7 +1386,8 @@ export function irToPlaceholderTemplate(node: IRNode, restSpreadNames?: Set<stri
1263
1386
  let mapExpr: string
1264
1387
  if (node.flatMapCallback) {
1265
1388
  const body = renderPreamble(node.flatMapCallback, {
1266
- renderLeaf: (ir) => irToPlaceholderTemplate(ir, restSpreadNames, loopDepth + 1, loopParams),
1389
+ // Leaf `key` stripped see the irToHtmlTemplate site above.
1390
+ renderLeaf: (ir) => irToPlaceholderTemplate(stripLeafKeyAttr(ir), restSpreadNames, loopDepth + 1, loopParams),
1267
1391
  })
1268
1392
  mapExpr = `\${${wrappedArray}.flatMap(${node.flatMapCallback.params} => ${body}).join('')}`
1269
1393
  } else if (node.preamble) {
@@ -2214,7 +2338,11 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
2214
2338
  case 'expression':
2215
2339
  if (node.expr === 'null' || node.expr === 'undefined') return ''
2216
2340
  if (node.clientOnly && node.slotId) {
2217
- return `<!--bf-client:${node.slotId}--><!--/-->`
2341
+ // Ordinary claimed 'text' slot pair (slot unification A3) — matches
2342
+ // the SSR adapters' `renderExpression` byte-for-byte (byte parity):
2343
+ // empty at CSR mount too, since the expression is evaluated only
2344
+ // once init's createEffect runs, same as the SSR case.
2345
+ return `<!--bf:${node.slotId}--><!--/-->`
2218
2346
  }
2219
2347
  {
2220
2348
  const transformed = transformExpr(node.expr, node.templateExpr)
@@ -2386,7 +2514,8 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
2386
2514
  const body = renderPreamble(node.flatMapCallback, {
2387
2515
  textVariant: 'template',
2388
2516
  transformJs: (t) => applyPropsRewrite(t, propsObjectName ?? null),
2389
- renderLeaf: (ir) => recurseInLoopBody(ir),
2517
+ // Leaf `key` stripped — see the irToHtmlTemplate site above.
2518
+ renderLeaf: (ir) => recurseInLoopBody(stripLeafKeyAttr(ir)),
2390
2519
  })
2391
2520
  mapExpr = `\${${iterArrayExpr}.flatMap(${node.flatMapCallback.params} => ${body}).join('')}`
2392
2521
  } else if (node.preamble) {
@@ -8,13 +8,16 @@ 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',
12
- 'createComponent', 'renderChild', 'registerComponent', 'registerTemplate', 'initChild', 'upsertChild', 'updateClientMarker',
11
+ 'hydrate', 'insert', 'getLoopChildren', 'getLoopNodes', 'mapArray', 'mapArrayAnchored', 'patchLeaf', 'createDisposableEffect',
12
+ 'createComponent', 'renderChild', 'registerComponent', 'registerTemplate', 'initChild', 'upsertChild',
13
13
  'createPortal',
14
14
  'provideContext', 'createContext', 'useContext',
15
- 'forwardProps', 'applyRestAttrs', 'splitProps', 'spreadAttrs', 'styleToCss', 'escapeAttr', 'escapeText',
15
+ 'forwardProps', 'applyRestAttrs', 'splitProps', 'spreadAttrs', 'styleToCss', 'escapeAttr', 'escapeText', 'escapeTextOrNode',
16
16
  'qsa', 'qsaItem', 'qsaChildScope', 'qsaChildScopes', 'upsertChildItem', '__slot', '__bfSlot', '__bfText',
17
- 'tAfter',
17
+ // Claim-plan interpreter (slot unification A2/A3, spec/slot-unification.md)
18
+ // — the "one claim mechanism" that replaced `patchSlotRange` and
19
+ // `updateClientMarker` (both deleted) as the content-slot update door.
20
+ 'claimSlots', 'lazySlots',
18
21
  // Profile mode (#1690, SR3) — turn-boundary markers around event handlers.
19
22
  'beginTurn', 'endTurn',
20
23
  // Catalogued `Date` lowering (#2274/#2292) — the client counterpart to
@@ -570,6 +570,13 @@ 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 claimed 'markup'-slot effect (`preambleRegions` in the
575
+ // client-JS loop plan; slot unification A3, `@barefootjs/client/
576
+ // runtime/claim-slots.ts`); it must never ALSO become a `reactiveTexts`
577
+ // entry, which would patch it via `.textContent` and corrupt markup
578
+ // (a `joinArrayChild` region's value is raw HTML, not text).
579
+ if (n.preambleRegion) return
573
580
  const originFreeIds = freeIdsFromRefs(n.origin?.freeRefs)
574
581
  const expanded = expandConstantForReactivity(n.expr, ctx, originFreeIds)
575
582
  // 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'
@@ -254,7 +255,7 @@ export interface LoopCore {
254
255
  /**
255
256
  * Unique id for this loop's `<!--bf-loop:<id>--> ... <!--bf-/loop:<id>-->`
256
257
  * marker pair, threaded from `IRLoop.markerId`. Each `mapArray()` /
257
- * `reconcileElements()` call passes this id so sibling loops under the
258
+ * `mapArrayAnchored()` call passes this id so sibling loops under the
258
259
  * same parent reconcile their own range (#1087).
259
260
  */
260
261
  markerId: string
@@ -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
  /**
@@ -585,7 +609,7 @@ export interface TopLevelLoop extends LoopCore {
585
609
  // Per-item bindings (events / reactiveAttrs / reactiveTexts / refs / conditionals)
586
610
  // now live on `LoopCore.bindings` — see issue #1244 §B.
587
611
  isStaticArray: boolean // True if array is a static prop (not a signal)
588
- useElementReconciliation?: boolean // True: reconcileElements + composite rendering (native root with child components)
612
+ useElementReconciliation?: boolean // True: mapArray/mapArrayAnchored + composite rendering (native root with child components)
589
613
  /** Inner loop metadata for composite element reconciliation (array, param, key, container) */
590
614
  innerLoops?: NestedLoop[]
591
615
  /** Offset of this loop's items past its preceding container siblings (#1693). */
@@ -630,6 +654,14 @@ export interface ReactiveAttribute extends AttrMeta {
630
654
  export interface ClientOnlyElement {
631
655
  slotId: string
632
656
  expression: string
657
+ /**
658
+ * Slot unification Step B (`spec/slot-unification.md` §3(b), §5 Step B):
659
+ * copied from `IRExpression.elidedPath` (`client-only-elision.ts`) when
660
+ * the compiler proved this slot's marker pair can be elided. Absent
661
+ * (`undefined`) is always safe — the claim plan falls back to `path: []`
662
+ * / marker-based resolution exactly as before Step B.
663
+ */
664
+ elidedPath?: readonly number[]
633
665
  }
634
666
 
635
667
 
@@ -180,7 +180,7 @@ export function applyObjectIterationWrap(
180
180
  }
181
181
 
182
182
  /**
183
- * Build the chained array expression for reconcileList. Thin
183
+ * Build the chained array expression for mapArray/mapArrayAnchored. Thin
184
184
  * adapter over `buildLoopChainExpr` that unpacks the collected
185
185
  * `TopLevelLoop` / `BranchLoop` shape into the primitive inputs.
186
186
  * Branch loops carry the same `filterPredicate` / `sortComparator`