diff --git a/src/Microsoft.Blazor/Properties/AssemblyInfo.cs b/src/Microsoft.Blazor/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..19ed5e7c8c --- /dev/null +++ b/src/Microsoft.Blazor/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.Blazor.Test")] diff --git a/src/Microsoft.Blazor/RenderTree/RenderTreeDiff.cs b/src/Microsoft.Blazor/RenderTree/RenderTreeDiff.cs new file mode 100644 index 0000000000..af0835980f --- /dev/null +++ b/src/Microsoft.Blazor/RenderTree/RenderTreeDiff.cs @@ -0,0 +1,214 @@ +// 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; + +namespace Microsoft.Blazor.RenderTree +{ + internal class RenderTreeDiff + { + private const int MinBufferLength = 10; + private RenderTreeDiffEntry[] _entries = new RenderTreeDiffEntry[10]; + private int _entriesInUse = 0; + + public ArraySegment ComputeDifference( + ArraySegment oldTree, + ArraySegment newTree) + { + _entriesInUse = 0; + AppendDiffEntriesForRange(oldTree.Array, 0, oldTree.Count, newTree.Array, 0, newTree.Count); + + // If the previous usage of the buffer showed that we have allocated + // much more space than needed, free up the excess memory + var shrinkToLength = Math.Max(MinBufferLength, _entries.Length / 2); + if (_entriesInUse < shrinkToLength) + { + Array.Resize(ref _entries, shrinkToLength); + } + + return new ArraySegment(_entries, 0, _entriesInUse); + } + + private void AppendDiffEntriesForRange( + RenderTreeNode[] oldTree, int oldStartIndex, int oldEndIndexExcl, + RenderTreeNode[] newTree, int newStartIndex, int newEndIndexExcl) + { + var hasMoreOld = oldEndIndexExcl > 0; + var hasMoreNew = newEndIndexExcl > 0; + var prevOldSeq = -1; + var prevNewSeq = -1; + while (hasMoreOld || hasMoreNew) + { + var oldSeq = hasMoreOld ? oldTree[oldStartIndex].Sequence : int.MaxValue; + var newSeq = hasMoreNew ? newTree[newStartIndex].Sequence : int.MaxValue; + + if (oldSeq == newSeq) + { + AppendDiffEntriesForNodesWithSameSequence(oldTree, oldStartIndex, newTree, newStartIndex); + oldStartIndex = NextSiblingIndex(oldTree, oldStartIndex); + newStartIndex = NextSiblingIndex(newTree, newStartIndex); + hasMoreOld = oldEndIndexExcl > oldStartIndex; + hasMoreNew = newEndIndexExcl > newStartIndex; + prevOldSeq = oldSeq; + prevNewSeq = newSeq; + } + else + { + bool treatAsInsert; + var oldLoopedBack = oldSeq <= prevOldSeq; + var newLoopedBack = newSeq <= prevNewSeq; + if (oldLoopedBack == newLoopedBack) + { + // Both sequences are proceeding through the same loop block, so do a simple + // preordered merge join (picking from whichever side brings us closer to being + // back in sync) + treatAsInsert = newSeq < oldSeq; + } + else if (oldLoopedBack) + { + // Old sequence looped back but new one didn't + // The new sequence either has some extra trailing elements in the current loop block + // which we should insert, or omits some old trailing loop blocks which we should delete + var newLoopsBackLater = false; + for (var testIndex = newStartIndex + 1; testIndex < newEndIndexExcl; testIndex++) + { + if (newTree[testIndex].Sequence < newSeq) + { + newLoopsBackLater = true; + break; + } + } + + // If the new sequence loops back later to an earlier point than this, + // then we know it's part of the existing loop block (so should be inserted). + // If not, then it's unrelated to the previous loop block (so we should treat + // the old items as trailing loop blocks to be removed). + treatAsInsert = newLoopsBackLater; + } + else + { + // New sequence looped back but old one didn't + // The old sequence either has some extra trailing elements in the current loop block + // which we should delete, or the new sequence has extra trailing loop blocks which we + // should insert + var oldLoopsBackLater = false; + for (var testIndex = oldStartIndex + 1; testIndex < oldEndIndexExcl; testIndex++) + { + if (oldTree[testIndex].Sequence < oldSeq) + { + oldLoopsBackLater = true; + break; + } + } + + // If the old sequence loops back later to an earlier point than this, + // then we know it's part of the existing loop block (so should be removed). + // If not, then it's unrelated to the previous loop block (so we should treat + // the new items as trailing loop blocks to be inserted). + treatAsInsert = !oldLoopsBackLater; + } + + if (treatAsInsert) + { + Append(RenderTreeDiffEntry.PrependNode(newStartIndex)); + newStartIndex = NextSiblingIndex(newTree, newStartIndex); + hasMoreNew = newEndIndexExcl > newStartIndex; + prevNewSeq = newSeq; + } + else + { + Append(RenderTreeDiffEntry.RemoveNode()); + oldStartIndex = NextSiblingIndex(oldTree, oldStartIndex); + hasMoreOld = oldEndIndexExcl > oldStartIndex; + prevOldSeq = oldSeq; + } + } + } + } + + private static int NextSiblingIndex(RenderTreeNode[] tree, int nodeIndex) + { + var descendantsEndIndex = tree[nodeIndex].ElementDescendantsEndIndex; + return (descendantsEndIndex == 0 ? nodeIndex : descendantsEndIndex) + 1; + } + + private void AppendDiffEntriesForNodesWithSameSequence( + RenderTreeNode[] oldTree, int oldNodeIndex, + RenderTreeNode[] newTree, int newNodeIndex) + { + // We can assume that the old and new nodes are of the same type, because they correspond + // to the same sequence number (and if not, the behaviour is undefined). + switch (newTree[newNodeIndex].NodeType) + { + case RenderTreeNodeType.Text: + { + var oldText = oldTree[oldNodeIndex].TextContent; + var newText = newTree[newNodeIndex].TextContent; + if (string.Equals(oldText, newText, StringComparison.Ordinal)) + { + Append(RenderTreeDiffEntry.Continue()); + } + else + { + Append(RenderTreeDiffEntry.UpdateText(newNodeIndex)); + } + break; + } + + case RenderTreeNodeType.Element: + { + var oldElementName = oldTree[oldNodeIndex].ElementName; + var newElementName = newTree[newNodeIndex].ElementName; + if (string.Equals(oldElementName, newElementName, StringComparison.Ordinal)) + { + // TODO: Compare attributes + // TODO: Then, recurse into children + Append(RenderTreeDiffEntry.Continue()); + } + else + { + // Elements with different names are treated as completely unrelated + Append(RenderTreeDiffEntry.PrependNode(newNodeIndex)); + Append(RenderTreeDiffEntry.RemoveNode()); + } + break; + } + + case RenderTreeNodeType.Component: + { + var oldComponentType = oldTree[oldNodeIndex].ComponentType; + var newComponentType = newTree[newNodeIndex].ComponentType; + if (oldComponentType == newComponentType) + { + // TODO: Compare attributes and notify the existing child component + // instance of any changes + // TODO: Also copy the existing child component instance to the new + // tree so we can preserve it across multiple updates + + Append(RenderTreeDiffEntry.Continue()); + } + else + { + // Child components of different types are treated as completely unrelated + Append(RenderTreeDiffEntry.PrependNode(newNodeIndex)); + Append(RenderTreeDiffEntry.RemoveNode()); + } + break; + } + + default: + throw new NotImplementedException($"Not yet implemented: diffing for nodes of type {newTree[newNodeIndex].NodeType}"); + } + } + + private void Append(RenderTreeDiffEntry entry) + { + if (_entriesInUse == _entries.Length) + { + Array.Resize(ref _entries, _entries.Length * 2); + } + + _entries[_entriesInUse++] = entry; + } + } +} diff --git a/src/Microsoft.Blazor/RenderTree/RenderTreeDiffEntry.cs b/src/Microsoft.Blazor/RenderTree/RenderTreeDiffEntry.cs new file mode 100644 index 0000000000..67fb5de7d7 --- /dev/null +++ b/src/Microsoft.Blazor/RenderTree/RenderTreeDiffEntry.cs @@ -0,0 +1,33 @@ +// 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. + +namespace Microsoft.Blazor.RenderTree +{ + internal struct RenderTreeDiffEntry + { + public RenderTreeDiffEntryType Type { get; private set; } + public int NewTreeIndex { get; private set; } + + public static RenderTreeDiffEntry Continue() => new RenderTreeDiffEntry + { + Type = RenderTreeDiffEntryType.Continue + }; + + internal static RenderTreeDiffEntry RemoveNode() => new RenderTreeDiffEntry + { + Type = RenderTreeDiffEntryType.RemoveNode + }; + + internal static RenderTreeDiffEntry PrependNode(int newTreeIndex) => new RenderTreeDiffEntry + { + Type = RenderTreeDiffEntryType.PrependNode, + NewTreeIndex = newTreeIndex + }; + + internal static RenderTreeDiffEntry UpdateText(int newTreeIndex) => new RenderTreeDiffEntry + { + Type = RenderTreeDiffEntryType.UpdateText, + NewTreeIndex = newTreeIndex + }; + } +} diff --git a/src/Microsoft.Blazor/RenderTree/RenderTreeDiffEntryType.cs b/src/Microsoft.Blazor/RenderTree/RenderTreeDiffEntryType.cs new file mode 100644 index 0000000000..20302fa4db --- /dev/null +++ b/src/Microsoft.Blazor/RenderTree/RenderTreeDiffEntryType.cs @@ -0,0 +1,14 @@ +// 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. + +namespace Microsoft.Blazor.RenderTree +{ + internal enum RenderTreeDiffEntryType: int + { + Continue = 1, + PrependNode = 2, + RemoveNode = 3, + UpdateAttribute = 4, + UpdateText = 5, + } +} diff --git a/test/Microsoft.Blazor.Test/RenderTreeBuilderTest.cs b/test/Microsoft.Blazor.Test/RenderTreeBuilderTest.cs index 70d41b4b73..f11f480ef7 100644 --- a/test/Microsoft.Blazor.Test/RenderTreeBuilderTest.cs +++ b/test/Microsoft.Blazor.Test/RenderTreeBuilderTest.cs @@ -298,7 +298,7 @@ namespace Microsoft.Blazor.Test private class TestRenderer : Renderer { - protected override void UpdateDisplay(int componentId, ArraySegment renderTree) + internal protected override void UpdateDisplay(int componentId, ArraySegment renderTree) => throw new NotImplementedException(); } } diff --git a/test/Microsoft.Blazor.Test/RenderTreeDiffTest.cs b/test/Microsoft.Blazor.Test/RenderTreeDiffTest.cs new file mode 100644 index 0000000000..ba18ce3d9b --- /dev/null +++ b/test/Microsoft.Blazor.Test/RenderTreeDiffTest.cs @@ -0,0 +1,326 @@ +// 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 Microsoft.Blazor.Components; +using Microsoft.Blazor.Rendering; +using Microsoft.Blazor.RenderTree; +using System; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace Microsoft.Blazor.Test +{ + public class RenderTreeDiffTest + { + [Theory] + [MemberData(nameof(RecognizesEquivalentNodesAsSameCases))] + public void RecognizesEquivalentNodesAsSame(Action appendAction) + { + // Arrange + var oldTree = new RenderTreeBuilder(new FakeRenderer()); + var newTree = new RenderTreeBuilder(new FakeRenderer()); + var diff = new RenderTreeDiff(); + appendAction(oldTree); + appendAction(newTree); + + // Act + var result = diff.ComputeDifference(oldTree.GetNodes(), newTree.GetNodes()); + + // Assert + Assert.Collection(result, + entry => Assert.Equal(RenderTreeDiffEntryType.Continue, entry.Type)); + } + + public static IEnumerable RecognizesEquivalentNodesAsSameCases() + => new Action[] + { + builder => builder.AddText(0, "Hello"), + builder => builder.OpenElement(0, "Some Element"), + builder => + { + builder.OpenElement(0, "Some Element"); + builder.AddAttribute("My attribute", "My value"); + builder.CloseElement(); + }, + builder => builder.AddComponent(0) + }.Select(x => new object[] { x }); + + [Fact] + public void RecognizesNewItemsBeingInserted() + { + // Arrange + var oldTree = new RenderTreeBuilder(new FakeRenderer()); + var newTree = new RenderTreeBuilder(new FakeRenderer()); + var diff = new RenderTreeDiff(); + oldTree.AddText(0, "text0"); + oldTree.AddText(2, "text2"); + newTree.AddText(0, "text0"); + newTree.AddText(1, "text1"); + newTree.AddText(2, "text2"); + + // Act + var result = diff.ComputeDifference(oldTree.GetNodes(), newTree.GetNodes()); + + // Assert + Assert.Collection(result, + entry => Assert.Equal(RenderTreeDiffEntryType.Continue, entry.Type), + entry => + { + Assert.Equal(RenderTreeDiffEntryType.PrependNode, entry.Type); + Assert.Equal(1, entry.NewTreeIndex); + }, + entry => Assert.Equal(RenderTreeDiffEntryType.Continue, entry.Type)); + } + + [Fact] + public void RecognizesOldItemsBeingRemoved() + { + // Arrange + var oldTree = new RenderTreeBuilder(new FakeRenderer()); + var newTree = new RenderTreeBuilder(new FakeRenderer()); + var diff = new RenderTreeDiff(); + oldTree.AddText(0, "text0"); + oldTree.AddText(1, "text1"); + oldTree.AddText(2, "text2"); + newTree.AddText(0, "text0"); + newTree.AddText(2, "text2"); + + // Act + var result = diff.ComputeDifference(oldTree.GetNodes(), newTree.GetNodes()); + + // Assert + Assert.Collection(result, + entry => Assert.Equal(RenderTreeDiffEntryType.Continue, entry.Type), + entry => Assert.Equal(RenderTreeDiffEntryType.RemoveNode, entry.Type), + entry => Assert.Equal(RenderTreeDiffEntryType.Continue, entry.Type)); + } + + [Fact] + public void RecognizesTrailingSequenceWithinLoopBlockBeingRemoved() + { + // Arrange + var oldTree = new RenderTreeBuilder(new FakeRenderer()); + var newTree = new RenderTreeBuilder(new FakeRenderer()); + var diff = new RenderTreeDiff(); + oldTree.AddText(0, "x"); // Loop start + oldTree.AddText(1, "x"); // Will be removed + oldTree.AddText(2, "x"); // Will be removed + oldTree.AddText(0, "x"); // Loop start + newTree.AddText(0, "x"); // Loop start + newTree.AddText(0, "x"); // Loop start + + // Act + var result = diff.ComputeDifference(oldTree.GetNodes(), newTree.GetNodes()); + + // Assert + Assert.Collection(result, + entry => Assert.Equal(RenderTreeDiffEntryType.Continue, entry.Type), + entry => Assert.Equal(RenderTreeDiffEntryType.RemoveNode, entry.Type), + entry => Assert.Equal(RenderTreeDiffEntryType.RemoveNode, entry.Type), + entry => Assert.Equal(RenderTreeDiffEntryType.Continue, entry.Type)); + } + + [Fact] + public void RecognizesTrailingSequenceWithinLoopBlockBeingAppended() + { + // Arrange + var oldTree = new RenderTreeBuilder(new FakeRenderer()); + var newTree = new RenderTreeBuilder(new FakeRenderer()); + var diff = new RenderTreeDiff(); + oldTree.AddText(0, "x"); // Loop start + oldTree.AddText(0, "x"); // Loop start + newTree.AddText(0, "x"); // Loop start + newTree.AddText(1, "x"); // Will be added + newTree.AddText(2, "x"); // Will be added + newTree.AddText(0, "x"); // Loop start + + // Act + var result = diff.ComputeDifference(oldTree.GetNodes(), newTree.GetNodes()); + + // Assert + Assert.Collection(result, + entry => Assert.Equal(RenderTreeDiffEntryType.Continue, entry.Type), + entry => + { + Assert.Equal(RenderTreeDiffEntryType.PrependNode, entry.Type); + Assert.Equal(1, entry.NewTreeIndex); + }, + entry => + { + Assert.Equal(RenderTreeDiffEntryType.PrependNode, entry.Type); + Assert.Equal(2, entry.NewTreeIndex); + }, + entry => Assert.Equal(RenderTreeDiffEntryType.Continue, entry.Type)); + } + + [Fact] + public void RecognizesTrailingLoopBlockBeingRemoved() + { + // Arrange + var oldTree = new RenderTreeBuilder(new FakeRenderer()); + var newTree = new RenderTreeBuilder(new FakeRenderer()); + var diff = new RenderTreeDiff(); + oldTree.AddText(0, "x"); + oldTree.AddText(1, "x"); + oldTree.AddText(0, "x"); // Will be removed + oldTree.AddText(1, "x"); // Will be removed + newTree.AddText(0, "x"); + newTree.AddText(1, "x"); + + // Act + var result = diff.ComputeDifference(oldTree.GetNodes(), newTree.GetNodes()); + + // Assert + Assert.Collection(result, + entry => Assert.Equal(RenderTreeDiffEntryType.Continue, entry.Type), + entry => Assert.Equal(RenderTreeDiffEntryType.Continue, entry.Type), + entry => Assert.Equal(RenderTreeDiffEntryType.RemoveNode, entry.Type), + entry => Assert.Equal(RenderTreeDiffEntryType.RemoveNode, entry.Type)); + } + + [Fact] + public void RecognizesTrailingLoopBlockBeingAdded() + { + // Arrange + var oldTree = new RenderTreeBuilder(new FakeRenderer()); + var newTree = new RenderTreeBuilder(new FakeRenderer()); + var diff = new RenderTreeDiff(); + oldTree.AddText(0, "x"); + oldTree.AddText(1, "x"); + newTree.AddText(0, "x"); + newTree.AddText(1, "x"); + newTree.AddText(0, "x"); // Will be added + newTree.AddText(1, "x"); // Will be added + + // Act + var result = diff.ComputeDifference(oldTree.GetNodes(), newTree.GetNodes()); + + // Assert + Assert.Collection(result, + entry => Assert.Equal(RenderTreeDiffEntryType.Continue, entry.Type), + entry => Assert.Equal(RenderTreeDiffEntryType.Continue, entry.Type), + entry => + { + Assert.Equal(RenderTreeDiffEntryType.PrependNode, entry.Type); + Assert.Equal(2, entry.NewTreeIndex); + }, + entry => + { + Assert.Equal(RenderTreeDiffEntryType.PrependNode, entry.Type); + Assert.Equal(3, entry.NewTreeIndex); + }); + } + + [Fact] + public void HandlesAdjacentItemsBeingRemovedAndInsertedAtOnce() + { + // Arrange + var oldTree = new RenderTreeBuilder(new FakeRenderer()); + var newTree = new RenderTreeBuilder(new FakeRenderer()); + var diff = new RenderTreeDiff(); + oldTree.AddText(0, "text"); + newTree.AddText(1, "text"); + + // Act + var result = diff.ComputeDifference(oldTree.GetNodes(), newTree.GetNodes()); + + // Assert + Assert.Collection(result, + entry => Assert.Equal(RenderTreeDiffEntryType.RemoveNode, entry.Type), + entry => Assert.Equal(RenderTreeDiffEntryType.PrependNode, entry.Type)); + } + + [Fact] + public void RecognizesTextUpdates() + { + // Arrange + var oldTree = new RenderTreeBuilder(new FakeRenderer()); + var newTree = new RenderTreeBuilder(new FakeRenderer()); + var diff = new RenderTreeDiff(); + oldTree.AddText(123, "old text"); + newTree.AddText(123, "new text"); + + // Act + var result = diff.ComputeDifference(oldTree.GetNodes(), newTree.GetNodes()); + + // Assert + Assert.Collection(result, + entry => + { + Assert.Equal(RenderTreeDiffEntryType.UpdateText, entry.Type); + Assert.Equal(0, entry.NewTreeIndex); + }); + } + + [Fact] + public void RecognizesElementNameChangesAtSameSequenceNumber() + { + // Note: It's not possible to trigger this scenario from a Razor component, because + // a given source sequence can only have a single fixed element name. We might later + // decide just to throw in this scenario, since it's unnecessary to support it. + + // Arrange + var oldTree = new RenderTreeBuilder(new FakeRenderer()); + var newTree = new RenderTreeBuilder(new FakeRenderer()); + var diff = new RenderTreeDiff(); + oldTree.OpenElement(123, "old element"); + oldTree.CloseElement(); + newTree.OpenElement(123, "new element"); + newTree.CloseElement(); + + // Act + var result = diff.ComputeDifference(oldTree.GetNodes(), newTree.GetNodes()); + + // Assert + Assert.Collection(result, + entry => + { + Assert.Equal(RenderTreeDiffEntryType.PrependNode, entry.Type); + Assert.Equal(0, entry.NewTreeIndex); + }, + entry => Assert.Equal(RenderTreeDiffEntryType.RemoveNode, entry.Type)); + } + + [Fact] + public void RecognizesComponentTypeChangesAtSameSequenceNumber() + { + // Arrange + var oldTree = new RenderTreeBuilder(new FakeRenderer()); + var newTree = new RenderTreeBuilder(new FakeRenderer()); + var diff = new RenderTreeDiff(); + oldTree.AddComponent(123); + newTree.AddComponent(123); + + // Act + var result = diff.ComputeDifference(oldTree.GetNodes(), newTree.GetNodes()); + + // Assert + Assert.Collection(result, + entry => + { + Assert.Equal(RenderTreeDiffEntryType.PrependNode, entry.Type); + Assert.Equal(0, entry.NewTreeIndex); + }, + entry => Assert.Equal(RenderTreeDiffEntryType.RemoveNode, entry.Type)); + } + + private class FakeRenderer : Renderer + { + internal protected override void UpdateDisplay(int componentId, ArraySegment renderTree) + => throw new NotImplementedException(); + } + + private class FakeComponent : IComponent + { + public void BuildRenderTree(RenderTreeBuilder builder) + => throw new NotImplementedException(); + } + + private class FakeComponent2 : IComponent + { + public void BuildRenderTree(RenderTreeBuilder builder) + => throw new NotImplementedException(); + } + } +} diff --git a/test/Microsoft.Blazor.Test/RendererTest.cs b/test/Microsoft.Blazor.Test/RendererTest.cs index 0fd27ff48f..0d00d8e60d 100644 --- a/test/Microsoft.Blazor.Test/RendererTest.cs +++ b/test/Microsoft.Blazor.Test/RendererTest.cs @@ -306,7 +306,7 @@ namespace Microsoft.Blazor.Test public new void RenderComponent(int componentId) => base.RenderComponent(componentId); - protected override void UpdateDisplay(int componentId, ArraySegment renderTree) + internal protected override void UpdateDisplay(int componentId, ArraySegment renderTree) { } } @@ -325,7 +325,7 @@ namespace Microsoft.Blazor.Test public new void DispatchEvent(int componentId, int renderTreeIndex, UIEventArgs args) => base.DispatchEvent(componentId, renderTreeIndex, args); - protected override void UpdateDisplay(int componentId, ArraySegment renderTree) + internal protected override void UpdateDisplay(int componentId, ArraySegment renderTree) { RenderTreesByComponentId[componentId] = renderTree; }