From 1177a21c86c6c7e82fd4acc59c0ad0b2a068843e Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Wed, 3 Dec 2025 15:04:25 +0100 Subject: [PATCH] Add MathML namespace support for interactive rendering Fixes #60064 MathML elements were not rendering correctly when added with interactive content because they were being created using document.createElement() instead of document.createElementNS() with the MathML namespace. Changes: - Add isMathMLElement() helper function in LogicalElements.ts - Update insertElement() to use createElementNS for MathML elements - Update parseMarkup() to handle MathML content - Add E2E tests for MathML rendering --- .../Web.JS/src/Rendering/BrowserRenderer.ts | 21 +++- .../Web.JS/src/Rendering/LogicalElements.ts | 8 ++ .../test/E2ETest/Tests/MathMLTest.cs | 117 ++++++++++++++++++ .../test/testassets/BasicTestApp/Index.razor | 1 + .../BasicTestApp/MathMLComponent.razor | 83 +++++++++++++ .../BasicTestApp/MathMLRowComponent.razor | 7 ++ 6 files changed, 231 insertions(+), 6 deletions(-) create mode 100644 src/Components/test/E2ETest/Tests/MathMLTest.cs create mode 100644 src/Components/test/testassets/BasicTestApp/MathMLComponent.razor create mode 100644 src/Components/test/testassets/BasicTestApp/MathMLRowComponent.razor diff --git a/src/Components/Web.JS/src/Rendering/BrowserRenderer.ts b/src/Components/Web.JS/src/Rendering/BrowserRenderer.ts index 6cd526a49e61..08cfd8f86608 100644 --- a/src/Components/Web.JS/src/Rendering/BrowserRenderer.ts +++ b/src/Components/Web.JS/src/Rendering/BrowserRenderer.ts @@ -3,12 +3,13 @@ import { RenderBatch, ArrayBuilderSegment, RenderTreeEdit, RenderTreeFrame, EditType, FrameType, ArrayValues } from './RenderBatch/RenderBatch'; import { EventDelegator } from './Events/EventDelegator'; -import { LogicalElement, PermutationListEntry, toLogicalElement, insertLogicalChild, removeLogicalChild, getLogicalParent, getLogicalChild, createAndInsertLogicalContainer, isSvgElement, permuteLogicalChildren, getClosestDomElement, emptyLogicalElement, getLogicalChildrenArray, depthFirstNodeTreeTraversal } from './LogicalElements'; +import { LogicalElement, PermutationListEntry, toLogicalElement, insertLogicalChild, removeLogicalChild, getLogicalParent, getLogicalChild, createAndInsertLogicalContainer, isSvgElement, isMathMLElement, permuteLogicalChildren, getClosestDomElement, emptyLogicalElement, getLogicalChildrenArray, depthFirstNodeTreeTraversal } from './LogicalElements'; import { applyCaptureIdToElement } from './ElementReferenceCapture'; import { attachToEventDelegator as attachNavigationManagerToEventDelegator } from '../Services/NavigationManager'; import { applyAnyDeferredValue, tryApplySpecialProperty } from './DomSpecialPropertyUtil'; const sharedTemplateElemForParsing = document.createElement('template'); const sharedSvgElemForParsing = document.createElementNS('http://www.w3.org/2000/svg', 'g'); +const sharedMathMLElemForParsing = document.createElementNS('http://www.w3.org/1998/Math/MathML', 'mrow'); const elementsToClearOnRootComponentRender = new Set(); const internalAttributeNamePrefix = '__internal_'; const eventPreventDefaultAttributeNamePrefix = 'preventDefault_'; @@ -266,9 +267,14 @@ export class BrowserRenderer { const frameReader = batch.frameReader; const tagName = frameReader.elementName(frame)!; - const newDomElementRaw = (tagName === 'svg' || isSvgElement(parent)) ? - document.createElementNS('http://www.w3.org/2000/svg', tagName) : - document.createElement(tagName); + let newDomElementRaw: Element; + if (tagName === 'svg' || isSvgElement(parent)) { + newDomElementRaw = document.createElementNS('http://www.w3.org/2000/svg', tagName); + } else if (tagName === 'math' || isMathMLElement(parent)) { + newDomElementRaw = document.createElementNS('http://www.w3.org/1998/Math/MathML', tagName); + } else { + newDomElementRaw = document.createElement(tagName); + } const newElement = toLogicalElement(newDomElementRaw); let inserted = false; @@ -316,7 +322,7 @@ export class BrowserRenderer { const markupContainer = createAndInsertLogicalContainer(parent, childIndex); const markupContent = batch.frameReader.markupContent(markupFrame); - const parsedMarkup = parseMarkup(markupContent, isSvgElement(parent)); + const parsedMarkup = parseMarkup(markupContent, isSvgElement(parent), isMathMLElement(parent)); let logicalSiblingIndex = 0; while (parsedMarkup.firstChild) { insertLogicalChild(parsedMarkup.firstChild, markupContainer, logicalSiblingIndex++); @@ -410,10 +416,13 @@ export interface ComponentDescriptor { end: Node; } -function parseMarkup(markup: string, isSvg: boolean) { +function parseMarkup(markup: string, isSvg: boolean, isMathML: boolean) { if (isSvg) { sharedSvgElemForParsing.innerHTML = markup || ' '; return sharedSvgElemForParsing; + } else if (isMathML) { + sharedMathMLElemForParsing.innerHTML = markup || ' '; + return sharedMathMLElemForParsing; } else { sharedTemplateElemForParsing.innerHTML = markup || ' '; diff --git a/src/Components/Web.JS/src/Rendering/LogicalElements.ts b/src/Components/Web.JS/src/Rendering/LogicalElements.ts index 4059a05488bd..cd71867412b9 100644 --- a/src/Components/Web.JS/src/Rendering/LogicalElements.ts +++ b/src/Components/Web.JS/src/Rendering/LogicalElements.ts @@ -242,6 +242,14 @@ export function isSvgElement(element: LogicalElement): boolean { return closestElement.namespaceURI === 'http://www.w3.org/2000/svg' && closestElement['tagName'] !== 'foreignObject'; } +// MathML elements need to be created with the MathML namespace to render correctly. +// Similar to SVG, MathML has its own namespace (http://www.w3.org/1998/Math/MathML) +// and elements created without this namespace will not render properly in browsers. +export function isMathMLElement(element: LogicalElement): boolean { + const closestElement = getClosestDomElement(element) as any; + return closestElement.namespaceURI === 'http://www.w3.org/1998/Math/MathML'; +} + export function getLogicalChildrenArray(element: LogicalElement): LogicalElement[] { return element[logicalChildrenPropname] as LogicalElement[]; } diff --git a/src/Components/test/E2ETest/Tests/MathMLTest.cs b/src/Components/test/E2ETest/Tests/MathMLTest.cs new file mode 100644 index 000000000000..c0ff61610001 --- /dev/null +++ b/src/Components/test/E2ETest/Tests/MathMLTest.cs @@ -0,0 +1,117 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using BasicTestApp; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; +using Microsoft.AspNetCore.E2ETesting; +using OpenQA.Selenium; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Components.E2ETest.Tests; + +public class MathMLTest : ServerTestBase> +{ + public MathMLTest( + BrowserFixture browserFixture, + ToggleExecutionModeServerFixture serverFixture, + ITestOutputHelper output) + : base(browserFixture, serverFixture, output) + { + } + + protected override void InitializeAsyncCore() + { + Navigate(ServerPathBase); + } + + [Fact] + public void CanRenderMathMLWithCorrectNamespace() + { + var appElement = Browser.MountTestComponent(); + + var mathElement = appElement.FindElement(By.Id("mathml-with-callback")); + Assert.NotNull(mathElement); + + // Verify the math element has the correct MathML namespace + var mathMrowElement = mathElement.FindElement(By.XPath(".//*[local-name()='mrow' and namespace-uri()='http://www.w3.org/1998/Math/MathML']")); + Assert.NotNull(mathMrowElement); + + // Verify child elements also have the correct namespace + var mathMnElement = mathElement.FindElement(By.XPath(".//*[local-name()='mn' and namespace-uri()='http://www.w3.org/1998/Math/MathML']")); + Assert.NotNull(mathMnElement); + Assert.Equal("10", mathMnElement.Text); + + // Click button to update and verify the value changes while maintaining correct namespace + appElement.FindElement(By.Id("increment-btn")).Click(); + Browser.Equal("11", () => mathMnElement.Text); + } + + [Fact] + public void CanRenderStaticMathMLWithCorrectNamespace() + { + var appElement = Browser.MountTestComponent(); + + var mathElement = appElement.FindElement(By.Id("mathml-static")); + Assert.NotNull(mathElement); + + // Verify msup elements have the correct namespace + var msupElements = mathElement.FindElements(By.XPath(".//*[local-name()='msup' and namespace-uri()='http://www.w3.org/1998/Math/MathML']")); + Assert.Equal(3, msupElements.Count); + } + + [Fact] + public void CanRenderMathMLChildComponentWithCorrectNamespace() + { + var appElement = Browser.MountTestComponent(); + + var mathElement = appElement.FindElement(By.Id("mathml-with-child-component")); + Assert.NotNull(mathElement); + + // The child component should render mrow with correct namespace + var mathMrowElement = mathElement.FindElement(By.XPath(".//*[local-name()='mrow' and namespace-uri()='http://www.w3.org/1998/Math/MathML']")); + Assert.NotNull(mathMrowElement); + + // Verify mi element from child component + var mathMiElement = mathElement.FindElement(By.XPath(".//*[local-name()='mi' and namespace-uri()='http://www.w3.org/1998/Math/MathML']")); + Assert.NotNull(mathMiElement); + Assert.Equal("z", mathMiElement.Text); + } + + [Fact] + public void CanRenderConditionalMathMLWithCorrectNamespace() + { + var appElement = Browser.MountTestComponent(); + + // Initially the conditional MathML should not be present + var conditionalMath = appElement.FindElements(By.Id("mathml-conditional")); + Assert.Empty(conditionalMath); + + // Click toggle to show the conditional MathML + appElement.FindElement(By.Id("toggle-btn")).Click(); + + // Now the MathML should be present with correct namespace + Browser.Exists(By.Id("mathml-conditional")); + var mathElement = appElement.FindElement(By.Id("mathml-conditional")); + + var mathMrowElement = mathElement.FindElement(By.XPath(".//*[local-name()='mrow' and namespace-uri()='http://www.w3.org/1998/Math/MathML']")); + Assert.NotNull(mathMrowElement); + } + + [Fact] + public void CanRenderComplexMathMLWithCorrectNamespace() + { + var appElement = Browser.MountTestComponent(); + + var mathElement = appElement.FindElement(By.Id("mathml-complex")); + Assert.NotNull(mathElement); + + // Verify mfrac element has the correct namespace + var mfracElement = mathElement.FindElement(By.XPath(".//*[local-name()='mfrac' and namespace-uri()='http://www.w3.org/1998/Math/MathML']")); + Assert.NotNull(mfracElement); + + // Verify msqrt element has the correct namespace + var msqrtElement = mathElement.FindElement(By.XPath(".//*[local-name()='msqrt' and namespace-uri()='http://www.w3.org/1998/Math/MathML']")); + Assert.NotNull(msqrtElement); + } +} diff --git a/src/Components/test/testassets/BasicTestApp/Index.razor b/src/Components/test/testassets/BasicTestApp/Index.razor index f9cc718f7ca1..9cc2c8753b05 100644 --- a/src/Components/test/testassets/BasicTestApp/Index.razor +++ b/src/Components/test/testassets/BasicTestApp/Index.razor @@ -114,6 +114,7 @@ + diff --git a/src/Components/test/testassets/BasicTestApp/MathMLComponent.razor b/src/Components/test/testassets/BasicTestApp/MathMLComponent.razor new file mode 100644 index 000000000000..e1d6d01fc122 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/MathMLComponent.razor @@ -0,0 +1,83 @@ +@* Test component for MathML rendering with correct namespace *@ + +

MathML with Interactive Content

+ + + x + = + @value + + + + + +

Static MathML

+ + + + a + 2 + + + + + b + 2 + + = + + c + 2 + + + + +

MathML with Child Component

+ + + + +

Conditionally Rendered MathML

+ +@if (showConditional) +{ + + + y + = + 42 + + +} + +

Complex MathML (Quadratic Formula)

+ + + x + = + + + - + b + ± + + + b2 + - + 4 + a + c + + + + + 2 + a + + + + + +@code { + int value = 10; + bool showConditional = false; +} diff --git a/src/Components/test/testassets/BasicTestApp/MathMLRowComponent.razor b/src/Components/test/testassets/BasicTestApp/MathMLRowComponent.razor new file mode 100644 index 000000000000..36fff34a1ca7 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/MathMLRowComponent.razor @@ -0,0 +1,7 @@ +@* Child component for MathML that renders a simple row *@ + + + z + = + 100 +