Support "Fixed" mode for <CascadingValue>

This commit is contained in:
Steve Sanderson 2018-10-15 13:00:17 +01:00
parent 4a6f471d12
commit 18df30568c
5 changed files with 165 additions and 11 deletions

View File

@ -15,6 +15,7 @@ namespace Microsoft.AspNetCore.Blazor.Components
{ {
private RenderHandle _renderHandle; private RenderHandle _renderHandle;
private HashSet<ComponentState> _subscribers; // Lazily instantiated private HashSet<ComponentState> _subscribers; // Lazily instantiated
private bool _hasSetParametersPreviously;
/// <summary> /// <summary>
/// The content to which the value should be provided. /// The content to which the value should be provided.
@ -35,8 +36,18 @@ namespace Microsoft.AspNetCore.Blazor.Components
/// </summary> /// </summary>
[Parameter] private string Name { get; set; } [Parameter] private string Name { get; set; }
/// <summary>
/// If true, indicates that <see cref="Value"/> will not change. This is a
/// performance optimization that allows the framework to skip setting up
/// change notifications. Set this flag only if you will not change
/// <see cref="Value"/> during the component's lifetime.
/// </summary>
[Parameter] private bool Fixed { get; set; }
object ICascadingValueComponent.CurrentValue => Value; object ICascadingValueComponent.CurrentValue => Value;
bool ICascadingValueComponent.CurrentValueIsFixed => Fixed;
/// <inheritdoc /> /// <inheritdoc />
public void Init(RenderHandle renderHandle) public void Init(RenderHandle renderHandle)
{ {
@ -52,9 +63,11 @@ namespace Microsoft.AspNetCore.Blazor.Components
var hasSuppliedValue = false; var hasSuppliedValue = false;
var previousValue = Value; var previousValue = Value;
var previousFixed = Fixed;
Value = default; Value = default;
ChildContent = null; ChildContent = null;
Name = null; Name = null;
Fixed = false;
foreach (var parameter in parameters) foreach (var parameter in parameters)
{ {
@ -75,12 +88,23 @@ namespace Microsoft.AspNetCore.Blazor.Components
throw new ArgumentException($"The parameter '{nameof(Name)}' for component '{nameof(CascadingValue<T>)}' does not allow null or empty values."); throw new ArgumentException($"The parameter '{nameof(Name)}' for component '{nameof(CascadingValue<T>)}' does not allow null or empty values.");
} }
} }
else if (parameter.Name.Equals(nameof(Fixed), StringComparison.OrdinalIgnoreCase))
{
Fixed = (bool)parameter.Value;
}
else else
{ {
throw new ArgumentException($"The component '{nameof(CascadingValue<T>)}' does not accept a parameter with the name '{parameter.Name}'."); throw new ArgumentException($"The component '{nameof(CascadingValue<T>)}' does not accept a parameter with the name '{parameter.Name}'.");
} }
} }
if (_hasSetParametersPreviously && Fixed != previousFixed)
{
throw new InvalidOperationException($"The value of {nameof(Fixed)} cannot be changed dynamically.");
}
_hasSetParametersPreviously = true;
// It's OK for the value to be null, but some "Value" param must be suppled // 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. // because it serves no useful purpose to have a <CascadingValue> otherwise.
if (!hasSuppliedValue) if (!hasSuppliedValue)
@ -120,6 +144,15 @@ namespace Microsoft.AspNetCore.Blazor.Components
void ICascadingValueComponent.Subscribe(ComponentState subscriber) void ICascadingValueComponent.Subscribe(ComponentState subscriber)
{ {
#if DEBUG
if (Fixed)
{
// Should not be possible. User code cannot trigger this.
// Checking only to catch possible future framework bugs.
throw new InvalidOperationException($"Cannot subscribe to a {typeof(CascadingValue<>).Name} when {nameof(Fixed)} is true.");
}
#endif
if (_subscribers == null) if (_subscribers == null)
{ {
_subscribers = new HashSet<ComponentState>(); _subscribers = new HashSet<ComponentState>();

View File

@ -15,6 +15,8 @@ namespace Microsoft.AspNetCore.Blazor.Components
object CurrentValue { get; } object CurrentValue { get; }
bool CurrentValueIsFixed { get; }
void Subscribe(ComponentState subscriber); void Subscribe(ComponentState subscriber);
void Unsubscribe(ComponentState subscriber); void Unsubscribe(ComponentState subscriber);

View File

@ -20,6 +20,7 @@ namespace Microsoft.AspNetCore.Blazor.Rendering
private readonly IComponent _component; private readonly IComponent _component;
private readonly Renderer _renderer; private readonly Renderer _renderer;
private readonly IReadOnlyList<CascadingParameterState> _cascadingParameters; private readonly IReadOnlyList<CascadingParameterState> _cascadingParameters;
private readonly bool _hasAnyCascadingParameterSubscriptions;
private RenderTreeBuilder _renderTreeBuilderCurrent; private RenderTreeBuilder _renderTreeBuilderCurrent;
private RenderTreeBuilder _renderTreeBuilderPrevious; private RenderTreeBuilder _renderTreeBuilderPrevious;
private ArrayBuilder<RenderTreeFrame> _latestDirectParametersSnapshot; // Lazily instantiated private ArrayBuilder<RenderTreeFrame> _latestDirectParametersSnapshot; // Lazily instantiated
@ -48,7 +49,7 @@ namespace Microsoft.AspNetCore.Blazor.Rendering
if (_cascadingParameters != null) if (_cascadingParameters != null)
{ {
AddCascadingParameterSubscriptions(); _hasAnyCascadingParameterSubscriptions = AddCascadingParameterSubscriptions();
} }
} }
@ -88,7 +89,7 @@ namespace Microsoft.AspNetCore.Blazor.Rendering
RenderTreeDiffBuilder.DisposeFrames(batchBuilder, _renderTreeBuilderCurrent.GetFrames()); RenderTreeDiffBuilder.DisposeFrames(batchBuilder, _renderTreeBuilderCurrent.GetFrames());
if (_cascadingParameters != null) if (_hasAnyCascadingParameterSubscriptions)
{ {
RemoveCascadingParameterSubscriptions(); RemoveCascadingParameterSubscriptions();
} }
@ -119,12 +120,7 @@ namespace Microsoft.AspNetCore.Blazor.Rendering
// If we bypass this, the component won't receive the cascading parameters nor // If we bypass this, the component won't receive the cascading parameters nor
// will it update its snapshot of direct parameters. // will it update its snapshot of direct parameters.
// TODO: Consider adding a "static" mode for tree params in which we don't if (_hasAnyCascadingParameterSubscriptions)
// subscribe for updates, and hence don't have to do any of the parameter
// snapshotting. This would be useful for things like FormContext that aren't
// going to change.
if (_cascadingParameters != null)
{ {
// We may need to replay these direct parameters later (in NotifyCascadingValueChanged), // We may need to replay these direct parameters later (in NotifyCascadingValueChanged),
// but we can't guarantee that the original underlying data won't have mutated in the // but we can't guarantee that the original underlying data won't have mutated in the
@ -133,8 +129,12 @@ namespace Microsoft.AspNetCore.Blazor.Rendering
{ {
_latestDirectParametersSnapshot = new ArrayBuilder<RenderTreeFrame>(); _latestDirectParametersSnapshot = new ArrayBuilder<RenderTreeFrame>();
} }
parameters.CaptureSnapshot(_latestDirectParametersSnapshot);
parameters.CaptureSnapshot(_latestDirectParametersSnapshot);
}
if (_cascadingParameters != null)
{
parameters = parameters.WithCascadingParameters(_cascadingParameters); parameters = parameters.WithCascadingParameters(_cascadingParameters);
} }
@ -150,13 +150,22 @@ namespace Microsoft.AspNetCore.Blazor.Rendering
Component.SetParameters(allParams); Component.SetParameters(allParams);
} }
private void AddCascadingParameterSubscriptions() private bool AddCascadingParameterSubscriptions()
{ {
var hasSubscription = false;
var numCascadingParameters = _cascadingParameters.Count; var numCascadingParameters = _cascadingParameters.Count;
for (var i = 0; i < numCascadingParameters; i++) for (var i = 0; i < numCascadingParameters; i++)
{ {
_cascadingParameters[i].ValueSupplier.Subscribe(this); var valueSupplier = _cascadingParameters[i].ValueSupplier;
if (!valueSupplier.CurrentValueIsFixed)
{
valueSupplier.Subscribe(this);
hasSubscription = true;
}
} }
return hasSubscription;
} }
private void RemoveCascadingParameterSubscriptions() private void RemoveCascadingParameterSubscriptions()

View File

@ -4,6 +4,7 @@
using Microsoft.AspNetCore.Blazor.Components; using Microsoft.AspNetCore.Blazor.Components;
using Microsoft.AspNetCore.Blazor.RenderTree; using Microsoft.AspNetCore.Blazor.RenderTree;
using Microsoft.AspNetCore.Blazor.Test.Helpers; using Microsoft.AspNetCore.Blazor.Test.Helpers;
using System;
using System.Linq; using System.Linq;
using Xunit; using Xunit;
@ -236,6 +237,113 @@ namespace Microsoft.AspNetCore.Blazor.Test
Assert.Equal(2, nestedComponent.NumSetParametersCalls); Assert.Equal(2, nestedComponent.NumSetParametersCalls);
} }
[Fact]
public void DoesNotNotifyDescendantsOfUpdatedCascadingParameterValuesWhenFixed()
{
// Arrange
var providedValue = "Initial value";
var shouldIncludeChild = true;
var renderer = new TestRenderer();
var component = new TestComponent(builder =>
{
builder.OpenComponent<CascadingValue<string>>(0);
builder.AddAttribute(1, "Value", providedValue);
builder.AddAttribute(2, "Fixed", true);
builder.AddAttribute(3, RenderTreeBuilder.ChildContent, new RenderFragment(childBuilder =>
{
if (shouldIncludeChild)
{
childBuilder.OpenComponent<CascadingParameterConsumerComponent<string>>(0);
childBuilder.AddAttribute(1, "RegularParameter", "Goodbye");
childBuilder.CloseComponent();
}
}));
builder.CloseComponent();
});
// Act 1: Initial render; capture nested component ID
var componentId = renderer.AssignRootComponentId(component);
component.TriggerRender();
var firstBatch = renderer.Batches.Single();
var nestedComponent = FindComponent<CascadingParameterConsumerComponent<string>>(firstBatch, out var nestedComponentId);
Assert.Equal(1, nestedComponent.NumRenders);
// Assert: Initial value is supplied to descendant
var nestedComponentDiff = firstBatch.DiffsByComponentId[nestedComponentId].Single();
Assert.Collection(nestedComponentDiff.Edits, edit =>
{
Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type);
AssertFrame.Text(
firstBatch.ReferenceFrames[edit.ReferenceFrameIndex],
"CascadingParameter=Initial value; RegularParameter=Goodbye");
});
// Act 2: Re-render CascadingValue with new value
providedValue = "Updated value";
component.TriggerRender();
// Assert: We did not re-render the descendant
Assert.Equal(2, renderer.Batches.Count);
var secondBatch = renderer.Batches[1];
Assert.Equal(2, secondBatch.DiffsByComponentId.Count); // Root + CascadingValue, but not nested one
Assert.Equal(1, nestedComponent.NumSetParametersCalls);
Assert.Equal(1, nestedComponent.NumRenders);
// Act 3: Dispose
shouldIncludeChild = false;
component.TriggerRender();
// Assert: Absence of an exception here implies we didn't cause a problem by
// trying to remove a non-existent subscription
}
[Fact]
public void CascadingValueThrowsIfFixedFlagChangesToTrue()
{
// Arrange
var renderer = new TestRenderer();
var isFixed = false;
var component = new TestComponent(builder =>
{
builder.OpenComponent<CascadingValue<object>>(0);
builder.AddAttribute(1, "Fixed", isFixed);
builder.AddAttribute(2, "Value", new object());
builder.CloseComponent();
});
renderer.AssignRootComponentId(component);
component.TriggerRender();
// Act/Assert
isFixed = true;
var ex = Assert.Throws<InvalidOperationException>(() => component.TriggerRender());
Assert.Equal("The value of Fixed cannot be changed dynamically.", ex.Message);
}
[Fact]
public void CascadingValueThrowsIfFixedFlagChangesToFalse()
{
// Arrange
var renderer = new TestRenderer();
var isFixed = true;
var component = new TestComponent(builder =>
{
builder.OpenComponent<CascadingValue<object>>(0);
if (isFixed) // Showing also that "unset" is treated as "false"
{
builder.AddAttribute(1, "Fixed", true);
}
builder.AddAttribute(2, "Value", new object());
builder.CloseComponent();
});
renderer.AssignRootComponentId(component);
component.TriggerRender();
// Act/Assert
isFixed = false;
var ex = Assert.Throws<InvalidOperationException>(() => component.TriggerRender());
Assert.Equal("The value of Fixed cannot be changed dynamically.", ex.Message);
}
private static T FindComponent<T>(CapturedBatch batch, out int componentId) private static T FindComponent<T>(CapturedBatch batch, out int componentId)
{ {
var componentFrame = batch.ReferenceFrames.Single( var componentFrame = batch.ReferenceFrames.Single(

View File

@ -327,6 +327,8 @@ namespace Microsoft.AspNetCore.Blazor.Test
public object CurrentValue { get; } public object CurrentValue { get; }
public bool CurrentValueIsFixed => false;
public bool CanSupplyValue(Type valueType, string valueName) public bool CanSupplyValue(Type valueType, string valueName)
=> throw new NotImplementedException(); => throw new NotImplementedException();