diff --git a/src/Components/Authorization/src/CascadingAuthenticationStateServiceCollectionExtensions.cs b/src/Components/Authorization/src/CascadingAuthenticationStateServiceCollectionExtensions.cs new file mode 100644 index 000000000000..be4c47538375 --- /dev/null +++ b/src/Components/Authorization/src/CascadingAuthenticationStateServiceCollectionExtensions.cs @@ -0,0 +1,57 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extension methods for configuring cascading authentication state on a service collection. +/// +public static class CascadingAuthenticationStateServiceCollectionExtensions +{ + /// + /// Adds cascading authentication state to the . This is equivalent to + /// having a component at the root of your component hierarchy. + /// + /// The . + /// The . + public static IServiceCollection AddCascadingAuthenticationState(this IServiceCollection serviceCollection) + { + return serviceCollection.AddCascadingValue>(services => + { + var authenticationStateProvider = services.GetRequiredService(); + return new AuthenticationStateCascadingValueSource(authenticationStateProvider); + }); + } + + private sealed class AuthenticationStateCascadingValueSource : CascadingValueSource>, IDisposable + { + // This is intended to produce identical behavior to having a + // wrapped around the root component. + + private readonly AuthenticationStateProvider _authenticationStateProvider; + + public AuthenticationStateCascadingValueSource(AuthenticationStateProvider authenticationStateProvider) + : base(authenticationStateProvider.GetAuthenticationStateAsync, isFixed: false) + { + _authenticationStateProvider = authenticationStateProvider; + _authenticationStateProvider.AuthenticationStateChanged += HandleAuthenticationStateChanged; + } + + private void HandleAuthenticationStateChanged(Task newAuthStateTask) + { + // It's OK to discard the task because this only represents the duration of the dispatch to sync context. + // It handles any exceptions internally by dispatching them to the renderer within the context of whichever + // component threw when receiving the update. This is the same as how a CascadingValue doesn't get notified + // about exceptions that happen inside the recipients of value notifications. + _ = NotifyChangedAsync(newAuthStateTask); + } + + public void Dispose() + { + _authenticationStateProvider.AuthenticationStateChanged -= HandleAuthenticationStateChanged; + } + } +} diff --git a/src/Components/Authorization/src/PublicAPI.Unshipped.txt b/src/Components/Authorization/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..d26ce6553158 100644 --- a/src/Components/Authorization/src/PublicAPI.Unshipped.txt +++ b/src/Components/Authorization/src/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +Microsoft.Extensions.DependencyInjection.CascadingAuthenticationStateServiceCollectionExtensions +static Microsoft.Extensions.DependencyInjection.CascadingAuthenticationStateServiceCollectionExtensions.AddCascadingAuthenticationState(this Microsoft.Extensions.DependencyInjection.IServiceCollection! serviceCollection) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! diff --git a/src/Components/Components/src/CascadingParameterState.cs b/src/Components/Components/src/CascadingParameterState.cs index da3ba05c5dc4..a4b2637e090f 100644 --- a/src/Components/Components/src/CascadingParameterState.cs +++ b/src/Components/Components/src/CascadingParameterState.cs @@ -57,6 +57,7 @@ public static IReadOnlyList FindCascadingParameters(Com private static ICascadingValueSupplier? GetMatchingCascadingValueSupplier(in CascadingParameterInfo info, ComponentState componentState) { + // First scan up through the component hierarchy var candidate = componentState; do { @@ -68,6 +69,15 @@ public static IReadOnlyList FindCascadingParameters(Com candidate = candidate.LogicalParentComponentState; } while (candidate != null); + // We got to the root and found no match, so now look at the providers registered in DI + foreach (var valueSupplier in componentState.Renderer.ServiceProviderCascadingValueSuppliers) + { + if (valueSupplier.CanSupplyValue(info)) + { + return valueSupplier; + } + } + // No match return null; } diff --git a/src/Components/Components/src/CascadingValueServiceCollectionExtensions.cs b/src/Components/Components/src/CascadingValueServiceCollectionExtensions.cs new file mode 100644 index 000000000000..07e0ae985b58 --- /dev/null +++ b/src/Components/Components/src/CascadingValueServiceCollectionExtensions.cs @@ -0,0 +1,53 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extension methods for configuring cascading values on an . +/// +public static class CascadingValueServiceCollectionExtensions +{ + /// + /// Adds a cascading value to the . This is equivalent to having + /// a fixed at the root of the component hierarchy. + /// + /// The value type. + /// The . + /// A callback that supplies a fixed value within each service provider scope. + /// The . + public static IServiceCollection AddCascadingValue( + this IServiceCollection serviceCollection, Func valueFactory) + => serviceCollection.AddScoped(sp => new CascadingValueSource(() => valueFactory(sp), isFixed: true)); + + /// + /// Adds a cascading value to the . This is equivalent to having + /// a fixed at the root of the component hierarchy. + /// + /// The value type. + /// The . + /// A name for the cascading value. If set, can be configured to match based on this name. + /// A callback that supplies a fixed value within each service provider scope. + /// The . + public static IServiceCollection AddCascadingValue( + this IServiceCollection serviceCollection, string name, Func valueFactory) + => serviceCollection.AddScoped(sp => new CascadingValueSource(name, () => valueFactory(sp), isFixed: true)); + + /// + /// Adds a cascading value to the . This is equivalent to having + /// a at the root of the component hierarchy. + /// + /// With this overload, you can supply a which allows you + /// to notify about updates to the value later, causing recipients to re-render. This overload should + /// only be used if you plan to update the value dynamically. + /// + /// The value type. + /// The . + /// A callback that supplies a within each service provider scope. + /// The . + public static IServiceCollection AddCascadingValue( + this IServiceCollection serviceCollection, Func> sourceFactory) + => serviceCollection.AddScoped(sourceFactory); +} diff --git a/src/Components/Components/src/CascadingValueSource.cs b/src/Components/Components/src/CascadingValueSource.cs new file mode 100644 index 000000000000..680c022c71ee --- /dev/null +++ b/src/Components/Components/src/CascadingValueSource.cs @@ -0,0 +1,177 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using Microsoft.AspNetCore.Components.Rendering; + +namespace Microsoft.AspNetCore.Components; + +/// +/// Supplies a cascading value that can be received by components using +/// . +/// +public class CascadingValueSource : ICascadingValueSupplier +{ + // By *not* making this sealed, people who want to deal with value disposal can subclass this, + // add IDisposable, and then do what they want during shutdown + + private readonly ConcurrentDictionary>? _subscribers; + private readonly bool _isFixed; + private readonly string? _name; + + // You can either provide an initial value to the constructor, or a func to provide one lazily + private TValue? _currentValue; + private Func? _initialValueFactory; + + /// + /// Constructs an instance of . + /// + /// The initial value. + /// A flag to indicate whether the value is fixed. If false, all receipients will subscribe for update notifications, which you can issue by calling . These subscriptions come at a performance cost, so if the value will not change, set to true. + public CascadingValueSource(TValue value, bool isFixed) : this(isFixed) + { + _currentValue = value; + } + + /// + /// Constructs an instance of . + /// + /// A name for the cascading value. If set, can be configured to match based on this name. + /// The initial value. + /// A flag to indicate whether the value is fixed. If false, all receipients will subscribe for update notifications, which you can issue by calling . These subscriptions come at a performance cost, so if the value will not change, set to true. + public CascadingValueSource(string name, TValue value, bool isFixed) : this(value, isFixed) + { + ArgumentNullException.ThrowIfNull(name); + _name = name; + } + + /// + /// Constructs an instance of . + /// + /// A callback that produces the initial value when first required. + /// A flag to indicate whether the value is fixed. If false, all receipients will subscribe for update notifications, which you can issue by calling . These subscriptions come at a performance cost, so if the value will not change, set to true. + public CascadingValueSource(Func valueFactory, bool isFixed) : this(isFixed) + { + _initialValueFactory = valueFactory; + } + + /// + /// Constructs an instance of . + /// + /// A name for the cascading value. If set, can be configured to match based on this name. + /// A callback that produces the initial value when first required. + /// A flag to indicate whether the value is fixed. If false, all receipients will subscribe for update notifications, which you can issue by calling . These subscriptions come at a performance cost, so if the value will not change, set to true. + public CascadingValueSource(string name, Func valueFactory, bool isFixed) : this(valueFactory, isFixed) + { + ArgumentNullException.ThrowIfNull(name); + _name = name; + } + + private CascadingValueSource(bool isFixed) + { + _isFixed = isFixed; + + if (!_isFixed) + { + _subscribers = new(); + } + } + + /// + /// Notifies subscribers that the value has changed (for example, if it has been mutated). + /// + /// A that completes when the notifications have been issued. + public Task NotifyChangedAsync() + { + if (_isFixed) + { + throw new InvalidOperationException($"Cannot notify about changes because the {GetType()} is configured as fixed."); + } + + if (_subscribers?.Count > 0) + { + var tasks = new List(); + + foreach (var (dispatcher, subscribers) in _subscribers) + { + tasks.Add(dispatcher.InvokeAsync(() => + { + foreach (var subscriber in subscribers) + { + subscriber.NotifyCascadingValueChanged(ParameterViewLifetime.Unbound); + } + })); + } + + return Task.WhenAll(tasks); + } + else + { + return Task.CompletedTask; + } + } + + /// + /// Notifies subscribers that the value has changed, supplying a new value. + /// + /// + /// A that completes when the notifications have been issued. + public Task NotifyChangedAsync(TValue newValue) + { + _currentValue = newValue; + _initialValueFactory = null; // This definitely won't be used now + + return NotifyChangedAsync(); + } + + bool ICascadingValueSupplier.IsFixed => _isFixed; + + bool ICascadingValueSupplier.CanSupplyValue(in CascadingParameterInfo parameterInfo) + { + if (parameterInfo.Attribute is not CascadingParameterAttribute cascadingParameterAttribute || !parameterInfo.PropertyType.IsAssignableFrom(typeof(TValue))) + { + return false; + } + + // We only consider explicitly requested names, not the property name. + var requestedName = cascadingParameterAttribute.Name; + return (requestedName == null && _name == null) // Match on type alone + || string.Equals(requestedName, _name, StringComparison.OrdinalIgnoreCase); // Also match on name + } + + object? ICascadingValueSupplier.GetCurrentValue(in CascadingParameterInfo parameterInfo) + { + if (_initialValueFactory is not null) + { + _currentValue = _initialValueFactory(); + _initialValueFactory = null; + } + + return _currentValue; + } + + void ICascadingValueSupplier.Subscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) + { + Dispatcher dispatcher = subscriber.Renderer.Dispatcher; + dispatcher.AssertAccess(); + + // The .Add is threadsafe because we are in the sync context for this dispatcher + _subscribers?.GetOrAdd(dispatcher, _ => new()).Add(subscriber); + } + + void ICascadingValueSupplier.Unsubscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) + { + Dispatcher dispatcher = subscriber.Renderer.Dispatcher; + dispatcher.AssertAccess(); + + if (_subscribers?.TryGetValue(dispatcher, out var subscribersForDispatcher) == true) + { + // Threadsafe because we're in the sync context for this dispatcher + subscribersForDispatcher.Remove(subscriber); + if (subscribersForDispatcher.Count == 0) + { + _subscribers.Remove(dispatcher, out _); + } + } + } +} diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 4c248387cdf1..0093fc225da2 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -46,6 +46,13 @@ Microsoft.AspNetCore.Components.CascadingParameterInfo.Attribute.get -> Microsof Microsoft.AspNetCore.Components.CascadingParameterInfo.CascadingParameterInfo() -> void Microsoft.AspNetCore.Components.CascadingParameterInfo.PropertyName.get -> string! Microsoft.AspNetCore.Components.CascadingParameterInfo.PropertyType.get -> System.Type! +Microsoft.AspNetCore.Components.CascadingValueSource +Microsoft.AspNetCore.Components.CascadingValueSource.CascadingValueSource(string! name, System.Func! valueFactory, bool isFixed) -> void +Microsoft.AspNetCore.Components.CascadingValueSource.CascadingValueSource(string! name, TValue value, bool isFixed) -> void +Microsoft.AspNetCore.Components.CascadingValueSource.CascadingValueSource(System.Func! valueFactory, bool isFixed) -> void +Microsoft.AspNetCore.Components.CascadingValueSource.CascadingValueSource(TValue value, bool isFixed) -> void +Microsoft.AspNetCore.Components.CascadingValueSource.NotifyChangedAsync() -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Components.CascadingValueSource.NotifyChangedAsync(TValue newValue) -> System.Threading.Tasks.Task! Microsoft.AspNetCore.Components.ComponentBase.DispatchExceptionAsync(System.Exception! exception) -> System.Threading.Tasks.Task! Microsoft.AspNetCore.Components.IComponentRenderMode Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.PersistStateAsync(Microsoft.AspNetCore.Components.IPersistentComponentStateStore! store, Microsoft.AspNetCore.Components.Dispatcher! dispatcher) -> System.Threading.Tasks.Task! @@ -103,6 +110,7 @@ Microsoft.AspNetCore.Components.StreamRenderingAttribute.Enabled.get -> bool Microsoft.AspNetCore.Components.StreamRenderingAttribute.StreamRenderingAttribute(bool enabled) -> void *REMOVED*Microsoft.AspNetCore.Components.CascadingParameterAttribute.Name.get -> string? *REMOVED*Microsoft.AspNetCore.Components.CascadingParameterAttribute.Name.set -> void +Microsoft.Extensions.DependencyInjection.CascadingValueServiceCollectionExtensions override Microsoft.AspNetCore.Components.CascadingParameterAttribute.Name.get -> string? override Microsoft.AspNetCore.Components.CascadingParameterAttribute.Name.set -> void override Microsoft.AspNetCore.Components.EventCallback.GetHashCode() -> int @@ -113,6 +121,9 @@ override Microsoft.AspNetCore.Components.EventCallback.Equals(object? ob *REMOVED*Microsoft.AspNetCore.Components.SupplyParameterFromQueryAttribute.Name.set -> void override Microsoft.AspNetCore.Components.SupplyParameterFromQueryAttribute.Name.get -> string? override Microsoft.AspNetCore.Components.SupplyParameterFromQueryAttribute.Name.set -> void +static Microsoft.Extensions.DependencyInjection.CascadingValueServiceCollectionExtensions.AddCascadingValue(this Microsoft.Extensions.DependencyInjection.IServiceCollection! serviceCollection, string! name, System.Func! valueFactory) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static Microsoft.Extensions.DependencyInjection.CascadingValueServiceCollectionExtensions.AddCascadingValue(this Microsoft.Extensions.DependencyInjection.IServiceCollection! serviceCollection, System.Func!>! sourceFactory) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static Microsoft.Extensions.DependencyInjection.CascadingValueServiceCollectionExtensions.AddCascadingValue(this Microsoft.Extensions.DependencyInjection.IServiceCollection! serviceCollection, System.Func! valueFactory) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! virtual Microsoft.AspNetCore.Components.Binding.CascadingModelBindingProvider.Subscribe(Microsoft.AspNetCore.Components.Rendering.ComponentState! subscriber) -> void virtual Microsoft.AspNetCore.Components.Binding.CascadingModelBindingProvider.Unsubscribe(Microsoft.AspNetCore.Components.Rendering.ComponentState! subscriber) -> void virtual Microsoft.AspNetCore.Components.Rendering.ComponentState.DisposeAsync() -> System.Threading.Tasks.ValueTask diff --git a/src/Components/Components/src/RenderTree/Renderer.cs b/src/Components/Components/src/RenderTree/Renderer.cs index badbcb669daf..deccaddcf4ad 100644 --- a/src/Components/Components/src/RenderTree/Renderer.cs +++ b/src/Components/Components/src/RenderTree/Renderer.cs @@ -5,6 +5,7 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Linq; using Microsoft.AspNetCore.Components.HotReload; using Microsoft.AspNetCore.Components.Reflection; using Microsoft.AspNetCore.Components.Rendering; @@ -87,8 +88,14 @@ public Renderer(IServiceProvider serviceProvider, ILoggerFactory loggerFactory, // logger name in here as a string literal. _logger = loggerFactory.CreateLogger("Microsoft.AspNetCore.Components.RenderTree.Renderer"); _componentFactory = new ComponentFactory(componentActivator, this); + + ServiceProviderCascadingValueSuppliers = serviceProvider.GetService() is null + ? Array.Empty() + : serviceProvider.GetServices().ToArray(); } + internal ICascadingValueSupplier[] ServiceProviderCascadingValueSuppliers { get; } + internal HotReloadManager HotReloadManager { get; set; } = HotReloadManager.Default; private static IComponentActivator GetComponentActivatorOrDefault(IServiceProvider serviceProvider) diff --git a/src/Components/Components/src/Rendering/ComponentState.cs b/src/Components/Components/src/Rendering/ComponentState.cs index a98adba82b0f..302120753360 100644 --- a/src/Components/Components/src/Rendering/ComponentState.cs +++ b/src/Components/Components/src/Rendering/ComponentState.cs @@ -84,6 +84,8 @@ public ComponentState(Renderer renderer, int componentId, IComponent component, internal RenderTreeBuilder CurrentRenderTree { get; set; } + internal Renderer Renderer => _renderer; + internal void RenderIntoBatch(RenderBatchBuilder batchBuilder, RenderFragment renderFragment, out Exception? renderFragmentException) { renderFragmentException = null; @@ -181,6 +183,15 @@ internal void SetDirectParameters(ParameterView parameters) internal void NotifyCascadingValueChanged(in ParameterViewLifetime lifetime) { + // If the component was already disposed, we must not try to supply new parameters. Among other reasons, + // _latestDirectParametersSnapshot will already have been disposed and that puts it into an invalid state + // so we can't even read from it. Note that disposal doesn't instantly trigger unsubscription from cascading + // values - that only happens when the ComponentState is processed later by the disposal queue. + if (_componentWasDisposed) + { + return; + } + var directParams = _latestDirectParametersSnapshot != null ? new ParameterView(lifetime, _latestDirectParametersSnapshot.Buffer, 0) : ParameterView.Empty; diff --git a/src/Components/Components/test/CascadingParameterTest.cs b/src/Components/Components/test/CascadingParameterTest.cs index ba97eeb110f0..e2792934f63e 100644 --- a/src/Components/Components/test/CascadingParameterTest.cs +++ b/src/Components/Components/test/CascadingParameterTest.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Components.RenderTree; using Microsoft.AspNetCore.Components.Test.Helpers; +using Microsoft.Extensions.DependencyInjection; namespace Microsoft.AspNetCore.Components.Test; @@ -437,6 +438,223 @@ public void CanSupplyCascadingValuesForSpecificCascadingParameterAttributeType() }); } + [Fact] + public void CanSupplyCascadingValueFromServiceProvider() + { + // Arrange + var services = new ServiceCollection(); + var constructionCount = 0; + services.AddCascadingValue(_ => + { + constructionCount++; + return new MyParamType("Hello"); + }); + var renderer = new TestRenderer(services.BuildServiceProvider()); + + // Assert: The value is constructed lazily, so we won't have been asked for it yet, even if some + // related components were rendered + var unrelatedComponentId = renderer.AssignRootComponentId(new TestComponent(_ => { })); + renderer.RenderRootComponent(unrelatedComponentId); + Assert.Equal(0, constructionCount); + + // Act/Assert: Render a component that consumes the value + var component = new CascadingParameterConsumerComponent { RegularParameter = "Goodbye" }; + var componentId = renderer.AssignRootComponentId(component); + Assert.Equal(0, constructionCount); + renderer.RenderRootComponent(componentId); + Assert.Equal(1, constructionCount); + var batch = renderer.Batches.Skip(1).Single(); + var diff = batch.DiffsByComponentId[componentId].Single(); + + // The component was rendered with the correct parameters + Assert.Collection(diff.Edits, + edit => + { + Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type); + AssertFrame.Text( + batch.ReferenceFrames[edit.ReferenceFrameIndex], + "CascadingParameter=Hello; RegularParameter=Goodbye"); + }); + Assert.Equal(1, component.NumRenders); + + // Act/Assert: Even if another component consumes the value, we don't call the factory again + var anotherConsumer = new CascadingParameterConsumerComponent { RegularParameter = "Goodbye" }; + var anotherConsumerComponentId = renderer.AssignRootComponentId(anotherConsumer); + renderer.RenderRootComponent(anotherConsumerComponentId); + Assert.Equal(1, constructionCount); + Assert.Same(component.GetCascadingParameterValue(), anotherConsumer.GetCascadingParameterValue()); + } + + [Fact] + public void CanSupplyCascadingValueFromServiceProviderUsingName() + { + // Arrange + var services = new ServiceCollection(); + services.AddCascadingValue("Ignored", _ => new MyParamType("Should be ignored")); + services.AddCascadingValue("My cascading parameter name", _ => new MyParamType("Should be used")); + services.AddCascadingValue("Also ignored", _ => new MyParamType("Should also be ignored")); + var renderer = new TestRenderer(services.BuildServiceProvider()); + var component = new ConsumeNamedCascadingValueComponent(); + + // Act/Assert + var componentId = renderer.AssignRootComponentId(component); + renderer.RenderRootComponent(componentId); + var batch = renderer.Batches.Single(); + var diff = batch.DiffsByComponentId[componentId].Single(); + + // The component was rendered with the correct parameters + Assert.Collection(diff.Edits, + edit => + { + Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type); + AssertFrame.Text( + batch.ReferenceFrames[edit.ReferenceFrameIndex], + "The value is 'Should be used'"); + }); + } + + [Fact] + public void PrefersComponentHierarchyCascadingValuesOverServiceProviderValues() + { + // Arrange + var services = new ServiceCollection(); + services.AddCascadingValue(_ => new MyParamType("Hello from services (this should be overridden)")); + var renderer = new TestRenderer(services.BuildServiceProvider()); + var component = new TestComponent(builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "Value", new MyParamType("Hello from component hierarchy")); + builder.AddComponentParameter(2, "ChildContent", new RenderFragment(childBuilder => + { + childBuilder.OpenComponent>(0); + childBuilder.AddComponentParameter(1, "RegularParameter", "Goodbye"); + childBuilder.CloseComponent(); + })); + builder.CloseComponent(); + }); + + // Act/Assert + var componentId = renderer.AssignRootComponentId(component); + renderer.RenderRootComponent(componentId); + var batch = renderer.Batches.Single(); + var nestedComponent = FindComponent>(batch, out var nestedComponentId); + var nestedComponentDiff = batch.DiffsByComponentId[nestedComponentId].Single(); + + // The nested component was rendered with the correct parameters + Assert.Collection(nestedComponentDiff.Edits, + edit => + { + Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type); + AssertFrame.Text( + batch.ReferenceFrames[edit.ReferenceFrameIndex], + "CascadingParameter=Hello from component hierarchy; RegularParameter=Goodbye"); + }); + Assert.Equal(1, nestedComponent.NumRenders); + } + + [Fact] + public void ThrowsIfAttemptingToSubscribeToCascadingValueSourceOutsideSyncContext() + { + // Arrange + var services = new ServiceCollection(); + var cascadingValueSource = new CascadingValueSource(new MyParamType("Initial value"), isFixed: false); + services.AddCascadingValue(_ => cascadingValueSource); + var renderer = new TestRenderer(services.BuildServiceProvider()); + var component = new CascadingParameterConsumerComponent(); + + // Act/Assert: Throws because this is where it tries to attach to the CascadingValueSource + var ex = Assert.Throws(() => renderer.AssignRootComponentId(component)); + Assert.Contains("The current thread is not associated with the Dispatcher", ex.Message); + } + + [Fact] + public async Task CanTriggerUpdatesOnCascadingValuesFromServiceProvider() + { + // Arrange + var services = new ServiceCollection(); + var myParamValue = new MyParamType("Initial value"); + var cascadingValueSource = new CascadingValueSource(myParamValue, isFixed: false); + services.AddCascadingValue(_ => cascadingValueSource); + var renderer = new TestRenderer(services.BuildServiceProvider()); + var component = new CascadingParameterConsumerComponent { RegularParameter = "Goodbye" }; + + // Act/Assert 1: 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; RegularParameter=Goodbye"); + }); + Assert.Equal(1, component.NumRenders); + + // Act/Assert 2: Notify about a mutation + myParamValue.ChangeValue("Mutated value"); + await cascadingValueSource.NotifyChangedAsync(); + + Assert.Equal(2, renderer.Batches.Count); + var secondBatch = renderer.Batches[1]; + var diff2 = secondBatch.DiffsByComponentId[componentId].Single(); + + // The nested component was rendered with the correct parameters + Assert.Collection(diff2.Edits, + edit => + { + Assert.Equal(RenderTreeEditType.UpdateText, edit.Type); + Assert.Equal(0, edit.ReferenceFrameIndex); // This is the only change + AssertFrame.Text(secondBatch.ReferenceFrames[0], "CascadingParameter=Mutated value; RegularParameter=Goodbye"); + }); + Assert.Equal(2, component.NumRenders); + + // Act/Assert 3: Notify about a completely different object + await cascadingValueSource.NotifyChangedAsync(new MyParamType("Whole new object")); + Assert.Equal(3, renderer.Batches.Count); + var thirdBatch = renderer.Batches[2]; + var diff3 = thirdBatch.DiffsByComponentId[componentId].Single(); + + // The nested component was rendered with the correct parameters + Assert.Collection(diff3.Edits, + edit => + { + Assert.Equal(RenderTreeEditType.UpdateText, edit.Type); + Assert.Equal(0, edit.ReferenceFrameIndex); // This is the only change + AssertFrame.Text(thirdBatch.ReferenceFrames[0], "CascadingParameter=Whole new object; RegularParameter=Goodbye"); + }); + Assert.Equal(3, component.NumRenders); + + // Disposing the subscriber does not cause any error + // We can't really observe any more than this because disposing is what causes unsubscription, and once you're + // disposed you're not getting notifications anyway, so the most we can say is there was no error + await renderer.Dispatcher.InvokeAsync(() => renderer.RemoveRootComponent(componentId)); + await cascadingValueSource.NotifyChangedAsync(new MyParamType("Nobody is listening, but this shouldn't be an error")); + } + + [Fact] + public async Task AfterSupplyingValueThroughNotifyChanged_InitialValueFactoryIsNotUsed() + { + // Arrange + var services = new ServiceCollection(); + var cascadingValueSource = new CascadingValueSource( + () => throw new InvalidOperationException("This should not be used because NotifyChanged is called with a value first"), isFixed: false); + services.AddCascadingValue(_ => cascadingValueSource); + var renderer = new TestRenderer(services.BuildServiceProvider()); + var component = new CascadingParameterConsumerComponent { RegularParameter = "Goodbye" }; + + // Act: Supply an update before the value is first consumed + var updatedValue = new MyParamType("Updated value"); + await cascadingValueSource.NotifyChangedAsync(updatedValue); + + // Assert: We see the supplied value, and the factory isn't used (it would have thrown) + var componentId = await renderer.Dispatcher.InvokeAsync(() => renderer.AssignRootComponentId(component)); + renderer.RenderRootComponent(componentId); + Assert.Same(updatedValue, component.GetCascadingParameterValue()); + } + private static T FindComponent(CapturedBatch batch, out int componentId) { var componentFrame = batch.ReferenceFrames.Single( @@ -469,6 +687,8 @@ class CascadingParameterConsumerComponent : AutoRenderComponent [CascadingParameter] T CascadingParameter { get; set; } [Parameter] public string RegularParameter { get; set; } + public T GetCascadingParameterValue() => CascadingParameter; + public override async Task SetParametersAsync(ParameterView parameters) { lastParameterView = parameters; @@ -569,4 +789,25 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.AddContent(0, $"Value 2 is '{Value}'."); } } + + class ConsumeNamedCascadingValueComponent : AutoRenderComponent + { + [CascadingParameter(Name = "My cascading parameter name")] + public object Value { get; set; } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.AddContent(0, $"The value is '{Value}'"); + } + } + + class MyParamType(string StringValue) + { + public override string ToString() => StringValue; + + public void ChangeValue(string newValue) + { + StringValue = newValue; + } + } } diff --git a/src/Components/test/E2ETest/ServerRenderingTests/AuthTests/ServerRenderedAuthenticationStateTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/AuthTests/ServerRenderedAuthenticationStateTest.cs new file mode 100644 index 000000000000..378b146dd787 --- /dev/null +++ b/src/Components/test/E2ETest/ServerRenderingTests/AuthTests/ServerRenderedAuthenticationStateTest.cs @@ -0,0 +1,65 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Components.TestServer.RazorComponents; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; +using TestServer; +using Microsoft.AspNetCore.E2ETesting; +using Xunit.Abstractions; +using OpenQA.Selenium; + +namespace Microsoft.AspNetCore.Components.E2ETests.ServerRenderingTests.AuthTests; + +public class ServerRenderedAuthenticationStateTest + : ServerTestBase>> +{ + public ServerRenderedAuthenticationStateTest( + BrowserFixture browserFixture, + BasicTestAppServerSiteFixture> serverFixture, + ITestOutputHelper output) + : base(browserFixture, serverFixture, output) + { + } + + [Fact] + public void CanUseServerAuthenticationState_Static() + { + Navigate($"{ServerPathBase}/auth/static-authentication-state"); + + Browser.Equal("False", () => Browser.FindElement(By.Id("identity-authenticated")).Text); + Browser.Equal("", () => Browser.FindElement(By.Id("identity-name")).Text); + Browser.Equal("(none)", () => Browser.FindElement(By.Id("test-claim")).Text); + + Browser.Click(By.LinkText("Log in")); + + Browser.Equal("True", () => Browser.FindElement(By.Id("identity-authenticated")).Text); + Browser.Equal("YourUsername", () => Browser.FindElement(By.Id("identity-name")).Text); + Browser.Equal("Test claim value", () => Browser.FindElement(By.Id("test-claim")).Text); + + Browser.Click(By.LinkText("Log out")); + Browser.Equal("False", () => Browser.FindElement(By.Id("identity-authenticated")).Text); + } + + [Fact] + public void CanUseServerAuthenticationState_Interactive() + { + Navigate($"{ServerPathBase}/auth/interactive-authentication-state"); + + Browser.Equal("True", () => Browser.FindElement(By.Id("is-interactive")).Text); + Browser.Equal("False", () => Browser.FindElement(By.Id("identity-authenticated")).Text); + Browser.Equal("", () => Browser.FindElement(By.Id("identity-name")).Text); + Browser.Equal("(none)", () => Browser.FindElement(By.Id("test-claim")).Text); + + Browser.Click(By.LinkText("Log in")); + + Browser.Equal("True", () => Browser.FindElement(By.Id("is-interactive")).Text); + Browser.Equal("True", () => Browser.FindElement(By.Id("identity-authenticated")).Text); + Browser.Equal("YourUsername", () => Browser.FindElement(By.Id("identity-name")).Text); + Browser.Equal("Test claim value", () => Browser.FindElement(By.Id("test-claim")).Text); + + Browser.Click(By.LinkText("Log out")); + Browser.Equal("True", () => Browser.FindElement(By.Id("is-interactive")).Text); + Browser.Equal("False", () => Browser.FindElement(By.Id("identity-authenticated")).Text); + } +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs index 3c4e60ff478e..4b8f03a4fa21 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs +++ b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs @@ -2,11 +2,10 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; +using System.Security.Claims; using Components.TestServer.RazorComponents; -using Components.TestServer.RazorComponents.Pages; using Components.TestServer.RazorComponents.Pages.Forms; using Components.TestServer.Services; -using Microsoft.AspNetCore.Components; namespace TestServer; @@ -30,6 +29,7 @@ public void ConfigureServices(IServiceCollection services) }); services.AddHttpContextAccessor(); services.AddSingleton(); + services.AddCascadingAuthenticationState(); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. @@ -48,6 +48,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { app.UseStaticFiles(); app.UseRouting(); + UseFakeAuthState(app); app.UseEndpoints(endpoints => { endpoints.MapRazorComponents() @@ -62,6 +63,44 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) }); } + private static void UseFakeAuthState(IApplicationBuilder app) + { + app.Use((HttpContext context, Func next) => + { + // Completely insecure fake auth system with no password for tests. Do not do anything like this in real apps. + // It accepts a query parameter 'username' and then sets or deletes a cookie to hold that, and supplies a principal + // using this username (taken either from the cookie or query param). + const string cookieKey = "fake_username"; + context.Request.Cookies.TryGetValue(cookieKey, out var username); + if (context.Request.Query.TryGetValue("username", out var usernameFromQuery)) + { + username = usernameFromQuery; + if (string.IsNullOrEmpty(username)) + { + context.Response.Cookies.Delete(cookieKey); + } + else + { + // Expires when browser is closed, so tests won't interfere with each other + context.Response.Cookies.Append(cookieKey, username); + } + } + + if (!string.IsNullOrEmpty(username)) + { + var claims = new List + { + new Claim(ClaimTypes.Name, username), + new Claim("test-claim", "Test claim value"), + }; + + context.User = new ClaimsPrincipal(new ClaimsIdentity(claims, "FakeAuthenticationType")); + } + + return next(); + }); + } + private static void MapEnhancedNavigationEndpoints(IEndpointRouteBuilder endpoints) { // Used when testing that enhanced nav can show non-HTML responses (which it does by doing a full navigation) diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Auth/InteractiveAuthenticationState.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Auth/InteractiveAuthenticationState.razor new file mode 100644 index 000000000000..77199f976abb --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Auth/InteractiveAuthenticationState.razor @@ -0,0 +1,30 @@ +@page "/auth/interactive-authentication-state" +@attribute [RenderModeServer] +@inject NavigationManager Nav +@using BasicTestApp.AuthTest + +

Interactive authentication state

+ + +

+ Interactive: + @_interactive +

+ +
+ +Log in | +Log out + +@code { + bool _interactive = false; + + protected override void OnAfterRender(bool firstRender) + { + if (firstRender) + { + _interactive = true; + StateHasChanged(); + } + } +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Auth/StaticAuthenticationState.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Auth/StaticAuthenticationState.razor new file mode 100644 index 000000000000..b374bf50bab7 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Auth/StaticAuthenticationState.razor @@ -0,0 +1,11 @@ +@page "/auth/static-authentication-state" +@inject NavigationManager Nav +@using BasicTestApp.AuthTest + +

Static authentication state

+ + +
+ +Log in | +Log out