Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions src/Components/Components/src/DynamicComponent.cs
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"/>.
Comment thread
HaoK marked this conversation as resolved.
/// </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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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)?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"dictionary building" attribute diffing mode

magic. got it. But yeah, in the most common case, i.e using a Dictionary<TKey, TValue>, the observed behavior is that you enumerate based on enumeration. While it's good to know that we don't rely on this behavior, it's fairly likely we're going to hit the fast past.

Comment thread
SteveSandersonMS marked this conversation as resolved.
}
}

builder.CloseComponent();
}
}
}
8 changes: 8 additions & 0 deletions src/Components/Components/src/PublicAPI.Unshipped.txt
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!
118 changes: 118 additions & 0 deletions src/Components/Components/test/DynamicComponentTest.cs
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
Expand Up @@ -91,4 +91,12 @@ public ServerVirtualizationTest(BrowserFixture browserFixture, ToggleExecutionMo
{
}
}

public class ServerDynamicComponentRenderingTest : DynamicComponentRenderingTest
{
public ServerDynamicComponentRenderingTest(BrowserFixture browserFixture, ToggleExecutionModeServerFixture<Program> serverFixture, ITestOutputHelper output)
: base(browserFixture, serverFixture.WithServerExecution(), output)
{
}
}
}
93 changes: 93 additions & 0 deletions src/Components/test/E2ETest/Tests/DynamicComponentRenderingTest.cs
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);
}
}
}
Loading