@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.
- package/dist/index.js +136 -59
- package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts +66 -38
- package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts.map +1 -1
- package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
- package/dist/ir-to-client-js/emit-registration.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts +15 -1
- package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
- package/dist/ir-to-client-js/imports.d.ts +2 -2
- package/dist/ir-to-client-js/imports.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +210 -210
- package/src/__tests__/aliased-destructured-prop-csr.test.ts +8 -2
- package/src/__tests__/csr-template-loop-shadowing.test.ts +5 -2
- package/src/__tests__/env-signal-template-prelude.test.ts +89 -0
- package/src/__tests__/inner-loop-mathml-namespace.test.ts +135 -0
- package/src/__tests__/markup-prop-brand.test.ts +161 -0
- package/src/__tests__/mathml-mapArray-namespace.test.ts +230 -0
- package/src/__tests__/text-slot-escaping.test.ts +21 -12
- package/src/ir-to-client-js/collect-elements.ts +38 -5
- package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +10 -10
- package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +3 -4
- package/src/ir-to-client-js/control-flow/stringify/loop.ts +18 -14
- package/src/ir-to-client-js/control-flow/stringify/template-parse.ts +142 -78
- package/src/ir-to-client-js/csr-substitute.ts +3 -2
- package/src/ir-to-client-js/emit-registration.ts +56 -4
- package/src/ir-to-client-js/html-template.ts +103 -12
- package/src/ir-to-client-js/imports.ts +4 -0
- 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
|
|
5
|
-
* - a plain text slot (`{stringValue}`) IS
|
|
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
|
|
10
|
-
* markers for live nodes; escaping the whole call
|
|
11
|
-
* drops slotted content (the regression that broke
|
|
12
|
-
* `__bfSlot` escapes its own plain-string path
|
|
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
|
|
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+-->\$\{
|
|
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
|
|
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 {
|
|
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
|
|
765
|
-
// `<svg>`-wrap namespace fix-up is orthogonal
|
|
766
|
-
// this path model — safe fallback to
|
|
767
|
-
|
|
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,
|
|
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
|
|
87
|
-
// (#2219): `template.innerHTML` parses in the
|
|
88
|
-
// `<line>`/`<circle>` root clones as
|
|
89
|
-
//
|
|
90
|
-
// on the top-level
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
const
|
|
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 {
|
|
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
|
|
143
|
-
const innerHtml =
|
|
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,
|
|
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
|
|
405
|
-
// descend one extra level (#2219) — `template.innerHTML`
|
|
406
|
-
// the HTML namespace and the materialized elements
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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${
|
|
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${
|
|
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
|
|
4
|
-
* root is
|
|
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
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* extra level to get the
|
|
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
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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
|
|
49
|
-
*
|
|
50
|
-
* bodies; without the wrap the cloned
|
|
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 (
|
|
58
|
-
* to no-wrap so the user sees the same broken
|
|
59
|
-
* a silent over-wrap
|
|
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
|
|
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
|
|
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
|
|
82
|
-
|
|
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
|
-
*
|
|
124
|
+
* namespace wrap swallows every sibling root at once (#2233 Copilot
|
|
125
|
+
* review, generalised to MathML in #1096).
|
|
88
126
|
*
|
|
89
|
-
* `
|
|
90
|
-
* templates that's exact, but a fragment whose first root is
|
|
91
|
-
* CONTAINER (`<><svg/><span/></>`)
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
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):
|
|
98
|
-
*
|
|
99
|
-
* in the HTML namespace — exactly the pre-#2219
|
|
100
|
-
* shape correctly needs a scan of every top-level
|
|
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
|
|
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
|
|
106
|
-
|
|
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
|
|
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
|
-
|
|
167
|
-
|
|
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
|
-
*
|
|
181
|
-
* `
|
|
182
|
-
* the interpolated template, so the same wrap decision
|
|
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
|
|
186
|
-
const html =
|
|
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
|
|
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
|
-
|
|
208
|
-
|
|
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 = \`${
|
|
217
|
-
`${indent}return __tpl.content.
|
|
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
|
|
316
|
-
// Wrap in
|
|
317
|
-
// descend one level to pick up the per-item roots.
|
|
318
|
-
const innerHtmlExpr =
|
|
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
|
|
321
|
-
const parentExpr =
|
|
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
|
|
456
|
-
//
|
|
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',
|