Skip to content

Attribute names that a browser rejects with InvalidCharacterError render fine in bUnit, so the test passes #1898

Description

@hediasjarno

Describe the bug

bUnit applies a render tree to its AngleSharp DOM by serializing it to an HTML string and parsing
that string, so nothing on that path ever validates an attribute name. The HTML5 parser is
correctly permissive, and the result is that any RenderTreeFrameType.Attribute frame renders
successfully in bUnit — including names the browser's Element.setAttribute rejects with
InvalidCharacterError. The component renders green in the test suite and throws on first paint
in a browser.

Reproduction steps:

  1. Render a component with an attribute name that is not a valid XML Name (either through the
    Razor compiler as shown below, or directly with RenderTreeBuilder.AddAttribute).
  2. Assert anything you like about the result — the render succeeds.
  3. Load the same component in a browser — Blazor's renderer calls
    element.setAttribute(name, value) and the render throws.

How we hit it in production: a Razor comment placed between two attributes of a component tag is
emitted by the Razor compiler as a parameter name
(dotnet/razor#9590, open since 2019, fixed
2026-07-10 by dotnet/roslyn#84276, and still
present in every .NET 10 SDK up to 10.0.400). A component that captures unmatched values splats
it onto a real element and the page fails to render. Our 226-test bUnit suite, including tests
that rendered exactly that component, stayed green. A human loading the page found it.

The Razor bug is upstream and now fixed, but the blind spot is not tied to it — any render tree
carrying an invalid attribute name has the same problem, as the RenderTreeBuilder test below
shows.

Example:
Testing this component:

@* SplatTarget.razor — the MudComponentBase pattern: capture unmatched values, splat them *@
<div class="splat-target" @attributes="UserAttributes">@ChildContent</div>

@code {
    [Parameter(CaptureUnmatchedValues = true)]
    public Dictionary<string, object?> UserAttributes { get; set; } = new();

    [Parameter]
    public RenderFragment? ChildContent { get; set; }
}
@* ComponentUnderTest.razor — a Razor comment between two attributes of a component tag *@
<SplatTarget Dense="true"
             @* this comment is not stripped here *@
             RowClass="position-relative">component</SplatTarget>

With this test:

public sealed class InvalidAttributeNameTests : BunitContext
{
    private const string RazorComment = "@* this comment is not stripped here *@";

    // Passes. The rendered markup carries an attribute name no browser will accept.
    [Fact]
    public void Component_with_invalid_attribute_name_renders()
    {
        var cut = Render<ComponentUnderTest>();

        Assert.NotNull(cut.Find(".splat-target"));
    }

    // Passes too, and needs no compiler bug at all — three lines of RenderTreeBuilder.
    [Fact]
    public void Render_tree_with_invalid_attribute_name_renders()
    {
        var cut = Render(builder =>
        {
            builder.OpenElement(0, "div");
            builder.AddAttribute(1, "data-dense", "true");
            builder.AddAttribute(2, RazorComment, "true");
            builder.AddAttribute(3, "data-row", "position-relative");
            builder.CloseElement();
        });

        Assert.NotNull(cut.Find("div"));
    }

    // The contrast: the very same AngleSharp document rejects the very same name when it is set
    // through the DOM API — the API the browser uses. bUnit never goes through it.
    [Fact]
    public void AngleSharp_dom_api_rejects_the_name_bUnit_accepted()
    {
        var cut = Render(builder =>
        {
            builder.OpenElement(0, "div");
            builder.AddAttribute(1, RazorComment, "true");
            builder.CloseElement();
        });

        var element = cut.Find("div").Owner!.CreateElement("div");

        var ex = Assert.Throws<DomException>(() => element.SetAttribute(RazorComment, "true"));

        Assert.Equal("InvalidCharacter", ex.Name);
        Assert.Equal(5, ex.Code);
    }
}

Results in this output:

All three tests pass.

cut.Markup for the component test:
  <div class="splat-target" Dense="true" @* this comment is not stripped here *@ RowClass="position-relative">component</div>

The attributes AngleSharp's parser produced from that markup — one invalid name silently
became nine valid ones, which is why a rendered-DOM dump looks harmless:
  [class]="splat-target" [dense]="true" [@*] [this] [comment] [is] [not] [stripped] [here]
  [*@] [rowclass]="position-relative"

The DOM API, on that same document:
  element.SetAttribute("@* this comment is not stripped here *@", "true")
    -> DomException: Name = InvalidCharacter, Code = 5

What the same component does in a browser (Blazor WebAssembly):
  InvalidCharacterError: Failed to execute 'setAttribute' on 'Element':
  '@* this comment is not stripped here *@' is not a valid attribute name.

Expected behavior:

Rendering an attribute name that is not a valid XML Name should fail the test with a clear
message, the way it fails in a browser — for example
'@* this comment is not stripped here *@' is not a valid attribute name. Today it silently
produces a DOM that no browser can produce, and no assertion can see the difference.

Version info:

  • bUnit version: 2.9.0 (AngleSharp 1.7.0). The same code path exists in v1.x — see
    src/bunit.web/Rendering/Internal/Htmlizer.cs at v1.40.0.
  • .NET Runtime and Blazor version: net10.0, Microsoft.AspNetCore.Components 10.0.10, Blazor
    WebAssembly. Reproduced with the Razor compiler from SDK 10.0.204, 10.0.400 and
    11.0.100-preview.3; SDK 11.0.100-preview.7 no longer emits the bad name, but the
    RenderTreeBuilder test above still passes there.
  • OS type and version: Windows 11 (24H2, x64)

Additional context:

Where the validation is skipped

  • Htmlizer.RenderAttributes appends frame.AttributeName straight into the StringBuilder
    (src/bunit/Rendering/Internal/Htmlizer.cs, three result.Append(frame.AttributeName) sites) —
    no validation, no escaping.
  • RenderedComponent.Nodes feeds that string to BunitHtmlParser.Parse, which calls
    htmlParser.ParseFragment(markup, ctx) (src/bunit/Rendering/BunitHtmlParser.cs).
  • A search of bUnit's src/ for SetAttribute, CreateAttribute, SetNamedItem and
    SetOwnAttribute returns no hits, so AngleSharp's DOM-level validation is unreachable from a
    render.

Why AngleSharp will not surface it for you

AngleSharp is conformant on both paths, so this cannot be fixed upstream:

  • DOM path — Element.SetAttribute throws exactly as the DOM Standard requires ("If
    qualifiedName does not match the Name production in XML, then throw an InvalidCharacterError"):
    if (!name.IsXmlName()) throw new DomException(DomError.InvalidCharacter);.
  • Parser path — the HTML Standard's attribute-name state treats only ", ' and < as parse
    errors inside a name, so @ and * are unremarkable. Subscribing to HtmlParser.Error yields
    no errors for this markup, and IsStrictMode = true parses it without complaint.

Suggested fix

Validate where the browser validates — when an attribute frame becomes markup. AngleSharp
already exposes the predicate as public API, so no new code is needed:

// Htmlizer.RenderAttributes, before appending the name
if (!frame.AttributeName.IsXmlName()) // AngleSharp.Text.XmlExtensions
{
    throw new InvalidAttributeNameException(frame.AttributeName, elementName);
}

Two details worth deciding:

  • bUnit's own attributes stay valid. blazor:onclick, blazor:elementReference and CSS scope
    attributes (b-xxxxxxxxxx) are all valid XML Names — a colon is a legal NameChar — so this
    check does not fire on synthetic attributes. Verified against a component rendering event
    handlers.
  • Markup frames are a different case. When every attribute on an element is static, Razor
    collapses it into a single AddMarkupContent frame, and the browser applies that through
    innerHTML on a <template> (BrowserRenderer.ts, parseMarkup) — the same permissive parser.
    Those frames should keep today's behaviour; only RenderTreeFrameType.Attribute frames go
    through element.setAttribute(name, valueOrNullToRemove) in the browser.

On the breaking-change risk

Htmlizer descends from Microsoft's StaticHtmlRenderer, which also writes attribute names raw,
so today's behaviour does faithfully match Blazor's static SSR path. The argument for
changing it is that bUnit models interactive rendering — it dispatches events, tracks handler
ids, drives re-render cycles — and the interactive renderer is the one that calls setAttribute.
If a hard throw is judged too breaking, the same check as an opt-in switch (defaulting to on in
the next major) would still close the gap. Worth noting that any suite this newly fails is a suite
whose component crashes in a real browser.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions