// 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.AspNetCore.Blazor.Components; using Microsoft.AspNetCore.Blazor.Rendering; using Microsoft.AspNetCore.Blazor.RenderTree; using Microsoft.AspNetCore.Blazor.Test.Shared; using System; using System.Collections.Generic; using System.Linq; using Xunit; namespace Microsoft.AspNetCore.Blazor.Test { public class RenderTreeDiffComputerTest { private readonly Renderer renderer; private readonly RenderTreeBuilder oldTree; private readonly RenderTreeBuilder newTree; private RenderTreeDiffComputer diff; public RenderTreeDiffComputerTest() { renderer = new FakeRenderer(); oldTree = new RenderTreeBuilder(renderer); newTree = new RenderTreeBuilder(renderer); diff = new RenderTreeDiffComputer(renderer); } [Theory] [MemberData(nameof(RecognizesEquivalentFramesAsSameCases))] public void RecognizesEquivalentFramesAsSame(Action appendAction) { // Arrange appendAction(oldTree); appendAction(newTree); // Act var result = GetSingleUpdatedComponent(); // Assert Assert.Empty(result.Edits); } public static IEnumerable RecognizesEquivalentFramesAsSameCases() => new Action[] { builder => builder.AddText(0, "Hello"), builder => builder.OpenElement(0, "Some Element"), builder => { builder.OpenElement(0, "Some Element"); builder.AddAttribute(1, "My attribute", "My value"); builder.CloseElement(); }, builder => builder.OpenComponent(0) }.Select(x => new object[] { x }); [Fact] public void RecognizesNewItemsBeingInserted() { // Arrange oldTree.AddText(0, "text0"); oldTree.AddText(2, "text2"); newTree.AddText(0, "text0"); newTree.AddText(1, "text1"); newTree.AddText(2, "text2"); // Act var result = GetSingleUpdatedComponent(); // Assert Assert.Collection(result.Edits, entry => { AssertEdit(entry, RenderTreeEditType.PrependFrame, 1); Assert.Equal(0, entry.ReferenceFrameIndex); AssertFrame.Text(result.ReferenceFrames.Array[0], "text1", 1); }); } [Fact] public void RecognizesOldItemsBeingRemoved() { // Arrange oldTree.AddText(0, "text0"); oldTree.AddText(1, "text1"); oldTree.AddText(2, "text2"); newTree.AddText(0, "text0"); newTree.AddText(2, "text2"); // Act var result = GetSingleUpdatedComponent(); // Assert Assert.Collection(result.Edits, entry => AssertEdit(entry, RenderTreeEditType.RemoveFrame, 1)); } [Fact] public void RecognizesTrailingSequenceWithinLoopBlockBeingRemoved() { // Arrange 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 = GetSingleUpdatedComponent(); // Assert Assert.Collection(result.Edits, entry => AssertEdit(entry, RenderTreeEditType.RemoveFrame, 1), entry => AssertEdit(entry, RenderTreeEditType.RemoveFrame, 1)); } [Fact] public void RecognizesTrailingSequenceWithinLoopBlockBeingAppended() { // Arrange oldTree.AddText(10, "x"); // Loop start oldTree.AddText(10, "x"); // Loop start newTree.AddText(10, "x"); // Loop start newTree.AddText(11, "x"); // Will be added newTree.AddText(12, "x"); // Will be added newTree.AddText(10, "x"); // Loop start // Act var result = GetSingleUpdatedComponent(); // Assert Assert.Collection(result.Edits, entry => { AssertEdit(entry, RenderTreeEditType.PrependFrame, 1); Assert.Equal(0, entry.ReferenceFrameIndex); }, entry => { AssertEdit(entry, RenderTreeEditType.PrependFrame, 2); Assert.Equal(1, entry.ReferenceFrameIndex); }); Assert.Collection(result.ReferenceFrames, frame => AssertFrame.Text(frame, "x", 11), frame => AssertFrame.Text(frame, "x", 12)); } [Fact] public void RecognizesTrailingLoopBlockBeingRemoved() { // Arrange 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 = GetSingleUpdatedComponent(); // Assert Assert.Collection(result.Edits, entry => AssertEdit(entry, RenderTreeEditType.RemoveFrame, 2), entry => AssertEdit(entry, RenderTreeEditType.RemoveFrame, 2)); } [Fact] public void RecognizesTrailingLoopBlockBeingAdded() { // Arrange oldTree.AddText(10, "x"); oldTree.AddText(11, "x"); newTree.AddText(10, "x"); newTree.AddText(11, "x"); newTree.AddText(10, "x"); // Will be added newTree.AddText(11, "x"); // Will be added // Act var result = GetSingleUpdatedComponent(); // Assert Assert.Collection(result.Edits, entry => { AssertEdit(entry, RenderTreeEditType.PrependFrame, 2); Assert.Equal(0, entry.ReferenceFrameIndex); }, entry => { AssertEdit(entry, RenderTreeEditType.PrependFrame, 3); Assert.Equal(1, entry.ReferenceFrameIndex); }); Assert.Collection(result.ReferenceFrames, frame => AssertFrame.Text(frame, "x", 10), frame => AssertFrame.Text(frame, "x", 11)); } [Fact] public void RecognizesLeadingLoopBlockItemsBeingAdded() { // Arrange oldTree.AddText(12, "x"); oldTree.AddText(12, "x"); // Note that the '0' and '1' items are not present on this iteration newTree.AddText(12, "x"); newTree.AddText(10, "x"); newTree.AddText(11, "x"); newTree.AddText(12, "x"); // Act var result = GetSingleUpdatedComponent(); // Assert Assert.Collection(result.Edits, entry => { AssertEdit(entry, RenderTreeEditType.PrependFrame, 1); Assert.Equal(0, entry.ReferenceFrameIndex); }, entry => { AssertEdit(entry, RenderTreeEditType.PrependFrame, 2); Assert.Equal(1, entry.ReferenceFrameIndex); }); Assert.Collection(result.ReferenceFrames, frame => AssertFrame.Text(frame, "x", 10), frame => AssertFrame.Text(frame, "x", 11)); } [Fact] public void RecognizesLeadingLoopBlockItemsBeingRemoved() { // Arrange oldTree.AddText(2, "x"); oldTree.AddText(0, "x"); oldTree.AddText(1, "x"); oldTree.AddText(2, "x"); newTree.AddText(2, "x"); newTree.AddText(2, "x"); // Note that the '0' and '1' items are not present on this iteration // Act var result = GetSingleUpdatedComponent(); // Assert Assert.Collection(result.Edits, entry => AssertEdit(entry, RenderTreeEditType.RemoveFrame, 1), entry => AssertEdit(entry, RenderTreeEditType.RemoveFrame, 1)); } [Fact] public void HandlesAdjacentItemsBeingRemovedAndInsertedAtOnce() { // Arrange oldTree.AddText(0, "text"); newTree.AddText(1, "text"); // Act var result = GetSingleUpdatedComponent(); // Assert Assert.Collection(result.Edits, entry => AssertEdit(entry, RenderTreeEditType.RemoveFrame, 0), entry => AssertEdit(entry, RenderTreeEditType.PrependFrame, 0)); } [Fact] public void RecognizesTextUpdates() { // Arrange oldTree.AddText(123, "old text 1"); oldTree.AddText(182, "old text 2"); newTree.AddText(123, "new text 1"); newTree.AddText(182, "new text 2"); // Act var result = GetSingleUpdatedComponent(); // Assert Assert.Collection(result.Edits, entry => { AssertEdit(entry, RenderTreeEditType.UpdateText, 0); Assert.Equal(0, entry.ReferenceFrameIndex); }, entry => { AssertEdit(entry, RenderTreeEditType.UpdateText, 1); Assert.Equal(1, entry.ReferenceFrameIndex); }); } [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 oldTree.OpenElement(123, "old element"); oldTree.CloseElement(); newTree.OpenElement(123, "new element"); newTree.CloseElement(); // Act var result = GetSingleUpdatedComponent(); // Assert Assert.Collection(result.Edits, entry => { AssertEdit(entry, RenderTreeEditType.PrependFrame, 0); Assert.Equal(0, entry.ReferenceFrameIndex); }, entry => AssertEdit(entry, RenderTreeEditType.RemoveFrame, 1)); } [Fact] public void RecognizesComponentTypeChangesAtSameSequenceNumber() { // Arrange oldTree.OpenComponent(123); oldTree.CloseComponent(); newTree.OpenComponent(123); newTree.CloseComponent(); // Act var renderBatch = GetRenderedBatch(); // Assert: Even though we didn't assign IDs to the components, this // shows that FakeComponent was disposed Assert.Collection(renderBatch.DisposedComponentIDs, disposedComponentId => Assert.Equal(0, disposedComponentId)); // Assert: First updated component is the root with one child being // prepended, and its earlier incarnation being removed Assert.Equal(2, renderBatch.UpdatedComponents.Count); var updatedComponent1 = renderBatch.UpdatedComponents.Array[0]; Assert.Collection(updatedComponent1.Edits, entry => { AssertEdit(entry, RenderTreeEditType.PrependFrame, 0); Assert.Equal(0, entry.ReferenceFrameIndex); Assert.IsType(updatedComponent1.ReferenceFrames.Array[0].Component); }, entry => AssertEdit(entry, RenderTreeEditType.RemoveFrame, 1)); // Assert: Second updated component is the new FakeComponent2 var updatedComponent2 = renderBatch.UpdatedComponents.Array[1]; Assert.Collection(updatedComponent2.Edits, entry => { AssertEdit(entry, RenderTreeEditType.PrependFrame, 0); Assert.Equal(0, entry.ReferenceFrameIndex); }); } [Fact] public void RecognizesAttributesAdded() { // Arrange oldTree.OpenElement(0, "My element"); oldTree.AddAttribute(1, "existing", "existing value"); oldTree.CloseElement(); newTree.OpenElement(0, "My element"); newTree.AddAttribute(1, "existing", "existing value"); newTree.AddAttribute(2, "added", "added value"); newTree.CloseElement(); // Act var result = GetSingleUpdatedComponent(); // Assert Assert.Collection(result.Edits, entry => { AssertEdit(entry, RenderTreeEditType.SetAttribute, 0); Assert.Equal(0, entry.ReferenceFrameIndex); }); Assert.Collection(result.ReferenceFrames, frame => AssertFrame.Attribute(frame, "added", "added value")); } [Fact] public void RecognizesAttributesRemoved() { // Arrange oldTree.OpenElement(0, "My element"); oldTree.AddAttribute(1, "will be removed", "will be removed value"); oldTree.AddAttribute(2, "will survive", "surviving value"); oldTree.CloseElement(); newTree.OpenElement(0, "My element"); newTree.AddAttribute(2, "will survive", "surviving value"); newTree.CloseElement(); // Act var result = GetSingleUpdatedComponent(); // Assert Assert.Collection(result.Edits, entry => { AssertEdit(entry, RenderTreeEditType.RemoveAttribute, 0); Assert.Equal("will be removed", entry.RemovedAttributeName); }); } [Fact] public void RecognizesAttributeStringValuesChanged() { // Arrange oldTree.OpenElement(0, "My element"); oldTree.AddAttribute(1, "will remain", "will remain value"); oldTree.AddAttribute(2, "will change", "will change value"); oldTree.CloseElement(); newTree.OpenElement(0, "My element"); newTree.AddAttribute(1, "will remain", "will remain value"); newTree.AddAttribute(2, "will change", "did change value"); newTree.CloseElement(); // Act var result = GetSingleUpdatedComponent(); // Assert Assert.Collection(result.Edits, entry => { AssertEdit(entry, RenderTreeEditType.SetAttribute, 0); Assert.Equal(0, entry.ReferenceFrameIndex); }); Assert.Collection(result.ReferenceFrames, frame => AssertFrame.Attribute(frame, "will change", "did change value")); } [Fact] public void RecognizesAttributeEventHandlerValuesChanged() { // Arrange UIEventHandler retainedHandler = _ => { }; UIEventHandler removedHandler = _ => { }; UIEventHandler addedHandler = _ => { }; oldTree.OpenElement(0, "My element"); oldTree.AddAttribute(1, "will remain", retainedHandler); oldTree.AddAttribute(2, "will change", removedHandler); oldTree.CloseElement(); newTree.OpenElement(0, "My element"); newTree.AddAttribute(1, "will remain", retainedHandler); newTree.AddAttribute(2, "will change", addedHandler); newTree.CloseElement(); // Act var result = GetSingleUpdatedComponent(); // Assert Assert.Collection(result.Edits, entry => { AssertEdit(entry, RenderTreeEditType.SetAttribute, 0); Assert.Equal(0, entry.ReferenceFrameIndex); }); Assert.Collection(result.ReferenceFrames, frame => AssertFrame.Attribute(frame, "will change", addedHandler)); } [Fact] public void RecognizesAttributeNamesChangedAtSameSourceSequence() { // Arrange oldTree.OpenElement(0, "My element"); oldTree.AddAttribute(1, "oldname", "same value"); oldTree.CloseElement(); newTree.OpenElement(0, "My element"); newTree.AddAttribute(1, "newname", "same value"); newTree.CloseElement(); // Act var result = GetSingleUpdatedComponent(); // Assert Assert.Collection(result.Edits, entry => { AssertEdit(entry, RenderTreeEditType.SetAttribute, 0); Assert.Equal(0, entry.ReferenceFrameIndex); }, entry => { AssertEdit(entry, RenderTreeEditType.RemoveAttribute, 0); Assert.Equal("oldname", entry.RemovedAttributeName); }); Assert.Collection(result.ReferenceFrames, frame => AssertFrame.Attribute(frame, "newname", "same value")); } [Fact] public void DiffsElementsHierarchically() { // Arrange oldTree.AddText(09, "unrelated"); oldTree.OpenElement(10, "root"); oldTree.OpenElement(11, "child"); oldTree.OpenElement(12, "grandchild"); oldTree.AddText(13, "grandchild old text"); oldTree.CloseElement(); oldTree.CloseElement(); oldTree.CloseElement(); newTree.AddText(09, "unrelated"); newTree.OpenElement(10, "root"); newTree.OpenElement(11, "child"); newTree.OpenElement(12, "grandchild"); newTree.AddText(13, "grandchild new text"); newTree.CloseElement(); newTree.CloseElement(); newTree.CloseElement(); // Act var result = GetSingleUpdatedComponent(); // Assert Assert.Collection(result.Edits, entry => AssertEdit(entry, RenderTreeEditType.StepIn, 1), entry => AssertEdit(entry, RenderTreeEditType.StepIn, 0), entry => AssertEdit(entry, RenderTreeEditType.StepIn, 0), entry => { AssertEdit(entry, RenderTreeEditType.UpdateText, 0); Assert.Equal(0, entry.ReferenceFrameIndex); }, entry => AssertEdit(entry, RenderTreeEditType.StepOut, 0), entry => AssertEdit(entry, RenderTreeEditType.StepOut, 0), entry => AssertEdit(entry, RenderTreeEditType.StepOut, 0)); Assert.Collection(result.ReferenceFrames, frame => AssertFrame.Text(frame, "grandchild new text", 13)); } [Fact] public void SkipsUnmodifiedSubtrees() { // Arrange oldTree.OpenElement(10, "root"); oldTree.AddText(11, "Text that will change"); oldTree.OpenElement(12, "Subtree that will not change"); oldTree.OpenElement(13, "Another"); oldTree.AddText(14, "Text that will not change"); oldTree.CloseElement(); oldTree.CloseElement(); oldTree.CloseElement(); newTree.OpenElement(10, "root"); newTree.AddText(11, "Text that has changed"); newTree.OpenElement(12, "Subtree that will not change"); newTree.OpenElement(13, "Another"); newTree.AddText(14, "Text that will not change"); newTree.CloseElement(); newTree.CloseElement(); newTree.CloseElement(); // Act var result = GetSingleUpdatedComponent(); // Assert Assert.Collection(result.Edits, entry => AssertEdit(entry, RenderTreeEditType.StepIn, 0), entry => { AssertEdit(entry, RenderTreeEditType.UpdateText, 0); Assert.Equal(0, entry.ReferenceFrameIndex); }, entry => AssertEdit(entry, RenderTreeEditType.StepOut, 0)); Assert.Collection(result.ReferenceFrames, frame => AssertFrame.Text(frame, "Text that has changed", 11)); } [Fact] public void SkipsUnmodifiedTrailingSiblings() { // Arrange oldTree.AddText(10, "text1"); oldTree.AddText(11, "text2"); oldTree.AddText(12, "text3"); oldTree.AddText(13, "text4"); newTree.AddText(10, "text1"); newTree.AddText(11, "text2modified"); newTree.AddText(12, "text3"); newTree.AddText(13, "text4"); // Act var result = GetSingleUpdatedComponent(); // Assert Assert.Collection(result.Edits, entry => { AssertEdit(entry, RenderTreeEditType.UpdateText, 1); Assert.Equal(0, entry.ReferenceFrameIndex); }); Assert.Collection(result.ReferenceFrames, frame => AssertFrame.Text(frame, "text2modified", 11)); } [Fact] public void InstantiatesChildComponentsForInsertedFrames() { // Arrange oldTree.AddText(10, "text1"); // 0: text1 oldTree.OpenElement(11, "container"); // 1: oldTree.CloseElement(); // newTree.AddText(10, "text1"); // 0: text1 newTree.OpenElement(11, "container"); // 1: newTree.OpenComponent(12); // 2: newTree.CloseComponent(); // newTree.OpenComponent(13); // 3: newTree.CloseComponent(); // newTree.CloseElement(); // // Act var renderBatch = GetRenderedBatch(); // Assert Assert.Equal(3, renderBatch.UpdatedComponents.Count); // First component is the root one var firstComponentDiff = renderBatch.UpdatedComponents.Array[0]; Assert.Collection(firstComponentDiff.Edits, entry => AssertEdit(entry, RenderTreeEditType.StepIn, 1), entry => { AssertEdit(entry, RenderTreeEditType.PrependFrame, 0); Assert.Equal(0, entry.ReferenceFrameIndex); }, entry => { AssertEdit(entry, RenderTreeEditType.PrependFrame, 1); Assert.Equal(1, entry.ReferenceFrameIndex); }, entry => AssertEdit(entry, RenderTreeEditType.StepOut, 0)); Assert.Collection(firstComponentDiff.ReferenceFrames, frame => AssertFrame.ComponentWithInstance(frame, 0, 12), frame => AssertFrame.ComponentWithInstance(frame, 1, 13)); // Second in batch is the first child component var secondComponentDiff = renderBatch.UpdatedComponents.Array[1]; Assert.Equal(0, secondComponentDiff.ComponentId); Assert.Empty(secondComponentDiff.Edits); // Because FakeComponent produces no frames Assert.Empty(secondComponentDiff.ReferenceFrames); // Because FakeComponent produces no frames // Third in batch is the second child component var thirdComponentDiff = renderBatch.UpdatedComponents.Array[2]; Assert.Equal(1, thirdComponentDiff.ComponentId); Assert.Collection(thirdComponentDiff.Edits, entry => AssertEdit(entry, RenderTreeEditType.PrependFrame, 0)); Assert.Collection(thirdComponentDiff.ReferenceFrames, frame => AssertFrame.Text(frame, $"Hello from {nameof(FakeComponent2)}")); } [Fact] public void SetsKnownPropertiesOnChildComponents() { // Arrange var testObject = new object(); newTree.OpenComponent(0); newTree.AddAttribute(1, nameof(FakeComponent.IntProperty), 123); newTree.AddAttribute(2, nameof(FakeComponent.StringProperty), "some string"); newTree.AddAttribute(3, nameof(FakeComponent.ObjectProperty), testObject); newTree.CloseComponent(); // Act var renderBatch = GetRenderedBatch(); var componentInstance = newTree.GetFrames().First().Component as FakeComponent; // Assert Assert.Equal(2, renderBatch.UpdatedComponents.Count); var rootComponentDiff = renderBatch.UpdatedComponents.Array[0]; AssertEdit(rootComponentDiff.Edits.Single(), RenderTreeEditType.PrependFrame, 0); Assert.NotNull(componentInstance); Assert.Equal(123, componentInstance.IntProperty); Assert.Equal("some string", componentInstance.StringProperty); Assert.Same(testObject, componentInstance.ObjectProperty); } [Fact] public void ThrowsIfAssigningUnknownPropertiesToChildComponents() { // Arrange var testObject = new object(); newTree.OpenComponent(0); newTree.AddAttribute(1, "SomeUnknownProperty", 123); newTree.CloseComponent(); // Act/Assert var ex = Assert.Throws(() => { diff.ApplyNewRenderTreeVersion(new RenderBatchBuilder(), 0, oldTree.GetFrames(), newTree.GetFrames()); }); Assert.Equal($"Component of type '{typeof(FakeComponent).FullName}' does not have a property matching the name 'SomeUnknownProperty'.", ex.Message); } [Fact] public void ThrowsIfAssigningReadOnlyPropertiesToChildComponents() { // Arrange var testObject = new object(); newTree.OpenComponent(0); newTree.AddAttribute(1, nameof(FakeComponent.ReadonlyProperty), 123); newTree.CloseComponent(); // Act/Assert var ex = Assert.Throws(() => { diff.ApplyNewRenderTreeVersion(new RenderBatchBuilder(), 0, oldTree.GetFrames(), newTree.GetFrames()); }); Assert.StartsWith($"Unable to set property '{nameof(FakeComponent.ReadonlyProperty)}' on " + $"component of type '{typeof(FakeComponent).FullName}'.", ex.Message); } [Fact] public void RetainsChildComponentsForExistingFrames() { // Arrange oldTree.AddText(10, "text1"); // 0: text1 oldTree.OpenElement(11, "container"); // 1: oldTree.OpenComponent(12); // 2: oldTree.CloseComponent(); // oldTree.OpenComponent(13); // 3: oldTree.CloseComponent(); // oldTree.CloseElement(); // newTree.AddText(10, "text1"); // 0: text1 newTree.OpenElement(11, "container"); // 1: newTree.OpenComponent(12); // 2: newTree.CloseComponent(); // newTree.OpenComponent(13); // 3: newTree.CloseComponent(); // newTree.CloseElement(); // (12); oldTree.AddAttribute(13, nameof(FakeComponent.StringProperty), "String will change"); oldTree.AddAttribute(14, nameof(FakeComponent.ObjectProperty), objectWillNotChange); oldTree.CloseComponent(); newTree.OpenComponent(12); newTree.AddAttribute(13, nameof(FakeComponent.StringProperty), "String did change"); newTree.AddAttribute(14, nameof(FakeComponent.ObjectProperty), objectWillNotChange); newTree.CloseComponent(); diff.ApplyNewRenderTreeVersion(new RenderBatchBuilder(), 0, new RenderTreeBuilder(renderer).GetFrames(), oldTree.GetFrames()); var originalComponentInstance = (FakeComponent)oldTree.GetFrames().Array[0].Component; originalComponentInstance.ObjectProperty = null; // So we can see it doesn't get reassigned // Act var renderBatch = GetRenderedBatch(); var newComponentInstance = (FakeComponent)oldTree.GetFrames().Array[0].Component; // Assert Assert.Equal(2, renderBatch.UpdatedComponents.Count); Assert.Same(originalComponentInstance, newComponentInstance); Assert.Equal("String did change", newComponentInstance.StringProperty); Assert.Null(newComponentInstance.ObjectProperty); // To observe that the property wasn't even written, we nulled it out on the original } [Fact] public void NotifiesIHandlePropertiesChangedBeforeFirstRender() { // Arrange newTree.OpenComponent(0); newTree.CloseComponent(); // Act var batch = GetRenderedBatch(); var diffForChildComponent = batch.UpdatedComponents.Array[1]; // Assert Assert.Collection(diffForChildComponent.ReferenceFrames, frame => AssertFrame.Text(frame, "Notifications: 1", 0)); } [Fact] public void NotifiesIHandlePropertiesChangedWhenChanged() { // Arrange var newTree1 = new RenderTreeBuilder(renderer); var newTree2 = new RenderTreeBuilder(renderer); oldTree.OpenComponent(0); oldTree.AddAttribute(1, nameof(HandlePropertiesChangedComponent.IntProperty), 123); oldTree.CloseComponent(); newTree1.OpenComponent(0); newTree1.AddAttribute(1, nameof(HandlePropertiesChangedComponent.IntProperty), 123); newTree1.CloseComponent(); newTree2.OpenComponent(0); newTree2.AddAttribute(1, nameof(HandlePropertiesChangedComponent.IntProperty), 456); newTree2.CloseComponent(); // Act/Assert 0: Initial render var batch0 = GetRenderedBatch(new RenderTreeBuilder(renderer), oldTree); var diffForChildComponent0 = batch0.UpdatedComponents.Array[1]; var childComponentFrame = batch0.UpdatedComponents.Array[0].ReferenceFrames.Array[0]; var childComponentInstance = (HandlePropertiesChangedComponent)childComponentFrame.Component; Assert.Equal(1, childComponentInstance.NotificationsCount); Assert.Collection(diffForChildComponent0.ReferenceFrames, frame => AssertFrame.Text(frame, "Notifications: 1", 0)); // Act/Assert 1: If properties didn't change, we don't notify GetRenderedBatch(oldTree, newTree1); Assert.Equal(1, childComponentInstance.NotificationsCount); // Act/Assert 2: If properties did change, we do notify var batch2 = GetRenderedBatch(newTree1, newTree2); var diffForChildComponent2 = batch2.UpdatedComponents.Array[1]; Assert.Equal(2, childComponentInstance.NotificationsCount); Assert.Collection(diffForChildComponent2.ReferenceFrames, frame => AssertFrame.Text(frame, "Notifications: 2", 0)); } [Fact] public void CallsDisposeOnlyOnRemovedChildComponents() { // Arrange oldTree.OpenComponent(10); // oldTree.CloseComponent(); // oldTree.OpenComponent(20); // oldTree.CloseComponent(); // oldTree.OpenComponent(30); // oldTree.CloseComponent(); // newTree.OpenComponent(30); // newTree.CloseComponent(); // diff.ApplyNewRenderTreeVersion(new RenderBatchBuilder(), 0, new RenderTreeBuilder(renderer).GetFrames(), oldTree.GetFrames()); var disposableComponent1 = (DisposableComponent)oldTree.GetFrames().Array[0].Component; var nonDisposableComponent = (NonDisposableComponent)oldTree.GetFrames().Array[1].Component; var disposableComponent2 = (DisposableComponent)oldTree.GetFrames().Array[2].Component; // Act var renderedBatch = GetRenderedBatch(); // Assert: We track NonDisposableComponent was disposed even though it's not IDisposable Assert.Equal(renderedBatch.DisposedComponentIDs, new[] { 0, 1 }); // Assert: We did call Dispose on the disposed DisposableComponent Assert.Equal(1, disposableComponent1.DisposalCount); // Assert: We didn't dispose the retained component Assert.Equal(0, disposableComponent2.DisposalCount); } private RenderTreeDiff GetSingleUpdatedComponent() { var diffsInBatch = GetRenderedBatch().UpdatedComponents; Assert.Equal(1, diffsInBatch.Count); return diffsInBatch.Array[0]; } private RenderBatch GetRenderedBatch() => GetRenderedBatch(oldTree, newTree); private RenderBatch GetRenderedBatch(RenderTreeBuilder from, RenderTreeBuilder to) { var batchBuilder = new RenderBatchBuilder(); diff.ApplyNewRenderTreeVersion(batchBuilder, 0, from.GetFrames(), to.GetFrames()); return batchBuilder.ToBatch(); } private class FakeRenderer : Renderer { internal protected override void UpdateDisplay(RenderBatch renderBatch) { } } private class FakeComponent : IComponent { public int IntProperty { get; set; } public string StringProperty { get; set; } public object ObjectProperty { get; set; } public string ReadonlyProperty { get; private set; } private string PrivateProperty { get; set; } public void BuildRenderTree(RenderTreeBuilder builder) { } } private class FakeComponent2 : IComponent { public void BuildRenderTree(RenderTreeBuilder builder) { builder.AddText(100, $"Hello from {nameof(FakeComponent2)}"); } } private class HandlePropertiesChangedComponent : IComponent, IHandlePropertiesChanged { public int NotificationsCount { get; private set; } public int IntProperty { get; set; } public void BuildRenderTree(RenderTreeBuilder builder) { builder.AddText(0, $"Notifications: {NotificationsCount}"); } public void OnPropertiesChanged() { NotificationsCount++; } } private class DisposableComponent : IComponent, IDisposable { public int DisposalCount { get; private set; } public void Dispose() => DisposalCount++; public void BuildRenderTree(RenderTreeBuilder builder) { } } private class NonDisposableComponent : IComponent { public void BuildRenderTree(RenderTreeBuilder builder) { } } private static void AssertEdit( RenderTreeEdit edit, RenderTreeEditType type, int siblingIndex) { Assert.Equal(type, edit.Type); Assert.Equal(siblingIndex, edit.SiblingIndex); } } }