Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Blazor] Take cascading parameter attribute type into account when supplying cascading values #48554

Merged
merged 14 commits into from
Jun 16, 2023
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ public AuthorizeRouteViewTest()
serviceCollection.AddSingleton<IAuthorizationPolicyProvider, TestAuthorizationPolicyProvider>();
serviceCollection.AddSingleton<IAuthorizationService>(_testAuthorizationService);
serviceCollection.AddSingleton<NavigationManager, TestNavigationManager>();
serviceCollection.AddSingleton<IFormValueSupplier, TestFormValueSupplier>();

var services = serviceCollection.BuildServiceProvider();
_renderer = new TestRenderer(services);
Expand Down
136 changes: 82 additions & 54 deletions src/Components/Components/src/Binding/CascadingModelBinder.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Reflection.Metadata;
using Microsoft.AspNetCore.Components.Binding;
using Microsoft.AspNetCore.Components.Rendering;
Expand All @@ -11,12 +13,13 @@ namespace Microsoft.AspNetCore.Components;
/// <summary>
/// Defines the binding context for data bound from external sources.
/// </summary>
public sealed class CascadingModelBinder : IComponent, ICascadingValueComponent, IDisposable
public sealed class CascadingModelBinder : IComponent, ICascadingValueSupplier, IDisposable
{
private readonly Dictionary<Type, CascadingModelBindingProvider?> _providersByCascadingParameterAttributeType = new();

private RenderHandle _handle;
private ModelBindingContext? _bindingContext;
private bool _hasPendingQueuedRender;
private BindingInfo? _bindingInfo;

/// <summary>
/// The binding context name.
Expand All @@ -40,7 +43,9 @@ public sealed class CascadingModelBinder : IComponent, ICascadingValueComponent,

[Inject] internal NavigationManager Navigation { get; set; } = null!;

[Inject] internal IFormValueSupplier FormValueSupplier { get; set; } = null!;
[Inject] internal IEnumerable<CascadingModelBindingProvider> ModelBindingProviders { get; set; } = Enumerable.Empty<CascadingModelBindingProvider>();

internal ModelBindingContext? BindingContext => _bindingContext;

void IComponent.Attach(RenderHandle renderHandle)
{
Expand Down Expand Up @@ -110,85 +115,108 @@ internal void UpdateBindingInformation(string url)
// BindingContextId = <<base-relative-uri>>((<<existing-query>>&)|?)handler=my-handler
var name = ModelBindingContext.Combine(ParentContext, Name);
var bindingId = string.IsNullOrEmpty(name) ? "" : GenerateBindingContextId(name);
var bindingContextDidChange =
_bindingContext is null ||
!string.Equals(_bindingContext.Name, name, StringComparison.Ordinal) ||
!string.Equals(_bindingContext.BindingContextId, bindingId, StringComparison.Ordinal);

var bindingContext = _bindingContext != null &&
string.Equals(_bindingContext.Name, name, StringComparison.Ordinal) &&
string.Equals(_bindingContext.BindingContextId, bindingId, StringComparison.Ordinal) ?
_bindingContext : new ModelBindingContext(name, bindingId, FormValueSupplier.CanConvertSingleValue);

// It doesn't matter that we don't check IsFixed, since the CascadingValue we are setting up will throw if the app changes.
if (IsFixed && _bindingContext != null && _bindingContext != bindingContext)
if (bindingContextDidChange)
{
// Throw an exception if either the Name or the BindingContextId changed. Once a CascadingModelBinder has been initialized
// as fixed, it can't change it's name nor its BindingContextId. This can happen in several situations:
// * Component ParentContext hierarchy changes.
// * Technically, the component won't be retained in this case and will be destroyed instead.
// * A parent changes Name.
throw new InvalidOperationException($"'{nameof(CascadingModelBinder)}' 'Name' can't change after initialized.");
}
if (IsFixed && _bindingContext is not null)
{
// Throw an exception if either the Name or the BindingContextId changed. Once a CascadingModelBinder has been initialized
// as fixed, it can't change it's name nor its BindingContextId. This can happen in several situations:
// * Component ParentContext hierarchy changes.
// * Technically, the component won't be retained in this case and will be destroyed instead.
// * A parent changes Name.
throw new InvalidOperationException($"'{nameof(CascadingModelBinder)}' 'Name' can't change after initialized.");
}

_bindingContext = bindingContext;
_bindingContext = new ModelBindingContext(name, bindingId, CanBind);
}

string GenerateBindingContextId(string name)
{
var bindingId = Navigation.ToBaseRelativePath(Navigation.GetUriWithQueryParameter("handler", name));
var hashIndex = bindingId.IndexOf('#');
return hashIndex == -1 ? bindingId : new string(bindingId.AsSpan(0, hashIndex));
}

bool CanBind(Type type)
=> ModelBindingProviders.Any(provider => provider.SupportsParameterType(type));
MackinnonBuck marked this conversation as resolved.
Show resolved Hide resolved
}

void IDisposable.Dispose()
bool ICascadingValueSupplier.CanSupplyValue(in CascadingParameterInfo parameterInfo)
=> TryGetProvider(in parameterInfo, out var provider)
&& provider.CanSupplyValue(_bindingContext, parameterInfo);

void ICascadingValueSupplier.Subscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo)
{
Navigation.LocationChanged -= HandleLocationChanged;
// We expect there to always be a provider at this point, because CanSupplyValue must have returned true.
var provider = GetProviderOrThrow(parameterInfo);

if (!provider.AreValuesFixed)
{
provider.Subscribe(subscriber);
}
}

bool ICascadingValueComponent.CanSupplyValue(Type valueType, string? valueName)
void ICascadingValueSupplier.Unsubscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo)
{
var formName = string.IsNullOrEmpty(valueName) ?
(_bindingContext?.Name) :
ModelBindingContext.Combine(_bindingContext, valueName);
// We expect there to always be a provider at this point, because CanSupplyValue must have returned true.
var provider = GetProviderOrThrow(parameterInfo);

if (_bindingInfo != null &&
string.Equals(_bindingInfo.FormName, formName, StringComparison.Ordinal) &&
_bindingInfo.ValueType.Equals(valueType))
if (!provider.AreValuesFixed)
{
// We already bound the value, but some component might have been destroyed and
// re-created. If the type and name of the value that we bound are the same,
// we can provide the value that we bound.
return true;
provider.Unsubscribe(subscriber);
}
}

// Can't supply the value if this context is for a form with a different name.
if (FormValueSupplier.CanBind(formName!, valueType))
{
var bindingSucceeded = FormValueSupplier.TryBind(formName!, valueType, out var boundValue);
_bindingInfo = new BindingInfo(formName, valueType, bindingSucceeded, boundValue);
if (!bindingSucceeded)
{
// Report errors
}
object? ICascadingValueSupplier.GetCurrentValue(in CascadingParameterInfo parameterInfo)
=> TryGetProvider(in parameterInfo, out var provider)
? provider.GetCurrentValue(_bindingContext, parameterInfo)
: null;

return true;
private CascadingModelBindingProvider GetProviderOrThrow(in CascadingParameterInfo parameterInfo)
{
if (!TryGetProvider(parameterInfo, out var provider))
{
throw new InvalidOperationException($"No model binding provider could be found for parameter '{parameterInfo.PropertyName}'.");
}

return false;
return provider;
}

void ICascadingValueComponent.Subscribe(ComponentState subscriber)
private bool TryGetProvider(in CascadingParameterInfo parameterInfo, [NotNullWhen(true)] out CascadingModelBindingProvider? result)
{
throw new InvalidOperationException("Form values are always fixed.");
}
var attributeType = parameterInfo.Attribute.GetType();

void ICascadingValueComponent.Unsubscribe(ComponentState subscriber)
{
throw new InvalidOperationException("Form values are always fixed.");
}
if (_providersByCascadingParameterAttributeType.TryGetValue(attributeType, out result))
{
return result is not null;
}

object? ICascadingValueComponent.CurrentValue => _bindingInfo == null ?
throw new InvalidOperationException("Tried to access form value before it was bound.") :
_bindingInfo.BoundValue;
// We deliberately cache 'null' results to avoid searching for the same attribute type multiple times.
result = FindProviderForAttributeType(attributeType);
_providersByCascadingParameterAttributeType[attributeType] = result;
return result is not null;

bool ICascadingValueComponent.CurrentValueIsFixed => true;
CascadingModelBindingProvider? FindProviderForAttributeType(Type attributeType)
{
foreach (var provider in ModelBindingProviders)
{
if (provider.SupportsCascadingParameterAttributeType(attributeType))
{
return provider;
}
}

return null;
}
}

private record BindingInfo(string? FormName, Type ValueType, bool BindingResult, object? BoundValue);
void IDisposable.Dispose()
{
Navigation.LocationChanged -= HandleLocationChanged;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.AspNetCore.Components.Rendering;

namespace Microsoft.AspNetCore.Components.Binding;

/// <summary>
/// Provides values that get supplied to cascading parameters with <see cref="CascadingModelBinder"/>.
/// </summary>
public abstract class CascadingModelBindingProvider
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Later on, rather than this being a general DI service with a public API, we might change this into a Renderer API so it's not publicly visible or extensible (except for people implementing a renderer). It potentially leads to a whole bunch of simplifications and reductions in API surface. This is something we discussed before, but I think you're right not to add it to this PR.

I'm totally happy with this PR proceeding as-is, since right now this works and we have other things to focus on for preview 6. But just wanted to raise this so it doesn't seem surprising if I try to move around a bunch of this machinery and possible constrain how much we have to support in preview 7. If you have any concerns about that please let me know!

{
/// <summary>
/// Gets whether values supplied by this instance will not change.
/// </summary>
protected internal abstract bool AreValuesFixed { get; }

/// <summary>
/// Determines whether this instance can provide values for parameters annotated with the specified attribute type.
/// </summary>
/// <param name="attributeType">The attribute type.</param>
/// <returns><c>true</c> if this instance can provide values for parameters annotated with the specified attribute type, otherwise <c>false</c>.</returns>
protected internal abstract bool SupportsCascadingParameterAttributeType(Type attributeType);

/// <summary>
/// Determines whether this instance can provide values to parameters with the specified type.
/// </summary>
/// <param name="parameterType">The parameter type.</param>
/// <returns><c>true</c> if this instance can provide values to parameters with the specified type, otherwise <c>false</c>.</returns>
protected internal abstract bool SupportsParameterType(Type parameterType);

/// <summary>
/// Determines whether this instance can supply a value for the specified parameter.
/// </summary>
/// <param name="bindingContext">The current <see cref="ModelBindingContext"/>.</param>
/// <param name="parameterInfo">The <see cref="CascadingParameterInfo"/> for the component parameter.</param>
/// <returns><c>true</c> if a value can be supplied, otherwise <c>false</c>.</returns>
protected internal abstract bool CanSupplyValue(ModelBindingContext? bindingContext, in CascadingParameterInfo parameterInfo);

/// <summary>
/// Gets the value for the specified parameter.
/// </summary>
/// <param name="bindingContext">The current <see cref="ModelBindingContext"/>.</param>
/// <param name="parameterInfo">The <see cref="CascadingParameterInfo"/> for the component parameter.</param>
/// <returns>The value to supply to the parameter.</returns>
protected internal abstract object? GetCurrentValue(ModelBindingContext? bindingContext, in CascadingParameterInfo parameterInfo);

/// <summary>
/// Subscribes to changes in supplied values, if they can change.
/// </summary>
/// <remarks>
/// This method must be implemented if <see cref="AreValuesFixed"/> is <c>false</c>.
/// </remarks>
/// <param name="subscriber">The <see cref="ComponentState"/> for the subscribing component.</param>
protected internal virtual void Subscribe(ComponentState subscriber)
=> throw new InvalidOperationException(
$"'{nameof(CascadingModelBindingProvider)}' instances that have non-fixed values must provide an implementation for '{nameof(Subscribe)}'.");

/// <summary>
/// Unsubscribes from changes in supplied values, if they can change.
/// </summary>
/// <remarks>
/// This method must be implemented if <see cref="AreValuesFixed"/> is <c>false</c>.
/// </remarks>
/// <param name="subscriber">The <see cref="ComponentState"/> for the unsubscribing component.</param>
protected internal virtual void Unsubscribe(ComponentState subscriber)
=> throw new InvalidOperationException(
$"'{nameof(CascadingModelBindingProvider)}' instances that have non-fixed values must provide an implementation for '{nameof(Unsubscribe)}'.");
}
Loading