diff --git a/src/Components/Server/src/Circuits/CircuitHost.cs b/src/Components/Server/src/Circuits/CircuitHost.cs index 0d7c7336dd..bfb4b0cdae 100644 --- a/src/Components/Server/src/Circuits/CircuitHost.cs +++ b/src/Components/Server/src/Circuits/CircuitHost.cs @@ -111,8 +111,8 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits var count = Descriptors.Count; for (var i = 0; i < count; i++) { - var (componentType, sequence) = Descriptors[i]; - await Renderer.AddComponentAsync(componentType, sequence.ToString()); + var (componentType, parameters, sequence) = Descriptors[i]; + await Renderer.AddComponentAsync(componentType, parameters, sequence.ToString()); } Log.InitializationSucceeded(_logger); diff --git a/src/Components/Server/src/Circuits/ComponentDescriptor.cs b/src/Components/Server/src/Circuits/ComponentDescriptor.cs index 0c3dcf6cf2..13a64f83ef 100644 --- a/src/Components/Server/src/Circuits/ComponentDescriptor.cs +++ b/src/Components/Server/src/Circuits/ComponentDescriptor.cs @@ -9,12 +9,11 @@ namespace Microsoft.AspNetCore.Components.Server { public Type ComponentType { get; set; } + public ParameterView Parameters { get; set; } + public int Sequence { get; set; } - public void Deconstruct(out Type componentType, out int sequence) - { - componentType = ComponentType; - sequence = Sequence; - } + public void Deconstruct(out Type componentType, out ParameterView parameters, out int sequence) => + (componentType, sequence, parameters) = (ComponentType, Sequence, Parameters); } } diff --git a/src/Components/Server/src/Circuits/ComponentParameterDeserializer.cs b/src/Components/Server/src/Circuits/ComponentParameterDeserializer.cs new file mode 100644 index 0000000000..58452aea19 --- /dev/null +++ b/src/Components/Server/src/Circuits/ComponentParameterDeserializer.cs @@ -0,0 +1,188 @@ +// 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.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Components.Server +{ + internal class ComponentParameterDeserializer + { + private readonly ILogger _logger; + private readonly ComponentParametersTypeCache _parametersCache; + + public ComponentParameterDeserializer( + ILogger logger, + ComponentParametersTypeCache parametersCache) + { + _logger = logger; + _parametersCache = parametersCache; + } + + public bool TryDeserializeParameters(IList parametersDefinitions, IList parameterValues, out ParameterView parameters) + { + parameters = default; + var parametersDictionary = new Dictionary(); + + if (parameterValues.Count != parametersDefinitions.Count) + { + // Mismatched number of definition/parameter values. + Log.MismatchedParameterAndDefinitions(_logger, parametersDefinitions.Count, parameterValues.Count); + return false; + } + + for (var i = 0; i < parametersDefinitions.Count; i++) + { + var definition = parametersDefinitions[i]; + if (definition.Name == null) + { + Log.MissingParameterDefinitionName(_logger); + return false; + } + + if (definition.TypeName == null && definition.Assembly == null) + { + parametersDictionary.Add(definition.Name, null); + } + else if (definition.TypeName == null || definition.Assembly == null) + { + Log.IncompleteParameterDefinition(_logger, definition.Name, definition.TypeName, definition.Assembly); + return false; + } + else + { + var parameterType = _parametersCache.GetParameterType(definition.Assembly, definition.TypeName); + if (parameterType == null) + { + Log.InvalidParameterType(_logger, definition.Name, definition.Assembly, definition.TypeName); + return false; + } + try + { + // At this point we know the parameter is not null, as we don't serialize the type name or the assembly name + // for null parameters. + var value = (JsonElement)parameterValues[i]; + var parameterValue = JsonSerializer.Deserialize( + value.GetRawText(), + parameterType, + ServerComponentSerializationSettings.JsonSerializationOptions); + + parametersDictionary.Add(definition.Name, parameterValue); + } + catch (Exception e) + { + Log.InvalidParameterValue(_logger, definition.Name, definition.TypeName, definition.Assembly, e); + return false; + } + } + } + + parameters = ParameterView.FromDictionary(parametersDictionary); + return true; + } + + private ComponentParameter[] GetParameterDefinitions(string parametersDefinitions) + { + try + { + return JsonSerializer.Deserialize(parametersDefinitions, ServerComponentSerializationSettings.JsonSerializationOptions); + } + catch (Exception e) + { + Log.FailedToParseParameterDefinitions(_logger, e); + return null; + } + } + + private JsonDocument GetParameterValues(string parameterValues) + { + try + { + return JsonDocument.Parse(parameterValues); + } + catch (Exception e) + { + Log.FailedToParseParameterValues(_logger, e); + return null; + } + } + + private static class Log + { + private static readonly Action _parameterValuesInvalidFormat = + LoggerMessage.Define( + LogLevel.Debug, + new EventId(1, "ParameterValuesInvalidFormat"), + "Parameter values must be an array."); + + private static readonly Action _incompleteParameterDefinition = + LoggerMessage.Define( + LogLevel.Debug, + new EventId(2, "IncompleteParameterDefinition"), + "The parameter definition for '{ParameterName}' is incomplete: Type='{TypeName}' Assembly='{Assembly}'."); + + private static readonly Action _invalidParameterType = + LoggerMessage.Define( + LogLevel.Debug, + new EventId(3, "InvalidParameterType"), + "The parameter '{ParameterName} with type '{TypeName}' in assembly '{Assembly}' could not be found."); + + private static readonly Action _invalidParameterValue = + LoggerMessage.Define( + LogLevel.Debug, + new EventId(4, "InvalidParameterValue"), + "Could not parse the parameter value for parameter '{Name}' of type '{TypeName}' and assembly '{Assembly}'."); + + private static readonly Action _failedToParseParameterDefinitions = + LoggerMessage.Define( + LogLevel.Debug, + new EventId(5, "FailedToParseParameterDefinitions"), + "Failed to parse the parameter definitions."); + + private static readonly Action _failedToParseParameterValues = + LoggerMessage.Define( + LogLevel.Debug, + new EventId(6, "FailedToParseParameterValues"), + "Failed to parse the parameter values."); + + private static readonly Action _mismatchedParameterAndDefinitions = + LoggerMessage.Define( + LogLevel.Debug, + new EventId(7, "MismatchedParameterAndDefinitions"), + "The number of parameter definitions '{DescriptorsLength}' does not match the number parameter values '{ValuesLength}'."); + + private static readonly Action _missingParameterDefinitionName = + LoggerMessage.Define( + LogLevel.Debug, + new EventId(8, "MissingParameterDefinitionName"), + "The name is missing in a parameter definition."); + + internal static void ParameterValuesInvalidFormat(ILogger logger) => + _parameterValuesInvalidFormat(logger, null); + + internal static void IncompleteParameterDefinition(ILogger logger, string name, string typeName, string assembly) => + _incompleteParameterDefinition(logger, name, typeName, assembly, null); + + internal static void InvalidParameterType(ILogger logger, string name, string assembly, string typeName) => + _invalidParameterType(logger, name, assembly, typeName, null); + + internal static void InvalidParameterValue(ILogger logger, string name, string typeName, string assembly, Exception e) => + _invalidParameterValue(logger, name, typeName, assembly,e); + + internal static void FailedToParseParameterDefinitions(ILogger logger, Exception e) => + _failedToParseParameterDefinitions(logger, e); + + internal static void FailedToParseParameterValues(ILogger logger, Exception e) => + _failedToParseParameterValues(logger, e); + + internal static void MismatchedParameterAndDefinitions(ILogger logger, int definitionsLength, int valuesLength) => + _mismatchedParameterAndDefinitions(logger, definitionsLength, valuesLength, null); + + internal static void MissingParameterDefinitionName(ILogger logger) => + _missingParameterDefinitionName(logger, null); + } + } +} diff --git a/src/Components/Server/src/Circuits/ComponentParametersTypeCache.cs b/src/Components/Server/src/Circuits/ComponentParametersTypeCache.cs new file mode 100644 index 0000000000..063d96661e --- /dev/null +++ b/src/Components/Server/src/Circuits/ComponentParametersTypeCache.cs @@ -0,0 +1,58 @@ +// 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.Concurrent; +using System.Linq; +using System.Reflection; + +namespace Microsoft.AspNetCore.Components +{ + internal class ComponentParametersTypeCache + { + private readonly ConcurrentDictionary _typeToKeyLookUp = new ConcurrentDictionary(); + + public Type GetParameterType(string assembly, string type) + { + var key = new Key(assembly, type); + if (_typeToKeyLookUp.TryGetValue(key, out var resolvedType)) + { + return resolvedType; + } + else + { + return _typeToKeyLookUp.GetOrAdd(key, ResolveType, AppDomain.CurrentDomain.GetAssemblies()); + } + } + + private static Type ResolveType(Key key, Assembly[] assemblies) + { + var assembly = assemblies + .FirstOrDefault(a => string.Equals(a.GetName().Name, key.Assembly, StringComparison.Ordinal)); + + if (assembly == null) + { + return null; + } + + return assembly.GetType(key.Type, throwOnError: false, ignoreCase: false); + } + + private struct Key : IEquatable + { + public Key(string assembly, string type) => + (Assembly, Type) = (assembly, type); + + public string Assembly { get; set; } + + public string Type { get; set; } + + public override bool Equals(object obj) => Equals((Key)obj); + + public bool Equals(Key other) => string.Equals(Assembly, other.Assembly, StringComparison.Ordinal) && + string.Equals(Type, other.Type, StringComparison.Ordinal); + + public override int GetHashCode() => HashCode.Combine(Assembly, Type); + } + } +} diff --git a/src/Components/Server/src/Circuits/RemoteRenderer.cs b/src/Components/Server/src/Circuits/RemoteRenderer.cs index 3596ffbcc6..509944e17a 100644 --- a/src/Components/Server/src/Circuits/RemoteRenderer.cs +++ b/src/Components/Server/src/Circuits/RemoteRenderer.cs @@ -64,6 +64,24 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits return RenderRootComponentAsync(componentId); } + /// + /// Associates the with the , + /// causing it to be displayed in the specified DOM element. + /// + /// The type of the component. + /// The parameters for the component. + /// A CSS selector that uniquely identifies a DOM element. + public Task AddComponentAsync(Type componentType, ParameterView parameters, string domElementSelector) + { + var component = InstantiateComponent(componentType); + var componentId = AssignRootComponentId(component); + + var attachComponentTask = _client.SendAsync("JS.AttachComponent", componentId, domElementSelector); + CaptureAsyncExceptions(attachComponentTask); + + return RenderRootComponentAsync(componentId, parameters); + } + protected override void ProcessPendingRender() { if (_unacknowledgedRenderBatches.Count >= _options.MaxBufferedUnacknowledgedRenderBatches) diff --git a/src/Components/Server/src/Circuits/ServerComponentDeserializer.cs b/src/Components/Server/src/Circuits/ServerComponentDeserializer.cs index 0fe81f4ead..039ae1ad8f 100644 --- a/src/Components/Server/src/Circuits/ServerComponentDeserializer.cs +++ b/src/Components/Server/src/Circuits/ServerComponentDeserializer.cs @@ -25,8 +25,12 @@ namespace Microsoft.AspNetCore.Components.Server // 'sequence' indicates the order in which this component got rendered on the server. // 'assemblyName' the assembly name for the rendered component. // 'type' the full type name for the rendered component. + // 'parameterDefinitions' a JSON serialized array that contains the definitions for the parameters including their names and types and assemblies. + // 'parameterValues' a JSON serialized array containing the parameter values. // 'invocationId' a random string that matches all components rendered by as part of a single HTTP response. // For example: base64(dataprotection({ "sequence": 1, "assemblyName": "Microsoft.AspNetCore.Components", "type":"Microsoft.AspNetCore.Components.Routing.Router", "invocationId": "<>"})) + // With parameters + // For example: base64(dataprotection({ "sequence": 1, "assemblyName": "Microsoft.AspNetCore.Components", "type":"Microsoft.AspNetCore.Components.Routing.Router", "invocationId": "<>", parameterDefinitions: "[{ \"name\":\"Parameter\", \"typeName\":\"string\", \"assembly\":\"System.Private.CoreLib\"}], parameterValues: [<>]})) // Serialization: // For a given response, MVC renders one or more markers in sequence, including a descriptor for each rendered @@ -55,11 +59,13 @@ namespace Microsoft.AspNetCore.Components.Server private readonly IDataProtector _dataProtector; private readonly ILogger _logger; private readonly ServerComponentTypeCache _rootComponentTypeCache; + private readonly ComponentParameterDeserializer _parametersDeserializer; public ServerComponentDeserializer( IDataProtectionProvider dataProtectionProvider, ILogger logger, - ServerComponentTypeCache rootComponentTypeCache) + ServerComponentTypeCache rootComponentTypeCache, + ComponentParameterDeserializer parametersDeserializer) { // When we protect the data we use a time-limited data protector with the // limits established in 'ServerComponentSerializationSettings.DataExpiration' @@ -74,6 +80,7 @@ namespace Microsoft.AspNetCore.Components.Server _logger = logger; _rootComponentTypeCache = rootComponentTypeCache; + _parametersDeserializer = parametersDeserializer; } public bool TryDeserializeComponentDescriptorCollection(string serializedComponentRecords, out List descriptors) @@ -176,9 +183,16 @@ namespace Microsoft.AspNetCore.Components.Server return default; } + if (!_parametersDeserializer.TryDeserializeParameters(serverComponent.ParameterDefinitions, serverComponent.ParameterValues, out var parameters)) + { + // TryDeserializeParameters does appropriate logging. + return default; + } + var componentDescriptor = new ComponentDescriptor { ComponentType = componentType, + Parameters = parameters, Sequence = serverComponent.Sequence }; diff --git a/src/Components/Server/src/DependencyInjection/ComponentServiceCollectionExtensions.cs b/src/Components/Server/src/DependencyInjection/ComponentServiceCollectionExtensions.cs index 5a079c5401..e906b7dc26 100644 --- a/src/Components/Server/src/DependencyInjection/ComponentServiceCollectionExtensions.cs +++ b/src/Components/Server/src/DependencyInjection/ComponentServiceCollectionExtensions.cs @@ -58,6 +58,8 @@ namespace Microsoft.Extensions.DependencyInjection services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddScoped(s => s.GetRequiredService().Circuit); diff --git a/src/Components/Server/src/Microsoft.AspNetCore.Components.Server.csproj b/src/Components/Server/src/Microsoft.AspNetCore.Components.Server.csproj index 0631873913..2e66dffc7e 100644 --- a/src/Components/Server/src/Microsoft.AspNetCore.Components.Server.csproj +++ b/src/Components/Server/src/Microsoft.AspNetCore.Components.Server.csproj @@ -70,6 +70,7 @@ + diff --git a/src/Components/Server/test/Circuits/ServerComponentDeserializerTest.cs b/src/Components/Server/test/Circuits/ServerComponentDeserializerTest.cs index 1b3a8601da..907fa96192 100644 --- a/src/Components/Server/test/Circuits/ServerComponentDeserializerTest.cs +++ b/src/Components/Server/test/Circuits/ServerComponentDeserializerTest.cs @@ -2,6 +2,7 @@ // 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 System.Text.Json; using System.Threading.Tasks; @@ -40,6 +41,45 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits Assert.Equal(0, deserializedDescriptor.Sequence); } + [Fact] + public void CanParseSingleMarkerWithParameters() + { + // Arrange + var markers = SerializeMarkers(CreateMarkers( + (typeof(TestComponent), new Dictionary { ["Parameter"] = "Value" }))); + var serverComponentDeserializer = CreateServerComponentDeserializer(); + + // Act & assert + Assert.True(serverComponentDeserializer.TryDeserializeComponentDescriptorCollection(markers, out var descriptors)); + var deserializedDescriptor = Assert.Single(descriptors); + Assert.Equal(typeof(TestComponent).FullName, deserializedDescriptor.ComponentType.FullName); + Assert.Equal(0, deserializedDescriptor.Sequence); + var parameters = deserializedDescriptor.Parameters.ToDictionary(); + Assert.Single(parameters); + Assert.Contains("Parameter", parameters.Keys); + Assert.Equal("Value", parameters["Parameter"]); + } + + [Fact] + public void CanParseSingleMarkerWithNullParameters() + { + // Arrange + var markers = SerializeMarkers(CreateMarkers( + (typeof(TestComponent), new Dictionary { ["Parameter"] = null }))); + var serverComponentDeserializer = CreateServerComponentDeserializer(); + + // Act & assert + Assert.True(serverComponentDeserializer.TryDeserializeComponentDescriptorCollection(markers, out var descriptors)); + var deserializedDescriptor = Assert.Single(descriptors); + Assert.Equal(typeof(TestComponent).FullName, deserializedDescriptor.ComponentType.FullName); + Assert.Equal(0, deserializedDescriptor.Sequence); + + var parameters = deserializedDescriptor.Parameters.ToDictionary(); + Assert.Single(parameters); + Assert.Contains("Parameter", parameters.Keys); + Assert.Null(parameters["Parameter"]); + } + [Fact] public void CanParseMultipleMarkers() { @@ -60,6 +100,65 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits Assert.Equal(1, secondDescriptor.Sequence); } + [Fact] + public void CanParseMultipleMarkersWithParameters() + { + // Arrange + var markers = SerializeMarkers(CreateMarkers( + (typeof(TestComponent), new Dictionary { ["First"] = "Value" }), + (typeof(TestComponent), new Dictionary { ["Second"] = null }))); + var serverComponentDeserializer = CreateServerComponentDeserializer(); + + // Act & assert + Assert.True(serverComponentDeserializer.TryDeserializeComponentDescriptorCollection(markers, out var descriptors)); + Assert.Equal(2, descriptors.Count); + + var firstDescriptor = descriptors[0]; + Assert.Equal(typeof(TestComponent).FullName, firstDescriptor.ComponentType.FullName); + Assert.Equal(0, firstDescriptor.Sequence); + var firstParameters = firstDescriptor.Parameters.ToDictionary(); + Assert.Single(firstParameters); + Assert.Contains("First", firstParameters.Keys); + Assert.Equal("Value", firstParameters["First"]); + + + var secondDescriptor = descriptors[1]; + Assert.Equal(typeof(TestComponent).FullName, secondDescriptor.ComponentType.FullName); + Assert.Equal(1, secondDescriptor.Sequence); + var secondParameters = secondDescriptor.Parameters.ToDictionary(); + Assert.Single(secondParameters); + Assert.Contains("Second", secondParameters.Keys); + Assert.Null(secondParameters["Second"]); + } + + [Fact] + public void CanParseMultipleMarkersWithAndWithoutParameters() + { + // Arrange + var markers = SerializeMarkers(CreateMarkers( + (typeof(TestComponent), new Dictionary { ["First"] = "Value" }), + (typeof(TestComponent), null))); + var serverComponentDeserializer = CreateServerComponentDeserializer(); + + // Act & assert + Assert.True(serverComponentDeserializer.TryDeserializeComponentDescriptorCollection(markers, out var descriptors)); + Assert.Equal(2, descriptors.Count); + + var firstDescriptor = descriptors[0]; + Assert.Equal(typeof(TestComponent).FullName, firstDescriptor.ComponentType.FullName); + Assert.Equal(0, firstDescriptor.Sequence); + var firstParameters = firstDescriptor.Parameters.ToDictionary(); + Assert.Single(firstParameters); + Assert.Contains("First", firstParameters.Keys); + Assert.Equal("Value", firstParameters["First"]); + + + var secondDescriptor = descriptors[1]; + Assert.Equal(typeof(TestComponent).FullName, secondDescriptor.ComponentType.FullName); + Assert.Equal(1, secondDescriptor.Sequence); + Assert.Empty(secondDescriptor.Parameters.ToDictionary()); + } + [Fact] public void DoesNotParseOutOfOrderMarkers() { @@ -213,7 +312,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits private string SerializeComponent(string assembly, string type) => JsonSerializer.Serialize( - new ServerComponent(0, assembly, type, Guid.NewGuid()), + new ServerComponent(0, assembly, type, Array.Empty(), Array.Empty(), Guid.NewGuid()), ServerComponentSerializationSettings.JsonSerializationOptions); private ServerComponentDeserializer CreateServerComponentDeserializer() @@ -221,7 +320,8 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits return new ServerComponentDeserializer( _ephemeralDataProtectionProvider, NullLogger.Instance, - new ServerComponentTypeCache()); + new ServerComponentTypeCache(), + new ComponentParameterDeserializer(NullLogger.Instance, new ComponentParametersTypeCache())); } private string SerializeMarkers(ServerComponentMarker[] markers) => @@ -233,7 +333,24 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits var markers = new ServerComponentMarker[types.Length]; for (var i = 0; i < types.Length; i++) { - markers[i] = serializer.SerializeInvocation(_invocationSequence, types[i], false); + markers[i] = serializer.SerializeInvocation(_invocationSequence, types[i], ParameterView.Empty, false); + } + + return markers; + } + + private ServerComponentMarker[] CreateMarkers(params (Type, Dictionary)[] types) + { + var serializer = new ServerComponentSerializer(_ephemeralDataProtectionProvider); + var markers = new ServerComponentMarker[types.Length]; + for (var i = 0; i < types.Length; i++) + { + var (type, parameters) = types[i]; + markers[i] = serializer.SerializeInvocation( + _invocationSequence, + type, + parameters == null ? ParameterView.Empty : ParameterView.FromDictionary(parameters), + false); } return markers; @@ -245,7 +362,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits var markers = new ServerComponentMarker[types.Length]; for (var i = 0; i < types.Length; i++) { - markers[i] = serializer.SerializeInvocation(sequence, types[i], false); + markers[i] = serializer.SerializeInvocation(sequence, types[i], ParameterView.Empty, false); } return markers; diff --git a/src/Components/test/E2ETest/Microsoft.AspNetCore.Components.E2ETests.csproj b/src/Components/test/E2ETest/Microsoft.AspNetCore.Components.E2ETests.csproj index 324d00d12f..5ddf7c9790 100644 --- a/src/Components/test/E2ETest/Microsoft.AspNetCore.Components.E2ETests.csproj +++ b/src/Components/test/E2ETest/Microsoft.AspNetCore.Components.E2ETests.csproj @@ -52,6 +52,7 @@ + diff --git a/src/Components/test/E2ETest/ServerExecutionTests/MultipleRootComponentsTest.cs b/src/Components/test/E2ETest/ServerExecutionTests/MultipleRootComponentsTest.cs index 55dc9dc076..b3523741a6 100644 --- a/src/Components/test/E2ETest/ServerExecutionTests/MultipleRootComponentsTest.cs +++ b/src/Components/test/E2ETest/ServerExecutionTests/MultipleRootComponentsTest.cs @@ -68,9 +68,10 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests var greets = Browser.FindElements(By.CssSelector(".greet-wrapper .greet")).Select(e => e.Text).ToArray(); - Assert.Equal(4, greets.Length); // 1 statically rendered + 3 prerendered + Assert.Equal(5, greets.Length); // 1 statically rendered + 3 prerendered + 1 server prerendered Assert.Single(greets, "Hello John"); - Assert.Equal(3, greets.Where(g => string.Equals("Hello", g)).Count()); // 3 prerendered + Assert.Single(greets, "Hello Abraham"); + Assert.Equal(3, greets.Where(g => string.Equals("Hello", g)).Count()); // 3 server prerendered without parameters var content = Browser.FindElement(By.Id("test-container")).GetAttribute("innerHTML"); var markers = ReadMarkers(content); var componentSequence = markers.Select(m => m.Item1.PrerenderId != null).ToArray(); @@ -84,6 +85,8 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests true, false, true, + false, + true }; Assert.Equal(expectedComponentSequence, componentSequence); @@ -93,6 +96,8 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests Browser.Exists(By.CssSelector("h3.interactive")); var updatedGreets = Browser.FindElements(By.CssSelector(".greet-wrapper .greet")).Select(e => e.Text).ToArray(); Assert.Equal(7, updatedGreets.Where(g => string.Equals("Hello Alfred", g)).Count()); + Assert.Single(updatedGreets.Where(g => string.Equals("Hello Albert", g))); + Assert.Single(updatedGreets.Where(g => string.Equals("Hello Abraham", g))); } private (ServerComponentMarker, ServerComponentMarker)[] ReadMarkers(string content) diff --git a/src/Components/test/testassets/BasicTestApp/MultipleComponents/GreeterComponent.razor b/src/Components/test/testassets/BasicTestApp/MultipleComponents/GreeterComponent.razor index cd64d86f66..80d9391a65 100644 --- a/src/Components/test/testassets/BasicTestApp/MultipleComponents/GreeterComponent.razor +++ b/src/Components/test/testassets/BasicTestApp/MultipleComponents/GreeterComponent.razor @@ -9,7 +9,7 @@ protected override void OnAfterRender(bool firstRender) { - if (firstRender) + if (firstRender && Name == null) { Name = "Alfred"; interactive = "interactive"; diff --git a/src/Components/test/testassets/TestServer/Pages/MultipleComponents.cshtml b/src/Components/test/testassets/TestServer/Pages/MultipleComponents.cshtml index 28bab23e1c..61ce8d6efb 100644 --- a/src/Components/test/testassets/TestServer/Pages/MultipleComponents.cshtml +++ b/src/Components/test/testassets/TestServer/Pages/MultipleComponents.cshtml @@ -27,6 +27,10 @@

Some content after

+
+ @(await Html.RenderComponentAsync(RenderMode.Server, new { Name = "Albert" })) + @(await Html.RenderComponentAsync(RenderMode.ServerPrerendered, new { Name = "Abraham" })) +
@* diff --git a/src/Mvc/Mvc.ViewFeatures/src/HtmlHelperComponentExtensions.cs b/src/Mvc/Mvc.ViewFeatures/src/HtmlHelperComponentExtensions.cs index 0d8a336b8d..303c820cc9 100644 --- a/src/Mvc/Mvc.ViewFeatures/src/HtmlHelperComponentExtensions.cs +++ b/src/Mvc/Mvc.ViewFeatures/src/HtmlHelperComponentExtensions.cs @@ -94,11 +94,6 @@ namespace Microsoft.AspNetCore.Mvc.Rendering private static async Task PrerenderedServerComponentAsync(HttpContext context, ServerComponentInvocationSequence invocationId, Type type, ParameterView parametersCollection) { - if (parametersCollection.GetEnumerator().MoveNext()) - { - throw new InvalidOperationException("Prerendering server components with parameters is not supported."); - } - var serviceProvider = context.RequestServices; var prerenderer = serviceProvider.GetRequiredService(); var invocationSerializer = serviceProvider.GetRequiredService(); @@ -106,6 +101,7 @@ namespace Microsoft.AspNetCore.Mvc.Rendering var currentInvocation = invocationSerializer.SerializeInvocation( invocationId, type, + parametersCollection, prerendered: true); var result = await prerenderer.PrerenderComponentAsync( @@ -121,14 +117,9 @@ namespace Microsoft.AspNetCore.Mvc.Rendering private static IHtmlContent NonPrerenderedServerComponent(HttpContext context, ServerComponentInvocationSequence invocationId, Type type, ParameterView parametersCollection) { - if (parametersCollection.GetEnumerator().MoveNext()) - { - throw new InvalidOperationException("Server components with parameters are not supported."); - } - var serviceProvider = context.RequestServices; var invocationSerializer = serviceProvider.GetRequiredService(); - var currentInvocation = invocationSerializer.SerializeInvocation(invocationId, type, prerendered: false); + var currentInvocation = invocationSerializer.SerializeInvocation(invocationId, type, parametersCollection, prerendered: false); return new ComponentHtmlContent(invocationSerializer.GetPreamble(currentInvocation)); } diff --git a/src/Mvc/Mvc.ViewFeatures/src/Microsoft.AspNetCore.Mvc.ViewFeatures.csproj b/src/Mvc/Mvc.ViewFeatures/src/Microsoft.AspNetCore.Mvc.ViewFeatures.csproj index 5366a0d906..8d9b65b94b 100644 --- a/src/Mvc/Mvc.ViewFeatures/src/Microsoft.AspNetCore.Mvc.ViewFeatures.csproj +++ b/src/Mvc/Mvc.ViewFeatures/src/Microsoft.AspNetCore.Mvc.ViewFeatures.csproj @@ -34,6 +34,7 @@ + diff --git a/src/Mvc/Mvc.ViewFeatures/src/ServerComponentSerializer.cs b/src/Mvc/Mvc.ViewFeatures/src/ServerComponentSerializer.cs index 6e116c6bb1..d5c3a3c270 100644 --- a/src/Mvc/Mvc.ViewFeatures/src/ServerComponentSerializer.cs +++ b/src/Mvc/Mvc.ViewFeatures/src/ServerComponentSerializer.cs @@ -19,22 +19,27 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures .CreateProtector(ServerComponentSerializationSettings.DataProtectionProviderPurpose) .ToTimeLimitedDataProtector(); - public ServerComponentMarker SerializeInvocation(ServerComponentInvocationSequence invocationId, Type type, bool prerendered) + public ServerComponentMarker SerializeInvocation(ServerComponentInvocationSequence invocationId, Type type, ParameterView parameters, bool prerendered) { - var (sequence, serverComponent) = CreateSerializedServerComponent(invocationId, type); + var (sequence, serverComponent) = CreateSerializedServerComponent(invocationId, type, parameters); return prerendered ? ServerComponentMarker.Prerendered(sequence, serverComponent) : ServerComponentMarker.NonPrerendered(sequence, serverComponent); } private (int sequence, string payload) CreateSerializedServerComponent( ServerComponentInvocationSequence invocationId, - Type rootComponent) + Type rootComponent, + ParameterView parameters) { var sequence = invocationId.Next(); + var (definitions, values) = ComponentParameter.FromParameterView(parameters); + var serverComponent = new ServerComponent( sequence, rootComponent.Assembly.GetName().Name, rootComponent.FullName, + definitions, + values, invocationId.Value); var serializedServerComponent = JsonSerializer.Serialize(serverComponent, ServerComponentSerializationSettings.JsonSerializationOptions); diff --git a/src/Mvc/Mvc.ViewFeatures/test/HtmlHelperComponentExtensionsTests.cs b/src/Mvc/Mvc.ViewFeatures/test/HtmlHelperComponentExtensionsTests.cs index 413f0c2fb1..a7629497a7 100644 --- a/src/Mvc/Mvc.ViewFeatures/test/HtmlHelperComponentExtensionsTests.cs +++ b/src/Mvc/Mvc.ViewFeatures/test/HtmlHelperComponentExtensionsTests.cs @@ -188,25 +188,208 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Test Assert.Equal("

Hello Steve!

", content); } - [Theory] - [InlineData(RenderMode.Server, "Server components with parameters are not supported.")] - [InlineData(RenderMode.ServerPrerendered, "Prerendering server components with parameters is not supported.")] - public async Task ComponentWithParametersObject_ThrowsInvalidOperationExceptionForServerRenderModes( - RenderMode renderMode, - string expectedMessage) + [Fact] + public async Task CanRender_ComponentWithParameters_ServerMode() { // Arrange var helper = CreateHelper(); var writer = new StringWriter(); + var protector = _dataprotectorProvider.CreateProtector(ServerComponentSerializationSettings.DataProtectionProviderPurpose) + .ToTimeLimitedDataProtector(); - // Act & Assert - var result = await Assert.ThrowsAsync(() => helper.RenderComponentAsync( - renderMode, + // Act + var result = await helper.RenderComponentAsync( + RenderMode.Server, new { - Name = "Steve" - })); - Assert.Equal(expectedMessage, result.Message); + Name = "Daniel" + }); + result.WriteTo(writer, HtmlEncoder.Default); + var content = writer.ToString(); + var match = Regex.Match(content, ServerComponentPattern); + + // Assert + Assert.True(match.Success); + var marker = JsonSerializer.Deserialize(match.Groups[1].Value, ServerComponentSerializationSettings.JsonSerializationOptions); + Assert.Equal(0, marker.Sequence); + Assert.Null(marker.PrerenderId); + Assert.NotNull(marker.Descriptor); + Assert.Equal("server", marker.Type); + + var unprotectedServerComponent = protector.Unprotect(marker.Descriptor); + var serverComponent = JsonSerializer.Deserialize(unprotectedServerComponent, ServerComponentSerializationSettings.JsonSerializationOptions); + Assert.Equal(0, serverComponent.Sequence); + Assert.Equal(typeof(GreetingComponent).Assembly.GetName().Name, serverComponent.AssemblyName); + Assert.Equal(typeof(GreetingComponent).FullName, serverComponent.TypeName); + Assert.NotEqual(Guid.Empty, serverComponent.InvocationId); + + var parameterDefinition = Assert.Single(serverComponent.ParameterDefinitions); + Assert.Equal("Name", parameterDefinition.Name); + Assert.Equal("System.String", parameterDefinition.TypeName); + Assert.Equal("System.Private.CoreLib", parameterDefinition.Assembly); + + var value = Assert.Single(serverComponent.ParameterValues); + var rawValue = Assert.IsType(value); + Assert.Equal("Daniel", rawValue.GetString()); + } + + [Fact] + public async Task CanRender_ComponentWithNullParameters_ServerMode() + { + // Arrange + var helper = CreateHelper(); + var writer = new StringWriter(); + var protector = _dataprotectorProvider.CreateProtector(ServerComponentSerializationSettings.DataProtectionProviderPurpose) + .ToTimeLimitedDataProtector(); + + // Act + var result = await helper.RenderComponentAsync( + RenderMode.Server, + new + { + Name = (string)null + }); + result.WriteTo(writer, HtmlEncoder.Default); + var content = writer.ToString(); + var match = Regex.Match(content, ServerComponentPattern); + + // Assert + Assert.True(match.Success); + var marker = JsonSerializer.Deserialize(match.Groups[1].Value, ServerComponentSerializationSettings.JsonSerializationOptions); + Assert.Equal(0, marker.Sequence); + Assert.Null(marker.PrerenderId); + Assert.NotNull(marker.Descriptor); + Assert.Equal("server", marker.Type); + + var unprotectedServerComponent = protector.Unprotect(marker.Descriptor); + var serverComponent = JsonSerializer.Deserialize(unprotectedServerComponent, ServerComponentSerializationSettings.JsonSerializationOptions); + Assert.Equal(0, serverComponent.Sequence); + Assert.Equal(typeof(GreetingComponent).Assembly.GetName().Name, serverComponent.AssemblyName); + Assert.Equal(typeof(GreetingComponent).FullName, serverComponent.TypeName); + Assert.NotEqual(Guid.Empty, serverComponent.InvocationId); + + Assert.NotNull(serverComponent.ParameterDefinitions); + var parameterDefinition = Assert.Single(serverComponent.ParameterDefinitions); + Assert.Equal("Name", parameterDefinition.Name); + Assert.Null(parameterDefinition.TypeName); + Assert.Null(parameterDefinition.Assembly); + + var value = Assert.Single(serverComponent.ParameterValues);; + Assert.Null(value); + } + + [Fact] + public async Task CanPrerender_ComponentWithParameters_ServerMode() + { + // Arrange + var helper = CreateHelper(); + var writer = new StringWriter(); + var protector = _dataprotectorProvider.CreateProtector(ServerComponentSerializationSettings.DataProtectionProviderPurpose) + .ToTimeLimitedDataProtector(); + + // Act + var result = await helper.RenderComponentAsync( + RenderMode.ServerPrerendered, + new + { + Name = "Daniel" + }); + result.WriteTo(writer, HtmlEncoder.Default); + var content = writer.ToString(); + var match = Regex.Match(content, PrerenderedServerComponentPattern, RegexOptions.Multiline); + + // Assert + Assert.True(match.Success); + var preamble = match.Groups["preamble"].Value; + var preambleMarker = JsonSerializer.Deserialize(preamble, ServerComponentSerializationSettings.JsonSerializationOptions); + Assert.Equal(0, preambleMarker.Sequence); + Assert.NotNull(preambleMarker.PrerenderId); + Assert.NotNull(preambleMarker.Descriptor); + Assert.Equal("server", preambleMarker.Type); + + var unprotectedServerComponent = protector.Unprotect(preambleMarker.Descriptor); + var serverComponent = JsonSerializer.Deserialize(unprotectedServerComponent, ServerComponentSerializationSettings.JsonSerializationOptions); + Assert.NotEqual(default, serverComponent); + Assert.Equal(0, serverComponent.Sequence); + Assert.Equal(typeof(GreetingComponent).Assembly.GetName().Name, serverComponent.AssemblyName); + Assert.Equal(typeof(GreetingComponent).FullName, serverComponent.TypeName); + Assert.NotEqual(Guid.Empty, serverComponent.InvocationId); + + var parameterDefinition = Assert.Single(serverComponent.ParameterDefinitions); + Assert.Equal("Name", parameterDefinition.Name); + Assert.Equal("System.String", parameterDefinition.TypeName); + Assert.Equal("System.Private.CoreLib", parameterDefinition.Assembly); + + var value = Assert.Single(serverComponent.ParameterValues); + var rawValue = Assert.IsType(value); + Assert.Equal("Daniel", rawValue.GetString()); + + var prerenderedContent = match.Groups["content"].Value; + Assert.Equal("

Hello Daniel!

", prerenderedContent); + + var epilogue = match.Groups["epilogue"].Value; + var epilogueMarker = JsonSerializer.Deserialize(epilogue, ServerComponentSerializationSettings.JsonSerializationOptions); + Assert.Equal(preambleMarker.PrerenderId, epilogueMarker.PrerenderId); + Assert.Null(epilogueMarker.Sequence); + Assert.Null(epilogueMarker.Descriptor); + Assert.Null(epilogueMarker.Type); + } + + [Fact] + public async Task CanPrerender_ComponentWithNullParameters_ServerMode() + { + // Arrange + var helper = CreateHelper(); + var writer = new StringWriter(); + var protector = _dataprotectorProvider.CreateProtector(ServerComponentSerializationSettings.DataProtectionProviderPurpose) + .ToTimeLimitedDataProtector(); + + // Act + var result = await helper.RenderComponentAsync( + RenderMode.ServerPrerendered, + new + { + Name = (string)null + }); + result.WriteTo(writer, HtmlEncoder.Default); + var content = writer.ToString(); + var match = Regex.Match(content, PrerenderedServerComponentPattern, RegexOptions.Multiline); + + // Assert + Assert.True(match.Success); + var preamble = match.Groups["preamble"].Value; + var preambleMarker = JsonSerializer.Deserialize(preamble, ServerComponentSerializationSettings.JsonSerializationOptions); + Assert.Equal(0, preambleMarker.Sequence); + Assert.NotNull(preambleMarker.PrerenderId); + Assert.NotNull(preambleMarker.Descriptor); + Assert.Equal("server", preambleMarker.Type); + + var unprotectedServerComponent = protector.Unprotect(preambleMarker.Descriptor); + var serverComponent = JsonSerializer.Deserialize(unprotectedServerComponent, ServerComponentSerializationSettings.JsonSerializationOptions); + Assert.NotEqual(default, serverComponent); + Assert.Equal(0, serverComponent.Sequence); + Assert.Equal(typeof(GreetingComponent).Assembly.GetName().Name, serverComponent.AssemblyName); + Assert.Equal(typeof(GreetingComponent).FullName, serverComponent.TypeName); + Assert.NotEqual(Guid.Empty, serverComponent.InvocationId); + + Assert.NotNull(serverComponent.ParameterDefinitions); + var parameterDefinition = Assert.Single(serverComponent.ParameterDefinitions); + Assert.Equal("Name", parameterDefinition.Name); + Assert.Null(parameterDefinition.TypeName); + Assert.Null(parameterDefinition.Assembly); + + var value = Assert.Single(serverComponent.ParameterValues); + Assert.Null(value); + + var prerenderedContent = match.Groups["content"].Value; + Assert.Equal("

Hello (null)!

", prerenderedContent); + + var epilogue = match.Groups["epilogue"].Value; + var epilogueMarker = JsonSerializer.Deserialize(epilogue, ServerComponentSerializationSettings.JsonSerializationOptions); + Assert.Equal(preambleMarker.PrerenderId, epilogueMarker.PrerenderId); + Assert.Null(epilogueMarker.Sequence); + Assert.Null(epilogueMarker.Descriptor); + Assert.Null(epilogueMarker.Type); } [Fact] @@ -547,7 +730,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Test var s = 0; base.BuildRenderTree(builder); builder.OpenElement(s++, "p"); - builder.AddContent(s++, $"Hello {Name}!"); + builder.AddContent(s++, $"Hello {Name ?? ("(null)")}!"); builder.CloseElement(); } } diff --git a/src/Shared/Components/ComponentParameter.cs b/src/Shared/Components/ComponentParameter.cs new file mode 100644 index 0000000000..c76a14d8fd --- /dev/null +++ b/src/Shared/Components/ComponentParameter.cs @@ -0,0 +1,35 @@ +// 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; + +namespace Microsoft.AspNetCore.Components +{ + internal struct ComponentParameter + { + public string Name { get; set; } + public string TypeName { get; set; } + public string Assembly { get; set; } + + public static (IList parameterDefinitions, IList parameterValues) FromParameterView(ParameterView parameters) + { + var parameterDefinitions = new List(); + var parameterValues = new List(); + foreach (var kvp in parameters) + { + var valueType = kvp.Value?.GetType(); + parameterDefinitions.Add(new ComponentParameter + { + Name = kvp.Name, + TypeName = valueType?.FullName, + Assembly = valueType?.Assembly?.GetName()?.Name + }); + + parameterValues.Add(kvp.Value); + } + + return (parameterDefinitions, parameterValues); + } + } +} diff --git a/src/Shared/Components/ServerComponent.cs b/src/Shared/Components/ServerComponent.cs index 2fb241842f..4346b01570 100644 --- a/src/Shared/Components/ServerComponent.cs +++ b/src/Shared/Components/ServerComponent.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Collections.Generic; namespace Microsoft.AspNetCore.Components { @@ -14,8 +15,11 @@ namespace Microsoft.AspNetCore.Components int sequence, string assemblyName, string typeName, + IList parametersDefinitions, + IList parameterValues, Guid invocationId) => - (Sequence, AssemblyName, TypeName, InvocationId) = (sequence, assemblyName, typeName, invocationId); + (Sequence, AssemblyName, TypeName, ParameterDefinitions, ParameterValues, InvocationId) = + (sequence, assemblyName, typeName, parametersDefinitions, parameterValues, invocationId); // The order in which this component was rendered public int Sequence { get; set; } @@ -26,6 +30,12 @@ namespace Microsoft.AspNetCore.Components // The type name of the component. public string TypeName { get; set; } + // The definition for the parameters for the component. + public IList ParameterDefinitions { get; set; } + + // The values for the parameters for the component. + public IList ParameterValues { get; set; } + // An id that uniquely identifies all components generated as part of a single HTTP response. public Guid InvocationId { get; set; } }