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 HashSet<ComponentState> _subscribers; // Lazily instantiated
private bool _hasSetParametersPreviously;
/// <summary>
/// The content to which the value should be provided.
@ -35,8 +36,18 @@ namespace Microsoft.AspNetCore.Blazor.Components
/// </summary>
[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;
bool ICascadingValueComponent.CurrentValueIsFixed => Fixed;
/// <inheritdoc />
public void Init(RenderHandle renderHandle)
{
@ -52,9 +63,11 @@ namespace Microsoft.AspNetCore.Blazor.Components
var hasSuppliedValue = false;
var previousValue = Value;
var previousFixed = Fixed;
Value = default;
ChildContent = null;
Name = null;
Fixed = false;
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.");
}
}
else if (parameter.Name.Equals(nameof(Fixed), StringComparison.OrdinalIgnoreCase))
{
Fixed = (bool)parameter.Value;
}
else
{
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
// because it serves no useful purpose to have a <CascadingValue> otherwise.
if (!hasSuppliedValue)
@ -120,6 +144,15 @@ namespace Microsoft.AspNetCore.Blazor.Components
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)
{
_subscribers = new HashSet<ComponentState>();

View File

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

View File

@ -20,6 +20,7 @@ namespace Microsoft.AspNetCore.Blazor.Rendering
private readonly IComponent _component;
private readonly Renderer _renderer;
private readonly IReadOnlyList<CascadingParameterState> _cascadingParameters;
private readonly bool _hasAnyCascadingParameterSubscriptions;
private RenderTreeBuilder _renderTreeBuilderCurrent;
private RenderTreeBuilder _renderTreeBuilderPrevious;
private ArrayBuilder<RenderTreeFrame> _latestDirectParametersSnapshot; // Lazily instantiated
@ -48,7 +49,7 @@ namespace Microsoft.AspNetCore.Blazor.Rendering
if (_cascadingParameters != null)
{
AddCascadingParameterSubscriptions();
_hasAnyCascadingParameterSubscriptions = AddCascadingParameterSubscriptions();
}
}
@ -88,7 +89,7 @@ namespace Microsoft.AspNetCore.Blazor.Rendering
RenderTreeDiffBuilder.DisposeFrames(batchBuilder, _renderTreeBuilderCurrent.GetFrames());
if (_cascadingParameters != null)
if (_hasAnyCascadingParameterSubscriptions)
{
RemoveCascadingParameterSubscriptions();
}
@ -119,12 +120,7 @@ namespace Microsoft.AspNetCore.Blazor.Rendering
// If we bypass this, the component won't receive the cascading parameters nor
// will it update its snapshot of direct parameters.
// TODO: Consider adding a "static" mode for tree params in which we don't
// 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)
if (_hasAnyCascadingParameterSubscriptions)
{
// 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
@ -133,8 +129,12 @@ namespace Microsoft.AspNetCore.Blazor.Rendering
{
_latestDirectParametersSnapshot = new ArrayBuilder<RenderTreeFrame>();
}
parameters.CaptureSnapshot(_latestDirectParametersSnapshot);
parameters.CaptureSnapshot(_latestDirectParametersSnapshot);
}
if (_cascadingParameters != null)
{
parameters = parameters.WithCascadingParameters(_cascadingParameters);
}
@ -150,13 +150,22 @@ namespace Microsoft.AspNetCore.Blazor.Rendering
Component.SetParameters(allParams);
}
private void AddCascadingParameterSubscriptions()
private bool AddCascadingParameterSubscriptions()
{
var hasSubscription = false;
var numCascadingParameters = _cascadingParameters.Count;
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()

View File

@ -4,6 +4,7 @@
using Microsoft.AspNetCore.Blazor.Components;
using Microsoft.AspNetCore.Blazor.RenderTree;
using Microsoft.AspNetCore.Blazor.Test.Helpers;
using System;
using System.Linq;
using Xunit;
@ -236,6 +237,113 @@ namespace Microsoft.AspNetCore.Blazor.Test
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)
{
var componentFrame = batch.ReferenceFrames.Single(

View File

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