diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BindLoweringPass.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BindLoweringPass.cs index 3df03f85eb..706283549a 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BindLoweringPass.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BindLoweringPass.cs @@ -1,17 +1,17 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using System; -using System.Collections.Generic; using System.Linq; using Microsoft.AspNetCore.Razor.Language; -using Microsoft.AspNetCore.Razor.Language.Extensions; using Microsoft.AspNetCore.Razor.Language.Intermediate; namespace Microsoft.AspNetCore.Blazor.Razor { internal class BindLoweringPass : IntermediateNodePassBase, IRazorOptimizationPass { + // Run after event handler pass + public override int Order => base.Order + 50; + protected override void ExecuteCore(RazorCodeDocument codeDocument, DocumentIntermediateNode documentNode) { var @namespace = documentNode.FindPrimaryNamespace(); @@ -35,7 +35,8 @@ namespace Microsoft.AspNetCore.Blazor.Razor var attributeNode = node.Children[j] as ComponentAttributeExtensionNode; if (attributeNode != null && attributeNode.TagHelper != null && - attributeNode.TagHelper.IsBindTagHelper()) + attributeNode.TagHelper.IsBindTagHelper() && + attributeNode.AttributeName.StartsWith("bind")) { RewriteUsage(node, j, attributeNode); } @@ -153,11 +154,14 @@ namespace Microsoft.AspNetCore.Blazor.Razor { // Skip anything we can't understand. It's important that we don't crash, that will bring down // the build. + node.Diagnostics.Add(BlazorDiagnosticFactory.CreateBindAttribute_InvalidSyntax( + attributeNode.Source, + attributeNode.AttributeName)); return; } - var originalContent = GetAttributeContent(attributeNode); - if (string.IsNullOrEmpty(originalContent)) + var original = GetAttributeContent(attributeNode); + if (string.IsNullOrEmpty(original.Content)) { // This can happen in error cases, the parser will already have flagged this // as an error, so ignore it. @@ -166,13 +170,14 @@ namespace Microsoft.AspNetCore.Blazor.Razor // Look for a matching format node. If we find one then we need to pass the format into the // two nodes we generate. - string format = null; + IntermediateToken format = null; if (TryGetFormatNode(node, attributeNode, valueAttributeName, out var formatNode)) { - // Don't write the format out as its own attribute; + // Don't write the format out as its own attribute, just capture it as a string + // or expression. node.Children.Remove(formatNode); format = GetAttributeContent(formatNode); } @@ -190,23 +195,32 @@ namespace Microsoft.AspNetCore.Blazor.Razor // // BindMethods.GetValue() OR // BindMethods.GetValue(, ) - // - // For now, the way this is done isn't debuggable. But since the expression - // passed here must be an LValue, it's probably not important. - var valueNodeContent = format == null ? - $"{BlazorApi.BindMethods.GetValue}({originalContent})" : - $"{BlazorApi.BindMethods.GetValue}({originalContent}, {format})"; valueAttributeNode.Children.Clear(); - valueAttributeNode.Children.Add(new CSharpExpressionIntermediateNode() + + var expression = new CSharpExpressionIntermediateNode(); + valueAttributeNode.Children.Add(expression); + + expression.Children.Add(new IntermediateToken() { - Children = + Content = $"{BlazorApi.BindMethods.GetValue}(", + Kind = TokenKind.CSharp + }); + expression.Children.Add(original); + + if (!string.IsNullOrEmpty(format?.Content)) + { + expression.Children.Add(new IntermediateToken() { - new IntermediateToken() - { - Content = valueNodeContent, - Kind = TokenKind.CSharp - }, - }, + Content = ", ", + Kind = TokenKind.CSharp, + }); + expression.Children.Add(format); + } + + expression.Children.Add(new IntermediateToken() + { + Content = ")", + Kind = TokenKind.CSharp, }); var changeAttributeNode = new ComponentAttributeExtensionNode(attributeNode) @@ -230,20 +244,19 @@ namespace Microsoft.AspNetCore.Blazor.Razor // BindMethods.SetValueHandler(__value => = __value, ) OR // BindMethods.SetValueHandler(__value => = __value, , ) // - // For now, the way this is done isn't debuggable. But since the expression - // passed here must be an LValue, it's probably not important. + // Note that the linemappings here are applied to the value attribute, not the change attribute. string changeAttributeContent = null; if (changeAttributeNode.BoundAttribute == null && format == null) { - changeAttributeContent = $"{BlazorApi.BindMethods.SetValueHandler}(__value => {originalContent} = __value, {originalContent})"; + changeAttributeContent = $"{BlazorApi.BindMethods.SetValueHandler}(__value => {original.Content} = __value, {original.Content})"; } else if (changeAttributeNode.BoundAttribute == null && format != null) { - changeAttributeContent = $"{BlazorApi.BindMethods.SetValueHandler}(__value => {originalContent} = __value, {originalContent}, {format})"; + changeAttributeContent = $"{BlazorApi.BindMethods.SetValueHandler}(__value => {original.Content} = __value, {original.Content}, {format.Content})"; } else { - changeAttributeContent = $"__value => {originalContent} = __value"; + changeAttributeContent = $"__value => {original.Content} = __value"; } changeAttributeNode.Children.Clear(); @@ -394,24 +407,25 @@ namespace Microsoft.AspNetCore.Blazor.Razor return false; } - private static string GetAttributeContent(ComponentAttributeExtensionNode node) + private static IntermediateToken GetAttributeContent(ComponentAttributeExtensionNode node) { if (node.Children[0] is HtmlContentIntermediateNode htmlContentNode) { // This case can be hit for a 'string' attribute. We want to turn it into // an expression. - return "\"" + ((IntermediateToken)htmlContentNode.Children.Single()).Content + "\""; + var content = "\"" + ((IntermediateToken)htmlContentNode.Children.Single()).Content + "\""; + return new IntermediateToken() { Kind = TokenKind.CSharp, Content = content }; } else if (node.Children[0] is CSharpExpressionIntermediateNode cSharpNode) { // This case can be hit when the attribute has an explicit @ inside, which // 'escapes' any special sugar we provide for codegen. - return ((IntermediateToken)cSharpNode.Children.Single()).Content; + return ((IntermediateToken)cSharpNode.Children.Single()); } else { // This is the common case for 'mixed' content - return ((IntermediateToken)node.Children.Single()).Content; + return ((IntermediateToken)node.Children.Single()); } } } diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorApi.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorApi.cs index 075e21f646..81e1f761b8 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorApi.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorApi.cs @@ -82,11 +82,18 @@ namespace Microsoft.AspNetCore.Blazor.Razor public static readonly string GetValue = "Microsoft.AspNetCore.Blazor.Components.BindMethods.GetValue"; + public static readonly string GetEventHandlerValue = "Microsoft.AspNetCore.Blazor.Components.BindMethods.GetEventHandlerValue"; + public static readonly string SetValue = "Microsoft.AspNetCore.Blazor.Components.BindMethods.SetValue"; public static readonly string SetValueHandler = "Microsoft.AspNetCore.Blazor.Components.BindMethods.SetValueHandler"; } + public static class EventHandlerAttribute + { + public static readonly string FullTypeName = "Microsoft.AspNetCore.Blazor.Components.EventHandlerAttribute"; + } + public static class UIEventHandler { public static readonly string FullTypeName = "Microsoft.AspNetCore.Blazor.UIEventHandler"; diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorDiagnosticFactory.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorDiagnosticFactory.cs index b8a2958029..b00be81793 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorDiagnosticFactory.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorDiagnosticFactory.cs @@ -105,5 +105,37 @@ namespace Microsoft.AspNetCore.Blazor.Razor Environment.NewLine + string.Join(Environment.NewLine, attributes.Select(p => p.TagHelper.DisplayName))); return diagnostic; } + + public static readonly RazorDiagnosticDescriptor EventHandler_Duplicates = + new RazorDiagnosticDescriptor( + "BL9990", + () => "The attribute '{0}' was matched by multiple event handlers attributes. Duplicates:{1}", + RazorDiagnosticSeverity.Error); + + public static RazorDiagnostic CreateEventHandler_Duplicates(SourceSpan? source, string attribute, ComponentAttributeExtensionNode[] attributes) + { + var diagnostic = RazorDiagnostic.Create( + EventHandler_Duplicates, + source ?? SourceSpan.Undefined, + attribute, + Environment.NewLine + string.Join(Environment.NewLine, attributes.Select(p => p.TagHelper.DisplayName))); + return diagnostic; + } + + public static readonly RazorDiagnosticDescriptor BindAttribute_InvalidSyntax = + new RazorDiagnosticDescriptor( + "BL9991", + () => "The attribute names could not be inferred from bind attibute '{0}'. Bind attributes should be of the form" + + "'bind', 'bind-value' or 'bind-value-change'", + RazorDiagnosticSeverity.Error); + + public static RazorDiagnostic CreateBindAttribute_InvalidSyntax(SourceSpan? source, string attribute) + { + var diagnostic = RazorDiagnostic.Create( + BindAttribute_InvalidSyntax, + source ?? SourceSpan.Undefined, + attribute); + return diagnostic; + } } } diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorExtensionInitializer.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorExtensionInitializer.cs index 66c98560ff..cfb2e3fe0b 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorExtensionInitializer.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorExtensionInitializer.cs @@ -65,17 +65,18 @@ namespace Microsoft.AspNetCore.Blazor.Razor builder.Features.Add(new ConfigureBlazorCodeGenerationOptions()); - // Implementation of components + // Blazor-specific passes, in order. builder.Features.Add(new ComponentDocumentClassifierPass()); builder.Features.Add(new ComplexAttributeContentPass()); - builder.Features.Add(new ComponentLoweringPass()); - builder.Features.Add(new ComponentTagHelperDescriptorProvider()); - - // Implementation of bind builder.Features.Add(new BindLoweringPass()); - builder.Features.Add(new BindTagHelperDescriptorProvider()); + builder.Features.Add(new EventHandlerLoweringPass()); + builder.Features.Add(new ComponentLoweringPass()); builder.Features.Add(new OrphanTagHelperLoweringPass()); + builder.Features.Add(new ComponentTagHelperDescriptorProvider()); + builder.Features.Add(new BindTagHelperDescriptorProvider()); + builder.Features.Add(new EventHandlerTagHelperDescriptorProvider()); + if (builder.Configuration.ConfigurationName == DeclarationConfiguration.ConfigurationName) { // This is for 'declaration only' processing. We don't want to try and emit any method bodies during diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorMetadata.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorMetadata.cs index ff9bbca451..ae48701754 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorMetadata.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorMetadata.cs @@ -33,7 +33,15 @@ namespace Microsoft.AspNetCore.Blazor.Razor public static readonly string RuntimeName = "Blazor.IComponent"; public readonly static string TagHelperKind = "Blazor.Component-0.1"; + } + public static class EventHandler + { + public static readonly string EventArgsType = "Blazor.EventHandler.EventArgs"; + + public static readonly string RuntimeName = "Blazor.None"; + + public readonly static string TagHelperKind = "Blazor.EventHandler-0.1"; } } } diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorRuntimeNodeWriter.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorRuntimeNodeWriter.cs index f4b72488b5..0b281e4ae4 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorRuntimeNodeWriter.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorRuntimeNodeWriter.cs @@ -33,14 +33,14 @@ namespace Microsoft.AspNetCore.Blazor.Razor private readonly ScopeStack _scopeStack = new ScopeStack(); private string _unconsumedHtml; - private IList _currentAttributeValues; + private List _currentAttributeValues; private IDictionary _currentElementAttributes = new Dictionary(); private IList _currentElementAttributeTokens = new List(); private int _sourceSequence = 0; private struct PendingAttribute { - public object AttributeValue; + public List Values { get; set; } } private struct PendingAttributeToken @@ -180,19 +180,22 @@ namespace Microsoft.AspNetCore.Blazor.Razor // ... so to avoid losing whitespace, convert the prefix to a further token in the list if (!string.IsNullOrEmpty(node.Prefix)) { - _currentAttributeValues.Add(node.Prefix); + _currentAttributeValues.Add(new IntermediateToken() { Kind = TokenKind.Html, Content = node.Prefix }); } - _currentAttributeValues.Add((IntermediateToken)node.Children.Single()); + for (var i = 0; i < node.Children.Count; i++) + { + _currentAttributeValues.Add((IntermediateToken)node.Children[i]); + } } public override void WriteHtmlAttribute(CodeRenderingContext context, HtmlAttributeIntermediateNode node) { - _currentAttributeValues = new List(); + _currentAttributeValues = new List(); context.RenderChildren(node); _currentElementAttributes[node.AttributeName] = new PendingAttribute { - AttributeValue = _currentAttributeValues + Values = _currentAttributeValues, }; _currentAttributeValues = null; } @@ -205,7 +208,7 @@ namespace Microsoft.AspNetCore.Blazor.Razor } var stringContent = ((IntermediateToken)node.Children.Single()).Content; - _currentAttributeValues.Add(node.Prefix + stringContent); + _currentAttributeValues.Add(new IntermediateToken() { Kind = TokenKind.Html, Content = node.Prefix + stringContent, }); } public override void WriteHtmlContent(CodeRenderingContext context, HtmlContentIntermediateNode node) @@ -263,14 +266,15 @@ namespace Microsoft.AspNetCore.Blazor.Razor foreach (var attribute in nextTag.Attributes) { - WriteAttribute(codeWriter, attribute.Key, attribute.Value); + var token = new IntermediateToken() { Kind = TokenKind.Html, Content = attribute.Value }; + WriteAttribute(codeWriter, attribute.Key, new[] { token }); } if (_currentElementAttributes.Count > 0) { foreach (var pair in _currentElementAttributes) { - WriteAttribute(codeWriter, pair.Key, pair.Value.AttributeValue); + WriteAttribute(codeWriter, pair.Key, pair.Value.Values); } _currentElementAttributes.Clear(); } @@ -331,10 +335,13 @@ namespace Microsoft.AspNetCore.Blazor.Razor // The @bind(X, Y, Z, ...) syntax is special. We convert it to a pair of attributes: // [1] value=@BindMethods.GetValue(X, Y, Z, ...) var valueParams = bindMatch.Groups[1].Value; - WriteAttribute(context.CodeWriter, "value", new IntermediateToken + WriteAttribute(context.CodeWriter, "value", new[] { - Kind = TokenKind.CSharp, - Content = $"{BlazorApi.BindMethods.GetValue}({valueParams})" + new IntermediateToken + { + Kind = TokenKind.CSharp, + Content = $"{BlazorApi.BindMethods.GetValue}({valueParams})" + } }); // [2] @onchange(BindSetValue(parsed => { X = parsed; }, X, Y, Z, ...)) @@ -467,22 +474,14 @@ namespace Microsoft.AspNetCore.Blazor.Razor // Minimized attributes always map to 'true' context.CodeWriter.Write("true"); } - else if ( - node.Children.Count != 1 || - node.Children[0] is HtmlContentIntermediateNode htmlNode && htmlNode.Children.Count != 1 || - node.Children[0] is CSharpExpressionIntermediateNode cSharpNode && cSharpNode.Children.Count != 1) - { - // We don't expect this to happen, we just want to know if it can. - throw new InvalidOperationException("Attribute nodes should either be minimized or a single content node."); - } else if (node.BoundAttribute?.IsDelegateProperty() ?? false) { // We always surround the expression with the delegate constructor. This makes type // inference inside lambdas, and method group conversion do the right thing. IntermediateToken token = null; - if ((cSharpNode = node.Children[0] as CSharpExpressionIntermediateNode) != null) + if ((node.Children[0] as CSharpExpressionIntermediateNode) != null) { - token = cSharpNode.Children[0] as IntermediateToken; + token = node.Children[0].Children[0] as IntermediateToken; } else { @@ -498,11 +497,24 @@ namespace Microsoft.AspNetCore.Blazor.Razor context.CodeWriter.Write(")"); } } - else if ((cSharpNode = node.Children[0] as CSharpExpressionIntermediateNode) != null) + else if (node.Children[0] is CSharpExpressionIntermediateNode cSharpNode) { - context.CodeWriter.Write(((IntermediateToken)cSharpNode.Children[0]).Content); + // We don't allow mixed content in component attributes. If this happens, then + // we should make sure that all of the tokens are the same kind. We report an + // error if user code tries to do this, so this check is to catch bugs in the + // compiler. + for (var i = 0; i < cSharpNode.Children.Count; i++) + { + var token = (IntermediateToken)cSharpNode.Children[i]; + if (!token.IsCSharp) + { + throw new InvalidOperationException("Unexpected mixed content in a component."); + } + + context.CodeWriter.Write(token.Content); + } } - else if ((htmlNode = node.Children[0] as HtmlContentIntermediateNode) != null) + else if (node.Children[0] is HtmlContentIntermediateNode htmlNode) { // This is how string attributes are lowered by default, a single HTML node with a single HTML token. context.CodeWriter.WriteStringLiteral(((IntermediateToken)htmlNode.Children[0]).Content); @@ -549,7 +561,7 @@ namespace Microsoft.AspNetCore.Blazor.Razor return document.Substring(tagToken.Position.Position + offset, tagToken.Name.Length); } - private void WriteAttribute(CodeWriter codeWriter, string key, object value) + private void WriteAttribute(CodeWriter codeWriter, string key, IList value) { BeginWriteAttribute(codeWriter, key); WriteAttributeValue(codeWriter, value); @@ -586,64 +598,89 @@ namespace Microsoft.AspNetCore.Blazor.Razor return builder.ToString(); } - private static void WriteAttributeValue(CodeWriter writer, object value) + // There are a few cases here, we need to handle: + // - Pure HTML + // - Pure CSharp + // - Mixed HTML and CSharp + // + // Only the mixed case is complicated, we want to turn it into code that will concatenate + // the values into a string at runtime. + + private static void WriteAttributeValue(CodeWriter writer, IList tokens) { - if (value == null) + if (tokens == null) { - throw new ArgumentNullException(nameof(value)); + throw new ArgumentNullException(nameof(tokens)); } - switch (value) + var hasHtml = false; + var hasCSharp = false; + for (var i = 0; i < tokens.Count; i++) { - case string valueString: - writer.WriteStringLiteral(valueString); - break; - case IntermediateToken token: + if (tokens[i].IsCSharp) + { + hasCSharp |= true; + } + else + { + hasHtml |= true; + } + } + + if (hasHtml && hasCSharp) + { + // If it's a C# expression, we have to wrap it in parens, otherwise things like ternary + // expressions don't compose with concatenation. However, this is a little complicated + // because C# tokens themselves aren't guaranteed to be distinct expressions. We want + // to treat all contiguous C# tokens as a single expression. + var insideCSharp = false; + for (var i = 0; i < tokens.Count; i++) + { + var token = tokens[i]; + if (token.IsCSharp) { - if (token.IsCSharp) + if (!insideCSharp) { - writer.Write(token.Content); - } - else - { - writer.WriteStringLiteral(token.Content); - } - break; - } - case IEnumerable concatenatedValues: - { - var first = true; - foreach (var concatenatedValue in concatenatedValues) - { - if (first) - { - first = false; - } - else + if (i != 0) { writer.Write(" + "); } - // If it's a C# expression, we have to wrap it in parens, otherwise - // things like ternary expressions don't compose with concatenation - var isCSharp = concatenatedValue is IntermediateToken intermediateToken - && intermediateToken.Kind == TokenKind.CSharp; - if (isCSharp) - { - writer.Write("("); - } - - WriteAttributeValue(writer, concatenatedValue); - - if (isCSharp) - { - writer.Write(")"); - } + writer.Write("("); + insideCSharp = true; } - break; + + writer.Write(token.Content); } - default: - throw new ArgumentException($"Unsupported attribute value type: {value.GetType().FullName}"); + else + { + if (insideCSharp) + { + writer.Write(")"); + insideCSharp = false; + } + + if (i != 0) + { + writer.Write(" + "); + } + + writer.WriteStringLiteral(token.Content); + } + } + + if (insideCSharp) + { + writer.Write(")"); + } + } + else if (hasCSharp) + { + writer.Write(string.Join("", tokens.Select(t => t.Content))); + } + else if (hasHtml) + { + writer.WriteStringLiteral(string.Join("", tokens.Select(t => t.Content))); } } } diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComplexAttributeContentPass.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComplexAttributeContentPass.cs index 763dddb85c..9dddd9cfa2 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComplexAttributeContentPass.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComplexAttributeContentPass.cs @@ -89,6 +89,13 @@ namespace Microsoft.AspNetCore.Blazor.Razor return true; } + else if (node.Children.Count == 1 && + node.Children[0] is CSharpCodeIntermediateNode cSharpCodeNode) + { + // This is the case when an attribute contains a code block @{ ... } + // We don't support this. + return true; + } else if (node.Children.Count > 1) { // This is the common case for 'mixed' content diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComponentLoweringPass.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComponentLoweringPass.cs index 1134925548..0be41ccf53 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComponentLoweringPass.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComponentLoweringPass.cs @@ -81,33 +81,10 @@ namespace Microsoft.AspNetCore.Blazor.Razor if (node.Children[i] is TagHelperPropertyIntermediateNode propertyNode && propertyNode.TagHelper == tagHelper) { - // We don't support 'complex' content for components (mixed C# and markup) right now. - // It's not clear yet if Blazor will have a good scenario to use these constructs. - // - // This is where a lot of the complexity in the Razor/TagHelpers model creeps in and we - // might be able to avoid it if these features aren't needed. - if (HasComplexChildContent(propertyNode)) - { - node.Diagnostics.Add(BlazorDiagnosticFactory.Create_UnsupportedComplexContent( - propertyNode, - propertyNode.AttributeName)); - node.Children.RemoveAt(i); - continue; - } - node.Children[i] = new ComponentAttributeExtensionNode(propertyNode); } else if (node.Children[i] is TagHelperHtmlAttributeIntermediateNode htmlNode) { - if (HasComplexChildContent(htmlNode)) - { - node.Diagnostics.Add(BlazorDiagnosticFactory.Create_UnsupportedComplexContent( - htmlNode, - htmlNode.AttributeName)); - node.Children.RemoveAt(i); - continue; - } - // For any nodes that don't map to a component property we won't have type information // but these should follow the same path through the runtime. var attributeNode = new ComponentAttributeExtensionNode(htmlNode); @@ -154,31 +131,5 @@ namespace Microsoft.AspNetCore.Blazor.Razor } } } - - private static bool HasComplexChildContent(IntermediateNode node) - { - if (node.Children.Count == 1 && - node.Children[0] is HtmlAttributeIntermediateNode htmlNode && - htmlNode.Children.Count > 1) - { - // This case can be hit for a 'string' attribute - return true; - } - else if (node.Children.Count == 1 && - node.Children[0] is CSharpExpressionIntermediateNode cSharpNode && - cSharpNode.Children.Count > 1) - { - // This case can be hit when the attribute has an explicit @ inside, which - // 'escapes' any special sugar we provide for codegen. - return true; - } - else if (node.Children.Count > 1) - { - // This is the common case for 'mixed' content - return true; - } - - return false; - } } } diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/EventHandlerLoweringPass.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/EventHandlerLoweringPass.cs new file mode 100644 index 0000000000..44bd8bb311 --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/EventHandlerLoweringPass.cs @@ -0,0 +1,158 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Linq; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Language.Intermediate; + +namespace Microsoft.AspNetCore.Blazor.Razor +{ + internal class EventHandlerLoweringPass : IntermediateNodePassBase, IRazorOptimizationPass + { + protected override void ExecuteCore(RazorCodeDocument codeDocument, DocumentIntermediateNode documentNode) + { + var @namespace = documentNode.FindPrimaryNamespace(); + var @class = documentNode.FindPrimaryClass(); + if (@namespace == null || @class == null) + { + // Nothing to do, bail. We can't function without the standard structure. + return; + } + + // For each event handler *usage* we need to rewrite the tag helper node to map to basic constructs. + var nodes = documentNode.FindDescendantNodes(); + for (var i = 0; i < nodes.Count; i++) + { + var node = nodes[i]; + + ProcessDuplicates(node); + + for (var j = node.Children.Count - 1; j >= 0; j--) + { + var attributeNode = node.Children[j] as ComponentAttributeExtensionNode; + if (attributeNode != null && + attributeNode.TagHelper != null && + attributeNode.TagHelper.IsEventHandlerTagHelper()) + { + RewriteUsage(node, j, attributeNode); + } + } + } + } + + private void ProcessDuplicates(TagHelperIntermediateNode node) + { + // Reverse order because we will remove nodes. + // + // Each 'property' node could be duplicated if there are multiple tag helpers that match that + // particular attribute. This is likely to happen when a component also defines something like + // OnClick. We want to remove the 'onclick' and let it fall back to be handled by the component. + for (var i = node.Children.Count - 1; i >= 0; i--) + { + var attributeNode = node.Children[i] as ComponentAttributeExtensionNode; + if (attributeNode != null && + attributeNode.TagHelper != null && + attributeNode.TagHelper.IsEventHandlerTagHelper()) + { + for (var j = 0; j < node.Children.Count; j++) + { + var duplicate = node.Children[j] as ComponentAttributeExtensionNode; + if (duplicate != null && + duplicate.TagHelper != null && + duplicate.TagHelper.IsComponentTagHelper() && + duplicate.AttributeName == attributeNode.AttributeName) + { + // Found a duplicate - remove the 'fallback' in favor of the + // more specific tag helper. + node.Children.RemoveAt(i); + node.TagHelpers.Remove(attributeNode.TagHelper); + break; + } + } + } + } + + // If we still have duplicates at this point then they are genuine conflicts. + var duplicates = node.Children + .OfType() + .Where(p => p.TagHelper?.IsEventHandlerTagHelper() ?? false) + .GroupBy(p => p.AttributeName) + .Where(g => g.Count() > 1); + + foreach (var duplicate in duplicates) + { + node.Diagnostics.Add(BlazorDiagnosticFactory.CreateEventHandler_Duplicates( + node.Source, + duplicate.Key, + duplicate.ToArray())); + foreach (var property in duplicate) + { + node.Children.Remove(property); + } + } + } + + private void RewriteUsage(TagHelperIntermediateNode node, int index, ComponentAttributeExtensionNode attributeNode) + { + var original = GetAttributeContent(attributeNode); + if (string.IsNullOrEmpty(original.Content)) + { + // This can happen in error cases, the parser will already have flagged this + // as an error, so ignore it. + return; + } + + var rewrittenNode = new ComponentAttributeExtensionNode(attributeNode); + node.Children[index] = rewrittenNode; + + // Now rewrite the content of the value node to look like: + // + // BindMethods.GetEventHandlerValue() + // + // This method is overloaded on string and TDelegate, which means that it will put the code in the + // correct context for intellisense when typing in the attribute. + var eventArgsType = attributeNode.TagHelper.GetEventArgsType(); + + rewrittenNode.Children.Clear(); + rewrittenNode.Children.Add(new CSharpExpressionIntermediateNode() + { + Children = + { + new IntermediateToken() + { + Content = $"{BlazorApi.BindMethods.GetEventHandlerValue}<{eventArgsType}>(", + Kind = TokenKind.CSharp + }, + original, + new IntermediateToken() + { + Content = $")", + Kind = TokenKind.CSharp + } + }, + }); + } + + private static IntermediateToken GetAttributeContent(ComponentAttributeExtensionNode node) + { + if (node.Children[0] is HtmlContentIntermediateNode htmlContentNode) + { + // This case can be hit for a 'string' attribute. We want to turn it into + // an expression. + var content = "\"" + ((IntermediateToken)htmlContentNode.Children.Single()).Content + "\""; + return new IntermediateToken() { Content = content, Kind = TokenKind.CSharp, }; + } + else if (node.Children[0] is CSharpExpressionIntermediateNode cSharpNode) + { + // This case can be hit when the attribute has an explicit @ inside, which + // 'escapes' any special sugar we provide for codegen. + return ((IntermediateToken)cSharpNode.Children.Single()); + } + else + { + // This is the common case for 'mixed' content + return ((IntermediateToken)node.Children.Single()); + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/EventHandlerTagHelperDescriptorProvider.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/EventHandlerTagHelperDescriptorProvider.cs new file mode 100644 index 0000000000..17d07d9cdb --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/EventHandlerTagHelperDescriptorProvider.cs @@ -0,0 +1,215 @@ +// 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.AspNetCore.Razor.Language; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Razor; + +namespace Microsoft.AspNetCore.Blazor.Razor +{ + internal class EventHandlerTagHelperDescriptorProvider : ITagHelperDescriptorProvider + { + public int Order { get; set; } + + public RazorEngine Engine { get; set; } + + public void Execute(TagHelperDescriptorProviderContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + var compilation = context.GetCompilation(); + if (compilation == null) + { + return; + } + + var bindMethods = compilation.GetTypeByMetadataName(BlazorApi.BindMethods.FullTypeName); + if (bindMethods == null) + { + // If we can't find BindMethods, then just bail. We won't be able to compile the + // generated code anyway. + return; + } + + + var eventHandlerData = GetEventHandlerData(compilation); + + foreach (var tagHelper in CreateEventHandlerTagHelpers(eventHandlerData)) + { + context.Results.Add(tagHelper); + } + } + + private List GetEventHandlerData(Compilation compilation) + { + var eventHandlerAttribute = compilation.GetTypeByMetadataName(BlazorApi.EventHandlerAttribute.FullTypeName); + if (eventHandlerAttribute == null) + { + // This won't likely happen, but just in case. + return new List(); + } + + var types = new List(); + var visitor = new EventHandlerDataVisitor(types); + + // Visit the primary output of this compilation, as well as all references. + visitor.Visit(compilation.Assembly); + foreach (var reference in compilation.References) + { + // We ignore .netmodules here - there really isn't a case where they are used by user code + // even though the Roslyn APIs all support them. + if (compilation.GetAssemblyOrModuleSymbol(reference) is IAssemblySymbol assembly) + { + visitor.Visit(assembly); + } + } + + var results = new List(); + + for (var i = 0; i < types.Count; i++) + { + var type = types[i]; + var attributes = type.GetAttributes(); + + // Not handling duplicates here for now since we're the primary ones extending this. + // If we see users adding to the set of event handler constructs we will want to add deduplication + // and potentially diagnostics. + for (var j = 0; j < attributes.Length; j++) + { + var attribute = attributes[j]; + + if (attribute.AttributeClass == eventHandlerAttribute) + { + results.Add(new EventHandlerData( + type.ContainingAssembly.Name, + type.ToDisplayString(), + (string)attribute.ConstructorArguments[0].Value, + (INamedTypeSymbol)attribute.ConstructorArguments[1].Value)); + } + } + } + + return results; + } + + private List CreateEventHandlerTagHelpers(List data) + { + var results = new List(); + + for (var i = 0; i < data.Count; i++) + { + var entry = data[i]; + + var builder = TagHelperDescriptorBuilder.Create(BlazorMetadata.EventHandler.TagHelperKind, entry.Attribute, entry.Assembly); + builder.Documentation = string.Format( + Resources.EventHandlerTagHelper_Documentation, + entry.Attribute, + entry.EventArgsType.ToDisplayString()); + + builder.Metadata.Add(BlazorMetadata.SpecialKindKey, BlazorMetadata.EventHandler.TagHelperKind); + builder.Metadata.Add(BlazorMetadata.EventHandler.EventArgsType, entry.EventArgsType.ToDisplayString()); + builder.Metadata[TagHelperMetadata.Runtime.Name] = BlazorMetadata.EventHandler.RuntimeName; + + // WTE has a bug in 15.7p1 where a Tag Helper without a display-name that looks like + // a C# property will crash trying to create the toolips. + builder.SetTypeName(entry.TypeName); + + builder.TagMatchingRule(rule => + { + rule.TagName = "*"; + + rule.Attribute(a => + { + a.Name = entry.Attribute; + a.NameComparisonMode = RequiredAttributeDescriptor.NameComparisonMode.FullMatch; + }); + }); + + builder.BindAttribute(a => + { + a.Documentation = string.Format( + Resources.EventHandlerTagHelper_Documentation, + entry.Attribute, + entry.EventArgsType.ToDisplayString()); + + a.Name = entry.Attribute; + + // Use a string here so that we get HTML context by default. + a.TypeName = typeof(string).FullName; + + // WTE has a bug 15.7p1 where a Tag Helper without a display-name that looks like + // a C# property will crash trying to create the toolips. + a.SetPropertyName(entry.Attribute); + }); + + results.Add(builder.Build()); + } + + return results; + } + + private struct EventHandlerData + { + public EventHandlerData( + string assembly, + string typeName, + string element, + INamedTypeSymbol eventArgsType) + { + Assembly = assembly; + TypeName = typeName; + Attribute = element; + EventArgsType = eventArgsType; + } + + public string Assembly { get; } + + public string TypeName { get; } + + public string Attribute { get; } + + public INamedTypeSymbol EventArgsType { get; } + } + + private class EventHandlerDataVisitor : SymbolVisitor + { + private List _results; + + public EventHandlerDataVisitor(List results) + { + _results = results; + } + + public override void VisitNamedType(INamedTypeSymbol symbol) + { + if (symbol.Name == "EventHandlers" && symbol.DeclaredAccessibility == Accessibility.Public) + { + _results.Add(symbol); + } + } + + public override void VisitNamespace(INamespaceSymbol symbol) + { + foreach (var member in symbol.GetMembers()) + { + Visit(member); + } + } + + public override void VisitAssembly(IAssemblySymbol symbol) + { + // This as a simple yet high-value optimization that excludes the vast majority of + // assemblies that (by definition) can't contain a component. + if (symbol.Name != null && !symbol.Name.StartsWith("System.", StringComparison.Ordinal)) + { + Visit(symbol.GlobalNamespace); + } + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/Resources.Designer.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/Resources.Designer.cs index 8e9b245101..972cb6c2e0 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/Resources.Designer.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/Resources.Designer.cs @@ -105,6 +105,15 @@ namespace Microsoft.AspNetCore.Blazor.Razor { } } + /// + /// Looks up a localized string similar to Sets the '{0}' attribute to the provided string or delegate value. A delegate value should be of type '{1}'.. + /// + internal static string EventHandlerTagHelper_Documentation { + get { + return ResourceManager.GetString("EventHandlerTagHelper_Documentation", resourceCulture); + } + } + /// /// Looks up a localized string similar to Declares an interface implementation for the current document.. /// diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/Resources.resx b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/Resources.resx index 565fa192c9..bc959ac31f 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/Resources.resx +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/Resources.resx @@ -132,6 +132,9 @@ Specifies a format to convert the value specified by the corresponding bind attribute. For example: <code>format-value="..."</code> will apply a format string to the value specified in <code>bind-value-...</code>. The format string can currently only be used with expressions of type <code>DateTime</code>. + + Sets the '{0}' attribute to the provided string or delegate value. A delegate value should be of type '{1}'. + Declares an interface implementation for the current document. diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/TagHelperDescriptorExtensions.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/TagHelperDescriptorExtensions.cs index 8b01ac6206..6ddedf9d09 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/TagHelperDescriptorExtensions.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/TagHelperDescriptorExtensions.cs @@ -90,5 +90,28 @@ namespace Microsoft.AspNetCore.Blazor.Razor return !tagHelper.Metadata.ContainsKey(BlazorMetadata.SpecialKindKey); } + + public static bool IsEventHandlerTagHelper(this TagHelperDescriptor tagHelper) + { + if (tagHelper == null) + { + throw new ArgumentNullException(nameof(tagHelper)); + } + + return + tagHelper.Metadata.TryGetValue(BlazorMetadata.SpecialKindKey, out var kind) && + string.Equals(BlazorMetadata.EventHandler.TagHelperKind, kind); + } + + public static string GetEventArgsType(this TagHelperDescriptor tagHelper) + { + if (tagHelper == null) + { + throw new ArgumentNullException(nameof(tagHelper)); + } + + tagHelper.Metadata.TryGetValue(BlazorMetadata.EventHandler.EventArgsType, out var result); + return result; + } } } diff --git a/src/Microsoft.AspNetCore.Blazor/Components/BindMethods.cs b/src/Microsoft.AspNetCore.Blazor/Components/BindMethods.cs index 2565ec21f6..b8d8994c2a 100644 --- a/src/Microsoft.AspNetCore.Blazor/Components/BindMethods.cs +++ b/src/Microsoft.AspNetCore.Blazor/Components/BindMethods.cs @@ -23,6 +23,24 @@ namespace Microsoft.AspNetCore.Blazor.Components value == default ? null : (format == null ? value.ToString() : value.ToString(format)); + /// + /// Not intended to be used directly. + /// + public static string GetEventHandlerValue(string value) + where T : UIEventArgs + { + return value; + } + + /// + /// Not intended to be used directly. + /// + public static UIEventHandler GetEventHandlerValue(Action value) + where T : UIEventArgs + { + return e => value((T)e); + } + /// /// Not intended to be used directly. /// diff --git a/src/Microsoft.AspNetCore.Blazor/Components/EventHandlerAttribute.cs b/src/Microsoft.AspNetCore.Blazor/Components/EventHandlerAttribute.cs new file mode 100644 index 0000000000..1903603d8a --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor/Components/EventHandlerAttribute.cs @@ -0,0 +1,31 @@ +// 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; + +namespace Microsoft.AspNetCore.Blazor.Components +{ + [AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] + public sealed class EventHandlerAttribute : Attribute + { + public EventHandlerAttribute(string attributeName, Type eventArgsType) + { + if (attributeName == null) + { + throw new ArgumentNullException(nameof(attributeName)); + } + + if (eventArgsType == null) + { + throw new ArgumentNullException(nameof(eventArgsType)); + } + + AttributeName = attributeName; + EventArgsType = eventArgsType; + } + + public string AttributeName { get; } + + public Type EventArgsType { get; } + } +} diff --git a/src/Microsoft.AspNetCore.Blazor/Components/EventHandlers.cs b/src/Microsoft.AspNetCore.Blazor/Components/EventHandlers.cs new file mode 100644 index 0000000000..a3b78c9662 --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor/Components/EventHandlers.cs @@ -0,0 +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 System; + +namespace Microsoft.AspNetCore.Blazor.Components +{ + [EventHandler("onchange", typeof(UIChangeEventArgs))] + [EventHandler("onclick", typeof(UIMouseEventArgs))] + public static class EventHandlers + { + } +} diff --git a/test/Microsoft.AspNetCore.Blazor.Build.Test/BindRazorIntegrationTest.cs b/test/Microsoft.AspNetCore.Blazor.Build.Test/BindRazorIntegrationTest.cs index d709c3412e..af4afc4f3c 100644 --- a/test/Microsoft.AspNetCore.Blazor.Build.Test/BindRazorIntegrationTest.cs +++ b/test/Microsoft.AspNetCore.Blazor.Build.Test/BindRazorIntegrationTest.cs @@ -488,24 +488,35 @@ namespace Test } [Fact] - public void Render_Bind_With_IncorrectAttributeNames() + public void Render_BindFallback_InvalidSyntax_TooManyParts() { - //more than 3 parts - Assert.Throws(() => CompileToComponent(@" + // Arrange & Act + var generated = CompileToCSharp(@" @addTagHelper *, TestAssembly @functions { public string Text { get; set; } = ""text""; -}")); +}"); - //ends with '-' - Assert.Throws(() => CompileToComponent(@" + // Assert + var diagnostic = Assert.Single(generated.Diagnostics); + Assert.Equal("BL9991", diagnostic.Id); + } + + [Fact] + public void Render_BindFallback_InvalidSyntax_TrailingDash() + { + // Arrange & Act + var generated = CompileToCSharp(@" @addTagHelper *, TestAssembly @functions { public string Text { get; set; } = ""text""; -}")); +}"); + // Assert + var diagnostic = Assert.Single(generated.Diagnostics); + Assert.Equal("BL9991", diagnostic.Id); } } } diff --git a/test/Microsoft.AspNetCore.Blazor.Build.Test/DesignTimeCodeGenerationTest.cs b/test/Microsoft.AspNetCore.Blazor.Build.Test/DesignTimeCodeGenerationTest.cs index ada82614ea..603a57970f 100644 --- a/test/Microsoft.AspNetCore.Blazor.Build.Test/DesignTimeCodeGenerationTest.cs +++ b/test/Microsoft.AspNetCore.Blazor.Build.Test/DesignTimeCodeGenerationTest.cs @@ -205,5 +205,56 @@ namespace Test AssertCSharpDocumentMatchesBaseline(generated.CodeDocument); CompileToAssembly(generated); } + + [Fact] + public void EventHandler_OnElement_WithString() + { + // Arrange + + // Act + var generated = CompileToCSharp(@" +"); + + // Assert + AssertDocumentNodeMatchesBaseline(generated.CodeDocument); + AssertCSharpDocumentMatchesBaseline(generated.CodeDocument); + CompileToAssembly(generated); + } + + [Fact] + public void EventHandler_OnElement_WithLambdaDelegate() + { + // Arrange + + // Act + var generated = CompileToCSharp(@" +@using Microsoft.AspNetCore.Blazor + { })"" />"); + + // Assert + AssertDocumentNodeMatchesBaseline(generated.CodeDocument); + AssertCSharpDocumentMatchesBaseline(generated.CodeDocument); + CompileToAssembly(generated); + } + + [Fact] + public void EventHandler_OnElement_WithDelegate() + { + // Arrange + + // Act + var generated = CompileToCSharp(@" +@using Microsoft.AspNetCore.Blazor + +@functions { + void OnClick(UIMouseEventArgs e) { + } +}"); + + // Assert + AssertDocumentNodeMatchesBaseline(generated.CodeDocument); + AssertCSharpDocumentMatchesBaseline(generated.CodeDocument); + CompileToAssembly(generated); + } } } diff --git a/test/Microsoft.AspNetCore.Blazor.Build.Test/RenderingRazorIntegrationTest.cs b/test/Microsoft.AspNetCore.Blazor.Build.Test/RenderingRazorIntegrationTest.cs index d11ee173be..35b48b36c4 100644 --- a/test/Microsoft.AspNetCore.Blazor.Build.Test/RenderingRazorIntegrationTest.cs +++ b/test/Microsoft.AspNetCore.Blazor.Build.Test/RenderingRazorIntegrationTest.cs @@ -180,6 +180,22 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test frame => AssertFrame.Attribute(frame, "attr", "Hello, WORLD with number 246!", 1)); } + // This test exercises the case where two IntermediateTokens are part of the same expression. + // In these case they are split by a comment. + [Fact] + public void SupportsAttributesWithInterpolatedStringExpressionValues_SplitByComment() + { + // Arrange/Act + var component = CompileToComponent( + "@{ var myValue = \"world\"; var myNum=123; }" + + ""); + + // Assert + Assert.Collection(GetRenderTree(component), + frame => AssertFrame.Element(frame, "elem", 2, 0), + frame => AssertFrame.Attribute(frame, "attr", "Hello, WORLD with number 246!", 1)); + } + [Fact] public void SupportsAttributesWithInterpolatedTernaryExpressionValues() { @@ -220,7 +236,7 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test frame => AssertFrame.Element(frame, "elem", 2, 0), frame => AssertFrame.Attribute(frame, "data-abc", "Hello", 1)); } - + [Fact(Skip = "Currently broken due to #219. TODO: Once the issue is fixed, re-enable this test, remove the test below, and remove the implementation of its workaround.")] public void SupportsDataDashAttributesWithCSharpExpressionValues() { @@ -465,6 +481,86 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test frame => AssertFrame.Text(frame, "\n", 3)); } + [Fact] // In this case, onclick is just a normal HTML attribute + public void SupportsEventHandlerWithString() + { + // Arrange + var component = CompileToComponent(@" + + @functions { private int valueToSupply = 100; diff --git a/test/testapps/BasicTestApp/RenderFragmentToggler.cshtml b/test/testapps/BasicTestApp/RenderFragmentToggler.cshtml index 228f6187e0..d999a13799 100644 --- a/test/testapps/BasicTestApp/RenderFragmentToggler.cshtml +++ b/test/testapps/BasicTestApp/RenderFragmentToggler.cshtml @@ -7,7 +7,7 @@ @ExampleFragment } - +

The end

diff --git a/test/testapps/BasicTestApp/RouterTest/Links.cshtml b/test/testapps/BasicTestApp/RouterTest/Links.cshtml index c45a8dfd58..e05cdca072 100644 --- a/test/testapps/BasicTestApp/RouterTest/Links.cshtml +++ b/test/testapps/BasicTestApp/RouterTest/Links.cshtml @@ -13,6 +13,6 @@
  • With parameters
  • -