diff --git a/src/Microsoft.AspNetCore.Blazor/Components/CascadingValue.cs b/src/Microsoft.AspNetCore.Blazor/Components/CascadingValue.cs index 5c0ac5195..e130d41d2 100644 --- a/src/Microsoft.AspNetCore.Blazor/Components/CascadingValue.cs +++ b/src/Microsoft.AspNetCore.Blazor/Components/CascadingValue.cs @@ -15,6 +15,7 @@ public class CascadingValue : ICascadingValueComponent, IComponent { private RenderHandle _renderHandle; private HashSet _subscribers; // Lazily instantiated + private bool _hasSetParametersPreviously; /// /// The content to which the value should be provided. @@ -35,8 +36,18 @@ public class CascadingValue : ICascadingValueComponent, IComponent /// [Parameter] private string Name { get; set; } + /// + /// If true, indicates that 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 + /// during the component's lifetime. + /// + [Parameter] private bool IsFixed { get; set; } + object ICascadingValueComponent.CurrentValue => Value; + bool ICascadingValueComponent.CurrentValueIsFixed => IsFixed; + /// public void Init(RenderHandle renderHandle) { @@ -52,9 +63,11 @@ public void SetParameters(ParameterCollection parameters) var hasSuppliedValue = false; var previousValue = Value; + var previousFixed = IsFixed; Value = default; ChildContent = null; Name = null; + IsFixed = false; foreach (var parameter in parameters) { @@ -75,12 +88,23 @@ public void SetParameters(ParameterCollection parameters) throw new ArgumentException($"The parameter '{nameof(Name)}' for component '{nameof(CascadingValue)}' does not allow null or empty values."); } } + else if (parameter.Name.Equals(nameof(IsFixed), StringComparison.OrdinalIgnoreCase)) + { + IsFixed = (bool)parameter.Value; + } else { throw new ArgumentException($"The component '{nameof(CascadingValue)}' does not accept a parameter with the name '{parameter.Name}'."); } } + if (_hasSetParametersPreviously && IsFixed != previousFixed) + { + throw new InvalidOperationException($"The value of {nameof(IsFixed)} 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 otherwise. if (!hasSuppliedValue) @@ -120,6 +144,15 @@ bool ICascadingValueComponent.CanSupplyValue(Type requestedType, string requeste void ICascadingValueComponent.Subscribe(ComponentState subscriber) { +#if DEBUG + if (IsFixed) + { + // 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(IsFixed)} is true."); + } +#endif + if (_subscribers == null) { _subscribers = new HashSet(); diff --git a/src/Microsoft.AspNetCore.Blazor/Components/ICascadingValueComponent.cs b/src/Microsoft.AspNetCore.Blazor/Components/ICascadingValueComponent.cs index 2cf5349dd..d64940e74 100644 --- a/src/Microsoft.AspNetCore.Blazor/Components/ICascadingValueComponent.cs +++ b/src/Microsoft.AspNetCore.Blazor/Components/ICascadingValueComponent.cs @@ -15,6 +15,8 @@ internal interface ICascadingValueComponent object CurrentValue { get; } + bool CurrentValueIsFixed { get; } + void Subscribe(ComponentState subscriber); void Unsubscribe(ComponentState subscriber); diff --git a/src/Microsoft.AspNetCore.Blazor/Rendering/ComponentState.cs b/src/Microsoft.AspNetCore.Blazor/Rendering/ComponentState.cs index 10f069f71..3b0a4eaeb 100644 --- a/src/Microsoft.AspNetCore.Blazor/Rendering/ComponentState.cs +++ b/src/Microsoft.AspNetCore.Blazor/Rendering/ComponentState.cs @@ -20,6 +20,7 @@ internal class ComponentState private readonly IComponent _component; private readonly Renderer _renderer; private readonly IReadOnlyList _cascadingParameters; + private readonly bool _hasAnyCascadingParameterSubscriptions; private RenderTreeBuilder _renderTreeBuilderCurrent; private RenderTreeBuilder _renderTreeBuilderPrevious; private ArrayBuilder _latestDirectParametersSnapshot; // Lazily instantiated @@ -48,7 +49,7 @@ public ComponentState(Renderer renderer, int componentId, IComponent component, if (_cascadingParameters != null) { - AddCascadingParameterSubscriptions(); + _hasAnyCascadingParameterSubscriptions = AddCascadingParameterSubscriptions(); } } @@ -88,7 +89,7 @@ public void DisposeInBatch(RenderBatchBuilder batchBuilder) RenderTreeDiffBuilder.DisposeFrames(batchBuilder, _renderTreeBuilderCurrent.GetFrames()); - if (_cascadingParameters != null) + if (_hasAnyCascadingParameterSubscriptions) { RemoveCascadingParameterSubscriptions(); } @@ -119,12 +120,7 @@ public void SetDirectParameters(ParameterCollection parameters) // 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 @@ public void SetDirectParameters(ParameterCollection parameters) { _latestDirectParametersSnapshot = new ArrayBuilder(); } + parameters.CaptureSnapshot(_latestDirectParametersSnapshot); + } + if (_cascadingParameters != null) + { parameters = parameters.WithCascadingParameters(_cascadingParameters); } @@ -150,13 +150,22 @@ public void NotifyCascadingValueChanged() 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() diff --git a/test/Microsoft.AspNetCore.Blazor.E2ETest/ServerExecutionTests/TestSubclasses.cs b/test/Microsoft.AspNetCore.Blazor.E2ETest/ServerExecutionTests/TestSubclasses.cs index 0cd17d770..00afaa67b 100644 --- a/test/Microsoft.AspNetCore.Blazor.E2ETest/ServerExecutionTests/TestSubclasses.cs +++ b/test/Microsoft.AspNetCore.Blazor.E2ETest/ServerExecutionTests/TestSubclasses.cs @@ -56,4 +56,12 @@ public ServerRoutingTest(BrowserFixture browserFixture, ToggleExecutionModeServe { } } + + public class ServerCascadingValueTest : CascadingValueTest + { + public ServerCascadingValueTest(BrowserFixture browserFixture, ToggleExecutionModeServerFixture serverFixture, ITestOutputHelper output) + : base(browserFixture, serverFixture.WithServerExecution(), output) + { + } + } } diff --git a/test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/CascadingValueTest.cs b/test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/CascadingValueTest.cs new file mode 100644 index 000000000..50c455627 --- /dev/null +++ b/test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/CascadingValueTest.cs @@ -0,0 +1,86 @@ +// 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 BasicTestApp; +using Microsoft.AspNetCore.Blazor.E2ETest.Infrastructure; +using Microsoft.AspNetCore.Blazor.E2ETest.Infrastructure.ServerFixtures; +using OpenQA.Selenium; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests +{ + public class CascadingValueTest : BasicTestAppTestBase + { + public CascadingValueTest( + BrowserFixture browserFixture, + ToggleExecutionModeServerFixture serverFixture, + ITestOutputHelper output) + : base(browserFixture, serverFixture, output) + { + Navigate(ServerPathBase, noReload: !serverFixture.UsingAspNetHost); + MountTestComponent(); + } + + [Fact] + public void CanUpdateValuesMatchedByType() + { + var currentCount = Browser.FindElement(By.Id("current-count")); + var incrementButton = Browser.FindElement(By.Id("increment-count")); + + // We have the correct initial value + WaitAssert.Equal("100", () => currentCount.Text); + + // Updates are propagated + incrementButton.Click(); + WaitAssert.Equal("101", () => currentCount.Text); + incrementButton.Click(); + WaitAssert.Equal("102", () => currentCount.Text); + + // Didn't re-render unrelated descendants + Assert.Equal("1", Browser.FindElement(By.Id("receive-by-interface-num-renders")).Text); + } + + [Fact] + public void CanUpdateValuesMatchedByName() + { + var currentFlag1Value = Browser.FindElement(By.Id("flag-1")); + var currentFlag2Value = Browser.FindElement(By.Id("flag-2")); + + WaitAssert.Equal("False", () => currentFlag1Value.Text); + WaitAssert.Equal("False", () => currentFlag2Value.Text); + + // Observe that the correct cascading parameter updates + Browser.FindElement(By.Id("toggle-flag-1")).Click(); + WaitAssert.Equal("True", () => currentFlag1Value.Text); + WaitAssert.Equal("False", () => currentFlag2Value.Text); + Browser.FindElement(By.Id("toggle-flag-2")).Click(); + WaitAssert.Equal("True", () => currentFlag1Value.Text); + WaitAssert.Equal("True", () => currentFlag2Value.Text); + + // Didn't re-render unrelated descendants + Assert.Equal("1", Browser.FindElement(By.Id("receive-by-interface-num-renders")).Text); + } + + [Fact] + public void CanUpdateFixedValuesMatchedByInterface() + { + var currentCount = Browser.FindElement(By.Id("current-count")); + var decrementButton = Browser.FindElement(By.Id("decrement-count")); + + // We have the correct initial value + WaitAssert.Equal("100", () => currentCount.Text); + + // Updates are propagated + decrementButton.Click(); + WaitAssert.Equal("99", () => currentCount.Text); + decrementButton.Click(); + WaitAssert.Equal("98", () => currentCount.Text); + + // Renders the descendant the same number of times we triggered + // events on it, because we always re-render components after they + // have an event + Assert.Equal("3", Browser.FindElement(By.Id("receive-by-interface-num-renders")).Text); + } + } +} diff --git a/test/Microsoft.AspNetCore.Blazor.Test/CascadingParameterTest.cs b/test/Microsoft.AspNetCore.Blazor.Test/CascadingParameterTest.cs index d4b2a6f40..ab5a3deea 100644 --- a/test/Microsoft.AspNetCore.Blazor.Test/CascadingParameterTest.cs +++ b/test/Microsoft.AspNetCore.Blazor.Test/CascadingParameterTest.cs @@ -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 @@ public void StopsNotifyingDescendantsIfTheyAreRemoved() 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>(0); + builder.AddAttribute(1, "Value", providedValue); + builder.AddAttribute(2, "IsFixed", true); + builder.AddAttribute(3, RenderTreeBuilder.ChildContent, new RenderFragment(childBuilder => + { + if (shouldIncludeChild) + { + childBuilder.OpenComponent>(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>(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>(0); + builder.AddAttribute(1, "IsFixed", isFixed); + builder.AddAttribute(2, "Value", new object()); + builder.CloseComponent(); + }); + renderer.AssignRootComponentId(component); + component.TriggerRender(); + + // Act/Assert + isFixed = true; + var ex = Assert.Throws(() => component.TriggerRender()); + Assert.Equal("The value of IsFixed 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>(0); + if (isFixed) // Showing also that "unset" is treated as "false" + { + builder.AddAttribute(1, "IsFixed", true); + } + builder.AddAttribute(2, "Value", new object()); + builder.CloseComponent(); + }); + renderer.AssignRootComponentId(component); + component.TriggerRender(); + + // Act/Assert + isFixed = false; + var ex = Assert.Throws(() => component.TriggerRender()); + Assert.Equal("The value of IsFixed cannot be changed dynamically.", ex.Message); + } + private static T FindComponent(CapturedBatch batch, out int componentId) { var componentFrame = batch.ReferenceFrames.Single( diff --git a/test/Microsoft.AspNetCore.Blazor.Test/ParameterCollectionTest.cs b/test/Microsoft.AspNetCore.Blazor.Test/ParameterCollectionTest.cs index f2def78ac..2da9dcf3d 100644 --- a/test/Microsoft.AspNetCore.Blazor.Test/ParameterCollectionTest.cs +++ b/test/Microsoft.AspNetCore.Blazor.Test/ParameterCollectionTest.cs @@ -327,6 +327,8 @@ public TestCascadingValue(object value) public object CurrentValue { get; } + public bool CurrentValueIsFixed => false; + public bool CanSupplyValue(Type valueType, string valueName) => throw new NotImplementedException(); diff --git a/test/testapps/BasicTestApp/CascadingValueTest/CascadingValueIntermediary.cshtml b/test/testapps/BasicTestApp/CascadingValueTest/CascadingValueIntermediary.cshtml new file mode 100644 index 000000000..b6db3ef1c --- /dev/null +++ b/test/testapps/BasicTestApp/CascadingValueTest/CascadingValueIntermediary.cshtml @@ -0,0 +1,9 @@ +@* + The only purpose of this component is to validate that cascaded value updates + can pass through entirely unrelated components that wouldn't normally rerender + their children when they themselves are being rerendered. +*@ + + + + diff --git a/test/testapps/BasicTestApp/CascadingValueTest/CascadingValueReceiveByName.cshtml b/test/testapps/BasicTestApp/CascadingValueTest/CascadingValueReceiveByName.cshtml new file mode 100644 index 000000000..a0d14e571 --- /dev/null +++ b/test/testapps/BasicTestApp/CascadingValueTest/CascadingValueReceiveByName.cshtml @@ -0,0 +1,16 @@ +@* + This component is to show that we can differentiate cascaded values + by name if they are of the same type. +*@ + +

+ Flag 1: @MyFlag1 +

+

+ Flag 2: @MyFlag2 +

+ +@functions { + [CascadingParameter(Name = "TestFlag1")] bool MyFlag1 { get; set; } + [CascadingParameter(Name = "TestFlag2")] bool MyFlag2 { get; set; } +} diff --git a/test/testapps/BasicTestApp/CascadingValueTest/CascadingValueReceiveByType.cshtml b/test/testapps/BasicTestApp/CascadingValueTest/CascadingValueReceiveByType.cshtml new file mode 100644 index 000000000..a1731d14d --- /dev/null +++ b/test/testapps/BasicTestApp/CascadingValueTest/CascadingValueReceiveByType.cshtml @@ -0,0 +1,8 @@ +

+ Current count: + @CurrentCounter.NumClicks +

+ +@functions { + [CascadingParameter] CounterDTO CurrentCounter { get; set; } +} diff --git a/test/testapps/BasicTestApp/CascadingValueTest/CascadingValueReceiveFixedByInterface.cshtml b/test/testapps/BasicTestApp/CascadingValueTest/CascadingValueReceiveFixedByInterface.cshtml new file mode 100644 index 000000000..3878c21e6 --- /dev/null +++ b/test/testapps/BasicTestApp/CascadingValueTest/CascadingValueReceiveFixedByInterface.cshtml @@ -0,0 +1,21 @@ +@* + This component is to show that: + + 1. We can match a cascading parameter based on interface + 2. Since the supplied value is fixed (see CascadingValueSupplier.cshtml), + ancestor renders don't trigger descendant renders even though the + supplied value type is mutable. +*@ + +@{ numRenders++; } +

+ @(nameof(CascadingValueReceiveFixedByInterface)) render count: + @numRenders + +

+ +@functions { + int numRenders = 0; + + [CascadingParameter] ICanDecrement Ancestor { get; set; } +} diff --git a/test/testapps/BasicTestApp/CascadingValueTest/CascadingValueSupplier.cshtml b/test/testapps/BasicTestApp/CascadingValueTest/CascadingValueSupplier.cshtml new file mode 100644 index 000000000..a772b949d --- /dev/null +++ b/test/testapps/BasicTestApp/CascadingValueTest/CascadingValueSupplier.cshtml @@ -0,0 +1,33 @@ +@implements ICanDecrement + +@* + While it looks somewhat ridiculous to nest so many CascadingValue components, + it simplifies the E2E test and is not intended as a representative use case. + Each of the CascadingValue components here is configured differently for the test. +*@ + + + + + + + + + + + +

+

+

+ +@functions { + CounterDTO counterState = new CounterDTO { NumClicks = 100 }; + bool currentFlagValue1; + bool currentFlagValue2; + + public void DecrementCount() + { + counterState.NumClicks--; + StateHasChanged(); + } +} diff --git a/test/testapps/BasicTestApp/CascadingValueTest/CascadingValueTypes.cs b/test/testapps/BasicTestApp/CascadingValueTest/CascadingValueTypes.cs new file mode 100644 index 000000000..e9d08a06c --- /dev/null +++ b/test/testapps/BasicTestApp/CascadingValueTest/CascadingValueTypes.cs @@ -0,0 +1,20 @@ +// 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. + +namespace BasicTestApp.CascadingValueTest +{ + public class CounterDTO + { + public int NumClicks { get; set; } + + public void IncrementCount() + { + NumClicks++; + } + } + + public interface ICanDecrement + { + void DecrementCount(); + } +} diff --git a/test/testapps/BasicTestApp/Index.cshtml b/test/testapps/BasicTestApp/Index.cshtml index ab0f6f49e..07d014e4b 100644 --- a/test/testapps/BasicTestApp/Index.cshtml +++ b/test/testapps/BasicTestApp/Index.cshtml @@ -41,6 +41,7 @@ + @if (SelectedComponentType != null)