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:
-
Build-time transformation: An MSBuild task would scan your
.razorfiles, 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 - Runtime detection: The browser extension would scan the DOM for these markers
- 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>
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>
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
Countparameter, 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:
- Which component rendered this DOM element?
- 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 │
│ │
└─────────────────────────────────────────────────────────────────┘
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
{
// ...
}
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();
}
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
SetParametersAsyncand never call base? What about components that don't inherit fromComponentBaseat 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);
}
}
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" }
}
};
}
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);
}
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;
}
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:
- Renderer calls
IComponentActivator.CreateInstance(typeof(Counter)) - Activator creates and returns the instance
-
Then the Renderer assigns a
componentIdand stores it in the component'sRenderHandle
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);
}
The strategy becomes:
- On creation: Track the instance in a "pending" collection (we have Type and instance, but no componentId)
- 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
};
}
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;
}
}
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);
};
}
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;
}
The User Experience
For developers using Blazor Developer Tools v0.10, the experience is simple:
<PackageReference Include="BlazorDeveloperTools" Version="0.10.0" />
// Program.cs
builder.Services.AddBlazorDevTools();
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)