diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorIntermediateNodeWriter.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorIntermediateNodeWriter.cs index 99e9ad0d41..35128dafb2 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorIntermediateNodeWriter.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorIntermediateNodeWriter.cs @@ -19,8 +19,6 @@ namespace Microsoft.AspNetCore.Blazor.Razor /// internal class BlazorIntermediateNodeWriter : IntermediateNodeWriter { - private const string builderVarName = "builder"; - // Per the HTML spec, the following elements are inherently self-closing // For example, is the same as (and therefore it cannot contain descendants) private static HashSet htmlVoidElementsLookup @@ -77,7 +75,7 @@ namespace Microsoft.AspNetCore.Blazor.Razor { if (node.Children[i] is IntermediateToken token && token.IsCSharp) { - _scopeStack.IncrementCurrentScopeChildCount(); + _scopeStack.IncrementCurrentScopeChildCount(context); context.CodeWriter.Write(token.Content); } else @@ -121,9 +119,9 @@ namespace Microsoft.AspNetCore.Blazor.Razor // Since we're not in the middle of writing an element, this must evaluate as some // text to display - _scopeStack.IncrementCurrentScopeChildCount(); + _scopeStack.IncrementCurrentScopeChildCount(context); context.CodeWriter - .WriteStartMethodInvocation($"{builderVarName}.{nameof(RenderTreeBuilder.AddContent)}") + .WriteStartMethodInvocation($"{_scopeStack.BuilderVarName}.{nameof(RenderTreeBuilder.AddContent)}") .Write((_sourceSequence++).ToString()) .WriteParameterSeparator(); @@ -211,9 +209,9 @@ namespace Microsoft.AspNetCore.Blazor.Razor case HtmlTokenType.Character: { // Text node - _scopeStack.IncrementCurrentScopeChildCount(); + _scopeStack.IncrementCurrentScopeChildCount(context); codeWriter - .WriteStartMethodInvocation($"{builderVarName}.{nameof(RenderTreeBuilder.AddContent)}") + .WriteStartMethodInvocation($"{_scopeStack.BuilderVarName}.{nameof(RenderTreeBuilder.AddContent)}") .Write((_sourceSequence++).ToString()) .WriteParameterSeparator() .WriteStringLiteral(nextToken.Data) @@ -230,18 +228,18 @@ namespace Microsoft.AspNetCore.Blazor.Razor if (nextToken.Type == HtmlTokenType.StartTag) { - _scopeStack.IncrementCurrentScopeChildCount(); + _scopeStack.IncrementCurrentScopeChildCount(context); if (isComponent) { codeWriter - .WriteStartMethodInvocation($"{builderVarName}.{nameof(RenderTreeBuilder.OpenComponent)}<{componentTypeName}>") + .WriteStartMethodInvocation($"{_scopeStack.BuilderVarName}.{nameof(RenderTreeBuilder.OpenComponent)}<{componentTypeName}>") .Write((_sourceSequence++).ToString()) .WriteEndMethodInvocation(); } else { codeWriter - .WriteStartMethodInvocation($"{builderVarName}.{nameof(RenderTreeBuilder.OpenElement)}") + .WriteStartMethodInvocation($"{_scopeStack.BuilderVarName}.{nameof(RenderTreeBuilder.OpenElement)}") .Write((_sourceSequence++).ToString()) .WriteParameterSeparator() .WriteStringLiteral(nextTag.Data) @@ -267,7 +265,7 @@ namespace Microsoft.AspNetCore.Blazor.Razor foreach (var token in _currentElementAttributeTokens) { codeWriter - .WriteStartMethodInvocation($"{builderVarName}.{nameof(RenderTreeBuilder.AddAttribute)}") + .WriteStartMethodInvocation($"{_scopeStack.BuilderVarName}.{nameof(RenderTreeBuilder.AddAttribute)}") .Write((_sourceSequence++).ToString()) .WriteParameterSeparator() .Write(token.AttributeValue.Content) @@ -286,6 +284,7 @@ namespace Microsoft.AspNetCore.Blazor.Razor || (!isComponent && htmlVoidElementsLookup.Contains(nextTag.Data))) { _scopeStack.CloseScope( + context: context, tagName: isComponent ? tagNameOriginalCase : nextTag.Data, isComponent: isComponent, source: CalculateSourcePosition(node.Source, nextToken.Position)); @@ -293,7 +292,7 @@ namespace Microsoft.AspNetCore.Blazor.Razor ? nameof(RenderTreeBuilder.CloseComponent) : nameof(RenderTreeBuilder.CloseElement); codeWriter - .WriteStartMethodInvocation($"{builderVarName}.{closeMethodName}") + .WriteStartMethodInvocation($"{_scopeStack.BuilderVarName}.{closeMethodName}") .WriteEndMethodInvocation(); } break; @@ -372,15 +371,20 @@ namespace Microsoft.AspNetCore.Blazor.Razor } private void WriteAttribute(CodeWriter codeWriter, string key, object value) + { + BeginWriteAttribute(codeWriter, key); + WriteAttributeValue(codeWriter, value); + codeWriter.WriteEndMethodInvocation(); + } + + public void BeginWriteAttribute(CodeWriter codeWriter, string key) { codeWriter - .WriteStartMethodInvocation($"{builderVarName}.{nameof(RenderTreeBuilder.AddAttribute)}") + .WriteStartMethodInvocation($"{_scopeStack.BuilderVarName}.{nameof(RenderTreeBuilder.AddAttribute)}") .Write((_sourceSequence++).ToString()) .WriteParameterSeparator() .WriteStringLiteral(key) .WriteParameterSeparator(); - WriteAttributeValue(codeWriter, value); - codeWriter.WriteEndMethodInvocation(); } public override void WriteUsingDirective(CodeRenderingContext context, UsingDirectiveIntermediateNode node) diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/RenderTreeBuilder.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/RenderTreeBuilder.cs index 40dd24f7f9..00742c7f34 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/RenderTreeBuilder.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/RenderTreeBuilder.cs @@ -22,5 +22,7 @@ namespace Microsoft.AspNetCore.Blazor.Razor public static readonly string Clear = nameof(Clear); public static readonly string GetFrames = nameof(GetFrames); + + public static readonly string ChildContent = nameof(ChildContent); } } diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ScopeStack.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ScopeStack.cs index abc543a36c..f61ffac0ed 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ScopeStack.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ScopeStack.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Language.CodeGeneration; using System; using System.Collections.Generic; @@ -14,14 +15,19 @@ namespace Microsoft.AspNetCore.Blazor.Razor /// internal class ScopeStack { + private const string _renderFragmentTypeName = "Microsoft.AspNetCore.Blazor.RenderTree.RenderFragment"; + private readonly Stack _stack = new Stack(); + private int _builderVarNumber = 1; + + public string BuilderVarName { get; private set; } = "builder"; public void OpenScope(string tagName, bool isComponent) { _stack.Push(new ScopeEntry(tagName, isComponent)); } - public void CloseScope(string tagName, bool isComponent, SourceSpan? source) + public void CloseScope(CodeRenderingContext context, string tagName, bool isComponent, SourceSpan? source) { if (_stack.Count == 0) { @@ -29,26 +35,59 @@ namespace Microsoft.AspNetCore.Blazor.Razor $"Unexpected closing tag '{tagName}' with no matching start tag.", source); } - var expected = _stack.Pop(); - if (!tagName.Equals(expected.TagName, StringComparison.Ordinal)) + var currentScope = _stack.Pop(); + if (!tagName.Equals(currentScope.TagName, StringComparison.Ordinal)) { throw new RazorCompilerException( - $"Mismatching closing tag. Found '{tagName}' but expected '{expected.TagName}'.", source); + $"Mismatching closing tag. Found '{tagName}' but expected '{currentScope.TagName}'.", source); } // Note: there's no unit test to cover the following, because there's no known way of - // triggering it from user code (i.e., Razor source code). But the test is here anyway + // triggering it from user code (i.e., Razor source code). But the check is here anyway // just in case one day it turns out there is some way of causing this error. - if (isComponent != expected.IsComponent) + if (isComponent != currentScope.IsComponent) { throw new RazorCompilerException( - $"Mismatching closing tag. Found '{tagName}' of type '{(isComponent ? "component" : "element")}' but expected type '{(expected.IsComponent ? "component" : "element")}'."); + $"Mismatching closing tag. Found '{tagName}' of type '{(isComponent ? "component" : "element")}' but expected type '{(currentScope.IsComponent ? "component" : "element")}'.", source); + } + + // When closing the scope for a component with children, it's time to close the lambda + if (currentScope.LambdaScope != null) + { + currentScope.LambdaScope.Dispose(); + context.CodeWriter.Write(")"); + context.CodeWriter.WriteEndMethodInvocation(); + OffsetBuilderVarNumber(-1); } } - public void IncrementCurrentScopeChildCount() + public void IncrementCurrentScopeChildCount(CodeRenderingContext context) { + if (_stack.Count > 0) + { + var currentScope = _stack.Peek(); + if (currentScope.IsComponent && currentScope.ChildCount == 0) + { + // When we're about to insert the first child into a component, + // it's time to open a new lambda + var blazorNodeWriter = (BlazorIntermediateNodeWriter)context.NodeWriter; + blazorNodeWriter.BeginWriteAttribute(context.CodeWriter, RenderTreeBuilder.ChildContent); + OffsetBuilderVarNumber(1); + context.CodeWriter.Write($"({_renderFragmentTypeName})("); + currentScope.LambdaScope = context.CodeWriter.BuildLambda(BuilderVarName); + } + + currentScope.ChildCount++; + } + } + + private void OffsetBuilderVarNumber(int delta) + { + _builderVarNumber += delta; + BuilderVarName = _builderVarNumber == 1 + ? "builder" + : $"builder{_builderVarNumber}"; } private class ScopeEntry @@ -56,6 +95,7 @@ namespace Microsoft.AspNetCore.Blazor.Razor public readonly string TagName; public readonly bool IsComponent; public int ChildCount; + public IDisposable LambdaScope; public ScopeEntry(string tagName, bool isComponent) { diff --git a/src/Microsoft.AspNetCore.Blazor/RenderTree/RenderTreeBuilder.cs b/src/Microsoft.AspNetCore.Blazor/RenderTree/RenderTreeBuilder.cs index 4b5f7e6bb2..e4936a1fd0 100644 --- a/src/Microsoft.AspNetCore.Blazor/RenderTree/RenderTreeBuilder.cs +++ b/src/Microsoft.AspNetCore.Blazor/RenderTree/RenderTreeBuilder.cs @@ -23,6 +23,11 @@ namespace Microsoft.AspNetCore.Blazor.RenderTree private readonly Stack _openElementIndices = new Stack(); private RenderTreeFrameType? _lastNonAttributeFrameType; + /// + /// The reserved parameter name used for supplying child content. + /// + public static string ChildContent = nameof(ChildContent); + /// /// Constructs an instance of . /// diff --git a/test/Microsoft.AspNetCore.Blazor.Build.Test/RazorCompilerTest.cs b/test/Microsoft.AspNetCore.Blazor.Build.Test/RazorCompilerTest.cs index 9a91cb5bef..ef5745aa00 100644 --- a/test/Microsoft.AspNetCore.Blazor.Build.Test/RazorCompilerTest.cs +++ b/test/Microsoft.AspNetCore.Blazor.Build.Test/RazorCompilerTest.cs @@ -420,13 +420,53 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test $""); var frames = GetRenderTree(component); + // Assert: component frames are correct Assert.Collection(frames, - frame => AssertFrame.Component(frame, 6, 0), + frame => AssertFrame.Component(frame, 3, 0), frame => AssertFrame.Attribute(frame, "attr", "abc", 1), - frame => AssertFrame.Text(frame, "Some text", 2), - frame => AssertFrame.Element(frame, "some-child", 3, 3), - frame => AssertFrame.Attribute(frame, "a", "1", 4), - frame => AssertFrame.Text(frame, "Nested text", 5)); + frame => AssertFrame.Attribute(frame, RenderTreeBuilder.ChildContent, 2)); + + // Assert: Captured ChildContent frames are correct + var childFrames = GetFrames((RenderFragment)frames[2].AttributeValue); + Assert.Collection(childFrames, + frame => AssertFrame.Text(frame, "Some text", 3), + frame => AssertFrame.Element(frame, "some-child", 3, 4), + frame => AssertFrame.Attribute(frame, "a", "1", 5), + frame => AssertFrame.Text(frame, "Nested text", 6)); + } + + [Fact] + public void CanNestComponentChildContent() + { + // Arrange/Act + var testComponentTypeName = typeof(TestComponent).FullName.Replace('+', '.'); + var component = CompileToComponent( + $"" + + $"" + + $"Some text" + + $"" + + $""); + var frames = GetRenderTree(component); + + // Assert: outer component frames are correct + Assert.Collection(frames, + frame => AssertFrame.Component(frame, 2, 0), + frame => AssertFrame.Attribute(frame, RenderTreeBuilder.ChildContent, 1)); + + // Assert: first level of ChildContent is correct + // Note that we don't really need the sequence numbers to continue on from the + // sequence numbers at the parent level. All that really matters is that they are + // correct relative to each other (i.e., incrementing) within the nesting level. + // As an implementation detail, it happens that they do follow on from the parent + // level, but we could change that part of the implementation if we wanted. + var innerFrames = GetFrames((RenderFragment)frames[1].AttributeValue).ToArray(); + Assert.Collection(innerFrames, + frame => AssertFrame.Component(frame, 2, 2), + frame => AssertFrame.Attribute(frame, RenderTreeBuilder.ChildContent, 3)); + + // Assert: second level of ChildContent is correct + Assert.Collection(GetFrames((RenderFragment)innerFrames[1].AttributeValue), + frame => AssertFrame.Text(frame, "Some text", 4)); } [Fact] @@ -651,6 +691,13 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test } } + private ArrayRange GetFrames(RenderFragment fragment) + { + var builder = new RenderTreeBuilder(new TestRenderer()); + fragment(builder); + return builder.GetFrames(); + } + private class CompileToCSharpResult { public string Code { get; set; }