diff --git a/src/Components/Components/src/DynamicComponent.cs b/src/Components/Components/src/DynamicComponent.cs new file mode 100644 index 000000000000..3e5e932a8bef --- /dev/null +++ b/src/Components/Components/src/DynamicComponent.cs @@ -0,0 +1,104 @@ +// 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; + + /// + /// Constructs an instance of . + /// + 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; } = default!; + + /// + /// 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."); + } + } + + 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) + { + 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..3acbf2de6886 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 @@ +