Skip to content

Commit ca9de74

Browse files
Components that accept bind-Something can request SomethingExpression (#213)
* In binding to components, automatically supply FooExpression when requested * Fix tests * CR feedback
1 parent 3f822a0 commit ca9de74

File tree

19 files changed

+588
-5
lines changed

19 files changed

+588
-5
lines changed

src/Razor/src/Microsoft.AspNetCore.Razor.Language/Components/BlazorMetadata.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ public static class Bind
2424
public readonly static string ValueAttribute = "Blazor.Bind.ValueAttribute";
2525

2626
public readonly static string ChangeAttribute = "Blazor.Bind.ChangeAttribute";
27+
28+
public readonly static string ExpressionAttribute = "Blazor.Bind.ExpressionAttribute";
2729
}
2830

2931
public static class ChildContent

src/Razor/src/Microsoft.AspNetCore.Razor.Language/Components/ComponentBindLoweringPass.cs

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -148,11 +148,11 @@ private IntermediateNode[] RewriteUsage(IntermediateNode parent, TagHelperProper
148148
{
149149
// Bind works similarly to a macro, it always expands to code that the user could have written.
150150
//
151-
// For the nodes that are related to the bind-attribute rewrite them to look like a pair of
151+
// For the nodes that are related to the bind-attribute rewrite them to look like a set of
152152
// 'normal' HTML attributes similar to the following transformation.
153153
//
154154
// Input: <MyComponent bind-Value="@currentCount" />
155-
// Output: <MyComponent Value ="...<get the value>..." ValueChanged ="... <set the value>..." />
155+
// Output: <MyComponent Value ="...<get the value>..." ValueChanged ="... <set the value>..." ValueExpression ="() => ...<get the value>..." />
156156
//
157157
// This means that the expression that appears inside of 'bind' must be an LValue or else
158158
// there will be errors. In general the errors that come from C# in this case are good enough
@@ -171,8 +171,10 @@ private IntermediateNode[] RewriteUsage(IntermediateNode parent, TagHelperProper
171171
node.AttributeName,
172172
out var valueAttributeName,
173173
out var changeAttributeName,
174+
out var expressionAttributeName,
174175
out var valueAttribute,
175-
out var changeAttribute))
176+
out var changeAttribute,
177+
out var expressionAttribute))
176178
{
177179
// Skip anything we can't understand. It's important that we don't crash, that will bring down
178180
// the build.
@@ -340,7 +342,32 @@ private IntermediateNode[] RewriteUsage(IntermediateNode parent, TagHelperProper
340342
changeNode.Children[0].Children.Add(changeExpressionTokens[i]);
341343
}
342344

343-
return new[] { valueNode, changeNode };
345+
// Finally, also emit a node for the "Expression" attribute, but only if the target
346+
// component is defined to accept one
347+
ComponentAttributeIntermediateNode expressionNode = null;
348+
if (expressionAttribute != null)
349+
{
350+
expressionNode = new ComponentAttributeIntermediateNode(node)
351+
{
352+
AttributeName = expressionAttributeName,
353+
BoundAttribute = expressionAttribute,
354+
PropertyName = expressionAttribute.GetPropertyName(),
355+
TagHelper = node.TagHelper,
356+
TypeName = expressionAttribute.IsWeaklyTyped() ? null : expressionAttribute.TypeName,
357+
};
358+
359+
expressionNode.Children.Clear();
360+
expressionNode.Children.Add(new CSharpExpressionIntermediateNode());
361+
expressionNode.Children[0].Children.Add(new IntermediateToken()
362+
{
363+
Content = $"() => {original.Content}",
364+
Kind = TokenKind.CSharp
365+
});
366+
}
367+
368+
return expressionNode == null
369+
? new[] { valueNode, changeNode }
370+
: new[] { valueNode, changeNode, expressionNode };
344371
}
345372
}
346373

@@ -394,11 +421,15 @@ private bool TryComputeAttributeNames(
394421
string attributeName,
395422
out string valueAttributeName,
396423
out string changeAttributeName,
424+
out string expressionAttributeName,
397425
out BoundAttributeDescriptor valueAttribute,
398-
out BoundAttributeDescriptor changeAttribute)
426+
out BoundAttributeDescriptor changeAttribute,
427+
out BoundAttributeDescriptor expressionAttribute)
399428
{
400429
valueAttribute = null;
401430
changeAttribute = null;
431+
expressionAttribute = null;
432+
expressionAttributeName = null;
402433

403434
// Even though some of our 'bind' tag helpers specify the attribute names, they
404435
// should still satisfy one of the valid syntaxes.
@@ -415,6 +446,7 @@ private bool TryComputeAttributeNames(
415446
// We expect 1 bind tag helper per-node.
416447
valueAttributeName = node.TagHelper.GetValueAttributeName() ?? valueAttributeName;
417448
changeAttributeName = node.TagHelper.GetChangeAttributeName() ?? changeAttributeName;
449+
expressionAttributeName = node.TagHelper.GetExpressionAttributeName() ?? expressionAttributeName;
418450

419451
// We expect 0-1 components per-node.
420452
var componentTagHelper = (parent as ComponentIntermediateNode)?.Component;
@@ -437,6 +469,12 @@ private bool TryComputeAttributeNames(
437469
changeAttributeName = valueAttributeName + "Changed";
438470
}
439471

472+
// Likewise for the expression attribute
473+
if (expressionAttributeName == null)
474+
{
475+
expressionAttributeName = valueAttributeName + "Expression";
476+
}
477+
440478
for (var i = 0; i < componentTagHelper.BoundAttributes.Count; i++)
441479
{
442480
var attribute = componentTagHelper.BoundAttributes[i];
@@ -450,6 +488,11 @@ private bool TryComputeAttributeNames(
450488
{
451489
changeAttribute = attribute;
452490
}
491+
492+
if (string.Equals(expressionAttributeName, attribute.Name))
493+
{
494+
expressionAttribute = attribute;
495+
}
453496
}
454497

455498
return true;

src/Razor/src/Microsoft.AspNetCore.Razor.Language/Components/TagHelperDescriptorExtensions.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,17 @@ public static string GetChangeAttributeName(this TagHelperDescriptor tagHelper)
9595
return result;
9696
}
9797

98+
public static string GetExpressionAttributeName(this TagHelperDescriptor tagHelper)
99+
{
100+
if (tagHelper == null)
101+
{
102+
throw new ArgumentNullException(nameof(tagHelper));
103+
}
104+
105+
tagHelper.Metadata.TryGetValue(BlazorMetadata.Bind.ExpressionAttribute, out var result);
106+
return result;
107+
}
108+
98109
public static bool IsChildContentTagHelper(this TagHelperDescriptor tagHelper)
99110
{
100111
if (tagHelper == null)

src/Razor/src/Microsoft.CodeAnalysis.Razor/BindTagHelperDescriptorProvider.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,8 @@ private List<TagHelperDescriptor> CreateComponentBindTagHelpers(ICollection<TagH
351351
//
352352
// The easiest way to figure this out without a lot of backtracking is to look for `FooChanged` and then
353353
// try to find a matching "Foo".
354+
//
355+
// We also look for a corresponding FooExpression attribute, though its presence is optional.
354356
for (var i = 0; i < tagHelper.BoundAttributes.Count; i++)
355357
{
356358
var changeAttribute = tagHelper.BoundAttributes[i];
@@ -360,12 +362,25 @@ private List<TagHelperDescriptor> CreateComponentBindTagHelpers(ICollection<TagH
360362
}
361363

362364
BoundAttributeDescriptor valueAttribute = null;
365+
BoundAttributeDescriptor expressionAttribute = null;
363366
var valueAttributeName = changeAttribute.Name.Substring(0, changeAttribute.Name.Length - "Changed".Length);
367+
var expressionAttributeName = valueAttributeName + "Expression";
364368
for (var j = 0; j < tagHelper.BoundAttributes.Count; j++)
365369
{
366370
if (tagHelper.BoundAttributes[j].Name == valueAttributeName && !tagHelper.BoundAttributes[j].IsDelegateProperty())
367371
{
368372
valueAttribute = tagHelper.BoundAttributes[j];
373+
374+
}
375+
376+
if (tagHelper.BoundAttributes[j].Name == expressionAttributeName)
377+
{
378+
expressionAttribute = tagHelper.BoundAttributes[j];
379+
}
380+
381+
if (valueAttribute != null && expressionAttribute != null)
382+
{
383+
// We found both, so we can stop looking now
369384
break;
370385
}
371386
}
@@ -388,6 +403,11 @@ private List<TagHelperDescriptor> CreateComponentBindTagHelpers(ICollection<TagH
388403
builder.Metadata[BlazorMetadata.Bind.ValueAttribute] = valueAttribute.Name;
389404
builder.Metadata[BlazorMetadata.Bind.ChangeAttribute] = changeAttribute.Name;
390405

406+
if (expressionAttribute != null)
407+
{
408+
builder.Metadata[BlazorMetadata.Bind.ExpressionAttribute] = expressionAttribute.Name;
409+
}
410+
391411
// WTE has a bug 15.7p1 where a Tag Helper without a display-name that looks like
392412
// a C# property will crash trying to create the toolips.
393413
builder.SetTypeName(tagHelper.GetTypeName());

src/Razor/test/Microsoft.AspNetCore.Razor.Language.Test/IntegrationTests/ComponentCodeGenerationTestBase.cs

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,122 @@ void IComponent.SetParameters(ParameterCollection parameters)
428428
CompileToAssembly(generated);
429429
}
430430

431+
[Fact]
432+
public void BindToComponent_SpecifiesValueAndExpression()
433+
{
434+
// Arrange
435+
AdditionalSyntaxTrees.Add(Parse(@"
436+
using System;
437+
using System.Linq.Expressions;
438+
using Microsoft.AspNetCore.Components;
439+
440+
namespace Test
441+
{
442+
public class MyComponent : ComponentBase
443+
{
444+
[Parameter]
445+
int Value { get; set; }
446+
447+
[Parameter]
448+
Action<int> ValueChanged { get; set; }
449+
450+
[Parameter]
451+
Expression<Func<int>> ValueExpression { get; set; }
452+
}
453+
}"));
454+
455+
// Act
456+
var generated = CompileToCSharp(@"
457+
@addTagHelper *, TestAssembly
458+
<MyComponent bind-Value=""ParentValue"" />
459+
@functions {
460+
public int ParentValue { get; set; } = 42;
461+
}");
462+
463+
// Assert
464+
AssertDocumentNodeMatchesBaseline(generated.CodeDocument);
465+
AssertCSharpDocumentMatchesBaseline(generated.CodeDocument);
466+
CompileToAssembly(generated);
467+
}
468+
469+
[Fact]
470+
public void BindToComponent_SpecifiesValueAndExpression_TypeChecked()
471+
{
472+
// Arrange
473+
AdditionalSyntaxTrees.Add(Parse(@"
474+
using System;
475+
using System.Linq.Expressions;
476+
using Microsoft.AspNetCore.Components;
477+
478+
namespace Test
479+
{
480+
public class MyComponent : ComponentBase
481+
{
482+
[Parameter]
483+
int Value { get; set; }
484+
485+
[Parameter]
486+
Action<int> ValueChanged { get; set; }
487+
488+
[Parameter]
489+
Expression<Func<string>> ValueExpression { get; set; }
490+
}
491+
}"));
492+
493+
// Act
494+
var generated = CompileToCSharp(@"
495+
@addTagHelper *, TestAssembly
496+
<MyComponent bind-Value=""ParentValue"" />
497+
@functions {
498+
public int ParentValue { get; set; } = 42;
499+
}");
500+
501+
var assembly = CompileToAssembly(generated, throwOnFailure: false);
502+
// This has some errors
503+
Assert.Collection(
504+
assembly.Diagnostics.OrderBy(d => d.Id),
505+
d => Assert.Equal("CS0029", d.Id),
506+
d => Assert.Equal("CS1662", d.Id));
507+
}
508+
509+
[Fact]
510+
public void BindToComponent_SpecifiesValueAndExpression_Generic()
511+
{
512+
// Arrange
513+
AdditionalSyntaxTrees.Add(Parse(@"
514+
using System;
515+
using System.Linq.Expressions;
516+
using Microsoft.AspNetCore.Components;
517+
518+
namespace Test
519+
{
520+
public class MyComponent<T> : ComponentBase
521+
{
522+
[Parameter]
523+
T SomeParam { get; set; }
524+
525+
[Parameter]
526+
Action<T> SomeParamChanged { get; set; }
527+
528+
[Parameter]
529+
Expression<Func<T>> SomeParamExpression { get; set; }
530+
}
531+
}"));
532+
533+
// Act
534+
var generated = CompileToCSharp(@"
535+
@addTagHelper *, TestAssembly
536+
<MyComponent bind-SomeParam=""ParentValue"" />
537+
@functions {
538+
public DateTime ParentValue { get; set; } = DateTime.Now;
539+
}");
540+
541+
// Assert
542+
AssertDocumentNodeMatchesBaseline(generated.CodeDocument);
543+
AssertCSharpDocumentMatchesBaseline(generated.CodeDocument);
544+
CompileToAssembly(generated);
545+
}
546+
431547
[Fact]
432548
public void BindToElement_WritesAttributes()
433549
{
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// <auto-generated/>
2+
#pragma warning disable 1591
3+
namespace Test
4+
{
5+
#line hidden
6+
using System;
7+
using System.Collections.Generic;
8+
using System.Linq;
9+
using System.Threading.Tasks;
10+
using Microsoft.AspNetCore.Components;
11+
public class TestComponent : Microsoft.AspNetCore.Components.ComponentBase
12+
{
13+
#pragma warning disable 219
14+
private void __RazorDirectiveTokenHelpers__() {
15+
((System.Action)(() => {
16+
#line 1 "x:\dir\subdir\Test\TestComponent.cshtml"
17+
global::System.Object __typeHelper = "*, TestAssembly";
18+
19+
#line default
20+
#line hidden
21+
}
22+
))();
23+
}
24+
#pragma warning restore 219
25+
#pragma warning disable 0414
26+
private static System.Object __o = null;
27+
#pragma warning restore 0414
28+
#pragma warning disable 1998
29+
protected override void BuildRenderTree(Microsoft.AspNetCore.Components.RenderTree.RenderTreeBuilder builder)
30+
{
31+
base.BuildRenderTree(builder);
32+
__o = Microsoft.AspNetCore.Components.RuntimeHelpers.TypeCheck<System.Int32>(Microsoft.AspNetCore.Components.BindMethods.GetValue(
33+
#line 2 "x:\dir\subdir\Test\TestComponent.cshtml"
34+
ParentValue
35+
36+
#line default
37+
#line hidden
38+
));
39+
__o = new System.Action<System.Int32>(
40+
__value => ParentValue = __value);
41+
__o = Microsoft.AspNetCore.Components.RuntimeHelpers.TypeCheck<System.Linq.Expressions.Expression<System.Func<System.Int32>>>(() => ParentValue);
42+
builder.AddAttribute(-1, "ChildContent", (Microsoft.AspNetCore.Components.RenderFragment)((builder2) => {
43+
}
44+
));
45+
}
46+
#pragma warning restore 1998
47+
#line 3 "x:\dir\subdir\Test\TestComponent.cshtml"
48+
49+
public int ParentValue { get; set; } = 42;
50+
51+
#line default
52+
#line hidden
53+
}
54+
}
55+
#pragma warning restore 1591

0 commit comments

Comments
 (0)