Skip to content

Commit 9375ed2

Browse files
Implement DynamicComponent (#28082)
Implements #26781 Although developers have been able to implement custom rendertree builder for dynamic component selection since the beginning of Blazor, we've always intended to make this a simpler built-in feature. It's fairly simple, so I can't think of any reason not to just do it now. ### Usage ```razor <DynamicComponent Type="@sometype" /> @code { Type someType = typeof(MyOtherComponent); // Or use custom logic to vary this at runtime } ``` ... or: ```razor <DynamicComponent Type="@sometype" Parameters="@myDictionaryOfParameters" /> @code { Type someType = ... IDictionary<string, object> myDictionaryOfParameters = ... } ```
1 parent 4187129 commit 9375ed2

File tree

7 files changed

+412
-0
lines changed

7 files changed

+412
-0
lines changed
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.Diagnostics.CodeAnalysis;
7+
using System.Threading.Tasks;
8+
using Microsoft.AspNetCore.Components.Rendering;
9+
10+
namespace Microsoft.AspNetCore.Components
11+
{
12+
/// <summary>
13+
/// A component that renders another component dynamically according to its
14+
/// <see cref="Type" /> parameter.
15+
/// </summary>
16+
public class DynamicComponent : IComponent
17+
{
18+
private RenderHandle _renderHandle;
19+
private RenderFragment _cachedRenderFragment;
20+
21+
/// <summary>
22+
/// Constructs an instance of <see cref="DynamicComponent"/>.
23+
/// </summary>
24+
public DynamicComponent()
25+
{
26+
_cachedRenderFragment = Render;
27+
}
28+
29+
/// <summary>
30+
/// Gets or sets the type of the component to be rendered. The supplied type must
31+
/// implement <see cref="IComponent"/>.
32+
/// </summary>
33+
[Parameter]
34+
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)]
35+
public Type Type { get; set; } = default!;
36+
37+
/// <summary>
38+
/// Gets or sets a dictionary of parameters to be passed to the component.
39+
/// </summary>
40+
// Note that this deliberately does *not* use CaptureUnmatchedValues. Reasons:
41+
// [1] The primary scenario for DynamicComponent is where the call site doesn't
42+
// know which child component it's rendering, so it typically won't know what
43+
// set of parameters to pass either, hence the developer most likely wants to
44+
// pass a dictionary rather than having a fixed set of parameter names in markup.
45+
// [2] If we did have CaptureUnmatchedValues here, then it would become a breaking
46+
// change to ever add more parameters to DynamicComponent itself in the future,
47+
// because they would shadow any coincidentally same-named ones on the target
48+
// component. This could lead to application bugs.
49+
[Parameter]
50+
public IDictionary<string, object>? Parameters { get; set; }
51+
52+
/// <inheritdoc />
53+
public void Attach(RenderHandle renderHandle)
54+
{
55+
_renderHandle = renderHandle;
56+
}
57+
58+
/// <inheritdoc />
59+
public Task SetParametersAsync(ParameterView parameters)
60+
{
61+
// This manual parameter assignment logic will be marginally faster than calling
62+
// ComponentProperties.SetProperties.
63+
foreach (var entry in parameters)
64+
{
65+
if (entry.Name.Equals(nameof(Type), StringComparison.OrdinalIgnoreCase))
66+
{
67+
Type = (Type)entry.Value;
68+
}
69+
else if (entry.Name.Equals(nameof(Parameters), StringComparison.OrdinalIgnoreCase))
70+
{
71+
Parameters = (IDictionary<string, object>)entry.Value;
72+
}
73+
else
74+
{
75+
throw new InvalidOperationException(
76+
$"{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.");
77+
}
78+
}
79+
80+
if (Type is null)
81+
{
82+
throw new InvalidOperationException($"{nameof(DynamicComponent)} requires a non-null value for the parameter {nameof(Type)}.");
83+
}
84+
85+
_renderHandle.Render(_cachedRenderFragment);
86+
return Task.CompletedTask;
87+
}
88+
89+
void Render(RenderTreeBuilder builder)
90+
{
91+
builder.OpenComponent(0, Type);
92+
93+
if (Parameters != null)
94+
{
95+
foreach (var entry in Parameters)
96+
{
97+
builder.AddAttribute(1, entry.Key, entry.Value);
98+
}
99+
}
100+
101+
builder.CloseComponent();
102+
}
103+
}
104+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
11
#nullable enable
2+
Microsoft.AspNetCore.Components.DynamicComponent
3+
Microsoft.AspNetCore.Components.DynamicComponent.Attach(Microsoft.AspNetCore.Components.RenderHandle renderHandle) -> void
4+
Microsoft.AspNetCore.Components.DynamicComponent.DynamicComponent() -> void
5+
Microsoft.AspNetCore.Components.DynamicComponent.Parameters.get -> System.Collections.Generic.IDictionary<string!, object!>?
6+
Microsoft.AspNetCore.Components.DynamicComponent.Parameters.set -> void
7+
Microsoft.AspNetCore.Components.DynamicComponent.SetParametersAsync(Microsoft.AspNetCore.Components.ParameterView parameters) -> System.Threading.Tasks.Task!
8+
Microsoft.AspNetCore.Components.DynamicComponent.Type.get -> System.Type!
9+
Microsoft.AspNetCore.Components.DynamicComponent.Type.set -> void
210
static Microsoft.AspNetCore.Components.ParameterView.FromDictionary(System.Collections.Generic.IDictionary<string!, object?>! parameters) -> Microsoft.AspNetCore.Components.ParameterView
311
virtual Microsoft.AspNetCore.Components.RenderTree.Renderer.DispatchEventAsync(ulong eventHandlerId, Microsoft.AspNetCore.Components.RenderTree.EventFieldInfo? fieldInfo, System.EventArgs! eventArgs) -> System.Threading.Tasks.Task!
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.Linq;
7+
using Microsoft.AspNetCore.Components.Rendering;
8+
using Microsoft.AspNetCore.Components.Test.Helpers;
9+
using Xunit;
10+
11+
namespace Microsoft.AspNetCore.Components
12+
{
13+
public class DynamicComponentTest
14+
{
15+
[Fact]
16+
public void RejectsUnknownParameters()
17+
{
18+
var ex = Assert.Throws<InvalidOperationException>(() =>
19+
{
20+
var parameters = new Dictionary<string, object>
21+
{
22+
{ "unknownparameter", 123 }
23+
};
24+
_ = new DynamicComponent().SetParametersAsync(ParameterView.FromDictionary(parameters));
25+
});
26+
27+
Assert.StartsWith(
28+
$"{ nameof(DynamicComponent)} does not accept a parameter with the name 'unknownparameter'.",
29+
ex.Message);
30+
}
31+
32+
[Fact]
33+
public void RequiresTypeParameter()
34+
{
35+
var instance = new DynamicComponent();
36+
var renderer = new TestRenderer();
37+
var componentId = renderer.AssignRootComponentId(instance);
38+
39+
var ex = Assert.Throws<InvalidOperationException>(
40+
() => renderer.RenderRootComponent(componentId, ParameterView.Empty));
41+
42+
Assert.StartsWith(
43+
$"{ nameof(DynamicComponent)} requires a non-null value for the parameter {nameof(DynamicComponent.Type)}.",
44+
ex.Message);
45+
}
46+
47+
[Fact]
48+
public void CanRenderComponentByType()
49+
{
50+
// Arrange
51+
var instance = new DynamicComponent();
52+
var renderer = new TestRenderer();
53+
var componentId = renderer.AssignRootComponentId(instance);
54+
var parameters = ParameterView.FromDictionary(new Dictionary<string, object>
55+
{
56+
{ nameof(DynamicComponent.Type), typeof(TestComponent) },
57+
});
58+
59+
// Act
60+
renderer.RenderRootComponent(componentId, parameters);
61+
62+
// Assert
63+
var batch = renderer.Batches.Single();
64+
AssertFrame.Component<TestComponent>(batch.ReferenceFrames[0], 1, 0);
65+
AssertFrame.Text(batch.ReferenceFrames[1], "Hello from TestComponent with IntProp=0", 0);
66+
}
67+
68+
[Fact]
69+
public void CanRenderComponentByTypeWithParameters()
70+
{
71+
// Arrange
72+
var instance = new DynamicComponent();
73+
var renderer = new TestRenderer();
74+
var childParameters = new Dictionary<string, object>
75+
{
76+
{ nameof(TestComponent.IntProp), 123 },
77+
{ nameof(TestComponent.ChildContent), (RenderFragment)(builder =>
78+
{
79+
builder.AddContent(0, "This is some child content");
80+
})},
81+
};
82+
var parameters = ParameterView.FromDictionary(new Dictionary<string, object>
83+
{
84+
{ nameof(DynamicComponent.Type), typeof(TestComponent) },
85+
{ nameof(DynamicComponent.Parameters), childParameters },
86+
});
87+
88+
// Act
89+
renderer.RenderRootComponent(
90+
renderer.AssignRootComponentId(instance),
91+
parameters);
92+
93+
// Assert
94+
var batch = renderer.Batches.Single();
95+
96+
// It renders a reference to the child component with its parameters
97+
AssertFrame.Component<TestComponent>(batch.ReferenceFrames[0], 3, 0);
98+
AssertFrame.Attribute(batch.ReferenceFrames[1], nameof(TestComponent.IntProp), 123, 1);
99+
AssertFrame.Attribute(batch.ReferenceFrames[2], nameof(TestComponent.ChildContent), 1);
100+
101+
// The child component itself is rendered
102+
AssertFrame.Text(batch.ReferenceFrames[3], "Hello from TestComponent with IntProp=123", 0);
103+
AssertFrame.Text(batch.ReferenceFrames[4], "This is some child content", 0);
104+
}
105+
106+
private class TestComponent : AutoRenderComponent
107+
{
108+
[Parameter] public int IntProp { get; set; }
109+
[Parameter] public RenderFragment ChildContent { get; set; }
110+
111+
protected override void BuildRenderTree(RenderTreeBuilder builder)
112+
{
113+
builder.AddContent(0, $"Hello from TestComponent with IntProp={IntProp}");
114+
builder.AddContent(1, ChildContent);
115+
}
116+
}
117+
}
118+
}

src/Components/test/E2ETest/ServerExecutionTests/TestSubclasses.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,4 +91,12 @@ public ServerVirtualizationTest(BrowserFixture browserFixture, ToggleExecutionMo
9191
{
9292
}
9393
}
94+
95+
public class ServerDynamicComponentRenderingTest : DynamicComponentRenderingTest
96+
{
97+
public ServerDynamicComponentRenderingTest(BrowserFixture browserFixture, ToggleExecutionModeServerFixture<Program> serverFixture, ITestOutputHelper output)
98+
: base(browserFixture, serverFixture.WithServerExecution(), output)
99+
{
100+
}
101+
}
94102
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System.Linq;
5+
using BasicTestApp;
6+
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
7+
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
8+
using Microsoft.AspNetCore.E2ETesting;
9+
using OpenQA.Selenium;
10+
using OpenQA.Selenium.Support.UI;
11+
using Xunit;
12+
using Xunit.Abstractions;
13+
14+
namespace Microsoft.AspNetCore.Components.E2ETest.Tests
15+
{
16+
public class DynamicComponentRenderingTest : ServerTestBase<ToggleExecutionModeServerFixture<Program>>
17+
{
18+
private IWebElement app;
19+
private SelectElement testCasePicker;
20+
21+
public DynamicComponentRenderingTest(
22+
BrowserFixture browserFixture,
23+
ToggleExecutionModeServerFixture<Program> serverFixture,
24+
ITestOutputHelper output)
25+
: base(browserFixture, serverFixture, output)
26+
{
27+
}
28+
29+
protected override void InitializeAsyncCore()
30+
{
31+
Navigate(ServerPathBase, noReload: _serverFixture.ExecutionMode == ExecutionMode.Client);
32+
app = Browser.MountTestComponent<DynamicComponentRendering>();
33+
testCasePicker = new SelectElement(app.FindElement(By.Id("dynamic-component-case-picker")));
34+
}
35+
36+
[Fact]
37+
public void CanRenderComponentDynamically()
38+
{
39+
var hostRenderCountDisplay = app.FindElement(By.Id("outer-rendercount"));
40+
Browser.Equal("1", () => hostRenderCountDisplay.Text);
41+
42+
testCasePicker.SelectByText("Counter");
43+
Browser.Equal("2", () => hostRenderCountDisplay.Text);
44+
45+
// Basic rendering of a dynamic child works
46+
var childContainer = app.FindElement(By.Id("dynamic-child"));
47+
var currentCountDisplay = childContainer.FindElements(By.TagName("p")).First();
48+
Browser.Equal("Current count: 0", () => currentCountDisplay.Text);
49+
50+
// The dynamic child can process events and re-render as normal
51+
var incrementButton = childContainer.FindElement(By.TagName("button"));
52+
incrementButton.Click();
53+
Browser.Equal("Current count: 1", () => currentCountDisplay.Text);
54+
55+
// Re-rendering the child doesn't re-render the host
56+
Browser.Equal("2", () => hostRenderCountDisplay.Text);
57+
58+
// Re-rendering the host doesn't lose state in the child (e.g., by recreating it)
59+
app.FindElement(By.Id("re-render-host")).Click();
60+
Browser.Equal("3", () => hostRenderCountDisplay.Text);
61+
Browser.Equal("Current count: 1", () => currentCountDisplay.Text);
62+
incrementButton.Click();
63+
Browser.Equal("Current count: 2", () => currentCountDisplay.Text);
64+
}
65+
66+
[Fact]
67+
public void CanPassParameters()
68+
{
69+
testCasePicker.SelectByText("Component with parameters");
70+
var dynamicChild = app.FindElement(By.Id("dynamic-child"));
71+
72+
// Regular parameters work
73+
Browser.Equal("Hello 123", () => dynamicChild.FindElement(By.CssSelector(".Param1 li")).Text);
74+
75+
// Derived parameters work
76+
Browser.Equal("Goodbye Derived", () => dynamicChild.FindElement(By.CssSelector(".Param2")).Text);
77+
78+
// Catch-all parameters work
79+
Browser.Equal("unmatchedParam This is the unmatched param value", () => dynamicChild.FindElement(By.CssSelector(".Param3 li")).Text);
80+
}
81+
82+
[Fact]
83+
public void CanChangeDynamicallyRenderedComponent()
84+
{
85+
testCasePicker.SelectByText("Component with parameters");
86+
var dynamicChild = app.FindElement(By.Id("dynamic-child"));
87+
Browser.Equal("Component With Parameters", () => dynamicChild.FindElement(By.TagName("h3")).Text);
88+
89+
testCasePicker.SelectByText("Counter");
90+
Browser.Equal("Counter", () => dynamicChild.FindElement(By.TagName("h1")).Text);
91+
}
92+
}
93+
}

0 commit comments

Comments
 (0)