Add runtime support for Blazor attribute splatting (#10357)

* Add basic tests for duplicate attributes

* Add AddMultipleAttributes improve RTB

- Adds AddMultipleAttributes
- Fix RTB to de-dupe attributes
- Fix RTB behaviour with boxed EventCallback (#8336)
- Add lots of tests for new RTB behaviour and EventCallback

* Harden EventCallback test

This was being flaky while I was running E2E tests locally, and it
wasn't using a resiliant equality comparison.

* Add new setting on ParameterAttribute

Adds the option to mark a parameter as an *extra* parameter. Why is this
on ParameterAttribute and not a new type? It makes sense to make it a
modifier on Parameter so you can use it both ways (explicitly set it, or
allow it to collect *extras*).

Added unit tests and validations for interacting with the new setting.

* Add renderer tests for 'extra' parameters

* Refactor Diagnostics for more analyzers

* Simplify analyzer and fix CascadingParameter

This is the *easy way* to write an analyzer that looks at declarations.
The information that's avaialable from symbols is much more high level
than syntax. Much of what's in this code today is needed to reverse
engineer what the compiler does already. If you use symbols you get to
benefit from all of that.

Also added validation for cascading parameters to the analyzer that I
think was just missing due to oversight.

The overall design pattern here is what I've been converging on for the
ASP.NET Core analyzers as a whole, and it seems to scale really well.

* Add analyzer for types

* Add analyzer for uniqueness

This involved a refactor to run the analyzer per-type instead of
per-property.

* Fix project file

* Adjust name

* PR feedback on PCE and more renames

* Remove unused parameter

* Fix #10398

* Add E2E test

* Pranavs cool feedback

* Optimize silent frame removal

* code check

* pr feedback
This commit is contained in:
Ryan Nowak 2019-05-28 17:17:50 -07:00 committed by GitHub
parent 9f9c79bbe8
commit 6ca30bbfc9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 2060 additions and 169 deletions

View File

@ -0,0 +1,89 @@
// 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.Linq;
using Microsoft.CodeAnalysis;
namespace Microsoft.AspNetCore.Components.Analyzers
{
internal static class ComponentFacts
{
public static bool IsAnyParameter(ComponentSymbols symbols, IPropertySymbol property)
{
if (symbols == null)
{
throw new ArgumentNullException(nameof(symbols));
}
if (property == null)
{
throw new ArgumentNullException(nameof(property));
}
return property.GetAttributes().Any(a =>
{
return a.AttributeClass == symbols.ParameterAttribute || a.AttributeClass == symbols.CascadingParameterAttribute;
});
}
public static bool IsParameter(ComponentSymbols symbols, IPropertySymbol property)
{
if (symbols == null)
{
throw new ArgumentNullException(nameof(symbols));
}
if (property == null)
{
throw new ArgumentNullException(nameof(property));
}
return property.GetAttributes().Any(a => a.AttributeClass == symbols.ParameterAttribute);
}
public static bool IsParameterWithCaptureUnmatchedValues(ComponentSymbols symbols, IPropertySymbol property)
{
if (symbols == null)
{
throw new ArgumentNullException(nameof(symbols));
}
if (property == null)
{
throw new ArgumentNullException(nameof(property));
}
var attribute = property.GetAttributes().FirstOrDefault(a => a.AttributeClass == symbols.ParameterAttribute);
if (attribute == null)
{
return false;
}
foreach (var kvp in attribute.NamedArguments)
{
if (string.Equals(kvp.Key, ComponentsApi.ParameterAttribute.CaptureUnmatchedValues, StringComparison.Ordinal))
{
return kvp.Value.Value as bool? ?? false;
}
}
return false;
}
public static bool IsCascadingParameter(ComponentSymbols symbols, IPropertySymbol property)
{
if (symbols == null)
{
throw new ArgumentNullException(nameof(symbols));
}
if (property == null)
{
throw new ArgumentNullException(nameof(property));
}
return property.GetAttributes().Any(a => a.AttributeClass == symbols.CascadingParameterAttribute);
}
}
}

View File

@ -0,0 +1,111 @@
// 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.Collections.Immutable;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Diagnostics;
namespace Microsoft.AspNetCore.Components.Analyzers
{
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class ComponentParameterAnalyzer : DiagnosticAnalyzer
{
public ComponentParameterAnalyzer()
{
SupportedDiagnostics = ImmutableArray.Create(new[]
{
DiagnosticDescriptors.ComponentParametersShouldNotBePublic,
DiagnosticDescriptors.ComponentParameterCaptureUnmatchedValuesMustBeUnique,
DiagnosticDescriptors.ComponentParameterCaptureUnmatchedValuesHasWrongType,
});
}
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; }
public override void Initialize(AnalysisContext context)
{
context.RegisterCompilationStartAction(context =>
{
if (!ComponentSymbols.TryCreate(context.Compilation, out var symbols))
{
// Types we need are not defined.
return;
}
// This operates per-type because one of the validations we need has to look for duplicates
// defined on the same type.
context.RegisterSymbolStartAction(context =>
{
var properties = new List<IPropertySymbol>();
var type = (INamedTypeSymbol)context.Symbol;
foreach (var member in type.GetMembers())
{
if (member is IPropertySymbol property && ComponentFacts.IsAnyParameter(symbols, property))
{
// Annotated with [Parameter] or [CascadingParameter]
properties.Add(property);
}
}
if (properties.Count == 0)
{
return;
}
context.RegisterSymbolEndAction(context =>
{
var captureUnmatchedValuesParameters = new List<IPropertySymbol>();
// Per-property validations
foreach (var property in properties)
{
if (property.SetMethod?.DeclaredAccessibility == Accessibility.Public)
{
context.ReportDiagnostic(Diagnostic.Create(
DiagnosticDescriptors.ComponentParametersShouldNotBePublic,
property.Locations[0],
property.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat)));
}
if (ComponentFacts.IsParameterWithCaptureUnmatchedValues(symbols, property))
{
captureUnmatchedValuesParameters.Add(property);
// Check the type, we need to be able to assign a Dictionary<string, object>
var conversion = context.Compilation.ClassifyConversion(symbols.ParameterCaptureUnmatchedValuesRuntimeType, property.Type);
if (!conversion.Exists || conversion.IsExplicit)
{
context.ReportDiagnostic(Diagnostic.Create(
DiagnosticDescriptors.ComponentParameterCaptureUnmatchedValuesHasWrongType,
property.Locations[0],
property.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat),
property.Type.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat),
symbols.ParameterCaptureUnmatchedValuesRuntimeType.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat)));
}
}
}
// Check if the type defines multiple CaptureUnmatchedValues parameters. Doing this outside the loop means we place the
// errors on the type.
if (captureUnmatchedValuesParameters.Count > 1)
{
context.ReportDiagnostic(Diagnostic.Create(
DiagnosticDescriptors.ComponentParameterCaptureUnmatchedValuesMustBeUnique,
context.Symbol.Locations[0],
type.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat),
Environment.NewLine,
string.Join(
Environment.NewLine,
captureUnmatchedValuesParameters.Select(p => p.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat)).OrderBy(n => n))));
}
});
}, SymbolKind.NamedType);
});
}
}
}

View File

@ -1,76 +0,0 @@
// 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 Microsoft.AspNetCore.Components.Shared;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
using System.Collections.Immutable;
using System.Linq;
namespace Microsoft.AspNetCore.Components.Analyzers
{
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class ComponentParametersShouldNotBePublicAnalyzer : DiagnosticAnalyzer
{
public const string DiagnosticId = "BL9993";
private const string Category = "Encapsulation";
private static readonly LocalizableString Title = new LocalizableResourceString(nameof(Resources.ComponentParametersShouldNotBePublic_Title), Resources.ResourceManager, typeof(Resources));
private static readonly LocalizableString MessageFormat = new LocalizableResourceString(nameof(Resources.ComponentParametersShouldNotBePublic_Format), Resources.ResourceManager, typeof(Resources));
private static readonly LocalizableString Description = new LocalizableResourceString(nameof(Resources.ComponentParametersShouldNotBePublic_Description), Resources.ResourceManager, typeof(Resources));
private static DiagnosticDescriptor Rule = new DiagnosticDescriptor(
DiagnosticId,
Title,
MessageFormat,
Category,
DiagnosticSeverity.Warning,
isEnabledByDefault: true,
description: Description);
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics
=> ImmutableArray.Create(Rule);
public override void Initialize(AnalysisContext context)
{
context.RegisterSyntaxNodeAction(AnalyzeSyntax, SyntaxKind.PropertyDeclaration);
}
private void AnalyzeSyntax(SyntaxNodeAnalysisContext context)
{
var semanticModel = context.SemanticModel;
var declaration = (PropertyDeclarationSyntax)context.Node;
var parameterAttribute = declaration.AttributeLists
.SelectMany(list => list.Attributes)
.Where(attr => semanticModel.GetTypeInfo(attr).Type?.ToDisplayString() == ComponentsApi.ParameterAttribute.FullTypeName)
.FirstOrDefault();
if (parameterAttribute != null && IsPubliclySettable(declaration))
{
var identifierText = declaration.Identifier.Text;
if (!string.IsNullOrEmpty(identifierText))
{
context.ReportDiagnostic(Diagnostic.Create(
Rule,
declaration.GetLocation(),
identifierText));
}
}
}
private static bool IsPubliclySettable(PropertyDeclarationSyntax declaration)
{
// If the property has a setter explicitly marked private/protected/internal, then it's not public
var setter = declaration.AccessorList?.Accessors.SingleOrDefault(x => x.Keyword.IsKind(SyntaxKind.SetKeyword));
if (setter != null && setter.Modifiers.Any(x => x.IsKind(SyntaxKind.PrivateKeyword) || x.IsKind(SyntaxKind.ProtectedKeyword) || x.IsKind(SyntaxKind.InternalKeyword)))
{
return false;
}
// Otherwise fallback to the property declaration modifiers
return declaration.Modifiers.Any(x => x.IsKind(SyntaxKind.PublicKeyword));
}
}
}

View File

@ -1,15 +1,15 @@
// Copyright (c) .NET Foundation. All rights reserved.
// 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.Collections.Immutable;
using System.Composition;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using System.Collections.Immutable;
using System.Composition;
using System.Linq;
using System.Threading.Tasks;
namespace Microsoft.AspNetCore.Components.Analyzers
{
@ -19,7 +19,7 @@ namespace Microsoft.AspNetCore.Components.Analyzers
private static readonly LocalizableString Title = new LocalizableResourceString(nameof(Resources.ComponentParametersShouldNotBePublic_FixTitle), Resources.ResourceManager, typeof(Resources));
public override ImmutableArray<string> FixableDiagnosticIds
=> ImmutableArray.Create(ComponentParametersShouldNotBePublicAnalyzer.DiagnosticId);
=> ImmutableArray.Create(DiagnosticDescriptors.ComponentParametersShouldNotBePublic.Id);
public sealed override FixAllProvider GetFixAllProvider()
{

View File

@ -0,0 +1,65 @@
// 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 Microsoft.CodeAnalysis;
namespace Microsoft.AspNetCore.Components.Analyzers
{
internal class ComponentSymbols
{
public static bool TryCreate(Compilation compilation, out ComponentSymbols symbols)
{
if (compilation == null)
{
throw new ArgumentNullException(nameof(compilation));
}
var parameterAttribute = compilation.GetTypeByMetadataName(ComponentsApi.ParameterAttribute.MetadataName);
if (parameterAttribute == null)
{
symbols = null;
return false;
}
var cascadingParameterAttribute = compilation.GetTypeByMetadataName(ComponentsApi.CascadingParameterAttribute.MetadataName);
if (cascadingParameterAttribute == null)
{
symbols = null;
return false;
}
var dictionary = compilation.GetTypeByMetadataName("System.Collections.Generic.Dictionary`2");
var @string = compilation.GetSpecialType(SpecialType.System_String);
var @object = compilation.GetSpecialType(SpecialType.System_Object);
if (dictionary == null || @string == null || @object == null)
{
symbols = null;
return false;
}
var parameterCaptureUnmatchedValuesRuntimeType = dictionary.Construct(@string, @object);
symbols = new ComponentSymbols(parameterAttribute, cascadingParameterAttribute, parameterCaptureUnmatchedValuesRuntimeType);
return true;
}
private ComponentSymbols(
INamedTypeSymbol parameterAttribute,
INamedTypeSymbol cascadingParameterAttribute,
INamedTypeSymbol parameterCaptureUnmatchedValuesRuntimeType)
{
ParameterAttribute = parameterAttribute;
CascadingParameterAttribute = cascadingParameterAttribute;
ParameterCaptureUnmatchedValuesRuntimeType = parameterCaptureUnmatchedValuesRuntimeType;
}
public INamedTypeSymbol ParameterAttribute { get; }
// Dictionary<string, object>
public INamedTypeSymbol ParameterCaptureUnmatchedValuesRuntimeType { get; }
public INamedTypeSymbol CascadingParameterAttribute { get; }
}
}

View File

@ -1,7 +1,7 @@
// 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.
namespace Microsoft.AspNetCore.Components.Shared
namespace Microsoft.AspNetCore.Components.Analyzers
{
// Constants for type and method names used in code-generation
// Keep these in sync with the actual definitions
@ -13,6 +13,14 @@ namespace Microsoft.AspNetCore.Components.Shared
{
public static readonly string FullTypeName = "Microsoft.AspNetCore.Components.ParameterAttribute";
public static readonly string MetadataName = FullTypeName;
public static readonly string CaptureUnmatchedValues = "CaptureUnmatchedValues";
}
public static class CascadingParameterAttribute
{
public static readonly string FullTypeName = "Microsoft.AspNetCore.Components.CascadingParameterAttribute";
public static readonly string MetadataName = FullTypeName;
}
}
}

View File

@ -0,0 +1,41 @@
// 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 Microsoft.CodeAnalysis;
namespace Microsoft.AspNetCore.Components.Analyzers
{
internal static class DiagnosticDescriptors
{
// Note: The Razor Compiler (including Components features) use the RZ prefix for diagnostics, so there's currently
// no change of clashing between that and the BL prefix used here.
//
// Tracking https://github.com/aspnet/AspNetCore/issues/10382 to rationalize this
public static readonly DiagnosticDescriptor ComponentParametersShouldNotBePublic = new DiagnosticDescriptor(
"BL0001",
new LocalizableResourceString(nameof(Resources.ComponentParametersShouldNotBePublic_Title), Resources.ResourceManager, typeof(Resources)),
new LocalizableResourceString(nameof(Resources.ComponentParametersShouldNotBePublic_Format), Resources.ResourceManager, typeof(Resources)),
"Encapsulation",
DiagnosticSeverity.Warning,
isEnabledByDefault: true,
description: new LocalizableResourceString(nameof(Resources.ComponentParametersShouldNotBePublic_Description), Resources.ResourceManager, typeof(Resources)));
public static readonly DiagnosticDescriptor ComponentParameterCaptureUnmatchedValuesMustBeUnique = new DiagnosticDescriptor(
"BL0002",
new LocalizableResourceString(nameof(Resources.ComponentParameterCaptureUnmatchedValuesMustBeUnique_Title), Resources.ResourceManager, typeof(Resources)),
new LocalizableResourceString(nameof(Resources.ComponentParameterCaptureUnmatchedValuesMustBeUnique_Format), Resources.ResourceManager, typeof(Resources)),
"Usage",
DiagnosticSeverity.Warning,
isEnabledByDefault: true,
description: new LocalizableResourceString(nameof(Resources.ComponentParameterCaptureUnmatchedValuesMustBeUnique_Description), Resources.ResourceManager, typeof(Resources)));
public static readonly DiagnosticDescriptor ComponentParameterCaptureUnmatchedValuesHasWrongType = new DiagnosticDescriptor(
"BL0003",
new LocalizableResourceString(nameof(Resources.ComponentParameterCaptureUnmatchedValuesHasWrongType_Title), Resources.ResourceManager, typeof(Resources)),
new LocalizableResourceString(nameof(Resources.ComponentParameterCaptureUnmatchedValuesHasWrongType_Format), Resources.ResourceManager, typeof(Resources)),
"Usage",
DiagnosticSeverity.Warning,
isEnabledByDefault: true,
description: new LocalizableResourceString(nameof(Resources.ComponentParameterCaptureUnmatchedValuesHasWrongType_Description), Resources.ResourceManager, typeof(Resources)));
}
}

View File

@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Components.Analyzers.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")]

View File

@ -10,7 +10,6 @@
namespace Microsoft.AspNetCore.Components.Analyzers {
using System;
using System.Reflection;
/// <summary>
@ -20,7 +19,7 @@ namespace Microsoft.AspNetCore.Components.Analyzers {
// class via a tool like ResGen or Visual Studio.
// To add or remove a member, edit your .ResX file then rerun ResGen
// with the /str option, or rebuild your VS project.
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "15.0.0.0")]
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
internal class Resources {
@ -40,7 +39,7 @@ namespace Microsoft.AspNetCore.Components.Analyzers {
internal static global::System.Resources.ResourceManager ResourceManager {
get {
if (object.ReferenceEquals(resourceMan, null)) {
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Microsoft.AspNetCore.Components.Analyzers.Resources", typeof(Resources).GetTypeInfo().Assembly);
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Microsoft.AspNetCore.Components.Analyzers.Resources", typeof(Resources).Assembly);
resourceMan = temp;
}
return resourceMan;
@ -61,6 +60,60 @@ namespace Microsoft.AspNetCore.Components.Analyzers {
}
}
/// <summary>
/// Looks up a localized string similar to Component parameters with CaptureUnmatchedValuess must be a correct type..
/// </summary>
internal static string ComponentParameterCaptureUnmatchedValuesHasWrongType_Description {
get {
return ResourceManager.GetString("ComponentParameterCaptureUnmatchedValuesHasWrongType_Description", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Component parameter &apos;{0}&apos; defines CaptureUnmatchedValuess but has an unsupported type &apos;{1}&apos;. Use a type assignable from &apos;{2}&apos;..
/// </summary>
internal static string ComponentParameterCaptureUnmatchedValuesHasWrongType_Format {
get {
return ResourceManager.GetString("ComponentParameterCaptureUnmatchedValuesHasWrongType_Format", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Component parameter with CaptureUnmatchedValues has the wrong type.
/// </summary>
internal static string ComponentParameterCaptureUnmatchedValuesHasWrongType_Title {
get {
return ResourceManager.GetString("ComponentParameterCaptureUnmatchedValuesHasWrongType_Title", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Components may only define a single parameter with CaptureUnmatchedValues..
/// </summary>
internal static string ComponentParameterCaptureUnmatchedValuesMustBeUnique_Description {
get {
return ResourceManager.GetString("ComponentParameterCaptureUnmatchedValuesMustBeUnique_Description", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Component type &apos;{0}&apos; defines properties multiple parameters with CaptureUnmatchedValues. Properties: {1}{2}.
/// </summary>
internal static string ComponentParameterCaptureUnmatchedValuesMustBeUnique_Format {
get {
return ResourceManager.GetString("ComponentParameterCaptureUnmatchedValuesMustBeUnique_Format", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Component has multiple CaptureUnmatchedValues parameters.
/// </summary>
internal static string ComponentParameterCaptureUnmatchedValuesMustBeUnique_Title {
get {
return ResourceManager.GetString("ComponentParameterCaptureUnmatchedValuesMustBeUnique_Title", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Component parameters should not have public setters..
/// </summary>

View File

@ -1,17 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
@ -26,36 +26,36 @@
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
@ -129,4 +129,22 @@
<data name="ComponentParametersShouldNotBePublic_Title" xml:space="preserve">
<value>Component parameter has public setter</value>
</data>
</root>
<data name="ComponentParameterCaptureUnmatchedValuesMustBeUnique_Description" xml:space="preserve">
<value>Components may only define a single parameter with CaptureUnmatchedValues.</value>
</data>
<data name="ComponentParameterCaptureUnmatchedValuesMustBeUnique_Format" xml:space="preserve">
<value>Component type '{0}' defines properties multiple parameters with CaptureUnmatchedValues. Properties: {1}{2}</value>
</data>
<data name="ComponentParameterCaptureUnmatchedValuesMustBeUnique_Title" xml:space="preserve">
<value>Component has multiple CaptureUnmatchedValues parameters</value>
</data>
<data name="ComponentParameterCaptureUnmatchedValuesHasWrongType_Description" xml:space="preserve">
<value>Component parameters with CaptureUnmatchedValues must be a correct type.</value>
</data>
<data name="ComponentParameterCaptureUnmatchedValuesHasWrongType_Format" xml:space="preserve">
<value>Component parameter '{0}' defines CaptureUnmatchedValues but has an unsupported type '{1}'. Use a type assignable from '{2}'.</value>
</data>
<data name="ComponentParameterCaptureUnmatchedValuesHasWrongType_Title" xml:space="preserve">
<value>Component parameter with CaptureUnmatchedValues has the wrong type</value>
</data>
</root>

View File

@ -0,0 +1,80 @@
// 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 Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
using TestHelper;
using Xunit;
namespace Microsoft.AspNetCore.Components.Analyzers.Test
{
public class ComponentParameterCaptureUnmatchedValuesHasWrongTypeTest : DiagnosticVerifier
{
[Theory]
[InlineData("System.Collections.Generic.IEnumerable<System.Collections.Generic.KeyValuePair<string, object>>")]
[InlineData("System.Collections.Generic.Dictionary<string, object>")]
[InlineData("System.Collections.Generic.IDictionary<string, object>")]
[InlineData("System.Collections.Generic.IReadOnlyDictionary<string, object>")]
public void IgnoresPropertiesWithSupportedType(string propertyType)
{
var test = $@"
namespace ConsoleApplication1
{{
using {typeof(ParameterAttribute).Namespace};
class TypeName
{{
[Parameter(CaptureUnmatchedValues = true)] {propertyType} MyProperty {{ get; set; }}
}}
}}" + ComponentsTestDeclarations.Source;
VerifyCSharpDiagnostic(test);
}
[Fact]
public void IgnoresPropertiesWithCaptureUnmatchedValuesFalse()
{
var test = $@"
namespace ConsoleApplication1
{{
using {typeof(ParameterAttribute).Namespace};
class TypeName
{{
[Parameter(CaptureUnmatchedValues = false)] string MyProperty {{ get; set; }}
}}
}}" + ComponentsTestDeclarations.Source;
VerifyCSharpDiagnostic(test);
}
[Fact]
public void AddsDiagnosticForInvalidType()
{
var test = $@"
namespace ConsoleApplication1
{{
using {typeof(ParameterAttribute).Namespace};
class TypeName
{{
[Parameter(CaptureUnmatchedValues = true)] string MyProperty {{ get; set; }}
}}
}}" + ComponentsTestDeclarations.Source;
VerifyCSharpDiagnostic(test,
new DiagnosticResult
{
Id = DiagnosticDescriptors.ComponentParameterCaptureUnmatchedValuesHasWrongType.Id,
Message = "Component parameter 'ConsoleApplication1.TypeName.MyProperty' defines CaptureUnmatchedValues but has an unsupported type 'string'. Use a type assignable from 'System.Collections.Generic.Dictionary<string, object>'.",
Severity = DiagnosticSeverity.Warning,
Locations = new[]
{
new DiagnosticResultLocation("Test0.cs", 7, 63)
}
});
}
protected override DiagnosticAnalyzer GetCSharpDiagnosticAnalyzer()
{
return new ComponentParameterAnalyzer();
}
}
}

View File

@ -0,0 +1,68 @@
// 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 Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
using TestHelper;
using Xunit;
namespace Microsoft.AspNetCore.Components.Analyzers.Test
{
public class ComponentParameterCaptureUnmatchedValuesMustBeUniqueTest : DiagnosticVerifier
{
[Fact]
public void IgnoresPropertiesWithCaptureUnmatchedValuesFalse()
{
var test = $@"
namespace ConsoleApplication1
{{
using System.Collections.Generic;
using {typeof(ParameterAttribute).Namespace};
class TypeName
{{
[Parameter(CaptureUnmatchedValues = false)] string MyProperty {{ get; set; }}
[Parameter(CaptureUnmatchedValues = true)] Dictionary<string, object> MyOtherProperty {{ get; set; }}
}}
}}" + ComponentsTestDeclarations.Source;
VerifyCSharpDiagnostic(test);
}
[Fact]
public void AddsDiagnosticForMultipleCaptureUnmatchedValuesProperties()
{
var test = $@"
namespace ConsoleApplication1
{{
using System.Collections.Generic;
using {typeof(ParameterAttribute).Namespace};
class TypeName
{{
[Parameter(CaptureUnmatchedValues = true)] Dictionary<string, object> MyProperty {{ get; set; }}
[Parameter(CaptureUnmatchedValues = true)] Dictionary<string, object> MyOtherProperty {{ get; set; }}
}}
}}" + ComponentsTestDeclarations.Source;
var message = @"Component type 'ConsoleApplication1.TypeName' defines properties multiple parameters with CaptureUnmatchedValues. Properties:
ConsoleApplication1.TypeName.MyOtherProperty
ConsoleApplication1.TypeName.MyProperty";
VerifyCSharpDiagnostic(test,
new DiagnosticResult
{
Id = DiagnosticDescriptors.ComponentParameterCaptureUnmatchedValuesMustBeUnique.Id,
Message = message,
Severity = DiagnosticSeverity.Warning,
Locations = new[]
{
new DiagnosticResultLocation("Test0.cs", 6, 15)
}
});
}
protected override DiagnosticAnalyzer GetCSharpDiagnosticAnalyzer()
{
return new ComponentParameterAnalyzer();
}
}
}

View File

@ -1,7 +1,6 @@
// 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 Microsoft.AspNetCore.Components;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.Diagnostics;
@ -12,15 +11,6 @@ namespace Microsoft.AspNetCore.Components.Analyzers.Test
{
public class ComponentParametersShouldNotBePublic : CodeFixVerifier
{
static string ParameterSource = $@"
namespace {typeof(ParameterAttribute).Namespace}
{{
public class {typeof(ParameterAttribute).Name} : System.Attribute
{{
}}
}}
";
[Fact]
public void IgnoresPublicPropertiesWithoutParameterAttribute()
{
@ -31,7 +21,7 @@ namespace Microsoft.AspNetCore.Components.Analyzers.Test
{
public string MyProperty { get; set; }
}
}" + ParameterSource;
}" + ComponentsTestDeclarations.Source;
VerifyCSharpDiagnostic(test);
}
@ -48,10 +38,10 @@ namespace Microsoft.AspNetCore.Components.Analyzers.Test
{
[Parameter] string MyPropertyNoModifer { get; set; }
[Parameter] private string MyPropertyPrivate { get; set; }
[Parameter] protected string MyPropertyProtected { get; set; }
[Parameter] internal string MyPropertyInternal { get; set; }
[CascadingParameter] protected string MyPropertyProtected { get; set; }
[CascadingParameter] internal string MyPropertyInternal { get; set; }
}
}" + ParameterSource;
}" + ComponentsTestDeclarations.Source;
VerifyCSharpDiagnostic(test);
}
@ -67,29 +57,29 @@ namespace Microsoft.AspNetCore.Components.Analyzers.Test
class TypeName
{
[Parameter] public string BadProperty1 { get; set; }
[Parameter] public object BadProperty2 { get; set; }
[CascadingParameter] public object BadProperty2 { get; set; }
}
}" + ParameterSource;
}" + ComponentsTestDeclarations.Source;
VerifyCSharpDiagnostic(test,
new DiagnosticResult
{
Id = "BL9993",
Message = "Component parameter 'BadProperty1' has a public setter, but component parameters should not be publicly settable.",
Id = DiagnosticDescriptors.ComponentParametersShouldNotBePublic.Id,
Message = "Component parameter 'ConsoleApplication1.TypeName.BadProperty1' has a public setter, but component parameters should not be publicly settable.",
Severity = DiagnosticSeverity.Warning,
Locations = new[]
{
new DiagnosticResultLocation("Test0.cs", 8, 13)
new DiagnosticResultLocation("Test0.cs", 8, 39)
}
},
new DiagnosticResult
{
Id = "BL9993",
Message = "Component parameter 'BadProperty2' has a public setter, but component parameters should not be publicly settable.",
Id = DiagnosticDescriptors.ComponentParametersShouldNotBePublic.Id,
Message = "Component parameter 'ConsoleApplication1.TypeName.BadProperty2' has a public setter, but component parameters should not be publicly settable.",
Severity = DiagnosticSeverity.Warning,
Locations = new[]
{
new DiagnosticResultLocation("Test0.cs", 9, 13)
new DiagnosticResultLocation("Test0.cs", 9, 48)
}
});
@ -101,9 +91,9 @@ namespace Microsoft.AspNetCore.Components.Analyzers.Test
class TypeName
{
[Parameter] string BadProperty1 { get; set; }
[Parameter] object BadProperty2 { get; set; }
[CascadingParameter] object BadProperty2 { get; set; }
}
}" + ParameterSource);
}" + ComponentsTestDeclarations.Source);
}
[Fact]
@ -120,7 +110,7 @@ namespace Microsoft.AspNetCore.Components.Analyzers.Test
[Parameter] public object MyProperty2 { get; protected set; }
[Parameter] public object MyProperty2 { get; internal set; }
}
}" + ParameterSource;
}" + ComponentsTestDeclarations.Source;
VerifyCSharpDiagnostic(test);
}
@ -132,7 +122,7 @@ namespace Microsoft.AspNetCore.Components.Analyzers.Test
protected override DiagnosticAnalyzer GetCSharpDiagnosticAnalyzer()
{
return new ComponentParametersShouldNotBePublicAnalyzer();
return new ComponentParameterAnalyzer();
}
}
}

View File

@ -0,0 +1,22 @@
// 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.
namespace Microsoft.AspNetCore.Components.Analyzers
{
public static class ComponentsTestDeclarations
{
public static readonly string Source = $@"
namespace {typeof(ParameterAttribute).Namespace}
{{
public class {typeof(ParameterAttribute).Name} : System.Attribute
{{
public bool CaptureUnmatchedValues {{ get; set; }}
}}
public class {typeof(CascadingParameterAttribute).Name} : System.Attribute
{{
}}
}}
";
}
}

View File

@ -373,6 +373,7 @@ namespace Microsoft.AspNetCore.Components
public sealed partial class ParameterAttribute : System.Attribute
{
public ParameterAttribute() { }
public bool CaptureUnmatchedValues { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
}
[System.Runtime.InteropServices.StructLayoutAttribute(System.Runtime.InteropServices.LayoutKind.Sequential)]
public readonly partial struct ParameterCollection
@ -769,6 +770,7 @@ namespace Microsoft.AspNetCore.Components.RenderTree
public void AddContent<T>(int sequence, Microsoft.AspNetCore.Components.RenderFragment<T> fragment, T value) { }
public void AddElementReferenceCapture(int sequence, System.Action<Microsoft.AspNetCore.Components.ElementRef> elementReferenceCaptureAction) { }
public void AddMarkupContent(int sequence, string markupContent) { }
public void AddMultipleAttributes<T>(int sequence, System.Collections.Generic.IEnumerable<System.Collections.Generic.KeyValuePair<string, T>> attributes) { }
public void Clear() { }
public void CloseComponent() { }
public void CloseElement() { }
@ -813,6 +815,7 @@ namespace Microsoft.AspNetCore.Components.RenderTree
}
public enum RenderTreeFrameType
{
None = 0,
Element = 1,
Text = 2,
Attribute = 3,

View File

@ -9,7 +9,7 @@ namespace Microsoft.AspNetCore.Components
/// <summary>
/// A bound event handler delegate.
/// </summary>
public readonly struct EventCallback
public readonly struct EventCallback : IEventCallback
{
/// <summary>
/// Gets a reference to the <see cref="EventCallbackFactory"/>.
@ -60,12 +60,17 @@ namespace Microsoft.AspNetCore.Components
return Receiver.HandleEventAsync(new EventCallbackWorkItem(Delegate), arg);
}
object IEventCallback.UnpackForRenderTree()
{
return RequiresExplicitReceiver ? (object)this : Delegate;
}
}
/// <summary>
/// A bound event handler delegate.
/// </summary>
public readonly struct EventCallback<T>
public readonly struct EventCallback<T> : IEventCallback
{
internal readonly MulticastDelegate Delegate;
internal readonly IHandleEvent Receiver;
@ -86,7 +91,7 @@ namespace Microsoft.AspNetCore.Components
/// </summary>
public bool HasDelegate => Delegate != null;
// This is a hint to the runtime that Reciever is a different object than what
// This is a hint to the runtime that Receiver is a different object than what
// Delegate.Target points to. This allows us to avoid boxing the command object
// when building the render tree. See logic where this is used.
internal bool RequiresExplicitReceiver => Receiver != null && !object.ReferenceEquals(Receiver, Delegate?.Target);
@ -111,5 +116,18 @@ namespace Microsoft.AspNetCore.Components
{
return new EventCallback(Receiver ?? Delegate?.Target as IHandleEvent, Delegate);
}
object IEventCallback.UnpackForRenderTree()
{
return RequiresExplicitReceiver ? (object)AsUntyped() : Delegate;
}
}
// Used to understand boxed generic EventCallbacks
internal interface IEventCallback
{
bool HasDelegate { get; }
object UnpackForRenderTree();
}
}

View File

@ -2,6 +2,8 @@
// 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 Microsoft.AspNetCore.Components.RenderTree;
namespace Microsoft.AspNetCore.Components
{
@ -11,5 +13,24 @@ namespace Microsoft.AspNetCore.Components
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = false)]
public sealed class ParameterAttribute : Attribute
{
/// <summary>
/// Gets or sets a value that determines whether the parameter will capture values that
/// don't match any other parameter.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="CaptureUnmatchedValues"/> allows a component to accept arbitrary additional
/// attributes, and pass them to another component, or some element of the underlying markup.
/// </para>
/// <para>
/// <see cref="CaptureUnmatchedValues"/> can be used on at most one parameter per component.
/// </para>
/// <para>
/// <see cref="CaptureUnmatchedValues"/> should only be applied to parameters of a type that
/// can be used with <see cref="RenderTreeBuilder.AddMultipleAttributes{T}(int, System.Collections.Generic.IEnumerable{System.Collections.Generic.KeyValuePair{string, T}})"/>
/// such as <see cref="Dictionary{String, Object}"/>.
/// </para>
/// </remarks>
public bool CaptureUnmatchedValues { get; set; }
}
}

View File

@ -1,11 +1,13 @@
// 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 Microsoft.AspNetCore.Components.Reflection;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Reflection;
using Microsoft.AspNetCore.Components.Reflection;
namespace Microsoft.AspNetCore.Components
{
@ -41,17 +43,72 @@ namespace Microsoft.AspNetCore.Components
_cachedWritersByType[targetType] = writers;
}
foreach (var parameter in parameterCollection)
// The logic is split up for simplicity now that we have CaptureUnmatchedValues parameters.
if (writers.CaptureUnmatchedValuesWriter == null)
{
var parameterName = parameter.Name;
if (!writers.WritersByName.TryGetValue(parameterName, out var writerWithIndex))
// Logic for components without a CaptureUnmatchedValues parameter
foreach (var parameter in parameterCollection)
{
ThrowForUnknownIncomingParameterName(targetType, parameterName);
var parameterName = parameter.Name;
if (!writers.WritersByName.TryGetValue(parameterName, out var writer))
{
// Case 1: There is nowhere to put this value.
ThrowForUnknownIncomingParameterName(targetType, parameterName);
throw null; // Unreachable
}
SetProperty(target, writer, parameterName, parameter.Value);
}
}
else
{
// Logic with components with a CaptureUnmatchedValues parameter
var isCaptureUnmatchedValuesParameterSetExplicitly = false;
Dictionary<string, object> unmatched = null;
foreach (var parameter in parameterCollection)
{
var parameterName = parameter.Name;
if (string.Equals(parameterName, writers.CaptureUnmatchedValuesPropertyName, StringComparison.OrdinalIgnoreCase))
{
isCaptureUnmatchedValuesParameterSetExplicitly = true;
}
var isUnmatchedValue = !writers.WritersByName.TryGetValue(parameterName, out var writer);
if (isUnmatchedValue)
{
unmatched ??= new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
unmatched[parameterName] = parameter.Value;
}
else
{
Debug.Assert(writer != null);
SetProperty(target, writer, parameterName, parameter.Value);
}
}
if (unmatched != null && isCaptureUnmatchedValuesParameterSetExplicitly)
{
// This has to be an error because we want to allow users to set the CaptureUnmatchedValues
// parameter explicitly and ....
// 1. We don't ever want to mutate a value the user gives us.
// 2. We also don't want to implicitly copy a value the user gives us.
//
// Either one of those implementation choices would do something unexpected.
ThrowForCaptureUnmatchedValuesConflict(targetType, writers.CaptureUnmatchedValuesPropertyName, unmatched);
throw null; // Unreachable
}
else if (unmatched != null)
{
// We had some unmatched values, set the CaptureUnmatchedValues property
SetProperty(target, writers.CaptureUnmatchedValuesWriter, writers.CaptureUnmatchedValuesPropertyName, unmatched);
}
}
static void SetProperty(object target, IPropertySetter writer, string parameterName, object value)
{
try
{
writerWithIndex.SetValue(target, parameter.Value);
writer.SetValue(target, value);
}
catch (Exception ex)
{
@ -93,18 +150,49 @@ namespace Microsoft.AspNetCore.Components
}
}
class WritersForType
private static void ThrowForCaptureUnmatchedValuesConflict(Type targetType, string parameterName, Dictionary<string, object> unmatched)
{
public Dictionary<string, IPropertySetter> WritersByName { get; }
throw new InvalidOperationException(
$"The property '{parameterName}' on component type '{targetType.FullName}' cannot be set explicitly " +
$"when also used to capture unmatched values. Unmatched values:" + Environment.NewLine +
string.Join(Environment.NewLine, unmatched.Keys.OrderBy(k => k)));
}
private static void ThrowForMultipleCaptureUnmatchedValuesParameters(Type targetType)
{
// We don't care about perf here, we want to report an accurate and useful error.
var propertyNames = targetType
.GetProperties(_bindablePropertyFlags)
.Where(p => p.GetCustomAttribute<ParameterAttribute>()?.CaptureUnmatchedValues == true)
.Select(p => p.Name)
.OrderBy(p => p)
.ToArray();
throw new InvalidOperationException(
$"Multiple properties were found on component type '{targetType.FullName}' with " +
$"'{nameof(ParameterAttribute)}.{nameof(ParameterAttribute.CaptureUnmatchedValues)}'. Only a single property " +
$"per type can use '{nameof(ParameterAttribute)}.{nameof(ParameterAttribute.CaptureUnmatchedValues)}'. Properties:" + Environment.NewLine +
string.Join(Environment.NewLine, propertyNames));
}
private static void ThrowForInvalidCaptureUnmatchedValuesParameterType(Type targetType, PropertyInfo propertyInfo)
{
throw new InvalidOperationException(
$"The property '{propertyInfo.Name}' on component type '{targetType.FullName}' cannot be used " +
$"with '{nameof(ParameterAttribute)}.{nameof(ParameterAttribute.CaptureUnmatchedValues)}' because it has the wrong type. " +
$"The property must be assignable from 'Dictionary<string, object>'.");
}
private class WritersForType
{
public WritersForType(Type targetType)
{
WritersByName = new Dictionary<string, IPropertySetter>(StringComparer.OrdinalIgnoreCase);
foreach (var propertyInfo in GetCandidateBindableProperties(targetType))
{
var shouldCreateWriter = propertyInfo.IsDefined(typeof(ParameterAttribute))
|| propertyInfo.IsDefined(typeof(CascadingParameterAttribute));
if (!shouldCreateWriter)
var parameterAttribute = propertyInfo.GetCustomAttribute<ParameterAttribute>();
var isParameter = parameterAttribute != null || propertyInfo.IsDefined(typeof(CascadingParameterAttribute));
if (!isParameter)
{
continue;
}
@ -120,8 +208,34 @@ namespace Microsoft.AspNetCore.Components
}
WritersByName.Add(propertyName, propertySetter);
if (parameterAttribute != null && parameterAttribute.CaptureUnmatchedValues)
{
// This is an "Extra" parameter.
//
// There should only be one of these.
if (CaptureUnmatchedValuesWriter != null)
{
ThrowForMultipleCaptureUnmatchedValuesParameters(targetType);
}
// It must be able to hold a Dictionary<string, object> since that's what we create.
if (!propertyInfo.PropertyType.IsAssignableFrom(typeof(Dictionary<string, object>)))
{
ThrowForInvalidCaptureUnmatchedValuesParameterType(targetType, propertyInfo);
}
CaptureUnmatchedValuesWriter = MemberAssignment.CreatePropertySetter(targetType, propertyInfo);
CaptureUnmatchedValuesPropertyName = propertyInfo.Name;
}
}
}
public Dictionary<string, IPropertySetter> WritersByName { get; }
public IPropertySetter CaptureUnmatchedValuesWriter { get; }
public string CaptureUnmatchedValuesPropertyName { get; }
}
}
}

View File

@ -1,4 +1,4 @@
// Copyright (c) .NET Foundation. All rights reserved.
// 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;

View File

@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;
@ -27,6 +28,8 @@ namespace Microsoft.AspNetCore.Components.RenderTree
private readonly ArrayBuilder<RenderTreeFrame> _entries = new ArrayBuilder<RenderTreeFrame>(10);
private readonly Stack<int> _openElementIndices = new Stack<int>();
private RenderTreeFrameType? _lastNonAttributeFrameType;
private bool _hasSeenAddMultipleAttributes;
private Dictionary<string, int> _seenAttributeNames;
/// <summary>
/// The reserved parameter name used for supplying child content.
@ -52,6 +55,14 @@ namespace Microsoft.AspNetCore.Components.RenderTree
/// <param name="elementName">A value representing the type of the element.</param>
public void OpenElement(int sequence, string elementName)
{
// We are entering a new scope, since we track the "duplicate attributes" per
// element/component we might need to clean them up now.
if (_hasSeenAddMultipleAttributes)
{
var indexOfLastElementOrComponent = _openElementIndices.Peek();
ProcessDuplicateAttributes(first: indexOfLastElementOrComponent + 1);
}
_openElementIndices.Push(_entries.Count);
Append(RenderTreeFrame.Element(sequence, elementName));
}
@ -63,6 +74,14 @@ namespace Microsoft.AspNetCore.Components.RenderTree
public void CloseElement()
{
var indexOfEntryBeingClosed = _openElementIndices.Pop();
// We might be closing an element with only attributes, run the duplicate cleanup pass
// if necessary.
if (_hasSeenAddMultipleAttributes)
{
ProcessDuplicateAttributes(first: indexOfEntryBeingClosed + 1);
}
ref var entry = ref _entries.Buffer[indexOfEntryBeingClosed];
entry = entry.WithElementSubtreeLength(_entries.Count - indexOfEntryBeingClosed);
}
@ -157,6 +176,10 @@ namespace Microsoft.AspNetCore.Components.RenderTree
// or absence of an attribute, and false => "False" which isn't falsy in js.
Append(RenderTreeFrame.Attribute(sequence, name, BoxedTrue));
}
else
{
TrackAttributeName(name);
}
}
/// <summary>
@ -178,6 +201,10 @@ namespace Microsoft.AspNetCore.Components.RenderTree
{
Append(RenderTreeFrame.Attribute(sequence, name, value));
}
else
{
TrackAttributeName(name);
}
}
/// <summary>
@ -275,6 +302,10 @@ namespace Microsoft.AspNetCore.Components.RenderTree
{
Append(RenderTreeFrame.Attribute(sequence, name, value));
}
else
{
TrackAttributeName(name);
}
}
/// <summary>
@ -308,12 +339,17 @@ namespace Microsoft.AspNetCore.Components.RenderTree
// so we can get it out on the other side.
Append(RenderTreeFrame.Attribute(sequence, name, (object)value));
}
else
else if (value.HasDelegate)
{
// In the common case the receiver is also the delegate's target, so we
// just need to retain the delegate. This allows us to avoid an allocation.
Append(RenderTreeFrame.Attribute(sequence, name, value.Delegate));
}
else
{
// Track the attribute name if needed since we elided the frame.
TrackAttributeName(name);
}
}
/// <summary>
@ -347,12 +383,17 @@ namespace Microsoft.AspNetCore.Components.RenderTree
// need to preserve the type of an EventCallback<T> when it's invoked from the DOM.
Append(RenderTreeFrame.Attribute(sequence, name, (object)value.AsUntyped()));
}
else
else if (value.HasDelegate)
{
// In the common case the receiver is also the delegate's target, so we
// just need to retain the delegate. This allows us to avoid an allocation.
Append(RenderTreeFrame.Attribute(sequence, name, value.Delegate));
}
else
{
// Track the attribute name if needed since we elided the frame.
TrackAttributeName(name);
}
}
/// <summary>
@ -372,7 +413,8 @@ namespace Microsoft.AspNetCore.Components.RenderTree
{
if (value == null)
{
// Do nothing, treat 'null' attribute values for elements as a conditional attribute.
// Treat 'null' attribute values for elements as a conditional attribute.
TrackAttributeName(name);
}
else if (value is bool boolValue)
{
@ -380,8 +422,22 @@ namespace Microsoft.AspNetCore.Components.RenderTree
{
Append(RenderTreeFrame.Attribute(sequence, name, BoxedTrue));
}
// Don't add anything for false bool value.
else
{
// Don't add anything for false bool value.
TrackAttributeName(name);
}
}
else if (value is IEventCallback callbackValue)
{
if (callbackValue.HasDelegate)
{
Append(RenderTreeFrame.Attribute(sequence, name, callbackValue.UnpackForRenderTree()));
}
else
{
TrackAttributeName(name);
}
}
else if (value is MulticastDelegate)
{
@ -395,6 +451,7 @@ namespace Microsoft.AspNetCore.Components.RenderTree
}
else if (_lastNonAttributeFrameType == RenderTreeFrameType.Component)
{
// If this is a component, we always want to preserve the original type.
Append(RenderTreeFrame.Attribute(sequence, name, value));
}
else
@ -425,6 +482,40 @@ namespace Microsoft.AspNetCore.Components.RenderTree
Append(frame.WithAttributeSequence(sequence));
}
/// <summary>
/// Adds frames representing multiple attributes with the same sequence number.
/// </summary>
/// <typeparam name="T">The attribute value type.</typeparam>
/// <param name="sequence">An integer that represents the position of the instruction in the source code.</param>
/// <param name="attributes">A collection of key-value pairs representing attributes.</param>
public void AddMultipleAttributes<T>(int sequence, IEnumerable<KeyValuePair<string, T>> attributes)
{
// NOTE: The IEnumerable<KeyValuePair<string, T>> is the simplest way to support a variety of
// different types like IReadOnlyDictionary<>, Dictionary<>, and IDictionary<>.
//
// None of those types are contravariant, and since we want to support attributes having a value
// of type object, the simplest thing to do is drop down to IEnumerable<KeyValuePair<>> which
// is contravariant. This also gives us things like List<KeyValuePair<>> and KeyValuePair<>[]
// for free even though we don't expect those types to be common.
// Calling this up-front just to make sure we validate before mutating anything.
AssertCanAddAttribute();
if (attributes != null)
{
_hasSeenAddMultipleAttributes = true;
foreach (var attribute in attributes)
{
// This will call the AddAttribute(int, string, object) overload.
//
// This is fine because we try to make the object overload behave identically
// to the others.
AddAttribute(sequence, attribute.Key, attribute.Value);
}
}
}
/// <summary>
/// Appends a frame representing a child component.
/// </summary>
@ -482,6 +573,14 @@ namespace Microsoft.AspNetCore.Components.RenderTree
private void OpenComponentUnchecked(int sequence, Type componentType)
{
// We are entering a new scope, since we track the "duplicate attributes" per
// element/component we might need to clean them up now.
if (_hasSeenAddMultipleAttributes)
{
var indexOfLastElementOrComponent = _openElementIndices.Peek();
ProcessDuplicateAttributes(first: indexOfLastElementOrComponent + 1);
}
_openElementIndices.Push(_entries.Count);
Append(RenderTreeFrame.ChildComponent(sequence, componentType));
}
@ -493,6 +592,14 @@ namespace Microsoft.AspNetCore.Components.RenderTree
public void CloseComponent()
{
var indexOfEntryBeingClosed = _openElementIndices.Pop();
// We might be closing a component with only attributes. Run the attribute cleanup pass
// if necessary.
if (_hasSeenAddMultipleAttributes)
{
ProcessDuplicateAttributes(first: indexOfEntryBeingClosed + 1);
}
ref var entry = ref _entries.Buffer[indexOfEntryBeingClosed];
entry = entry.WithComponentSubtreeLength(_entries.Count - indexOfEntryBeingClosed);
}
@ -579,6 +686,8 @@ namespace Microsoft.AspNetCore.Components.RenderTree
_entries.Clear();
_openElementIndices.Clear();
_lastNonAttributeFrameType = null;
_hasSeenAddMultipleAttributes = false;
_seenAttributeNames?.Clear();
}
/// <summary>
@ -590,13 +699,111 @@ namespace Microsoft.AspNetCore.Components.RenderTree
private void Append(in RenderTreeFrame frame)
{
var frameType = frame.FrameType;
_entries.Append(frame);
var frameType = frame.FrameType;
if (frameType != RenderTreeFrameType.Attribute)
{
_lastNonAttributeFrameType = frame.FrameType;
}
}
// Internal for testing
internal void ProcessDuplicateAttributes(int first)
{
Debug.Assert(_hasSeenAddMultipleAttributes);
// When AddMultipleAttributes method has been called, we need to postprocess attributes while closing
// the element/component. However, we also don't know the end index we should look at because it
// will contain nested content.
var buffer = _entries.Buffer;
var last = _entries.Count - 1;
for (var i = first; i <= last; i++)
{
if (buffer[i].FrameType != RenderTreeFrameType.Attribute)
{
last = i - 1;
break;
}
}
// Now that we've found the last attribute, we can iterate backwards and process duplicates.
var seenAttributeNames = (_seenAttributeNames ??= new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase));
for (var i = last; i >= first; i--)
{
ref var frame = ref buffer[i];
Debug.Assert(frame.FrameType == RenderTreeFrameType.Attribute, $"Frame type is {frame.FrameType} at {i}");
if (!seenAttributeNames.TryGetValue(frame.AttributeName, out var index))
{
// This is the first time seeing this attribute name. Add to the dictionary and move on.
seenAttributeNames.Add(frame.AttributeName, i);
}
else if (index < i)
{
// This attribute is overriding a "silent frame" where we didn't create a frame for an AddAttribute call.
// This is the case for a null event handler, or bool false value.
//
// We need to update our tracking, in case the attribute appeared 3 or more times.
seenAttributeNames[frame.AttributeName] = i;
}
else if (index > i)
{
// This attribute has been overridden. For now, blank out its name to *mark* it. We'll do a pass
// later to wipe it out.
frame = default;
}
else
{
// OK so index == i. How is that possible? Well it's possible for a "silent frame" immediately
// followed by setting the same attribute. Think of it this way, when we create a "silent frame"
// we have to track that attribute name with *some* index.
//
// The only index value we can safely use is _entries.Count (next available). This is fine because
// we never use these indexes to look stuff up, only for comparison.
//
// That gets you here, and there's no action to take.
}
}
// This is the pass where we cleanup attributes that have been wiped out.
//
// We copy the entries we're keeping into the earlier parts of the list (preserving order).
//
// Note that we iterate to the end of the list here, there might be additional frames after the attributes
// (ref) or content) that need to move to the left.
var offset = first;
for (var i = first; i < _entries.Count; i++)
{
ref var frame = ref buffer[i];
if (frame.FrameType != RenderTreeFrameType.None)
{
buffer[offset++] = frame;
}
}
// Clean up now unused space at the end of the list.
var residue = _entries.Count - offset;
for (var i = 0; i < residue; i++)
{
_entries.RemoveLast();
}
seenAttributeNames.Clear();
_hasSeenAddMultipleAttributes = false;
}
// Internal for testing
internal void TrackAttributeName(string name)
{
if (!_hasSeenAddMultipleAttributes)
{
return;
}
var seenAttributeNames = (_seenAttributeNames ??= new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase));
seenAttributeNames[name] = _entries.Count; // See comment in ProcessAttributes for why this is OK.
}
}
}

View File

@ -8,6 +8,11 @@ namespace Microsoft.AspNetCore.Components.RenderTree
/// </summary>
public enum RenderTreeFrameType: int
{
/// <summary>
/// Used only for unintialized frames.
/// </summary>
None = 0,
/// <summary>
/// Represents a container for other frames.
/// </summary>

View File

@ -4,8 +4,8 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.RenderTree;
using Microsoft.AspNetCore.Components.Test.Helpers;
using Xunit;
@ -138,6 +138,142 @@ namespace Microsoft.AspNetCore.Components.Test
ex.Message);
}
[Fact]
public void SettingCaptureUnmatchedValuesParameterExplicitlyWorks()
{
// Arrange
var target = new HasCaptureUnmatchedValuesProperty();
var value = new Dictionary<string, object>();
var parameterCollection = new ParameterCollectionBuilder
{
{ nameof(HasCaptureUnmatchedValuesProperty.CaptureUnmatchedValues), value },
}.Build();
// Act
parameterCollection.SetParameterProperties(target);
// Assert
Assert.Same(value, target.CaptureUnmatchedValues);
}
[Fact]
public void SettingCaptureUnmatchedValuesParameterWithUnmatchedValuesWorks()
{
// Arrange
var target = new HasCaptureUnmatchedValuesProperty();
var parameterCollection = new ParameterCollectionBuilder
{
{ nameof(HasCaptureUnmatchedValuesProperty.StringProp), "hi" },
{ "test1", 123 },
{ "test2", 456 },
}.Build();
// Act
parameterCollection.SetParameterProperties(target);
// Assert
Assert.Equal("hi", target.StringProp);
Assert.Collection(
target.CaptureUnmatchedValues.OrderBy(kvp => kvp.Key),
kvp =>
{
Assert.Equal("test1", kvp.Key);
Assert.Equal(123, kvp.Value);
},
kvp =>
{
Assert.Equal("test2", kvp.Key);
Assert.Equal(456, kvp.Value);
});
}
[Fact]
public void SettingCaptureUnmatchedValuesParameterExplicitlyAndImplicitly_Throws()
{
// Arrange
var target = new HasCaptureUnmatchedValuesProperty();
var parameterCollection = new ParameterCollectionBuilder
{
{ nameof(HasCaptureUnmatchedValuesProperty.CaptureUnmatchedValues), new Dictionary<string, object>() },
{ "test1", 123 },
{ "test2", 456 },
}.Build();
// Act
var ex = Assert.Throws<InvalidOperationException>(() => parameterCollection.SetParameterProperties(target));
// Assert
Assert.Equal(
$"The property '{nameof(HasCaptureUnmatchedValuesProperty.CaptureUnmatchedValues)}' on component type '{typeof(HasCaptureUnmatchedValuesProperty).FullName}' cannot be set explicitly when " +
$"also used to capture unmatched values. Unmatched values:" + Environment.NewLine +
$"test1" + Environment.NewLine +
$"test2",
ex.Message);
}
[Fact]
public void SettingCaptureUnmatchedValuesParameterExplicitlyAndImplicitly_ReverseOrder_Throws()
{
// Arrange
var target = new HasCaptureUnmatchedValuesProperty();
var parameterCollection = new ParameterCollectionBuilder
{
{ "test2", 456 },
{ "test1", 123 },
{ nameof(HasCaptureUnmatchedValuesProperty.CaptureUnmatchedValues), new Dictionary<string, object>() },
}.Build();
// Act
var ex = Assert.Throws<InvalidOperationException>(() => parameterCollection.SetParameterProperties(target));
// Assert
Assert.Equal(
$"The property '{nameof(HasCaptureUnmatchedValuesProperty.CaptureUnmatchedValues)}' on component type '{typeof(HasCaptureUnmatchedValuesProperty).FullName}' cannot be set explicitly when " +
$"also used to capture unmatched values. Unmatched values:" + Environment.NewLine +
$"test1" + Environment.NewLine +
$"test2",
ex.Message);
}
[Fact]
public void HasDuplicateCaptureUnmatchedValuesParameters_Throws()
{
// Arrange
var target = new HasDupliateCaptureUnmatchedValuesProperty();
var parameterCollection = new ParameterCollectionBuilder().Build();
// Act
var ex = Assert.Throws<InvalidOperationException>(() => parameterCollection.SetParameterProperties(target));
// Assert
Assert.Equal(
$"Multiple properties were found on component type '{typeof(HasDupliateCaptureUnmatchedValuesProperty).FullName}' " +
$"with '{nameof(ParameterAttribute)}.{nameof(ParameterAttribute.CaptureUnmatchedValues)}'. " +
$"Only a single property per type can use '{nameof(ParameterAttribute)}.{nameof(ParameterAttribute.CaptureUnmatchedValues)}'. " +
$"Properties:" + Environment.NewLine +
$"{nameof(HasDupliateCaptureUnmatchedValuesProperty.CaptureUnmatchedValuesProp1)}" + Environment.NewLine +
$"{nameof(HasDupliateCaptureUnmatchedValuesProperty.CaptureUnmatchedValuesProp2)}",
ex.Message);
}
[Fact]
public void HasCaptureUnmatchedValuesParameteterWithWrongType_Throws()
{
// Arrange
var target = new HasWrongTypeCaptureUnmatchedValuesProperty();
var parameterCollection = new ParameterCollectionBuilder().Build();
// Act
var ex = Assert.Throws<InvalidOperationException>(() => parameterCollection.SetParameterProperties(target));
// Assert
Assert.Equal(
$"The property '{nameof(HasWrongTypeCaptureUnmatchedValuesProperty.CaptureUnmatchedValuesProp)}' on component type '{typeof(HasWrongTypeCaptureUnmatchedValuesProperty).FullName}' cannot be used with " +
$"'{nameof(ParameterAttribute)}.{nameof(ParameterAttribute.CaptureUnmatchedValues)}' because it has the wrong type. " +
$"The property must be assignable from 'Dictionary<string, object>'.",
ex.Message);
}
[Fact]
public void IncomingParameterValueMismatchesDeclaredParameterType_Throws()
{
@ -273,6 +409,25 @@ namespace Microsoft.AspNetCore.Components.Test
[Parameter] new int IntProp { get; set; }
}
class HasCaptureUnmatchedValuesProperty
{
[Parameter] internal int IntProp { get; set; }
[Parameter] internal string StringProp { get; set; }
[Parameter] internal object ObjectProp { get; set; }
[Parameter(CaptureUnmatchedValues = true)] internal IReadOnlyDictionary<string, object> CaptureUnmatchedValues { get; set; }
}
class HasDupliateCaptureUnmatchedValuesProperty
{
[Parameter(CaptureUnmatchedValues = true)] internal Dictionary<string, object> CaptureUnmatchedValuesProp1 { get; set; }
[Parameter(CaptureUnmatchedValues = true)] internal IDictionary<string, object> CaptureUnmatchedValuesProp2 { get; set; }
}
class HasWrongTypeCaptureUnmatchedValuesProperty
{
[Parameter(CaptureUnmatchedValues = true)] internal KeyValuePair<string, object>[] CaptureUnmatchedValuesProp { get; set; }
}
class ParameterCollectionBuilder : IEnumerable
{
private readonly List<(string Name, object Value)> _keyValuePairs

View File

@ -1,13 +1,14 @@
// 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 Microsoft.AspNetCore.Components;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.RenderTree;
using Microsoft.AspNetCore.Components.Test.Helpers;
using System;
using System.Linq;
using System.Threading.Tasks;
using Moq;
using Xunit;
namespace Microsoft.AspNetCore.Components.Test
@ -245,6 +246,200 @@ namespace Microsoft.AspNetCore.Components.Test
frame => AssertFrame.Text(frame, "some text"));
}
[Fact]
public void CanAddMultipleAttributes_AllowsNull()
{
// Arrange
var builder = new RenderTreeBuilder(new TestRenderer());
// Act
builder.OpenElement(0, "myelement");
builder.AddMultipleAttributes<object>(0, null);
builder.CloseElement();
// Assert
var frames = builder.GetFrames().AsEnumerable().ToArray();
Assert.Collection(
frames,
frame => AssertFrame.Element(frame, "myelement", 1));
}
[Fact]
public void CanAddMultipleAttributes_InterspersedWithOtherAttributes()
{
// Arrange
var builder = new RenderTreeBuilder(new TestRenderer());
Action<UIEventArgs> eventHandler = eventInfo => { };
// Act
builder.OpenElement(0, "myelement");
builder.AddAttribute(0, "attribute1", "value 1");
builder.AddMultipleAttributes(0, new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase)
{
{ "attribute1", "test1" },
{ "attribute2", true },
{ "attribute3", eventHandler },
});
builder.AddAttribute(0, "ATTRIBUTE2", true);
builder.AddMultipleAttributes(0, new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase)
{
{ "attribute4", "test4" },
{ "attribute5", false },
{ "attribute6", eventHandler },
});
// Null or false values don't create frames of their own, but they can
// "knock out" earlier values.
builder.AddAttribute(0, "attribute6", false);
builder.AddAttribute(0, "attribute4", (string)null);
builder.AddAttribute(0, "attribute7", "the end");
builder.CloseElement();
// Assert
var frames = builder.GetFrames().AsEnumerable().ToArray();
Assert.Collection(
frames,
frame => AssertFrame.Element(frame, "myelement", 5),
frame => AssertFrame.Attribute(frame, "attribute1", "test1"),
frame => AssertFrame.Attribute(frame, "attribute3", eventHandler),
frame => AssertFrame.Attribute(frame, "ATTRIBUTE2", true),
frame => AssertFrame.Attribute(frame, "attribute7", "the end"));
}
[Fact]
public void CanAddMultipleAttributes_DictionaryString()
{
var attributes = new Dictionary<string, string>
{
{ "attribute1", "test1" },
{ "attribute2", "123" },
{ "attribute3", "456" },
};
// Act & Assert
CanAddMultipleAttributesTest(attributes);
}
[Fact]
public void CanAddMultipleAttributes_DictionaryObject()
{
var attributes = new Dictionary<string, object>
{
{ "attribute1", "test1" },
{ "attribute2", "123" },
{ "attribute3", true },
};
// Act & Assert
CanAddMultipleAttributesTest(attributes);
}
[Fact]
public void CanAddMultipleAttributes_IReadOnlyDictionaryString()
{
var attributes = new Dictionary<string, string>
{
{ "attribute1", "test1" },
{ "attribute2", "123" },
{ "attribute3", "456" },
};
// Act & Assert
CanAddMultipleAttributesTest((IReadOnlyDictionary<string, string>)attributes);
}
[Fact]
public void CanAddMultipleAttributes_IReadOnlyDictionaryObject()
{
var attributes = new Dictionary<string, object>
{
{ "attribute1", "test1" },
{ "attribute2", "123" },
{ "attribute3", true },
};
// Act & Assert
CanAddMultipleAttributesTest((IReadOnlyDictionary<string, object>)attributes);
}
[Fact]
public void CanAddMultipleAttributes_ListKvpString()
{
var attributes = new List<KeyValuePair<string, object>>()
{
new KeyValuePair<string, object>("attribute1", "test1"),
new KeyValuePair<string, object>("attribute2", "123"),
new KeyValuePair<string, object>("attribute3", "456"),
};
// Act & Assert
CanAddMultipleAttributesTest(attributes);
}
[Fact]
public void CanAddMultipleAttributes_ListKvpObject()
{
var attributes = new List<KeyValuePair<string, object>>()
{
new KeyValuePair<string, object>("attribute1", "test1"),
new KeyValuePair<string, object>("attribute2", "123"),
new KeyValuePair<string, object>("attribute3", true),
};
// Act & Assert
CanAddMultipleAttributesTest(attributes);
}
[Fact]
public void CanAddMultipleAttributes_ArrayKvpString()
{
var attributes = new KeyValuePair<string, string>[]
{
new KeyValuePair<string, string>("attribute1", "test1"),
new KeyValuePair<string, string>("attribute2", "123"),
new KeyValuePair<string, string>("attribute3", "456"),
};
// Act & Assert
CanAddMultipleAttributesTest(attributes);
}
[Fact]
public void CanAddMultipleAttributes_ArrayKvpObject()
{
var attributes = new KeyValuePair<string, object>[]
{
new KeyValuePair<string, object>("attribute1", "test1"),
new KeyValuePair<string, object>("attribute2", "123"),
new KeyValuePair<string, object>("attribute3", true),
};
// Act & Assert
CanAddMultipleAttributesTest(attributes);
}
private void CanAddMultipleAttributesTest<T>(IEnumerable<KeyValuePair<string, T>> attributes)
{
// Arrange
var builder = new RenderTreeBuilder(new TestRenderer());
// Act
builder.OpenElement(0, "myelement");
builder.AddMultipleAttributes(0, attributes);
builder.CloseElement();
// Assert
var frames = builder.GetFrames().AsEnumerable().ToArray();
var i = 1;
foreach (var attribute in attributes)
{
var frame = frames[i++];
AssertFrame.Attribute(frame, attribute.Key, attribute.Value);
}
}
[Fact]
public void CannotAddAttributeAtRoot()
{
@ -859,6 +1054,160 @@ namespace Microsoft.AspNetCore.Components.Test
frame => AssertFrame.Attribute(frame, "attr", value, 1));
}
[Fact]
public void AddAttribute_Element_EventCallback_AddsFrame()
{
// Arrange
var builder = new RenderTreeBuilder(new TestRenderer());
var callback = new EventCallback(null, new Action(() => { }));
// Act
builder.OpenElement(0, "elem");
builder.AddAttribute(1, "attr", callback);
builder.CloseElement();
// Assert
Assert.Collection(
builder.GetFrames().AsEnumerable(),
frame => AssertFrame.Element(frame, "elem", 2, 0),
frame => AssertFrame.Attribute(frame, "attr", callback.Delegate, 1));
}
[Fact]
public void AddAttribute_Element_EventCallback_Default_DoesNotAddFrame()
{
// Arrange
var builder = new RenderTreeBuilder(new TestRenderer());
var callback = default(EventCallback);
// Act
builder.OpenElement(0, "elem");
builder.AddAttribute(1, "attr", callback);
builder.CloseElement();
// Assert
Assert.Collection(
builder.GetFrames().AsEnumerable(),
frame => AssertFrame.Element(frame, "elem", 1, 0));
}
[Fact]
public void AddAttribute_Element_EventCallbackWithReceiver_AddsFrame()
{
// Arrange
var builder = new RenderTreeBuilder(new TestRenderer());
var receiver = Mock.Of<IHandleEvent>();
var callback = new EventCallback(receiver, new Action(() => { }));
// Act
builder.OpenElement(0, "elem");
builder.AddAttribute(1, "attr", callback);
builder.CloseElement();
// Assert
Assert.Collection(
builder.GetFrames().AsEnumerable(),
frame => AssertFrame.Element(frame, "elem", 2, 0),
frame => AssertFrame.Attribute(frame, "attr", callback, 1));
}
[Fact]
public void AddAttribute_Component_EventCallback_AddsFrame()
{
// Arrange
var builder = new RenderTreeBuilder(new TestRenderer());
var receiver = Mock.Of<IHandleEvent>();
var callback = new EventCallback(receiver, new Action(() => { }));
// Act
builder.OpenComponent<TestComponent>(0);
builder.AddAttribute(1, "attr", callback);
builder.CloseComponent();
// Assert
Assert.Collection(
builder.GetFrames().AsEnumerable(),
frame => AssertFrame.Component<TestComponent>(frame, 2, 0),
frame => AssertFrame.Attribute(frame, "attr", callback, 1));
}
[Fact]
public void AddAttribute_Element_EventCallbackOfT_AddsFrame()
{
// Arrange
var builder = new RenderTreeBuilder(new TestRenderer());
var callback = new EventCallback<string>(null, new Action<string>((s) => { }));
// Act
builder.OpenElement(0, "elem");
builder.AddAttribute(1, "attr", callback);
builder.CloseElement();
// Assert
Assert.Collection(
builder.GetFrames().AsEnumerable(),
frame => AssertFrame.Element(frame, "elem", 2, 0),
frame => AssertFrame.Attribute(frame, "attr", callback.Delegate, 1));
}
[Fact]
public void AddAttribute_Element_EventCallbackOfT_Default_DoesNotAddFrame()
{
// Arrange
var builder = new RenderTreeBuilder(new TestRenderer());
var callback = default(EventCallback<string>);
// Act
builder.OpenElement(0, "elem");
builder.AddAttribute(1, "attr", callback);
builder.CloseElement();
// Assert
Assert.Collection(
builder.GetFrames().AsEnumerable(),
frame => AssertFrame.Element(frame, "elem", 1, 0));
}
[Fact]
public void AddAttribute_Element_EventCallbackWithReceiverOfT_AddsFrame()
{
// Arrange
var builder = new RenderTreeBuilder(new TestRenderer());
var receiver = Mock.Of<IHandleEvent>();
var callback = new EventCallback<string>(receiver, new Action<string>((s) => { }));
// Act
builder.OpenElement(0, "elem");
builder.AddAttribute(1, "attr", callback);
builder.CloseElement();
// Assert
Assert.Collection(
builder.GetFrames().AsEnumerable(),
frame => AssertFrame.Element(frame, "elem", 2, 0),
frame => AssertFrame.Attribute(frame, "attr", new EventCallback(callback.Receiver, callback.Delegate), 1));
}
[Fact]
public void AddAttribute_Component_EventCallbackOfT_AddsFrame()
{
// Arrange
var builder = new RenderTreeBuilder(new TestRenderer());
var receiver = Mock.Of<IHandleEvent>();
var callback = new EventCallback<string>(receiver, new Action<string>((s) => { }));
// Act
builder.OpenComponent<TestComponent>(0);
builder.AddAttribute(1, "attr", callback);
builder.CloseComponent();
// Assert
Assert.Collection(
builder.GetFrames().AsEnumerable(),
frame => AssertFrame.Component<TestComponent>(frame, 2, 0),
frame => AssertFrame.Attribute(frame, "attr", callback, 1));
}
[Fact]
public void AddAttribute_Element_ObjectBoolTrue_AddsFrame()
{
@ -1030,6 +1379,140 @@ namespace Microsoft.AspNetCore.Components.Test
frame => AssertFrame.Attribute(frame, "attr", value, 1));
}
[Fact]
public void AddAttribute_Element_ObjectEventCallback_AddsFrame()
{
// Arrange
var builder = new RenderTreeBuilder(new TestRenderer());
var callback = new EventCallback(null, new Action(() => { }));
// Act
builder.OpenElement(0, "elem");
builder.AddAttribute(1, "attr", (object)callback);
builder.CloseElement();
// Assert
Assert.Collection(
builder.GetFrames().AsEnumerable(),
frame => AssertFrame.Element(frame, "elem", 2, 0),
frame => AssertFrame.Attribute(frame, "attr", callback.Delegate, 1));
}
[Fact]
public void AddAttribute_Element_ObjectEventCallback_Default_DoesNotAddFrame()
{
// Arrange
var builder = new RenderTreeBuilder(new TestRenderer());
var callback = default(EventCallback);
// Act
builder.OpenElement(0, "elem");
builder.AddAttribute(1, "attr", (object)callback);
builder.CloseElement();
// Assert
Assert.Collection(
builder.GetFrames().AsEnumerable(),
frame => AssertFrame.Element(frame, "elem", 1, 0));
}
[Fact]
public void AddAttribute_Element_ObjectEventCallbackWithReceiver_AddsFrame()
{
// Arrange
var builder = new RenderTreeBuilder(new TestRenderer());
var receiver = Mock.Of<IHandleEvent>();
var callback = new EventCallback(receiver, new Action(() => { }));
// Act
builder.OpenElement(0, "elem");
builder.AddAttribute(1, "attr", (object)callback);
builder.CloseElement();
// Assert
Assert.Collection(
builder.GetFrames().AsEnumerable(),
frame => AssertFrame.Element(frame, "elem", 2, 0),
frame => AssertFrame.Attribute(frame, "attr", callback, 1));
}
[Fact]
public void AddAttribute_Component_ObjectEventCallback_AddsFrame()
{
// Arrange
var builder = new RenderTreeBuilder(new TestRenderer());
var receiver = Mock.Of<IHandleEvent>();
var callback = new EventCallback(receiver, new Action(() => { }));
// Act
builder.OpenComponent<TestComponent>(0);
builder.AddAttribute(1, "attr", (object)callback);
builder.CloseComponent();
// Assert
Assert.Collection(
builder.GetFrames().AsEnumerable(),
frame => AssertFrame.Component<TestComponent>(frame, 2, 0),
frame => AssertFrame.Attribute(frame, "attr", callback, 1));
}
[Fact]
public void AddAttribute_Element_ObjectEventCallbackOfT_AddsFrame()
{
// Arrange
var builder = new RenderTreeBuilder(new TestRenderer());
var callback = new EventCallback<string>(null, new Action<string>((s) => { }));
// Act
builder.OpenElement(0, "elem");
builder.AddAttribute(1, "attr", (object)callback);
builder.CloseElement();
// Assert
Assert.Collection(
builder.GetFrames().AsEnumerable(),
frame => AssertFrame.Element(frame, "elem", 2, 0),
frame => AssertFrame.Attribute(frame, "attr", callback.Delegate, 1));
}
[Fact]
public void AddAttribute_Element_ObjectEventCallbackOfT_Default_DoesNotAddFrame()
{
// Arrange
var builder = new RenderTreeBuilder(new TestRenderer());
var callback = default(EventCallback<string>);
// Act
builder.OpenElement(0, "elem");
builder.AddAttribute(1, "attr", (object)callback);
builder.CloseElement();
// Assert
Assert.Collection(
builder.GetFrames().AsEnumerable(),
frame => AssertFrame.Element(frame, "elem", 1, 0));
}
[Fact]
public void AddAttribute_Element_ObjectEventCallbackWithReceiverOfT_AddsFrame()
{
// Arrange
var builder = new RenderTreeBuilder(new TestRenderer());
var receiver = Mock.Of<IHandleEvent>();
var callback = new EventCallback<string>(receiver, new Action<string>((s) => { }));
// Act
builder.OpenElement(0, "elem");
builder.AddAttribute(1, "attr", (object)callback);
builder.CloseElement();
// Assert
Assert.Collection(
builder.GetFrames().AsEnumerable(),
frame => AssertFrame.Element(frame, "elem", 2, 0),
frame => AssertFrame.Attribute(frame, "attr", new EventCallback(callback.Receiver, callback.Delegate), 1));
}
[Fact]
public void AddAttribute_Element_ObjectNull_IgnoresFrame()
{
@ -1148,6 +1631,215 @@ namespace Microsoft.AspNetCore.Components.Test
Assert.Equal("value", ex.ParamName);
}
[Fact]
public void ProcessDuplicateAttributes_DoesNotRemoveDuplicatesWithoutAddMultipleAttributes()
{
// Arrange
var builder = new RenderTreeBuilder(new TestRenderer());
builder.OpenElement(0, "div");
builder.AddAttribute(0, "id", "hi");
builder.AddAttribute(0, "id", "bye");
builder.CloseElement();
// Act
var frames = builder.GetFrames().AsEnumerable();
// Assert
Assert.Collection(
frames,
f => AssertFrame.Element(f, "div", 3, 0),
f => AssertFrame.Attribute(f, "id", "hi"),
f => AssertFrame.Attribute(f, "id", "bye"));
}
[Fact]
public void ProcessDuplicateAttributes_StopsAtFirstNonAttributeFrame_Capture()
{
// Arrange
var capture = (Action<ElementRef>)((_) => { });
var builder = new RenderTreeBuilder(new TestRenderer());
builder.OpenElement(0, "div");
builder.AddAttribute(0, "id", "hi");
builder.AddMultipleAttributes(0, new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase)
{
{ "id", "bye" },
});
builder.AddElementReferenceCapture(0, capture);
builder.CloseElement();
// Act
var frames = builder.GetFrames().AsEnumerable();
// Assert
Assert.Collection(
frames,
f => AssertFrame.Element(f, "div", 3, 0),
f => AssertFrame.Attribute(f, "id", "bye"),
f => AssertFrame.ElementReferenceCapture(f, capture));
}
[Fact]
public void ProcessDuplicateAttributes_StopsAtFirstNonAttributeFrame_Content()
{
// Arrange
var builder = new RenderTreeBuilder(new TestRenderer());
builder.OpenElement(0, "div");
builder.AddAttribute(0, "id", "hi");
builder.AddMultipleAttributes(0, new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase)
{
{ "id", "bye" },
});
builder.AddContent(0, "hey");
builder.CloseElement();
// Act
var frames = builder.GetFrames().AsEnumerable();
// Assert
Assert.Collection(
frames,
f => AssertFrame.Element(f, "div", 3, 0),
f => AssertFrame.Attribute(f, "id", "bye"),
f => AssertFrame.Text(f, "hey"));
}
[Fact]
public void ProcessDuplicateAttributes_CanRemoveDuplicateInsideElement()
{
// Arrange
var builder = new RenderTreeBuilder(new TestRenderer());
builder.OpenElement(0, "div");
builder.AddAttribute(0, "id", "hi");
builder.AddMultipleAttributes(0, new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase)
{
{ "id", "bye" },
});
builder.CloseElement();
// Act
var frames = builder.GetFrames().AsEnumerable();
// Assert
Assert.Collection(
frames,
f => AssertFrame.Element(f, "div", 2, 0),
f => AssertFrame.Attribute(f, "id", "bye"));
}
[Fact]
public void ProcessDuplicateAttributes_CanRemoveDuplicateInsideComponent()
{
// Arrange
var builder = new RenderTreeBuilder(new TestRenderer());
builder.OpenComponent<TestComponent>(0);
builder.AddAttribute(0, "id", "hi");
builder.AddMultipleAttributes(0, new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase)
{
{ "id", "bye" },
});
builder.CloseComponent();
// Act
var frames = builder.GetFrames().AsEnumerable();
// Assert
Assert.Collection(
frames,
f => AssertFrame.Component<TestComponent>(f, 2, 0),
f => AssertFrame.Attribute(f, "id", "bye"));
}
// This covers a special case we have to handle explicitly in the RTB logic.
[Fact]
public void ProcessDuplicateAttributes_SilentFrameFollowedBySameAttribute()
{
// Arrange
var builder = new RenderTreeBuilder(new TestRenderer());
builder.OpenComponent<TestComponent>(0);
builder.AddAttribute(0, "id", (string)null);
builder.AddMultipleAttributes(0, new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase)
{
{ "id", "bye" },
});
builder.CloseComponent();
// Act
var frames = builder.GetFrames().AsEnumerable();
// Assert
Assert.Collection(
frames,
f => AssertFrame.Component<TestComponent>(f, 2, 0),
f => AssertFrame.Attribute(f, "id", "bye"));
}
[Fact]
public void ProcessDuplicateAttributes_DoesNotRemoveDuplicatesInsideChildElement()
{
// Arrange
var builder = new RenderTreeBuilder(new TestRenderer());
builder.OpenElement(0, "div");
builder.AddAttribute(0, "id", "hi");
builder.AddMultipleAttributes(0, new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase)
{
{ "id", "bye" },
});
builder.OpenElement(0, "strong");
builder.AddAttribute(0, "id", "hi");
builder.AddAttribute(0, "id", "bye");
builder.CloseElement();
builder.CloseElement();
// Act
var frames = builder.GetFrames().AsEnumerable();
// Assert
Assert.Collection(
frames,
f => AssertFrame.Element(f, "div", 5, 0),
f => AssertFrame.Attribute(f, "id", "bye"),
f => AssertFrame.Element(f, "strong", 3),
f => AssertFrame.Attribute(f, "id", "hi"),
f => AssertFrame.Attribute(f, "id", "bye"));
}
[Fact]
public void ProcessDuplicateAttributes_CanRemoveOverwrittenAttributes()
{
// Arrange
var builder = new RenderTreeBuilder(new TestRenderer());
builder.OpenElement(0, "div");
builder.AddAttribute(0, "A", "hi");
builder.AddAttribute(0, "2", new EventCallback(null, (Action)(() => { })));
builder.AddMultipleAttributes(0, new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase)
{
{ "a", null }, // Replace with null value (case-insensitive)
{ "2", false }, // Replace with 'false'
{ "3", "hey there" }, // Add a new value
});
builder.AddAttribute(0, "3", "see ya"); // Overwrite value added by splat
builder.AddAttribute(0, "4", false); // Add a false value
builder.AddAttribute(0, "5", "another one");
builder.AddMultipleAttributes(0, new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase)
{
{ "5", null }, // overwrite value with null
{ "6", new EventCallback(null, (Action)(() =>{ })) },
});
builder.AddAttribute(0, "6", default(EventCallback<string>)); // Replace with a 'silent' EventCallback<string>
builder.CloseElement();
// Act
var frames = builder.GetFrames().AsEnumerable();
// Assert
Assert.Collection(
frames,
f => AssertFrame.Element(f, "div", 2, 0),
f => AssertFrame.Attribute(f, "3", "see ya"));
}
private class TestComponent : IComponent
{
public void Configure(RenderHandle renderHandle) { }

View File

@ -1900,6 +1900,76 @@ namespace Microsoft.AspNetCore.Components.Test
AssertFrame.Text(renderer.Batches[1].ReferenceFrames[0], "second");
}
[Fact]
public void ReRendersChildComponentWhenUnmatchedValuesChange()
{
// Arrange: First render
var renderer = new TestRenderer();
var firstRender = true;
var component = new TestComponent(builder =>
{
builder.OpenComponent<MyStrongComponent>(1);
builder.AddAttribute(1, "class", firstRender ? "first" : "second");
builder.AddAttribute(2, "id", "some_text");
builder.AddAttribute(3, nameof(MyStrongComponent.Text), "hi there.");
builder.CloseComponent();
});
var rootComponentId = renderer.AssignRootComponentId(component);
component.TriggerRender();
var childComponentId = renderer.Batches.Single()
.ReferenceFrames
.Single(frame => frame.FrameType == RenderTreeFrameType.Component)
.ComponentId;
// Act: Second render
firstRender = false;
component.TriggerRender();
var diff = renderer.Batches[1].DiffsByComponentId[childComponentId].Single();
// Assert
Assert.Collection(diff.Edits,
edit =>
{
Assert.Equal(RenderTreeEditType.SetAttribute, edit.Type);
Assert.Equal(0, edit.ReferenceFrameIndex);
});
AssertFrame.Attribute(renderer.Batches[1].ReferenceFrames[0], "class", "second");
}
// This is a sanity check that diffs of "unmatched" values *just work* without any specialized
// code in the renderer to handle it. All of the data that's used in the diff is contained in
// the render tree, and the diff process does not need to inspect the state of the component.
[Fact]
public void ReRendersDoesNotReRenderChildComponentWhenUnmatchedValuesDoNotChange()
{
// Arrange: First render
var renderer = new TestRenderer();
var component = new TestComponent(builder =>
{
builder.OpenComponent<MyStrongComponent>(1);
builder.AddAttribute(1, "class", "cool-beans");
builder.AddAttribute(2, "id", "some_text");
builder.AddAttribute(3, nameof(MyStrongComponent.Text), "hi there.");
builder.CloseComponent();
});
var rootComponentId = renderer.AssignRootComponentId(component);
component.TriggerRender();
var childComponentId = renderer.Batches.Single()
.ReferenceFrames
.Single(frame => frame.FrameType == RenderTreeFrameType.Component)
.ComponentId;
// Act: Second render
component.TriggerRender();
// Assert
Assert.False(renderer.Batches[1].DiffsByComponentId.ContainsKey(childComponentId));
}
[Fact]
public void RenderBatchIncludesListOfDisposedComponents()
{
@ -3270,6 +3340,21 @@ namespace Microsoft.AspNetCore.Components.Test
}
}
private class MyStrongComponent : AutoRenderComponent
{
[Parameter(CaptureUnmatchedValues = true)] internal IDictionary<string, object> Attributes { get; set; }
[Parameter] internal string Text { get; set; }
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenElement(0, "strong");
builder.AddMultipleAttributes(1, Attributes);
builder.AddContent(2, Text);
builder.CloseElement();
}
}
private class FakeComponent : IComponent
{
[Parameter]

View File

@ -125,6 +125,40 @@ namespace Microsoft.AspNetCore.Components.Rendering
Assert.Equal(expectedHtml, result);
}
[Fact]
public void RenderComponentAsync_SkipsDuplicatedAttribute()
{
// Arrange
var expectedHtml = new[]
{
"<", "p", " ",
"another", "=", "\"", "another-value", "\"", " ",
"Class", "=", "\"", "test2", "\"", ">",
"Hello world!",
"</", "p", ">"
};
var serviceProvider = new ServiceCollection().AddSingleton(new RenderFragment(rtb =>
{
rtb.OpenElement(0, "p");
rtb.AddAttribute(1, "class", "test1");
rtb.AddAttribute(2, "another", "another-value");
rtb.AddMultipleAttributes(3, new Dictionary<string, object>()
{
{ "Class", "test2" }, // Matching is case-insensitive.
});
rtb.AddContent(4, "Hello world!");
rtb.CloseElement();
})).BuildServiceProvider();
var htmlRenderer = GetHtmlRenderer(serviceProvider);
// Act
var result = GetResult(Dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<TestComponent>(ParameterCollection.Empty)));
// Assert
Assert.Equal(expectedHtml, result);
}
[Fact]
public void RenderComponentAsync_HtmlEncodesAttributeValues()
{

View File

@ -597,6 +597,26 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
Browser.Equal("Hello from interop call", () => appElement.FindElement(By.Id("val-set-by-interop")).GetAttribute("value"));
}
[Fact]
public void CanUseAddMultipleAttributes()
{
var appElement = MountTestComponent<DuplicateAttributesComponent>();
var selector = By.CssSelector("#duplicate-on-element > div");
WaitUntilExists(selector);
var element = appElement.FindElement(selector);
Assert.Equal(string.Empty, element.GetAttribute("bool")); // attribute is present
Assert.Equal("middle-value", element.GetAttribute("string"));
Assert.Equal("unmatched-value", element.GetAttribute("unmatched"));
selector = By.CssSelector("#duplicate-on-element-override > div");
element = appElement.FindElement(selector);
Assert.Null(element.GetAttribute("bool")); // attribute is not present
Assert.Equal("other-text", element.GetAttribute("string"));
Assert.Equal("unmatched-value", element.GetAttribute("unmatched"));
}
static IAlert SwitchToAlert(IWebDriver driver)
{
try

View File

@ -1,7 +1,6 @@
// 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.Threading.Tasks;
using BasicTestApp;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
@ -40,9 +39,9 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
{
var target = Browser.FindElement(By.CssSelector($"#{@case} button"));
var count = Browser.FindElement(By.Id("render_count"));
Assert.Equal("Render Count: 1", count.Text);
Browser.Equal("Render Count: 1", () => count.Text);
target.Click();
Assert.Equal("Render Count: 2", count.Text);
Browser.Equal("Render Count: 2", () => count.Text);
}
}
}

View File

@ -0,0 +1,28 @@
<div id="duplicate-on-element">
<DuplicateAttributesOnElementChildComponent
BoolAttributeBefore="true"
StringAttributeBefore="original-text"
UnmatchedValues="@elementValues"/>
</div>
<div id="duplicate-on-element-override">
<DuplicateAttributesOnElementChildComponent
BoolAttributeBefore="true"
StringAttributeBefore="original-text"
UnmatchedValues="@elementValues"
BoolAttributeAfter="false"
StringAttributeAfter="other-text" />
</div>
@functions {
void SomeMethod()
{
}
Dictionary<string, object> elementValues = new Dictionary<string, object>()
{
{ "bool", true },
{ "string", "middle-value" },
{ "unmatched", "unmatched-value" },
};
}

View File

@ -0,0 +1,37 @@
// 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.Collections.Generic;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.RenderTree;
namespace BasicTestApp
{
// Written in C# for flexibility and because we don't currently have the ability to write this in .razor.
public class DuplicateAttributesOnElementChildComponent : ComponentBase
{
[Parameter] public string StringAttributeBefore { get; private set; }
[Parameter] public bool BoolAttributeBefore { get; private set; }
[Parameter] public string StringAttributeAfter { get; private set; }
[Parameter] public bool? BoolAttributeAfter { get; private set; }
[Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object> UnmatchedValues { get; private set; }
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenElement(0, "div");
builder.AddAttribute(1, "string", StringAttributeBefore);
builder.AddAttribute(2, "bool", BoolAttributeBefore);
builder.AddMultipleAttributes(3, UnmatchedValues);
if (StringAttributeAfter != null)
{
builder.AddAttribute(4, "string", StringAttributeAfter);
}
if (BoolAttributeAfter != null)
{
builder.AddAttribute(5, "bool", BoolAttributeAfter);
}
builder.CloseElement();
}
}
}

View File

@ -56,6 +56,7 @@
<option value="BasicTestApp.RouterTest.UriHelperComponent">UriHelper Test</option>
<option value="BasicTestApp.AuthTest.CascadingAuthenticationStateParent">Cascading authentication state</option>
<option value="BasicTestApp.AuthTest.AuthRouter">Auth cases</option>
<option value="BasicTestApp.DuplicateAttributesComponent">Duplicate attributes</option>
</select>
@if (SelectedComponentType != null)