@barefootjs/jsx 0.34.0 → 0.35.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 (101) hide show
  1. package/dist/analyzer.d.ts.map +1 -1
  2. package/dist/index.d.ts +2 -1
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +344 -215
  5. package/dist/ir-to-client-js/collect-elements.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-component-loop.d.ts.map +1 -1
  8. package/dist/ir-to-client-js/control-flow/plan/build-composite-loop.d.ts.map +1 -1
  9. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts +11 -0
  10. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/control-flow/plan/build-plain-row.d.ts +52 -0
  13. package/dist/ir-to-client-js/control-flow/plan/build-plain-row.d.ts.map +1 -0
  14. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts +2 -0
  15. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts +6 -7
  17. package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts +5 -7
  19. package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +10 -6
  22. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/control-flow/shared.d.ts +11 -9
  24. package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/control-flow/stringify/composite-loop.d.ts.map +1 -1
  26. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/csr-substitute.d.ts +16 -0
  28. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  29. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  30. package/dist/ir-to-client-js/emit-registration.d.ts.map +1 -1
  31. package/dist/ir-to-client-js/html-template.d.ts +2 -2
  32. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  33. package/dist/ir-to-client-js/imports.d.ts +2 -2
  34. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  35. package/dist/ir-to-client-js/index.d.ts.map +1 -1
  36. package/dist/ir-to-client-js/markup-slots.d.ts +21 -0
  37. package/dist/ir-to-client-js/markup-slots.d.ts.map +1 -0
  38. package/dist/ir-to-client-js/safe-html.d.ts +158 -0
  39. package/dist/ir-to-client-js/safe-html.d.ts.map +1 -0
  40. package/dist/ir-to-client-js/utils.d.ts +34 -1
  41. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  42. package/dist/jsx-to-ir.d.ts.map +1 -1
  43. package/dist/prop-rewrite.d.ts +8 -3
  44. package/dist/prop-rewrite.d.ts.map +1 -1
  45. package/dist/props-binding.d.ts +22 -1
  46. package/dist/props-binding.d.ts.map +1 -1
  47. package/dist/ssr-defaults.d.ts.map +1 -1
  48. package/dist/types.d.ts +5 -10
  49. package/dist/types.d.ts.map +1 -1
  50. package/package.json +2 -2
  51. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +10 -12
  52. package/src/__tests__/build-plain-row-consolidation.test.ts +120 -0
  53. package/src/__tests__/client-template-escape-soundness.test.ts +538 -0
  54. package/src/__tests__/csr-template-loop-shadowing.test.ts +4 -2
  55. package/src/__tests__/flatmap-segments.test.ts +1 -1
  56. package/src/__tests__/lazy-conditional.test.ts +52 -12
  57. package/src/__tests__/lazy-preamble.test.ts +11 -11
  58. package/src/__tests__/lazy-row-eligibility.test.ts +8 -3
  59. package/src/__tests__/loop-child-reactive-attr-const-shadow.test.ts +25 -3
  60. package/src/__tests__/loop-item-conditional-codegen.test.ts +4 -2
  61. package/src/__tests__/map-arbitrary-body.test.ts +1 -1
  62. package/src/__tests__/nested-loop-index-param.test.ts +14 -3
  63. package/src/__tests__/nested-loop-reactive-attrs.test.ts +4 -2
  64. package/src/__tests__/reactive-attrs-in-map.test.ts +13 -10
  65. package/src/__tests__/rewrite-destructured-props.test.ts +37 -0
  66. package/src/__tests__/safe-html-splice-door.test.ts +61 -0
  67. package/src/__tests__/ssr-defaults.test.ts +59 -0
  68. package/src/analyzer.ts +4 -0
  69. package/src/index.ts +8 -2
  70. package/src/ir-to-client-js/collect-elements.ts +18 -2
  71. package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +20 -28
  72. package/src/ir-to-client-js/control-flow/plan/build-component-loop.ts +9 -7
  73. package/src/ir-to-client-js/control-flow/plan/build-composite-loop.ts +10 -6
  74. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +8 -8
  75. package/src/ir-to-client-js/control-flow/plan/build-lazy-row.ts +18 -2
  76. package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +3 -3
  77. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +35 -35
  78. package/src/ir-to-client-js/control-flow/plan/build-plain-row.ts +110 -0
  79. package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +5 -2
  80. package/src/ir-to-client-js/control-flow/plan/lazy-conditional.ts +11 -10
  81. package/src/ir-to-client-js/control-flow/plan/lazy-preamble.ts +11 -13
  82. package/src/ir-to-client-js/control-flow/plan/lazy-row-eligibility.ts +13 -3
  83. package/src/ir-to-client-js/control-flow/plan/loop.ts +10 -6
  84. package/src/ir-to-client-js/control-flow/shared.ts +40 -17
  85. package/src/ir-to-client-js/control-flow/stringify/composite-loop.ts +2 -1
  86. package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +10 -0
  87. package/src/ir-to-client-js/csr-substitute.ts +29 -0
  88. package/src/ir-to-client-js/emit-reactive.ts +4 -1
  89. package/src/ir-to-client-js/emit-registration.ts +2 -4
  90. package/src/ir-to-client-js/html-template.ts +58 -244
  91. package/src/ir-to-client-js/imports.ts +3 -0
  92. package/src/ir-to-client-js/index.ts +4 -4
  93. package/src/ir-to-client-js/markup-slots.ts +25 -0
  94. package/src/ir-to-client-js/safe-html.ts +243 -0
  95. package/src/ir-to-client-js/utils.ts +58 -10
  96. package/src/jsx-to-ir.ts +115 -20
  97. package/src/prop-rewrite.ts +8 -4
  98. package/src/props-binding.ts +37 -1
  99. package/src/ssr-defaults.ts +45 -0
  100. package/src/types.ts +5 -10
  101. 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}\``
@@ -1085,7 +928,7 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: ReadonlySet<str
1085
928
  // the slot — `renderChildScopeArgs` still passes its slotId with the
1086
929
  // `loopItemRoot` flag (#2833) so it keeps `bf-h`/`bf-m` slot identity,
1087
930
  // matching the Hono reference (#2444).
1088
- return `\${renderChild('${nameForRegistryRef(node.name)}', ${propsExpr}${renderChildScopeArgs(node, keyArg)})}`
931
+ return interp(renderChildCall(nameForRegistryRef(node.name), propsExpr, renderChildScopeArgs(node, keyArg)))
1089
932
  }
1090
933
 
1091
934
  case 'loop': {
@@ -1130,14 +973,14 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: ReadonlySet<str
1130
973
  const body = renderPreamble(node.flatMapCallback, {
1131
974
  renderLeaf: (ir) => irToHtmlTemplate(stripLeafKeyAttr(ir), restSpreadNames, loopDepth + 1, loopParams, branchSlotsVar),
1132
975
  })
1133
- mapExpr = `\${${wrappedArray}.flatMap(${node.flatMapCallback.params} => ${body}).join('')}`
976
+ mapExpr = interp(mappedRowsMarkup(wrappedArray, 'flatMap', node.flatMapCallback.params, body))
1134
977
  } else if (node.preamble) {
1135
978
  // Stage 3 / D4 — render JSX leaves in an arbitrary array-builder
1136
979
  // preamble (the hydrate-template context uses the bare loop param).
1137
980
  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('')}`
981
+ mapExpr = interp(mappedRowsMarkup(wrappedArray, iterMethod, callbackParam, `{ ${preamble} return \`${childTemplate}\` }`))
1139
982
  } else {
1140
- mapExpr = `\${${wrappedArray}.${iterMethod}(${callbackParam} => \`${childTemplate}\`).join('')}`
983
+ mapExpr = interp(mappedRowsMarkup(wrappedArray, iterMethod, callbackParam, `\`${childTemplate}\``))
1141
984
  }
1142
985
  // Wrap with loop boundary markers so reconciliation doesn't affect siblings
1143
986
  return `<!--${loopStartMarker(node.markerId)}-->${mapExpr}<!--${loopEndMarker(node.markerId)}-->`
@@ -1552,19 +1395,10 @@ export function irToPlaceholderTemplate(node: IRNode, restSpreadNames?: Readonly
1552
1395
 
1553
1396
  case 'expression': {
1554
1397
  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}}`
1398
+ // Same "`expr`, never `templateExpr`" reasoning as `irToHtmlTemplate`
1399
+ // (#2765); escaping is the door's job.
1400
+ const hole = interp(spliceChildValue(node, wrapExpr(node.expr), {}))
1401
+ return node.slotId ? `<!--bf:${node.slotId}-->${hole}<!--/-->` : hole
1568
1402
  }
1569
1403
 
1570
1404
  case 'conditional': {
@@ -1572,7 +1406,7 @@ export function irToPlaceholderTemplate(node: IRNode, restSpreadNames?: Readonly
1572
1406
  const falseBranch = recurse(node.whenFalse)
1573
1407
  const trueHtml = node.slotId ? addCondAttrToTemplate(trueBranch, node.slotId) : trueBranch
1574
1408
  const falseHtml = node.slotId ? addCondAttrToTemplate(falseBranch, node.slotId) : falseBranch
1575
- return `\${${wrapExpr(node.condition)} ? \`${trueHtml}\` : \`${falseHtml}\`}`
1409
+ return interp(conditionalMarkup(wrapExpr(node.condition), trueHtml, falseHtml))
1576
1410
  }
1577
1411
 
1578
1412
  case 'fragment':
@@ -1606,13 +1440,13 @@ export function irToPlaceholderTemplate(node: IRNode, restSpreadNames?: Readonly
1606
1440
  // Leaf `key` stripped — see the irToHtmlTemplate site above.
1607
1441
  renderLeaf: (ir) => irToPlaceholderTemplate(stripLeafKeyAttr(ir), restSpreadNames, loopDepth + 1, loopParams),
1608
1442
  })
1609
- mapExpr = `\${${wrappedArray}.flatMap(${node.flatMapCallback.params} => ${body}).join('')}`
1443
+ mapExpr = interp(mappedRowsMarkup(wrappedArray, 'flatMap', node.flatMapCallback.params, body))
1610
1444
  } else if (node.preamble) {
1611
1445
  // Stage 3 / D4 — render JSX leaves in an arbitrary array-builder preamble.
1612
1446
  const preamble = renderPreamble(node.preamble, { textVariant: 'client', renderLeaf: (ir) => irToPlaceholderTemplate(ir, restSpreadNames, loopDepth + 1, loopParams) })
1613
- mapExpr = `\${${wrappedArray}.${iterMethod}(${callbackParam} => { ${preamble} return \`${childTemplate}\` }).join('')}`
1447
+ mapExpr = interp(mappedRowsMarkup(wrappedArray, iterMethod, callbackParam, `{ ${preamble} return \`${childTemplate}\` }`))
1614
1448
  } else {
1615
- mapExpr = `\${${wrappedArray}.${iterMethod}(${callbackParam} => \`${childTemplate}\`).join('')}`
1449
+ mapExpr = interp(mappedRowsMarkup(wrappedArray, iterMethod, callbackParam, `\`${childTemplate}\``))
1616
1450
  }
1617
1451
  return `<!--${loopStartMarker(node.markerId)}-->${mapExpr}<!--${loopEndMarker(node.markerId)}-->`
1618
1452
  }
@@ -1881,8 +1715,8 @@ export interface TemplateOptions {
1881
1715
  * (`ctx.dynamicElements`, populated by `collectElements` — the exact set
1882
1716
  * `emit-reactive.ts`'s `emitDynamicTextUpdates` claims `kind: 'markup'`
1883
1717
  * 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
1718
+ * `slotId` is a member, `spliceChildValue`'s door (`safe-html.ts`) emits
1719
+ * `escapeTextOrMarkup` instead of `escapeText` so a `bfMarkup()`-branded prop value (a JSX
1886
1720
  * element passed at a non-`children` component prop position) reaches
1887
1721
  * the initial-render template raw, matching what the reactive writer
1888
1722
  * already does via `escapeTextOrNode`. Computed once per component from
@@ -2087,17 +1921,8 @@ function irToComponentTemplateWithOpts(node: IRNode, opts: TemplateOptions): str
2087
1921
  if (node.markerless) return ''
2088
1922
  return `<!--bf:${node.slotId}--><!--/-->`
2089
1923
  }
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)}}`
1924
+ const hole = interp(spliceChildValue(node, transformExpr(node.expr, node.templateExpr), { markupSlotIds: opts.markupSlotIds }))
1925
+ return node.slotId ? `<!--bf:${node.slotId}-->${hole}<!--/-->` : hole
2101
1926
  }
2102
1927
 
2103
1928
  case 'conditional': {
@@ -2113,7 +1938,7 @@ function irToComponentTemplateWithOpts(node: IRNode, opts: TemplateOptions): str
2113
1938
  const falseBranch = recurse(node.whenFalse)
2114
1939
  const trueHtml = node.slotId ? addCondAttrToTemplate(trueBranch, node.slotId) : trueBranch
2115
1940
  const falseHtml = node.slotId ? addCondAttrToTemplate(falseBranch, node.slotId) : falseBranch
2116
- return `\${${transformExpr(node.condition, node.templateCondition)} ? \`${trueHtml}\` : \`${falseHtml}\`}`
1941
+ return interp(conditionalMarkup(transformExpr(node.condition, node.templateCondition), trueHtml, falseHtml))
2117
1942
  }
2118
1943
 
2119
1944
  case 'fragment':
@@ -2167,7 +1992,7 @@ function irToComponentTemplateWithOpts(node: IRNode, opts: TemplateOptions): str
2167
1992
  const propsExpr = propsEntries.length > 0 ? `{${propsEntries.join(', ')}}` : '{}'
2168
1993
  const keyProp = node.props.find(p => p.name === 'key')
2169
1994
  const keyArg = keyProp ? `, ${transformKeyValue(keyProp.value, transformExpr)}` : ''
2170
- return `\${renderChild('${nameForRegistryRef(node.name)}', ${propsExpr}${keyArg})}`
1995
+ return interp(renderChildCall(nameForRegistryRef(node.name), propsExpr, keyArg))
2171
1996
  }
2172
1997
 
2173
1998
  case 'loop': {
@@ -2187,7 +2012,7 @@ function irToComponentTemplateWithOpts(node: IRNode, opts: TemplateOptions): str
2187
2012
  // with the conditional-template path.
2188
2013
  const consequent = recurse(node.consequent)
2189
2014
  const alternate = node.alternate ? recurse(node.alternate) : ''
2190
- return `\${${transformExpr(node.condition, node.templateCondition)} ? \`${consequent}\` : \`${alternate}\`}`
2015
+ return interp(conditionalMarkup(transformExpr(node.condition, node.templateCondition), consequent, alternate))
2191
2016
  }
2192
2017
 
2193
2018
  case 'provider':
@@ -2349,13 +2174,7 @@ export function generateCsrTemplate(
2349
2174
  }
2350
2175
  }
2351
2176
  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))
2177
+ const markupSlotIds = markupSlotIdsOf(ctx)
2359
2178
  return generateCsrTemplateWithOpts(node, { inlinableConstants, restSpreadNames, propsObjectName, csrEnv, unsafeLocalNames: effectiveUnsafeLocalNames, deferredChildSlots, loopDepth: -1, markupSlotIds, restPropsName: ctx.restPropsName })
2360
2179
  }
2361
2180
 
@@ -2736,18 +2555,13 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
2736
2555
  const transformed = transformExpr(node.expr, node.templateExpr)
2737
2556
  // Init-body-only refs would render the literal text "undefined"
2738
2557
  // 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)}}`
2558
+ // an empty placeholder instead — a literal, so it bypasses the door.
2559
+ const hole = interp(
2560
+ transformed === UNSAFE_TEMPLATE_EXPR
2561
+ ? EMPTY_MARKUP
2562
+ : spliceChildValue(node, transformed, { markupSlotIds: opts.markupSlotIds }),
2563
+ )
2564
+ return node.slotId ? `<!--bf:${node.slotId}-->${hole}<!--/-->` : hole
2751
2565
  }
2752
2566
 
2753
2567
  case 'conditional': {
@@ -2764,7 +2578,7 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
2764
2578
  const falseBranch = recurse(node.whenFalse)
2765
2579
  const trueHtml = node.slotId ? addCondAttrToTemplate(trueBranch, node.slotId) : trueBranch
2766
2580
  const falseHtml = node.slotId ? addCondAttrToTemplate(falseBranch, node.slotId) : falseBranch
2767
- return `\${${transformExpr(node.condition, node.templateCondition)} ? \`${trueHtml}\` : \`${falseHtml}\`}`
2581
+ return interp(conditionalMarkup(transformExpr(node.condition, node.templateCondition), trueHtml, falseHtml))
2768
2582
  }
2769
2583
 
2770
2584
  case 'fragment':
@@ -2844,7 +2658,7 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
2844
2658
  // See `renderChildScopeArgs`'s docstring — a loop item root still gets
2845
2659
  // its slotId, plus the `loopItemRoot` flag (#2833), rather than no
2846
2660
  // slot argument at all.
2847
- return `\${renderChild('${nameForRegistryRef(node.name)}', ${propsExpr}${renderChildScopeArgs(node, keyArg)})}`
2661
+ return interp(renderChildCall(nameForRegistryRef(node.name), propsExpr, renderChildScopeArgs(node, keyArg)))
2848
2662
  }
2849
2663
 
2850
2664
  case 'loop': {
@@ -2916,14 +2730,14 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
2916
2730
  // Leaf `key` stripped — see the irToHtmlTemplate site above.
2917
2731
  renderLeaf: (ir) => recurseInLoopBody(stripLeafKeyAttr(ir)),
2918
2732
  })
2919
- mapExpr = `\${${iterArrayExpr}.flatMap(${node.flatMapCallback.params} => ${body}).join('')}`
2733
+ mapExpr = interp(mappedRowsMarkup(iterArrayExpr, 'flatMap', node.flatMapCallback.params, body))
2920
2734
  } else if (node.preamble) {
2921
2735
  // Stage 3 / D4 — template-variant js text with the props rewrite
2922
2736
  // applied per segment; JSX leaves render via the loop-body recursion.
2923
2737
  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('')}`
2738
+ mapExpr = interp(mappedRowsMarkup(iterArrayExpr, iterMethod, callbackParam, `{ ${preamble} return \`${childTemplate}\` }`))
2925
2739
  } else {
2926
- mapExpr = `\${${iterArrayExpr}.${iterMethod}(${callbackParam} => \`${childTemplate}\`).join('')}`
2740
+ mapExpr = interp(mappedRowsMarkup(iterArrayExpr, iterMethod, callbackParam, `\`${childTemplate}\``))
2927
2741
  }
2928
2742
  return `<!--${loopStartMarker(node.markerId)}-->${mapExpr}<!--${loopEndMarker(node.markerId)}-->`
2929
2743
  }
@@ -2931,7 +2745,7 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
2931
2745
  case 'if-statement': {
2932
2746
  const consequent = recurse(node.consequent)
2933
2747
  const alternate = node.alternate ? recurse(node.alternate) : ''
2934
- return `\${${transformExpr(node.condition, node.templateCondition)} ? \`${consequent}\` : \`${alternate}\`}`
2748
+ return interp(conditionalMarkup(transformExpr(node.condition, node.templateCondition), consequent, alternate))
2935
2749
  }
2936
2750
 
2937
2751
  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
+ }