@barefootjs/jsx 0.34.0 → 0.35.1

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 (148) hide show
  1. package/dist/analyzer-context.d.ts.map +1 -1
  2. package/dist/analyzer.d.ts.map +1 -1
  3. package/dist/index.d.ts +2 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +1072 -903
  6. package/dist/ir-to-client-js/collect-elements.d.ts +22 -2
  7. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  8. package/dist/ir-to-client-js/control-flow/plan/build-branch-loop.d.ts.map +1 -1
  9. package/dist/ir-to-client-js/control-flow/plan/build-component-loop.d.ts.map +1 -1
  10. package/dist/ir-to-client-js/control-flow/plan/build-composite-loop.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts +14 -2
  12. package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts +11 -0
  14. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts.map +1 -1
  15. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts +6 -1
  16. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/plan/build-plain-row.d.ts +52 -0
  19. package/dist/ir-to-client-js/control-flow/plan/build-plain-row.d.ts.map +1 -0
  20. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts +5 -2
  21. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/control-flow/plan/inner-loop.d.ts +24 -44
  23. package/dist/ir-to-client-js/control-flow/plan/inner-loop.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts +6 -7
  25. package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts.map +1 -1
  26. package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts +5 -7
  27. package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts.map +1 -1
  28. package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts.map +1 -1
  29. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts +14 -0
  30. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts.map +1 -1
  31. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +10 -6
  32. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  33. package/dist/ir-to-client-js/control-flow/shared.d.ts +11 -9
  34. package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
  35. package/dist/ir-to-client-js/control-flow/stringify/composite-loop.d.ts.map +1 -1
  36. package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts +6 -14
  37. package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts.map +1 -1
  38. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts +1 -3
  39. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -1
  40. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts +19 -3
  41. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  42. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  43. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts +5 -1
  44. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts.map +1 -1
  45. package/dist/ir-to-client-js/csr-substitute.d.ts +16 -0
  46. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  47. package/dist/ir-to-client-js/emit-reactive.d.ts +54 -0
  48. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  49. package/dist/ir-to-client-js/emit-registration.d.ts.map +1 -1
  50. package/dist/ir-to-client-js/html-template.d.ts +2 -2
  51. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  52. package/dist/ir-to-client-js/imports.d.ts +2 -2
  53. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  54. package/dist/ir-to-client-js/index.d.ts.map +1 -1
  55. package/dist/ir-to-client-js/markup-slots.d.ts +21 -0
  56. package/dist/ir-to-client-js/markup-slots.d.ts.map +1 -0
  57. package/dist/ir-to-client-js/reactivity.d.ts +62 -18
  58. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  59. package/dist/ir-to-client-js/safe-html.d.ts +158 -0
  60. package/dist/ir-to-client-js/safe-html.d.ts.map +1 -0
  61. package/dist/ir-to-client-js/types.d.ts +18 -2
  62. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  63. package/dist/ir-to-client-js/utils.d.ts +34 -1
  64. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  65. package/dist/jsx-to-ir.d.ts.map +1 -1
  66. package/dist/prop-rewrite.d.ts +8 -3
  67. package/dist/prop-rewrite.d.ts.map +1 -1
  68. package/dist/props-binding.d.ts +22 -1
  69. package/dist/props-binding.d.ts.map +1 -1
  70. package/dist/reactivity-checker.d.ts +18 -0
  71. package/dist/reactivity-checker.d.ts.map +1 -1
  72. package/dist/ssr-defaults.d.ts.map +1 -1
  73. package/dist/types.d.ts +5 -10
  74. package/dist/types.d.ts.map +1 -1
  75. package/package.json +2 -2
  76. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +138 -22
  77. package/src/__tests__/binding-scope-ratchet.test.ts +25 -2
  78. package/src/__tests__/build-plain-row-consolidation.test.ts +120 -0
  79. package/src/__tests__/child-prop-fallback-wrap.test.ts +4 -2
  80. package/src/__tests__/client-template-escape-soundness.test.ts +538 -0
  81. package/src/__tests__/compiler-stress-1244.test.ts +8 -8
  82. package/src/__tests__/csr-template-loop-shadowing.test.ts +4 -2
  83. package/src/__tests__/flatmap-segments.test.ts +1 -1
  84. package/src/__tests__/issue-2868-cond-arm-tag-collision.test.ts +185 -0
  85. package/src/__tests__/issue-2869-attr-dedup-guard.test.ts +141 -0
  86. package/src/__tests__/lazy-conditional.test.ts +52 -12
  87. package/src/__tests__/lazy-preamble.test.ts +11 -11
  88. package/src/__tests__/lazy-row-eligibility.test.ts +8 -3
  89. package/src/__tests__/loop-child-reactive-attr-const-shadow.test.ts +25 -3
  90. package/src/__tests__/loop-index-reactivity.test.ts +208 -0
  91. package/src/__tests__/loop-item-conditional-codegen.test.ts +4 -2
  92. package/src/__tests__/map-arbitrary-body.test.ts +1 -1
  93. package/src/__tests__/map-body-brand-checker.test.ts +176 -0
  94. package/src/__tests__/nested-loop-index-param.test.ts +41 -8
  95. package/src/__tests__/nested-loop-non-outer-reactivity.test.ts +177 -0
  96. package/src/__tests__/nested-loop-plain.test.ts +39 -25
  97. package/src/__tests__/nested-loop-reactive-attrs.test.ts +16 -6
  98. package/src/__tests__/preamble-branch-arm-rerun.test.ts +135 -0
  99. package/src/__tests__/reactive-attrs-in-map.test.ts +13 -10
  100. package/src/__tests__/rewrite-destructured-props.test.ts +37 -0
  101. package/src/__tests__/safe-html-splice-door.test.ts +61 -0
  102. package/src/__tests__/ssr-defaults.test.ts +59 -0
  103. package/src/__tests__/style-css-var-reactive.test.ts +4 -1
  104. package/src/analyzer-context.ts +12 -10
  105. package/src/analyzer.ts +4 -0
  106. package/src/index.ts +8 -2
  107. package/src/ir-to-client-js/collect-elements.ts +126 -32
  108. package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +20 -28
  109. package/src/ir-to-client-js/control-flow/plan/build-component-loop.ts +9 -7
  110. package/src/ir-to-client-js/control-flow/plan/build-composite-loop.ts +12 -6
  111. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +36 -59
  112. package/src/ir-to-client-js/control-flow/plan/build-lazy-row.ts +25 -4
  113. package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +36 -9
  114. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +35 -35
  115. package/src/ir-to-client-js/control-flow/plan/build-plain-row.ts +116 -0
  116. package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +23 -8
  117. package/src/ir-to-client-js/control-flow/plan/inner-loop.ts +24 -45
  118. package/src/ir-to-client-js/control-flow/plan/lazy-conditional.ts +11 -10
  119. package/src/ir-to-client-js/control-flow/plan/lazy-preamble.ts +11 -13
  120. package/src/ir-to-client-js/control-flow/plan/lazy-row-eligibility.ts +13 -3
  121. package/src/ir-to-client-js/control-flow/plan/loop-child-arm.ts +14 -0
  122. package/src/ir-to-client-js/control-flow/plan/loop.ts +10 -6
  123. package/src/ir-to-client-js/control-flow/shared.ts +40 -17
  124. package/src/ir-to-client-js/control-flow/stringify/composite-loop.ts +2 -1
  125. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +20 -66
  126. package/src/ir-to-client-js/control-flow/stringify/insert.ts +4 -2
  127. package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +22 -15
  128. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +53 -11
  129. package/src/ir-to-client-js/control-flow/stringify/loop.ts +4 -2
  130. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +29 -18
  131. package/src/ir-to-client-js/csr-substitute.ts +29 -0
  132. package/src/ir-to-client-js/emit-reactive.ts +92 -6
  133. package/src/ir-to-client-js/emit-registration.ts +2 -4
  134. package/src/ir-to-client-js/html-template.ts +67 -245
  135. package/src/ir-to-client-js/imports.ts +3 -0
  136. package/src/ir-to-client-js/index.ts +4 -4
  137. package/src/ir-to-client-js/markup-slots.ts +25 -0
  138. package/src/ir-to-client-js/reactivity.ts +73 -27
  139. package/src/ir-to-client-js/safe-html.ts +243 -0
  140. package/src/ir-to-client-js/types.ts +19 -3
  141. package/src/ir-to-client-js/utils.ts +58 -10
  142. package/src/jsx-to-ir.ts +136 -24
  143. package/src/prop-rewrite.ts +8 -4
  144. package/src/props-binding.ts +37 -1
  145. package/src/reactivity-checker.ts +60 -0
  146. package/src/ssr-defaults.ts +45 -0
  147. package/src/types.ts +5 -10
  148. package/src/__tests__/text-slot-escaping.test.ts +0 -65
@@ -15,6 +15,8 @@ import { BF_PARENT_SCOPE_PLACEHOLDER, BF_SCOPE, classifyDOMProp, escapeHtml } fr
15
15
  import { buildLoopChainExpr } from '../loop-chain.ts'
16
16
  import { renderChildScopeArgs } from '../adapters/child-scope.ts'
17
17
  import { BindingScope } from '../scope/binding-scope.ts'
18
+ import { interp, spliceChildValue, conditionalMarkup, renderChildCall, mappedRowsMarkup, dangerousInnerHtml, EMPTY_MARKUP } from './safe-html.ts'
19
+ import { markupSlotIdsOf } from './markup-slots.ts'
18
20
 
19
21
  /**
20
22
  * Protect string literals from regex-based replacements.
@@ -310,113 +312,6 @@ function escapeAttrValueExpr(valExpr: string): string {
310
312
  return `escapeAttr(${valExpr})`
311
313
  }
312
314
 
313
- /**
314
- * Build a runtime expression that HTML-escapes an interpolated **text
315
- * content** slot, via the `escapeText` runtime helper. Only the
316
- * `<!--bf:sN-->${expr}<!--/-->` text-marker form is text content: the
317
- * runtime treats whatever sits between the markers as the slot's text, so
318
- * a string value containing `<` / `&` (e.g. `{user.name}`) must be escaped
319
- * to parse correctly under `innerHTML` and to match the SSR-rendered
320
- * bytes. Bare `${...}` interpolations — `{children}` passthrough and
321
- * `renderChild(...)` output — are pre-rendered HTML and must NOT be
322
- * escaped, so this is applied only at the four text-marker emit sites.
323
- * The no-`slotId` fallthrough (every `case 'expression'` branch's final
324
- * `return` in this file) is shared by several unrelated shapes besides
325
- * `{children}` passthrough — an `escapeLeafTextExpressions`-wrapped
326
- * preamble leaf, `lowerFormControlValueSsr`'s textarea initial value, an
327
- * inlined constant, a `''`/`undefined` deferred placeholder — every one of
328
- * which is either already escaped or a literal, and must reach the
329
- * template untouched. `bareSpliceExpr` below is that branch's single door
330
- * — the one place the fallthrough's decision is made — and it is what
331
- * picks the genuine `{children}` reference back out for `markupOrEmpty`'s
332
- * nullish guard (#2775); see its own docstring.
333
- * Hono escapes text content with the same set as attribute values
334
- * (`& " ' < >`), so `escapeText` delegates to the same operation.
335
- *
336
- * `isMarkup` (#2651) switches to `escapeTextOrMarkup` — `escapeText`'s
337
- * strict superset that additionally unwraps a `bfMarkup()`-branded value
338
- * raw instead of escaping it. Two of the four text-marker call sites
339
- * (`irToComponentTemplateWithOpts`, `generateCsrTemplateWithOpts`) pass
340
- * `true` for a slot `ctx.dynamicElements` also claims — the same
341
- * membership `emit-reactive.ts` uses to pick the 'markup' writer kind for
342
- * this slot's REACTIVE update, so the initial-render escape and the
343
- * reactive-update escape agree on whether the slot may carry raw markup.
344
- * The other two (`irToHtmlTemplate`, `irToPlaceholderTemplate`) build
345
- * loop-item / conditional-branch HTML, which the reactive side always
346
- * treats as plain text (`__bfText`) regardless — they never pass `true`,
347
- * so they keep calling `escapeText` byte-for-byte as before.
348
- */
349
- function escapeTextSlotExpr(innerExpr: string, isMarkup = false): string {
350
- return `${isMarkup ? 'escapeTextOrMarkup' : 'escapeText'}(${innerExpr})`
351
- }
352
-
353
- /**
354
- * Recognizes a JSX child-position expression that is exactly a reference to
355
- * the reserved `children` prop — bare `children` (destructured) or
356
- * `<receiver>.children` for any single-identifier receiver (`props.children`,
357
- * a custom props-param name, a loop-scoped alias closing over props, ...).
358
- * Checked against `node.expr` — the ORIGINAL source text, never a
359
- * transformed/wrapped form — so it stays accurate regardless of which
360
- * builder is asking, and regardless of any earlier pass
361
- * (`escapeLeafTextExpressions`, `lowerFormControlValueSsr`) that may have
362
- * wrapped an unrelated leaf.
363
- *
364
- * Deliberately LOOSER than `isTransparentFragment` (`jsx-to-ir.ts`), which
365
- * answers the same underlying question one level up. That function runs on
366
- * the TS AST and compares the expression text against an EXACT set —
367
- * `children`, `props.children`, and the analyzer-resolved
368
- * `${ctx.analyzer.propsObjectName}.children`. This layer works on IR and has
369
- * no analyzer, so the resolved props name is not reachable here; matching any
370
- * single-identifier receiver is the available approximation, chosen — not an
371
- * inherited convention.
372
- *
373
- * The looseness costs nothing measurable. An unrelated `.children` member —
374
- * a tree node's own `children` array, say — does not even arrive here: a
375
- * reactive member expression is given a `slotId` and takes the escaped
376
- * text-slot branch above, so it never reaches the bare-splice fallthrough
377
- * this gates. And were one to arrive, the outcome is still benign: the
378
- * branch never escaped its value either way, a non-nullish value is
379
- * returned untouched, and a nullish one rendering `''` instead of the
380
- * literal `"undefined"` is an improvement in its own right.
381
- *
382
- * Both the ORIGINAL source text and the RESOLVED expression are tested,
383
- * because either one alone misses a shape. `node.expr` is the only form
384
- * that does not vary between the four builders, so it stays the primary
385
- * test; but it is the pre-substitution text, which for a
386
- * destructured-and-renamed children (`const { children: kids } = props`)
387
- * reads `kids` and matches nothing — while the resolved expression the
388
- * emitter is about to splice already reads `(_p.children)`. Testing both
389
- * closes that (#2786) without giving up `node.expr`'s stability.
390
- */
391
- function isChildrenPassthroughExpr(expr: string): boolean {
392
- return /^([A-Za-z_$][\w$]*\.)?children$/.test(expr.trim())
393
- }
394
-
395
- /**
396
- * The single door for the bare (no-`slotId`) expression splice — the
397
- * counterpart to `escapeTextSlotExpr` for the branch that must NOT escape.
398
- * All four `case 'expression'` builders in this file route through here so
399
- * this decision exists in exactly one place: four copies that agree today
400
- * are four that can drift apart tomorrow, and this file is where that has
401
- * already happened (#2753 -> #2762).
402
- *
403
- * Only a genuine `{children}` passthrough gets `markupOrEmpty`'s nullish
404
- * guard (#2775). Everything else this fallthrough hosts — an
405
- * `escapeLeafTextExpressions`-wrapped preamble leaf,
406
- * `lowerFormControlValueSsr`'s textarea initial value, an inlined constant,
407
- * a `''`/`undefined` deferred placeholder — reaches the template exactly as
408
- * it arrived, already escaped or a literal. Escaping is never correct here:
409
- * the value is pre-rendered HTML, per `escapeTextSlotExpr`'s docstring.
410
- */
411
- function bareSpliceExpr(node: IRExpression, valueExpr: string): string {
412
- // Strip the parens the emitter wraps a substituted expression in, so the
413
- // resolved form is comparable to the bare source text.
414
- const resolved = valueExpr.trim().replace(/^\(+|\)+$/g, '')
415
- const isChildren =
416
- isChildrenPassthroughExpr(node.expr) || isChildrenPassthroughExpr(resolved)
417
- return !node.joinArrayChild && isChildren ? `markupOrEmpty(${valueExpr})` : valueExpr
418
- }
419
-
420
315
  /**
421
316
  * `dangerouslySetInnerHTML={{ __html: E }}` makes the element's content its
422
317
  * raw innerHTML — the intentional, React-style escape hatch. Returns the
@@ -435,7 +330,7 @@ function dangerouslyHtmlChildren(
435
330
  ): string | null {
436
331
  const attr = attrs.find(a => a.name === 'dangerouslySetInnerHTML')
437
332
  if (!attr || attr.value.kind !== 'expression') return null
438
- return `\${((${toExpr(attr.value)}) ?? {}).__html ?? ''}`
333
+ return interp(dangerousInnerHtml(toExpr(attr.value)))
439
334
  }
440
335
 
441
336
  /**
@@ -672,9 +567,17 @@ function buildSpreadAttrsMergeCall(args: {
672
567
  * expression (e.g. `t.id`). Emits a live `${keyExpr}` interpolation so each
673
568
  * rendered item carries its own key — `loopItemMarker` is reserved for
674
569
  * already-evaluated key strings (runtime / static contexts).
570
+ *
571
+ * The key is wrapped in `escapeCommentText` (#2795 follow-up): unlike every
572
+ * other hole in this file, this one lands inside HTML COMMENT content, where
573
+ * `escapeText`'s `& < > " '` escaping does nothing — the only thing that
574
+ * matters here is that the key can't spell `-->` and close the comment
575
+ * early. See `escapeCommentText`'s docstring (`@barefootjs/client/runtime`)
576
+ * for why a lossy hyphen substitution is fine: nothing reads this key back
577
+ * out of the DOM.
675
578
  */
676
579
  function itemAnchorTemplate(keyExpr: string): string {
677
- return `<!--${loopItemMarker('${' + keyExpr + '}')}-->`
580
+ return `<!--${loopItemMarker('${escapeCommentText(' + keyExpr + ')}')}-->`
678
581
  }
679
582
 
680
583
  /**
@@ -710,9 +613,9 @@ export function renderPreamble(
710
613
  const text = opts.textVariant === 'template' ? (seg.templateText ?? seg.text) : seg.text
711
614
  out += opts.transformJs ? opts.transformJs(text) : text
712
615
  } else if (opts.rawLeaf) {
713
- out += opts.renderLeaf(escapeLeafTextExpressions(seg.ir))
616
+ out += opts.renderLeaf(seg.ir)
714
617
  } else {
715
- out += '`' + opts.renderLeaf(escapeLeafTextExpressions(seg.ir)) + '`'
618
+ out += '`' + opts.renderLeaf(seg.ir) + '`'
716
619
  }
717
620
  }
718
621
  return out
@@ -803,43 +706,11 @@ export function renderFlatMapProjectionClientBody(
803
706
  const params = inner.index ? `(${inner.param}, ${inner.index})` : `(${inner.param})`
804
707
  const key = inner.key ? `(${inner.key})` : 'undefined'
805
708
  const html = inner.children
806
- .map((c) => irToHtmlTemplate(escapeLeafTextExpressions(c), restSpreadNames, 1, undefined, undefined))
709
+ .map((c) => irToHtmlTemplate(c, restSpreadNames, 1, undefined, undefined))
807
710
  .join('')
808
711
  return `${chained}.map(${params} => ({ k: ${key}, h: \`${html}\` }))`
809
712
  }
810
713
 
811
- /**
812
- * SSR/CSR escaping parity for preamble leaves, decided once at the door: a
813
- * JSX-runtime SSR adapter renders the leaf's raw JSX and auto-escapes text
814
- * interpolations (`{c}`), so the client's HTML-string lowering must escape the
815
- * same positions — wrap every text-position expression in `escapeText(...)`.
816
- * Pure (returns a transformed copy); the neutral IR is never mutated with a
817
- * client-only concern. Attribute values already flow through the template
818
- * emitters' own attr escaping.
819
- */
820
- function escapeLeafTextExpressions(ir: IRNode): IRNode {
821
- switch (ir.type) {
822
- case 'element':
823
- return { ...ir, children: ir.children.map(escapeLeafTextExpressions) }
824
- case 'fragment':
825
- return { ...ir, children: ir.children.map(escapeLeafTextExpressions) }
826
- case 'expression': {
827
- if (ir.expr === 'null' || ir.expr === 'undefined') return ir
828
- // Already-wrapped or slotted expressions keep their existing handling.
829
- if (ir.slotId || ir.expr.trimStart().startsWith('escapeText(')) return ir
830
- return { ...ir, expr: `escapeText((${ir.expr}))`, templateExpr: ir.templateExpr ? `escapeText((${ir.templateExpr}))` : ir.templateExpr }
831
- }
832
- case 'conditional':
833
- return {
834
- ...ir,
835
- whenTrue: escapeLeafTextExpressions(ir.whenTrue),
836
- whenFalse: ir.whenFalse ? escapeLeafTextExpressions(ir.whenFalse) : ir.whenFalse,
837
- }
838
- default:
839
- return ir
840
- }
841
- }
842
-
843
714
  // `loopParams` here is the accessor-rewrite spec list `wrapExprWithLoopParams`
844
715
  // consumes, not a `BindingScope`-trackable name set — see that function's
845
716
  // docstring (`ir-to-client-js/utils.ts`) for why this stays outside #2482's
@@ -848,9 +719,6 @@ function escapeLeafTextExpressions(ir: IRNode): IRNode {
848
719
  export function irToHtmlTemplate(node: IRNode, restSpreadNames?: ReadonlySet<string>, loopDepth = 0, loopParams?: ReadonlyArray<string | LoopParamSpec>, branchSlotsVar?: string, inHoistedChildren = false): string {
849
720
  const recurse = (n: IRNode): string => irToHtmlTemplate(n, restSpreadNames, loopDepth, loopParams, branchSlotsVar, inHoistedChildren)
850
721
  const wrapExpr = (expr: string) => wrapExprWithLoopParams(expr, loopParams)
851
- const wrapInterpolation = (expr: string): string => branchSlotsVar
852
- ? `__bfSlot(${expr}, ${branchSlotsVar})`
853
- : expr
854
722
 
855
723
  switch (node.type) {
856
724
  case 'element': {
@@ -967,38 +835,13 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: ReadonlySet<str
967
835
  // because their `clientOnly` semantics differ — see that function's
968
836
  // own comment (#2617).
969
837
 
970
- // Escape only when the IR says so (`escapeInClientTemplate`) most
971
- // `${...}` here is already pre-rendered HTML. Never take
972
- // `templateExpr` wholesale instead: it rebinds to `_p.xxx`, dropping
973
- // the `?? {}` prop-defaulting guard in this builder's init scope
974
- // (`client-js-generation.test.ts`).
975
- const escapeForClient = (e: string): string =>
976
- node.escapeInClientTemplate ? `escapeText(${e})` : e
977
- if (node.markerless) {
978
- const bare = escapeForClient(wrapInterpolation(wrapExpr(node.expr)))
979
- return `\${${bare}}`
980
- }
981
- const inner = escapeForClient(wrapInterpolation(wrapExpr(node.expr)))
982
- // Stage 3 / D4 — an element-array child ({out}) built by an arbitrary
983
- // .map() preamble is an array of HTML strings; join it rather than let
984
- // `${[...]}` `String`-comma-collapse it. Only reached on a JS-runtime
985
- // adapter (the flag is set in Phase 1 only there); a plain local can be
986
- // read twice safely.
987
- const valueExpr = node.joinArrayChild
988
- ? `Array.isArray(${inner}) ? ${inner}.join('') : (${inner} ?? '')`
989
- : inner
990
- if (node.slotId) {
991
- // In branch-slot context `wrapInterpolation` routes the value
992
- // through `__bfSlot`, which returns raw `<!--bf-slot:N-->` markers
993
- // for live `Node` values (spliced back by `insert()`). Escaping
994
- // would corrupt those markers and drop slotted content (#1694
995
- // regression). `__bfSlot` owns coercion of its own value, so the
996
- // text-escape applies only to the non-slot (plain text) form.
997
- const slotted = branchSlotsVar || node.joinArrayChild ? valueExpr : escapeTextSlotExpr(valueExpr)
998
- return `<!--bf:${node.slotId}-->\${${slotted}}<!--/-->`
999
- }
1000
- // Bare-splice fallthrough (no `slotId`, not an array-child join).
1001
- return `\${${bareSpliceExpr(node, valueExpr)}}`
838
+ // Never take `templateExpr` here instead of `expr`: it rebinds to
839
+ // `_p.xxx`, dropping the `?? {}` prop-defaulting guard in this
840
+ // builder's init scope (`client-js-generation.test.ts`). Escaping is
841
+ // the door's job (`spliceChildValue`), not this builder's.
842
+ const hole = interp(spliceChildValue(node, wrapExpr(node.expr), { branchSlotsVar }))
843
+ if (node.markerless) return hole
844
+ return node.slotId ? `<!--bf:${node.slotId}-->${hole}<!--/-->` : hole
1002
845
  }
1003
846
 
1004
847
  case 'conditional': {
@@ -1006,7 +849,7 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: ReadonlySet<str
1006
849
  const falseBranch = recurse(node.whenFalse)
1007
850
  const trueHtml = node.slotId ? addCondAttrToTemplate(trueBranch, node.slotId) : trueBranch
1008
851
  const falseHtml = node.slotId ? addCondAttrToTemplate(falseBranch, node.slotId) : falseBranch
1009
- return `\${${wrapExpr(node.condition)} ? \`${trueHtml}\` : \`${falseHtml}\`}`
852
+ return interp(conditionalMarkup(wrapExpr(node.condition), trueHtml, falseHtml))
1010
853
  }
1011
854
 
1012
855
  case 'fragment':
@@ -1044,8 +887,8 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: ReadonlySet<str
1044
887
  // trust the brand and skip re-escaping it. EXCEPT an explicit
1045
888
  // `children={<jsx/>}` prop (out of scope, unchanged): the
1046
889
  // child's `{children}` interpolation is the bare-passthrough
1047
- // door (`escapeTextSlotExpr`'s own docstring), never routed
1048
- // through an escape/unwrap call, so a branded object here
890
+ // arm of `spliceChildValue`'s door (`safe-html.ts`), never
891
+ // routed through an escape/unwrap call, so a branded object here
1049
892
  // would stringify to `[object Object]` instead of unwrapping.
1050
893
  return p.name === 'children'
1051
894
  ? `${quotePropName(p.name)}: \`${childHtml}\``
@@ -1068,7 +911,15 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: ReadonlySet<str
1068
911
  // emit sites (`irToComponentTemplateWithOpts`,
1069
912
  // `generateCsrTemplateWithOpts`), which already pick the
1070
913
  // prop-rewritten variant.
1071
- const expr = attrValueToString(p.value, { useTemplate: true }) ?? 'undefined'
914
+ // Also wrap through `wrapExpr` (#2868 follow-up): this
915
+ // `renderChild(...)` call can itself live inside a loop row's
916
+ // conditional-branch HTML (a child component rendered by
917
+ // `.map()`-row type), so a prop reading the row's own loop
918
+ // param/index must read the live accessor, same as every
919
+ // other expression position `irToHtmlTemplate` renders —
920
+ // previously masked by the post-hoc `wrapLoopParamAsAccessor`
921
+ // pass over the whole branch string, which #2868 removed.
922
+ const expr = wrapExpr(attrValueToString(p.value, { useTemplate: true }) ?? 'undefined')
1072
923
  return `${quotePropName(p.name)}: ${expr}`
1073
924
  }
1074
925
  }
@@ -1085,7 +936,7 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: ReadonlySet<str
1085
936
  // the slot — `renderChildScopeArgs` still passes its slotId with the
1086
937
  // `loopItemRoot` flag (#2833) so it keeps `bf-h`/`bf-m` slot identity,
1087
938
  // matching the Hono reference (#2444).
1088
- return `\${renderChild('${nameForRegistryRef(node.name)}', ${propsExpr}${renderChildScopeArgs(node, keyArg)})}`
939
+ return interp(renderChildCall(nameForRegistryRef(node.name), propsExpr, renderChildScopeArgs(node, keyArg)))
1089
940
  }
1090
941
 
1091
942
  case 'loop': {
@@ -1130,14 +981,14 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: ReadonlySet<str
1130
981
  const body = renderPreamble(node.flatMapCallback, {
1131
982
  renderLeaf: (ir) => irToHtmlTemplate(stripLeafKeyAttr(ir), restSpreadNames, loopDepth + 1, loopParams, branchSlotsVar),
1132
983
  })
1133
- mapExpr = `\${${wrappedArray}.flatMap(${node.flatMapCallback.params} => ${body}).join('')}`
984
+ mapExpr = interp(mappedRowsMarkup(wrappedArray, 'flatMap', node.flatMapCallback.params, body))
1134
985
  } else if (node.preamble) {
1135
986
  // Stage 3 / D4 — render JSX leaves in an arbitrary array-builder
1136
987
  // preamble (the hydrate-template context uses the bare loop param).
1137
988
  const preamble = renderPreamble(node.preamble, { textVariant: 'client', renderLeaf: (ir) => irToHtmlTemplate(ir, restSpreadNames, loopDepth + 1, loopParams, branchSlotsVar) })
1138
- mapExpr = `\${${wrappedArray}.${iterMethod}(${callbackParam} => { ${preamble} return \`${childTemplate}\` }).join('')}`
989
+ mapExpr = interp(mappedRowsMarkup(wrappedArray, iterMethod, callbackParam, `{ ${preamble} return \`${childTemplate}\` }`))
1139
990
  } else {
1140
- mapExpr = `\${${wrappedArray}.${iterMethod}(${callbackParam} => \`${childTemplate}\`).join('')}`
991
+ mapExpr = interp(mappedRowsMarkup(wrappedArray, iterMethod, callbackParam, `\`${childTemplate}\``))
1141
992
  }
1142
993
  // Wrap with loop boundary markers so reconciliation doesn't affect siblings
1143
994
  return `<!--${loopStartMarker(node.markerId)}-->${mapExpr}<!--${loopEndMarker(node.markerId)}-->`
@@ -1552,19 +1403,10 @@ export function irToPlaceholderTemplate(node: IRNode, restSpreadNames?: Readonly
1552
1403
 
1553
1404
  case 'expression': {
1554
1405
  if (node.expr === 'null' || node.expr === 'undefined') return ''
1555
- const wrapped = wrapExpr(node.expr)
1556
- // Stage 3 / D4 — join an element-array child ({out}) built by the preamble.
1557
- const value = node.joinArrayChild
1558
- ? `Array.isArray(${wrapped}) ? ${wrapped}.join('') : (${wrapped} ?? '')`
1559
- : wrapped
1560
- if (node.slotId) {
1561
- return `<!--bf:${node.slotId}-->\${${node.joinArrayChild ? value : escapeTextSlotExpr(wrapped)}}<!--/-->`
1562
- }
1563
- // Bare-splice fallthrough (no `slotId`) — this builder's composite-row
1564
- // twin of `irToHtmlTemplate`'s `escapeForClient`, same "why not
1565
- // templateExpr" reasoning (#2765).
1566
- const spliced = bareSpliceExpr(node, value)
1567
- return `\${${node.escapeInClientTemplate ? `escapeText(${spliced})` : spliced}}`
1406
+ // Same "`expr`, never `templateExpr`" reasoning as `irToHtmlTemplate`
1407
+ // (#2765); escaping is the door's job.
1408
+ const hole = interp(spliceChildValue(node, wrapExpr(node.expr), {}))
1409
+ return node.slotId ? `<!--bf:${node.slotId}-->${hole}<!--/-->` : hole
1568
1410
  }
1569
1411
 
1570
1412
  case 'conditional': {
@@ -1572,7 +1414,7 @@ export function irToPlaceholderTemplate(node: IRNode, restSpreadNames?: Readonly
1572
1414
  const falseBranch = recurse(node.whenFalse)
1573
1415
  const trueHtml = node.slotId ? addCondAttrToTemplate(trueBranch, node.slotId) : trueBranch
1574
1416
  const falseHtml = node.slotId ? addCondAttrToTemplate(falseBranch, node.slotId) : falseBranch
1575
- return `\${${wrapExpr(node.condition)} ? \`${trueHtml}\` : \`${falseHtml}\`}`
1417
+ return interp(conditionalMarkup(wrapExpr(node.condition), trueHtml, falseHtml))
1576
1418
  }
1577
1419
 
1578
1420
  case 'fragment':
@@ -1606,13 +1448,13 @@ export function irToPlaceholderTemplate(node: IRNode, restSpreadNames?: Readonly
1606
1448
  // Leaf `key` stripped — see the irToHtmlTemplate site above.
1607
1449
  renderLeaf: (ir) => irToPlaceholderTemplate(stripLeafKeyAttr(ir), restSpreadNames, loopDepth + 1, loopParams),
1608
1450
  })
1609
- mapExpr = `\${${wrappedArray}.flatMap(${node.flatMapCallback.params} => ${body}).join('')}`
1451
+ mapExpr = interp(mappedRowsMarkup(wrappedArray, 'flatMap', node.flatMapCallback.params, body))
1610
1452
  } else if (node.preamble) {
1611
1453
  // Stage 3 / D4 — render JSX leaves in an arbitrary array-builder preamble.
1612
1454
  const preamble = renderPreamble(node.preamble, { textVariant: 'client', renderLeaf: (ir) => irToPlaceholderTemplate(ir, restSpreadNames, loopDepth + 1, loopParams) })
1613
- mapExpr = `\${${wrappedArray}.${iterMethod}(${callbackParam} => { ${preamble} return \`${childTemplate}\` }).join('')}`
1455
+ mapExpr = interp(mappedRowsMarkup(wrappedArray, iterMethod, callbackParam, `{ ${preamble} return \`${childTemplate}\` }`))
1614
1456
  } else {
1615
- mapExpr = `\${${wrappedArray}.${iterMethod}(${callbackParam} => \`${childTemplate}\`).join('')}`
1457
+ mapExpr = interp(mappedRowsMarkup(wrappedArray, iterMethod, callbackParam, `\`${childTemplate}\``))
1616
1458
  }
1617
1459
  return `<!--${loopStartMarker(node.markerId)}-->${mapExpr}<!--${loopEndMarker(node.markerId)}-->`
1618
1460
  }
@@ -1881,8 +1723,8 @@ export interface TemplateOptions {
1881
1723
  * (`ctx.dynamicElements`, populated by `collectElements` — the exact set
1882
1724
  * `emit-reactive.ts`'s `emitDynamicTextUpdates` claims `kind: 'markup'`
1883
1725
  * for on the REACTIVE side, #2651). When a text-marker expression's
1884
- * `slotId` is a member, `escapeTextSlotExpr` emits `escapeTextOrMarkup`
1885
- * instead of `escapeText` so a `bfMarkup()`-branded prop value (a JSX
1726
+ * `slotId` is a member, `spliceChildValue`'s door (`safe-html.ts`) emits
1727
+ * `escapeTextOrMarkup` instead of `escapeText` so a `bfMarkup()`-branded prop value (a JSX
1886
1728
  * element passed at a non-`children` component prop position) reaches
1887
1729
  * the initial-render template raw, matching what the reactive writer
1888
1730
  * already does via `escapeTextOrNode`. Computed once per component from
@@ -2087,17 +1929,8 @@ function irToComponentTemplateWithOpts(node: IRNode, opts: TemplateOptions): str
2087
1929
  if (node.markerless) return ''
2088
1930
  return `<!--bf:${node.slotId}--><!--/-->`
2089
1931
  }
2090
- const wrapped = transformExpr(node.expr, node.templateExpr)
2091
- // Stage 3 / D4 — join an element-array child ({out}) built by the preamble.
2092
- const value = node.joinArrayChild
2093
- ? `Array.isArray(${wrapped}) ? ${wrapped}.join('') : (${wrapped} ?? '')`
2094
- : wrapped
2095
- if (node.slotId) {
2096
- const isMarkup = opts.markupSlotIds?.has(node.slotId) ?? false
2097
- return `<!--bf:${node.slotId}-->\${${node.joinArrayChild ? value : escapeTextSlotExpr(wrapped, isMarkup)}}<!--/-->`
2098
- }
2099
- // Bare-splice fallthrough (no `slotId`).
2100
- return `\${${bareSpliceExpr(node, value)}}`
1932
+ const hole = interp(spliceChildValue(node, transformExpr(node.expr, node.templateExpr), { markupSlotIds: opts.markupSlotIds }))
1933
+ return node.slotId ? `<!--bf:${node.slotId}-->${hole}<!--/-->` : hole
2101
1934
  }
2102
1935
 
2103
1936
  case 'conditional': {
@@ -2113,7 +1946,7 @@ function irToComponentTemplateWithOpts(node: IRNode, opts: TemplateOptions): str
2113
1946
  const falseBranch = recurse(node.whenFalse)
2114
1947
  const trueHtml = node.slotId ? addCondAttrToTemplate(trueBranch, node.slotId) : trueBranch
2115
1948
  const falseHtml = node.slotId ? addCondAttrToTemplate(falseBranch, node.slotId) : falseBranch
2116
- return `\${${transformExpr(node.condition, node.templateCondition)} ? \`${trueHtml}\` : \`${falseHtml}\`}`
1949
+ return interp(conditionalMarkup(transformExpr(node.condition, node.templateCondition), trueHtml, falseHtml))
2117
1950
  }
2118
1951
 
2119
1952
  case 'fragment':
@@ -2167,7 +2000,7 @@ function irToComponentTemplateWithOpts(node: IRNode, opts: TemplateOptions): str
2167
2000
  const propsExpr = propsEntries.length > 0 ? `{${propsEntries.join(', ')}}` : '{}'
2168
2001
  const keyProp = node.props.find(p => p.name === 'key')
2169
2002
  const keyArg = keyProp ? `, ${transformKeyValue(keyProp.value, transformExpr)}` : ''
2170
- return `\${renderChild('${nameForRegistryRef(node.name)}', ${propsExpr}${keyArg})}`
2003
+ return interp(renderChildCall(nameForRegistryRef(node.name), propsExpr, keyArg))
2171
2004
  }
2172
2005
 
2173
2006
  case 'loop': {
@@ -2187,7 +2020,7 @@ function irToComponentTemplateWithOpts(node: IRNode, opts: TemplateOptions): str
2187
2020
  // with the conditional-template path.
2188
2021
  const consequent = recurse(node.consequent)
2189
2022
  const alternate = node.alternate ? recurse(node.alternate) : ''
2190
- return `\${${transformExpr(node.condition, node.templateCondition)} ? \`${consequent}\` : \`${alternate}\`}`
2023
+ return interp(conditionalMarkup(transformExpr(node.condition, node.templateCondition), consequent, alternate))
2191
2024
  }
2192
2025
 
2193
2026
  case 'provider':
@@ -2349,13 +2182,7 @@ export function generateCsrTemplate(
2349
2182
  }
2350
2183
  }
2351
2184
  const effectiveUnsafeLocalNames = mergeCsrNullUnsafe(ctx, unsafeLocalNames)
2352
- // `ctx.dynamicElements` (populated by `collectElements`, before any
2353
- // template-string build runs) IS the claim-plan-'markup' membership
2354
- // `emit-reactive.ts` claims for these same slot ids on the reactive
2355
- // side (#2651) — every entry is already non-conditional / non-`@client`
2356
- // by construction (`collectElements`'s `expression` visitor only pushes
2357
- // here). Reused as-is, not re-derived.
2358
- const markupSlotIds = new Set(ctx.dynamicElements.map(e => e.slotId))
2185
+ const markupSlotIds = markupSlotIdsOf(ctx)
2359
2186
  return generateCsrTemplateWithOpts(node, { inlinableConstants, restSpreadNames, propsObjectName, csrEnv, unsafeLocalNames: effectiveUnsafeLocalNames, deferredChildSlots, loopDepth: -1, markupSlotIds, restPropsName: ctx.restPropsName })
2360
2187
  }
2361
2188
 
@@ -2736,18 +2563,13 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
2736
2563
  const transformed = transformExpr(node.expr, node.templateExpr)
2737
2564
  // Init-body-only refs would render the literal text "undefined"
2738
2565
  // before init's createEffect overwrites the slot (#1128). Emit
2739
- // an empty placeholder instead.
2740
- const expr = transformed === UNSAFE_TEMPLATE_EXPR ? "''" : transformed
2741
- // Stage 3 / D4 — join an element-array child ({out}) built by the preamble.
2742
- const value = node.joinArrayChild
2743
- ? `Array.isArray(${expr}) ? ${expr}.join('') : (${expr} ?? '')`
2744
- : expr
2745
- if (node.slotId) {
2746
- const isMarkup = opts.markupSlotIds?.has(node.slotId) ?? false
2747
- return `<!--bf:${node.slotId}-->\${${node.joinArrayChild ? value : escapeTextSlotExpr(expr, isMarkup)}}<!--/-->`
2748
- }
2749
- // Bare-splice fallthrough (no `slotId`).
2750
- return `\${${bareSpliceExpr(node, value)}}`
2566
+ // an empty placeholder instead — a literal, so it bypasses the door.
2567
+ const hole = interp(
2568
+ transformed === UNSAFE_TEMPLATE_EXPR
2569
+ ? EMPTY_MARKUP
2570
+ : spliceChildValue(node, transformed, { markupSlotIds: opts.markupSlotIds }),
2571
+ )
2572
+ return node.slotId ? `<!--bf:${node.slotId}-->${hole}<!--/-->` : hole
2751
2573
  }
2752
2574
 
2753
2575
  case 'conditional': {
@@ -2764,7 +2586,7 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
2764
2586
  const falseBranch = recurse(node.whenFalse)
2765
2587
  const trueHtml = node.slotId ? addCondAttrToTemplate(trueBranch, node.slotId) : trueBranch
2766
2588
  const falseHtml = node.slotId ? addCondAttrToTemplate(falseBranch, node.slotId) : falseBranch
2767
- return `\${${transformExpr(node.condition, node.templateCondition)} ? \`${trueHtml}\` : \`${falseHtml}\`}`
2589
+ return interp(conditionalMarkup(transformExpr(node.condition, node.templateCondition), trueHtml, falseHtml))
2768
2590
  }
2769
2591
 
2770
2592
  case 'fragment':
@@ -2844,7 +2666,7 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
2844
2666
  // See `renderChildScopeArgs`'s docstring — a loop item root still gets
2845
2667
  // its slotId, plus the `loopItemRoot` flag (#2833), rather than no
2846
2668
  // slot argument at all.
2847
- return `\${renderChild('${nameForRegistryRef(node.name)}', ${propsExpr}${renderChildScopeArgs(node, keyArg)})}`
2669
+ return interp(renderChildCall(nameForRegistryRef(node.name), propsExpr, renderChildScopeArgs(node, keyArg)))
2848
2670
  }
2849
2671
 
2850
2672
  case 'loop': {
@@ -2916,14 +2738,14 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
2916
2738
  // Leaf `key` stripped — see the irToHtmlTemplate site above.
2917
2739
  renderLeaf: (ir) => recurseInLoopBody(stripLeafKeyAttr(ir)),
2918
2740
  })
2919
- mapExpr = `\${${iterArrayExpr}.flatMap(${node.flatMapCallback.params} => ${body}).join('')}`
2741
+ mapExpr = interp(mappedRowsMarkup(iterArrayExpr, 'flatMap', node.flatMapCallback.params, body))
2920
2742
  } else if (node.preamble) {
2921
2743
  // Stage 3 / D4 — template-variant js text with the props rewrite
2922
2744
  // applied per segment; JSX leaves render via the loop-body recursion.
2923
2745
  const preamble = renderPreamble(node.preamble, { textVariant: 'template', transformJs: (t) => rewritePropsObjectRef(t, propsObjectName ?? null, restPropsName ?? null, { enclosingScope: childScope }), renderLeaf: (ir) => recurseInLoopBody(ir) })
2924
- mapExpr = `\${${iterArrayExpr}.${iterMethod}(${callbackParam} => { ${preamble} return \`${childTemplate}\` }).join('')}`
2746
+ mapExpr = interp(mappedRowsMarkup(iterArrayExpr, iterMethod, callbackParam, `{ ${preamble} return \`${childTemplate}\` }`))
2925
2747
  } else {
2926
- mapExpr = `\${${iterArrayExpr}.${iterMethod}(${callbackParam} => \`${childTemplate}\`).join('')}`
2748
+ mapExpr = interp(mappedRowsMarkup(iterArrayExpr, iterMethod, callbackParam, `\`${childTemplate}\``))
2927
2749
  }
2928
2750
  return `<!--${loopStartMarker(node.markerId)}-->${mapExpr}<!--${loopEndMarker(node.markerId)}-->`
2929
2751
  }
@@ -2931,7 +2753,7 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
2931
2753
  case 'if-statement': {
2932
2754
  const consequent = recurse(node.consequent)
2933
2755
  const alternate = node.alternate ? recurse(node.alternate) : ''
2934
- return `\${${transformExpr(node.condition, node.templateCondition)} ? \`${consequent}\` : \`${alternate}\`}`
2756
+ return interp(conditionalMarkup(transformExpr(node.condition, node.templateCondition), consequent, alternate))
2935
2757
  }
2936
2758
 
2937
2759
  case 'provider':
@@ -30,6 +30,9 @@ export const RUNTIME_IMPORT_CANDIDATES = [
30
30
  'createPortal',
31
31
  'provideContext', 'createContext', 'useContext',
32
32
  'forwardProps', 'applyRestAttrs', 'splitProps', 'spreadAttrs', 'styleToCss', 'escapeAttr', 'escapeText', 'escapeTextOrNode',
33
+ // Whole-item loop conditional's `bf-loop-i:<key>` anchor comment (#1665,
34
+ // #2795 follow-up) — neutralizes `-` so a key can't close the comment early.
35
+ 'escapeCommentText',
33
36
  // JSX-element-as-non-children-prop markup brand (#2651) — `bfMarkup` wraps
34
37
  // the compiler-built HTML at the producer (renderChild / initChild props);
35
38
  // `escapeTextOrMarkup` unwraps it at the claim-plan-'markup' template slot.
@@ -15,6 +15,7 @@ import { generateInitFunction } from './generate-init.ts'
15
15
  import { buildReferencesGraph, graphUsedIdentifiers } from './build-references.ts'
16
16
  import { addConstantPropRefsToSet } from './init-declarations.ts'
17
17
  import { canGenerateStaticTemplate, irToComponentTemplate, generateCsrTemplate } from './html-template.ts'
18
+ import { markupSlotIdsOf } from './markup-slots.ts'
18
19
  import { PROPS_PARAM } from './utils.ts'
19
20
  import { buildInlinableConstants, csrInlinableConstantsFromCtx } from './emit-registration.ts'
20
21
  import { buildEnvFromCtx } from './compute-inlinability.ts'
@@ -282,10 +283,9 @@ function generateTemplateOnlyMount(ir: ComponentIR, ctx: ClientJsContext): strin
282
283
 
283
284
  if (canGenerateStaticTemplate(ir.root, propNamesForStaticCheck, inlinableConstants, unsafeLocalNames)) {
284
285
  // `ctx.dynamicElements` is always empty on this template-only path
285
- // (`needsClientJs(ctx)` gates it) — passed through anyway so this
286
- // matches `emitRegistrationAndHydration`'s derivation byte-for-byte
287
- // rather than special-casing "no markup slots here" (#2651).
288
- const markupSlotIds = new Set(ctx.dynamicElements.map(e => e.slotId))
286
+ // (`needsClientJs(ctx)` gates it) — derived through the shared function
287
+ // anyway rather than special-casing "no markup slots here" (#2651).
288
+ const markupSlotIds = markupSlotIdsOf(ctx)
289
289
  templateHtml = irToComponentTemplate(ir.root, inlinableConstants, restSpreadNames, ctx.propsObjectName, markupSlotIds, ctx.restPropsName)
290
290
  }
291
291
 
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Which content slots may carry raw markup at INITIAL render — decided once
3
+ * (#2795, consolidating three copies of the same one-liner that were
4
+ * "trusted by comment" to agree with `emit-reactive.ts`'s writer kind).
5
+ *
6
+ * Every non-conditional `ctx.dynamicElements` entry gets a claim-plan
7
+ * writer of `kind: 'markup'` on the REACTIVE side (`emit-reactive.ts`),
8
+ * because its expression may resolve to a live Node or to a `bfMarkup()`-
9
+ * branded JSX-prop value (#1663, #2651). The initial-render template must
10
+ * make the same call for the same slots — `escapeTextOrMarkup` instead of
11
+ * `escapeText` (`safe-html.ts`'s `spliceChildValue`, arm 3) — so first paint
12
+ * and first update agree on whether the slot unwraps a branded value.
13
+ * `client-template-escape-soundness.test.ts` pins that the two sets are
14
+ * equal on emitted output.
15
+ */
16
+
17
+ import type { ClientJsContext } from './types.ts'
18
+
19
+ /** The claim-writer kind every `ctx.dynamicElements` slot is emitted with. */
20
+ export const DYNAMIC_ELEMENT_WRITER_KIND = 'markup' as const
21
+
22
+ /** Slot ids whose initial-render splice must be `escapeTextOrMarkup`. */
23
+ export function markupSlotIdsOf(ctx: Pick<ClientJsContext, 'dynamicElements'>): ReadonlySet<string> {
24
+ return new Set(ctx.dynamicElements.map(e => e.slotId))
25
+ }