@barefootjs/jsx 0.31.7 → 0.31.9

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 (31) hide show
  1. package/dist/index.js +136 -59
  2. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  3. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  4. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  5. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts +66 -38
  6. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts.map +1 -1
  7. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  8. package/dist/ir-to-client-js/emit-registration.d.ts.map +1 -1
  9. package/dist/ir-to-client-js/html-template.d.ts +15 -1
  10. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/imports.d.ts +2 -2
  12. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  13. package/package.json +2 -2
  14. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +210 -210
  15. package/src/__tests__/aliased-destructured-prop-csr.test.ts +8 -2
  16. package/src/__tests__/csr-template-loop-shadowing.test.ts +5 -2
  17. package/src/__tests__/env-signal-template-prelude.test.ts +89 -0
  18. package/src/__tests__/inner-loop-mathml-namespace.test.ts +135 -0
  19. package/src/__tests__/markup-prop-brand.test.ts +161 -0
  20. package/src/__tests__/mathml-mapArray-namespace.test.ts +230 -0
  21. package/src/__tests__/text-slot-escaping.test.ts +21 -12
  22. package/src/ir-to-client-js/collect-elements.ts +38 -5
  23. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +10 -10
  24. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +3 -4
  25. package/src/ir-to-client-js/control-flow/stringify/loop.ts +18 -14
  26. package/src/ir-to-client-js/control-flow/stringify/template-parse.ts +142 -78
  27. package/src/ir-to-client-js/csr-substitute.ts +3 -2
  28. package/src/ir-to-client-js/emit-registration.ts +56 -4
  29. package/src/ir-to-client-js/html-template.ts +103 -12
  30. package/src/ir-to-client-js/imports.ts +4 -0
  31. package/src/ir-to-client-js/index.ts +6 -1
@@ -1,15 +1,23 @@
1
1
  /**
2
- * Text-slot HTML-escaping emit shape (#1694 + follow-up).
2
+ * Text-slot HTML-escaping emit shape (#1694 + follow-up, #2651).
3
3
  *
4
- * Pins which interpolations the client template wraps in `escapeText`:
5
- * - a plain text slot (`{stringValue}`) IS escaped — it becomes the
6
- * slot's text content under `innerHTML`;
4
+ * Pins which interpolations the client template wraps in an escape call:
5
+ * - a plain, non-conditional dynamic text slot (`{stringValue}`) IS
6
+ * escaped — it becomes the slot's text content under `innerHTML`. Since
7
+ * #2651 this goes through `escapeTextOrMarkup`, not bare `escapeText`:
8
+ * the slot is claim-plan `kind: 'markup'` (the value may be a live
9
+ * `Node`, or a `bfMarkup()`-branded JSX-element-prop value), and
10
+ * `escapeTextOrMarkup` is a strict superset of `escapeText` for every
11
+ * non-branded value — this pin's actual escaping behaviour for a plain
12
+ * string is unchanged, only the call name changed to match the
13
+ * REACTIVE side's existing `escapeTextOrNode` classification;
7
14
  * - a branch-slot expression (Child-position value inside a conditional
8
15
  * `template()` arrow) is routed through `__bfSlot` and must NOT be
9
- * wrapped in `escapeText`. `__bfSlot` returns raw `<!--bf-slot:N-->`
10
- * markers for live nodes; escaping the whole call corrupts them and
11
- * drops slotted content (the regression that broke `e2e-site-ui`).
12
- * `__bfSlot` escapes its own plain-string path internally instead.
16
+ * wrapped in either escape call. `__bfSlot` returns raw
17
+ * `<!--bf-slot:N-->` markers for live nodes; escaping the whole call
18
+ * corrupts them and drops slotted content (the regression that broke
19
+ * `e2e-site-ui`). `__bfSlot` escapes its own plain-string path
20
+ * internally instead.
13
21
  */
14
22
 
15
23
  import { describe, test, expect } from 'bun:test'
@@ -27,7 +35,7 @@ function getClientJs(source: string, filename: string): string {
27
35
  }
28
36
 
29
37
  describe('text-slot escaping', () => {
30
- test('a plain text slot is wrapped in escapeText', () => {
38
+ test('a plain text slot is wrapped in escapeTextOrMarkup (#2651)', () => {
31
39
  const clientJs = getClientJs(
32
40
  `'use client'
33
41
  export function Label({ text }: { text: string }) {
@@ -35,10 +43,10 @@ describe('text-slot escaping', () => {
35
43
  }`,
36
44
  'Label.tsx',
37
45
  )
38
- expect(clientJs).toMatch(/<!--bf:\w+-->\$\{escapeText\(_p\.text\)\}<!--\/-->/)
46
+ expect(clientJs).toMatch(/<!--bf:\w+-->\$\{escapeTextOrMarkup\(_p\.text\)\}<!--\/-->/)
39
47
  })
40
48
 
41
- test('a branch-slot expression is NOT wrapped in escapeText', () => {
49
+ test('a branch-slot expression is NOT wrapped in escapeTextOrMarkup or escapeText', () => {
42
50
  const clientJs = getClientJs(
43
51
  `'use client'
44
52
  import { createSignal } from '@barefootjs/client'
@@ -50,7 +58,8 @@ describe('text-slot escaping', () => {
50
58
  )
51
59
  // The branch value goes through __bfSlot (raw markers preserved)…
52
60
  expect(clientJs).toMatch(/\$\{__bfSlot\(/)
53
- // …and must never be double-wrapped by the text escape.
61
+ // …and must never be double-wrapped by either text-escape call.
62
+ expect(clientJs).not.toMatch(/escapeTextOrMarkup\(\s*__bfSlot/)
54
63
  expect(clientJs).not.toMatch(/escapeText\(\s*__bfSlot/)
55
64
  })
56
65
  })
@@ -7,7 +7,7 @@ import type { ClientJsContext, ConditionalBranchChildComponent, ConditionalBranc
7
7
  import { attrValueToString, freeIdsFromRefs, quotePropName, PROPS_PARAM } from './utils.ts'
8
8
  import { classifyReactivity, decideWrapForAttr, decideWrapForChildProp, decideWrapFromAstFlags, collectEventHandlersFromIR, collectConditionalBranchEvents, collectConditionalBranchRefs, collectConditionalBranchChildComponents, collectLoopChildEventsWithNesting, collectLoopChildReactiveAttrs, collectLoopChildReactiveTexts, collectLoopChildRefs, emptyLoopChildBindings, buildLoopRowScope, anyNameIn } from './reactivity.ts'
9
9
  import { irToHtmlTemplate, irToPlaceholderTemplate, irChildrenToJsExpr, buildLoopSkeletonTemplate, computeSkeletonSlotPaths, renderFlatMapClientBody, renderFlatMapProjectionClientBody, flatMapCallbackHasKeyedLeaf, type SkeletonSlotPaths } from './html-template.ts'
10
- import { templateRootIsSvg } from './control-flow/stringify/template-parse.ts'
10
+ import { detectRootNamespaceWrapTag } from './control-flow/stringify/template-parse.ts'
11
11
  import { expandDynamicPropValue, expandConstantForReactivity } from './prop-handling.ts'
12
12
  import { extractFreeIdentifiersFromText } from './csr-substitute.ts'
13
13
  import { walkIR, stopAt } from './walker.ts'
@@ -463,6 +463,29 @@ function jsxChildrenContainComponent(nodes: IRNode[]): boolean {
463
463
  return false
464
464
  }
465
465
 
466
+ /**
467
+ * Narrow gate for branding a `jsx-children` getter's value with `bfMarkup()`
468
+ * (#2651). The constructor (`jsx-to-ir.ts`'s `processComponentProps`) always
469
+ * stores exactly one node here (`AttrValueOf.jsxChildren([...])`), so
470
+ * `nodes[0]` is the whole payload; `irChildrenToJsExpr` (`html-template.ts`)
471
+ * turns a single `'element'` node into ONE HTML-string template literal —
472
+ * the same shape the `renderChild` / `irToComponentTemplateWithOpts` /
473
+ * `generateCsrTemplateWithOpts` "jsx-children" doors always produce (they
474
+ * join every child into one string regardless of shape), and the shape
475
+ * this fixture's `header={<strong>Title</strong>}` exercises.
476
+ *
477
+ * Deliberately narrow — NOT a general "does this reduce to one string"
478
+ * check: a `'fragment'` node (`header={<>text<strong/></>}`, multiple
479
+ * children) or a `'conditional'` node reduces through `irChildrenToJsExpr`
480
+ * to an array literal or a nested ternary of un-escaped-vs-escaped parts,
481
+ * for which `escapeTextOrNode`/`escapeTextOrMarkup` have no (array) or an
482
+ * unproven (ternary) contract. Those shapes are left unbranded — see
483
+ * #2651's door inventory — rather than guessed at here.
484
+ */
485
+ function isSingleElementJsxChildren(nodes: IRNode[]): boolean {
486
+ return nodes.length === 1 && nodes[0].type === 'element'
487
+ }
488
+
466
489
  /** Build rest spread names from context (rest/props spreads handled by applyRestAttrs, not spreadAttrs). */
467
490
  function buildRestSpreadNames(ctx: ClientJsContext): Set<string> {
468
491
  const names = new Set<string>()
@@ -502,6 +525,15 @@ function buildComponentPropsExpr(props: IRProp[], ctx: ClientJsContext): string
502
525
  const jsxExpr = irChildrenToJsExpr(prop.value.children)
503
526
  if (jsxChildrenContainComponent(prop.value.children)) {
504
527
  propsForInit.push(`get ${quotePropName(prop.name)}() { return __slot(() => ${jsxExpr}) }`)
528
+ } else if (prop.name !== 'children' && isSingleElementJsxChildren(prop.value.children)) {
529
+ // Brand (#2651): `jsxExpr` is the single HTML-string template
530
+ // literal `irChildrenToJsExpr` builds for a lone 'element' node —
531
+ // see `isSingleElementJsxChildren`'s docstring for why only this
532
+ // shape is branded here. Excludes an explicit `children={<jsx/>}`
533
+ // prop (out of scope, unchanged) — the child's `{children}`
534
+ // interpolation is a bare passthrough with no unwrap call, so a
535
+ // branded object there would stringify to `[object Object]`.
536
+ propsForInit.push(`get ${quotePropName(prop.name)}() { return bfMarkup(${jsxExpr}) }`)
505
537
  } else {
506
538
  propsForInit.push(`get ${quotePropName(prop.name)}() { return ${jsxExpr} }`)
507
539
  }
@@ -761,10 +793,11 @@ export function collectElements(
761
793
  }
762
794
  skeletonTemplate = buildLoopSkeletonTemplate(l.children[0], skeletonSafeSlots) ?? undefined
763
795
  // Direct child-index paths (perf, #2143): only attempted when the
764
- // skeleton itself hoisted, and skipped for SVG roots for now (the
765
- // `<svg>`-wrap namespace fix-up is orthogonal and untested against
766
- // this path model — safe fallback to qsa/$t for those loops).
767
- if (skeletonTemplate && !templateRootIsSvg(skeletonTemplate)) {
796
+ // skeleton itself hoisted, and skipped for SVG/MathML roots for
797
+ // now (the `<svg>`/`<math>`-wrap namespace fix-up is orthogonal
798
+ // and untested against this path model — safe fallback to
799
+ // qsa/$t for those loops).
800
+ if (skeletonTemplate && !detectRootNamespaceWrapTag(skeletonTemplate)) {
768
801
  skeletonPaths = computeSkeletonSlotPaths(l.children[0], skeletonSafeSlots) ?? undefined
769
802
  }
770
803
  }
@@ -33,7 +33,7 @@
33
33
  import { keyAttrName, profileBindingId, varSlotId } from '../../utils.ts'
34
34
  import { emitComponentAndEventSetup } from '../shared.ts'
35
35
  import { emitAttrUpdate } from '../../emit-reactive.ts'
36
- import { emitMultiRootTemplateCloneLines, templateRootIsSvg } from './template-parse.ts'
36
+ import { emitMultiRootTemplateCloneLines, namespaceWrapForTemplate } from './template-parse.ts'
37
37
  import { emitLoopChildRefs } from './loop.ts'
38
38
  import { claimPlanLiteral, claimWriterVarName, type ClaimSlotSpec } from './claim-plan.ts'
39
39
  import type {
@@ -83,15 +83,15 @@ function emitReactive(lines: string[], inner: InnerLoopPlan, indent: string, pc:
83
83
  lines.push(`${innerIndent} __innerEl${uid}.__bfExtras = __innerExtras${uid}`)
84
84
  lines.push(`${indent} }`)
85
85
  } else {
86
- // SVG-rooted item templates must parse inside a synthetic `<svg>` wrap
87
- // (#2219): `template.innerHTML` parses in the HTML namespace, so a bare
88
- // `<line>`/`<circle>` root clones as an HTMLUnknownElement and the SVG
89
- // renderer silently draws nothing. Mirrors `templateRootIsSvg` handling
90
- // on the top-level (#135/#1088) and branch-arm paths; HTML-rooted
91
- // templates keep byte-identical output.
92
- const isSvg = templateRootIsSvg(emit.wrappedTemplate)
93
- const innerHtml = isSvg ? `<svg>${emit.wrappedTemplate}</svg>` : emit.wrappedTemplate
94
- const childPath = isSvg ? '.firstElementChild.firstElementChild' : '.firstElementChild'
86
+ // SVG/MathML-rooted item templates must parse inside a synthetic
87
+ // namespace wrap (#2219, #1096): `template.innerHTML` parses in the
88
+ // HTML namespace, so a bare `<line>`/`<circle>`/`<mrow>` root clones as
89
+ // an HTMLUnknownElement and the SVG/MathML renderer silently draws
90
+ // nothing. Mirrors `namespaceWrapForTemplate` handling on the top-level
91
+ // (#135/#1088) and branch-arm paths; HTML-rooted templates keep
92
+ // byte-identical output.
93
+ const { wrapTag, childPath } = namespaceWrapForTemplate(emit.wrappedTemplate)
94
+ const innerHtml = wrapTag ? `<${wrapTag}>${emit.wrappedTemplate}</${wrapTag}>` : emit.wrappedTemplate
95
95
  lines.push(`${indent} let __innerEl${uid} = __existing ?? (() => { const __t = document.createElement('template'); __t.innerHTML = \`${innerHtml}\`; return __t.content${childPath}.cloneNode(true) })()`)
96
96
  }
97
97
  if (emit.wrappedKey) {
@@ -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}`,
@@ -452,8 +452,9 @@ export function buildSignalMemoEnv(
452
452
  for (const s of signals) {
453
453
  // Env signals (#2057) have no static initial value to bake — their getter
454
454
  // is a live request-scoped read (`searchParams().get(k)`). Leave it in the
455
- // CSR template as a real call, exactly as the pre-#2057 bare `searchParams()`
456
- // import was (it was never a substitutable signal).
455
+ // CSR template as a real call; `emitRegistrationAndHydration`'s
456
+ // `buildTemplateDefPart` (#2654) gives the template lambda its own
457
+ // `const [<getter>] = <envFactory>()` prelude so the call resolves.
457
458
  if (s.envReader) continue
458
459
  substitutions.set(s.getter, {
459
460
  kind: 'call',