DEV Community

Cover image for Blazor Developer Tools v0.10: A Deep Dive into Framework-Level Integration
Joseph Gregory
Joseph Gregory

Posted on

Blazor Developer Tools v0.10: A Deep Dive into Framework-Level Integration

When I first released Blazor Developer Tools, I had a simple goal: give Blazor developers the same component inspection experience that React developers have enjoyed for years. React DevTools lets you see your component tree, inspect props in real-time, and understand your application's structure. Blazor had nothing equivalent.

The v0.9 release was a working MVP, but I always knew the architecture had fundamental limitations that would eventually hit a wall. After weeks of studying the Blazor source code and exploring dead ends, I've arrived at a completely new architecture for v0.10 that solves these problems at the framework level.

This post explains what I learned, the options I considered, and why the new approach works.

The Original Approach: Invisible Span Markers

The v0.9 architecture was clever but ultimately a workaround. Here's how it worked:

  1. Build-time transformation: An MSBuild task would scan your .razor files, create shadow copies, inject invisible <span> markers with data attributes containing component metadata in the show files and feed the shadow files down the normal razor pipeline
  2. Runtime detection: The browser extension would scan the DOM for these markers
  3. Tree reconstruction: The extension would rebuild the component hierarchy from the marker positions
<!-- What got injected -->
<span data-bdt-component="Counter" data-bdt-file="Pages/Counter.razor" style="display:none"></span>
Enter fullscreen mode Exit fullscreen mode

This worked for simple cases. You could see your component tree in DevTools. But several problems emerged.

Problem 1: Strict Parent Components

Some component libraries enforce what types of children they accept. MudBlazor's MudItem component, for example, validates its children and throws an exception if it encounters unexpected elements—like our invisible spans.

<!-- This would throw an exception -->
<MudGrid>
    <MudItem> <!-- MudItem only accepts specific children -->
        <span data-bdt-component="..."></span> <!-- 💥 Rejected -->
        <MyComponent />
    </MudItem>
</MudGrid>
Enter fullscreen mode Exit fullscreen mode

I added a workaround: a SkipComponents configuration that let users specify which components to exclude from marker injection. But this was a patch, not a solution. Users had to discover which components broke, then configure around them.

Problem 2: No Access to Runtime Data

The span markers contained static metadata captured at build time. There was no connection to the actual running component instances. This meant:

  • No live parameter values: You could see that a component had a Count parameter, but not its current value
  • No performance metrics: No way to track render counts or timing
  • No component state: The markers were dead HTML, disconnected from the living Blazor application

Problem 3: DOM Pollution

Injecting markers into the DOM, even invisible ones, felt wrong. It modified the application's output, potentially affecting CSS selectors, automated tests, or edge cases I hadn't considered.

I knew the architecture needed to change fundamentally. The question was: how?

Understanding the Core Problem

At its heart, I needed to answer two questions:

  1. Which component rendered this DOM element?
  2. What are the current parameter values for component X?

Blazor doesn't expose APIs to answer either question. The component tree exists in .NET memory, the DOM exists in the browser, and there's no public bridge between them.

How Blazor Actually Works

To find a solution, I needed to understand Blazor's rendering pipeline. Here's the simplified flow:

┌─────────────────────────────────────────────────────────────────┐
│ .NET SIDE                                                       │
│                                                                 │
│  Component Instance                                             │
│       │                                                         │
│       ▼                                                         │
│  BuildRenderTree() → RenderTreeFrames                           │
│       │                                                         │
│       ▼                                                         │
│  Renderer assigns componentId, diffs against previous tree      │
│       │                                                         │
│       ▼                                                         │
│  RenderBatch (binary format)                                    │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘
                              │
                              │ SignalR (Server) or 
                              │ Direct call (WebAssembly)
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│ BROWSER SIDE                                                    │
│                                                                 │
│  blazor.server.js / blazor.webassembly.js                       │
│       │                                                         │
│       ▼                                                         │
│  BrowserRenderer interprets RenderBatch                         │
│       │                                                         │
│       ▼                                                         │
│  DOM mutations applied                                          │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Key insight: Blazor maintains an internal mapping between componentId (an integer assigned to each component instance) and its DOM location. This mapping exists in the JavaScript runtime's BrowserRenderer, but it's private.

Every component gets a componentId when created. This ID flows through the render batch to JavaScript. If I could intercept component creation and correlate it with what JavaScript sees, I'd have my bridge.

The Options I Explored

I spent considerable time exploring approaches that ultimately didn't work. Here's what I learned.

Option 1: Intercept the MSBuild Pipeline

My first thought was to leverage the existing CSS isolation mechanism. Blazor already adds empty b-xxxxxxxxxx attributes to elements for scoped CSS. Could I piggyback on this?

The problem: the Razor compilation is a black box. You provide .razor files, MSBuild invokes the compiler, and you get compiled output. There's no hook to intercept the middle of this process and inject additional attributes. The CSS isolation attributes are added by the compiler itself, not by an extensible pipeline.

Option 2: Force a Base Class

What if I required all components to inherit from a BdtComponentBase instead of ComponentBase?

// User would have to change every component to this
public class Counter : BdtComponentBase // instead of ComponentBase
{
    // ...
}
Enter fullscreen mode Exit fullscreen mode

This fails for several reasons:

  • User friction: Requiring changes to every component is a non-starter for adoption
  • Inheritance conflicts: Many users already inherit from custom base classes in their projects, or from base classes provided by component libraries
  • Third-party components: You can't modify MudBlazor or Radzen components to inherit from your base class

Option 3: Source Generator Lifecycle Injection

A source generator could potentially inject registration code into lifecycle methods like OnInitialized:

// Generated code injected into component
protected override void OnInitialized()
{
    BdtRegistry.Register(this);
    base.OnInitialized();
}
Enter fullscreen mode Exit fullscreen mode

The problems multiply quickly:

  • Existing overrides: If the user already overrides OnInitialized, you have a conflict. Do you wrap theirs? What about the call order?
  • Partial classes: Razor components are already partial classes with generated code. Adding more generated lifecycle overrides creates complexity
  • Edge cases: What about components that override SetParametersAsync and never call base? What about components that don't inherit from ComponentBase at all?

Option 4: Fork the Blazor SDK

The nuclear option: create a modified Blazor SDK that includes tracking instrumentation.

This is technically possible but practically terrible:

  • Maintenance burden: You'd need to track every Blazor release and merge changes
  • User friction: Users would need to reference a custom SDK instead of the official one
  • Trust: Asking users to replace core framework components is a big ask

Option 5: Inherit from the Renderer

Blazor's Renderer class is where the magic happens. If I could subclass it and add tracking...

public class BdtRenderer : WebAssemblyRenderer // ❌ This doesn't work
{
    protected override void AddComponent(IComponent component)
    {
        // Track it here!
        base.AddComponent(component);
    }
}
Enter fullscreen mode Exit fullscreen mode

Unfortunately, you can't specify your own renderer. The renderer is instantiated internally by the framework. There's no services.AddSingleton<Renderer, MyRenderer>() pattern here. Looking at the source code, the constructors are public, but the actual instantiation is buried in infrastructure code that doesn't consult the DI container for the renderer itself.

Option 6: Metadata-Only Source Generator

What if I just generated static metadata at compile time—a registry mapping component types to their source files and parameter definitions?

// Generated at compile time
public static class BdtMetadata
{
    public static Dictionary<Type, ComponentInfo> Components = new()
    {
        [typeof(Counter)] = new ComponentInfo 
        { 
            SourceFile = "Pages/Counter.razor",
            Parameters = new[] { "IncrementAmount" }
        }
    };
}
Enter fullscreen mode Exit fullscreen mode

This gives you static information, but still doesn't solve the runtime problem. You'd know that Counter components exist and have an IncrementAmount parameter, but you couldn't answer "what's the current value of IncrementAmount for the Counter instance at DOM position X?"

The fundamental issue is componentId correlation. JavaScript sees componentId values in render batches. .NET has component instances. But connecting a specific componentId to its Type and live instance requires runtime tracking that this approach doesn't provide.

The Solution: IComponentActivator

Deep in the Blazor source code, I found a seam: IComponentActivator.

namespace Microsoft.AspNetCore.Components;

public interface IComponentActivator
{
    IComponent CreateInstance(Type componentType);
}
Enter fullscreen mode Exit fullscreen mode

This interface exists specifically to allow customization of component instantiation. When Blazor needs to create a component, it calls IComponentActivator.CreateInstance(). By default, it uses a DefaultComponentActivator that simply calls Activator.CreateInstance().

Here's the critical code from Renderer.cs:

private static IComponentActivator GetComponentActivatorOrDefault(IServiceProvider serviceProvider)
{
    return serviceProvider.GetService<IComponentActivator>()
        ?? DefaultComponentActivator.Instance;
}
Enter fullscreen mode Exit fullscreen mode

If you register an IComponentActivator in the DI container, Blazor will use it.

This is the open window I needed.

The componentId Timing Problem

There's the catch. When CreateInstance is called, the component doesn't have a componentId yet. The sequence is:

  1. Renderer calls IComponentActivator.CreateInstance(typeof(Counter))
  2. Activator creates and returns the instance
  3. Then the Renderer assigns a componentId and stores it in the component's RenderHandle

So in CreateInstance, I have the Type and the instance, but not the componentId. How do I correlate them later?

The Lazy Resolution Trick

Here's the insight: the componentId is stored in a private field (_renderHandle) on ComponentBase. Once it's assigned (which happens before OnInitialized), I can extract it via reflection:

private static int? ExtractComponentId(IComponent instance)
{
    if (instance is not ComponentBase componentBase)
        return null;

    var renderHandleField = typeof(ComponentBase)
        .GetField("_renderHandle", BindingFlags.NonPublic | BindingFlags.Instance);

    var renderHandle = renderHandleField?.GetValue(componentBase);
    if (renderHandle == null) return null;

    var componentIdProperty = typeof(RenderHandle)
        .GetProperty("ComponentId", BindingFlags.NonPublic | BindingFlags.Instance);

    return (int?)componentIdProperty?.GetValue(renderHandle);
}
Enter fullscreen mode Exit fullscreen mode

The strategy becomes:

  1. On creation: Track the instance in a "pending" collection (we have Type and instance, but no componentId)
  2. On query: When JavaScript asks about a componentId, scan pending instances, extract their componentIds via reflection, and find the match

This lazy resolution means we don't pay the reflection cost until someone actually queries for component information.

The Three-Pillar Architecture

The v0.10 architecture has three distinct layers that work together:

Pillar 1: Compile-Time Metadata (Source Generator)

A source generator scans all .razor files during build and generates a static registry:

// Generated code
public static class BdtComponentMetadata
{
    public static readonly Dictionary<Type, ComponentInfo> Registry = new()
    {
        [typeof(MyApp.Pages.Counter)] = new ComponentInfo
        {
            SourceFile = "Pages/Counter.razor",
            LineNumber = 1,
            Parameters = new[]
            {
                new ParameterInfo { Name = "IncrementAmount", TypeName = "int" }
            }
        },
        // ... all other components
    };
}
Enter fullscreen mode Exit fullscreen mode

This provides static metadata with zero runtime overhead for the scanning.

Pillar 2: Runtime Tracking (IComponentActivator + Registry)

The custom activator intercepts every component creation:

public class BdtComponentActivator : IComponentActivator
{
    private readonly BdtRegistry _registry;

    public IComponent CreateInstance(Type componentType)
    {
        var instance = Activator.CreateInstance(componentType) as IComponent;
        _registry.TrackPendingInstance(instance, componentType);
        return instance;
    }
}
Enter fullscreen mode Exit fullscreen mode

The registry maintains:

  • Pending instances: Components we've seen but haven't resolved componentIds for
  • Resolved instances: componentId → Type → WeakReference

When queried, it can provide live parameter values by reflecting over the actual component instance.

Pillar 3: Browser-Side Interception (JavaScript)

A JavaScript module (auto-loaded via Blazor's JavaScript initializer mechanism) intercepts render batches:

// BlazorDeveloperTools.lib.module.js
export function afterStarted(blazor) {
    const originalRenderBatch = Blazor._internal.renderBatch;

    Blazor._internal.renderBatch = function(batchId, batchData) {
        // See which componentIds were updated
        const updatedIds = parseRenderBatch(batchData);

        // Query .NET for component info
        for (const id of updatedIds) {
            const info = await DotNet.invokeMethodAsync(
                'BlazorDeveloperTools', 
                'GetComponentInfo', 
                id
            );
            // Send to DevTools panel
            notifyDevTools(info);
        }

        // Call original
        return originalRenderBatch.apply(this, arguments);
    };
}
Enter fullscreen mode Exit fullscreen mode

The JavaScript initializer mechanism (*.lib.module.js files in wwwroot) means this loads automatically—no script tags required from the user.

Why This Architecture Works

Requirement How It's Met
No user code changes ✓ Just add NuGet package and one service registration
Works with any component library ✓ No DOM injection, intercepts at framework level
Live parameter values ✓ Reflection on actual component instances
Component tree accuracy ✓ Every component flows through the activator
Performance metrics (future) ✓ Can track render timing via batch interception
Memory safe ✓ WeakReferences prevent memory leaks

What About Conflicts?

One concern: what if another library also registers an IComponentActivator?

In practice, this is extremely rare. The only library I'm aware of that uses this is bUnit (for testing). And if a conflict does occur, activators can be chained:

public static IServiceCollection AddBlazorDevTools(this IServiceCollection services)
{
    var existingActivator = services
        .FirstOrDefault(d => d.ServiceType == typeof(IComponentActivator));

    // Chain to existing if present
    services.AddSingleton<IComponentActivator>(sp => 
        new BdtComponentActivator(
            sp.GetRequiredService<BdtRegistry>(),
            existingActivator?.ImplementationInstance as IComponentActivator
        ));

    return services;
}
Enter fullscreen mode Exit fullscreen mode

The User Experience

For developers using Blazor Developer Tools v0.10, the experience is simple:

<PackageReference Include="BlazorDeveloperTools" Version="0.10.0" />
Enter fullscreen mode Exit fullscreen mode
// Program.cs
builder.Services.AddBlazorDevTools();
Enter fullscreen mode Exit fullscreen mode

That's it. No base classes. No MSBuild configuration. No skipping problematic components. It just works—with MudBlazor, Radzen, Syncfusion, or any other component library.

What's Next

The v0.10 architecture unlocks capabilities that were impossible with the span marker approach:

  • Live parameter values: See the actual current state of component parameters
  • Render tracking: Count how many times each component renders
  • Performance profiling: Measure render duration and identify bottlenecks
  • State snapshots: Capture component state at points in time
  • "Why did this render?": Trace what triggered a re-render

The marker approach could only ever show you the tree. This architecture gives us access to the living application.

Conclusion

Sometimes the right solution requires going deeper. The v0.9 span markers were a clever hack, but they fought against Blazor rather than working with it. The v0.10 architecture integrates at the framework level through an official extension point.

The lesson: when you hit a wall, study the source code. Somewhere in there, there might be a seam that the framework authors left open—intentionally or not.

Blazor Developer Tools v0.10 is currently in development. Follow the progress on GitHub or try the current v0.9 release while the new architecture takes shape.


Joseph Gregory is a .NET developer and creator of Blazor Developer Tools. Follow him on X for updates.

Top comments (0)