Skip to content

Commit d42b6ba

Browse files
authoredDec 8, 2023
feat: MathML support (#7836)
close #7820
1 parent bc7698d commit d42b6ba

19 files changed

+372
-157
lines changed
 

‎packages/compiler-dom/src/parserOptions.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { ParserOptions, NodeTypes, Namespaces } from '@vue/compiler-core'
2-
import { isVoidTag, isHTMLTag, isSVGTag } from '@vue/shared'
2+
import { isVoidTag, isHTMLTag, isSVGTag, isMathMLTag } from '@vue/shared'
33
import { TRANSITION, TRANSITION_GROUP } from './runtimeHelpers'
44
import { decodeHtmlBrowser } from './decodeHtmlBrowser'
55

66
export const parserOptions: ParserOptions = {
77
parseMode: 'html',
88
isVoidTag,
9-
isNativeTag: tag => isHTMLTag(tag) || isSVGTag(tag),
9+
isNativeTag: tag => isHTMLTag(tag) || isSVGTag(tag) || isMathMLTag(tag),
1010
isPreTag: tag => tag === 'pre',
1111
decodeEntities: __BROWSER__ ? decodeHtmlBrowser : undefined,
1212

‎packages/runtime-core/src/apiCreateApp.ts

+17-5
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
ComponentPublicInstance
1717
} from './componentPublicInstance'
1818
import { Directive, validateDirectiveName } from './directives'
19-
import { RootRenderFunction } from './renderer'
19+
import { ElementNamespace, RootRenderFunction } from './renderer'
2020
import { InjectionKey } from './apiInject'
2121
import { warn } from './warning'
2222
import { createVNode, cloneVNode, VNode } from './vnode'
@@ -47,7 +47,7 @@ export interface App<HostElement = any> {
4747
mount(
4848
rootContainer: HostElement | string,
4949
isHydrate?: boolean,
50-
isSVG?: boolean
50+
namespace?: boolean | ElementNamespace
5151
): ComponentPublicInstance
5252
unmount(): void
5353
provide<T>(key: InjectionKey<T> | string, value: T): this
@@ -297,7 +297,7 @@ export function createAppAPI<HostElement>(
297297
mount(
298298
rootContainer: HostElement,
299299
isHydrate?: boolean,
300-
isSVG?: boolean
300+
namespace?: boolean | ElementNamespace
301301
): any {
302302
if (!isMounted) {
303303
// #5571
@@ -313,17 +313,29 @@ export function createAppAPI<HostElement>(
313313
// this will be set on the root instance on initial mount.
314314
vnode.appContext = context
315315

316+
if (namespace === true) {
317+
namespace = 'svg'
318+
} else if (namespace === false) {
319+
namespace = undefined
320+
}
321+
316322
// HMR root reload
317323
if (__DEV__) {
318324
context.reload = () => {
319-
render(cloneVNode(vnode), rootContainer, isSVG)
325+
// casting to ElementNamespace because TS doesn't guarantee type narrowing
326+
// over function boundaries
327+
render(
328+
cloneVNode(vnode),
329+
rootContainer,
330+
namespace as ElementNamespace
331+
)
320332
}
321333
}
322334

323335
if (isHydrate && hydrate) {
324336
hydrate(vnode as VNode<Node, Element>, rootContainer as any)
325337
} else {
326-
render(vnode, rootContainer, isSVG)
338+
render(vnode, rootContainer, namespace)
327339
}
328340
isMounted = true
329341
app._container = rootContainer

‎packages/runtime-core/src/compat/global.ts

+10-4
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
} from '@vue/shared'
1818
import { warn } from '../warning'
1919
import { cloneVNode, createVNode } from '../vnode'
20-
import { RootRenderFunction } from '../renderer'
20+
import { ElementNamespace, RootRenderFunction } from '../renderer'
2121
import {
2222
App,
2323
AppConfig,
@@ -503,15 +503,21 @@ function installCompatMount(
503503
container = selectorOrEl || document.createElement('div')
504504
}
505505

506-
const isSVG = container instanceof SVGElement
506+
let namespace: ElementNamespace
507+
if (container instanceof SVGElement) namespace = 'svg'
508+
else if (
509+
typeof MathMLElement === 'function' &&
510+
container instanceof MathMLElement
511+
)
512+
namespace = 'mathml'
507513

508514
// HMR root reload
509515
if (__DEV__) {
510516
context.reload = () => {
511517
const cloned = cloneVNode(vnode)
512518
// compat mode will use instance if not reset to null
513519
cloned.component = null
514-
render(cloned, container, isSVG)
520+
render(cloned, container, namespace)
515521
}
516522
}
517523

@@ -538,7 +544,7 @@ function installCompatMount(
538544
container.innerHTML = ''
539545

540546
// TODO hydration
541-
render(vnode, container, isSVG)
547+
render(vnode, container, namespace)
542548

543549
if (container instanceof Element) {
544550
container.removeAttribute('v-cloak')

‎packages/runtime-core/src/components/KeepAlive.ts

+11-4
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ import {
3737
queuePostRenderEffect,
3838
MoveType,
3939
RendererElement,
40-
RendererNode
40+
RendererNode,
41+
ElementNamespace
4142
} from '../renderer'
4243
import { setTransitionHooks } from './BaseTransition'
4344
import { ComponentRenderContext } from '../componentPublicInstance'
@@ -64,7 +65,7 @@ export interface KeepAliveContext extends ComponentRenderContext {
6465
vnode: VNode,
6566
container: RendererElement,
6667
anchor: RendererNode | null,
67-
isSVG: boolean,
68+
namespace: ElementNamespace,
6869
optimized: boolean
6970
) => void
7071
deactivate: (vnode: VNode) => void
@@ -125,7 +126,13 @@ const KeepAliveImpl: ComponentOptions = {
125126
} = sharedContext
126127
const storageContainer = createElement('div')
127128

128-
sharedContext.activate = (vnode, container, anchor, isSVG, optimized) => {
129+
sharedContext.activate = (
130+
vnode,
131+
container,
132+
anchor,
133+
namespace,
134+
optimized
135+
) => {
129136
const instance = vnode.component!
130137
move(vnode, container, anchor, MoveType.ENTER, parentSuspense)
131138
// in case props have changed
@@ -136,7 +143,7 @@ const KeepAliveImpl: ComponentOptions = {
136143
anchor,
137144
instance,
138145
parentSuspense,
139-
isSVG,
146+
namespace,
140147
vnode.slotScopeIds,
141148
optimized
142149
)

‎packages/runtime-core/src/components/Suspense.ts

+26-25
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ import {
1818
MoveType,
1919
SetupRenderEffectFn,
2020
RendererNode,
21-
RendererElement
21+
RendererElement,
22+
ElementNamespace
2223
} from '../renderer'
2324
import { queuePostFlushCb } from '../scheduler'
2425
import { filterSingleRoot, updateHOCHostEl } from '../componentRenderUtils'
@@ -63,7 +64,7 @@ export const SuspenseImpl = {
6364
anchor: RendererNode | null,
6465
parentComponent: ComponentInternalInstance | null,
6566
parentSuspense: SuspenseBoundary | null,
66-
isSVG: boolean,
67+
namespace: ElementNamespace,
6768
slotScopeIds: string[] | null,
6869
optimized: boolean,
6970
// platform-specific impl passed from renderer
@@ -76,7 +77,7 @@ export const SuspenseImpl = {
7677
anchor,
7778
parentComponent,
7879
parentSuspense,
79-
isSVG,
80+
namespace,
8081
slotScopeIds,
8182
optimized,
8283
rendererInternals
@@ -88,7 +89,7 @@ export const SuspenseImpl = {
8889
container,
8990
anchor,
9091
parentComponent,
91-
isSVG,
92+
namespace,
9293
slotScopeIds,
9394
optimized,
9495
rendererInternals
@@ -130,7 +131,7 @@ function mountSuspense(
130131
anchor: RendererNode | null,
131132
parentComponent: ComponentInternalInstance | null,
132133
parentSuspense: SuspenseBoundary | null,
133-
isSVG: boolean,
134+
namespace: ElementNamespace,
134135
slotScopeIds: string[] | null,
135136
optimized: boolean,
136137
rendererInternals: RendererInternals
@@ -147,7 +148,7 @@ function mountSuspense(
147148
container,
148149
hiddenContainer,
149150
anchor,
150-
isSVG,
151+
namespace,
151152
slotScopeIds,
152153
optimized,
153154
rendererInternals
@@ -161,7 +162,7 @@ function mountSuspense(
161162
null,
162163
parentComponent,
163164
suspense,
164-
isSVG,
165+
namespace,
165166
slotScopeIds
166167
)
167168
// now check if we have encountered any async deps
@@ -179,7 +180,7 @@ function mountSuspense(
179180
anchor,
180181
parentComponent,
181182
null, // fallback tree will not have suspense context
182-
isSVG,
183+
namespace,
183184
slotScopeIds
184185
)
185186
setActiveBranch(suspense, vnode.ssFallback!)
@@ -195,7 +196,7 @@ function patchSuspense(
195196
container: RendererElement,
196197
anchor: RendererNode | null,
197198
parentComponent: ComponentInternalInstance | null,
198-
isSVG: boolean,
199+
namespace: ElementNamespace,
199200
slotScopeIds: string[] | null,
200201
optimized: boolean,
201202
{ p: patch, um: unmount, o: { createElement } }: RendererInternals
@@ -218,7 +219,7 @@ function patchSuspense(
218219
null,
219220
parentComponent,
220221
suspense,
221-
isSVG,
222+
namespace,
222223
slotScopeIds,
223224
optimized
224225
)
@@ -232,7 +233,7 @@ function patchSuspense(
232233
anchor,
233234
parentComponent,
234235
null, // fallback tree will not have suspense context
235-
isSVG,
236+
namespace,
236237
slotScopeIds,
237238
optimized
238239
)
@@ -267,7 +268,7 @@ function patchSuspense(
267268
null,
268269
parentComponent,
269270
suspense,
270-
isSVG,
271+
namespace,
271272
slotScopeIds,
272273
optimized
273274
)
@@ -281,7 +282,7 @@ function patchSuspense(
281282
anchor,
282283
parentComponent,
283284
null, // fallback tree will not have suspense context
284-
isSVG,
285+
namespace,
285286
slotScopeIds,
286287
optimized
287288
)
@@ -296,7 +297,7 @@ function patchSuspense(
296297
anchor,
297298
parentComponent,
298299
suspense,
299-
isSVG,
300+
namespace,
300301
slotScopeIds,
301302
optimized
302303
)
@@ -311,7 +312,7 @@ function patchSuspense(
311312
null,
312313
parentComponent,
313314
suspense,
314-
isSVG,
315+
namespace,
315316
slotScopeIds,
316317
optimized
317318
)
@@ -330,7 +331,7 @@ function patchSuspense(
330331
anchor,
331332
parentComponent,
332333
suspense,
333-
isSVG,
334+
namespace,
334335
slotScopeIds,
335336
optimized
336337
)
@@ -349,7 +350,7 @@ function patchSuspense(
349350
null,
350351
parentComponent,
351352
suspense,
352-
isSVG,
353+
namespace,
353354
slotScopeIds,
354355
optimized
355356
)
@@ -376,7 +377,7 @@ export interface SuspenseBoundary {
376377
vnode: VNode<RendererNode, RendererElement, SuspenseProps>
377378
parent: SuspenseBoundary | null
378379
parentComponent: ComponentInternalInstance | null
379-
isSVG: boolean
380+
namespace: ElementNamespace
380381
container: RendererElement
381382
hiddenContainer: RendererElement
382383
anchor: RendererNode | null
@@ -413,7 +414,7 @@ function createSuspenseBoundary(
413414
container: RendererElement,
414415
hiddenContainer: RendererElement,
415416
anchor: RendererNode | null,
416-
isSVG: boolean,
417+
namespace: ElementNamespace,
417418
slotScopeIds: string[] | null,
418419
optimized: boolean,
419420
rendererInternals: RendererInternals,
@@ -455,7 +456,7 @@ function createSuspenseBoundary(
455456
vnode,
456457
parent: parentSuspense,
457458
parentComponent,
458-
isSVG,
459+
namespace,
459460
container,
460461
hiddenContainer,
461462
anchor,
@@ -576,7 +577,7 @@ function createSuspenseBoundary(
576577
return
577578
}
578579

579-
const { vnode, activeBranch, parentComponent, container, isSVG } =
580+
const { vnode, activeBranch, parentComponent, container, namespace } =
580581
suspense
581582

582583
// invoke @fallback event
@@ -594,7 +595,7 @@ function createSuspenseBoundary(
594595
next(activeBranch!),
595596
parentComponent,
596597
null, // fallback tree will not have suspense context
597-
isSVG,
598+
namespace,
598599
slotScopeIds,
599600
optimized
600601
)
@@ -675,7 +676,7 @@ function createSuspenseBoundary(
675676
// consider the comment placeholder case.
676677
hydratedEl ? null : next(instance.subTree),
677678
suspense,
678-
isSVG,
679+
namespace,
679680
optimized
680681
)
681682
if (placeholder) {
@@ -721,7 +722,7 @@ function hydrateSuspense(
721722
vnode: VNode,
722723
parentComponent: ComponentInternalInstance | null,
723724
parentSuspense: SuspenseBoundary | null,
724-
isSVG: boolean,
725+
namespace: ElementNamespace,
725726
slotScopeIds: string[] | null,
726727
optimized: boolean,
727728
rendererInternals: RendererInternals,
@@ -742,7 +743,7 @@ function hydrateSuspense(
742743
node.parentNode!,
743744
document.createElement('div'),
744745
null,
745-
isSVG,
746+
namespace,
746747
slotScopeIds,
747748
optimized,
748749
rendererInternals,

‎packages/runtime-core/src/components/Teleport.ts

+20-7
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import {
66
RendererElement,
77
RendererNode,
88
RendererOptions,
9-
traverseStaticChildren
9+
traverseStaticChildren,
10+
ElementNamespace
1011
} from '../renderer'
1112
import { VNode, VNodeArrayChildren, VNodeProps } from '../vnode'
1213
import { isString, ShapeFlags } from '@vue/shared'
@@ -28,6 +29,9 @@ const isTeleportDisabled = (props: VNode['props']): boolean =>
2829
const isTargetSVG = (target: RendererElement): boolean =>
2930
typeof SVGElement !== 'undefined' && target instanceof SVGElement
3031

32+
const isTargetMathML = (target: RendererElement): boolean =>
33+
typeof MathMLElement === 'function' && target instanceof MathMLElement
34+
3135
const resolveTarget = <T = RendererElement>(
3236
props: TeleportProps | null,
3337
select: RendererOptions['querySelector']
@@ -72,7 +76,7 @@ export const TeleportImpl = {
7276
anchor: RendererNode | null,
7377
parentComponent: ComponentInternalInstance | null,
7478
parentSuspense: SuspenseBoundary | null,
75-
isSVG: boolean,
79+
namespace: ElementNamespace,
7680
slotScopeIds: string[] | null,
7781
optimized: boolean,
7882
internals: RendererInternals
@@ -109,7 +113,11 @@ export const TeleportImpl = {
109113
if (target) {
110114
insert(targetAnchor, target)
111115
// #2652 we could be teleporting from a non-SVG tree into an SVG tree
112-
isSVG = isSVG || isTargetSVG(target)
116+
if (namespace === 'svg' || isTargetSVG(target)) {
117+
namespace = 'svg'
118+
} else if (namespace === 'mathml' || isTargetMathML(target)) {
119+
namespace = 'mathml'
120+
}
113121
} else if (__DEV__ && !disabled) {
114122
warn('Invalid Teleport target on mount:', target, `(${typeof target})`)
115123
}
@@ -124,7 +132,7 @@ export const TeleportImpl = {
124132
anchor,
125133
parentComponent,
126134
parentSuspense,
127-
isSVG,
135+
namespace,
128136
slotScopeIds,
129137
optimized
130138
)
@@ -145,7 +153,12 @@ export const TeleportImpl = {
145153
const wasDisabled = isTeleportDisabled(n1.props)
146154
const currentContainer = wasDisabled ? container : target
147155
const currentAnchor = wasDisabled ? mainAnchor : targetAnchor
148-
isSVG = isSVG || isTargetSVG(target)
156+
157+
if (namespace === 'svg' || isTargetSVG(target)) {
158+
namespace = 'svg'
159+
} else if (namespace === 'mathml' || isTargetMathML(target)) {
160+
namespace = 'mathml'
161+
}
149162

150163
if (dynamicChildren) {
151164
// fast path when the teleport happens to be a block root
@@ -155,7 +168,7 @@ export const TeleportImpl = {
155168
currentContainer,
156169
parentComponent,
157170
parentSuspense,
158-
isSVG,
171+
namespace,
159172
slotScopeIds
160173
)
161174
// even in block tree mode we need to make sure all root-level nodes
@@ -170,7 +183,7 @@ export const TeleportImpl = {
170183
currentAnchor,
171184
parentComponent,
172185
parentSuspense,
173-
isSVG,
186+
namespace,
174187
slotScopeIds,
175188
false
176189
)

‎packages/runtime-core/src/hydration.ts

+17-7
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,17 @@ enum DOMNodeTypes {
5252
let hasMismatch = false
5353

5454
const isSVGContainer = (container: Element) =>
55-
/svg/.test(container.namespaceURI!) && container.tagName !== 'foreignObject'
55+
container.namespaceURI!.includes('svg') &&
56+
container.tagName !== 'foreignObject'
57+
58+
const isMathMLContainer = (container: Element) =>
59+
container.namespaceURI!.includes('MathML')
60+
61+
const getContainerType = (container: Element): 'svg' | 'mathml' | undefined => {
62+
if (isSVGContainer(container)) return 'svg'
63+
if (isMathMLContainer(container)) return 'mathml'
64+
return undefined
65+
}
5666

5767
const isComment = (node: Node): node is Comment =>
5868
node.nodeType === DOMNodeTypes.COMMENT
@@ -277,7 +287,7 @@ export function createHydrationFunctions(
277287
null,
278288
parentComponent,
279289
parentSuspense,
280-
isSVGContainer(container),
290+
getContainerType(container),
281291
optimized
282292
)
283293

@@ -320,7 +330,7 @@ export function createHydrationFunctions(
320330
vnode,
321331
parentComponent,
322332
parentSuspense,
323-
isSVGContainer(parentNode(node)!),
333+
getContainerType(parentNode(node)!),
324334
slotScopeIds,
325335
optimized,
326336
rendererInternals,
@@ -453,7 +463,7 @@ export function createHydrationFunctions(
453463
key,
454464
null,
455465
props[key],
456-
false,
466+
undefined,
457467
undefined,
458468
parentComponent
459469
)
@@ -467,7 +477,7 @@ export function createHydrationFunctions(
467477
'onClick',
468478
null,
469479
props.onClick,
470-
false,
480+
undefined,
471481
undefined,
472482
parentComponent
473483
)
@@ -547,7 +557,7 @@ export function createHydrationFunctions(
547557
null,
548558
parentComponent,
549559
parentSuspense,
550-
isSVGContainer(container),
560+
getContainerType(container),
551561
slotScopeIds
552562
)
553563
}
@@ -639,7 +649,7 @@ export function createHydrationFunctions(
639649
next,
640650
parentComponent,
641651
parentSuspense,
642-
isSVGContainer(container),
652+
getContainerType(container),
643653
slotScopeIds
644654
)
645655
return next

‎packages/runtime-core/src/index.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,8 @@ export type {
260260
RendererElement,
261261
HydrationRenderer,
262262
RendererOptions,
263-
RootRenderFunction
263+
RootRenderFunction,
264+
ElementNamespace
264265
} from './renderer'
265266
export type { RootHydrateFunction } from './hydration'
266267
export type { Slot, Slots, SlotsType } from './componentSlots'

‎packages/runtime-core/src/renderer.ts

+105-77
Large diffs are not rendered by default.

‎packages/runtime-dom/__tests__/nodeOps.spec.ts

+16-6
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { nodeOps, svgNS } from '../src/nodeOps'
22

33
describe('runtime-dom: node-ops', () => {
44
test("the <select>'s multiple attr should be set in createElement", () => {
5-
const el = nodeOps.createElement('select', false, undefined, {
5+
const el = nodeOps.createElement('select', undefined, undefined, {
66
multiple: ''
77
}) as HTMLSelectElement
88
const option1 = nodeOps.createElement('option') as HTMLOptionElement
@@ -21,7 +21,12 @@ describe('runtime-dom: node-ops', () => {
2121
test('fresh insertion', () => {
2222
const content = `<div>one</div><div>two</div>three`
2323
const parent = document.createElement('div')
24-
const nodes = nodeOps.insertStaticContent!(content, parent, null, false)
24+
const nodes = nodeOps.insertStaticContent!(
25+
content,
26+
parent,
27+
null,
28+
undefined
29+
)
2530
expect(parent.innerHTML).toBe(content)
2631
expect(nodes[0]).toBe(parent.firstChild)
2732
expect(nodes[1]).toBe(parent.lastChild)
@@ -33,7 +38,12 @@ describe('runtime-dom: node-ops', () => {
3338
const parent = document.createElement('div')
3439
parent.innerHTML = existing
3540
const anchor = parent.firstChild
36-
const nodes = nodeOps.insertStaticContent!(content, parent, anchor, false)
41+
const nodes = nodeOps.insertStaticContent!(
42+
content,
43+
parent,
44+
anchor,
45+
undefined
46+
)
3747
expect(parent.innerHTML).toBe(content + existing)
3848
expect(nodes[0]).toBe(parent.firstChild)
3949
expect(nodes[1]).toBe(parent.childNodes[parent.childNodes.length - 2])
@@ -46,7 +56,7 @@ describe('runtime-dom: node-ops', () => {
4656
content,
4757
parent,
4858
null,
49-
true
59+
'svg'
5060
)
5161
expect(parent.innerHTML).toBe(content)
5262
expect(first).toBe(parent.firstChild)
@@ -65,7 +75,7 @@ describe('runtime-dom: node-ops', () => {
6575
content,
6676
parent,
6777
anchor,
68-
true
78+
'svg'
6979
)
7080
expect(parent.innerHTML).toBe(content + existing)
7181
expect(first).toBe(parent.firstChild)
@@ -88,7 +98,7 @@ describe('runtime-dom: node-ops', () => {
8898
content,
8999
parent,
90100
anchor,
91-
false,
101+
undefined,
92102
cached.firstChild,
93103
cached.lastChild
94104
)

‎packages/runtime-dom/__tests__/patchAttrs.spec.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,15 @@ import { xlinkNS } from '../src/modules/attrs'
44
describe('runtime-dom: attrs patching', () => {
55
test('xlink attributes', () => {
66
const el = document.createElementNS('http://www.w3.org/2000/svg', 'use')
7-
patchProp(el, 'xlink:href', null, 'a', true)
7+
patchProp(el, 'xlink:href', null, 'a', 'svg')
88
expect(el.getAttributeNS(xlinkNS, 'href')).toBe('a')
9-
patchProp(el, 'xlink:href', 'a', null, true)
9+
patchProp(el, 'xlink:href', 'a', null, 'svg')
1010
expect(el.getAttributeNS(xlinkNS, 'href')).toBe(null)
1111
})
1212

1313
test('textContent attributes /w svg', () => {
1414
const el = document.createElementNS('http://www.w3.org/2000/svg', 'use')
15-
patchProp(el, 'textContent', null, 'foo', true)
15+
patchProp(el, 'textContent', null, 'foo', 'svg')
1616
expect(el.attributes.length).toBe(0)
1717
expect(el.innerHTML).toBe('foo')
1818
})

‎packages/runtime-dom/__tests__/patchClass.spec.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ describe('runtime-dom: class patching', () => {
2525

2626
test('svg', () => {
2727
const el = document.createElementNS(svgNS, 'svg')
28-
patchProp(el, 'class', null, 'foo', true)
28+
patchProp(el, 'class', null, 'foo', 'svg')
2929
expect(el.getAttribute('class')).toBe('foo')
3030
})
3131
})

‎packages/runtime-dom/src/index.ts

+19-5
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import {
1010
RootHydrateFunction,
1111
isRuntimeOnly,
1212
DeprecationTypes,
13-
compatUtils
13+
compatUtils,
14+
ElementNamespace
1415
} from '@vue/runtime-core'
1516
import { nodeOps } from './nodeOps'
1617
import { patchProp } from './patchProp'
@@ -21,7 +22,8 @@ import {
2122
isHTMLTag,
2223
isSVGTag,
2324
extend,
24-
NOOP
25+
NOOP,
26+
isMathMLTag
2527
} from '@vue/shared'
2628

2729
declare module '@vue/reactivity' {
@@ -99,7 +101,7 @@ export const createApp = ((...args) => {
99101

100102
// clear content before mounting
101103
container.innerHTML = ''
102-
const proxy = mount(container, false, container instanceof SVGElement)
104+
const proxy = mount(container, false, resolveRootNamespace(container))
103105
if (container instanceof Element) {
104106
container.removeAttribute('v-cloak')
105107
container.setAttribute('data-v-app', '')
@@ -122,18 +124,30 @@ export const createSSRApp = ((...args) => {
122124
app.mount = (containerOrSelector: Element | ShadowRoot | string): any => {
123125
const container = normalizeContainer(containerOrSelector)
124126
if (container) {
125-
return mount(container, true, container instanceof SVGElement)
127+
return mount(container, true, resolveRootNamespace(container))
126128
}
127129
}
128130

129131
return app
130132
}) as CreateAppFunction<Element>
131133

134+
function resolveRootNamespace(container: Element): ElementNamespace {
135+
if (container instanceof SVGElement) {
136+
return 'svg'
137+
}
138+
if (
139+
typeof MathMLElement === 'function' &&
140+
container instanceof MathMLElement
141+
) {
142+
return 'mathml'
143+
}
144+
}
145+
132146
function injectNativeTagCheck(app: App) {
133147
// Inject `isNativeTag`
134148
// this is used for component name validation (dev only)
135149
Object.defineProperty(app.config, 'isNativeTag', {
136-
value: (tag: string) => isHTMLTag(tag) || isSVGTag(tag),
150+
value: (tag: string) => isHTMLTag(tag) || isSVGTag(tag) || isMathMLTag(tag),
137151
writable: false
138152
})
139153
}

‎packages/runtime-dom/src/nodeOps.ts

+18-8
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { RendererOptions } from '@vue/runtime-core'
22

33
export const svgNS = 'http://www.w3.org/2000/svg'
4+
export const mathmlNS = 'http://www.w3.org/1998/Math/MathML'
45

56
const doc = (typeof document !== 'undefined' ? document : null) as Document
67

@@ -18,10 +19,13 @@ export const nodeOps: Omit<RendererOptions<Node, Element>, 'patchProp'> = {
1819
}
1920
},
2021

21-
createElement: (tag, isSVG, is, props): Element => {
22-
const el = isSVG
23-
? doc.createElementNS(svgNS, tag)
24-
: doc.createElement(tag, is ? { is } : undefined)
22+
createElement: (tag, namespace, is, props): Element => {
23+
const el =
24+
namespace === 'svg'
25+
? doc.createElementNS(svgNS, tag)
26+
: namespace === 'mathml'
27+
? doc.createElementNS(mathmlNS, tag)
28+
: doc.createElement(tag, is ? { is } : undefined)
2529

2630
if (tag === 'select' && props && props.multiple != null) {
2731
;(el as HTMLSelectElement).setAttribute('multiple', props.multiple)
@@ -56,7 +60,7 @@ export const nodeOps: Omit<RendererOptions<Node, Element>, 'patchProp'> = {
5660
// Reason: innerHTML.
5761
// Static content here can only come from compiled templates.
5862
// As long as the user only uses trusted templates, this is safe.
59-
insertStaticContent(content, parent, anchor, isSVG, start, end) {
63+
insertStaticContent(content, parent, anchor, namespace, start, end) {
6064
// <parent> before | first ... last | anchor </parent>
6165
const before = anchor ? anchor.previousSibling : parent.lastChild
6266
// #5308 can only take cached path if:
@@ -70,10 +74,16 @@ export const nodeOps: Omit<RendererOptions<Node, Element>, 'patchProp'> = {
7074
}
7175
} else {
7276
// fresh insert
73-
templateContainer.innerHTML = isSVG ? `<svg>${content}</svg>` : content
77+
templateContainer.innerHTML =
78+
namespace === 'svg'
79+
? `<svg>${content}</svg>`
80+
: namespace === 'mathml'
81+
? `<math>${content}</math>`
82+
: content
83+
7484
const template = templateContainer.content
75-
if (isSVG) {
76-
// remove outer svg wrapper
85+
if (namespace === 'svg' || namespace === 'mathml') {
86+
// remove outer svg/math wrapper
7787
const wrapper = template.firstChild!
7888
while (wrapper.firstChild) {
7989
template.appendChild(wrapper.firstChild)

‎packages/runtime-dom/src/patchProp.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,13 @@ export const patchProp: DOMRendererOptions['patchProp'] = (
2020
key,
2121
prevValue,
2222
nextValue,
23-
isSVG = false,
23+
namespace,
2424
prevChildren,
2525
parentComponent,
2626
parentSuspense,
2727
unmountChildren
2828
) => {
29+
const isSVG = namespace === 'svg'
2930
if (key === 'class') {
3031
patchClass(el, nextValue, isSVG)
3132
} else if (key === 'style') {

‎packages/shared/src/domTagConfig.ts

+12
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ const SVG_TAGS =
2727
'polygon,polyline,radialGradient,rect,set,solidcolor,stop,switch,symbol,' +
2828
'text,textPath,title,tspan,unknown,use,view'
2929

30+
// https://developer.mozilla.org/en-US/docs/Web/MathML/Element
31+
const MATH_TAGS =
32+
'math,maction,annotation,annotation-xml,menclose,merror,mfenced,mfrac,mi,' +
33+
'mmultiscripts,mn,mo,mover,mpadded,mphantom,mprescripts,mroot,mrow,ms,' +
34+
'semantics,mspace,msqrt,mstyle,msub,msup,msubsup,mtable,mtd,mtext,mtr,' +
35+
'munder,munderover'
36+
3037
const VOID_TAGS =
3138
'area,base,br,col,embed,hr,img,input,link,meta,param,source,track,wbr'
3239

@@ -40,6 +47,11 @@ export const isHTMLTag = /*#__PURE__*/ makeMap(HTML_TAGS)
4047
* Do NOT use in runtime code paths unless behind `__DEV__` flag.
4148
*/
4249
export const isSVGTag = /*#__PURE__*/ makeMap(SVG_TAGS)
50+
/**
51+
* Compiler only.
52+
* Do NOT use in runtime code paths unless behind `__DEV__` flag.
53+
*/
54+
export const isMathMLTag = /*#__PURE__*/ makeMap(MATH_TAGS)
4355
/**
4456
* Compiler only.
4557
* Do NOT use in runtime code paths unless behind `__DEV__` flag.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// MathML logic is technically dom-specific, but the logic is placed in core
2+
// because splitting it out of core would lead to unnecessary complexity in both
3+
// the renderer and compiler implementations.
4+
// Related files:
5+
// - runtime-core/src/renderer.ts
6+
// - compiler-core/src/transforms/transformElement.ts
7+
8+
import { vtcKey } from '../../runtime-dom/src/components/Transition'
9+
import { render, h, ref, nextTick } from '../src'
10+
11+
describe('MathML support', () => {
12+
afterEach(() => {
13+
document.body.innerHTML = ''
14+
})
15+
16+
test('should mount elements with correct html namespace', () => {
17+
const root = document.createElement('div')
18+
document.body.appendChild(root)
19+
const App = {
20+
template: `
21+
<math display="block" id="e0">
22+
<semantics id="e1">
23+
<mrow id="e2">
24+
<msup>
25+
<mi>x</mi>
26+
<mn>2</mn>
27+
</msup>
28+
<mo>+</mo>
29+
<mi>y</mi>
30+
</mrow>
31+
32+
<annotation-xml encoding="text/html" id="e3">
33+
<div id="e4" />
34+
<svg id="e5" />
35+
</annotation-xml>
36+
</semantics>
37+
</math>
38+
`
39+
}
40+
render(h(App), root)
41+
const e0 = document.getElementById('e0')!
42+
expect(e0.namespaceURI).toMatch('Math')
43+
expect(e0.querySelector('#e1')!.namespaceURI).toMatch('Math')
44+
expect(e0.querySelector('#e2')!.namespaceURI).toMatch('Math')
45+
expect(e0.querySelector('#e3')!.namespaceURI).toMatch('Math')
46+
expect(e0.querySelector('#e4')!.namespaceURI).toMatch('xhtml')
47+
expect(e0.querySelector('#e5')!.namespaceURI).toMatch('svg')
48+
})
49+
50+
test('should patch elements with correct namespaces', async () => {
51+
const root = document.createElement('div')
52+
document.body.appendChild(root)
53+
const cls = ref('foo')
54+
const App = {
55+
setup: () => ({ cls }),
56+
template: `
57+
<div>
58+
<math id="f1" :class="cls">
59+
<annotation encoding="text/html">
60+
<div id="f2" :class="cls"/>
61+
</annotation>
62+
</math>
63+
</div>
64+
`
65+
}
66+
render(h(App), root)
67+
const f1 = document.querySelector('#f1')!
68+
const f2 = document.querySelector('#f2')!
69+
expect(f1.getAttribute('class')).toBe('foo')
70+
expect(f2.className).toBe('foo')
71+
72+
// set a transition class on the <div> - which is only respected on non-svg
73+
// patches
74+
;(f2 as any)[vtcKey] = ['baz']
75+
cls.value = 'bar'
76+
await nextTick()
77+
expect(f1.getAttribute('class')).toBe('bar')
78+
expect(f2.className).toBe('bar baz')
79+
})
80+
})

‎packages/vue/__tests__/svgNamespace.spec.ts

+9-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ import { vtcKey } from '../../runtime-dom/src/components/Transition'
99
import { render, h, ref, nextTick } from '../src'
1010

1111
describe('SVG support', () => {
12-
test('should mount elements with correct namespaces', () => {
12+
afterEach(() => {
13+
document.body.innerHTML = ''
14+
})
15+
16+
test('should mount elements with correct html namespace', () => {
1317
const root = document.createElement('div')
1418
document.body.appendChild(root)
1519
const App = {
@@ -18,6 +22,8 @@ describe('SVG support', () => {
1822
<svg id="e1">
1923
<foreignObject id="e2">
2024
<div id="e3"/>
25+
<svg id="e4"/>
26+
<math id="e5"/>
2127
</foreignObject>
2228
</svg>
2329
</div>
@@ -29,6 +35,8 @@ describe('SVG support', () => {
2935
expect(e0.querySelector('#e1')!.namespaceURI).toMatch('svg')
3036
expect(e0.querySelector('#e2')!.namespaceURI).toMatch('svg')
3137
expect(e0.querySelector('#e3')!.namespaceURI).toMatch('xhtml')
38+
expect(e0.querySelector('#e4')!.namespaceURI).toMatch('svg')
39+
expect(e0.querySelector('#e5')!.namespaceURI).toMatch('Math')
3240
})
3341

3442
test('should patch elements with correct namespaces', async () => {

‎scripts/setupVitest.ts

+2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { type SpyInstance } from 'vitest'
22

3+
vi.stubGlobal('MathMLElement', class MathMLElement {})
4+
35
expect.extend({
46
toHaveBeenWarned(received: string) {
57
asserted.add(received)

0 commit comments

Comments
 (0)
Please sign in to comment.