From e3dfb6db5b3ef139bf3236f173422be2b3564984 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 23 Nov 2020 15:44:08 +0000 Subject: [PATCH 1/5] Implement DynamicComponent --- .../Components/src/DynamicComponent.cs | 101 +++++++++++++++ .../Components/src/PublicAPI.Unshipped.txt | 8 ++ .../Components/test/DynamicComponentTest.cs | 118 ++++++++++++++++++ .../ServerExecutionTests/TestSubclasses.cs | 8 ++ .../Tests/DynamicComponentRenderingTest.cs | 93 ++++++++++++++ .../DynamicComponentRendering.razor | 80 ++++++++++++ .../test/testassets/BasicTestApp/Index.razor | 1 + 7 files changed, 409 insertions(+) create mode 100644 src/Components/Components/src/DynamicComponent.cs create mode 100644 src/Components/Components/test/DynamicComponentTest.cs create mode 100644 src/Components/test/E2ETest/Tests/DynamicComponentRenderingTest.cs create mode 100644 src/Components/test/testassets/BasicTestApp/DynamicComponentRendering.razor diff --git a/src/Components/Components/src/DynamicComponent.cs b/src/Components/Components/src/DynamicComponent.cs new file mode 100644 index 000000000000..d714fe095c5f --- /dev/null +++ b/src/Components/Components/src/DynamicComponent.cs @@ -0,0 +1,101 @@ +// 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.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components.Rendering; + +namespace Microsoft.AspNetCore.Components +{ + /// + /// A component that renders another component dynamically according to its + /// parameter. + /// + public class DynamicComponent : IComponent + { + private RenderHandle _renderHandle; + private RenderFragment _cachedRenderFragment; + + public DynamicComponent() + { + _cachedRenderFragment = Render; + } + + /// + /// Gets or sets the type of the component to be rendered. The supplied type must + /// implement . + /// + [Parameter] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] + public Type? Type { get; set; } + + /// + /// Gets or sets a dictionary of parameters to be passed to the component. + /// + // Note that this deliberately does *not* use CaptureUnmatchedValues. Reasons: + // [1] The primary scenario for DynamicComponent is where the call site doesn't + // know which child component it's rendering, so it typically won't know what + // set of parameters to pass either, hence the developer most likely wants to + // pass a dictionary rather than having a fixed set of parameter names in markup. + // [2] If we did have CaptureUnmatchedValues here, then it would become a breaking + // change to ever add more parameters to DynamicComponent itself in the future, + // because they would shadow any coincidentally same-named ones on the target + // component. This could lead to application bugs. + [Parameter] + public IDictionary? Parameters { get; set; } + + /// + public void Attach(RenderHandle renderHandle) + { + _renderHandle = renderHandle; + } + + /// + public Task SetParametersAsync(ParameterView parameters) + { + // This manual parameter assignment logic will be marginally faster than calling + // ComponentProperties.SetProperties. + foreach (var entry in parameters) + { + if (entry.Name.Equals(nameof (Type), StringComparison.OrdinalIgnoreCase)) + { + Type = (Type)entry.Value; + } + else if (entry.Name.Equals(nameof(Parameters), StringComparison.OrdinalIgnoreCase)) + { + Parameters = (IDictionary)entry.Value; + } + else + { + throw new InvalidOperationException( + $"{nameof(DynamicComponent)} does not accept a parameter with the name '{entry.Name}'. To pass parameters to the dynamically-rendered component, use the '{nameof(Parameters)}' parameter."); + } + } + + _renderHandle.Render(_cachedRenderFragment); + return Task.CompletedTask; + } + + void Render(RenderTreeBuilder builder) + { + if (Type is null) + { + throw new InvalidOperationException($"{nameof(DynamicComponent)} requires a non-null value for the parameter {nameof(Type)}."); + } + + builder.OpenComponent(0, Type); + + if (Parameters != null) + { + foreach (var entry in Parameters) + { + builder.AddAttribute(1, entry.Key, entry.Value); + } + } + + builder.CloseComponent(); + } + } +} diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 0724a63f090f..249ed6696a30 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -1,3 +1,11 @@ #nullable enable +Microsoft.AspNetCore.Components.DynamicComponent +Microsoft.AspNetCore.Components.DynamicComponent.Attach(Microsoft.AspNetCore.Components.RenderHandle renderHandle) -> void +Microsoft.AspNetCore.Components.DynamicComponent.DynamicComponent() -> void +Microsoft.AspNetCore.Components.DynamicComponent.Parameters.get -> System.Collections.Generic.IDictionary? +Microsoft.AspNetCore.Components.DynamicComponent.Parameters.set -> void +Microsoft.AspNetCore.Components.DynamicComponent.SetParametersAsync(Microsoft.AspNetCore.Components.ParameterView parameters) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Components.DynamicComponent.Type.get -> System.Type? +Microsoft.AspNetCore.Components.DynamicComponent.Type.set -> void static Microsoft.AspNetCore.Components.ParameterView.FromDictionary(System.Collections.Generic.IDictionary! parameters) -> Microsoft.AspNetCore.Components.ParameterView virtual Microsoft.AspNetCore.Components.RenderTree.Renderer.DispatchEventAsync(ulong eventHandlerId, Microsoft.AspNetCore.Components.RenderTree.EventFieldInfo? fieldInfo, System.EventArgs! eventArgs) -> System.Threading.Tasks.Task! diff --git a/src/Components/Components/test/DynamicComponentTest.cs b/src/Components/Components/test/DynamicComponentTest.cs new file mode 100644 index 000000000000..42c8b277c3b9 --- /dev/null +++ b/src/Components/Components/test/DynamicComponentTest.cs @@ -0,0 +1,118 @@ +// 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.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.AspNetCore.Components.Test.Helpers; +using Xunit; + +namespace Microsoft.AspNetCore.Components +{ + public class DynamicComponentTest + { + [Fact] + public void RejectsUnknownParameters() + { + var ex = Assert.Throws(() => + { + var parameters = new Dictionary + { + { "unknownparameter", 123 } + }; + _ = new DynamicComponent().SetParametersAsync(ParameterView.FromDictionary(parameters)); + }); + + Assert.StartsWith( + $"{ nameof(DynamicComponent)} does not accept a parameter with the name 'unknownparameter'.", + ex.Message); + } + + [Fact] + public void RequiresTypeParameter() + { + var instance = new DynamicComponent(); + var renderer = new TestRenderer(); + var componentId = renderer.AssignRootComponentId(instance); + + var ex = Assert.Throws( + () => renderer.RenderRootComponent(componentId, ParameterView.Empty)); + + Assert.StartsWith( + $"{ nameof(DynamicComponent)} requires a non-null value for the parameter {nameof(DynamicComponent.Type)}.", + ex.Message); + } + + [Fact] + public void CanRenderComponentByType() + { + // Arrange + var instance = new DynamicComponent(); + var renderer = new TestRenderer(); + var componentId = renderer.AssignRootComponentId(instance); + var parameters = ParameterView.FromDictionary(new Dictionary + { + { nameof(DynamicComponent.Type), typeof(TestComponent) }, + }); + + // Act + renderer.RenderRootComponent(componentId, parameters); + + // Assert + var batch = renderer.Batches.Single(); + AssertFrame.Component(batch.ReferenceFrames[0], 1, 0); + AssertFrame.Text(batch.ReferenceFrames[1], "Hello from TestComponent with IntProp=0", 0); + } + + [Fact] + public void CanRenderComponentByTypeWithParameters() + { + // Arrange + var instance = new DynamicComponent(); + var renderer = new TestRenderer(); + var childParameters = new Dictionary + { + { nameof(TestComponent.IntProp), 123 }, + { nameof(TestComponent.ChildContent), (RenderFragment)(builder => + { + builder.AddContent(0, "This is some child content"); + })}, + }; + var parameters = ParameterView.FromDictionary(new Dictionary + { + { nameof(DynamicComponent.Type), typeof(TestComponent) }, + { nameof(DynamicComponent.Parameters), childParameters }, + }); + + // Act + renderer.RenderRootComponent( + renderer.AssignRootComponentId(instance), + parameters); + + // Assert + var batch = renderer.Batches.Single(); + + // It renders a reference to the child component with its parameters + AssertFrame.Component(batch.ReferenceFrames[0], 3, 0); + AssertFrame.Attribute(batch.ReferenceFrames[1], nameof(TestComponent.IntProp), 123, 1); + AssertFrame.Attribute(batch.ReferenceFrames[2], nameof(TestComponent.ChildContent), 1); + + // The child component itself is rendered + AssertFrame.Text(batch.ReferenceFrames[3], "Hello from TestComponent with IntProp=123", 0); + AssertFrame.Text(batch.ReferenceFrames[4], "This is some child content", 0); + } + + private class TestComponent : AutoRenderComponent + { + [Parameter] public int IntProp { get; set; } + [Parameter] public RenderFragment ChildContent { get; set; } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.AddContent(0, $"Hello from TestComponent with IntProp={IntProp}"); + builder.AddContent(1, ChildContent); + } + } + } +} diff --git a/src/Components/test/E2ETest/ServerExecutionTests/TestSubclasses.cs b/src/Components/test/E2ETest/ServerExecutionTests/TestSubclasses.cs index d6f45c2d5e5b..a69ca685033f 100644 --- a/src/Components/test/E2ETest/ServerExecutionTests/TestSubclasses.cs +++ b/src/Components/test/E2ETest/ServerExecutionTests/TestSubclasses.cs @@ -91,4 +91,12 @@ public ServerVirtualizationTest(BrowserFixture browserFixture, ToggleExecutionMo { } } + + public class ServerDynamicComponentRenderingTest : DynamicComponentRenderingTest + { + public ServerDynamicComponentRenderingTest(BrowserFixture browserFixture, ToggleExecutionModeServerFixture serverFixture, ITestOutputHelper output) + : base(browserFixture, serverFixture.WithServerExecution(), output) + { + } + } } diff --git a/src/Components/test/E2ETest/Tests/DynamicComponentRenderingTest.cs b/src/Components/test/E2ETest/Tests/DynamicComponentRenderingTest.cs new file mode 100644 index 000000000000..9d59d7144a83 --- /dev/null +++ b/src/Components/test/E2ETest/Tests/DynamicComponentRenderingTest.cs @@ -0,0 +1,93 @@ +// 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.Linq; +using BasicTestApp; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; +using Microsoft.AspNetCore.E2ETesting; +using OpenQA.Selenium; +using OpenQA.Selenium.Support.UI; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Components.E2ETest.Tests +{ + public class DynamicComponentRenderingTest : ServerTestBase> + { + private IWebElement app; + private SelectElement testCasePicker; + + public DynamicComponentRenderingTest( + BrowserFixture browserFixture, + ToggleExecutionModeServerFixture serverFixture, + ITestOutputHelper output) + : base(browserFixture, serverFixture, output) + { + } + + protected override void InitializeAsyncCore() + { + Navigate(ServerPathBase, noReload: _serverFixture.ExecutionMode == ExecutionMode.Client); + app = Browser.MountTestComponent(); + testCasePicker = new SelectElement(app.FindElement(By.Id("dynamic-component-case-picker"))); + } + + [Fact] + public void CanRenderComponentDynamically() + { + var hostRenderCountDisplay = app.FindElement(By.Id("outer-rendercount")); + Browser.Equal("1", () => hostRenderCountDisplay.Text); + + testCasePicker.SelectByText("Counter"); + Browser.Equal("2", () => hostRenderCountDisplay.Text); + + // Basic rendering of a dynamic child works + var childContainer = app.FindElement(By.Id("dynamic-child")); + var currentCountDisplay = childContainer.FindElements(By.TagName("p")).First(); + Browser.Equal("Current count: 0", () => currentCountDisplay.Text); + + // The dynamic child can process events and re-render as normal + var incrementButton = childContainer.FindElement(By.TagName("button")); + incrementButton.Click(); + Browser.Equal("Current count: 1", () => currentCountDisplay.Text); + + // Re-rendering the child doesn't re-render the host + Browser.Equal("2", () => hostRenderCountDisplay.Text); + + // Re-rendering the host doesn't lose state in the child (e.g., by recreating it) + app.FindElement(By.Id("re-render-host")).Click(); + Browser.Equal("3", () => hostRenderCountDisplay.Text); + Browser.Equal("Current count: 1", () => currentCountDisplay.Text); + incrementButton.Click(); + Browser.Equal("Current count: 2", () => currentCountDisplay.Text); + } + + [Fact] + public void CanPassParameters() + { + testCasePicker.SelectByText("Component with parameters"); + var dynamicChild = app.FindElement(By.Id("dynamic-child")); + + // Regular parameters work + Browser.Equal("Hello 123", () => dynamicChild.FindElement(By.CssSelector(".Param1 li")).Text); + + // Derived parameters work + Browser.Equal("Goodbye Derived", () => dynamicChild.FindElement(By.CssSelector(".Param2")).Text); + + // Catch-all parameters work + Browser.Equal("unmatchedParam This is the unmatched param value", () => dynamicChild.FindElement(By.CssSelector(".Param3 li")).Text); + } + + [Fact] + public void CanChangeDynamicallyRenderedComponent() + { + testCasePicker.SelectByText("Component with parameters"); + var dynamicChild = app.FindElement(By.Id("dynamic-child")); + Browser.Equal("Component With Parameters", () => dynamicChild.FindElement(By.TagName("h3")).Text); + + testCasePicker.SelectByText("Counter"); + Browser.Equal("Counter", () => dynamicChild.FindElement(By.TagName("h1")).Text); + } + } +} diff --git a/src/Components/test/testassets/BasicTestApp/DynamicComponentRendering.razor b/src/Components/test/testassets/BasicTestApp/DynamicComponentRendering.razor new file mode 100644 index 000000000000..21ebc241fe19 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/DynamicComponentRendering.razor @@ -0,0 +1,80 @@ +

DynamicComponent

+ +

+ Select case: + +

+ +

+ Outer component render count: @(++renderCount) + +

+ +
+ +@if (!string.IsNullOrEmpty(currentCaseName)) +{ + var currentCase = cases[currentCaseName]; +
+ +
+} + +@code { + private int renderCount; + private string currentCaseName; + + private Dictionary cases = new Dictionary + { + { + "Counter", + new TestCase { ComponentType = typeof(CounterComponent) } + }, + { + "Component with parameters", + new TestCase + { + ComponentType = typeof(ComponentWithParameters), + ComponentParameters = new Dictionary + { + // Known parameters + { + nameof(ComponentWithParameters.Param1), + new List + { + new ComponentWithParameters.TestModel { IntProperty = 123, StringProperty = "Hello" } + } + }, + { + nameof(ComponentWithParameters.Param2), + new ComponentWithParameters.DerivedModel { IntProperty = 456, StringProperty = "Goodbye", DerivedProperty = "Derived" } + }, + + // An unmatched parameter + { + "unmatchedParam", + "This is the unmatched param value" + } + } + } + }, + }; + + void NoOpEventHandler() + { + // The purpose of this is just to make the host component re-render so the test + // can verify that no state was lost + } + + class TestCase + { + public Type ComponentType { get; set; } + public Dictionary ComponentParameters { get; set; } + } +} diff --git a/src/Components/test/testassets/BasicTestApp/Index.razor b/src/Components/test/testassets/BasicTestApp/Index.razor index e6b8138a7dbd..b1d97155db54 100644 --- a/src/Components/test/testassets/BasicTestApp/Index.razor +++ b/src/Components/test/testassets/BasicTestApp/Index.razor @@ -21,6 +21,7 @@ + From 079579a600ea447bdc7766269b5cc5d1fa82c82d Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 23 Nov 2020 16:04:52 +0000 Subject: [PATCH 2/5] Add missing XML doc --- src/Components/Components/src/DynamicComponent.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Components/Components/src/DynamicComponent.cs b/src/Components/Components/src/DynamicComponent.cs index d714fe095c5f..4401158658b0 100644 --- a/src/Components/Components/src/DynamicComponent.cs +++ b/src/Components/Components/src/DynamicComponent.cs @@ -18,6 +18,9 @@ public class DynamicComponent : IComponent private RenderHandle _renderHandle; private RenderFragment _cachedRenderFragment; + /// + /// Constructs an instance of . + /// public DynamicComponent() { _cachedRenderFragment = Render; From 4394a40f202e5930bc2f7442468c2d60e1619cce Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 23 Nov 2020 18:59:32 +0000 Subject: [PATCH 3/5] CR: Declare Type to be non-nullable --- src/Components/Components/src/DynamicComponent.cs | 2 +- src/Components/Components/src/PublicAPI.Unshipped.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Components/Components/src/DynamicComponent.cs b/src/Components/Components/src/DynamicComponent.cs index 4401158658b0..8d7d9da58e1b 100644 --- a/src/Components/Components/src/DynamicComponent.cs +++ b/src/Components/Components/src/DynamicComponent.cs @@ -32,7 +32,7 @@ public DynamicComponent() /// [Parameter] [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] - public Type? Type { get; set; } + public Type Type { get; set; } = default!; /// /// Gets or sets a dictionary of parameters to be passed to the component. diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 249ed6696a30..3acbf2de6886 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -5,7 +5,7 @@ Microsoft.AspNetCore.Components.DynamicComponent.DynamicComponent() -> void Microsoft.AspNetCore.Components.DynamicComponent.Parameters.get -> System.Collections.Generic.IDictionary? Microsoft.AspNetCore.Components.DynamicComponent.Parameters.set -> void Microsoft.AspNetCore.Components.DynamicComponent.SetParametersAsync(Microsoft.AspNetCore.Components.ParameterView parameters) -> System.Threading.Tasks.Task! -Microsoft.AspNetCore.Components.DynamicComponent.Type.get -> System.Type? +Microsoft.AspNetCore.Components.DynamicComponent.Type.get -> System.Type! Microsoft.AspNetCore.Components.DynamicComponent.Type.set -> void static Microsoft.AspNetCore.Components.ParameterView.FromDictionary(System.Collections.Generic.IDictionary! parameters) -> Microsoft.AspNetCore.Components.ParameterView virtual Microsoft.AspNetCore.Components.RenderTree.Renderer.DispatchEventAsync(ulong eventHandlerId, Microsoft.AspNetCore.Components.RenderTree.EventFieldInfo? fieldInfo, System.EventArgs! eventArgs) -> System.Threading.Tasks.Task! From 0c63ec71744b5193ceab25bea28a7f3503d08edd Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 23 Nov 2020 19:31:59 +0000 Subject: [PATCH 4/5] Update src/Components/Components/src/DynamicComponent.cs Co-authored-by: Pranav K --- src/Components/Components/src/DynamicComponent.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Components/Components/src/DynamicComponent.cs b/src/Components/Components/src/DynamicComponent.cs index 8d7d9da58e1b..d3a36ed96775 100644 --- a/src/Components/Components/src/DynamicComponent.cs +++ b/src/Components/Components/src/DynamicComponent.cs @@ -62,7 +62,7 @@ public Task SetParametersAsync(ParameterView parameters) // ComponentProperties.SetProperties. foreach (var entry in parameters) { - if (entry.Name.Equals(nameof (Type), StringComparison.OrdinalIgnoreCase)) + if (entry.Name.Equals(nameof(Type), StringComparison.OrdinalIgnoreCase)) { Type = (Type)entry.Value; } From 5a646170e82394f7b98c8f1ea4b62a0c80abfacb Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 24 Nov 2020 09:32:48 +0000 Subject: [PATCH 5/5] Move Type check. --- src/Components/Components/src/DynamicComponent.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Components/Components/src/DynamicComponent.cs b/src/Components/Components/src/DynamicComponent.cs index d3a36ed96775..3e5e932a8bef 100644 --- a/src/Components/Components/src/DynamicComponent.cs +++ b/src/Components/Components/src/DynamicComponent.cs @@ -77,17 +77,17 @@ public Task SetParametersAsync(ParameterView parameters) } } + if (Type is null) + { + throw new InvalidOperationException($"{nameof(DynamicComponent)} requires a non-null value for the parameter {nameof(Type)}."); + } + _renderHandle.Render(_cachedRenderFragment); return Task.CompletedTask; } void Render(RenderTreeBuilder builder) { - if (Type is null) - { - throw new InvalidOperationException($"{nameof(DynamicComponent)} requires a non-null value for the parameter {nameof(Type)}."); - } - builder.OpenComponent(0, Type); if (Parameters != null)