-
Notifications
You must be signed in to change notification settings - Fork 10.3k
Key support. Implements #8232 #9850
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
src/Components/Components/src/RenderTree/RenderTreeDiffBuilder.cs
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've taken an initial look through the source code. Everything looks good, although TBH I'm not an expert yet on the diffing code, so I will have to take a deeper look probably tomorrow. I plan to clone the branch and play a bit with it, but overall looks great!
If I understood correctly there are two cases for using key:
- Components: The entire component gets rerendered (this happens today without key, so no issue here?)
- Elements: Instead of moving the world around the first item, things are moved around. Do subtrees get diffed between matching key elements or do we always render the new entire subtree?
Regarding the algorithm. Are we using a specific diffing algorithm from literature or did we just "craft" our own. I imagine you've done this, but we could potentially do some research and see if we can find anything already invented that might perform better in terms of time/space.
I've taken a quick look at the tests and I couldn't find any E2E test. Shouldn't we have some?
The common use cases are described as best as I can in the PR description. It's not just about the first item in a list; it's about general insertions/deletions/reorderings and preserving the association between model objects and elements/components.
They do get diffed between matching keys. We only create an entirely new subtree when we have an entirely new key.
At a high level, our algorithm for elements/components/regions is essentially "merge join". This is much simpler than a general-purpose diff, as well as faster, and is possible only because we have the compile step that lets us inject sequence numbers. We only need a single linear pass, which is much cheaper than most general-purpose diffs that don't require sequence numbers. When we detect reordered attributes or keys, our algorithm is essentially "hash join", which is again O(N) time.
Still to do. I'll be adding them tomorrow. |
Fair enough, didn't see it. |
I looked over the first few commits, and got lost when we got to the diffing algo changes. This is an area where I haven't really gone deep in the past, and I think it would be more valuable for someone besides me to become a second expect on this (👀 in the direction of Team Blazor EU). I don't want to create noise by adding low value comments :) |
OK, hopefully this is now ready to go. |
d768243
to
a4933a3
Compare
/AzurePipelines run |
Azure Pipelines successfully started running 3 pipeline(s). |
Ping to reviewers on this. @javiercn If it would be helpful to have a call about this so I can walk you through the algorithm, please let me know! |
a4933a3
to
a045b8f
Compare
I'm very glad you suggested this. Adding these E2E tests uncovered two problems:
There are now E2E test cases for all things you suggested (typing while boxes move around, and checking checkboxes in lists that move as you check them). If you want to review the last few new commits please go ahead! I'm still planning to merge this once the CI checks complete because this PR has been waiting for so long and I think the final changes (which are mostly E2E tests) won't really need much further review. |
/AzurePipelines run |
Azure Pipelines successfully started running 3 pipeline(s). |
Yay! I added value :) Taking a look at the new commits. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
New changes look good too!
Windows, Mac, and Linux builds all failed for entirely different reasons, none of which appear related to this PR :( |
/AzurePipelines run |
Azure Pipelines successfully started running 3 pipeline(s). |
/AzurePipelines run AspNetCore-ci (Test: Templates - Windows Server 2016 x64) |
No pipelines are associated with this pull request. |
This is the runtime part of allowing syntax like
<something key="@somekey" />
to guarantee affinity of elements/components with model objects.Common use case
... or:
The use of
key
changes how the diff algorithm handles changes to thePeople
list:key
, it matches model objects with elements/components in the order they both appear. So if, say,People[0]
was removed, then it would reassociate each element/component with then+1
-th item in the list, and finally delete the last element/component. This can cause unnecessary extra re-rendering of child components or even problems such as making the focus appear to move to an unexpected textbox (if the focus was in a textbox forPerson[2]
, that textbox would now be reassociated with what was previouslyPerson[3]
, which looks as if the focus moved (although really, it's the rest of the world that moved and the focus stayed still)).key
, it matches model objects with elements/components based on equality of the suppliedkey
object. So if, say,People[0]
was removed, then it would know simply to delete the first element/component, and leave everything else alone.Similarly, if the entries in
People
were shuffled, then:key
, it would reassociate thei
-th element/component with the new entryPeople[i]
, and re-render. This performs a fine-grained update on all descendants (e.g., updating all the text nodes individually).key
, it would simply re-order the elements/components to match the new ordering. It would not necessarily re-render descendants (though in practice, in the components case, it does ask them if they want to re-render because it doesn't know whether thePeople
entries themselves have mutated).Using
key
remains optional, but it's nearly always a good idea to use it any time you render a list whose data is subject to insertions/deletions/reordering.The other possible reason to use
key
is if you want to guarantee non-preservation of a given subtree when some data changes. Example:Without
key
, the renderer will respond to changes by recursing into the descendants and updating each affected descendant individually. Withkey
, ifmyModel.Id
changes, it will throw out the entire subtree and insert a new one - this guarantees that things like focus state are not retained.Code review notes
While implementing this, I hit what I felt were complexity limits in a couple of areas, forcing me to refactor before making substantial additions. You may find it easier to read the individual commits in order. In particular, I refactored:
RenderTreeFrame
, because the old system of having many ad-hoc constructors corresponding to different scenarios became intractible. There would soon be clashes of constructor signatures, and it was hard to be sure that all theWith...
methods (that clone-with-mutatation the immutable struct) would correctly preserve all the fields that were not being modified. Basically, this is crying out for C# 8.x records. I changed it to have one constructor per frame type, where that constructor forces you to specify all the fields applicable to that frame type. It's therefore unambiguous where to add any new fields and ensures you can't forget to copy other ones.RenderTreeBuilder
, mainly to reorganize theAppendDiffEntriesForRange
logic. Previously it mixed "deciding what to do" with "doing that thing". Those two concepts are now separated, so it's easier to have multiple distinct code paths that result in the same outcome (e.g., "treat as insert" or "match and recurse into descendants"). The code diff for this refactoring is hard to read, unfortunately, because it looks as it basically everything changed even though mostly it was moving blocks of code around.AppendDiffEntriesForRange
into multiple methods, because it's huge. However, benchmarking showed about a 10% slowdown on Mono WebAssembly after doing that, which I would think is because it has to execute a lot more instructions to do all the parameter passing. So I have left it as one big method, but tried to clarify the different areas of responsibility.The logic around detecting keyed inserts/deletions is really simple and doesn't need much of an explanation. Handling moves, however, is much more complicated. My goal was to find a way of distributing the workload between the .NET side and the JS side such that it was no worse than O(N) on either side, and that the .NET side didn't involve any per-render allocations in typical cases. The solution was not generate a list of "moves" (I can't think of a way to do that without either allocating for each key or doing O(N^2) lookups, but if anyone has a solution, I'd be interested!). Instead it generates a description of the final state as a permutation of the normal post-edit state. On the JS side, we can apply the permutation in O(N) time with O(N) allocations (inserting marker nodes into the DOM to track where things will get inserted) - this small number of allocations on the JS side doesn't concern me at all.