Skip to content
This repository was archived by the owner on Feb 25, 2021. It is now read-only.

Cascading parameters #1545

Merged
merged 22 commits into from
Oct 15, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
1d5aee2
Add Provider component
SteveSandersonMS Oct 8, 2018
b57e897
Implement discovery and matching rules for tree parameters
SteveSandersonMS Oct 9, 2018
0ab23ab
Remove artificial component hierarchy unit tests now they are redundant
SteveSandersonMS Oct 9, 2018
4bb3977
Refactor: Have RenderTreeFrame point to the ComponentState instead of…
SteveSandersonMS Oct 9, 2018
56e40d1
Refactor: Add shared code path for updating parameters so there's onl…
SteveSandersonMS Oct 9, 2018
cc78a57
Refactor: Simplify Parameter by making it hold the name/value directly
SteveSandersonMS Oct 9, 2018
2af19a4
Refactor: Wrap ParameterEnumerator logic in extra level of iterator s…
SteveSandersonMS Oct 9, 2018
a3ede31
Extend ParameterEnumerator to list tree parameters too
SteveSandersonMS Oct 9, 2018
8cd0f9c
Include tree parameters in SetParameters calls
SteveSandersonMS Oct 9, 2018
6ad2b65
Refactor: Move parameter change detection logic into separate utility…
SteveSandersonMS Oct 10, 2018
332f9de
Refactor: Move tree parameter tests from RendererTest.cs their own file
SteveSandersonMS Oct 12, 2018
2dc609b
Have Provider re-render consumers when value changes. Unit tests in next
SteveSandersonMS Oct 10, 2018
79d0906
Components that accept tree parameters need to snapshot their direct …
SteveSandersonMS Oct 10, 2018
f8c4d64
Empty commit to reawaken CI
SteveSandersonMS Oct 12, 2018
c57e96f
CR: Make name matching case-insensitive
SteveSandersonMS Oct 12, 2018
c76af8f
Refactor: Rename Provider/TreeParameter to
SteveSandersonMS Oct 12, 2018
8948a8a
Add dedicated [CascadingParameter] attribute. Remove FromTree flag.
SteveSandersonMS Oct 12, 2018
fded30f
CR: CascadingParameterState cleanups
SteveSandersonMS Oct 12, 2018
7fb12a6
CR: Extra unit test
SteveSandersonMS Oct 12, 2018
d5e4c6a
CR: arguments/parameters
SteveSandersonMS Oct 12, 2018
5756ebf
CR: Enumerator improvements
SteveSandersonMS Oct 12, 2018
14d6522
Fix test
SteveSandersonMS Oct 12, 2018
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ public void AddComponent(Type componentType, string domElementSelector)
domElementSelector,
componentId);

component.SetParameters(ParameterCollection.Empty);
RenderRootComponent(componentId);
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ public void AddComponent(Type componentType, string domElementSelector)
componentId);
CaptureAsyncExceptions(attachComponentTask);

component.SetParameters(ParameterCollection.Empty);
RenderRootComponent(componentId);
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// 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
{
/// <summary>
/// Denotes the target member as a cascading component parameter. Its value will be
/// supplied by the closest ancestor <see cref="CascadingValue{T}"/> component that
/// supplies values with a compatible type and name.
/// </summary>
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = false)]
public sealed class CascadingParameterAttribute : Attribute
{
/// <summary>
/// If specified, the parameter value will be supplied by the closest
/// ancestor <see cref="CascadingValue{T}"/> that supplies a value with
/// this name.
///
/// If null, the parameter value will be supplied by the closest ancestor
/// <see cref="CascadingValue{T}"/> that supplies a value with a compatible
/// type.
/// </summary>
public string Name { get; set; }
}
}
128 changes: 128 additions & 0 deletions src/Microsoft.AspNetCore.Blazor/Components/CascadingParameterState.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// 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 Microsoft.AspNetCore.Blazor.Rendering;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Reflection;

namespace Microsoft.AspNetCore.Blazor.Components
{
internal readonly struct CascadingParameterState
{
private readonly static ConcurrentDictionary<Type, ReflectedCascadingParameterInfo[]> _cachedInfos
= new ConcurrentDictionary<Type, ReflectedCascadingParameterInfo[]>();

public string LocalValueName { get; }
public ICascadingValueComponent ValueSupplier { get; }

public CascadingParameterState(string localValueName, ICascadingValueComponent valueSupplier)
{
LocalValueName = localValueName;
ValueSupplier = valueSupplier;
}

public static IReadOnlyList<CascadingParameterState> FindCascadingParameters(ComponentState componentState)
{
var componentType = componentState.Component.GetType();
var infos = GetReflectedCascadingParameterInfos(componentType);

// For components known not to have any cascading parameters, bail out early
if (infos == null)
{
return null;
}

// Now try to find matches for each of the cascading parameters
// Defer instantiation of the result list until we know there's at least one
List<CascadingParameterState> resultStates = null;

var numInfos = infos.Length;
for (var infoIndex = 0; infoIndex < numInfos; infoIndex++)
{
ref var info = ref infos[infoIndex];
var supplier = GetMatchingCascadingValueSupplier(info, componentState);
if (supplier != null)
{
if (resultStates == null)
{
// Although not all parameters might be matched, we know the maximum number
resultStates = new List<CascadingParameterState>(infos.Length - infoIndex);
}

resultStates.Add(new CascadingParameterState(info.ConsumerValueName, supplier));
}
}

return resultStates;
}

private static ICascadingValueComponent GetMatchingCascadingValueSupplier(in ReflectedCascadingParameterInfo info, ComponentState componentState)
{
do
{
if (componentState.Component is ICascadingValueComponent candidateSupplier
&& candidateSupplier.CanSupplyValue(info.ValueType, info.SupplierValueName))
{
return candidateSupplier;
}

componentState = componentState.ParentComponentState;
} while (componentState != null);

// No match
return null;
}

private static ReflectedCascadingParameterInfo[] GetReflectedCascadingParameterInfos(Type componentType)
{
if (!_cachedInfos.TryGetValue(componentType, out var infos))
{
infos = CreateReflectedCascadingParameterInfos(componentType);
_cachedInfos[componentType] = infos;
}

return infos;
}

private static ReflectedCascadingParameterInfo[] CreateReflectedCascadingParameterInfos(Type componentType)
{
List<ReflectedCascadingParameterInfo> result = null;
var candidateProps = ParameterCollectionExtensions.GetCandidateBindableProperties(componentType);
foreach (var prop in candidateProps)
{
var attribute = prop.GetCustomAttribute<CascadingParameterAttribute>();
if (attribute != null)
{
if (result == null)
{
result = new List<ReflectedCascadingParameterInfo>();
}

result.Add(new ReflectedCascadingParameterInfo(
prop.Name,
prop.PropertyType,
attribute.Name));
}
}

return result?.ToArray();
}

readonly struct ReflectedCascadingParameterInfo
{
public string ConsumerValueName { get; }
public string SupplierValueName { get; }
public Type ValueType { get; }

public ReflectedCascadingParameterInfo(
string consumerValueName, Type valueType, string supplierValueName)
{
ConsumerValueName = consumerValueName;
SupplierValueName = supplierValueName;
ValueType = valueType;
}
}
}
}
149 changes: 149 additions & 0 deletions src/Microsoft.AspNetCore.Blazor/Components/CascadingValue.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
// 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 Microsoft.AspNetCore.Blazor.Rendering;
using Microsoft.AspNetCore.Blazor.RenderTree;
using System;
using System.Collections.Generic;

namespace Microsoft.AspNetCore.Blazor.Components
{
/// <summary>
/// A component that provides a cascading value to all descendant components.
/// </summary>
public class CascadingValue<T> : ICascadingValueComponent, IComponent
{
private RenderHandle _renderHandle;
private HashSet<ComponentState> _subscribers; // Lazily instantiated

/// <summary>
/// The content to which the value should be provided.
/// </summary>
[Parameter] private RenderFragment ChildContent { get; set; }

/// <summary>
/// The value to be provided.
/// </summary>
[Parameter] private T Value { get; set; }

/// <summary>
/// Optionally gives a name to the provided value. Descendant components
/// will be able to receive the value by specifying this name.
///
/// If no name is specified, then descendant components will receive the
/// value based the type of value they are requesting.
/// </summary>
[Parameter] private string Name { get; set; }

object ICascadingValueComponent.CurrentValue => Value;

/// <inheritdoc />
public void Init(RenderHandle renderHandle)
{
_renderHandle = renderHandle;
}

/// <inheritdoc />
public void SetParameters(ParameterCollection parameters)
{
// Implementing the parameter binding manually, instead of just calling
// parameters.AssignToProperties(this), is just a very slight perf optimization
// and makes it simpler impose rules about the params being required or not.

var hasSuppliedValue = false;
var previousValue = Value;
Value = default;
ChildContent = null;
Name = null;

foreach (var parameter in parameters)
{
if (parameter.Name.Equals(nameof(Value), StringComparison.OrdinalIgnoreCase))
{
Value = (T)parameter.Value;
hasSuppliedValue = true;
}
else if (parameter.Name.Equals(nameof(ChildContent), StringComparison.OrdinalIgnoreCase))
{
ChildContent = (RenderFragment)parameter.Value;
}
else if (parameter.Name.Equals(nameof(Name), StringComparison.OrdinalIgnoreCase))
{
Name = (string)parameter.Value;
if (string.IsNullOrEmpty(Name))
{
throw new ArgumentException($"The parameter '{nameof(Name)}' for component '{nameof(CascadingValue<T>)}' does not allow null or empty values.");
}
}
else
{
throw new ArgumentException($"The component '{nameof(CascadingValue<T>)}' does not accept a parameter with the name '{parameter.Name}'.");
}
}

// It's OK for the value to be null, but some "Value" param must be suppled
// because it serves no useful purpose to have a <CascadingValue> otherwise.
if (!hasSuppliedValue)
{
throw new ArgumentException($"Missing required parameter '{nameof(Value)}' for component '{nameof(Parameter)}'.");
}

// Rendering is most efficient when things are queued from rootmost to leafmost.
// Given a components A (parent) -> B (child), you want them to be queued in order
// [A, B] because if you had [B, A], then the render for A might change B's params
// making it render again, so you'd render [B, A, B], which is wasteful.
// At some point we might consider making the render queue actually enforce this
// ordering during insertion.
//
// For the CascadingValue component, this observation is why it's important to render
// ourself before notifying subscribers (which can be grandchildren or deeper).
// If we rerendered subscribers first, then our own subsequent render might cause an
// further update that makes those nested subscribers get rendered twice.
_renderHandle.Render(Render);

if (_subscribers != null && ChangeDetection.MayHaveChanged(previousValue, Value))
{
NotifySubscribers();
}
}

bool ICascadingValueComponent.CanSupplyValue(Type requestedType, string requestedName)
{
if (!requestedType.IsAssignableFrom(typeof(T)))
{
return false;
}

return (requestedName == null && Name == null) // Match on type alone
|| string.Equals(requestedName, Name, StringComparison.OrdinalIgnoreCase); // Also match on name
}

void ICascadingValueComponent.Subscribe(ComponentState subscriber)
{
if (_subscribers == null)
{
_subscribers = new HashSet<ComponentState>();
}

_subscribers.Add(subscriber);
}

void ICascadingValueComponent.Unsubscribe(ComponentState subscriber)
{
_subscribers.Remove(subscriber);
}

private void NotifySubscribers()
{
foreach (var subscriber in _subscribers)
{
subscriber.NotifyCascadingValueChanged();
}
}

private void Render(RenderTreeBuilder builder)
{
builder.AddContent(0, ChildContent);
}
}
}
45 changes: 45 additions & 0 deletions src/Microsoft.AspNetCore.Blazor/Components/ChangeDetection.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// 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
{
internal class ChangeDetection
{
public static bool MayHaveChanged<T1, T2>(T1 oldValue, T2 newValue)
Copy link
Member

@rynowak rynowak Oct 14, 2018

Choose a reason for hiding this comment

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

Seeing that the only usage of this in this commit is for boxed values, I'm curious to see how this will get used.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, I considered doing this without the generics, but TBH I didn't have any reason not to use them. Even if we can't benefit from having the types statically right now, maybe we will later. AFAICT it's not going to make any difference either way right now.

I know it means JITting different versions of the method for each combination of value types, but that's a limited fixed cost overall for any given app so I didn't think it was a drawback to worry about.

Let me know if you have concerns as I'm happy to change this. For now will proceed as-is.

{
var oldIsNotNull = oldValue != null;
var newIsNotNull = newValue != null;
if (oldIsNotNull != newIsNotNull)
Copy link
Member

Choose a reason for hiding this comment

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

Seeing code like this really makes me wish we had constexpr utilities like IsValueType(T1) that could be optimized statically.

{
return true; // One's null and the other isn't, so different
}
else if (oldIsNotNull) // i.e., both are not null (considering previous check)
{
var oldValueType = oldValue.GetType();
var newValueType = newValue.GetType();
if (oldValueType != newValueType // Definitely different
|| !IsKnownImmutableType(oldValueType) // Maybe different
|| !oldValue.Equals(newValue)) // Somebody says they are different
Copy link
Member

Choose a reason for hiding this comment

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

Is there really any value in this check about known immutable types? Is this here to avoid boxing?

Copy link
Member Author

Choose a reason for hiding this comment

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

It's because conceptually, the values we're comparing aren't really oldValue and newValue. The values we want to compare are the state reachable transitively from oldValue at some unknown point in the past versus the state reachable transitively from newValue now. The purpose of the check is to determine whether there's any chance that the deeply-reachable state is different now versus before.

If this was public API, we'd have to be much clearer about the semantics of this check and do some naming review to try to communicate this better. We could still change the name, but since it's internal, it's not as urgent.

With these semantics in mind, the only case where we can return true is for types known to be immutable. For, say, object, it's not enough for oldValue.Equals(newValue), because in most cases oldValue and newValue will be handles to the same object instance but that doesn't tell us anything about whether it's mutated.

It's unfortunate that .NET doesn't have any native concept of (im)mutability. That limits us to hard-coding a set of commonly-used known-immutable types.

{
return true;
}
}

// By now we know either both are null, or they are the same immutable type
// and ThatType::Equals says the two values are equal.
return false;
}

// The contents of this list need to trade off false negatives against computation
// time. So we don't want a huge list of types to check (or would have to move to
// a hashtable lookup, which is differently expensive). It's better not to include
// uncommon types here even if they are known to be immutable.
private static bool IsKnownImmutableType(Type type)
=> type.IsPrimitive
|| type == typeof(string)
|| type == typeof(DateTime)
|| type == typeof(decimal);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// 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 Microsoft.AspNetCore.Blazor.Rendering;
using System;

namespace Microsoft.AspNetCore.Blazor.Components
{
internal interface ICascadingValueComponent
{
// This interface exists only so that CascadingParameterState has a way
// to work with all CascadingValue<T> types regardless of T.

bool CanSupplyValue(Type valueType, string valueName);

object CurrentValue { get; }

void Subscribe(ComponentState subscriber);

void Unsubscribe(ComponentState subscriber);
}
}
Loading