@barefootjs/jsx 0.31.8 → 0.31.10

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 (43) hide show
  1. package/dist/analyzer.d.ts.map +1 -1
  2. package/dist/expression-parser.d.ts +20 -0
  3. package/dist/expression-parser.d.ts.map +1 -1
  4. package/dist/index.js +226 -66
  5. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  6. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  7. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  8. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts +66 -38
  9. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts.map +1 -1
  10. package/dist/ir-to-client-js/emit-registration.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/html-template.d.ts +15 -1
  12. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/imports.d.ts +2 -2
  14. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  15. package/dist/ssr-defaults.d.ts +10 -0
  16. package/dist/ssr-defaults.d.ts.map +1 -1
  17. package/dist/ssr-seed-plan.d.ts.map +1 -1
  18. package/package.json +2 -2
  19. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +210 -210
  20. package/src/__tests__/aliased-destructured-prop-csr.test.ts +8 -2
  21. package/src/__tests__/csr-template-loop-shadowing.test.ts +5 -2
  22. package/src/__tests__/date-lowering.test.ts +1 -1
  23. package/src/__tests__/env-signal-template-prelude.test.ts +4 -2
  24. package/src/__tests__/inner-loop-mathml-namespace.test.ts +135 -0
  25. package/src/__tests__/markup-prop-brand.test.ts +161 -0
  26. package/src/__tests__/mathml-mapArray-namespace.test.ts +230 -0
  27. package/src/__tests__/props-destructuring.test.ts +40 -3
  28. package/src/__tests__/ssr-defaults.test.ts +230 -0
  29. package/src/__tests__/ssr-seed-plan.test.ts +72 -0
  30. package/src/__tests__/text-slot-escaping.test.ts +21 -12
  31. package/src/analyzer.ts +83 -26
  32. package/src/expression-parser.ts +10 -1
  33. package/src/ir-to-client-js/collect-elements.ts +38 -5
  34. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +10 -10
  35. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +3 -4
  36. package/src/ir-to-client-js/control-flow/stringify/loop.ts +18 -14
  37. package/src/ir-to-client-js/control-flow/stringify/template-parse.ts +142 -78
  38. package/src/ir-to-client-js/emit-registration.ts +5 -1
  39. package/src/ir-to-client-js/html-template.ts +103 -12
  40. package/src/ir-to-client-js/imports.ts +4 -0
  41. package/src/ir-to-client-js/index.ts +6 -1
  42. package/src/ssr-defaults.ts +171 -6
  43. package/src/ssr-seed-plan.ts +82 -2
@@ -14,7 +14,7 @@
14
14
  import { varSlotId, DATA_BF_PH, keyAttrName, profileBindingId } from '../../utils.ts'
15
15
  import { emitComponentAndEventSetup } from '../shared.ts'
16
16
  import { emitAttrUpdate } from '../../emit-reactive.ts'
17
- import { templateRootIsSvg } from './template-parse.ts'
17
+ import { namespaceWrapForTemplate } from './template-parse.ts'
18
18
  import { emitListenerLine } from './event-listener.ts'
19
19
  import { nameForRegistryRef } from '../../component-scope.ts'
20
20
  import { claimPlanLiteral, claimWriterVarName, type ClaimSlotSpec } from './claim-plan.ts'
@@ -139,9 +139,8 @@ export function stringifyBranchInnerLoops(
139
139
  lines.push(`${indent} ${inner.paramUnwrap}`)
140
140
  }
141
141
  {
142
- const isSvg = templateRootIsSvg(inner.wrappedTemplate)
143
- const innerHtml = isSvg ? `<svg>${inner.wrappedTemplate}</svg>` : inner.wrappedTemplate
144
- const childPath = isSvg ? '.firstElementChild.firstElementChild' : '.firstElementChild'
142
+ const { wrapTag, childPath } = namespaceWrapForTemplate(inner.wrappedTemplate)
143
+ const innerHtml = wrapTag ? `<${wrapTag}>${inner.wrappedTemplate}</${wrapTag}>` : inner.wrappedTemplate
145
144
  lines.push(`${indent} let __bel${uid} = __existing ?? (() => { const __t = document.createElement('template'); __t.innerHTML = \`${innerHtml}\`; return __t.content${childPath}.cloneNode(true) })()`)
146
145
  }
147
146
  if (inner.wrappedKey) {
@@ -32,7 +32,7 @@
32
32
  import { emitRefCall, varSlotId, profileBindingId } from '../../utils.ts'
33
33
  import { emitAttrUpdate } from '../../emit-reactive.ts'
34
34
  import { stringifyReactiveEffects } from './reactive-effects.ts'
35
- import { emitTemplateCloneInline, emitLoopItemElementSetup, emitHoistedTemplateDecl, hoistedCloneExpr, templateRootIsSvg, multiRootTemplateNeedsSvgWrap } from './template-parse.ts'
35
+ import { emitTemplateCloneInline, emitLoopItemElementSetup, emitHoistedTemplateDecl, hoistedCloneExpr, namespaceWrapForTemplate, multiRootNamespaceWrapForTemplate, wrapHtmlForNamespace } from './template-parse.ts'
36
36
  import { buildSkeletonPathPlan, type SkeletonPathPlan } from './skeleton-paths.ts'
37
37
  import { stringifyComponentLoop } from './component-loop.ts'
38
38
  import { stringifyCompositeLoop } from './composite-loop.ts'
@@ -401,17 +401,21 @@ export function stringifyStaticLoop(lines: string[], plan: StaticLoopPlan): void
401
401
  lines.push(` let __iterEl = ${containerVar}.children[${childIndexExpr}]`)
402
402
  if (csrMaterialize) {
403
403
  lines.push(` if (!__iterEl) {`)
404
- // SVG-rooted item templates parse inside a synthetic `<svg>` wrap and
405
- // descend one extra level (#2219) — `template.innerHTML` alone parses in
406
- // the HTML namespace and the materialized elements would never draw.
407
- // Mirrors `templateRootIsSvg` handling on the reactive clone paths;
408
- // HTML-rooted templates keep byte-identical output. Multi-root fragments
409
- // use the fragment-aware predicate so an `<svg>`-container-first fragment
410
- // isn't over-wrapped (#2233 Copilot review).
411
- const isSvg = csrMaterialize.bodyIsMultiRoot
412
- ? multiRootTemplateNeedsSvgWrap(csrMaterialize.itemTemplate)
413
- : templateRootIsSvg(csrMaterialize.itemTemplate)
414
- const itemHtml = isSvg ? `<svg>${csrMaterialize.itemTemplate}</svg>` : csrMaterialize.itemTemplate
404
+ // SVG/MathML-rooted item templates parse inside a synthetic namespace
405
+ // wrap and descend one extra level (#2219, #1096) — `template.innerHTML`
406
+ // alone parses in the HTML namespace and the materialized elements
407
+ // would never draw. Reads the wrap decision AND descent path through
408
+ // the same single door as the reactive clone emitters
409
+ // (`namespaceWrapForTemplate`, #2662 Copilot review) so a future
410
+ // namespace or fragment-rule tweak can't update one path and miss the
411
+ // other; HTML-rooted templates keep byte-identical
412
+ // output. Multi-root fragments use the fragment-aware predicate so a
413
+ // `<svg>`/`<math>`-container-first fragment isn't over-wrapped (#2233
414
+ // Copilot review).
415
+ const { wrapTag, childPath } = csrMaterialize.bodyIsMultiRoot
416
+ ? multiRootNamespaceWrapForTemplate(csrMaterialize.itemTemplate)
417
+ : namespaceWrapForTemplate(csrMaterialize.itemTemplate)
418
+ const itemHtml = wrapHtmlForNamespace(csrMaterialize.itemTemplate, wrapTag)
415
419
  if (csrMaterialize.bodyIsMultiRoot) {
416
420
  // Multi-root: clone every top-level sibling of the per-item template and
417
421
  // insert them in order. `__iterEl` is the first root (the one reactive
@@ -420,7 +424,7 @@ export function stringifyStaticLoop(lines: string[], plan: StaticLoopPlan): void
420
424
  lines.push(` __mtpl.innerHTML = \`${itemHtml}\``)
421
425
  lines.push(` const __anchor = ${containerVar}.children[${childIndexExpr}] ?? null`)
422
426
  lines.push(` let __first = null`)
423
- lines.push(` let __sib = __mtpl.content${isSvg ? '.firstElementChild' : ''}.firstElementChild`)
427
+ lines.push(` let __sib = __mtpl.content${childPath}.firstElementChild`)
424
428
  lines.push(` while (__sib) {`)
425
429
  lines.push(` const __next = __sib.nextElementSibling`)
426
430
  lines.push(` const __cloned = __sib.cloneNode(true)`)
@@ -432,7 +436,7 @@ export function stringifyStaticLoop(lines: string[], plan: StaticLoopPlan): void
432
436
  } else {
433
437
  lines.push(` const __tpl = document.createElement('template')`)
434
438
  lines.push(` __tpl.innerHTML = \`${itemHtml}\``)
435
- lines.push(` const __cloned = __tpl.content${isSvg ? '.firstElementChild' : ''}.firstElementChild`)
439
+ lines.push(` const __cloned = __tpl.content${childPath}`)
436
440
  lines.push(` if (__cloned) {`)
437
441
  lines.push(` const __anchor = ${containerVar}.children[${childIndexExpr}] ?? null`)
438
442
  lines.push(` ${containerVar}.insertBefore(__cloned, __anchor)`)
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Helpers for emitting code that parses a template literal into a DOM
3
- * element clone, while preserving the SVG namespace when the loop body
4
- * root is an SVG element.
3
+ * element clone, while preserving the SVG/MathML namespace when the loop
4
+ * body root is a foreign-content element.
5
5
  *
6
6
  * Background (#135): the standard pattern
7
7
  * `const __tpl = document.createElement('template')`
@@ -14,10 +14,14 @@
14
14
  * Editor block when a new edge `<path>` was appended via mapArray and
15
15
  * never showed up on the canvas.
16
16
  *
17
- * Fix: when the template's root tag is an SVG element, wrap the parsed
18
- * markup in a synthetic `<svg>` so the HTML5 parser walks into SVG
19
- * foreign content and assigns the correct namespace, then descend one
20
- * extra level to get the real root.
17
+ * Fix: when the template's root tag is an SVG (or, #1096, MathML) element,
18
+ * wrap the parsed markup in the matching synthetic namespace root
19
+ * (`<svg>` / `<math>`) so the HTML5 parser walks into foreign content and
20
+ * assigns the correct namespace, then descend one extra level to get the
21
+ * real root. The two namespaces share every byte of this machinery —
22
+ * only the wrap tag name differs — so `detectRootNamespaceWrapTag` and
23
+ * `namespaceWrapForTemplate` below are the single door every emitter
24
+ * (in this file and its consumers) reads the wrap decision through.
21
25
  */
22
26
 
23
27
  import { findInterpolationEnd, findTopLevelTemplateLiterals } from '../../../scanner/js-scanner.ts'
@@ -37,73 +41,140 @@ const SVG_ROOT_TAGS = new Set([
37
41
  ])
38
42
 
39
43
  /**
40
- * Decide whether a template literal needs SVG-context parsing.
41
- * Looks at the first opening tag in the literal. The check is purely
42
- * lexical so that interpolations inside attribute values do not
43
- * confuse it.
44
+ * MathML root tags (#1096 port of the SVG fix above). `math` itself is
45
+ * included alongside the element vocabulary, mirroring how `svg` is
46
+ * included in `SVG_ROOT_TAGS`: a template whose bare root is the
47
+ * namespace container tag also needs the parser nudge (unlike the
48
+ * MULTI-ROOT fragment case in `multiRootTemplateNeedsNamespaceWrap`,
49
+ * where a container-first fragment already parses correctly on its own).
50
+ */
51
+ const MATHML_ROOT_TAGS = new Set([
52
+ 'math',
53
+ 'mrow', 'mfrac', 'msup', 'msub', 'msubsup', 'mn', 'mi', 'mo', 'mtext',
54
+ 'munder', 'mover', 'munderover', 'mtable', 'mtr', 'mtd',
55
+ 'msqrt', 'mroot', 'mstyle', 'merror', 'mpadded', 'mphantom', 'menclose',
56
+ 'semantics', 'annotation', 'annotation-xml',
57
+ ])
58
+
59
+ /** Namespaces whose root tags need the synthetic-wrap parser nudge. */
60
+ export type NamespaceWrapTag = 'svg' | 'math'
61
+
62
+ /**
63
+ * Look up which foreign-content namespace (if any) a single tag name
64
+ * belongs to. Case-insensitive fallback mirrors `templateRootIsSvg`'s
65
+ * original comment: SVG/MathML element names are case-sensitive in JSX
66
+ * (e.g. `linearGradient`, `annotation-xml`) but the canonical lower-case
67
+ * set is checked first, then the lower-cased tag, so both spellings match.
68
+ */
69
+ function namespaceForTag(tag: string): NamespaceWrapTag | null {
70
+ if (SVG_ROOT_TAGS.has(tag) || SVG_ROOT_TAGS.has(tag.toLowerCase())) return 'svg'
71
+ if (MATHML_ROOT_TAGS.has(tag) || MATHML_ROOT_TAGS.has(tag.toLowerCase())) return 'math'
72
+ return null
73
+ }
74
+
75
+ /**
76
+ * Decide whether a template literal needs foreign-content (SVG or MathML)
77
+ * parsing, and which. Looks at the first opening tag in the literal. The
78
+ * check is purely lexical so that interpolations inside attribute values
79
+ * do not confuse it.
44
80
  *
45
81
  * Three shapes are recognised:
46
- * 1. Direct element root — `<circle .../>`
82
+ * 1. Direct element root — `<circle .../>` / `<mrow>...</mrow>`
47
83
  * 2. Conditional body (#1088) — `${cond ? `<circle .../>` : `<rect .../>`}`
48
- * where every result-position template literal starts with an SVG
49
- * tag. The compiler emits this shape for `.map(s => cond ? <a/> : <b/>)`
50
- * bodies; without the wrap the cloned element ends up in the xhtml
51
- * namespace and renders nothing.
84
+ * where every result-position template literal (recursively) resolves
85
+ * to the SAME namespace. The compiler emits this shape for
86
+ * `.map(s => cond ? <a/> : <b/>)` bodies; without the wrap the cloned
87
+ * element ends up in the xhtml namespace and renders nothing.
52
88
  * 3. Reactive-conditional body — a branch wrapped in `<!--bf-cond-start:sX-->`
53
89
  * / `<!--bf-cond-end:sX-->` markers (emitted for nested reactive
54
90
  * conditionals). The check skips leading HTML comments and recurses
55
91
  * into the inner `${...}`.
56
92
  *
57
- * Mixed-namespace branches (one HTML, one SVG) intentionally fall through
58
- * to no-wrap so the user sees the same broken output as before instead of
59
- * a silent over-wrap into a `<foreignObject>`-style mismatch.
93
+ * Mixed-namespace branches (HTML+SVG, HTML+MathML, or SVG+MathML)
94
+ * intentionally fall through to no-wrap so the user sees the same broken
95
+ * output as before instead of a silent over-wrap that would drag one
96
+ * branch into the wrong foreign-content namespace.
60
97
  */
61
- export function templateRootIsSvg(template: string): boolean {
98
+ export function detectRootNamespaceWrapTag(template: string): NamespaceWrapTag | null {
62
99
  const stripped = stripLeadingNonContent(template)
63
100
 
64
101
  // Shape 1: direct element root.
65
102
  const m = stripped.match(/^<\s*([A-Za-z][A-Za-z0-9-]*)/)
66
- if (m) {
67
- // SVG element names are case-sensitive in JSX (e.g., `linearGradient`)
68
- // and arrive lowercased to the renderer for the kebab-cased forms;
69
- // match case-insensitively against the canonical lower-case set, but
70
- // keep the canonical name's casing in the lookup table so JSX names
71
- // like `clipPath` still match.
72
- const tag = m[1]
73
- if (SVG_ROOT_TAGS.has(tag)) return true
74
- return SVG_ROOT_TAGS.has(tag.toLowerCase())
75
- }
103
+ if (m) return namespaceForTag(m[1])
76
104
 
77
105
  // Shapes 2 & 3: single `${...}` interpolation whose result-position
78
- // template literals all (recursively) resolve to SVG roots (Option A in
79
- // #1088).
106
+ // template literals all (recursively) resolve to the SAME namespace
107
+ // (Option A in #1088, generalised to MathML in #1096).
80
108
  const branches = extractConditionalBranchTemplates(stripped)
81
- if (branches === null || branches.length === 0) return false
82
- return branches.every(templateRootIsSvg)
109
+ if (branches === null || branches.length === 0) return null
110
+ let result: NamespaceWrapTag | null | undefined
111
+ for (const branch of branches) {
112
+ const branchNs = detectRootNamespaceWrapTag(branch)
113
+ if (result === undefined) {
114
+ result = branchNs
115
+ } else if (result !== branchNs) {
116
+ return null
117
+ }
118
+ }
119
+ return result ?? null
83
120
  }
84
121
 
85
122
  /**
86
123
  * Wrap decision for MULTI-ROOT (fragment) templates, where the synthetic
87
- * `<svg>` wrap swallows every sibling root at once (#2233 Copilot review).
124
+ * namespace wrap swallows every sibling root at once (#2233 Copilot
125
+ * review, generalised to MathML in #1096).
88
126
  *
89
- * `templateRootIsSvg` inspects only the FIRST root tag. For single-root
90
- * templates that's exact, but a fragment whose first root is an `<svg>`
91
- * CONTAINER (`<><svg/><span/></>`) doesn't need the wrap at all — the
92
- * HTML parser enters foreign content at `<svg>` on its own and wrapping
93
- * would drag the HTML siblings into the SVG namespace (`<span>` becomes an
94
- * SVGUnknownElement, silently undrawn). So `<svg>`-first fragments skip
95
- * the wrap; only leaf-rooted fragments (`<line>`, `<circle>`, ...) get it.
127
+ * `detectRootNamespaceWrapTag` inspects only the FIRST root tag. For
128
+ * single-root templates that's exact, but a fragment whose first root is
129
+ * the namespace CONTAINER itself (`<><svg/><span/></>`, `<><math/><span/></>`)
130
+ * doesn't need the wrap at all the HTML parser enters foreign content at
131
+ * `<svg>`/`<math>` on its own and wrapping would drag the HTML siblings
132
+ * into the foreign namespace (`<span>` becomes an SVGUnknownElement/etc.,
133
+ * silently undrawn). So container-first fragments skip the wrap; only
134
+ * leaf-rooted fragments (`<line>`, `<circle>`, `<mrow>`, ...) get it.
96
135
  *
97
- * Known edge (degenerate, pre-existing): an `<svg>`-first fragment with
98
- * SVG-LEAF siblings (`<><svg/><line/></>`) leaves the bare leaf siblings
99
- * in the HTML namespace — exactly the pre-#2219 behavior. Deciding that
100
- * shape correctly needs a scan of every top-level root tag; not worth the
101
- * parser until a real component hits it.
136
+ * Known edge (degenerate, pre-existing): a container-first fragment with
137
+ * LEAF siblings of the same namespace (`<><svg/><line/></>`) leaves the
138
+ * bare leaf siblings in the HTML namespace — exactly the pre-#2219
139
+ * behavior. Deciding that shape correctly needs a scan of every top-level
140
+ * root tag; not worth the parser until a real component hits it.
102
141
  */
103
- export function multiRootTemplateNeedsSvgWrap(template: string): boolean {
142
+ export function multiRootTemplateNeedsNamespaceWrap(template: string): NamespaceWrapTag | null {
104
143
  const m = stripLeadingNonContent(template).match(/^<\s*([A-Za-z][A-Za-z0-9-]*)/)
105
- if (m && m[1].toLowerCase() === 'svg') return false
106
- return templateRootIsSvg(template)
144
+ if (m) {
145
+ const tagLower = m[1].toLowerCase()
146
+ if (tagLower === 'svg' || tagLower === 'math') return null
147
+ }
148
+ return detectRootNamespaceWrapTag(template)
149
+ }
150
+
151
+ /**
152
+ * Single door for the "wrap tag + descent path" pair every namespace-aware
153
+ * clone site needs. Consumers outside this file (`inner-loop.ts`,
154
+ * `loop-child-arm.ts`, `loop.ts`) read the wrap decision through this
155
+ * function instead of re-deriving `isSvg ? '<svg>' : ...` locally — keeps
156
+ * the SVG/MathML wrap code path in exactly one place (#1096).
157
+ */
158
+ export function namespaceWrapForTemplate(template: string): { wrapTag: NamespaceWrapTag | null; childPath: string } {
159
+ const wrapTag = detectRootNamespaceWrapTag(template)
160
+ return {
161
+ wrapTag,
162
+ childPath: wrapTag ? '.firstElementChild.firstElementChild' : '.firstElementChild',
163
+ }
164
+ }
165
+
166
+ /** `namespaceWrapForTemplate`, but for the multi-root fragment predicate. */
167
+ export function multiRootNamespaceWrapForTemplate(template: string): { wrapTag: NamespaceWrapTag | null; childPath: string } {
168
+ const wrapTag = multiRootTemplateNeedsNamespaceWrap(template)
169
+ return {
170
+ wrapTag,
171
+ childPath: wrapTag ? '.firstElementChild' : '',
172
+ }
173
+ }
174
+
175
+ /** Wrap `html` in the namespace's synthetic root tag, or return it as-is. */
176
+ export function wrapHtmlForNamespace(html: string, wrapTag: NamespaceWrapTag | null): string {
177
+ return wrapTag ? `<${wrapTag}>${html}</${wrapTag}>` : html
107
178
  }
108
179
 
109
180
  /**
@@ -159,14 +230,13 @@ function extractConditionalBranchTemplates(template: string): string[] | null {
159
230
  *
160
231
  * ` const __tpl = document.createElement('template'); __tpl.innerHTML = \`${template}\`; return __tpl.content.firstElementChild.cloneNode(true) `
161
232
  *
162
- * For SVG roots, the `innerHTML` is wrapped in `<svg>...</svg>` and the
163
- * traversal descends one extra level.
233
+ * For SVG/MathML roots, the `innerHTML` is wrapped in the matching
234
+ * namespace root tag and the traversal descends one extra level.
164
235
  */
165
236
  export function emitTemplateCloneInline(template: string): string {
166
- if (templateRootIsSvg(template)) {
167
- return `const __tpl = document.createElement('template'); __tpl.innerHTML = \`<svg>${template}</svg>\`; return __tpl.content.firstElementChild.firstElementChild.cloneNode(true)`
168
- }
169
- return `const __tpl = document.createElement('template'); __tpl.innerHTML = \`${template}\`; return __tpl.content.firstElementChild.cloneNode(true)`
237
+ const { wrapTag, childPath } = namespaceWrapForTemplate(template)
238
+ const html = wrapHtmlForNamespace(template, wrapTag)
239
+ return `const __tpl = document.createElement('template'); __tpl.innerHTML = \`${html}\`; return __tpl.content${childPath}.cloneNode(true)`
170
240
  }
171
241
 
172
242
  /**
@@ -177,13 +247,14 @@ export function emitTemplateCloneInline(template: string): string {
177
247
  * STATIC-ONLY skeleton produced by `buildLoopSkeletonTemplate` (dynamic attrs
178
248
  * omitted, text markers empty) — never the per-row interpolated `template`.
179
249
  *
180
- * SVG namespace wrap mirrors `emitTemplateCloneLines` (#135 / #1088):
181
- * `templateRootIsSvg` is re-checked against the skeleton (same root tag as
182
- * the interpolated template, so the same wrap decision applies).
250
+ * Namespace wrap mirrors `emitTemplateCloneLines` (#135 / #1088 / #1096):
251
+ * `detectRootNamespaceWrapTag` is re-checked against the skeleton (same
252
+ * root tag as the interpolated template, so the same wrap decision
253
+ * applies).
183
254
  */
184
255
  export function emitHoistedTemplateDecl(lines: string[], indent: string, tplVar: string, skeletonTemplate: string): void {
185
- const isSvg = templateRootIsSvg(skeletonTemplate)
186
- const html = isSvg ? `<svg>${skeletonTemplate}</svg>` : skeletonTemplate
256
+ const { wrapTag } = namespaceWrapForTemplate(skeletonTemplate)
257
+ const html = wrapHtmlForNamespace(skeletonTemplate, wrapTag)
187
258
  lines.push(`${indent}const ${tplVar} = document.createElement('template')`)
188
259
  lines.push(`${indent}${tplVar}.innerHTML = \`${html}\``)
189
260
  }
@@ -194,9 +265,7 @@ export function emitHoistedTemplateDecl(lines: string[], indent: string, tplVar:
194
265
  * `emitTemplateCloneInline` / `emitTemplateCloneLines` parse-and-clone.
195
266
  */
196
267
  export function hoistedCloneExpr(tplVar: string, skeletonTemplate: string): string {
197
- return templateRootIsSvg(skeletonTemplate)
198
- ? `${tplVar}.content.firstElementChild.firstElementChild.cloneNode(true)`
199
- : `${tplVar}.content.firstElementChild.cloneNode(true)`
268
+ return `${tplVar}.content${namespaceWrapForTemplate(skeletonTemplate).childPath}.cloneNode(true)`
200
269
  }
201
270
 
202
271
  /**
@@ -204,17 +273,12 @@ export function hoistedCloneExpr(tplVar: string, skeletonTemplate: string): stri
204
273
  * Returns three statements with no trailing newlines.
205
274
  */
206
275
  export function emitTemplateCloneLines(template: string, indent: string): string[] {
207
- if (templateRootIsSvg(template)) {
208
- return [
209
- `${indent}const __tpl = document.createElement('template')`,
210
- `${indent}__tpl.innerHTML = \`<svg>${template}</svg>\``,
211
- `${indent}return __tpl.content.firstElementChild.firstElementChild.cloneNode(true)`,
212
- ]
213
- }
276
+ const { wrapTag, childPath } = namespaceWrapForTemplate(template)
277
+ const html = wrapHtmlForNamespace(template, wrapTag)
214
278
  return [
215
279
  `${indent}const __tpl = document.createElement('template')`,
216
- `${indent}__tpl.innerHTML = \`${template}\``,
217
- `${indent}return __tpl.content.firstElementChild.cloneNode(true)`,
280
+ `${indent}__tpl.innerHTML = \`${html}\``,
281
+ `${indent}return __tpl.content${childPath}.cloneNode(true)`,
218
282
  ]
219
283
  }
220
284
 
@@ -312,13 +376,13 @@ export function emitMultiRootTemplateCloneLines(
312
376
  varEl: string,
313
377
  varExtras: string,
314
378
  ): string[] {
315
- const isSvg = multiRootTemplateNeedsSvgWrap(template)
316
- // Wrap in `<svg>` so the parser walks into SVG foreign content; we then
317
- // descend one level to pick up the per-item roots.
318
- const innerHtmlExpr = isSvg ? `\`<svg>${template}</svg>\`` : `\`${template}\``
379
+ const { wrapTag, childPath } = multiRootNamespaceWrapForTemplate(template)
380
+ // Wrap in the namespace's root tag so the parser walks into foreign
381
+ // content; we then descend one level to pick up the per-item roots.
382
+ const innerHtmlExpr = `\`${wrapHtmlForNamespace(template, wrapTag)}\``
319
383
  // `parent` is the element whose direct children are the per-item roots
320
- // (the `<svg>` wrap for SVG, the template's content for HTML).
321
- const parentExpr = isSvg ? `__tpl.content.firstElementChild` : `__tpl.content`
384
+ // (the namespace wrap for SVG/MathML, the template's content for HTML).
385
+ const parentExpr = `__tpl.content${childPath}`
322
386
  return [
323
387
  `${indent}const __tpl = document.createElement('template')`,
324
388
  `${indent}__tpl.innerHTML = ${innerHtmlExpr}`,
@@ -203,7 +203,11 @@ export function emitRegistrationAndHydration(
203
203
  // Build ComponentDef object for hydrate()
204
204
  const defParts: string[] = [`init: init${name}`]
205
205
  if (canGenerateStaticTemplate(_ir.root, propNamesForStaticCheck, inlinableConstants, unsafeLocalNames)) {
206
- const templateHtml = irToComponentTemplate(_ir.root, inlinableConstants, restSpreadNames, ctx.propsObjectName)
206
+ // `ctx.dynamicElements` is the claim-plan-'markup' membership
207
+ // (#2651) — see `generateCsrTemplate`'s identical derivation below
208
+ // for why this is reused as-is rather than re-derived.
209
+ const markupSlotIds = new Set(ctx.dynamicElements.map(e => e.slotId))
210
+ const templateHtml = irToComponentTemplate(_ir.root, inlinableConstants, restSpreadNames, ctx.propsObjectName, markupSlotIds)
207
211
  if (templateHtml) {
208
212
  defParts.push(buildTemplateDefPart(ctx, templateHtml))
209
213
  }
@@ -321,9 +321,22 @@ function escapeAttrValueExpr(valExpr: string): string {
321
321
  * escaped, so this is applied only at the four text-marker emit sites.
322
322
  * Hono escapes text content with the same set as attribute values
323
323
  * (`& " ' < >`), so `escapeText` delegates to the same operation.
324
+ *
325
+ * `isMarkup` (#2651) switches to `escapeTextOrMarkup` — `escapeText`'s
326
+ * strict superset that additionally unwraps a `bfMarkup()`-branded value
327
+ * raw instead of escaping it. Two of the four text-marker call sites
328
+ * (`irToComponentTemplateWithOpts`, `generateCsrTemplateWithOpts`) pass
329
+ * `true` for a slot `ctx.dynamicElements` also claims — the same
330
+ * membership `emit-reactive.ts` uses to pick the 'markup' writer kind for
331
+ * this slot's REACTIVE update, so the initial-render escape and the
332
+ * reactive-update escape agree on whether the slot may carry raw markup.
333
+ * The other two (`irToHtmlTemplate`, `irToPlaceholderTemplate`) build
334
+ * loop-item / conditional-branch HTML, which the reactive side always
335
+ * treats as plain text (`__bfText`) regardless — they never pass `true`,
336
+ * so they keep calling `escapeText` byte-for-byte as before.
324
337
  */
325
- function escapeTextSlotExpr(innerExpr: string): string {
326
- return `escapeText(${innerExpr})`
338
+ function escapeTextSlotExpr(innerExpr: string, isMarkup = false): string {
339
+ return `${isMarkup ? 'escapeTextOrMarkup' : 'escapeText'}(${innerExpr})`
327
340
  }
328
341
 
329
342
  /**
@@ -901,7 +914,20 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, lo
901
914
  case 'jsx-children': {
902
915
  const hoistedRecurse = (n: IRNode): string => irToHtmlTemplate(n, restSpreadNames, loopDepth, loopParams, branchSlotsVar, true)
903
916
  const childHtml = p.value.children.map(c => hoistedRecurse(c)).join('')
904
- return `${quotePropName(p.name)}: \`${childHtml}\``
917
+ // Brand the assembled HTML (#2651) — every text segment inside
918
+ // `childHtml` was already escaped node-by-node during
919
+ // `hoistedRecurse`'s own build, so the concatenated result is
920
+ // safe to splice raw; the child's own template evaluates this
921
+ // prop through `escapeTextOrMarkup`/`escapeTextOrNode`, which
922
+ // trust the brand and skip re-escaping it. EXCEPT an explicit
923
+ // `children={<jsx/>}` prop (out of scope, unchanged): the
924
+ // child's `{children}` interpolation is the bare-passthrough
925
+ // door (`escapeTextSlotExpr`'s own docstring), never routed
926
+ // through an escape/unwrap call, so a branded object here
927
+ // would stringify to `[object Object]` instead of unwrapping.
928
+ return p.name === 'children'
929
+ ? `${quotePropName(p.name)}: \`${childHtml}\``
930
+ : `${quotePropName(p.name)}: bfMarkup(\`${childHtml}\`)`
905
931
  }
906
932
  case 'literal':
907
933
  return `${quotePropName(p.name)}: ${JSON.stringify(p.value.value)}`
@@ -1478,6 +1504,25 @@ export function irChildrenToJsExpr(children: IRNode[]): string {
1478
1504
  return `[${exprs.join(', ')}]`
1479
1505
  }
1480
1506
 
1507
+ /**
1508
+ * Narrow gate for branding a nested `jsx-children` getter's value with
1509
+ * `bfMarkup()` (#2651) — the `irNodeToJsExprs` counterpart of
1510
+ * `collect-elements.ts`'s `isSingleElementJsxChildren` (duplicated rather
1511
+ * than imported: `collect-elements.ts` already imports FROM this file, so
1512
+ * the reverse import would cycle). Same shape, same reasoning: a
1513
+ * `jsx-children` prop's value is always a single stored node
1514
+ * (`AttrValueOf.jsxChildren([...])`); only when that node is a lone
1515
+ * `'element'` does `irChildrenToJsExpr` reduce it to ONE HTML-string
1516
+ * template literal — the shape `bfMarkup` + `escapeTextOrNode` /
1517
+ * `escapeTextOrMarkup` have a proven contract for. A `'fragment'` (multiple
1518
+ * children) or `'conditional'` node reduces to an array literal or a
1519
+ * nested ternary instead — left unbranded here for the same reason as the
1520
+ * `collect-elements.ts` twin.
1521
+ */
1522
+ function isSingleElementJsxChildren(nodes: IRNode[]): boolean {
1523
+ return nodes.length === 1 && nodes[0].type === 'element'
1524
+ }
1525
+
1481
1526
  function irNodeToJsExprs(node: IRNode): string[] {
1482
1527
  switch (node.type) {
1483
1528
  case 'component': {
@@ -1488,8 +1533,19 @@ function irNodeToJsExprs(node: IRNode): string[] {
1488
1533
  return `${quotePropName(p.name)}: ${attrValueToString(p.value) ?? 'undefined'}`
1489
1534
  }
1490
1535
  switch (p.value.kind) {
1491
- case 'jsx-children':
1492
- return `get ${quotePropName(p.name)}() { return ${irChildrenToJsExpr(p.value.children)} }`
1536
+ case 'jsx-children': {
1537
+ // Brand (#2651) only the lone-'element' shape, and never an
1538
+ // explicit `children={<jsx/>}` prop — see
1539
+ // `isSingleElementJsxChildren` (this file) for the shape
1540
+ // argument and the `collect-elements.ts` twin for the
1541
+ // `children`-exclusion argument (bare-passthrough consumer,
1542
+ // no unwrap call).
1543
+ const jsxExpr = irChildrenToJsExpr(p.value.children)
1544
+ const wrapped = (p.name !== 'children' && isSingleElementJsxChildren(p.value.children))
1545
+ ? `bfMarkup(${jsxExpr})`
1546
+ : jsxExpr
1547
+ return `get ${quotePropName(p.name)}() { return ${wrapped} }`
1548
+ }
1493
1549
  case 'literal':
1494
1550
  return `get ${quotePropName(p.name)}() { return ${JSON.stringify(p.value.value)} }`
1495
1551
  case 'boolean-shorthand':
@@ -1644,6 +1700,20 @@ export interface TemplateOptions {
1644
1700
  * the template phase agree on which children defer (dropped-prop fix).
1645
1701
  */
1646
1702
  deferredChildSlots?: ReadonlySet<string>
1703
+ /**
1704
+ * Slot ids of top-level, non-conditional dynamic expressions
1705
+ * (`ctx.dynamicElements`, populated by `collectElements` — the exact set
1706
+ * `emit-reactive.ts`'s `emitDynamicTextUpdates` claims `kind: 'markup'`
1707
+ * for on the REACTIVE side, #2651). When a text-marker expression's
1708
+ * `slotId` is a member, `escapeTextSlotExpr` emits `escapeTextOrMarkup`
1709
+ * instead of `escapeText` so a `bfMarkup()`-branded prop value (a JSX
1710
+ * element passed at a non-`children` component prop position) reaches
1711
+ * the initial-render template raw, matching what the reactive writer
1712
+ * already does via `escapeTextOrNode`. Computed once per component from
1713
+ * `ctx.dynamicElements` by `irToComponentTemplate` / `generateCsrTemplate`
1714
+ * — never re-derived here.
1715
+ */
1716
+ markupSlotIds?: ReadonlySet<string>
1647
1717
  }
1648
1718
 
1649
1719
  /**
@@ -1659,9 +1729,10 @@ export function irToComponentTemplate(
1659
1729
  node: IRNode,
1660
1730
  inlinableConstants?: Map<string, string>,
1661
1731
  restSpreadNames?: Set<string>,
1662
- propsObjectName?: string | null
1732
+ propsObjectName?: string | null,
1733
+ markupSlotIds?: ReadonlySet<string>
1663
1734
  ): string {
1664
- return irToComponentTemplateWithOpts(node, { inlinableConstants, restSpreadNames, propsObjectName, loopDepth: -1 })
1735
+ return irToComponentTemplateWithOpts(node, { inlinableConstants, restSpreadNames, propsObjectName, loopDepth: -1, markupSlotIds })
1665
1736
  }
1666
1737
 
1667
1738
  function irToComponentTemplateWithOpts(node: IRNode, opts: TemplateOptions): string {
@@ -1829,7 +1900,8 @@ function irToComponentTemplateWithOpts(node: IRNode, opts: TemplateOptions): str
1829
1900
  ? `Array.isArray(${wrapped}) ? ${wrapped}.join('') : (${wrapped} ?? '')`
1830
1901
  : wrapped
1831
1902
  if (node.slotId) {
1832
- return `<!--bf:${node.slotId}-->\${${node.joinArrayChild ? value : escapeTextSlotExpr(wrapped)}}<!--/-->`
1903
+ const isMarkup = opts.markupSlotIds?.has(node.slotId) ?? false
1904
+ return `<!--bf:${node.slotId}-->\${${node.joinArrayChild ? value : escapeTextSlotExpr(wrapped, isMarkup)}}<!--/-->`
1833
1905
  }
1834
1906
  return `\${${value}}`
1835
1907
  }
@@ -1873,7 +1945,12 @@ function irToComponentTemplateWithOpts(node: IRNode, opts: TemplateOptions): str
1873
1945
  case 'jsx-children': {
1874
1946
  const hoistedRecurse = (n: IRNode): string => irToComponentTemplateWithOpts(n, { ...opts, inHoistedChildren: true })
1875
1947
  const childHtml = p.value.children.map(c => hoistedRecurse(c)).join('')
1876
- return `${quotePropName(p.name)}: \`${childHtml}\``
1948
+ // Brand the assembled HTML (#2651), except an explicit
1949
+ // `children={<jsx/>}` prop — see the identical
1950
+ // `irToHtmlTemplate` case above for both arguments.
1951
+ return p.name === 'children'
1952
+ ? `${quotePropName(p.name)}: \`${childHtml}\``
1953
+ : `${quotePropName(p.name)}: bfMarkup(\`${childHtml}\`)`
1877
1954
  }
1878
1955
  case 'literal':
1879
1956
  return `${quotePropName(p.name)}: ${JSON.stringify(p.value.value)}`
@@ -2075,7 +2152,14 @@ export function generateCsrTemplate(
2075
2152
  }
2076
2153
  }
2077
2154
  const effectiveUnsafeLocalNames = mergeCsrNullUnsafe(ctx, unsafeLocalNames)
2078
- return generateCsrTemplateWithOpts(node, { inlinableConstants, restSpreadNames, propsObjectName, csrEnv, unsafeLocalNames: effectiveUnsafeLocalNames, deferredChildSlots, loopDepth: -1 })
2155
+ // `ctx.dynamicElements` (populated by `collectElements`, before any
2156
+ // template-string build runs) IS the claim-plan-'markup' membership
2157
+ // `emit-reactive.ts` claims for these same slot ids on the reactive
2158
+ // side (#2651) — every entry is already non-conditional / non-`@client`
2159
+ // by construction (`collectElements`'s `expression` visitor only pushes
2160
+ // here). Reused as-is, not re-derived.
2161
+ const markupSlotIds = new Set(ctx.dynamicElements.map(e => e.slotId))
2162
+ return generateCsrTemplateWithOpts(node, { inlinableConstants, restSpreadNames, propsObjectName, csrEnv, unsafeLocalNames: effectiveUnsafeLocalNames, deferredChildSlots, loopDepth: -1, markupSlotIds })
2079
2163
  }
2080
2164
 
2081
2165
  /**
@@ -2440,7 +2524,8 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
2440
2524
  ? `Array.isArray(${expr}) ? ${expr}.join('') : (${expr} ?? '')`
2441
2525
  : expr
2442
2526
  if (node.slotId) {
2443
- return `<!--bf:${node.slotId}-->\${${node.joinArrayChild ? value : escapeTextSlotExpr(expr)}}<!--/-->`
2527
+ const isMarkup = opts.markupSlotIds?.has(node.slotId) ?? false
2528
+ return `<!--bf:${node.slotId}-->\${${node.joinArrayChild ? value : escapeTextSlotExpr(expr, isMarkup)}}<!--/-->`
2444
2529
  }
2445
2530
  return `\${${value}}`
2446
2531
  }
@@ -2499,7 +2584,13 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
2499
2584
  case 'jsx-children': {
2500
2585
  const hoistedRecurse = (n: IRNode): string => generateCsrTemplateWithOpts(n, { ...opts, inHoistedChildren: true })
2501
2586
  const childHtml = p.value.children.map(c => hoistedRecurse(c)).join('')
2502
- return `${quotePropName(p.name)}: \`${childHtml}\``
2587
+ // Brand the assembled HTML (#2651), except an explicit
2588
+ // `children={<jsx/>}` prop — see the identical
2589
+ // `irToHtmlTemplate` case (top of this file) for both
2590
+ // arguments.
2591
+ return p.name === 'children'
2592
+ ? `${quotePropName(p.name)}: \`${childHtml}\``
2593
+ : `${quotePropName(p.name)}: bfMarkup(\`${childHtml}\`)`
2503
2594
  }
2504
2595
  case 'literal':
2505
2596
  return `${quotePropName(p.name)}: ${JSON.stringify(p.value.value)}`
@@ -20,6 +20,10 @@ export const RUNTIME_IMPORT_CANDIDATES = [
20
20
  'createPortal',
21
21
  'provideContext', 'createContext', 'useContext',
22
22
  'forwardProps', 'applyRestAttrs', 'splitProps', 'spreadAttrs', 'styleToCss', 'escapeAttr', 'escapeText', 'escapeTextOrNode',
23
+ // JSX-element-as-non-children-prop markup brand (#2651) — `bfMarkup` wraps
24
+ // the compiler-built HTML at the producer (renderChild / initChild props);
25
+ // `escapeTextOrMarkup` unwraps it at the claim-plan-'markup' template slot.
26
+ 'bfMarkup', 'escapeTextOrMarkup',
23
27
  'qsa', 'qsaItem', 'qsaChildScope', 'qsaChildScopes', 'upsertChildItem', '__slot', '__bfSlot', '__bfText',
24
28
  // Claim-plan interpreter (slot unification A2/A3, spec/slot-unification.md)
25
29
  // — the "one claim mechanism" that replaced `patchSlotRange` and
@@ -259,7 +259,12 @@ function generateTemplateOnlyMount(ir: ComponentIR, ctx: ClientJsContext): strin
259
259
  let templateHtml: string | undefined
260
260
 
261
261
  if (canGenerateStaticTemplate(ir.root, propNamesForStaticCheck, inlinableConstants, unsafeLocalNames)) {
262
- templateHtml = irToComponentTemplate(ir.root, inlinableConstants, restSpreadNames, ctx.propsObjectName)
262
+ // `ctx.dynamicElements` is always empty on this template-only path
263
+ // (`needsClientJs(ctx)` gates it) — passed through anyway so this
264
+ // matches `emitRegistrationAndHydration`'s derivation byte-for-byte
265
+ // rather than special-casing "no markup slots here" (#2651).
266
+ const markupSlotIds = new Set(ctx.dynamicElements.map(e => e.slotId))
267
+ templateHtml = irToComponentTemplate(ir.root, inlinableConstants, restSpreadNames, ctx.propsObjectName, markupSlotIds)
263
268
  }
264
269
 
265
270
  // CSR fallback: when static template generation fails (e.g., components with