Skip to content

[Blazor] Allow cascading value subscribers to get added and removed during change notification #57243

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

Merged
merged 4 commits into from
Aug 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
23 changes: 22 additions & 1 deletion src/Components/Components/src/CascadingValueSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.

using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
using Microsoft.AspNetCore.Components.Rendering;

namespace Microsoft.AspNetCore.Components;
Expand Down Expand Up @@ -96,7 +97,16 @@ public Task NotifyChangedAsync()
{
tasks.Add(dispatcher.InvokeAsync(() =>
{
foreach (var subscriber in subscribers)
var subscribersBuffer = new ComponentStateBuffer();
var subscribersCount = subscribers.Count;
var subscribersCopy = subscribersCount <= ComponentStateBuffer.Capacity
? subscribersBuffer[..subscribersCount]
: new ComponentState[subscribersCount];
subscribers.CopyTo(subscribersCopy);

// We iterate over a copy of the list because new subscribers might get
// added or removed during change notification
foreach (var subscriber in subscribersCopy)
{
subscriber.NotifyCascadingValueChanged(ParameterViewLifetime.Unbound);
}
Expand Down Expand Up @@ -174,4 +184,15 @@ void ICascadingValueSupplier.Unsubscribe(ComponentState subscriber, in Cascading
}
}
}

[InlineArray(Capacity)]
internal struct ComponentStateBuffer
{
public const int Capacity = 64;
#pragma warning disable IDE0051 // Remove unused private members
#pragma warning disable IDE0044 // Add readonly modifier
private ComponentState _values;
#pragma warning restore IDE0044 // Add readonly modifier
#pragma warning restore IDE0051 // Remove unused private members
}
}
126 changes: 126 additions & 0 deletions src/Components/Components/test/CascadingParameterTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,61 @@ public async Task CanTriggerUpdatesOnCascadingValuesFromServiceProvider()
await cascadingValueSource.NotifyChangedAsync(new MyParamType("Nobody is listening, but this shouldn't be an error"));
}

[Fact]
public async Task CanAddSubscriberDuringChangeNotification()
{
// Arrange
var services = new ServiceCollection();
var paramValue = new MyParamType("Initial value");
var cascadingValueSource = new CascadingValueSource<MyParamType>(paramValue, isFixed: false);
services.AddCascadingValue(_ => cascadingValueSource);
var renderer = new TestRenderer(services.BuildServiceProvider());
var component = new ConditionallyRenderSubscriberComponent()
{
RenderWhenEqualTo = "Final value",
};

// Act/Assert: Initial render
var componentId = await renderer.Dispatcher.InvokeAsync(() => renderer.AssignRootComponentId(component));
renderer.RenderRootComponent(componentId);
var firstBatch = renderer.Batches.Single();
var diff = firstBatch.DiffsByComponentId[componentId].Single();
Assert.Collection(diff.Edits,
edit =>
{
Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type);
AssertFrame.Text(
firstBatch.ReferenceFrames[edit.ReferenceFrameIndex],
"CascadingParameter=Initial value");
});
Assert.Equal(1, component.NumRenders);

// Act: Second render
paramValue.ChangeValue("Final value");
await cascadingValueSource.NotifyChangedAsync();
var secondBatch = renderer.Batches[1];
var diff2 = secondBatch.DiffsByComponentId[componentId].Single();

// Assert: Subscriber can get added during change notification and receive the cascading value
AssertFrame.Text(
secondBatch.ReferenceFrames[diff2.Edits[0].ReferenceFrameIndex],
"CascadingParameter=Final value");
Assert.Equal(2, component.NumRenders);

// Assert: Subscriber can get added during change notification and receive the cascading value
var nestedComponent = FindComponent<SimpleSubscriberComponent>(secondBatch, out var nestedComponentId);
var nestedComponentDiff = secondBatch.DiffsByComponentId[nestedComponentId].Single();
Assert.Collection(nestedComponentDiff.Edits,
edit =>
{
Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type);
AssertFrame.Text(
secondBatch.ReferenceFrames[edit.ReferenceFrameIndex],
"CascadingParameter=Final value");
});
Assert.Equal(1, nestedComponent.NumRenders);
}

[Fact]
public async Task AfterSupplyingValueThroughNotifyChanged_InitialValueFactoryIsNotUsed()
{
Expand Down Expand Up @@ -772,6 +827,40 @@ public void CanUseTryAddPatternForCascadingValuesInServiceCollection_CascadingVa
Assert.Equal(2, services.Count());
}

[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(CascadingValueSource<MyParamType>.ComponentStateBuffer.Capacity - 1)]
[InlineData(CascadingValueSource<MyParamType>.ComponentStateBuffer.Capacity)]
[InlineData(CascadingValueSource<MyParamType>.ComponentStateBuffer.Capacity + 1)]
[InlineData(CascadingValueSource<MyParamType>.ComponentStateBuffer.Capacity * 2)]
public async Task CanHaveManySubscribers(int numSubscribers)
{
// Arrange
var services = new ServiceCollection();
var paramValue = new MyParamType("Initial value");
var cascadingValueSource = new CascadingValueSource<MyParamType>(paramValue, isFixed: false);
services.AddCascadingValue(_ => cascadingValueSource);
var renderer = new TestRenderer(services.BuildServiceProvider());
var components = Enumerable.Range(0, numSubscribers).Select(_ => new SimpleSubscriberComponent()).ToArray();

// Act/Assert: Initial render
foreach (var component in components)
{
await renderer.Dispatcher.InvokeAsync(() => renderer.AssignRootComponentId(component));
component.TriggerRender();
Assert.Equal(1, component.NumRenders);
}

// Act/Assert: All components re-render when the cascading value changes
paramValue.ChangeValue("Final value");
await cascadingValueSource.NotifyChangedAsync();
foreach (var component in components)
{
Assert.Equal(2, component.NumRenders);
}
}

private class SingleDeliveryValue(string text)
{
public string Text => text;
Expand Down Expand Up @@ -861,6 +950,43 @@ public void AttemptIllegalAccessToLastParameterView()
}
}

class ConditionallyRenderSubscriberComponent : AutoRenderComponent
{
public int NumRenders { get; private set; }

public SimpleSubscriberComponent NestedSubscriber { get; private set; }

[Parameter] public string RenderWhenEqualTo { get; set; }

[CascadingParameter] MyParamType CascadingParameter { get; set; }

protected override void BuildRenderTree(RenderTreeBuilder builder)
{
NumRenders++;
builder.AddContent(0, $"CascadingParameter={CascadingParameter}");

if (string.Equals(RenderWhenEqualTo, CascadingParameter.ToString(), StringComparison.OrdinalIgnoreCase))
{
builder.OpenComponent<SimpleSubscriberComponent>(1);
builder.AddComponentReferenceCapture(2, component => NestedSubscriber = component as SimpleSubscriberComponent);
builder.CloseComponent();
}
}
}

class SimpleSubscriberComponent : AutoRenderComponent
{
public int NumRenders { get; private set; }

[CascadingParameter] MyParamType CascadingParameter { get; set; }

protected override void BuildRenderTree(RenderTreeBuilder builder)
{
NumRenders++;
builder.AddContent(0, $"CascadingParameter={CascadingParameter}");
}
}

class SingleDeliveryParameterConsumerComponent : AutoRenderComponent
{
public int NumSetParametersCalls { get; private set; }
Expand Down
Loading