From 76dafa819f30a210be157876cd6a9463c641d9c1 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 31 Jan 2018 16:19:01 +0000 Subject: [PATCH] Mechanism for components running logic when parents change their properties --- .../Components/BlazorComponent.cs | 7 +- .../Components/IHandlePropertiesChanged.cs | 17 +++++ .../RenderTree/RenderTreeDiffComputer.cs | 23 +++++- .../Tests/ComponentRenderingTest.cs | 17 +++++ .../RenderTreeDiffComputerTest.cs | 75 ++++++++++++++++++- .../PropertiesChangedHandlerChild.cshtml | 12 +++ .../PropertiesChangedHandlerParent.cshtml | 6 ++ 7 files changed, 151 insertions(+), 6 deletions(-) create mode 100644 src/Microsoft.AspNetCore.Blazor/Components/IHandlePropertiesChanged.cs create mode 100644 test/testapps/BasicTestApp/PropertiesChangedHandlerChild.cshtml create mode 100644 test/testapps/BasicTestApp/PropertiesChangedHandlerParent.cshtml diff --git a/src/Microsoft.AspNetCore.Blazor/Components/BlazorComponent.cs b/src/Microsoft.AspNetCore.Blazor/Components/BlazorComponent.cs index 70bbb02f41..11d4696d0c 100644 --- a/src/Microsoft.AspNetCore.Blazor/Components/BlazorComponent.cs +++ b/src/Microsoft.AspNetCore.Blazor/Components/BlazorComponent.cs @@ -11,7 +11,7 @@ namespace Microsoft.AspNetCore.Blazor.Components /// Optional base class for Blazor components. Alternatively, Blazor components may /// implement directly. /// - public abstract class BlazorComponent : IComponent + public abstract class BlazorComponent : IComponent, IHandlePropertiesChanged { /// public virtual void BuildRenderTree(RenderTreeBuilder builder) @@ -35,6 +35,11 @@ namespace Microsoft.AspNetCore.Blazor.Components public virtual Task ExecuteAsync() => throw new NotImplementedException($"Blazor components do not implement {nameof(ExecuteAsync)}."); + /// + public virtual void OnPropertiesChanged() + { + } + /// /// Handles click events by invoking . /// diff --git a/src/Microsoft.AspNetCore.Blazor/Components/IHandlePropertiesChanged.cs b/src/Microsoft.AspNetCore.Blazor/Components/IHandlePropertiesChanged.cs new file mode 100644 index 0000000000..299b6c94ac --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor/Components/IHandlePropertiesChanged.cs @@ -0,0 +1,17 @@ +// 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.AspNetCore.Blazor.Components +{ + /// + /// Interface implemented by components that receive notifications when their + /// properties are changed by their parent component. + /// + public interface IHandlePropertiesChanged + { + /// + /// Notifies the component that its properties have changed. + /// + void OnPropertiesChanged(); + } +} diff --git a/src/Microsoft.AspNetCore.Blazor/RenderTree/RenderTreeDiffComputer.cs b/src/Microsoft.AspNetCore.Blazor/RenderTree/RenderTreeDiffComputer.cs index 5c0903ee38..57dedac2d0 100644 --- a/src/Microsoft.AspNetCore.Blazor/RenderTree/RenderTreeDiffComputer.cs +++ b/src/Microsoft.AspNetCore.Blazor/RenderTree/RenderTreeDiffComputer.cs @@ -271,9 +271,7 @@ namespace Microsoft.AspNetCore.Blazor.RenderTree if (hasSetAnyProperty) { - // TODO: Instead, call some OnPropertiesUpdated method on IComponent, - // whose default implementation causes itself to be rerendered - _renderer.RenderInExistingBatch(batchBuilder, componentId); + TriggerChildComponentRender(batchBuilder, newComponentNode); } } @@ -508,11 +506,28 @@ namespace Microsoft.AspNetCore.Blazor.RenderTree attributeNode.AttributeValue); } - _renderer.RenderInExistingBatch(batchBuilder, node.ComponentId); + TriggerChildComponentRender(batchBuilder, node); } } } + private void TriggerChildComponentRender(RenderBatchBuilder batchBuilder, in RenderTreeNode node) + { + if (node.Component is IHandlePropertiesChanged notifyableComponent) + { + // TODO: Ensure any exceptions thrown here are handled equivalently to + // unhandled exceptions during rendering. + notifyableComponent.OnPropertiesChanged(); + } + + // TODO: Consider moving the responsibility for triggering re-rendering + // into the OnPropertiesChanged handler (if implemented) so that components + // can control whether any given set of property changes cause re-rendering. + // Not doing so yet because it's unclear that the usage patterns would be + // good to use. + _renderer.RenderInExistingBatch(batchBuilder, node.ComponentId); + } + private void DisposeChildComponents(RenderBatchBuilder batchBuilder, RenderTreeNode[] nodes, int elementOrComponentIndex) { var endIndex = nodes[elementOrComponentIndex].ElementDescendantsEndIndex; diff --git a/test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/ComponentRenderingTest.cs b/test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/ComponentRenderingTest.cs index ea1a15bf83..7dff6aea27 100644 --- a/test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/ComponentRenderingTest.cs +++ b/test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/ComponentRenderingTest.cs @@ -148,6 +148,23 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests elem => Assert.Equal("Child 3", elem.FindElement(By.ClassName("message")).Text)); } + [Fact] + public void ChildComponentsNotifiedWhenPropertiesChanged() + { + // Child component receives notification that lets it compute a property before first render + var appElement = MountTestComponent(); + var suppliedValueElement = appElement.FindElement(By.ClassName("supplied")); + var computedValueElement = appElement.FindElement(By.ClassName("computed")); + var incrementButton = appElement.FindElement(By.TagName("button")); + Assert.Equal("You supplied: 100", suppliedValueElement.Text); + Assert.Equal("I computed: 200", computedValueElement.Text); + + // When property changes, child is renotified before rerender + incrementButton.Click(); + Assert.Equal("You supplied: 101", suppliedValueElement.Text); + Assert.Equal("I computed: 202", computedValueElement.Text); + } + private IWebElement MountTestComponent() where TComponent: IComponent { var componentTypeName = typeof(TComponent).FullName; diff --git a/test/Microsoft.AspNetCore.Blazor.Test/RenderTreeDiffComputerTest.cs b/test/Microsoft.AspNetCore.Blazor.Test/RenderTreeDiffComputerTest.cs index c8446af5c9..41a2b67288 100644 --- a/test/Microsoft.AspNetCore.Blazor.Test/RenderTreeDiffComputerTest.cs +++ b/test/Microsoft.AspNetCore.Blazor.Test/RenderTreeDiffComputerTest.cs @@ -762,6 +762,59 @@ namespace Microsoft.AspNetCore.Blazor.Test 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.OpenComponentElement(0); + newTree.CloseElement(); + + // Act + var batch = GetRenderedBatch(); + var diffForChildComponent = batch.UpdatedComponents.Array[1]; + + // Assert + Assert.Collection(diffForChildComponent.CurrentState, + node => AssertNode.Text(node, "Notifications: 1", 0)); + } + + [Fact] + public void NotifiesIHandlePropertiesChangedWhenChanged() + { + // Arrange + var newTree1 = new RenderTreeBuilder(renderer); + var newTree2 = new RenderTreeBuilder(renderer); + oldTree.OpenComponentElement(0); + oldTree.AddAttribute(1, nameof(HandlePropertiesChangedComponent.IntProperty), 123); + oldTree.CloseElement(); + newTree1.OpenComponentElement(0); + newTree1.AddAttribute(1, nameof(HandlePropertiesChangedComponent.IntProperty), 123); + newTree1.CloseElement(); + newTree2.OpenComponentElement(0); + newTree2.AddAttribute(1, nameof(HandlePropertiesChangedComponent.IntProperty), 456); + newTree2.CloseElement(); + + // Act/Assert 0: Initial render + var batch0 = GetRenderedBatch(new RenderTreeBuilder(renderer), oldTree); + var diffForChildComponent0 = batch0.UpdatedComponents.Array[1]; + var childComponentNode = batch0.UpdatedComponents.Array[0].CurrentState.Array[0]; + var childComponentInstance = (HandlePropertiesChangedComponent)childComponentNode.Component; + Assert.Equal(1, childComponentInstance.NotificationsCount); + Assert.Collection(diffForChildComponent0.CurrentState, + node => AssertNode.Text(node, "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.CurrentState, + node => AssertNode.Text(node, "Notifications: 2", 0)); + } + [Fact] public void CallsDisposeOnlyOnRemovedChildComponents() { @@ -801,9 +854,12 @@ namespace Microsoft.AspNetCore.Blazor.Test } private RenderBatch GetRenderedBatch() + => GetRenderedBatch(oldTree, newTree); + + private RenderBatch GetRenderedBatch(RenderTreeBuilder from, RenderTreeBuilder to) { var batchBuilder = new RenderBatchBuilder(); - diff.ApplyNewRenderTreeVersion(batchBuilder, 0, oldTree.GetNodes(), newTree.GetNodes()); + diff.ApplyNewRenderTreeVersion(batchBuilder, 0, from.GetNodes(), to.GetNodes()); return batchBuilder.ToBatch(); } @@ -835,6 +891,23 @@ namespace Microsoft.AspNetCore.Blazor.Test } } + 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; } diff --git a/test/testapps/BasicTestApp/PropertiesChangedHandlerChild.cshtml b/test/testapps/BasicTestApp/PropertiesChangedHandlerChild.cshtml new file mode 100644 index 0000000000..af7cf9f5b5 --- /dev/null +++ b/test/testapps/BasicTestApp/PropertiesChangedHandlerChild.cshtml @@ -0,0 +1,12 @@ +
You supplied: @SuppliedValue
+
I computed: @ComputedValue
+ +@functions { + public int SuppliedValue { get; set; } + private int ComputedValue { get; set; } + + public override void OnPropertiesChanged() + { + ComputedValue = SuppliedValue * 2; + } +} diff --git a/test/testapps/BasicTestApp/PropertiesChangedHandlerParent.cshtml b/test/testapps/BasicTestApp/PropertiesChangedHandlerParent.cshtml new file mode 100644 index 0000000000..89137bf9cf --- /dev/null +++ b/test/testapps/BasicTestApp/PropertiesChangedHandlerParent.cshtml @@ -0,0 +1,6 @@ + + + +@functions { + private int valueToSupply = 100; +}