Skip to content

Instantly share code, notes, and snippets.

@SteveSandersonMS
Last active August 14, 2024 06:11
Show Gist options
  • Save SteveSandersonMS/ec232992c2446ab9a0059dd0fbc5d0c3 to your computer and use it in GitHub Desktop.
Save SteveSandersonMS/ec232992c2446ab9a0059dd0fbc5d0c3 to your computer and use it in GitHub Desktop.
Why sequence numbers should relate to code line numbers, not execution order

Why sequence numbers should relate to code line numbers, not execution order

Or in other words, why you should hard-code sequence numbers, and not generate them programmatically.

Unlike .jsx files, .razor/.cshtml files are always compiled. This is potentially a great advantage for .razor, because we can use the compile step to inject information that makes things better or faster at runtime.

A key example of this are sequence numbers. These indicate to the runtime which outputs came from which distinct and ordered lines of code. The runtime uses this information to generate efficient tree diffs in linear time, which is far faster than is normally possible for a general tree diff algorithm.

Example

Consider the following simple .razor file:

@if (someFlag)
{
    <text>First</text>
}
Second

This compiles to something like the following:

if (someFlag)
{
    builder.AddContent(0, "First");
}
builder.AddContent(1, "Second");

When this executes for the first time, if someFlag == true, the builder receives:

Sequence Type Data
0 Text node First
1 Text node Second

Now imagine that someFlag becomes false, and we render again. This time the builder receives:

Sequence Type Data
1 Text node Second

When it performs a diff, it sees that the item at sequence 0 was removed, so it generates the following trivial edit script:

  • Remove the first text node

What goes wrong if you generate sequence numbers programmatically

Imagine instead that you wrote the following rendertree builder logic:

var seq = 0;

if (someFlag)
{
    builder.AddContent(seq++, "First");
}
builder.AddContent(seq++, "Second");

Now the first output would be:

Sequence Type Data
0 Text node First
1 Text node Second

... in other words, identical to before, so no issues yet. But then on the second render when someFlag==false, the output would be:

Sequence Type Data
0 Text node Second

This time, the diff algorithm will see that two changes have occurred, and will generate the following edit script:

  • Change the value of the first text node to Second
  • Remove the second text node

Generating the sequence numbers has lost all the useful information about where the if/else branches and loops were present in the original code, and has resulted in a diff that is now twice as long.

This is a very trivial example. In more realistic cases with complex and deeply nested structures, and especially with loops, the performance cost is more severe still. Instead of immediately identifying which loop blocks or branches have been inserted/removed, the diff algorithm would have to recurse deeply into your trees and would build far longer edit scripts, because you're misleading it about how the old and new structures relate to each other.

Questions

  • Q: Despite this, I still want to generate the sequence numbers dynamically. Can I?
    • A: You can, but it will make your app performance worse.
  • Q: Couldn't the framework make up its own sequence numbers automatically at runtime?
    • A: No. The necessary information doesn't exist unless it is captured at compile time. Please see the example above.
  • Q: I find it impractical to hard-code sequence numbers in really long blocks of manually-implemented RenderTreeBuilder logic. What should I do?
    • A: Don't write really long blocks of manually-implemented RenderTreeBuilder logic. Preferably use .razor files and let the compiler deal with this for you, or if you can't, split up your code into smaller pieces wrapped in OpenRegion/CloseRegion calls. Each region has its own separate space of sequence numbers, so you can restart from zero (or any other arbitrary number) inside each region if you want.
  • Q: OK, so I'm going to hardcode the sequence numbers. What actual numeric values should I use?
    • A: The diffing algorithm only cares that they should be an increasing sequence. It doesn't matter what initial value they start from, or whether there are gaps. One legitimate option would be to use the code line number as the sequence number. Or start from zero and increase by ones or hundreds or whatever interval you like.
  • Q: Why does Razor Components use sequence numbers, when other tree-diffing UI frameworks don't?
    • A: Because it makes diffing far faster, and Razor Components has the advantage of a compile step that deals with this automatically for people authoring .razor/.cshtml files.
@legistek
Copy link

legistek commented Sep 12, 2019

By the way, here's a project file that demonstrates the issue.

https://www.dropbox.com/s/kficzqanv92td99/ItemsCollectionExample.zip?dl=1

The Counter page is where things are happening.

As you can see what I do in the PersonComponent class is try to be efficient with rendering by overriding ShouldRender to only re-render when Person has changed.

When you press the various buttons you can see how many renders and calls to ShouldRender have occurred. When appending the components correctly get re-used, but when inserting or deleting at the beginning, not surprisingly every component gets a new Person assigned based on their position in the list.

UPDATE -
Well I answered my own question. It looks like the secret is to use the @key attribute. You guys really thought of everything didn't you? :)

@fenati
Copy link

fenati commented Sep 13, 2019

While I was reading this, I got an idea.

Couldn't we just use the CallerLineNumberAttribute to generate the sequence numbers automatically?
For example, if I'd create an extension method like this, i think we could avoid hardcoding the sequence numbers:

public static void AddContent(this RenderTreeBuilder builder, string textContent, [CallerLineNumber] int sequence = 0)
{
  builder.AddContent(sequence, textContent);
}

With such extension method, I could write this:

builder.AddContent("anything");

In this case, the sequence number would be the caller's line number automatically.

Some things to keep in mind with this technique:

  • Do not invoke multiple extension methods in the same line - though I would avoid that anyway to keep good readability.
  • If the RenderTreeBuilder instance is used in several methods, then the order of method definitions is important. The "root" method should be defined first and the called methods afterwards.

@SteveSandersonMS
Copy link
Author

SteveSandersonMS commented Sep 13, 2019

@legistek There's a feature for your exact scenario: @key. This tells the diff algorithm how to map the old items to the new ones. https://docs.microsoft.com/en-us/aspnet/core/blazor/components?view=aspnetcore-3.0#use-key-to-control-the-preservation-of-elements-and-components

@fenati It's a good idea - one we considered before but ultimately decided against: dotnet/aspnetcore#10890

@legistek
Copy link

Thanks @SteveSandersonMS! Works like a charm. One word to the wise though, is that I was experimenting with OpenRegion and CloseRegion and those seemed to mess it up to the point of NO components in the region getting re-used at all. But using SetKey with correct numbering works perfectly.

@Joebeazelman
Copy link

Hmmm. From what I gather here, the sequence number isn't sequential, but rather a unique ID. If that's the case, shouldn't be named LineID or DiffLineID? Assuming it is so, is there a reason why RenderTreeBuilder can't be passed in a LineID from the source file? There must be a compiler preprocessor directive to capture the current source line such as #line?

@SteveSandersonMS
Copy link
Author

@Joebeazelman That was considered and decided against - see dotnet/aspnetcore#10890 (comment) for reasoning

@3GDXC
Copy link

3GDXC commented Feb 17, 2020

@SteveSanderson wouldn't it be better to have something like an element-hash with a key that represents hierarchical/organisational structure of the DOM; in current state (rendered) and update (pre-rendered) state?

@gingters
Copy link

gingters commented Mar 3, 2022

Coming a bit late to the party, but I have a question @SteveSandersonMS in regards to how this works in loops.
When I look into the generated/compiled BuildRenderTree method from a razor file, then I see that the loop re-uses the same sequence number. Example here:

foreach (PokemonModel pokemon in availablePokemon)
{
	__builder2.OpenComponent<MudItem>(23);
	__builder2.AddAttribute(24, "xs", RuntimeHelpers.TypeCheck(3));
	__builder2.AddAttribute(25, "ChildContent", (RenderFragment)delegate(RenderTreeBuilder __builder3)
	{
		__builder3.OpenComponent<PokemonCard>(26);
		__builder3.AddAttribute(27, "Pokemon", RuntimeHelpers.TypeCheck(pokemon));
		__builder3.CloseComponent();
	});
	__builder2.CloseComponent();
}

That would lead to the sequence numbers 23 to 27 to repeat over and over again until the list is finished.
How does that correspond to the statement that sequence numbers should always increase?

@SteveSandersonMS
Copy link
Author

How does that correspond to the statement that sequence numbers should always increase?

They should increase in your source code, not in terms of runtime behavior. The fact that, at runtime, the numbers repeat is how the diffing system realises you're in a loop.

@gingters
Copy link

gingters commented Mar 3, 2022

Thanks alot! I thought it could be that way, but so far I refrained a bit from reading the differ's source 😅

@gyuzal
Copy link

gyuzal commented Apr 20, 2022

@SteveSandersonMS could you please advise (not sure if the same question has already been asked) but i didn't get how to use hard coded sequence number within the loop, if I have something like this:

MyClass.cs
       protected override void BuildRenderTree(RenderTreeBuilder builder)
        {            
            int i = 0;
            base.BuildRenderTree(builder);
            builder.OpenElement(i++, Tag); //will change to 1
            builder.AddAttribute(i++, "class", Class); //will change to 2
           
                foreach (var item in DataDictionary)
                {
                    builder.AddAttribute(i++, $"data-{item.Key}", item.Value); //how to use it here?
                }
                        
                foreach (var item in AttributesDictionary)
                {
                    builder.AddAttribute(i++, item.Key, item.Value); //how to use it here?
                }
            
            builder.AddElementReferenceCapture(i++, async a => { await OnRefChange.InvokeAsync(a); }); //will change to 3
            builder.AddContent(i++, ChildContent); //will change to 4
            builder.CloseElement();
        }

@gingters
Copy link

Hello @gyuzal ,

the sequence should simply increase in between each usage. This might be by one each time, but you can also use larger increments, as I've shown here based on your example:

protected override void BuildRenderTree(RenderTreeBuilder builder)
{            
    base.BuildRenderTree(builder);
    builder.OpenElement(10, Tag);
    builder.AddAttribute(20, "class", Class);
           
    foreach (var item in DataDictionary)
    {
        builder.AddAttribute(30, $"data-{item.Key}", item.Value);
        // if you want to add something later in here, then you can use 31 here and not have to change the rest downwards
    }
                        
    foreach (var item in AttributesDictionary)
    {
        builder.AddAttribute(40, item.Key, item.Value);
        // if you want to add something later in here, then you can use 41 here and not have to change the rest downwards
    }
            
    builder.AddElementReferenceCapture(50, async a => { await OnRefChange.InvokeAsync(a); });
    builder.AddContent(60, ChildContent);
    builder.CloseElement();
}

In general: Use hardcoded numbers, and just increase them whenever you need a new sequence number. Don't care about loops and if/else statements (do NOT use if { 40 } else if { 40 } else { 40 } just to have the same next sequence number no matter what condition your data is in, but simply increase it every time as it follows your code: if { 40 }else if { 50 } else { 60 }.

@gyuzal
Copy link

gyuzal commented Apr 20, 2022

Thanks a lot @gingters, very helpful and clear!

@gingters
Copy link

You're welcome! I struggled a bit with that myself. I used ILSpy to look into what output of certain loops / conditions etc. generated from my Blazor components (the Razor syntax is compiled into a render tree builder, so you can see what C# code your Razor code is converted to) and learned from that how the sequence number is generated. It's a lot less magical once you see that ;)

@gyuzal
Copy link

gyuzal commented Apr 20, 2022

that's amazing @gingters
i'm sorry to be off the current topic but is there a way to get event object in the event handler when using RenderTreeBuilder?
Here's a piece of code where I try to apply StopPropagation() on the element click:

builder.AddAttribute(i++, "onclick", EventCallback.Factory.Create<MouseEventArgs>(this, OnClick));

private void OnClick(MouseEventArgs args)
{
   //e.StopPropagation() how do I get event here?      
}

@wstaelens
Copy link

regarding the loops, if I understand correctly the sequence should be the same in loops.

Instead of this:

 public RenderFragment Build()
    {
      return new RenderFragment(builder =>
      {
        var i = 0;
        builder.OpenComponent(i++, ComponentType);
        if (Attributes != null)
        {
          foreach (var kvp in Attributes)
          {
            builder.AddAttribute(i++, kvp.Key, kvp.Value);
          }
        }
        builder.CloseComponent();
      });
    }

it should be:

        public RenderFragment Build()
        {
            return new RenderFragment(builder =>
            {
                builder.OpenComponent(10, ComponentType);
                if (Attributes != null)
                {
                    foreach (var kvp in Attributes)
                    {
                        builder.AddAttribute(20, kvp.Key, kvp.Value);
                    }
                }
                builder.CloseComponent();
            });
        }

correct ?

@SteveSandersonMS
Copy link
Author

@wstaelens Correct!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment