diff --git a/.gitignore b/.gitignore index 9b152674da..eabb0b0e98 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ obj/ launchSettings.json artifacts/ msbuild.binlog -.vscode/ \ No newline at end of file +.vscode/ +BenchmarkDotNet.Artifacts/ \ No newline at end of file diff --git a/Blazor.sln b/Blazor.sln index 853eabe2ae..dfffd765ed 100644 --- a/Blazor.sln +++ b/Blazor.sln @@ -92,6 +92,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestContentPackage", "test\ EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LiveReloadTestApp", "test\testapps\LiveReloadTestApp\LiveReloadTestApp.csproj", "{0246AA77-1A27-4A67-874B-6EF6F99E414E}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "benchmarks", "benchmarks", "{36A7DEB7-5F88-4BFB-B57E-79EEC9950E25}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Blazor.Performance", "benchmarks\Microsoft.AspNetCore.Blazor.Performance\Microsoft.AspNetCore.Blazor.Performance.csproj", "{50F6820F-D058-4E68-9E15-801F893F514E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -323,6 +327,14 @@ Global {0246AA77-1A27-4A67-874B-6EF6F99E414E}.Release|Any CPU.Build.0 = Release|Any CPU {0246AA77-1A27-4A67-874B-6EF6F99E414E}.ReleaseNoVSIX|Any CPU.ActiveCfg = Release|Any CPU {0246AA77-1A27-4A67-874B-6EF6F99E414E}.ReleaseNoVSIX|Any CPU.Build.0 = Release|Any CPU + {50F6820F-D058-4E68-9E15-801F893F514E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {50F6820F-D058-4E68-9E15-801F893F514E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {50F6820F-D058-4E68-9E15-801F893F514E}.DebugNoVSIX|Any CPU.ActiveCfg = Debug|Any CPU + {50F6820F-D058-4E68-9E15-801F893F514E}.DebugNoVSIX|Any CPU.Build.0 = Debug|Any CPU + {50F6820F-D058-4E68-9E15-801F893F514E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {50F6820F-D058-4E68-9E15-801F893F514E}.Release|Any CPU.Build.0 = Release|Any CPU + {50F6820F-D058-4E68-9E15-801F893F514E}.ReleaseNoVSIX|Any CPU.ActiveCfg = Release|Any CPU + {50F6820F-D058-4E68-9E15-801F893F514E}.ReleaseNoVSIX|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -364,6 +376,7 @@ Global {9088E4E4-B855-457F-AE9E-D86709A5E1F4} = {F563ABB6-85FB-4CFC-B0D2-1D5130E8246D} {C57382BC-EE93-49D5-BC40-5C98AF8AA048} = {4AE0D35B-D97A-44D0-8392-C9240377DCCE} {0246AA77-1A27-4A67-874B-6EF6F99E414E} = {4AE0D35B-D97A-44D0-8392-C9240377DCCE} + {50F6820F-D058-4E68-9E15-801F893F514E} = {36A7DEB7-5F88-4BFB-B57E-79EEC9950E25} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {504DA352-6788-4DC0-8705-82167E72A4D3} diff --git a/benchmarks/Microsoft.AspNetCore.Blazor.Performance/AssemblyInfo.cs b/benchmarks/Microsoft.AspNetCore.Blazor.Performance/AssemblyInfo.cs new file mode 100644 index 0000000000..59c4ce5180 --- /dev/null +++ b/benchmarks/Microsoft.AspNetCore.Blazor.Performance/AssemblyInfo.cs @@ -0,0 +1,4 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +[assembly: BenchmarkDotNet.Attributes.AspNetCoreBenchmark] \ No newline at end of file diff --git a/benchmarks/Microsoft.AspNetCore.Blazor.Performance/Microsoft.AspNetCore.Blazor.Performance.csproj b/benchmarks/Microsoft.AspNetCore.Blazor.Performance/Microsoft.AspNetCore.Blazor.Performance.csproj new file mode 100644 index 0000000000..4782af235c --- /dev/null +++ b/benchmarks/Microsoft.AspNetCore.Blazor.Performance/Microsoft.AspNetCore.Blazor.Performance.csproj @@ -0,0 +1,19 @@ + + + + netcoreapp2.0 + Exe + true + true + + + + + + + + + + + + diff --git a/benchmarks/Microsoft.AspNetCore.Blazor.Performance/Program.cs b/benchmarks/Microsoft.AspNetCore.Blazor.Performance/Program.cs new file mode 100644 index 0000000000..71532fa840 --- /dev/null +++ b/benchmarks/Microsoft.AspNetCore.Blazor.Performance/Program.cs @@ -0,0 +1,47 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Diagnostics; +using Microsoft.AspNetCore.Blazor.Performance; + +namespace Microsoft.AspNetCore.BenchmarkDotNet.Runner +{ + internal partial class Program + { + static partial void BeforeMain(string[] args) + { + if (args.Length == 0 || args[0] != "--profile") + { + return; + } + + // Write code here if you want to profile something. Normally Benchmark.NET launches + // a separate process, which can be hard to profile. + // + // See: https://github.com/dotnet/BenchmarkDotNet/issues/387 + + // Example: + //Console.WriteLine("Starting..."); + //var stopwatch = Stopwatch.StartNew(); + //var benchmark = new RenderTreeDiffBuilderBenchmark(); + + //for (var i = 0; i < 100000; i++) + //{ + // benchmark.ComputeDiff_SingleFormField(); + // benchmark.ComputeDiff_SingleFormField(); + // benchmark.ComputeDiff_SingleFormField(); + // benchmark.ComputeDiff_SingleFormField(); + // benchmark.ComputeDiff_SingleFormField(); + // benchmark.ComputeDiff_SingleFormField(); + // benchmark.ComputeDiff_SingleFormField(); + // benchmark.ComputeDiff_SingleFormField(); + // benchmark.ComputeDiff_SingleFormField(); + // benchmark.ComputeDiff_SingleFormField(); + //} + + //Console.WriteLine($"Done after {stopwatch.ElapsedMilliseconds}ms"); + //Environment.Exit(0); + } + } +} diff --git a/benchmarks/Microsoft.AspNetCore.Blazor.Performance/RenderTreeDiffBuilderBenchmark.cs b/benchmarks/Microsoft.AspNetCore.Blazor.Performance/RenderTreeDiffBuilderBenchmark.cs new file mode 100644 index 0000000000..9f81a9042d --- /dev/null +++ b/benchmarks/Microsoft.AspNetCore.Blazor.Performance/RenderTreeDiffBuilderBenchmark.cs @@ -0,0 +1,108 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using BenchmarkDotNet.Attributes; +using Microsoft.AspNetCore.Blazor.Components; +using Microsoft.AspNetCore.Blazor.Rendering; +using Microsoft.AspNetCore.Blazor.RenderTree; + +namespace Microsoft.AspNetCore.Blazor.Performance +{ + public class RenderTreeDiffBuilderBenchmark + { + private readonly Renderer renderer; + private readonly RenderTreeBuilder original; + private readonly RenderTreeBuilder modified; + private readonly RenderBatchBuilder builder; + + public RenderTreeDiffBuilderBenchmark() + { + builder = new RenderBatchBuilder(); + renderer = new FakeRenderer(); + + // A simple component for basic tests -- this is similar to what MVC scaffolding generates + // for bootstrap3 form fields, but modified to be more Blazorey. + original = new RenderTreeBuilder(renderer); + original.OpenElement(0, "div"); + original.AddAttribute(1, "class", "form-group"); + + original.OpenElement(2, "label"); + original.AddAttribute(3, "class", "control-label"); + original.AddAttribute(4, "for", "name"); + original.AddAttribute(5, "data-unvalidated", true); + original.AddContent(6, "Car"); + original.CloseElement(); + + original.OpenElement(7, "input"); + original.AddAttribute(8, "class", "form-control"); + original.AddAttribute(9, "type", "text"); + original.AddAttribute(10, "name", "name"); // Notice the gap in sequence numbers + original.AddAttribute(12, "value", ""); + original.CloseElement(); + + original.OpenElement(13, "span"); + original.AddAttribute(14, "class", "text-danger field-validation-valid"); + original.AddContent(15, ""); + original.CloseElement(); + + original.CloseElement(); + + // Now simulate some input + modified = new RenderTreeBuilder(renderer); + modified.OpenElement(0, "div"); + modified.AddAttribute(1, "class", "form-group"); + + modified.OpenElement(2, "label"); + modified.AddAttribute(3, "class", "control-label"); + modified.AddAttribute(4, "for", "name"); + modified.AddAttribute(5, "data-unvalidated", false); + modified.AddContent(6, "Car"); + modified.CloseElement(); + + modified.OpenElement(7, "input"); + modified.AddAttribute(8, "class", "form-control"); + modified.AddAttribute(9, "type", "text"); + modified.AddAttribute(10, "name", "name"); + modified.AddAttribute(11, "data-validation-state", "invalid"); + modified.AddAttribute(12, "value", "Lamborghini"); + modified.CloseElement(); + + modified.OpenElement(13, "span"); + modified.AddAttribute(14, "class", "text-danger field-validation-invalid"); // changed + modified.AddContent(15, "No, you can't afford that."); + modified.CloseElement(); + + modified.CloseElement(); + } + + [Benchmark(Description = "RenderTreeDiffBuilder: Input and validation on a single form field.", Baseline = true)] + public void ComputeDiff_SingleFormField() + { + builder.Clear(); + var diff = RenderTreeDiffBuilder.ComputeDiff(renderer, builder, 0, original.GetFrames(), modified.GetFrames()); + GC.KeepAlive(diff); + } + + private class FakeRenderer : Renderer + { + public FakeRenderer() + : base(new TestServiceProvider()) + { + } + + protected override void UpdateDisplay(RenderBatch renderBatch) + { + } + } + + private class TestServiceProvider : IServiceProvider + { + public object GetService(Type serviceType) + { + return null; + } + } + } +} diff --git a/benchmarks/Microsoft.AspNetCore.Blazor.Performance/readme.md b/benchmarks/Microsoft.AspNetCore.Blazor.Performance/readme.md new file mode 100644 index 0000000000..82e7adfab7 --- /dev/null +++ b/benchmarks/Microsoft.AspNetCore.Blazor.Performance/readme.md @@ -0,0 +1,35 @@ +# Benchmarks + +## Instructions + +### Run All Benchmarks + +To run all use `*` as parameter +``` +dotnet run -c Release -- * +``` + +### Interactive Mode + +To see the list of benchmarks run (and choose interactively): +``` +dotnet run -c Release +``` + +### Run Specific Benchmark + +To run a specific benchmark add it as parameter +``` +dotnet run -c Release -- +``` + + +## Troubleshooting + +The runner will create logs in the `\BenchmarkDotNet.Artifacts` directory. That should include a lot more information +than what gets printed to the console. + +## Results + +Also in the `\BenchmarkDotNet.Artifacts\results` directive you'll find some markdown-formatted tables suitable for posting +in a github comment. \ No newline at end of file diff --git a/build/dependencies.props b/build/dependencies.props index 0142a8ed99..d36a1f4fda 100644 --- a/build/dependencies.props +++ b/build/dependencies.props @@ -3,7 +3,9 @@ $(MSBuildAllProjects);$(MSBuildThisFileFullPath) + 0.10.13 2.1.0-preview2-15704 + 2.1.0-preview3-32064 diff --git a/build/repo.props b/build/repo.props index 3921cba3bd..35e3dd34f0 100644 --- a/build/repo.props +++ b/build/repo.props @@ -2,7 +2,7 @@ - false + true diff --git a/src/Microsoft.AspNetCore.Blazor.Browser.JS/src/Rendering/BrowserRenderer.ts b/src/Microsoft.AspNetCore.Blazor.Browser.JS/src/Rendering/BrowserRenderer.ts index 495f9f2a9b..c06195c3c4 100644 --- a/src/Microsoft.AspNetCore.Blazor.Browser.JS/src/Rendering/BrowserRenderer.ts +++ b/src/Microsoft.AspNetCore.Blazor.Browser.JS/src/Rendering/BrowserRenderer.ts @@ -230,7 +230,7 @@ export class BrowserRenderer { case 'SELECT': case 'TEXTAREA': if (isCheckbox(element)) { - (element as HTMLInputElement).checked = value === 'True'; + (element as HTMLInputElement).checked = value === ''; } else { (element as any).value = value; diff --git a/src/Microsoft.AspNetCore.Blazor/Properties/AssemblyInfo.cs b/src/Microsoft.AspNetCore.Blazor/Properties/AssemblyInfo.cs index e25c100011..fcbca4b037 100644 --- a/src/Microsoft.AspNetCore.Blazor/Properties/AssemblyInfo.cs +++ b/src/Microsoft.AspNetCore.Blazor/Properties/AssemblyInfo.cs @@ -3,3 +3,4 @@ [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Blazor.Test")] [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Blazor.Browser.Test")] [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Blazor.Build.Test")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Blazor.Performance")] diff --git a/src/Microsoft.AspNetCore.Blazor/RenderTree/RenderTreeBuilder.cs b/src/Microsoft.AspNetCore.Blazor/RenderTree/RenderTreeBuilder.cs index 698f6ce51c..73f27914c1 100644 --- a/src/Microsoft.AspNetCore.Blazor/RenderTree/RenderTreeBuilder.cs +++ b/src/Microsoft.AspNetCore.Blazor/RenderTree/RenderTreeBuilder.cs @@ -18,6 +18,9 @@ namespace Microsoft.AspNetCore.Blazor.RenderTree /// public class RenderTreeBuilder { + private readonly static object BoxedTrue = true; + private readonly static object BoxedFalse = false; + private readonly Renderer _renderer; private readonly ArrayBuilder _entries = new ArrayBuilder(10); private readonly Stack _openElementIndices = new Stack(); @@ -97,9 +100,33 @@ namespace Microsoft.AspNetCore.Blazor.RenderTree public void AddContent(int sequence, object textContent) => AddContent(sequence, textContent?.ToString()); + /// + /// Appends a frame representing a bool-valued attribute. + /// The attribute is associated with the most recently added element. If the value is false and the + /// current element is not a component, the frame will be omitted. + /// + /// An integer that represents the position of the instruction in the source code. + /// The name of the attribute. + /// The value of the attribute. + public void AddAttribute(int sequence, string name, bool value) + { + AssertCanAddAttribute(); + if (_lastNonAttributeFrameType == RenderTreeFrameType.Component) + { + Append(RenderTreeFrame.Attribute(sequence, name, value ? BoxedTrue : BoxedFalse)); + } + else if (value) + { + // Don't add 'false' attributes for elements. We want booleans to map to the presence + // or absence of an attribute, and false => "False" which isn't falsy in js. + Append(RenderTreeFrame.Attribute(sequence, name, BoxedTrue)); + } + } + /// /// Appends a frame representing a string-valued attribute. - /// The attribute is associated with the most recently added element. + /// The attribute is associated with the most recently added element. If the value is null and the + /// current element is not a component, the frame will be omitted. /// /// An integer that represents the position of the instruction in the source code. /// The name of the attribute. @@ -107,12 +134,16 @@ namespace Microsoft.AspNetCore.Blazor.RenderTree public void AddAttribute(int sequence, string name, string value) { AssertCanAddAttribute(); - Append(RenderTreeFrame.Attribute(sequence, name, value)); + if (value != null || _lastNonAttributeFrameType == RenderTreeFrameType.Component) + { + Append(RenderTreeFrame.Attribute(sequence, name, value)); + } } /// /// Appends a frame representing an -valued attribute. - /// The attribute is associated with the most recently added element. + /// The attribute is associated with the most recently added element. If the value is null and the + /// current element is not a component, the frame will be omitted. /// /// An integer that represents the position of the instruction in the source code. /// The name of the attribute. @@ -120,22 +151,49 @@ namespace Microsoft.AspNetCore.Blazor.RenderTree public void AddAttribute(int sequence, string name, UIEventHandler value) { AssertCanAddAttribute(); - Append(RenderTreeFrame.Attribute(sequence, name, value)); + if (value != null || _lastNonAttributeFrameType == RenderTreeFrameType.Component) + { + Append(RenderTreeFrame.Attribute(sequence, name, value)); + } } /// /// Appends a frame representing a string-valued attribute. - /// The attribute is associated with the most recently added element. + /// The attribute is associated with the most recently added element. If the value is null, or + /// the value false and the current element is not a component, the + /// frame will be omitted. /// /// An integer that represents the position of the instruction in the source code. /// The name of the attribute. /// The value of the attribute. public void AddAttribute(int sequence, string name, object value) { + // This looks a bit daunting because we need to handle the boxed/object version of all of the + // types that AddAttribute special cases. if (_lastNonAttributeFrameType == RenderTreeFrameType.Element) { - // Element attribute values can only be strings or UIEventHandler - Append(RenderTreeFrame.Attribute(sequence, name, value.ToString())); + if (value == null) + { + // Do nothing, treat 'null' attribute values for elements as a conditional attribute. + } + else if (value is bool boolValue) + { + if (boolValue) + { + Append(RenderTreeFrame.Attribute(sequence, name, BoxedTrue)); + } + + // Don't add anything for false bool value. + } + else if (value is UIEventHandler eventHandler) + { + Append(RenderTreeFrame.Attribute(sequence, name, value)); + } + else + { + // The value is either a string, or should be treated as a string. + Append(RenderTreeFrame.Attribute(sequence, name, value.ToString())); + } } else if (_lastNonAttributeFrameType == RenderTreeFrameType.Component) { diff --git a/src/Microsoft.AspNetCore.Blazor/RenderTree/RenderTreeDiffBuilder.cs b/src/Microsoft.AspNetCore.Blazor/RenderTree/RenderTreeDiffBuilder.cs index 12952e2945..31828e4da2 100644 --- a/src/Microsoft.AspNetCore.Blazor/RenderTree/RenderTreeDiffBuilder.cs +++ b/src/Microsoft.AspNetCore.Blazor/RenderTree/RenderTreeDiffBuilder.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Collections.Generic; using Microsoft.AspNetCore.Blazor.Components; using Microsoft.AspNetCore.Blazor.Rendering; @@ -138,6 +139,125 @@ namespace Microsoft.AspNetCore.Blazor.RenderTree } } + // Handles the diff for attribute nodes only - this invariant is enforced by the caller. + // + // The diff for attributes is different because we allow attributes to appear in any order. + // Put another way, the attributes list of an element or component is *conceptually* + // unordered. This is a case where we can produce a more minimal diff by avoiding + // non-meaningful reorderings of attributes. + private static void AppendAttributeDiffEntriesForRange( + ref DiffContext diffContext, + int oldStartIndex, int oldEndIndexExcl, + int newStartIndex, int newEndIndexExcl) + { + // The overhead of the dictionary used by AppendAttributeDiffEntriesForRangeSlow is + // significant, so we want to try and do a merge-join if possible, but fall back to + // a hash-join if not. We'll do a merge join until we hit a case we can't handle and + // then fall back to the slow path. + // + // Also since duplicate attributes are not legal, we don't need to care about loops or + // the more complicated scenarios handled by AppendDiffEntriesForRange. + // + // We also assume that we won't see an attribute occur with different sequence numbers + // in the old and new sequences. It will be handled correct, but will generate a suboptimal + // diff. + var hasMoreOld = oldEndIndexExcl > oldStartIndex; + var hasMoreNew = newEndIndexExcl > newStartIndex; + var oldTree = diffContext.OldTree; + var newTree = diffContext.NewTree; + + while (hasMoreOld || hasMoreNew) + { + var oldSeq = hasMoreOld ? oldTree[oldStartIndex].Sequence : int.MaxValue; + var newSeq = hasMoreNew ? newTree[newStartIndex].Sequence : int.MaxValue; + var oldAttributeName = oldTree[oldStartIndex].AttributeName; + var newAttributeName = newTree[newStartIndex].AttributeName; + + if (oldSeq == newSeq && + string.Equals(oldAttributeName, newAttributeName, StringComparison.Ordinal)) + { + // These two attributes have the same sequence and name. Keep merging. + AppendDiffEntriesForAttributeFrame(ref diffContext, oldStartIndex, newStartIndex); + + oldStartIndex = NextSiblingIndex(oldTree[oldStartIndex], oldStartIndex); + newStartIndex = NextSiblingIndex(newTree[newStartIndex], newStartIndex); + hasMoreOld = oldEndIndexExcl > oldStartIndex; + hasMoreNew = newEndIndexExcl > newStartIndex; + } + else if (oldSeq < newSeq) + { + // An attribute was removed compared to the old sequence. + RemoveOldFrame(ref diffContext, oldStartIndex); + + oldStartIndex = NextSiblingIndex(oldTree[oldStartIndex], oldStartIndex); + hasMoreOld = oldEndIndexExcl > oldStartIndex; + } + else if (oldSeq > newSeq) + { + // An attribute was added compared to the new sequence. + InsertNewFrame(ref diffContext, newStartIndex); + + newStartIndex = NextSiblingIndex(newTree[newStartIndex], newStartIndex); + hasMoreNew = newEndIndexExcl > newStartIndex; + } + else + { + // These two attributes have the same sequence and different names. This is + // a failure case for merge-join, fall back to the slow path. + AppendAttributeDiffEntriesForRangeSlow( + ref diffContext, + oldStartIndex, oldEndIndexExcl, + newStartIndex, newEndIndexExcl); + return; + } + } + } + + private static void AppendAttributeDiffEntriesForRangeSlow( + ref DiffContext diffContext, + int oldStartIndex, int oldEndIndexExcl, + int newStartIndex, int newEndIndexExcl) + { + var oldTree = diffContext.OldTree; + var newTree = diffContext.NewTree; + + // Slow version of AppendAttributeDiffEntriesForRange that uses a dictionary. + // Algorithm: + // + // 1. iterate through the 'new' tree and add all attributes to the attributes set + // 2. iterate through the 'old' tree, removing matching attributes from set, and diffing + // 3. iterate through the remaining attributes in the set and add them + for (var i = newStartIndex; i < newEndIndexExcl; i++) + { + diffContext.AttributeDiffSet[newTree[i].AttributeName] = i; + } + + for (var i = oldStartIndex; i < oldEndIndexExcl; i++) + { + var oldName = oldTree[i].AttributeName; + if (diffContext.AttributeDiffSet.TryGetValue(oldName, out var matchIndex)) + { + // Has a match in the new tree, look for a diff + AppendDiffEntriesForAttributeFrame(ref diffContext, i, matchIndex); + diffContext.AttributeDiffSet.Remove(oldName); + } + else + { + // No match in the new tree, remove old attribute + RemoveOldFrame(ref diffContext, i); + } + } + + foreach (var kvp in diffContext.AttributeDiffSet) + { + // No match in the old tree + InsertNewFrame(ref diffContext, kvp.Value); + } + + // We should have processed any additions at this point. Reset for the next batch. + diffContext.AttributeDiffSet.Clear(); + } + private static void UpdateRetainedChildComponent( ref DiffContext diffContext, int oldComponentIndex, @@ -219,7 +339,7 @@ namespace Microsoft.AspNetCore.Blazor.RenderTree var newFrameAttributesEndIndexExcl = GetAttributesEndIndexExclusive(newTree, newFrameIndex); // Diff the attributes - AppendDiffEntriesForRange( + AppendAttributeDiffEntriesForRange( ref diffContext, oldFrameIndex + 1, oldFrameAttributesEndIndexExcl, newFrameIndex + 1, newFrameAttributesEndIndexExcl); @@ -284,46 +404,46 @@ namespace Microsoft.AspNetCore.Blazor.RenderTree break; } - case RenderTreeFrameType.Attribute: - { - var oldName = oldFrame.AttributeName; - var newName = newFrame.AttributeName; - if (string.Equals(oldName, newName, StringComparison.Ordinal)) - { - // Using Equals to account for string comparisons, nulls, etc. - var valueChanged = !Equals(oldFrame.AttributeValue, newFrame.AttributeValue); - if (valueChanged) - { - if (oldFrame.AttributeEventHandlerId > 0) - { - diffContext.BatchBuilder.DisposedEventHandlerIds.Append(oldFrame.AttributeEventHandlerId); - } - InitializeNewAttributeFrame(ref diffContext, ref newFrame); - var referenceFrameIndex = diffContext.ReferenceFrames.Append(newFrame); - diffContext.Edits.Append(RenderTreeEdit.SetAttribute(diffContext.SiblingIndex, referenceFrameIndex)); - } - else if (oldFrame.AttributeEventHandlerId > 0) - { - // Retain the event handler ID - newFrame = oldFrame; - } - } - else - { - // Since this code path is never reachable for Razor components (because you - // can't have two different attribute names from the same source sequence), we - // could consider removing the 'name equality' check entirely for perf - RemoveOldFrame(ref diffContext, oldFrameIndex); - InsertNewFrame(ref diffContext, newFrameIndex); - } - break; - } - + // We don't handle attributes here, they have their own diff logic. + // See AppendDiffEntriesForAttributeFrame default: throw new NotImplementedException($"Encountered unsupported frame type during diffing: {newTree[newFrameIndex].FrameType}"); } } + // This should only be called for attributes that have the same name. This is an + // invariant maintained by the callers. + private static void AppendDiffEntriesForAttributeFrame( + ref DiffContext diffContext, + int oldFrameIndex, + int newFrameIndex) + { + var oldTree = diffContext.OldTree; + var newTree = diffContext.NewTree; + ref var oldFrame = ref oldTree[oldFrameIndex]; + ref var newFrame = ref newTree[newFrameIndex]; + + // Using Equals to account for string comparisons, nulls, etc. + var valueChanged = !Equals(oldFrame.AttributeValue, newFrame.AttributeValue); + if (valueChanged) + { + if (oldFrame.AttributeEventHandlerId > 0) + { + diffContext.BatchBuilder.DisposedEventHandlerIds.Append(oldFrame.AttributeEventHandlerId); + } + InitializeNewAttributeFrame(ref diffContext, ref newFrame); + var referenceFrameIndex = diffContext.ReferenceFrames.Append(newFrame); + diffContext.Edits.Append(RenderTreeEdit.SetAttribute(diffContext.SiblingIndex, referenceFrameIndex)); + } + else if (oldFrame.AttributeEventHandlerId > 0) + { + // Retain the event handler ID by copying the old frame over the new frame. + // this will prevent us from needing to dispose the old event handler + // since it was unchanged. + newFrame = oldFrame; + } + } + private static void InsertNewFrame(ref DiffContext diffContext, int newFrameIndex) { var newTree = diffContext.NewTree; @@ -514,6 +634,7 @@ namespace Microsoft.AspNetCore.Blazor.RenderTree public readonly RenderTreeFrame[] NewTree; public readonly ArrayBuilder Edits; public readonly ArrayBuilder ReferenceFrames; + public readonly Dictionary AttributeDiffSet; public int SiblingIndex; public DiffContext( @@ -528,6 +649,7 @@ namespace Microsoft.AspNetCore.Blazor.RenderTree NewTree = newTree; Edits = batchBuilder.EditsBuffer; ReferenceFrames = batchBuilder.ReferenceFramesBuffer; + AttributeDiffSet = batchBuilder.AttributeDiffSet; SiblingIndex = 0; } } diff --git a/src/Microsoft.AspNetCore.Blazor/Rendering/RenderBatchBuilder.cs b/src/Microsoft.AspNetCore.Blazor/Rendering/RenderBatchBuilder.cs index e380cc753e..9892e6d7d0 100644 --- a/src/Microsoft.AspNetCore.Blazor/Rendering/RenderBatchBuilder.cs +++ b/src/Microsoft.AspNetCore.Blazor/Rendering/RenderBatchBuilder.cs @@ -27,6 +27,9 @@ namespace Microsoft.AspNetCore.Blazor.Rendering public Queue ComponentRenderQueue { get; } = new Queue(); public Queue ComponentDisposalQueue { get; } = new Queue(); + // Scratch data structure for understanding attribute diffs. + public Dictionary AttributeDiffSet { get; } = new Dictionary(); + public void Clear() { EditsBuffer.Clear(); @@ -35,6 +38,7 @@ namespace Microsoft.AspNetCore.Blazor.Rendering UpdatedComponentDiffs.Clear(); DisposedComponentIds.Clear(); DisposedEventHandlerIds.Clear(); + AttributeDiffSet.Clear(); } public RenderBatch ToBatch() diff --git a/src/Microsoft.AspNetCore.Blazor/Routing/NavLink.cs b/src/Microsoft.AspNetCore.Blazor/Routing/NavLink.cs index 84c8f03d47..8277f076f5 100644 --- a/src/Microsoft.AspNetCore.Blazor/Routing/NavLink.cs +++ b/src/Microsoft.AspNetCore.Blazor/Routing/NavLink.cs @@ -106,7 +106,7 @@ namespace Microsoft.AspNetCore.Blazor.Routing builder.AddAttribute(0, "class", CombineWithSpace(_cssClass, _isActive ? "active" : null)); // Pass through all other attributes unchanged - foreach (var kvp in _allAttributes.Where(kvp => kvp.Key != "class")) + foreach (var kvp in _allAttributes.Where(kvp => kvp.Key != "class" && kvp.Key != nameof(RenderTreeBuilder.ChildContent))) { builder.AddAttribute(0, kvp.Key, kvp.Value); } diff --git a/test/Microsoft.AspNetCore.Blazor.Build.Test/BindRazorIntegrationTest.cs b/test/Microsoft.AspNetCore.Blazor.Build.Test/BindRazorIntegrationTest.cs index 94785c1d7e..47e8434ec2 100644 --- a/test/Microsoft.AspNetCore.Blazor.Build.Test/BindRazorIntegrationTest.cs +++ b/test/Microsoft.AspNetCore.Blazor.Build.Test/BindRazorIntegrationTest.cs @@ -379,9 +379,8 @@ namespace Test // Assert Assert.Collection( frames, - frame => AssertFrame.Element(frame, "input", 4, 0), + frame => AssertFrame.Element(frame, "input", 3, 0), frame => AssertFrame.Attribute(frame, "type", "checkbox", 1), - frame => AssertFrame.Attribute(frame, "value", "False", 2), frame => AssertFrame.Attribute(frame, "onchange", typeof(UIEventHandler), 3), frame => AssertFrame.Whitespace(frame, 4)); } diff --git a/test/Microsoft.AspNetCore.Blazor.Build.Test/RenderingRazorIntegrationTest.cs b/test/Microsoft.AspNetCore.Blazor.Build.Test/RenderingRazorIntegrationTest.cs index 45b9dc40b3..d11ee173be 100644 --- a/test/Microsoft.AspNetCore.Blazor.Build.Test/RenderingRazorIntegrationTest.cs +++ b/test/Microsoft.AspNetCore.Blazor.Build.Test/RenderingRazorIntegrationTest.cs @@ -480,7 +480,7 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test var frames = GetRenderTree(component); Assert.Collection(frames, frame => AssertFrame.Element(frame, "input", 3, 0), - frame => AssertFrame.Attribute(frame, "value", "True", 1), + frame => AssertFrame.Attribute(frame, "value", true, 1), frame => { AssertFrame.Attribute(frame, "onchange", 2); diff --git a/test/Microsoft.AspNetCore.Blazor.Test/RenderTreeBuilderTest.cs b/test/Microsoft.AspNetCore.Blazor.Test/RenderTreeBuilderTest.cs index aa111caef1..7da569fdc1 100644 --- a/test/Microsoft.AspNetCore.Blazor.Test/RenderTreeBuilderTest.cs +++ b/test/Microsoft.AspNetCore.Blazor.Test/RenderTreeBuilderTest.cs @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Blazor.Test.Helpers; using System; using System.Linq; using Xunit; +using Xunit.Extensions; namespace Microsoft.AspNetCore.Blazor.Test { @@ -386,6 +387,326 @@ namespace Microsoft.AspNetCore.Blazor.Test Assert.Empty(builder.GetFrames()); } + [Fact] + public void AddAttribute_Element_BoolTrue_AddsFrame() + { + // Arrange + var builder = new RenderTreeBuilder(new TestRenderer()); + + // Act + builder.OpenElement(0, "elem"); + builder.AddAttribute(1, "attr", true); + builder.CloseElement(); + + // Assert + Assert.Collection( + builder.GetFrames(), + frame => AssertFrame.Element(frame, "elem", 2, 0), + frame => AssertFrame.Attribute(frame, "attr", true, 1)); + } + + [Fact] + public void AddAttribute_Element_BoolFalse_IgnoresFrame() + { + // Arrange + var builder = new RenderTreeBuilder(new TestRenderer()); + + // Act + builder.OpenElement(0, "elem"); + builder.AddAttribute(1, "attr", false); + builder.CloseElement(); + + // Assert + Assert.Collection( + builder.GetFrames(), + frame => AssertFrame.Element(frame, "elem", 1, 0)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AddAttribute_Component_Bool_SetsAttributeValue(bool value) + { + // Arrange + var builder = new RenderTreeBuilder(new TestRenderer()); + + // Act + builder.OpenComponent(0); + builder.AddAttribute(1, "attr", value); + builder.CloseComponent(); + + // Assert + Assert.Collection( + builder.GetFrames(), + frame => AssertFrame.Component(frame, 2, 0), + frame => AssertFrame.Attribute(frame, "attr", value, 1)); + } + + [Fact] + public void AddAttribute_Element_StringValue_AddsFrame() + { + // Arrange + var builder = new RenderTreeBuilder(new TestRenderer()); + + // Act + builder.OpenElement(0, "elem"); + builder.AddAttribute(1, "attr", "hi"); + builder.CloseElement(); + + // Assert + Assert.Collection( + builder.GetFrames(), + frame => AssertFrame.Element(frame, "elem", 2, 0), + frame => AssertFrame.Attribute(frame, "attr", "hi", 1)); + } + + [Fact] + public void AddAttribute_Element_StringNull_IgnoresFrame() + { + // Arrange + var builder = new RenderTreeBuilder(new TestRenderer()); + + // Act + builder.OpenElement(0, "elem"); + builder.AddAttribute(1, "attr", (string)null); + builder.CloseElement(); + + // Assert + Assert.Collection( + builder.GetFrames(), + frame => AssertFrame.Element(frame, "elem", 1, 0)); + } + + [Theory] + [InlineData("hi")] + [InlineData(null)] + public void AddAttribute_Component_StringValue_SetsAttributeValue(string value) + { + // Arrange + var builder = new RenderTreeBuilder(new TestRenderer()); + + // Act + builder.OpenComponent(0); + builder.AddAttribute(1, "attr", value); + builder.CloseComponent(); + + // Assert + Assert.Collection( + builder.GetFrames(), + frame => AssertFrame.Component(frame, 2, 0), + frame => AssertFrame.Attribute(frame, "attr", value, 1)); + } + + [Fact] + public void AddAttribute_Element_EventHandler_AddsFrame() + { + // Arrange + var builder = new RenderTreeBuilder(new TestRenderer()); + + var value = new UIEventHandler((e) => { }); + + // Act + builder.OpenElement(0, "elem"); + builder.AddAttribute(1, "attr", value); + builder.CloseElement(); + + // Assert + Assert.Collection( + builder.GetFrames(), + frame => AssertFrame.Element(frame, "elem", 2, 0), + frame => AssertFrame.Attribute(frame, "attr", value, 1)); + } + + [Fact] + public void AddAttribute_Element_NullEventHandler_IgnoresFrame() + { + // Arrange + var builder = new RenderTreeBuilder(new TestRenderer()); + + // Act + builder.OpenElement(0, "elem"); + builder.AddAttribute(1, "attr", (UIEventHandler)null); + builder.CloseElement(); + + // Assert + Assert.Collection( + builder.GetFrames(), + frame => AssertFrame.Element(frame, "elem", 1, 0)); + } + + public static TheoryData UIEventHandlerValues => new TheoryData + { + null, + (e) => { }, + }; + + [Theory] + [MemberData(nameof(UIEventHandlerValues))] + public void AddAttribute_Component_EventHandlerValue_SetsAttributeValue(UIEventHandler value) + { + // Arrange + var builder = new RenderTreeBuilder(new TestRenderer()); + + // Act + builder.OpenComponent(0); + builder.AddAttribute(1, "attr", value); + builder.CloseComponent(); + + // Assert + Assert.Collection( + builder.GetFrames(), + frame => AssertFrame.Component(frame, 2, 0), + frame => AssertFrame.Attribute(frame, "attr", value, 1)); + } + + [Fact] + public void AddAttribute_Element_ObjectBoolTrue_AddsFrame() + { + // Arrange + var builder = new RenderTreeBuilder(new TestRenderer()); + + // Act + builder.OpenElement(0, "elem"); + builder.AddAttribute(1, "attr", (object)true); + builder.CloseElement(); + + // Assert + Assert.Collection( + builder.GetFrames(), + frame => AssertFrame.Element(frame, "elem", 2, 0), + frame => AssertFrame.Attribute(frame, "attr", true, 1)); + } + + [Fact] + public void AddAttribute_Element_ObjectBoolFalse_IgnoresFrame() + { + // Arrange + var builder = new RenderTreeBuilder(new TestRenderer()); + + // Act + builder.OpenElement(0, "elem"); + builder.AddAttribute(1, "attr", (object)false); + builder.CloseElement(); + + // Assert + Assert.Collection( + builder.GetFrames(), + frame => AssertFrame.Element(frame, "elem", 1, 0)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AddAttribute_Component_ObjectBoolValue_SetsAttributeValue(bool value) + { + // Arrange + var builder = new RenderTreeBuilder(new TestRenderer()); + + // Act + builder.OpenComponent(0); + builder.AddAttribute(1, "attr", (object)value); + builder.CloseComponent(); + + // Assert + Assert.Collection( + builder.GetFrames(), + frame => AssertFrame.Component(frame, 2, 0), + frame => AssertFrame.Attribute(frame, "attr", value, 1)); + } + + [Fact] + public void AddAttribute_Element_ObjectStringValue_AddsFrame() + { + // Arrange + var builder = new RenderTreeBuilder(new TestRenderer()); + + // Act + builder.OpenElement(0, "elem"); + builder.AddAttribute(1, "attr", (object)"hi"); + builder.CloseElement(); + + // Assert + Assert.Collection( + builder.GetFrames(), + frame => AssertFrame.Element(frame, "elem", 2, 0), + frame => AssertFrame.Attribute(frame, "attr", "hi", 1)); + } + + [Fact] + public void AddAttribute_Component_ObjectStringValue_SetsAttributeValue() + { + // Arrange + var builder = new RenderTreeBuilder(new TestRenderer()); + + // Act + builder.OpenComponent(0); + builder.AddAttribute(1, "attr", (object)"hi"); + builder.CloseComponent(); + + // Assert + Assert.Collection( + builder.GetFrames(), + frame => AssertFrame.Component(frame, 2, 0), + frame => AssertFrame.Attribute(frame, "attr", "hi", 1)); + } + + [Fact] + public void AddAttribute_Element_ObjectEventHandler_AddsFrame() + { + // Arrange + var builder = new RenderTreeBuilder(new TestRenderer()); + + var value = new UIEventHandler((e) => { }); + + // Act + builder.OpenElement(0, "elem"); + builder.AddAttribute(1, "attr", (object)value); + builder.CloseElement(); + + // Assert + Assert.Collection( + builder.GetFrames(), + frame => AssertFrame.Element(frame, "elem", 2, 0), + frame => AssertFrame.Attribute(frame, "attr", value, 1)); + } + + [Fact] + public void AddAttribute_Component_ObjectEventHandleValue_SetsAttributeValue() + { + // Arrange + var builder = new RenderTreeBuilder(new TestRenderer()); + + var value = new UIEventHandler((e) => { }); + + // Act + builder.OpenComponent(0); + builder.AddAttribute(1, "attr", (object)value); + builder.CloseComponent(); + + // Assert + Assert.Collection( + builder.GetFrames(), + frame => AssertFrame.Component(frame, 2, 0), + frame => AssertFrame.Attribute(frame, "attr", value, 1)); + } + + [Fact] + public void AddAttribute_Element_ObjectNull_IgnoresFrame() + { + // Arrange + var builder = new RenderTreeBuilder(new TestRenderer()); + + // Act + builder.OpenElement(0, "elem"); + builder.AddAttribute(1, "attr", (object)null); + builder.CloseElement(); + + // Assert + Assert.Collection( + builder.GetFrames(), + frame => AssertFrame.Element(frame, "elem", 1, 0)); + } + private class TestComponent : IComponent { public void Init(RenderHandle renderHandle) { } diff --git a/test/Microsoft.AspNetCore.Blazor.Test/RenderTreeDiffBuilderTest.cs b/test/Microsoft.AspNetCore.Blazor.Test/RenderTreeDiffBuilderTest.cs index 75a7191756..61b3992fdf 100644 --- a/test/Microsoft.AspNetCore.Blazor.Test/RenderTreeDiffBuilderTest.cs +++ b/test/Microsoft.AspNetCore.Blazor.Test/RenderTreeDiffBuilderTest.cs @@ -475,6 +475,352 @@ namespace Microsoft.AspNetCore.Blazor.Test AssertFrame.Attribute(referenceFrames[0], "newname", "same value"); } + [Fact] + public void AttributeDiff_WithSameSequenceNumber_AttributeAddedAtStart() + { + // Arrange + oldTree.OpenElement(0, "My element"); + oldTree.AddAttribute(0, "attr2", "value2"); + oldTree.AddAttribute(0, "attr3", "value3"); + oldTree.CloseElement(); + newTree.OpenElement(0, "My element"); + newTree.AddAttribute(0, "attr1", "value1"); + newTree.AddAttribute(0, "attr2", "value2"); + newTree.AddAttribute(0, "attr3", "value3"); + newTree.CloseElement(); + + // Act + var (result, referenceFrames) = GetSingleUpdatedComponent(); + + // Assert + Assert.Collection( + result.Edits, + entry => + { + AssertEdit(entry, RenderTreeEditType.SetAttribute, 0); + Assert.Equal(0, entry.ReferenceFrameIndex); + }); + Assert.Collection( + referenceFrames, + frame => AssertFrame.Attribute(frame, "attr1", 0)); + } + + [Fact] + public void AttributeDiff_WithSameSequenceNumber_AttributeAddedInMiddle() + { + // Arrange + oldTree.OpenElement(0, "My element"); + oldTree.AddAttribute(0, "attr1", "value1"); + oldTree.AddAttribute(0, "attr3", "value3"); + oldTree.CloseElement(); + newTree.OpenElement(0, "My element"); + newTree.AddAttribute(0, "attr1", "value1"); + newTree.AddAttribute(0, "attr2", "value2"); + newTree.AddAttribute(0, "attr3", "value3"); + newTree.CloseElement(); + + // Act + var (result, referenceFrames) = GetSingleUpdatedComponent(); + + // Assert + Assert.Collection( + result.Edits, + entry => + { + AssertEdit(entry, RenderTreeEditType.SetAttribute, 0); + Assert.Equal(0, entry.ReferenceFrameIndex); + }); + + Assert.Collection( + referenceFrames, + frame => AssertFrame.Attribute(frame, "attr2", 0)); + } + + [Fact] + public void AttributeDiff_WithSameSequenceNumber_AttributeAddedAtEnd() + { + // Arrange + oldTree.OpenElement(0, "My element"); + oldTree.AddAttribute(0, "attr1", "value1"); + oldTree.AddAttribute(0, "attr2", "value2"); + oldTree.CloseElement(); + newTree.OpenElement(0, "My element"); + newTree.AddAttribute(0, "attr1", "value1"); + newTree.AddAttribute(0, "attr2", "value2"); + newTree.AddAttribute(0, "attr3", "value3"); + newTree.CloseElement(); + + // Act + var (result, referenceFrames) = GetSingleUpdatedComponent(); + + // Assert + Assert.Collection( + result.Edits, + entry => + { + AssertEdit(entry, RenderTreeEditType.SetAttribute, 0); + Assert.Equal(0, entry.ReferenceFrameIndex); + }); + + Assert.Collection( + referenceFrames, + frame => AssertFrame.Attribute(frame, "attr3", 0)); + } + + [Fact] + public void AttributeDiff_WithSequentialSequenceNumber_AttributeAddedAtStart() + { + // Arrange + oldTree.OpenElement(0, "My element"); + oldTree.AddAttribute(2, "attr2", "value2"); + oldTree.AddAttribute(3, "attr3", "value3"); + oldTree.CloseElement(); + newTree.OpenElement(0, "My element"); + newTree.AddAttribute(1, "attr1", "value1"); + newTree.AddAttribute(2, "attr2", "value2"); + newTree.AddAttribute(3, "attr3", "value3"); + newTree.CloseElement(); + + // Act + var (result, referenceFrames) = GetSingleUpdatedComponent(); + + // Assert + Assert.Collection( + result.Edits, + entry => + { + AssertEdit(entry, RenderTreeEditType.SetAttribute, 0); + Assert.Equal(0, entry.ReferenceFrameIndex); + }); + Assert.Collection( + referenceFrames, + frame => AssertFrame.Attribute(frame, "attr1", 1)); + } + + [Fact] + public void AttributeDiff_WithSequentialSequenceNumber_AttributeAddedInMiddle() + { + // Arrange + oldTree.OpenElement(0, "My element"); + oldTree.AddAttribute(1, "attr1", "value1"); + oldTree.AddAttribute(3, "attr3", "value3"); + oldTree.CloseElement(); + newTree.OpenElement(0, "My element"); + newTree.AddAttribute(1, "attr1", "value1"); + newTree.AddAttribute(2, "attr2", "value2"); + newTree.AddAttribute(3, "attr3", "value3"); + newTree.CloseElement(); + + // Act + var (result, referenceFrames) = GetSingleUpdatedComponent(); + + // Assert + Assert.Collection( + result.Edits, + entry => + { + AssertEdit(entry, RenderTreeEditType.SetAttribute, 0); + Assert.Equal(0, entry.ReferenceFrameIndex); + }); + + Assert.Collection( + referenceFrames, + frame => AssertFrame.Attribute(frame, "attr2", 2)); + } + + [Fact] + public void AttributeDiff_WithSequentialSequenceNumber_AttributeAddedAtEnd() + { + // Arrange + oldTree.OpenElement(0, "My element"); + oldTree.AddAttribute(1, "attr1", "value1"); + oldTree.AddAttribute(2, "attr2", "value2"); + oldTree.CloseElement(); + newTree.OpenElement(0, "My element"); + newTree.AddAttribute(1, "attr1", "value1"); + newTree.AddAttribute(2, "attr2", "value2"); + newTree.AddAttribute(3, "attr3", "value3"); + newTree.CloseElement(); + + // Act + var (result, referenceFrames) = GetSingleUpdatedComponent(); + + // Assert + Assert.Collection( + result.Edits, + entry => + { + AssertEdit(entry, RenderTreeEditType.SetAttribute, 0); + Assert.Equal(0, entry.ReferenceFrameIndex); + }); + + Assert.Collection( + referenceFrames, + frame => AssertFrame.Attribute(frame, "attr3", 3)); + } + + [Fact] + public void AttributeDiff_WithSameSequenceNumber_AttributeRemovedAtStart() + { + // Arrange + oldTree.OpenElement(0, "My element"); + oldTree.AddAttribute(0, "attr1", "value1"); + oldTree.AddAttribute(0, "attr2", "value2"); + oldTree.AddAttribute(0, "attr3", "value3"); + oldTree.CloseElement(); + newTree.OpenElement(0, "My element"); + newTree.AddAttribute(0, "attr2", "value2"); + newTree.AddAttribute(0, "attr3", "value3"); + newTree.CloseElement(); + + // Act + var (result, referenceFrames) = GetSingleUpdatedComponent(); + + // Assert + Assert.Collection( + result.Edits, + entry => + { + AssertEdit(entry, RenderTreeEditType.RemoveAttribute, 0); + Assert.Equal("attr1", entry.RemovedAttributeName); + }); + } + + [Fact] + public void AttributeDiff_WithSameSequenceNumber_AttributeRemovedInMiddle() + { + // Arrange + oldTree.OpenElement(0, "My element"); + oldTree.AddAttribute(0, "attr1", "value1"); + oldTree.AddAttribute(0, "attr2", "value2"); + oldTree.AddAttribute(0, "attr3", "value3"); + oldTree.CloseElement(); + newTree.OpenElement(0, "My element"); + newTree.AddAttribute(0, "attr1", "value1"); + newTree.AddAttribute(0, "attr3", "value3"); + newTree.CloseElement(); + + // Act + var (result, referenceFrames) = GetSingleUpdatedComponent(); + + // Assert + Assert.Collection( + result.Edits, + entry => + { + AssertEdit(entry, RenderTreeEditType.RemoveAttribute, 0); + Assert.Equal("attr2", entry.RemovedAttributeName); + }); + } + + [Fact] + public void AttributeDiff_WithSameSequenceNumber_AttributeRemovedAtEnd() + { + // Arrange + oldTree.OpenElement(0, "My element"); + oldTree.AddAttribute(0, "attr1", "value1"); + oldTree.AddAttribute(0, "attr2", "value2"); + oldTree.AddAttribute(0, "attr3", "value3"); + oldTree.CloseElement(); + newTree.OpenElement(0, "My element"); + newTree.AddAttribute(0, "attr1", "value1"); + newTree.AddAttribute(0, "attr2", "value2"); + newTree.CloseElement(); + + // Act + var (result, referenceFrames) = GetSingleUpdatedComponent(); + + // Assert + Assert.Collection( + result.Edits, + entry => + { + AssertEdit(entry, RenderTreeEditType.RemoveAttribute, 0); + Assert.Equal("attr3", entry.RemovedAttributeName); + }); + } + + [Fact] + public void AttributeDiff_WithSequentialSequenceNumber_AttributeRemovedAtStart() + { + // Arrange + oldTree.OpenElement(0, "My element"); + oldTree.AddAttribute(1, "attr1", "value1"); + oldTree.AddAttribute(2, "attr2", "value2"); + oldTree.AddAttribute(3, "attr3", "value3"); + oldTree.CloseElement(); + newTree.OpenElement(0, "My element"); + newTree.AddAttribute(2, "attr2", "value2"); + newTree.AddAttribute(3, "attr3", "value3"); + newTree.CloseElement(); + + // Act + var (result, referenceFrames) = GetSingleUpdatedComponent(); + + // Assert + Assert.Collection( + result.Edits, + entry => + { + AssertEdit(entry, RenderTreeEditType.RemoveAttribute, 0); + Assert.Equal("attr1", entry.RemovedAttributeName); + }); + } + + [Fact] + public void AttributeDiff_WithSequentialSequenceNumber_AttributeRemovedInMiddle() + { + // Arrange + oldTree.OpenElement(0, "My element"); + oldTree.AddAttribute(1, "attr1", "value1"); + oldTree.AddAttribute(2, "attr2", "value2"); + oldTree.AddAttribute(3, "attr3", "value3"); + oldTree.CloseElement(); + newTree.OpenElement(0, "My element"); + newTree.AddAttribute(1, "attr1", "value1"); + newTree.AddAttribute(3, "attr3", "value3"); + newTree.CloseElement(); + + // Act + var (result, referenceFrames) = GetSingleUpdatedComponent(); + + // Assert + Assert.Collection( + result.Edits, + entry => + { + AssertEdit(entry, RenderTreeEditType.RemoveAttribute, 0); + Assert.Equal("attr2", entry.RemovedAttributeName); + }); + } + + [Fact] + public void AttributeDiff_WithSequentialSequenceNumber_AttributeRemovedAtEnd() + { + // Arrange + oldTree.OpenElement(0, "My element"); + oldTree.AddAttribute(1, "attr1", "value1"); + oldTree.AddAttribute(2, "attr2", "value2"); + oldTree.AddAttribute(3, "attr3", "value3"); + oldTree.CloseElement(); + newTree.OpenElement(0, "My element"); + newTree.AddAttribute(1, "attr1", "value1"); + newTree.AddAttribute(2, "attr2", "value2"); + newTree.CloseElement(); + + // Act + var (result, referenceFrames) = GetSingleUpdatedComponent(); + + // Assert + Assert.Collection( + result.Edits, + entry => + { + AssertEdit(entry, RenderTreeEditType.RemoveAttribute, 0); + Assert.Equal("attr3", entry.RemovedAttributeName); + }); + } + [Fact] public void DiffsElementsHierarchically() { @@ -804,6 +1150,57 @@ namespace Microsoft.AspNetCore.Blazor.Test Assert.Same(originalFakeComponent2Instance, newFrame2.Component); } + [Fact] + public void PreservesEventHandlerIdsForRetainedEventHandlers() + { + // Arrange + UIEventHandler retainedHandler = _ => { }; + oldTree.OpenElement(0, "My element"); + oldTree.AddAttribute(1, "will remain", retainedHandler); + oldTree.CloseElement(); + newTree.OpenElement(0, "My element"); + newTree.AddAttribute(1, "will remain", retainedHandler); + newTree.CloseElement(); + + // Act + var (result, referenceFrames) = GetSingleUpdatedComponent(initializeFromFrames: true); + var oldAttributeFrame = oldTree.GetFrames().Array[1]; + var newAttributeFrame = newTree.GetFrames().Array[1]; + + // Assert + Assert.Empty(result.Edits); + AssertFrame.Attribute(oldAttributeFrame, "will remain", retainedHandler); + AssertFrame.Attribute(newAttributeFrame, "will remain", retainedHandler); + Assert.NotEqual(0, oldAttributeFrame.AttributeEventHandlerId); + Assert.Equal(oldAttributeFrame.AttributeEventHandlerId, newAttributeFrame.AttributeEventHandlerId); + } + + [Fact] + public void PreservesEventHandlerIdsForRetainedEventHandlers_SlowPath() + { + // Arrange + UIEventHandler retainedHandler = _ => { }; + oldTree.OpenElement(0, "My element"); + oldTree.AddAttribute(0, "will remain", retainedHandler); + oldTree.CloseElement(); + newTree.OpenElement(0, "My element"); + newTree.AddAttribute(0, "another-attribute", "go down the slow path please"); + newTree.AddAttribute(0, "will remain", retainedHandler); + newTree.CloseElement(); + + // Act + var (result, referenceFrames) = GetSingleUpdatedComponent(initializeFromFrames: true); + var oldAttributeFrame = oldTree.GetFrames().Array[1]; + var newAttributeFrame = newTree.GetFrames().Array[2]; + + // Assert + Assert.Single(result.Edits); + AssertFrame.Attribute(oldAttributeFrame, "will remain", retainedHandler); + AssertFrame.Attribute(newAttributeFrame, "will remain", retainedHandler); + Assert.NotEqual(0, oldAttributeFrame.AttributeEventHandlerId); + Assert.Equal(oldAttributeFrame.AttributeEventHandlerId, newAttributeFrame.AttributeEventHandlerId); + } + [Fact] public void SetsUpdatedParametersOnChildComponents() {