-
Notifications
You must be signed in to change notification settings - Fork 10.7k
Implement DynamicComponent #28082
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Implement DynamicComponent #28082
Changes from all commits
e3dfb6d
079579a
4394a40
0c63ec7
5a64617
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| { | ||
| /// <summary> | ||
| /// A component that renders another component dynamically according to its | ||
| /// <see cref="Type" /> parameter. | ||
| /// </summary> | ||
| public class DynamicComponent : IComponent | ||
| { | ||
| private RenderHandle _renderHandle; | ||
| private RenderFragment _cachedRenderFragment; | ||
|
|
||
| /// <summary> | ||
| /// Constructs an instance of <see cref="DynamicComponent"/>. | ||
| /// </summary> | ||
| public DynamicComponent() | ||
| { | ||
| _cachedRenderFragment = Render; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Gets or sets the type of the component to be rendered. The supplied type must | ||
| /// implement <see cref="IComponent"/>. | ||
| /// </summary> | ||
| [Parameter] | ||
| [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] | ||
| public Type Type { get; set; } = default!; | ||
|
|
||
| /// <summary> | ||
| /// Gets or sets a dictionary of parameters to be passed to the component. | ||
| /// </summary> | ||
| // 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<string, object>? Parameters { get; set; } | ||
|
|
||
| /// <inheritdoc /> | ||
| public void Attach(RenderHandle renderHandle) | ||
| { | ||
| _renderHandle = renderHandle; | ||
| } | ||
|
|
||
| /// <inheritdoc /> | ||
| 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<string, object>)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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not super familiar with the diffing behavior, what does it mean for all attributes to have the same sequence number? Should we be doing something special to ensure that we enumerate the dictionary in some consistent order (since dictionary has no enumeration guarantees)?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good point to raise. However I don't think we want to rely on enforcing consistent enumeration order. Presumably that would mean something like sorting the key-value pairs, which would immediately be the most expensive thing going on here. In practice, I'm assuming that the evaluation order will (at least mostly) be consistent on successive renders. If it is, the attribute diffing rules will produce identical and optimal behavior regardless of whether the sequence numbers are all equal or if they are increasing. If the evaluation order sometimes changes significantly between renders, then the attribute diffing system will also handle this just fine, but will involve more operations internally as the non-matching attribute names will trigger it going into its "dictionary building" attribute diffing mode which isn't as fast as the pure forward path it takes if the order hasn't changed.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
magic. got it. But yeah, in the most common case, i.e using a
SteveSandersonMS marked this conversation as resolved.
|
||
| } | ||
| } | ||
|
|
||
| builder.CloseComponent(); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string!, object!>? | ||
| 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<string!, object?>! 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! |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<InvalidOperationException>(() => | ||
| { | ||
| var parameters = new Dictionary<string, object> | ||
| { | ||
| { "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<InvalidOperationException>( | ||
| () => 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<string, object> | ||
| { | ||
| { nameof(DynamicComponent.Type), typeof(TestComponent) }, | ||
| }); | ||
|
|
||
| // Act | ||
| renderer.RenderRootComponent(componentId, parameters); | ||
|
|
||
| // Assert | ||
| var batch = renderer.Batches.Single(); | ||
| AssertFrame.Component<TestComponent>(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<string, object> | ||
| { | ||
| { nameof(TestComponent.IntProp), 123 }, | ||
| { nameof(TestComponent.ChildContent), (RenderFragment)(builder => | ||
| { | ||
| builder.AddContent(0, "This is some child content"); | ||
| })}, | ||
| }; | ||
| var parameters = ParameterView.FromDictionary(new Dictionary<string, object> | ||
| { | ||
| { 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<TestComponent>(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); | ||
| } | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<ToggleExecutionModeServerFixture<Program>> | ||
| { | ||
| private IWebElement app; | ||
| private SelectElement testCasePicker; | ||
|
|
||
| public DynamicComponentRenderingTest( | ||
| BrowserFixture browserFixture, | ||
| ToggleExecutionModeServerFixture<Program> serverFixture, | ||
| ITestOutputHelper output) | ||
| : base(browserFixture, serverFixture, output) | ||
| { | ||
| } | ||
|
|
||
| protected override void InitializeAsyncCore() | ||
| { | ||
| Navigate(ServerPathBase, noReload: _serverFixture.ExecutionMode == ExecutionMode.Client); | ||
| app = Browser.MountTestComponent<DynamicComponentRendering>(); | ||
| 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); | ||
| } | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.