This post is about Blazor, the Microsoft web framework that allows you to write single-page apps in C# instead of JavaScript. They run either in the browser or on the server. Rendering is done to a virtual DOM. The changes to the virtual DOM are sent as a diff to the browser to make the changes to the real DOM.
A little more than a year ago while hanging around in the gitter.im chatroom for Blazor, somebody posted an interesting observation while adding rows to a table: every time a row is being added the size of the diff grows by a few hundred bytes. Ideally, when adding a row, only a diff for the new row should be sent. Whether you add the first or the 100th row, the size of the HTML for the row, hence the size of the diff should remain the same.
A minimal repro of a component that will trigger this issue is listed above. A repository containing a Blazor server app can be found here. Blazor server is ideal in this case because the diffs are sent to the browser using a web socket allowing for easy inspection. Inspecting the messages clearly shows their increasing size for every row added, indicating that some part of the component is re-sent, even though the HTML for the row to be added is identical.
Inspecting the messages themselves just seem to reveal them growing with a bunch of numbers, not HTML, which makes it all the more intriguing. One thing is certain: when the size of a diff for adding a row is depending on the total size of the table, you quickly end up with performance problems.
Hector drops a hint
Along comes a guy called Hector Montero who drops a huge hint. Rewriting the code above to the following code, makes the issue disappear:
Spot the difference? The anonymous lambda callback, which writes the index and new value to the console whenever the value of the input changes, is converted to a method on the Something
class. The event callback is directly bound to the WriteToConsole
method on an instance of Something. So what is going on? Is the anonymous lambda somehow triggering the increasing diff size? To find out we have to dive into Blazor, the mechanism that binds javascript event listeners to C# callbacks, and how this all interacts with anonymous lambdas. We will use ILSpy to decompile the code that the Razor compiler generates as a starting point for our quest.
How Blazor handles events
Remember that Blazor renders to a virtual DOM and that diffs travel to the browser to update the real DOM? Well, there is also a route back. Whenever an event occurs in the browser, Blazor will capture the essentials of the event and sends them to .NET for further processing by a C# event handler. The transport channel for Blazor Server apps is SignalR and the image below illustrates the process:
This continuous loop of event handling and rendering diffs is the essence of Blazor, whether it’s Blazor Server or Blazor WebAssembly. With this knowledge, let’s look at the decompiled code of the second component, the one that is functioning correctly:
Line 45 is where it gets interesting:
__builder.AddAttribute(9, "onchange", EventCallback.Factory.Create((object)this, (Action<ChangeEventArgs>)item.WriteToConsole));
This line of code will create an attribute in the virtual DOM for the Input
tag and it will tie it to item.WriteToConsole
. It does this by means of a delegate. A delegate is a variable that holds a reference to a method. When it’s an instance method, it also holds a reference to the instance the method can be called on, called the target. The target, in this case, is the Something
instance referenced by item
. The delegate type is Action<ChangeEventArgs>
which means that this delegate can only point to methods that accept ChangeEventArgs
as the first parameter and returns void
. Our WriteToConsole
method will do nicely. The delegate is wrapped in an EventCallBack structure by EventCallback.Factory.Create
, that will store an additional reference to the component, so Blazor will know what component to rerender in the future when this event is fired.
What does Blazor do with all these EventCallback structures to wire them to the event listeners that are tied to the real DOM elements? We cannot have the JavaScript event listener call into .NET code directly, as JavaScript does not support this, and in the case of Server Blazor, the browser and the server might be quite a distance apart. Blazor therefore simply maintains a list of delegates that can be used as an event listener and gives them a number. The number is communicated as the attribute value in the diff that will travel to the browser. When the tag, in this case Input
is rendered inside the browser, an event listener is attached that will send the same number back over the wire to .NET. Inside Blazor the right delegate is looked up by number and subsequently executed. Textbook RPC and a very clever way to tie JavaScript to .NET.
All delegates are equal – but some are less equal than others
Every time the component rerenders, the virtual DOM is compared to the previous one. Every event callback tied to an attribute of a tag is compared as well. When the attribute is still tied to the same event callback, all is well, and the RenderTreeDiffBuilder will not emit a frame (part of a diff) for the tag. I say the same EventCallback, but this is a loose interpretation. EventCallback is a struct and it is tested for equality. Equals() in turn, will test the two members of EventCallback
for equality as well:
internal readonly MulticastDelegate? Delegate;
internal readonly IHandleEvent? Receiver;
So two event callbacks are equal when the Delegate and Receiver are equal. The receiver in this case is the component, that will always be equal between two subsequent renders of that component, which leaves us with equality for the delegate. Microsoft has something to say about delegate equality and it is very similar to struct equality:
The methods and targets of a delegate are compared for equality as follows:
If the two methods being compared are both static and are the same method on the same class, the methods are considered equal and the targets are also considered equal.
If the two methods being compared are instance methods and are the same method on the same object, the methods are considered equal and the targets are also considered equal.
Otherwise, the methods are not considered to be equal and the targets are also not considered to be equal.
In our case above for all the existing rows in the table, the method is always WriteToConsole
and the item
is always the same as well. Only the added row will have a new attribute that will have to be communicated in the diff.
Now, let’s go over to the decompilation of the problematic code:
What is happening on lines 33-34? The compiler created an inner class called <>c__DisplayClass0_0
for the closure of our lambda. To make sure we all know what a closure is, here is the definition from Wikipedia:
In programming languages, a closure, also lexical closure or function closure, is a technique for implementing lexically scopedname binding in a language with first-class functions. Operationally, a closure is a record storing a function[a] together with an environment.[1] The environment is a mapping associating each free variable of the function (variables that are used locally but defined in an enclosing scope) with the value or reference to which the name was bound when the closure was created.[b] Unlike a plain function, a closure allows the function to access those captured variables through the closure’s copies of their values or references, even when the function is invoked outside their scope.
Remember our lambda inside the input tag? It captures the reference of free variable item
for future execution of our lambda outside the scope of the BuildRenderTree
method.
<input type="text" value="@item.Name" @onchange='(e) => Console.WriteLine($"{item.Index}: {e.Value}")' />
So how does that generated class actually look? Let’s examine it in further detail:
So the free item variable is stored as a public field, it has a name that is invalid in normal C# (only the compiler can generate it) and it has a [CompilerGenerated]
attribute. A lot of effort for our simple lambda.
If we turn our attention again to the previous listing we see that on line 42 a delegate is created again, similar as before, but this is where things go awry:
__builder.AddAttribute(9, "onchange", EventCallback.Factory.Create(this, new Action<ChangeEventArgs>(<>c__DisplayClass0_.<BuildRenderTree>b__0)));
Our delegate isn’t pointing to the same WriteToConsole
method on the same item
anymore, it points to a new instance of <>c__DisplayClass0_0
on every rerender for every existing row in the table. Remember that Blazor maintains a list of delegates that are used to bind event listeners? If Blazor compares delegates using the Equals method they won’t be the same, because the target is a new instance of our closure class every time.
A quick look in RenderTreeDiffBuilder confirms our suspicion:
The simple Equals
comparison here will assume that this is a new delegate, and will replace the old ID with a new ID in the diff. At this point, I felt that the cause was clear and that a workaround was available as well (don’t use lambdas that capture free variables as event callbacks) and I left it at that. Periodically people contact me about it though, and recently it popped up in a discussion about performance again. Stefan Lörwald suggested that the issue might be more widespread and would extend to two-way-binding as well.
This piqued my interest and warrants a new look at the issue again, which we will do in part 2 of this series.