DEV Community

Ivan Rossouw
Ivan Rossouw

Posted on

When a Blazor Component Quietly Becomes HTML

A green build usually gives us useful confidence: the types line up, required references exist, and the compiler understood the program we intended to write.

Razor has an awkward edge case. Markup can look exactly like a component invocation while the compiler treats it as an ordinary, unknown HTML element. The project may still build. The page may still return a successful response. The component implementation never runs.

A recent committed change offered a compact example. A shared session control had been placed on several protected screens. It was meant to provide a visible identity cue and a way to leave the session. The markup looked correct, but the namespace containing the component was absent from the applicable Razor imports. The compiler emitted an unexpected-element warning rather than stopping the build. Existing tests did not assert the control's rendered behaviour, so they stayed uninformative.

The result was not a dramatic exception. It was quieter: the shared control simply was not there.

Component-shaped syntax is not component resolution

Suppose a page contains this generalized markup:

<SessionToolbar />
Enter fullscreen mode Exit fullscreen mode

Humans read the PascalCase name and infer a Blazor component. Razor still has to resolve that name in the namespaces available to the file. If the component lives elsewhere and the relevant @using is missing, the tag can be interpreted as markup instead of a component invocation.

That distinction changes everything. The component's render tree, event handlers, injected services, and child content never participate. Depending on the render mode and browser, an inert custom-looking element may exist in the output, but the intended user interface does not.

The normal fix is deliberately boring:

@using Example.App.Components.Shared
Enter fullscreen mode Exit fullscreen mode

Put the import in the closest _Imports.razor that genuinely owns the shared convention. Too narrow, and sibling pages drift. Too broad, and unrelated feature areas gain names they do not own. The namespace boundary should mirror the component's intended reuse boundary.

Why a successful response proves too little

Many web integration tests stop here:

var response = await client.GetAsync("/protected-area");
response.EnsureSuccessStatusCode();
Enter fullscreen mode Exit fullscreen mode

That proves the request completed successfully. It does not prove that the important shared control resolved, rendered, or supports its workflow.

For a navigation, consent, recovery, or session control, assert the affordance a user actually needs:

var html = await response.Content.ReadAsStringAsync();
Assert.Contains("Sign out", html, StringComparison.OrdinalIgnoreCase);
Enter fullscreen mode Exit fullscreen mode

The literal assertion is intentionally generalized here. In a real suite, prefer a stable accessible name, semantic role, test identifier, or component-level rendering assertion. The point is to test the output contract, not an implementation detail such as a private class name.

Choose the test layer that matches the risk:

  • a component test can prove conditional rendering and parameters quickly;
  • a server rendering test can prove routing, imports, layouts, and authorization context work together;
  • a browser test can prove hydration and the final interaction.

You do not need every layer for every component. A shared control that appears across multiple workflow-critical screens deserves more than a status-code assertion.

Turn a warning into a useful boundary

The compiler did provide evidence: an unexpected-element diagnostic. The problem was governance, not total silence.

One option is to elevate that specific Razor diagnostic in the project that should contain only known components and standard HTML:

<PropertyGroup>
  <WarningsAsErrors>$(WarningsAsErrors);RZ10012</WarningsAsErrors>
</PropertyGroup>
Enter fullscreen mode Exit fullscreen mode

Do this selectively. Some applications intentionally use web components or custom elements that Razor cannot resolve as Blazor components. Treating every occurrence as fatal in those projects would turn a valuable signal into noise. The right scope might be one application project, one CI configuration, or a warning policy paired with explicit suppressions for intentional custom elements.

The principle is broader than one diagnostic: warnings that mean "the framework may not be executing the code you think it is" deserve different treatment from cosmetic warnings.

The engineering trade-off

Strict diagnostics improve detection but add upgrade and integration friction. Rendered tests catch composition failures but require fixtures, authentication setup, and more execution time. Broad route coverage can also become brittle if every page asserts identical markup independently.

A balanced pattern is:

  1. centralize the namespace at the correct reuse boundary;
  2. elevate the unexpected-element warning where custom elements are not expected;
  3. render one representative route for each layout or policy family;
  4. assert the user-visible action supplied by the shared component; and
  5. keep one browser test for interaction when hydration or client-side state matters.

Representative tests are not proof of universal coverage. A stronger suite either discovers applicable routes from metadata or makes the shared component part of a layout that is itself tested. Be explicit about which claim the test supports.

Practical takeaway

During review, do not stop at "the tag is present in the Razor file." Check that the namespace is in scope, take the diagnostic seriously, and ask for one rendered assertion at the level where routing and composition meet.

A 200 response can prove that a page returned. It cannot prove that the user can see the control needed to finish the workflow.

The next time a shared Blazor component is introduced, ask a sharper question: what test would fail if Razor quietly treated this tag as HTML?

Top comments (0)