diff --git a/src/Components/Components/src/IPersistentComponentStateStore.cs b/src/Components/Components/src/IPersistentComponentStateStore.cs
index 1b14bca2a3ee..cd4c63ddbdf7 100644
--- a/src/Components/Components/src/IPersistentComponentStateStore.cs
+++ b/src/Components/Components/src/IPersistentComponentStateStore.cs
@@ -20,4 +20,11 @@ public interface IPersistentComponentStateStore
/// The serialized state to persist.
/// A that completes when the state is persisted to disk.
Task PersistStateAsync(IReadOnlyDictionary state);
+
+ ///
+ /// Returns a value that indicates whether the store supports the given .
+ ///
+ /// The in question.
+ /// true if the render mode is supported by the store, otherwise false.
+ bool SupportsRenderMode(IComponentRenderMode renderMode) => true;
}
diff --git a/src/Components/Components/src/Infrastructure/ComponentStatePersistenceManager.cs b/src/Components/Components/src/Infrastructure/ComponentStatePersistenceManager.cs
index 6886ea4a4bce..e1b4fdf605ec 100644
--- a/src/Components/Components/src/Infrastructure/ComponentStatePersistenceManager.cs
+++ b/src/Components/Components/src/Infrastructure/ComponentStatePersistenceManager.cs
@@ -11,17 +11,18 @@ namespace Microsoft.AspNetCore.Components.Infrastructure;
///
public class ComponentStatePersistenceManager
{
+ private readonly List _registeredCallbacks = new();
+ private readonly ILogger _logger;
+
private bool _stateIsPersisted;
- private readonly List> _pauseCallbacks = new();
private readonly Dictionary _currentState = new(StringComparer.Ordinal);
- private readonly ILogger _logger;
///
/// Initializes a new instance of .
///
public ComponentStatePersistenceManager(ILogger logger)
{
- State = new PersistentComponentState(_currentState, _pauseCallbacks);
+ State = new PersistentComponentState(_currentState, _registeredCallbacks);
_logger = logger;
}
@@ -48,43 +49,100 @@ public async Task RestoreStateAsync(IPersistentComponentStateStore store)
/// The that components are being rendered.
/// A that will complete when the state has been restored.
public Task PersistStateAsync(IPersistentComponentStateStore store, Renderer renderer)
- => PersistStateAsync(store, renderer.Dispatcher);
-
- ///
- /// Persists the component application state into the given .
- ///
- /// The to restore the application state from.
- /// The corresponding to the components' renderer.
- /// A that will complete when the state has been restored.
- public Task PersistStateAsync(IPersistentComponentStateStore store, Dispatcher dispatcher)
{
if (_stateIsPersisted)
{
throw new InvalidOperationException("State already persisted.");
}
- _stateIsPersisted = true;
-
- return dispatcher.InvokeAsync(PauseAndPersistState);
+ return renderer.Dispatcher.InvokeAsync(PauseAndPersistState);
async Task PauseAndPersistState()
{
State.PersistingState = true;
- await PauseAsync();
+
+ if (store is IEnumerable compositeStore)
+ {
+ // We only need to do inference when there is more than one store. This is determined by
+ // the set of rendered components.
+ InferRenderModes(renderer);
+
+ // Iterate over each store and give it a chance to run against the existing declared
+ // render modes. After we've run through a store, we clear the current state so that
+ // the next store can start with a clean slate.
+ foreach (var store in compositeStore)
+ {
+ await PersistState(store);
+ _currentState.Clear();
+ }
+ }
+ else
+ {
+ await PersistState(store);
+ }
+
State.PersistingState = false;
+ _stateIsPersisted = true;
+ }
+ async Task PersistState(IPersistentComponentStateStore store)
+ {
+ await PauseAsync(store);
await store.PersistStateAsync(_currentState);
}
}
- internal Task PauseAsync()
+ private void InferRenderModes(Renderer renderer)
+ {
+ for (var i = 0; i < _registeredCallbacks.Count; i++)
+ {
+ var registration = _registeredCallbacks[i];
+ if (registration.RenderMode != null)
+ {
+ // Explicitly set render mode, so nothing to do.
+ continue;
+ }
+
+ if (registration.Callback.Target is IComponent component)
+ {
+ var componentRenderMode = renderer.GetComponentRenderMode(component);
+ if (componentRenderMode != null)
+ {
+ _registeredCallbacks[i] = new PersistComponentStateRegistration(registration.Callback, componentRenderMode);
+ }
+ else
+ {
+ // If we can't find a render mode, it's an SSR only component and we don't need to
+ // persist its state at all.
+ _registeredCallbacks[i] = default;
+ }
+ continue;
+ }
+
+ throw new InvalidOperationException(
+ $"The registered callback {registration.Callback.Method.Name} must be associated with a component or define" +
+ $" an explicit render mode type during registration.");
+ }
+ }
+
+ internal Task PauseAsync(IPersistentComponentStateStore store)
{
List? pendingCallbackTasks = null;
- for (var i = 0; i < _pauseCallbacks.Count; i++)
+ for (var i = 0; i < _registeredCallbacks.Count; i++)
{
- var callback = _pauseCallbacks[i];
- var result = ExecuteCallback(callback, _logger);
+ var registration = _registeredCallbacks[i];
+
+ if (!store.SupportsRenderMode(registration.RenderMode!))
+ {
+ // The callback does not have an associated render mode and we are in a multi-store scenario.
+ // Otherwise, in a single store scenario, we just run the callback.
+ // If the registration callback is null, it's because it was associated with a component and we couldn't infer
+ // its render mode, which means is an SSR only component and we don't need to persist its state at all.
+ continue;
+ }
+
+ var result = ExecuteCallback(registration.Callback, _logger);
if (!result.IsCompletedSuccessfully)
{
pendingCallbackTasks ??= new();
diff --git a/src/Components/Components/src/PersistComponentStateRegistration.cs b/src/Components/Components/src/PersistComponentStateRegistration.cs
new file mode 100644
index 000000000000..0f874970f4e1
--- /dev/null
+++ b/src/Components/Components/src/PersistComponentStateRegistration.cs
@@ -0,0 +1,13 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.AspNetCore.Components;
+
+internal readonly struct PersistComponentStateRegistration(
+ Func callback,
+ IComponentRenderMode? renderMode)
+{
+ public Func Callback { get; } = callback;
+
+ public IComponentRenderMode? RenderMode { get; } = renderMode;
+}
diff --git a/src/Components/Components/src/PersistentComponentState.cs b/src/Components/Components/src/PersistentComponentState.cs
index c7dd82965e43..bc193dd77e5f 100644
--- a/src/Components/Components/src/PersistentComponentState.cs
+++ b/src/Components/Components/src/PersistentComponentState.cs
@@ -15,11 +15,11 @@ public class PersistentComponentState
private IDictionary? _existingState;
private readonly IDictionary _currentState;
- private readonly List> _registeredCallbacks;
+ private readonly List _registeredCallbacks;
internal PersistentComponentState(
- IDictionary currentState,
- List> pauseCallbacks)
+ IDictionary currentState,
+ List pauseCallbacks)
{
_currentState = currentState;
_registeredCallbacks = pauseCallbacks;
@@ -43,12 +43,24 @@ internal void InitializeExistingState(IDictionary existingState)
/// The callback to invoke when the application is being paused.
/// A subscription that can be used to unregister the callback when disposed.
public PersistingComponentStateSubscription RegisterOnPersisting(Func callback)
+ => RegisterOnPersisting(callback, null);
+
+ ///
+ /// Register a callback to persist the component state when the application is about to be paused.
+ /// Registered callbacks can use this opportunity to persist their state so that it can be retrieved when the application resumes.
+ ///
+ /// The callback to invoke when the application is being paused.
+ ///
+ /// A subscription that can be used to unregister the callback when disposed.
+ public PersistingComponentStateSubscription RegisterOnPersisting(Func callback, IComponentRenderMode? renderMode)
{
ArgumentNullException.ThrowIfNull(callback);
- _registeredCallbacks.Add(callback);
+ var persistenceCallback = new PersistComponentStateRegistration(callback, renderMode);
+
+ _registeredCallbacks.Add(persistenceCallback);
- return new PersistingComponentStateSubscription(_registeredCallbacks, callback);
+ return new PersistingComponentStateSubscription(_registeredCallbacks, persistenceCallback);
}
///
diff --git a/src/Components/Components/src/PersistingComponentStateSubscription.cs b/src/Components/Components/src/PersistingComponentStateSubscription.cs
index cba41a5de0e3..41e4fb7c4bb1 100644
--- a/src/Components/Components/src/PersistingComponentStateSubscription.cs
+++ b/src/Components/Components/src/PersistingComponentStateSubscription.cs
@@ -11,10 +11,10 @@ namespace Microsoft.AspNetCore.Components;
///
public readonly struct PersistingComponentStateSubscription : IDisposable
{
- private readonly List>? _callbacks;
- private readonly Func? _callback;
+ private readonly List? _callbacks;
+ private readonly PersistComponentStateRegistration? _callback;
- internal PersistingComponentStateSubscription(List> callbacks, Func callback)
+ internal PersistingComponentStateSubscription(List callbacks, PersistComponentStateRegistration callback)
{
_callbacks = callbacks;
_callback = callback;
@@ -23,9 +23,9 @@ internal PersistingComponentStateSubscription(List> callbacks, Func
public void Dispose()
{
- if (_callback != null)
+ if (_callback.HasValue)
{
- _callbacks?.Remove(_callback);
+ _callbacks?.Remove(_callback.Value);
}
}
}
diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt
index aae79b9250c9..49480b8aa1c6 100644
--- a/src/Components/Components/src/PublicAPI.Unshipped.txt
+++ b/src/Components/Components/src/PublicAPI.Unshipped.txt
@@ -16,11 +16,12 @@ Microsoft.AspNetCore.Components.CascadingValueSource.NotifyChangedAsync(
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!
Microsoft.AspNetCore.Components.InjectAttribute.Key.get -> object?
Microsoft.AspNetCore.Components.InjectAttribute.Key.init -> void
+Microsoft.AspNetCore.Components.IPersistentComponentStateStore.SupportsRenderMode(Microsoft.AspNetCore.Components.IComponentRenderMode! renderMode) -> bool
Microsoft.AspNetCore.Components.ParameterView.ToDictionary() -> System.Collections.Generic.IReadOnlyDictionary!
*REMOVED*Microsoft.AspNetCore.Components.ParameterView.ToDictionary() -> System.Collections.Generic.IReadOnlyDictionary!
+Microsoft.AspNetCore.Components.PersistentComponentState.RegisterOnPersisting(System.Func! callback, Microsoft.AspNetCore.Components.IComponentRenderMode? renderMode) -> Microsoft.AspNetCore.Components.PersistingComponentStateSubscription
Microsoft.AspNetCore.Components.RenderHandle.DispatchExceptionAsync(System.Exception! exception) -> System.Threading.Tasks.Task!
*REMOVED*Microsoft.AspNetCore.Components.NavigationManager.ToAbsoluteUri(string! relativeUri) -> System.Uri!
Microsoft.AspNetCore.Components.NavigationManager.ToAbsoluteUri(string? relativeUri) -> System.Uri!
@@ -44,6 +45,7 @@ Microsoft.AspNetCore.Components.RenderTree.NamedEventChangeType
Microsoft.AspNetCore.Components.RenderTree.NamedEventChangeType.Added = 0 -> Microsoft.AspNetCore.Components.RenderTree.NamedEventChangeType
Microsoft.AspNetCore.Components.RenderTree.NamedEventChangeType.Removed = 1 -> Microsoft.AspNetCore.Components.RenderTree.NamedEventChangeType
Microsoft.AspNetCore.Components.RenderTree.RenderBatch.NamedEventChanges.get -> Microsoft.AspNetCore.Components.RenderTree.ArrayRange?
+Microsoft.AspNetCore.Components.RenderTree.Renderer.GetComponentState(Microsoft.AspNetCore.Components.IComponent! component) -> Microsoft.AspNetCore.Components.Rendering.ComponentState!
Microsoft.AspNetCore.Components.RenderTree.RenderTreeFrame.ComponentFrameFlags.get -> Microsoft.AspNetCore.Components.RenderTree.ComponentFrameFlags
Microsoft.AspNetCore.Components.RenderTree.RenderTreeFrameType.ComponentRenderMode = 9 -> Microsoft.AspNetCore.Components.RenderTree.RenderTreeFrameType
Microsoft.AspNetCore.Components.RenderTree.RenderTreeFrameType.NamedEvent = 10 -> Microsoft.AspNetCore.Components.RenderTree.RenderTreeFrameType
@@ -101,6 +103,7 @@ virtual Microsoft.AspNetCore.Components.Rendering.ComponentState.DisposeAsync()
virtual Microsoft.AspNetCore.Components.RenderTree.Renderer.AddPendingTask(Microsoft.AspNetCore.Components.Rendering.ComponentState? componentState, System.Threading.Tasks.Task! task) -> void
virtual Microsoft.AspNetCore.Components.RenderTree.Renderer.CreateComponentState(int componentId, Microsoft.AspNetCore.Components.IComponent! component, Microsoft.AspNetCore.Components.Rendering.ComponentState? parentComponentState) -> Microsoft.AspNetCore.Components.Rendering.ComponentState!
virtual Microsoft.AspNetCore.Components.RenderTree.Renderer.DispatchEventAsync(ulong eventHandlerId, Microsoft.AspNetCore.Components.RenderTree.EventFieldInfo? fieldInfo, System.EventArgs! eventArgs, bool waitForQuiescence) -> System.Threading.Tasks.Task!
+virtual Microsoft.AspNetCore.Components.RenderTree.Renderer.GetComponentRenderMode(Microsoft.AspNetCore.Components.IComponent! component) -> Microsoft.AspNetCore.Components.IComponentRenderMode?
virtual Microsoft.AspNetCore.Components.RenderTree.Renderer.ResolveComponentForRenderMode(System.Type! componentType, int? parentComponentId, Microsoft.AspNetCore.Components.IComponentActivator! componentActivator, Microsoft.AspNetCore.Components.IComponentRenderMode! renderMode) -> Microsoft.AspNetCore.Components.IComponent!
~Microsoft.AspNetCore.Components.RenderTree.RenderTreeFrame.ComponentRenderMode.get -> Microsoft.AspNetCore.Components.IComponentRenderMode
~Microsoft.AspNetCore.Components.RenderTree.RenderTreeFrame.NamedEventAssignedName.get -> string
diff --git a/src/Components/Components/src/RenderTree/Renderer.cs b/src/Components/Components/src/RenderTree/Renderer.cs
index f1ba4a9e2b93..7b96e683131f 100644
--- a/src/Components/Components/src/RenderTree/Renderer.cs
+++ b/src/Components/Components/src/RenderTree/Renderer.cs
@@ -133,7 +133,20 @@ private static IComponentActivator GetComponentActivatorOrDefault(IServiceProvid
protected ComponentState GetComponentState(int componentId)
=> GetRequiredComponentState(componentId);
- internal ComponentState GetComponentState(IComponent component)
+ ///
+ /// Gets the for a given component if available.
+ ///
+ /// The component type
+ ///
+ protected internal virtual IComponentRenderMode? GetComponentRenderMode(IComponent component)
+ => null;
+
+ ///
+ /// Resolves the component state for a given instance.
+ ///
+ /// The instance
+ ///
+ protected internal ComponentState GetComponentState(IComponent component)
=> _componentStateByComponent.GetValueOrDefault(component);
private async void RenderRootComponentsOnHotReload()
diff --git a/src/Components/Components/test/Lifetime/ComponentApplicationStateTest.cs b/src/Components/Components/test/Lifetime/ComponentApplicationStateTest.cs
index 5d1d6983d91c..bed42d42dfbf 100644
--- a/src/Components/Components/test/Lifetime/ComponentApplicationStateTest.cs
+++ b/src/Components/Components/test/Lifetime/ComponentApplicationStateTest.cs
@@ -11,7 +11,7 @@ public class ComponentApplicationStateTest
public void InitializeExistingState_SetupsState()
{
// Arrange
- var applicationState = new PersistentComponentState(new Dictionary(), new List>());
+ var applicationState = new PersistentComponentState(new Dictionary(), new List());
var existingState = new Dictionary
{
["MyState"] = JsonSerializer.SerializeToUtf8Bytes(new byte[] { 1, 2, 3, 4 })
@@ -29,7 +29,7 @@ public void InitializeExistingState_SetupsState()
public void InitializeExistingState_ThrowsIfAlreadyInitialized()
{
// Arrange
- var applicationState = new PersistentComponentState(new Dictionary(), new List>());
+ var applicationState = new PersistentComponentState(new Dictionary(), new List());
var existingState = new Dictionary
{
["MyState"] = new byte[] { 1, 2, 3, 4 }
@@ -45,7 +45,7 @@ public void InitializeExistingState_ThrowsIfAlreadyInitialized()
public void TryRetrieveState_ReturnsStateWhenItExists()
{
// Arrange
- var applicationState = new PersistentComponentState(new Dictionary(), new List>());
+ var applicationState = new PersistentComponentState(new Dictionary(), new List());
var existingState = new Dictionary
{
["MyState"] = JsonSerializer.SerializeToUtf8Bytes(new byte[] { 1, 2, 3, 4 })
@@ -65,8 +65,10 @@ public void PersistState_SavesDataToTheStoreAsync()
{
// Arrange
var currentState = new Dictionary();
- var applicationState = new PersistentComponentState(currentState, new List>());
- applicationState.PersistingState = true;
+ var applicationState = new PersistentComponentState(currentState, new List())
+ {
+ PersistingState = true
+ };
var myState = new byte[] { 1, 2, 3, 4 };
// Act
@@ -82,8 +84,10 @@ public void PersistState_ThrowsForDuplicateKeys()
{
// Arrange
var currentState = new Dictionary();
- var applicationState = new PersistentComponentState(currentState, new List>());
- applicationState.PersistingState = true;
+ var applicationState = new PersistentComponentState(currentState, new List())
+ {
+ PersistingState = true
+ };
var myState = new byte[] { 1, 2, 3, 4 };
applicationState.PersistAsJson("MyState", myState);
@@ -97,8 +101,10 @@ public void PersistAsJson_SerializesTheDataToJsonAsync()
{
// Arrange
var currentState = new Dictionary();
- var applicationState = new PersistentComponentState(currentState, new List>());
- applicationState.PersistingState = true;
+ var applicationState = new PersistentComponentState(currentState, new List())
+ {
+ PersistingState = true
+ };
var myState = new byte[] { 1, 2, 3, 4 };
// Act
@@ -114,8 +120,10 @@ public void PersistAsJson_NullValueAsync()
{
// Arrange
var currentState = new Dictionary();
- var applicationState = new PersistentComponentState(currentState, new List>());
- applicationState.PersistingState = true;
+ var applicationState = new PersistentComponentState(currentState, new List())
+ {
+ PersistingState = true
+ };
// Act
applicationState.PersistAsJson("MyState", null);
@@ -132,7 +140,7 @@ public void TryRetrieveFromJson_DeserializesTheDataFromJson()
var myState = new byte[] { 1, 2, 3, 4 };
var serialized = JsonSerializer.SerializeToUtf8Bytes(myState);
var existingState = new Dictionary() { ["MyState"] = serialized };
- var applicationState = new PersistentComponentState(new Dictionary(), new List>());
+ var applicationState = new PersistentComponentState(new Dictionary(), new List());
applicationState.InitializeExistingState(existingState);
@@ -150,7 +158,7 @@ public void TryRetrieveFromJson_NullValue()
// Arrange
var serialized = JsonSerializer.SerializeToUtf8Bytes(null);
var existingState = new Dictionary() { ["MyState"] = serialized };
- var applicationState = new PersistentComponentState(new Dictionary(), new List>());
+ var applicationState = new PersistentComponentState(new Dictionary(), new List());
applicationState.InitializeExistingState(existingState);
diff --git a/src/Components/Components/test/Lifetime/ComponentApplicationLifetimeTest.cs b/src/Components/Components/test/Lifetime/ComponentStatePersistenceManagerTest.cs
similarity index 82%
rename from src/Components/Components/test/Lifetime/ComponentApplicationLifetimeTest.cs
rename to src/Components/Components/test/Lifetime/ComponentStatePersistenceManagerTest.cs
index 034bd10e5b70..02b104d41f43 100644
--- a/src/Components/Components/test/Lifetime/ComponentApplicationLifetimeTest.cs
+++ b/src/Components/Components/test/Lifetime/ComponentStatePersistenceManagerTest.cs
@@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.
using System.Buffers;
+using System.Collections;
using System.Text.Json;
using Microsoft.AspNetCore.Components.Infrastructure;
using Microsoft.AspNetCore.Components.RenderTree;
@@ -12,7 +13,7 @@
namespace Microsoft.AspNetCore.Components;
-public class ComponentApplicationLifetimeTest
+public class ComponentStatePersistenceManagerTest
{
[Fact]
public async Task RestoreStateAsync_InitializesStateWithDataFromTheProvidedStore()
@@ -41,7 +42,7 @@ public async Task RestoreStateAsync_ThrowsOnDoubleInitialization()
// Arrange
var state = new Dictionary
{
- ["MyState"] = new byte[] { 0, 1, 2, 3, 4 }
+ ["MyState"] = [0, 1, 2, 3, 4]
};
var store = new TestStore(state);
var lifetime = new ComponentStatePersistenceManager(NullLogger.Instance);
@@ -52,6 +53,28 @@ public async Task RestoreStateAsync_ThrowsOnDoubleInitialization()
await Assert.ThrowsAsync(() => lifetime.RestoreStateAsync(store));
}
+ [Fact]
+ public async Task PersistStateAsync_ThrowsWhenCallbackRenerModeCannotBeInferred()
+ {
+ // Arrange
+ var state = new Dictionary();
+ var store = new CompositeTestStore(state);
+ var lifetime = new ComponentStatePersistenceManager(NullLogger.Instance);
+
+ var renderer = new TestRenderer();
+ var data = new byte[] { 1, 2, 3, 4 };
+
+ lifetime.State.RegisterOnPersisting(() =>
+ {
+ lifetime.State.PersistAsJson("MyState", new byte[] { 1, 2, 3, 4 });
+ return Task.CompletedTask;
+ });
+
+ // Act
+ // Assert
+ await Assert.ThrowsAsync(() => lifetime.PersistStateAsync(store, renderer));
+ }
+
[Fact]
public async Task PersistStateAsync_SavesPersistedStateToTheStore()
{
@@ -67,7 +90,7 @@ public async Task PersistStateAsync_SavesPersistedStateToTheStore()
{
lifetime.State.PersistAsJson("MyState", new byte[] { 1, 2, 3, 4 });
return Task.CompletedTask;
- });
+ }, new TestRenderMode());
// Act
await lifetime.PersistStateAsync(store, renderer);
@@ -88,7 +111,7 @@ public async Task PersistStateAsync_InvokesPauseCallbacksDuringPersist()
var data = new byte[] { 1, 2, 3, 4 };
var invoked = false;
- lifetime.State.RegisterOnPersisting(() => { invoked = true; return default; });
+ lifetime.State.RegisterOnPersisting(() => { invoked = true; return default; }, new TestRenderMode());
// Act
await lifetime.PersistStateAsync(store, renderer);
@@ -111,8 +134,8 @@ public async Task PersistStateAsync_FiresCallbacksInParallel()
var tcs = new TaskCompletionSource();
var tcs2 = new TaskCompletionSource();
- lifetime.State.RegisterOnPersisting(async () => { sequence.Add(1); await tcs.Task; sequence.Add(3); });
- lifetime.State.RegisterOnPersisting(async () => { sequence.Add(2); await tcs2.Task; sequence.Add(4); });
+ lifetime.State.RegisterOnPersisting(async () => { sequence.Add(1); await tcs.Task; sequence.Add(3); }, new TestRenderMode());
+ lifetime.State.RegisterOnPersisting(async () => { sequence.Add(2); await tcs2.Task; sequence.Add(4); }, new TestRenderMode());
// Act
var persistTask = lifetime.PersistStateAsync(store, renderer);
@@ -170,8 +193,8 @@ public async Task PersistStateAsync_ContinuesInvokingPauseCallbacksDuringPersist
var data = new byte[] { 1, 2, 3, 4 };
var invoked = false;
- lifetime.State.RegisterOnPersisting(() => throw new InvalidOperationException());
- lifetime.State.RegisterOnPersisting(() => { invoked = true; return Task.CompletedTask; });
+ lifetime.State.RegisterOnPersisting(() => throw new InvalidOperationException(), new TestRenderMode());
+ lifetime.State.RegisterOnPersisting(() => { invoked = true; return Task.CompletedTask; }, new TestRenderMode());
// Act
await lifetime.PersistStateAsync(store, renderer);
@@ -196,8 +219,8 @@ public async Task PersistStateAsync_ContinuesInvokingPauseCallbacksDuringPersist
var invoked = false;
var tcs = new TaskCompletionSource();
- lifetime.State.RegisterOnPersisting(async () => { await tcs.Task; throw new InvalidOperationException(); });
- lifetime.State.RegisterOnPersisting(() => { invoked = true; return Task.CompletedTask; });
+ lifetime.State.RegisterOnPersisting(async () => { await tcs.Task; throw new InvalidOperationException(); }, new TestRenderMode());
+ lifetime.State.RegisterOnPersisting(() => { invoked = true; return Task.CompletedTask; }, new TestRenderMode());
// Act
var persistTask = lifetime.PersistStateAsync(store, renderer);
@@ -211,30 +234,6 @@ public async Task PersistStateAsync_ContinuesInvokingPauseCallbacksDuringPersist
Assert.Equal(LogLevel.Error, log.LogLevel);
}
- [Fact]
- public async Task PersistStateAsync_ThrowsWhenDeveloperTriesToPersistStateMultipleTimes()
- {
- // Arrange
- var state = new Dictionary();
- var store = new TestStore(state);
- var lifetime = new ComponentStatePersistenceManager(NullLogger.Instance);
-
- var renderer = new TestRenderer();
- var data = new byte[] { 1, 2, 3, 4 };
-
- lifetime.State.RegisterOnPersisting(() =>
- {
- lifetime.State.PersistAsJson("MyState", new byte[] { 1, 2, 3, 4 });
- return Task.CompletedTask;
- });
-
- // Act
- await lifetime.PersistStateAsync(store, renderer);
-
- // Assert
- await Assert.ThrowsAsync(() => lifetime.PersistStateAsync(store, renderer));
- }
-
private class TestRenderer : Renderer
{
public TestRenderer() : base(new ServiceCollection().BuildServiceProvider(), NullLoggerFactory.Instance)
@@ -277,4 +276,42 @@ public Task PersistStateAsync(IReadOnlyDictionary state)
return Task.CompletedTask;
}
}
+
+ private class CompositeTestStore : IPersistentComponentStateStore, IEnumerable
+ {
+ public CompositeTestStore(IDictionary initialState)
+ {
+ State = initialState;
+ }
+
+ public IDictionary State { get; set; }
+
+ public IEnumerator GetEnumerator()
+ {
+ yield return new TestStore(State);
+ yield return new TestStore(State);
+ }
+
+ public Task> GetPersistedStateAsync()
+ {
+ return Task.FromResult(State);
+ }
+
+ public Task PersistStateAsync(IReadOnlyDictionary state)
+ {
+ // We copy the data here because it's no longer available after this call completes.
+ State = state.ToDictionary(k => k.Key, v => v.Value);
+ return Task.CompletedTask;
+ }
+
+ IEnumerator IEnumerable.GetEnumerator()
+ {
+ return GetEnumerator();
+ }
+ }
+
+ private class TestRenderMode : IComponentRenderMode
+ {
+
+ }
}
diff --git a/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs b/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs
index 7dafc88bafab..48f9eacfe104 100644
--- a/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs
+++ b/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs
@@ -121,6 +121,10 @@ await EndpointHtmlRenderer.InitializeStandardComponentServicesAsync(
await _renderer.SendStreamingUpdatesAsync(context, quiesceTask, bufferWriter);
}
+ // Emit comment containing state.
+ var componentStateHtmlContent = await _renderer.PrerenderPersistedStateAsync(context);
+ componentStateHtmlContent.WriteTo(bufferWriter, HtmlEncoder.Default);
+
// Invoke FlushAsync to ensure any buffered content is asynchronously written to the underlying
// response asynchronously. In the absence of this line, the buffer gets synchronously written to the
// response as part of the Dispose which has a perf impact.
diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs
index 3b192d99552b..6a2d399e3520 100644
--- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs
+++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs
@@ -3,6 +3,7 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Encodings.Web;
+using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.Web.HtmlRendering;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Http;
@@ -33,19 +34,39 @@ protected override IComponent ResolveComponentForRenderMode([DynamicallyAccessed
}
}
+ protected override IComponentRenderMode? GetComponentRenderMode(IComponent component)
+ {
+ var componentState = GetComponentState(component);
+ var ssrRenderBoundary = GetClosestRenderModeBoundary(componentState);
+
+ if (ssrRenderBoundary is null)
+ {
+ return null;
+ }
+
+ return ssrRenderBoundary.RenderMode;
+ }
+
private SSRRenderModeBoundary? GetClosestRenderModeBoundary(int componentId)
{
var componentState = GetComponentState(componentId);
+ return GetClosestRenderModeBoundary(componentState);
+ }
+
+ private static SSRRenderModeBoundary? GetClosestRenderModeBoundary(ComponentState componentState)
+ {
+ var currentComponentState = componentState;
+
do
{
- if (componentState.Component is SSRRenderModeBoundary boundary)
+ if (currentComponentState.Component is SSRRenderModeBoundary boundary)
{
return boundary;
}
- componentState = componentState.ParentComponentState;
+ currentComponentState = currentComponentState.ParentComponentState;
}
- while (componentState is not null);
+ while (currentComponentState is not null);
return null;
}
diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.PrerenderingState.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.PrerenderingState.cs
index 3b710374d38f..161967076ec5 100644
--- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.PrerenderingState.cs
+++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.PrerenderingState.cs
@@ -1,6 +1,7 @@
// 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;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Components.Infrastructure;
using Microsoft.AspNetCore.Components.Web;
@@ -15,6 +16,98 @@ internal partial class EndpointHtmlRenderer
{
private static readonly object InvokedRenderModesKey = new object();
+ public async ValueTask PrerenderPersistedStateAsync(HttpContext httpContext)
+ {
+ SetHttpContext(httpContext);
+
+ var manager = _httpContext.RequestServices.GetRequiredService();
+
+ var renderModesMetadata = httpContext.GetEndpoint()?.Metadata.GetMetadata();
+
+ IPersistentComponentStateStore? store = null;
+
+ // There is configured render modes metadata, use this to determine where to persist state if possible
+ if (renderModesMetadata != null)
+ {
+ // No render modes are configured, do not persist state
+ if (renderModesMetadata.ConfiguredRenderModes.Length == 0)
+ {
+ return ComponentStateHtmlContent.Empty;
+ }
+
+ // Single render mode, no need to perform inference. Any component that tried to render an
+ // incompatible render mode would have failed at this point.
+ if (renderModesMetadata.ConfiguredRenderModes.Length == 1)
+ {
+ store = renderModesMetadata.ConfiguredRenderModes[0] switch
+ {
+ InteractiveServerRenderMode => new ProtectedPrerenderComponentApplicationStore(_httpContext.RequestServices.GetRequiredService()),
+ InteractiveWebAssemblyRenderMode => new PrerenderComponentApplicationStore(),
+ _ => throw new InvalidOperationException("Invalid configured render mode."),
+ };
+ }
+ }
+
+ if (store != null)
+ {
+ await manager.PersistStateAsync(store, this);
+ return store switch
+ {
+ ProtectedPrerenderComponentApplicationStore protectedStore => new ComponentStateHtmlContent(protectedStore, null),
+ PrerenderComponentApplicationStore prerenderStore => new ComponentStateHtmlContent(null, prerenderStore),
+ _ => throw new InvalidOperationException("Invalid store."),
+ };
+ }
+ else
+ {
+ // We were not able to resolve a store from the configured render modes metadata, we need to capture
+ // all possible destinations for the state and persist it in all of them.
+ var serverStore = new ProtectedPrerenderComponentApplicationStore(_httpContext.RequestServices.GetRequiredService());
+ var webAssemblyStore = new PrerenderComponentApplicationStore();
+
+ // The persistence state manager checks if the store implements
+ // IEnumerable and if so, it invokes PersistStateAsync on each store
+ // for each of the render mode callbacks defined.
+ // We pass in a composite store with fake stores for each render mode that only take care of
+ // creating a copy of the state for each render mode.
+ // Then, we copy the state from the auto store to the server and webassembly stores and persist
+ // the real state for server and webassembly render modes.
+ // This makes sure that:
+ // 1. The persistence state manager is agnostic to the render modes.
+ // 2. The callbacks are run only once, even if the state ends up persisted in multiple locations.
+ var server = new CopyOnlyStore();
+ var auto = new CopyOnlyStore();
+ var webAssembly = new CopyOnlyStore();
+ store = new CompositeStore(server, auto, webAssembly);
+
+ await manager.PersistStateAsync(store, this);
+
+ foreach (var kvp in auto.Saved)
+ {
+ server.Saved.Add(kvp.Key, kvp.Value);
+ webAssembly.Saved.Add(kvp.Key, kvp.Value);
+ }
+
+ // Persist state only if there is state to persist
+ var saveServerTask = server.Saved.Count > 0
+ ? serverStore.PersistStateAsync(server.Saved)
+ : Task.CompletedTask;
+
+ var saveWebAssemblyTask = webAssembly.Saved.Count > 0
+ ? webAssemblyStore.PersistStateAsync(webAssembly.Saved)
+ : Task.CompletedTask;
+
+ await Task.WhenAll(
+ saveServerTask,
+ saveWebAssemblyTask);
+
+ // Do not return any HTML content if there is no state to persist for a given mode.
+ return new ComponentStateHtmlContent(
+ server.Saved.Count > 0 ? serverStore : null,
+ webAssembly.Saved.Count > 0 ? webAssemblyStore : null);
+ }
+ }
+
public async ValueTask PrerenderPersistedStateAsync(HttpContext httpContext, PersistedStateSerializationMode serializationMode)
{
SetHttpContext(httpContext);
@@ -40,21 +133,25 @@ public async ValueTask PrerenderPersistedStateAsync(HttpContext ht
}
}
+ var manager = _httpContext.RequestServices.GetRequiredService();
+
// Now given the mode, we obtain a particular store for that mode
- var store = serializationMode switch
+ // and persist the state and return the HTML content
+ switch (serializationMode)
{
- PersistedStateSerializationMode.Server =>
- new ProtectedPrerenderComponentApplicationStore(_httpContext.RequestServices.GetRequiredService()),
- PersistedStateSerializationMode.WebAssembly =>
- new PrerenderComponentApplicationStore(),
- _ =>
- throw new InvalidOperationException("Invalid persistence mode.")
- };
-
- // Finally, persist the state and return the HTML content
- var manager = _httpContext.RequestServices.GetRequiredService();
- await manager.PersistStateAsync(store, Dispatcher);
- return new ComponentStateHtmlContent(store);
+ case PersistedStateSerializationMode.Server:
+ var protectedStore = new ProtectedPrerenderComponentApplicationStore(_httpContext.RequestServices.GetRequiredService());
+ await manager.PersistStateAsync(protectedStore, this);
+ return new ComponentStateHtmlContent(protectedStore, null);
+
+ case PersistedStateSerializationMode.WebAssembly:
+ var store = new PrerenderComponentApplicationStore();
+ await manager.PersistStateAsync(store, this);
+ return new ComponentStateHtmlContent(null, store);
+
+ default:
+ throw new InvalidOperationException("Invalid persistence mode.");
+ }
}
// Internal for test only
@@ -101,27 +198,80 @@ internal static InvokedRenderModes.Mode GetPersistStateRenderMode(HttpContext ht
: InvokedRenderModes.Mode.None;
}
- private sealed class ComponentStateHtmlContent : IHtmlContent
+ internal sealed class ComponentStateHtmlContent : IHtmlContent
{
- private PrerenderComponentApplicationStore? _store;
+ public static ComponentStateHtmlContent Empty { get; } = new(null, null);
+
+ internal PrerenderComponentApplicationStore? ServerStore { get; }
- public static ComponentStateHtmlContent Empty { get; }
- = new ComponentStateHtmlContent(null);
+ internal PrerenderComponentApplicationStore? WebAssemblyStore { get; }
- public ComponentStateHtmlContent(PrerenderComponentApplicationStore? store)
+ public ComponentStateHtmlContent(PrerenderComponentApplicationStore? serverStore, PrerenderComponentApplicationStore? webAssemblyStore)
{
- _store = store;
+ WebAssemblyStore = webAssemblyStore;
+ ServerStore = serverStore;
}
public void WriteTo(TextWriter writer, HtmlEncoder encoder)
{
- if (_store != null)
+ if (ServerStore is not null && ServerStore.PersistedState is not null)
{
- writer.Write("");
- _store = null;
}
+
+ if (WebAssemblyStore is not null && WebAssemblyStore.PersistedState is not null)
+ {
+ writer.Write("");
+ }
+ }
+ }
+
+ internal class CompositeStore : IPersistentComponentStateStore, IEnumerable
+ {
+ public CompositeStore(
+ CopyOnlyStore server,
+ CopyOnlyStore auto,
+ CopyOnlyStore webassembly)
+ {
+ Server = server;
+ Auto = auto;
+ Webassembly = webassembly;
+ }
+
+ public CopyOnlyStore Server { get; }
+ public CopyOnlyStore Auto { get; }
+ public CopyOnlyStore Webassembly { get; }
+
+ public IEnumerator GetEnumerator()
+ {
+ yield return Server;
+ yield return Auto;
+ yield return Webassembly;
+ }
+
+ public Task> GetPersistedStateAsync() => throw new NotImplementedException();
+
+ public Task PersistStateAsync(IReadOnlyDictionary state) => Task.CompletedTask;
+
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+ }
+
+ internal class CopyOnlyStore : IPersistentComponentStateStore where T : IComponentRenderMode
+ {
+ public Dictionary Saved { get; private set; } = new();
+
+ public Task> GetPersistedStateAsync() => throw new NotImplementedException();
+
+ public Task PersistStateAsync(IReadOnlyDictionary state)
+ {
+ Saved = new Dictionary(state);
+ return Task.CompletedTask;
}
+
+ public bool SupportsRenderMode(IComponentRenderMode renderMode) => renderMode is T;
}
}
diff --git a/src/Components/Endpoints/src/Rendering/SSRRenderModeBoundary.cs b/src/Components/Endpoints/src/Rendering/SSRRenderModeBoundary.cs
index 66a9331c0f1a..b0b4d9510901 100644
--- a/src/Components/Endpoints/src/Rendering/SSRRenderModeBoundary.cs
+++ b/src/Components/Endpoints/src/Rendering/SSRRenderModeBoundary.cs
@@ -26,12 +26,13 @@ internal class SSRRenderModeBoundary : IComponent
[DynamicallyAccessedMembers(Component)]
private readonly Type _componentType;
- private readonly IComponentRenderMode _renderMode;
private readonly bool _prerender;
private RenderHandle _renderHandle;
private IReadOnlyDictionary? _latestParameters;
private string? _markerKey;
+ public IComponentRenderMode RenderMode { get; }
+
public SSRRenderModeBoundary(
HttpContext httpContext,
[DynamicallyAccessedMembers(Component)] Type componentType,
@@ -40,7 +41,7 @@ public SSRRenderModeBoundary(
AssertRenderModeIsConfigured(httpContext, componentType, renderMode);
_componentType = componentType;
- _renderMode = renderMode;
+ RenderMode = renderMode;
_prerender = renderMode switch
{
InteractiveServerRenderMode mode => mode.Prerender,
@@ -76,7 +77,7 @@ private static void AssertRenderModeIsConfigured(HttpContext httpContext, Type c
}
}
- private static void AssertRenderModeIsConfigured(Type componentType, IComponentRenderMode specifiedMode, IComponentRenderMode[] configuredModes, string expectedCall) where TRequiredMode: IComponentRenderMode
+ private static void AssertRenderModeIsConfigured(Type componentType, IComponentRenderMode specifiedMode, IComponentRenderMode[] configuredModes, string expectedCall) where TRequiredMode : IComponentRenderMode
{
foreach (var configuredMode in configuredModes)
{
@@ -126,7 +127,7 @@ private void ValidateParameters(IReadOnlyDictionary latestParam
var valueType = value.GetType();
if (valueType.IsGenericType && valueType.GetGenericTypeDefinition() == typeof(RenderFragment<>))
{
- throw new InvalidOperationException($"Cannot pass RenderFragment parameter '{name}' to component '{_componentType.Name}' with rendermode '{_renderMode.GetType().Name}'. Templated content can't be passed across a rendermode boundary, because it is arbitrary code and cannot be serialized.");
+ throw new InvalidOperationException($"Cannot pass RenderFragment parameter '{name}' to component '{_componentType.Name}' with rendermode '{RenderMode.GetType().Name}'. Templated content can't be passed across a rendermode boundary, because it is arbitrary code and cannot be serialized.");
}
else
{
@@ -135,7 +136,7 @@ private void ValidateParameters(IReadOnlyDictionary latestParam
// somehow without actually emitting its result directly, wait for quiescence, and then prerender
// the output into a separate buffer so we can serialize it in a special way.
// A prototype implementation is at https://github.com/dotnet/aspnetcore/commit/ed330ff5b143974d9060828a760ad486b1d386ac
- throw new InvalidOperationException($"Cannot pass the parameter '{name}' to component '{_componentType.Name}' with rendermode '{_renderMode.GetType().Name}'. This is because the parameter is of the delegate type '{value.GetType()}', which is arbitrary code and cannot be serialized.");
+ throw new InvalidOperationException($"Cannot pass the parameter '{name}' to component '{_componentType.Name}' with rendermode '{RenderMode.GetType().Name}'. This is because the parameter is of the delegate type '{value.GetType()}', which is arbitrary code and cannot be serialized.");
}
}
}
@@ -163,15 +164,15 @@ public ComponentMarker ToMarker(HttpContext httpContext, int sequence, object? k
? ParameterView.Empty
: ParameterView.FromDictionary((IDictionary)_latestParameters);
- var marker = _renderMode switch
+ var marker = RenderMode switch
{
InteractiveServerRenderMode server => ComponentMarker.Create(ComponentMarker.ServerMarkerType, server.Prerender, _markerKey),
InteractiveWebAssemblyRenderMode webAssembly => ComponentMarker.Create(ComponentMarker.WebAssemblyMarkerType, webAssembly.Prerender, _markerKey),
InteractiveAutoRenderMode auto => ComponentMarker.Create(ComponentMarker.AutoMarkerType, auto.Prerender, _markerKey),
- _ => throw new UnreachableException($"Unknown render mode {_renderMode.GetType().FullName}"),
+ _ => throw new UnreachableException($"Unknown render mode {RenderMode.GetType().FullName}"),
};
- if (_renderMode is InteractiveServerRenderMode or InteractiveAutoRenderMode)
+ if (RenderMode is InteractiveServerRenderMode or InteractiveAutoRenderMode)
{
// Lazy because we don't actually want to require a whole chain of services including Data Protection
// to be required unless you actually use Server render mode.
@@ -181,7 +182,7 @@ public ComponentMarker ToMarker(HttpContext httpContext, int sequence, object? k
serverComponentSerializer.SerializeInvocation(ref marker, invocationId, _componentType, parameters);
}
- if (_renderMode is InteractiveWebAssemblyRenderMode or InteractiveAutoRenderMode)
+ if (RenderMode is InteractiveWebAssemblyRenderMode or InteractiveAutoRenderMode)
{
WebAssemblyComponentSerializer.SerializeInvocation(ref marker, _componentType, parameters);
}
diff --git a/src/Components/Endpoints/test/EndpointHtmlRendererTest.cs b/src/Components/Endpoints/test/EndpointHtmlRendererTest.cs
index 65c4d0692051..ec85442c9e5f 100644
--- a/src/Components/Endpoints/test/EndpointHtmlRendererTest.cs
+++ b/src/Components/Endpoints/test/EndpointHtmlRendererTest.cs
@@ -10,6 +10,7 @@
using Microsoft.AspNetCore.Components.Forms;
using Microsoft.AspNetCore.Components.Forms.Mapping;
using Microsoft.AspNetCore.Components.Infrastructure;
+using Microsoft.AspNetCore.Components.Reflection;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.Test.Helpers;
using Microsoft.AspNetCore.Components.Web;
@@ -1177,6 +1178,326 @@ public async Task DoesNotEmitNestedRenderModeBoundaries()
Assert.Equal("
This is InteractiveWithInteractiveChild
\n\n
Hello from InteractiveGreetingServer!
", prerenderedContent.Replace("\r\n", "\n"));
}
+ [Fact]
+ public async Task PrerenderedState_EmptyWhenNoDeclaredRenderModes()
+ {
+ var declaredRenderModesMetadata = new ConfiguredRenderModesMetadata([]);
+ var endpoint = new Endpoint((context) => Task.CompletedTask, new EndpointMetadataCollection(declaredRenderModesMetadata),
+ "TestEndpoint");
+
+ var httpContext = GetHttpContext();
+ httpContext.SetEndpoint(endpoint);
+ var content = await renderer.PrerenderPersistedStateAsync(httpContext);
+
+ Assert.Equal(EndpointHtmlRenderer.ComponentStateHtmlContent.Empty, content);
+ }
+
+ public static TheoryData SingleComponentRenderModeData => new TheoryData
+ {
+ RenderMode.InteractiveServer,
+ RenderMode.InteractiveWebAssembly
+ };
+
+ [Theory]
+ [MemberData(nameof(SingleComponentRenderModeData))]
+ public async Task PrerenderedState_SelectsSingleStoreCorrectly(IComponentRenderMode renderMode)
+ {
+ var declaredRenderModesMetadata = new ConfiguredRenderModesMetadata([renderMode]);
+ var endpoint = new Endpoint((context) => Task.CompletedTask, new EndpointMetadataCollection(declaredRenderModesMetadata),
+ "TestEndpoint");
+
+ var httpContext = GetHttpContext();
+ httpContext.SetEndpoint(endpoint);
+ var content = await renderer.PrerenderPersistedStateAsync(httpContext);
+
+ Assert.NotNull(content);
+ var stateContent = Assert.IsType(content);
+ switch (renderMode)
+ {
+ case InteractiveServerRenderMode:
+ Assert.NotNull(stateContent.ServerStore);
+ Assert.Null(stateContent.ServerStore.PersistedState);
+ Assert.Null(stateContent.WebAssemblyStore);
+ break;
+ case InteractiveWebAssemblyRenderMode:
+ Assert.NotNull(stateContent.WebAssemblyStore);
+ Assert.Null(stateContent.WebAssemblyStore.PersistedState);
+ Assert.Null(stateContent.ServerStore);
+ break;
+ default:
+ throw new InvalidOperationException($"Unexpected render mode: {renderMode}");
+ }
+ }
+
+ [Fact]
+ public async Task PrerenderedState_MultipleStoresCorrectly()
+ {
+ var declaredRenderModesMetadata = new ConfiguredRenderModesMetadata([RenderMode.InteractiveServer, RenderMode.InteractiveWebAssembly]);
+ var endpoint = new Endpoint((context) => Task.CompletedTask, new EndpointMetadataCollection(declaredRenderModesMetadata),
+ "TestEndpoint");
+
+ var httpContext = GetHttpContext();
+ httpContext.SetEndpoint(endpoint);
+ var content = await renderer.PrerenderPersistedStateAsync(httpContext);
+
+ Assert.NotNull(content);
+ var stateContent = Assert.IsType(content);
+ Assert.Null(stateContent.ServerStore);
+ Assert.Null(stateContent.WebAssemblyStore);
+ }
+
+ [Theory]
+ [InlineData("server")]
+ [InlineData("wasm")]
+ [InlineData("auto")]
+ public async Task PrerenderedState_PersistToStores_OnlyWhenContentIsAvailable(string renderMode)
+ {
+ IComponentRenderMode persistenceMode = renderMode switch
+ {
+ "server" => RenderMode.InteractiveServer,
+ "wasm" => RenderMode.InteractiveWebAssembly,
+ "auto" => RenderMode.InteractiveAuto,
+ _ => throw new InvalidOperationException($"Unexpected render mode: {renderMode}"),
+ };
+
+ var declaredRenderModesMetadata = new ConfiguredRenderModesMetadata([RenderMode.InteractiveServer, RenderMode.InteractiveWebAssembly]);
+ var endpoint = new Endpoint((context) => Task.CompletedTask, new EndpointMetadataCollection(declaredRenderModesMetadata),
+ "TestEndpoint");
+
+ var httpContext = GetHttpContext();
+ httpContext.SetEndpoint(endpoint);
+ var state = httpContext.RequestServices.GetRequiredService();
+
+ state.RegisterOnPersisting(() =>
+ {
+ state.PersistAsJson(renderMode, "persisted");
+ return Task.CompletedTask;
+ }, persistenceMode);
+
+ var content = await renderer.PrerenderPersistedStateAsync(httpContext);
+
+ Assert.NotNull(content);
+ var stateContent = Assert.IsType(content);
+ switch (persistenceMode)
+ {
+ case InteractiveServerRenderMode:
+ Assert.NotNull(stateContent.ServerStore);
+ Assert.NotNull(stateContent.ServerStore.PersistedState);
+ Assert.Null(stateContent.WebAssemblyStore);
+ break;
+ case InteractiveWebAssemblyRenderMode:
+ Assert.NotNull(stateContent.WebAssemblyStore);
+ Assert.NotNull(stateContent.WebAssemblyStore.PersistedState);
+ Assert.Null(stateContent.ServerStore);
+ break;
+ case InteractiveAutoRenderMode:
+ Assert.NotNull(stateContent.ServerStore);
+ Assert.NotNull(stateContent.ServerStore.PersistedState);
+ Assert.NotNull(stateContent.WebAssemblyStore);
+ Assert.NotNull(stateContent.WebAssemblyStore.PersistedState);
+ break;
+ default:
+ break;
+ }
+ }
+
+ [Theory]
+ [InlineData("server")]
+ [InlineData("wasm")]
+ public async Task PrerenderedState_PersistToStores_DoesNotNeedToInferRenderMode_ForSingleRenderMode(string declaredRenderMode)
+ {
+ IComponentRenderMode configuredMode = declaredRenderMode switch
+ {
+ "server" => RenderMode.InteractiveServer,
+ "wasm" => RenderMode.InteractiveWebAssembly,
+ "auto" => RenderMode.InteractiveAuto,
+ _ => throw new InvalidOperationException($"Unexpected render mode: {declaredRenderMode}"),
+ };
+
+ var declaredRenderModesMetadata = new ConfiguredRenderModesMetadata([configuredMode]);
+ var endpoint = new Endpoint((context) => Task.CompletedTask, new EndpointMetadataCollection(declaredRenderModesMetadata),
+ "TestEndpoint");
+
+ var httpContext = GetHttpContext();
+ httpContext.SetEndpoint(endpoint);
+ var state = httpContext.RequestServices.GetRequiredService();
+
+ state.RegisterOnPersisting(() =>
+ {
+ state.PersistAsJson("key", "persisted");
+ return Task.CompletedTask;
+ });
+
+ var content = await renderer.PrerenderPersistedStateAsync(httpContext);
+
+ Assert.NotNull(content);
+ var stateContent = Assert.IsType(content);
+ switch (configuredMode)
+ {
+ case InteractiveServerRenderMode:
+ Assert.NotNull(stateContent.ServerStore);
+ Assert.NotNull(stateContent.ServerStore.PersistedState);
+ Assert.Null(stateContent.WebAssemblyStore);
+ break;
+ case InteractiveWebAssemblyRenderMode:
+ Assert.NotNull(stateContent.WebAssemblyStore);
+ Assert.NotNull(stateContent.WebAssemblyStore.PersistedState);
+ Assert.Null(stateContent.ServerStore);
+ break;
+ default:
+ break;
+ }
+ }
+
+ [Fact]
+ public async Task PrerenderedState_Throws_WhenItCanInfer_CallbackRenderMode_ForMultipleRenderModes()
+ {
+ var declaredRenderModesMetadata = new ConfiguredRenderModesMetadata([RenderMode.InteractiveServer, RenderMode.InteractiveWebAssembly]);
+ var endpoint = new Endpoint((context) => Task.CompletedTask, new EndpointMetadataCollection(declaredRenderModesMetadata),
+ "TestEndpoint");
+
+ var httpContext = GetHttpContext();
+ httpContext.SetEndpoint(endpoint);
+ var state = httpContext.RequestServices.GetRequiredService();
+
+ state.RegisterOnPersisting(() =>
+ {
+ state.PersistAsJson("key", "persisted");
+ return Task.CompletedTask;
+ });
+
+ await Assert.ThrowsAsync(async () => await renderer.PrerenderPersistedStateAsync(httpContext));
+ }
+
+ [Theory]
+ [InlineData("server")]
+ [InlineData("auto")]
+ [InlineData("wasm")]
+ public async Task PrerenderedState_InfersCallbackRenderMode_ForMultipleRenderModes(string renderMode)
+ {
+ IComponentRenderMode persistenceMode = renderMode switch
+ {
+ "server" => RenderMode.InteractiveServer,
+ "wasm" => RenderMode.InteractiveWebAssembly,
+ "auto" => RenderMode.InteractiveAuto,
+ _ => throw new InvalidOperationException($"Unexpected render mode: {renderMode}"),
+ };
+ var declaredRenderModesMetadata = new ConfiguredRenderModesMetadata([RenderMode.InteractiveServer, RenderMode.InteractiveWebAssembly]);
+ var endpoint = new Endpoint((context) => Task.CompletedTask, new EndpointMetadataCollection(declaredRenderModesMetadata),
+ "TestEndpoint");
+
+ var httpContext = GetHttpContext();
+ httpContext.SetEndpoint(endpoint);
+ var state = httpContext.RequestServices.GetRequiredService();
+
+ var ssrBoundary = new SSRRenderModeBoundary(httpContext, typeof(PersistenceComponent), persistenceMode);
+ var id = renderer.AssignRootComponentId(ssrBoundary);
+
+ await renderer.Dispatcher.InvokeAsync(() => renderer.RenderRootComponentAsync(id, ParameterView.Empty));
+
+ var content = await renderer.PrerenderPersistedStateAsync(httpContext);
+ Assert.NotNull(content);
+ var stateContent = Assert.IsType(content);
+ switch (persistenceMode)
+ {
+ case InteractiveServerRenderMode:
+ Assert.NotNull(stateContent.ServerStore);
+ Assert.NotNull(stateContent.ServerStore.PersistedState);
+ Assert.Null(stateContent.WebAssemblyStore);
+ break;
+ case InteractiveWebAssemblyRenderMode:
+ Assert.NotNull(stateContent.WebAssemblyStore);
+ Assert.NotNull(stateContent.WebAssemblyStore.PersistedState);
+ Assert.Null(stateContent.ServerStore);
+ break;
+ case InteractiveAutoRenderMode:
+ Assert.NotNull(stateContent.ServerStore);
+ Assert.NotNull(stateContent.ServerStore.PersistedState);
+ Assert.NotNull(stateContent.WebAssemblyStore);
+ Assert.NotNull(stateContent.WebAssemblyStore.PersistedState);
+ break;
+ default:
+ break;
+ }
+ }
+
+ [Theory]
+ [InlineData("server", "server", true)]
+ [InlineData("auto", "server", true)]
+ [InlineData("auto", "wasm", true)]
+ [InlineData("wasm", "wasm", true)]
+ // Note that when an incompatible explicit render mode is specified we don't serialize the data.
+ [InlineData("server", "wasm", false)]
+ [InlineData("wasm", "server", false)]
+ public async Task PrerenderedState_ExplicitRenderModes_AreRespected(string renderMode, string declared, bool persisted)
+ {
+ IComponentRenderMode persistenceMode = renderMode switch
+ {
+ "server" => RenderMode.InteractiveServer,
+ "wasm" => RenderMode.InteractiveWebAssembly,
+ "auto" => RenderMode.InteractiveAuto,
+ _ => throw new InvalidOperationException($"Unexpected render mode: {renderMode}"),
+ };
+
+ IComponentRenderMode configuredMode = declared switch
+ {
+ "server" => RenderMode.InteractiveServer,
+ "wasm" => RenderMode.InteractiveWebAssembly,
+ "auto" => RenderMode.InteractiveAuto,
+ _ => throw new InvalidOperationException($"Unexpected render mode: {declared}"),
+ };
+
+ var declaredRenderModesMetadata = new ConfiguredRenderModesMetadata([configuredMode]);
+ var endpoint = new Endpoint((context) => Task.CompletedTask, new EndpointMetadataCollection(declaredRenderModesMetadata),
+ "TestEndpoint");
+
+ var httpContext = GetHttpContext();
+ httpContext.SetEndpoint(endpoint);
+ var state = httpContext.RequestServices.GetRequiredService();
+
+ var ssrBoundary = new SSRRenderModeBoundary(httpContext, typeof(PersistenceComponent), configuredMode);
+ var id = renderer.AssignRootComponentId(ssrBoundary);
+ await renderer.Dispatcher.InvokeAsync(() => renderer.RenderRootComponentAsync(
+ id,
+ ParameterView.FromDictionary(new Dictionary
+ {
+ ["Mode"] = renderMode,
+ })));
+
+ var content = await renderer.PrerenderPersistedStateAsync(httpContext);
+ Assert.NotNull(content);
+ var stateContent = Assert.IsType(content);
+ switch (configuredMode)
+ {
+ case InteractiveServerRenderMode:
+ if (persisted)
+ {
+ Assert.NotNull(stateContent.ServerStore);
+ Assert.NotNull(stateContent.ServerStore.PersistedState);
+ }
+ else
+ {
+ Assert.Null(stateContent.ServerStore.PersistedState);
+ }
+ Assert.Null(stateContent.WebAssemblyStore);
+ break;
+ case InteractiveWebAssemblyRenderMode:
+ if (persisted)
+ {
+ Assert.NotNull(stateContent.WebAssemblyStore);
+ Assert.NotNull(stateContent.WebAssemblyStore.PersistedState);
+ }
+ else
+ {
+ Assert.Null(stateContent.WebAssemblyStore.PersistedState);
+ }
+ Assert.Null(stateContent.ServerStore);
+ break;
+ default:
+ break;
+ }
+ }
+
private class NamedEventHandlerComponent : ComponentBase
{
[Parameter]
@@ -1230,7 +1551,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
builder.OpenElement(0, "form");
builder.AddAttribute(1, "onsubmit", !hasRendered
? () => { Message = "Received call to original handler"; }
- : () => { Message = "Received call to updated handler"; });
+ : () => { Message = "Received call to updated handler"; });
builder.AddNamedEvent("onsubmit", "default");
builder.CloseElement();
}
@@ -1266,6 +1587,44 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
=> _renderFragment(builder);
}
+ class PersistenceComponent : IComponent
+ {
+ [Inject] public PersistentComponentState State { get; set; }
+
+ [Parameter] public string Mode { get; set; }
+
+ private Task PersistState()
+ {
+ State.PersistAsJson("key", "value");
+ return Task.CompletedTask;
+ }
+
+ public void Attach(RenderHandle renderHandle)
+ {
+ }
+
+ public Task SetParametersAsync(ParameterView parameters)
+ {
+ ComponentProperties.SetProperties(parameters, this);
+ switch (Mode)
+ {
+ case "server":
+ State.RegisterOnPersisting(PersistState, RenderMode.InteractiveServer);
+ break;
+ case "wasm":
+ State.RegisterOnPersisting(PersistState, RenderMode.InteractiveWebAssembly);
+ break;
+ case "auto":
+ State.RegisterOnPersisting(PersistState, RenderMode.InteractiveAuto);
+ break;
+ default:
+ State.RegisterOnPersisting(PersistState);
+ break;
+ }
+ return Task.CompletedTask;
+ }
+ }
+
private static string HtmlContentToString(IHtmlAsyncContent result)
{
var writer = new StringWriter();
diff --git a/src/Components/Server/src/Circuits/CircuitFactory.cs b/src/Components/Server/src/Circuits/CircuitFactory.cs
index 7fce7503f35b..be600dc4db1e 100644
--- a/src/Components/Server/src/Circuits/CircuitFactory.cs
+++ b/src/Components/Server/src/Circuits/CircuitFactory.cs
@@ -61,8 +61,14 @@ public async ValueTask CreateCircuitHostAsync(
navigationManager.Initialize(baseUri, uri);
}
- var appLifetime = scope.ServiceProvider.GetRequiredService();
- await appLifetime.RestoreStateAsync(store);
+ if (components.Count > 0)
+ {
+ // Skip initializing the state if there are no components.
+ // This is the case on Blazor Web scenarios, which will initialize the state
+ // when the first set of components is provided via an UpdateRootComponents call.
+ var appLifetime = scope.ServiceProvider.GetRequiredService();
+ await appLifetime.RestoreStateAsync(store);
+ }
var serverComponentDeserializer = scope.ServiceProvider.GetRequiredService();
var jsComponentInterop = new CircuitJSComponentInterop(_options);
@@ -76,7 +82,11 @@ public async ValueTask CreateCircuitHostAsync(
jsRuntime,
jsComponentInterop);
- var circuitHandlers = scope.ServiceProvider.GetServices()
+ // In Blazor Server we have already restored the app state, so we can get the handlers from DI.
+ // In Blazor Web the state is provided in the first call to UpdateRootComponents, so we need to
+ // delay creating the handlers until then. Otherwise, a handler would be able to access the state
+ // in the constructor for Blazor Server, but not in Blazor Web.
+ var circuitHandlers = components.Count == 0 ? [] : scope.ServiceProvider.GetServices()
.OrderBy(h => h.Order)
.ToArray();
diff --git a/src/Components/Server/src/Circuits/CircuitHost.cs b/src/Components/Server/src/Circuits/CircuitHost.cs
index 145d4b529b50..313636d3f624 100644
--- a/src/Components/Server/src/Circuits/CircuitHost.cs
+++ b/src/Components/Server/src/Circuits/CircuitHost.cs
@@ -2,8 +2,10 @@
// The .NET Foundation licenses this file to you under the MIT license.
using System.Globalization;
+using System.Linq;
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
+using Microsoft.AspNetCore.Components.Infrastructure;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
@@ -18,11 +20,12 @@ internal partial class CircuitHost : IAsyncDisposable
{
private readonly AsyncServiceScope _scope;
private readonly CircuitOptions _options;
- private readonly CircuitHandler[] _circuitHandlers;
private readonly RemoteNavigationManager _navigationManager;
private readonly ILogger _logger;
private readonly Func, Task> _dispatchInboundActivity;
+ private CircuitHandler[] _circuitHandlers;
private bool _initialized;
+ private bool _isFirstUpdate = true;
private bool _disposed;
// This event is fired when there's an unrecoverable exception coming from the circuit, and
@@ -111,8 +114,15 @@ public Task InitializeAsync(ProtectedPrerenderComponentApplicationStore store, C
{
_initialized = true; // We're ready to accept incoming JSInterop calls from here on
- await OnCircuitOpenedAsync(cancellationToken);
- await OnConnectionUpAsync(cancellationToken);
+ // We only run the handlers in case we are in a Blazor Server scenario, which renders
+ // the components inmediately during start.
+ // On Blazor Web scenarios we delay running these handlers until the first UpdateRootComponents call
+ // We do this so that the handlers can have access to the restored application state.
+ if (Descriptors.Count > 0)
+ {
+ await OnCircuitOpenedAsync(cancellationToken);
+ await OnConnectionUpAsync(cancellationToken);
+ }
// Here, we add each root component but don't await the returned tasks so that the
// components can be processed in parallel.
@@ -130,7 +140,20 @@ public Task InitializeAsync(ProtectedPrerenderComponentApplicationStore store, C
// At this point all components have successfully produced an initial render and we can clear the contents of the component
// application state store. This ensures the memory that was not used during the initial render of these components gets
// reclaimed since no-one else is holding on to it any longer.
- store.ExistingState.Clear();
+ // This is also important because otherwise components will keep reusing the existing state after
+ // the initial render instead of initializing their state from the original sources like the Db or a
+ // web service, preventing UI updates.
+ if (Descriptors.Count > 0)
+ {
+ store.ExistingState.Clear();
+ }
+
+ // This variable is used to track that this is the first time we are updating components.
+ // In Blazor Web scenarios the app will send an initial empty list of descriptors,
+ // so we want to make sure that we allow setting up the state in that case.
+ // In Blazor Server the initial set of descriptors is provided via the call to Start, so
+ // we want to make sure we don't take any state afterwards.
+ _isFirstUpdate = Descriptors.Count == 0;
Log.InitializationSucceeded(_logger);
}
@@ -702,6 +725,113 @@ private async Task TryNotifyClientErrorAsync(IClientProxy client, string error,
}
}
+ internal Task UpdateRootComponents(
+ (RootComponentOperation, ComponentDescriptor?)[] operations,
+ ProtectedPrerenderComponentApplicationStore store,
+ IServerComponentDeserializer serverComponentDeserializer,
+ CancellationToken cancellation)
+ {
+ Log.UpdateRootComponentsStarted(_logger);
+
+ return Renderer.Dispatcher.InvokeAsync(async () =>
+ {
+ var shouldClearStore = false;
+ Task[]? pendingTasks = null;
+ try
+ {
+ if (Descriptors.Count > 0)
+ {
+ // Block updating components if they were provided during StartCircuit. This keeps
+ // the footprint for Blazor Server closer to what it was before.
+ throw new InvalidOperationException("UpdateRootComponents is not supported when components have" +
+ " been provided during circuit start up.");
+ }
+ if (_isFirstUpdate)
+ {
+ _isFirstUpdate = false;
+ if (store != null)
+ {
+ shouldClearStore = true;
+ // We only do this if we have no root components. Otherwise, the state would have been
+ // provided during the start up process
+ var appLifetime = _scope.ServiceProvider.GetRequiredService();
+ await appLifetime.RestoreStateAsync(store);
+ }
+
+ // Retrieve the circuit handlers at this point.
+ _circuitHandlers = [.. _scope.ServiceProvider.GetServices().OrderBy(h => h.Order)];
+ await OnCircuitOpenedAsync(cancellation);
+ await OnConnectionUpAsync(cancellation);
+
+ for (var i = 0; i < operations.Length; i++)
+ {
+ var operation = operations[i];
+ if (operation.Item1.Type != RootComponentOperationType.Add)
+ {
+ throw new InvalidOperationException($"The first set of update operations must always be of type {nameof(RootComponentOperationType.Add)}");
+ }
+ }
+
+ pendingTasks = new Task[operations.Length];
+ }
+
+ for (var i = 0; i < operations.Length;i++)
+ {
+ var (operation, descriptor) = operations[i];
+ switch (operation.Type)
+ {
+ case RootComponentOperationType.Add:
+ var task = Renderer.AddComponentAsync(descriptor.ComponentType, descriptor.Parameters, operation.SelectorId.Value.ToString(CultureInfo.InvariantCulture));
+ if (pendingTasks != null)
+ {
+ pendingTasks[i] = task;
+ }
+ break;
+ case RootComponentOperationType.Update:
+ var componentType = Renderer.GetExistingComponentType(operation.ComponentId.Value);
+ if (descriptor.ComponentType != componentType)
+ {
+ Log.InvalidComponentTypeForUpdate(_logger, message: "Component type mismatch.");
+ throw new InvalidOperationException($"Incorrect type for descriptor '{descriptor.ComponentType.FullName}'");
+ }
+
+ // We don't need to await component updates as any unhandled exception will be reported and terminate the circuit.
+ _ = Renderer.UpdateRootComponentAsync(operation.ComponentId.Value, descriptor.Parameters);
+
+ break;
+ case RootComponentOperationType.Remove:
+ Renderer.RemoveExistingRootComponent(operation.ComponentId.Value);
+ break;
+ }
+ }
+
+ if (pendingTasks != null)
+ {
+ await Task.WhenAll(pendingTasks);
+ }
+
+ Log.UpdateRootComponentsSucceeded(_logger);
+ }
+ catch (Exception ex)
+ {
+ // Report errors asynchronously. UpdateRootComponents is designed not to throw.
+ Log.UpdateRootComponentsFailed(_logger, ex);
+ UnhandledException?.Invoke(this, new UnhandledExceptionEventArgs(ex, isTerminating: false));
+ await TryNotifyClientErrorAsync(Client, GetClientErrorMessage(ex), ex);
+ }
+ finally
+ {
+ if (shouldClearStore)
+ {
+ // At this point all components have successfully produced an initial render and we can clear the contents of the component
+ // application state store. This ensures the memory that was not used during the initial render of these components gets
+ // reclaimed since no-one else is holding on to it any longer.
+ store.ExistingState.Clear();
+ }
+ }
+ });
+ }
+
private static partial class Log
{
// 100s used for lifecycle stuff
@@ -740,6 +870,15 @@ private static partial class Log
[LoggerMessage(110, LogLevel.Error, "Unhandled error invoking circuit handler type {handlerType}.{handlerMethod}: {Message}", EventName = "CircuitHandlerFailed")]
private static partial void CircuitHandlerFailed(ILogger logger, Type handlerType, string handlerMethod, string message, Exception exception);
+ [LoggerMessage(111, LogLevel.Debug, "Update root components started.", EventName = nameof(UpdateRootComponentsStarted))]
+ public static partial void UpdateRootComponentsStarted(ILogger logger);
+
+ [LoggerMessage(112, LogLevel.Debug, "Update root components succeeded.", EventName = nameof(UpdateRootComponentsSucceeded))]
+ public static partial void UpdateRootComponentsSucceeded(ILogger logger);
+
+ [LoggerMessage(113, LogLevel.Debug, "Update root components failed.", EventName = nameof(UpdateRootComponentsFailed))]
+ public static partial void UpdateRootComponentsFailed(ILogger logger, Exception exception);
+
public static void CircuitHandlerFailed(ILogger logger, CircuitHandler handler, string handlerMethod, Exception exception)
{
CircuitHandlerFailed(
@@ -765,6 +904,9 @@ public static void CircuitHandlerFailed(ILogger logger, CircuitHandler handler,
[LoggerMessage(115, LogLevel.Debug, "An exception occurred on the circuit host '{CircuitId}' while the client is disconnected.", EventName = "UnhandledExceptionClientDisconnected")]
public static partial void UnhandledExceptionClientDisconnected(ILogger logger, CircuitId circuitId, Exception exception);
+ [LoggerMessage(116, LogLevel.Debug, "The root component operation of type 'Update' was invalid: {Message}", EventName = nameof(InvalidComponentTypeForUpdate))]
+ public static partial void InvalidComponentTypeForUpdate(ILogger logger, string message);
+
[LoggerMessage(200, LogLevel.Debug, "Failed to parse the event data when trying to dispatch an event.", EventName = "DispatchEventFailedToParseEventData")]
public static partial void DispatchEventFailedToParseEventData(ILogger logger, Exception ex);
diff --git a/src/Components/Server/src/Circuits/IServerComponentDeserializer.cs b/src/Components/Server/src/Circuits/IServerComponentDeserializer.cs
index aa13fbb78fa8..0959a6925701 100644
--- a/src/Components/Server/src/Circuits/IServerComponentDeserializer.cs
+++ b/src/Components/Server/src/Circuits/IServerComponentDeserializer.cs
@@ -10,6 +10,6 @@ internal interface IServerComponentDeserializer
bool TryDeserializeComponentDescriptorCollection(
string serializedComponentRecords,
out List descriptors);
-
bool TryDeserializeSingleComponentDescriptor(ComponentMarker record, [NotNullWhen(true)] out ComponentDescriptor? result);
+ bool TryDeserializeRootComponentOperations(string serializedComponentOperations, out (RootComponentOperation, ComponentDescriptor?)[] operationsWithDescriptors);
}
diff --git a/src/Components/Server/src/Circuits/RemoteRenderer.cs b/src/Components/Server/src/Circuits/RemoteRenderer.cs
index 40f2f7a87a4c..48eb9df03b8e 100644
--- a/src/Components/Server/src/Circuits/RemoteRenderer.cs
+++ b/src/Components/Server/src/Circuits/RemoteRenderer.cs
@@ -3,9 +3,7 @@
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
-using System.Globalization;
using System.Linq;
-using System.Text.Json;
using Microsoft.AspNetCore.Components.RenderTree;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.SignalR;
@@ -72,93 +70,14 @@ protected override void AttachRootComponentToBrowser(int componentId, string dom
_ = CaptureAsyncExceptions(attachComponentTask);
}
- protected override void UpdateRootComponents(string operationsJson)
- {
- var operations = JsonSerializer.Deserialize>(
- operationsJson,
- ServerComponentSerializationSettings.JsonSerializationOptions);
-
- foreach (var operation in operations)
- {
- switch (operation.Type)
- {
- case RootComponentOperationType.Add:
- AddRootComponent(operation);
- break;
- case RootComponentOperationType.Update:
- UpdateRootComponent(operation);
- break;
- case RootComponentOperationType.Remove:
- RemoveRootComponent(operation);
- break;
- }
- }
-
- return;
-
- void AddRootComponent(RootComponentOperation operation)
- {
- if (operation.SelectorId is not { } selectorId)
- {
- Log.InvalidRootComponentOperation(_logger, operation.Type, message: "Missing selector ID.");
- return;
- }
+ internal Task UpdateRootComponentAsync(int componentId, ParameterView initialParameters) =>
+ RenderRootComponentAsync(componentId, initialParameters);
- if (operation.Marker is not { } marker)
- {
- Log.InvalidRootComponentOperation(_logger, operation.Type, message: "Missing marker.");
- return;
- }
-
- if (!_serverComponentDeserializer.TryDeserializeSingleComponentDescriptor(marker, out var descriptor))
- {
- throw new InvalidOperationException("Failed to deserialize a component descriptor when adding a new root component.");
- }
+ internal void RemoveExistingRootComponent(int componentId) =>
+ RemoveRootComponent(componentId);
- _ = AddComponentAsync(descriptor.ComponentType, descriptor.Parameters, selectorId.ToString(CultureInfo.InvariantCulture));
- }
-
- void UpdateRootComponent(RootComponentOperation operation)
- {
- if (operation.ComponentId is not { } componentId)
- {
- Log.InvalidRootComponentOperation(_logger, operation.Type, message: "Missing component ID.");
- return;
- }
-
- if (operation.Marker is not { } marker)
- {
- Log.InvalidRootComponentOperation(_logger, operation.Type, message: "Missing marker.");
- return;
- }
-
- var componentState = GetComponentState(componentId);
-
- if (!_serverComponentDeserializer.TryDeserializeSingleComponentDescriptor(marker, out var descriptor))
- {
- throw new InvalidOperationException("Failed to deserialize a component descriptor when updating an existing root component.");
- }
-
- if (descriptor.ComponentType != componentState.Component.GetType())
- {
- Log.InvalidRootComponentOperation(_logger, operation.Type, message: "Component type mismatch.");
- return;
- }
-
- _ = RenderRootComponentAsync(componentId, descriptor.Parameters);
- }
-
- void RemoveRootComponent(RootComponentOperation operation)
- {
- if (operation.ComponentId is not { } componentId)
- {
- Log.InvalidRootComponentOperation(_logger, operation.Type, message: "Missing component ID.");
- return;
- }
-
- this.RemoveRootComponent(componentId);
- }
- }
+ internal Type GetExistingComponentType(int componentId) =>
+ GetComponentState(componentId).Component.GetType();
protected override void ProcessPendingRender()
{
@@ -483,9 +402,6 @@ public static void CompletingBatchWithoutError(ILogger logger, long batchId, Tim
[LoggerMessage(107, LogLevel.Debug, "The queue of unacknowledged render batches is full.", EventName = "FullUnacknowledgedRenderBatchesQueue")]
public static partial void FullUnacknowledgedRenderBatchesQueue(ILogger logger);
-
- [LoggerMessage(108, LogLevel.Debug, "The root component operation of type '{OperationType}' was invalid: {Message}", EventName = "InvalidRootComponentOperation")]
- public static partial void InvalidRootComponentOperation(ILogger logger, RootComponentOperationType operationType, string message);
}
}
diff --git a/src/Components/Server/src/Circuits/ServerComponentDeserializer.cs b/src/Components/Server/src/Circuits/ServerComponentDeserializer.cs
index 36607d12ae58..bea61cedf912 100644
--- a/src/Components/Server/src/Circuits/ServerComponentDeserializer.cs
+++ b/src/Components/Server/src/Circuits/ServerComponentDeserializer.cs
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+using System.Buffers;
using System.Diagnostics.CodeAnalysis;
using System.Text;
using System.Text.Json;
@@ -270,6 +271,95 @@ private bool IsWellFormedServerComponent(ComponentMarker record)
return (componentDescriptor, serverComponent);
}
+ public bool TryDeserializeRootComponentOperations(string serializedComponentOperations, out (RootComponentOperation, ComponentDescriptor?)[] operations)
+ {
+ int[]? seenComponentIdsStorage = null;
+ try
+ {
+ var result = JsonSerializer.Deserialize(
+ serializedComponentOperations,
+ ServerComponentSerializationSettings.JsonSerializationOptions);
+
+ operations = new (RootComponentOperation, ComponentDescriptor?)[result.Length];
+
+ Span seenComponentIds = result.Length <= 128
+ ? stackalloc int[result.Length]
+ : (seenComponentIdsStorage = ArrayPool.Shared.Rent(result.Length)).AsSpan(0, result.Length);
+ var currentComponentIdIndex = 0;
+ for (var i = 0; i < result.Length; i++)
+ {
+ var operation = result[i];
+ if (operation.Type == RootComponentOperationType.Remove ||
+ operation.Type == RootComponentOperationType.Update)
+ {
+ if (operation.ComponentId == null)
+ {
+ Log.InvalidRootComponentOperation(_logger, operation.Type, message: "Missing component ID.");
+ operations = null;
+ return false;
+ }
+
+ if (seenComponentIds[0..currentComponentIdIndex]
+ .Contains(operation.ComponentId.Value))
+ {
+ Log.InvalidRootComponentOperation(_logger, operation.Type, message: "Duplicate component ID.");
+ operations = null;
+ return false;
+ }
+
+ seenComponentIds[currentComponentIdIndex++] = operation.ComponentId.Value;
+ }
+
+ if (operation.Type == RootComponentOperationType.Remove)
+ {
+ operations[i] = (operation, null);
+ continue;
+ }
+
+ if (operation.Type == RootComponentOperationType.Add)
+ {
+ if (operation.SelectorId is not { } selectorId)
+ {
+ Log.InvalidRootComponentOperation(_logger, operation.Type, message: "Missing selector ID.");
+ operations = null;
+ return false;
+ }
+ }
+
+ if (operation.Marker == null)
+ {
+ Log.InvalidRootComponentOperation(_logger, operation.Type, message: "Missing marker.");
+ operations = null;
+ return false;
+ }
+
+ if (!TryDeserializeSingleComponentDescriptor(operation.Marker.Value, out var descriptor))
+ {
+ operations = null;
+ return false;
+ }
+
+ operations[i] = (operation, descriptor);
+ }
+
+ return true;
+
+ }
+ catch (Exception ex)
+ {
+ Log.FailedToProcessRootComponentOperations(_logger, ex);
+ operations = null;
+ return false;
+ }
+ finally
+ {
+ if (seenComponentIdsStorage != null)
+ {
+ ArrayPool.Shared.Return(seenComponentIdsStorage);
+ }
+ }
+ }
+
private static partial class Log
{
[LoggerMessage(1, LogLevel.Debug, "Failed to deserialize the component descriptor.", EventName = "FailedToDeserializeDescriptor")]
@@ -301,5 +391,11 @@ private static partial class Log
[LoggerMessage(10, LogLevel.Debug, "The descriptor with sequence '{sequence}' was already used for the current invocationId '{invocationId}'.", EventName = "ReusedDescriptorSequence")]
public static partial void ReusedDescriptorSequence(ILogger logger, int sequence, string invocationId);
+
+ [LoggerMessage(11, LogLevel.Debug, "The root component operation of type '{OperationType}' was invalid: {Message}", EventName = "InvalidRootComponentOperation")]
+ public static partial void InvalidRootComponentOperation(ILogger logger, RootComponentOperationType operationType, string message);
+
+ [LoggerMessage(12, LogLevel.Debug, "Failed to parse root component operations", EventName = nameof(FailedToProcessRootComponentOperations))]
+ public static partial void FailedToProcessRootComponentOperations(ILogger logger, Exception exception);
}
}
diff --git a/src/Components/Server/src/ComponentHub.cs b/src/Components/Server/src/ComponentHub.cs
index ecfda9e35f65..730e75b71ddf 100644
--- a/src/Components/Server/src/ComponentHub.cs
+++ b/src/Components/Server/src/ComponentHub.cs
@@ -160,6 +160,33 @@ public async ValueTask StartCircuit(string baseUri, string uri, string s
}
}
+ public async Task UpdateRootComponents(string serializedComponentOperations, string applicationState)
+ {
+ var circuitHost = await GetActiveCircuitAsync();
+ if (circuitHost == null)
+ {
+ return;
+ }
+
+ if (!_serverComponentSerializer.TryDeserializeRootComponentOperations(
+ serializedComponentOperations,
+ out var operations))
+ {
+ // There was an error, so kill the circuit.
+ await _circuitRegistry.TerminateAsync(circuitHost.CircuitId);
+ await NotifyClientError(Clients.Caller, "The list of component operations is not valid.");
+ Context.Abort();
+
+ return;
+ }
+
+ var store = !string.IsNullOrEmpty(applicationState) ?
+ new ProtectedPrerenderComponentApplicationStore(applicationState, _dataProtectionProvider) :
+ new ProtectedPrerenderComponentApplicationStore(_dataProtectionProvider);
+
+ _ = circuitHost.UpdateRootComponents(operations, store, _serverComponentSerializer, Context.ConnectionAborted);
+ }
+
public async ValueTask ConnectCircuit(string circuitIdSecret)
{
// TryParseCircuitId will not throw.
diff --git a/src/Components/Server/test/Circuits/CircuitHostTest.cs b/src/Components/Server/test/Circuits/CircuitHostTest.cs
index fd8e82909726..dc1a53879884 100644
--- a/src/Components/Server/test/Circuits/CircuitHostTest.cs
+++ b/src/Components/Server/test/Circuits/CircuitHostTest.cs
@@ -3,6 +3,9 @@
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
+using System.Text.Json;
+using Microsoft.AspNetCore.Components.Endpoints;
+using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.DependencyInjection;
@@ -15,6 +18,9 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits;
public class CircuitHostTest
{
+ private readonly IDataProtectionProvider _ephemeralDataProtectionProvider = new EphemeralDataProtectionProvider();
+ private readonly ServerComponentInvocationSequence _invocationSequence = new();
+
[Fact]
public async Task DisposeAsync_DisposesResources()
{
@@ -252,7 +258,7 @@ public async Task InitializeAsync_ReportsOwnAsyncExceptions()
.Returns(tcs.Task)
.Verifiable();
- var circuitHost = TestCircuitHost.Create(handlers: new[] { handler.Object });
+ var circuitHost = TestCircuitHost.Create(handlers: new[] { handler.Object }, descriptors: [new ComponentDescriptor() ]);
circuitHost.UnhandledException += (sender, errorInfo) =>
{
Assert.Same(circuitHost, sender);
@@ -405,6 +411,194 @@ await circuitHost.HandleInboundActivityAsync(() =>
Assert.True(wasHandlerFuncInvoked);
}
+ [Fact]
+ public async Task UpdateRootComponents_CanAddNewRootComponent()
+ {
+ // Arrange
+ var circuitHost = TestCircuitHost.Create(
+ remoteRenderer: GetRemoteRenderer(),
+ serviceScope: new ServiceCollection().BuildServiceProvider().CreateAsyncScope());
+ var expectedMessage = "Hello, world!";
+ Dictionary parameters = new()
+ {
+ [nameof(DynamicallyAddedComponent.Message)] = expectedMessage,
+ };
+ var operation = new RootComponentOperation
+ {
+ Type = RootComponentOperationType.Add,
+ SelectorId = 1,
+ Marker = CreateMarker(typeof(DynamicallyAddedComponent), parameters),
+ };
+ var descriptor = new ComponentDescriptor()
+ {
+ ComponentType = typeof(DynamicallyAddedComponent),
+ Parameters = ParameterView.FromDictionary(parameters),
+ Sequence = 0,
+ };
+
+ // Act
+ await circuitHost.UpdateRootComponents(
+ [(operation, descriptor)], null, CreateDeserializer(), CancellationToken.None);
+
+ // Assert
+ var componentState = ((TestRemoteRenderer)circuitHost.Renderer).GetTestComponentState(0);
+ var component = Assert.IsType(componentState.Component);
+ Assert.Equal(expectedMessage, component.Message);
+ }
+
+ [Fact]
+ public async Task UpdateRootComponents_CanUpdateExistingRootComponent()
+ {
+ // Arrange
+ var circuitHost = TestCircuitHost.Create(
+ remoteRenderer: GetRemoteRenderer(),
+ serviceScope: new ServiceCollection().BuildServiceProvider().CreateAsyncScope());
+ var expectedMessage = "Updated message";
+
+ Dictionary parameters = new()
+ {
+ [nameof(DynamicallyAddedComponent.Message)] = expectedMessage,
+ };
+ await AddComponent(circuitHost, parameters);
+
+ var operation = new RootComponentOperation
+ {
+ Type = RootComponentOperationType.Update,
+ ComponentId = 0,
+ Marker = CreateMarker(typeof(DynamicallyAddedComponent), new()
+ {
+ [nameof(DynamicallyAddedComponent.Message)] = expectedMessage,
+ }),
+ };
+ var descriptor = new ComponentDescriptor()
+ {
+ ComponentType = typeof(DynamicallyAddedComponent),
+ Parameters = ParameterView.FromDictionary(new Dictionary()),
+ Sequence = 0,
+ };
+
+ // Act
+ await circuitHost.UpdateRootComponents([(operation, descriptor)], null, CreateDeserializer(), CancellationToken.None);
+
+ // Assert
+ var componentState = ((TestRemoteRenderer)circuitHost.Renderer).GetTestComponentState(0);
+ var component = Assert.IsType(componentState.Component);
+ Assert.Equal(expectedMessage, component.Message);
+ }
+
+ [Fact]
+ public async Task UpdateRootComponents_DoesNotUpdateExistingRootComponent_WhenDescriptorComponentTypeDoesNotMatchRootComponentType()
+ {
+ // Arrange
+ var circuitHost = TestCircuitHost.Create(
+ remoteRenderer: GetRemoteRenderer(),
+ serviceScope: new ServiceCollection().BuildServiceProvider().CreateAsyncScope());
+
+ // Arrange
+ var expectedMessage = "Existing message";
+ await AddComponent(circuitHost, new Dictionary()
+ {
+ [nameof(DynamicallyAddedComponent.Message)] = expectedMessage,
+ });
+
+ await AddComponent(circuitHost, []);
+
+ Dictionary parameters = new()
+ {
+ [nameof(DynamicallyAddedComponent.Message)] = "Updated message",
+ };
+ var operation = new RootComponentOperation
+ {
+ Type = RootComponentOperationType.Update,
+ ComponentId = 0,
+ Marker = CreateMarker(typeof(TestComponent) /* Note the incorrect component type */, parameters),
+ };
+ var descriptor = new ComponentDescriptor()
+ {
+ ComponentType = typeof(TestComponent),
+ Parameters = ParameterView.FromDictionary(parameters),
+ Sequence = 0,
+ };
+ var operationsJson = JsonSerializer.Serialize(
+ new[] { operation },
+ ServerComponentSerializationSettings.JsonSerializationOptions);
+
+ // Act
+ var evt = Assert.Raises(
+ handler => circuitHost.UnhandledException += new UnhandledExceptionEventHandler(handler),
+ handler => circuitHost.UnhandledException -= new UnhandledExceptionEventHandler(handler),
+ () => circuitHost.UpdateRootComponents(
+ [(operation, descriptor)], null, CreateDeserializer(), CancellationToken.None));
+
+ // Assert
+ var componentState = ((TestRemoteRenderer)circuitHost.Renderer).GetTestComponentState(0);
+ var component = Assert.IsType(componentState.Component);
+ Assert.Equal(expectedMessage, component.Message);
+
+ Assert.NotNull(evt);
+ var exception = Assert.IsType(evt.Arguments.ExceptionObject);
+ }
+
+ [Fact]
+ public async Task UpdateRootComponents_CanRemoveExistingRootComponent()
+ {
+ // Arrange
+ var circuitHost = TestCircuitHost.Create(
+ remoteRenderer: GetRemoteRenderer(),
+ serviceScope: new ServiceCollection().BuildServiceProvider().CreateAsyncScope());
+ var expectedMessage = "Updated message";
+
+ Dictionary parameters = new()
+ {
+ [nameof(DynamicallyAddedComponent.Message)] = expectedMessage,
+ };
+ await AddComponent(circuitHost, parameters);
+
+ var operation = new RootComponentOperation
+ {
+ Type = RootComponentOperationType.Remove,
+ ComponentId = 0,
+ };
+
+ // Act
+ await circuitHost.UpdateRootComponents([(operation, null)], null, CreateDeserializer(), CancellationToken.None);
+
+ // Assert
+ Assert.Throws(() =>
+ ((TestRemoteRenderer)circuitHost.Renderer).GetTestComponentState(0));
+ }
+
+ private async Task AddComponent(CircuitHost circuitHost, Dictionary parameters)
+ where TComponent : IComponent
+ {
+ var addOperation = new RootComponentOperation
+ {
+ Type = RootComponentOperationType.Add,
+ SelectorId = 1,
+ Marker = CreateMarker(typeof(TComponent), parameters),
+ };
+ var addDescriptor = new ComponentDescriptor()
+ {
+ ComponentType = typeof(TComponent),
+ Parameters = ParameterView.FromDictionary(parameters),
+ Sequence = 0,
+ };
+
+ // Add component
+ await circuitHost.UpdateRootComponents(
+ [(addOperation, addDescriptor)], null, CreateDeserializer(), CancellationToken.None);
+ }
+
+ private ProtectedPrerenderComponentApplicationStore CreateStore()
+ {
+ return new ProtectedPrerenderComponentApplicationStore(_ephemeralDataProtectionProvider);
+ }
+
+ private ServerComponentDeserializer CreateDeserializer()
+ {
+ return new ServerComponentDeserializer(_ephemeralDataProtectionProvider, NullLogger.Instance, new RootComponentTypeCache(), new ComponentParameterDeserializer(NullLogger.Instance, new ComponentParametersTypeCache()));
+ }
+
private static TestRemoteRenderer GetRemoteRenderer()
{
var serviceCollection = new ServiceCollection();
@@ -434,6 +628,18 @@ private static void SetupMockInboundActivityHandler(Mock circuit
.Verifiable();
}
+ private ComponentMarker CreateMarker(Type type, Dictionary parameters = null)
+ {
+ var serializer = new ServerComponentSerializer(_ephemeralDataProtectionProvider);
+ var marker = ComponentMarker.Create(ComponentMarker.ServerMarkerType, false, null);
+ serializer.SerializeInvocation(
+ ref marker,
+ _invocationSequence,
+ type,
+ parameters is null ? ParameterView.Empty : ParameterView.FromDictionary(parameters));
+ return marker;
+ }
+
private class TestRemoteRenderer : RemoteRenderer
{
public TestRemoteRenderer(IServiceProvider serviceProvider, IClientProxy client)
@@ -449,6 +655,9 @@ public TestRemoteRenderer(IServiceProvider serviceProvider, IClientProxy client)
{
}
+ public ComponentState GetTestComponentState(int id)
+ => base.GetComponentState(id);
+
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
@@ -580,10 +789,95 @@ public bool TryDeserializeComponentDescriptorCollection(string serializedCompone
return true;
}
+ public bool TryDeserializeRootComponentOperations(string serializedComponentOperations, out (RootComponentOperation, ComponentDescriptor)[] operationsWithDescriptors)
+ {
+ operationsWithDescriptors= default;
+ return true;
+ }
+
public bool TryDeserializeSingleComponentDescriptor(ComponentMarker record, [NotNullWhen(true)] out ComponentDescriptor result)
{
result = default;
return true;
}
}
+
+ private class DynamicallyAddedComponent : IComponent, IDisposable
+ {
+ private readonly TaskCompletionSource _disposeTcs = new();
+ private RenderHandle _renderHandle;
+
+ [Parameter]
+ public string Message { get; set; } = "Default message";
+
+ private void Render(RenderTreeBuilder builder)
+ {
+ builder.AddContent(0, Message);
+ }
+
+ public void Attach(RenderHandle renderHandle)
+ {
+ _renderHandle = renderHandle;
+ }
+
+ public Task SetParametersAsync(ParameterView parameters)
+ {
+ if (parameters.TryGetValue(nameof(Message), out var message))
+ {
+ Message = message;
+ }
+
+ TriggerRender();
+ return Task.CompletedTask;
+ }
+
+ public void TriggerRender()
+ {
+ var task = _renderHandle.Dispatcher.InvokeAsync(() => _renderHandle.Render(Render));
+ Assert.True(task.IsCompletedSuccessfully);
+ }
+
+ public Task WaitForDisposeAsync()
+ => _disposeTcs.Task;
+
+ public void Dispose()
+ {
+ _disposeTcs.SetResult();
+ }
+ }
+
+ private class TestComponent() : IComponent, IHandleAfterRender
+ {
+ private RenderHandle _renderHandle;
+ private readonly RenderFragment _renderFragment = (builder) =>
+ {
+ builder.OpenElement(0, "my element");
+ builder.AddContent(1, "some text");
+ builder.CloseElement();
+ };
+
+ public TestComponent(RenderFragment renderFragment) : this() => _renderFragment = renderFragment;
+
+ public Action OnAfterRenderComplete { get; set; }
+
+ public void Attach(RenderHandle renderHandle) => _renderHandle = renderHandle;
+
+ public Task OnAfterRenderAsync()
+ {
+ OnAfterRenderComplete?.Invoke();
+ return Task.CompletedTask;
+ }
+
+ public Task SetParametersAsync(ParameterView parameters)
+ {
+ TriggerRender();
+ return Task.CompletedTask;
+ }
+
+ public void TriggerRender()
+ {
+ var task = _renderHandle.Dispatcher.InvokeAsync(() => _renderHandle.Render(_renderFragment));
+ Assert.True(task.IsCompletedSuccessfully);
+ }
+ }
}
diff --git a/src/Components/Server/test/Circuits/ComponentHubTest.cs b/src/Components/Server/test/Circuits/ComponentHubTest.cs
index 263ff8c4860f..afdc33e7e674 100644
--- a/src/Components/Server/test/Circuits/ComponentHubTest.cs
+++ b/src/Components/Server/test/Circuits/ComponentHubTest.cs
@@ -170,6 +170,12 @@ public bool TryDeserializeComponentDescriptorCollection(string serializedCompone
return true;
}
+ public bool TryDeserializeRootComponentOperations(string serializedComponentOperations, out (RootComponentOperation, ComponentDescriptor)[] operationsWithDescriptors)
+ {
+ operationsWithDescriptors = default;
+ return true;
+ }
+
public bool TryDeserializeSingleComponentDescriptor(ComponentMarker record, [NotNullWhen(true)] out ComponentDescriptor result)
{
result = default;
diff --git a/src/Components/Server/test/Circuits/RemoteRendererTest.cs b/src/Components/Server/test/Circuits/RemoteRendererTest.cs
index ae0dd4247fc1..5a0f8686c689 100644
--- a/src/Components/Server/test/Circuits/RemoteRendererTest.cs
+++ b/src/Components/Server/test/Circuits/RemoteRendererTest.cs
@@ -25,7 +25,6 @@ public class RemoteRendererTest
private static readonly TimeSpan Timeout = Debugger.IsAttached ? System.Threading.Timeout.InfiniteTimeSpan : TimeSpan.FromSeconds(10);
private readonly IDataProtectionProvider _ephemeralDataProtectionProvider = new EphemeralDataProtectionProvider();
- private readonly ServerComponentInvocationSequence _invocationSequence = new();
[Fact]
public void WritesAreBufferedWhenTheClientIsOffline()
@@ -426,239 +425,6 @@ await renderer.Dispatcher.InvokeAsync(() => renderer.RenderComponentAsync renderer.UpdateRootComponents(operationsJson));
- var componentState = renderer.GetComponentState(0);
-
- // Assert
- var component = Assert.IsType(componentState.Component);
- Assert.Equal(expectedMessage, component.Message);
- }
-
- [Fact]
- public async Task UpdateRootComponents_DoesNotAddNewRootComponent_WhenSelectorIdIsMissing()
- {
- // Arrange
- var serviceProvider = CreateServiceProvider();
- var renderer = GetRemoteRenderer(serviceProvider);
- var operation = new RootComponentOperation
- {
- Type = RootComponentOperationType.Add,
- Marker = CreateMarker(typeof(DynamicallyAddedComponent)),
- };
- var operationsJson = JsonSerializer.Serialize(
- new[] { operation },
- ServerComponentSerializationSettings.JsonSerializationOptions);
-
- // Act
- await renderer.Dispatcher.InvokeAsync(() => renderer.UpdateRootComponents(operationsJson));
- renderer.UpdateRootComponents(operationsJson);
-
- // Assert
- var ex = Assert.Throws(() => renderer.GetComponentState(0));
- Assert.StartsWith("The renderer does not have a component with ID", ex.Message);
- }
-
- [Fact]
- public async Task UpdateRootComponents_Throws_WhenAddingComponentFromInvalidDescriptor()
- {
- // Arrange
- var serviceProvider = CreateServiceProvider();
- var renderer = GetRemoteRenderer(serviceProvider);
- var operation = new RootComponentOperation
- {
- Type = RootComponentOperationType.Add,
- SelectorId = 1,
- Marker = new ComponentMarker()
- {
- Descriptor = "some random text",
- },
- };
- var operationsJson = JsonSerializer.Serialize(
- new[] { operation },
- ServerComponentSerializationSettings.JsonSerializationOptions);
-
- // Act
- var task = renderer.Dispatcher.InvokeAsync(() => renderer.UpdateRootComponents(operationsJson));
-
- // Assert
- var ex = await Assert.ThrowsAsync(async () => await task);
- Assert.StartsWith("Failed to deserialize a component descriptor when adding", ex.Message);
- }
-
- [Fact]
- public async Task UpdateRootComponents_CanUpdateExistingRootComponent()
- {
- // Arrange
- var serviceProvider = CreateServiceProvider();
- var renderer = GetRemoteRenderer(serviceProvider);
- var component = new DynamicallyAddedComponent()
- {
- Message = "Existing message",
- };
- var expectedMessage = "Updated message";
- var componentId = renderer.AssignRootComponentId(component);
- var operation = new RootComponentOperation
- {
- Type = RootComponentOperationType.Update,
- ComponentId = componentId,
- Marker = CreateMarker(typeof(DynamicallyAddedComponent), new()
- {
- [nameof(DynamicallyAddedComponent.Message)] = expectedMessage,
- }),
- };
- var operationsJson = JsonSerializer.Serialize(
- new[] { operation },
- ServerComponentSerializationSettings.JsonSerializationOptions);
-
- // Act
- await renderer.Dispatcher.InvokeAsync(() => renderer.UpdateRootComponents(operationsJson));
-
- // Assert
- Assert.Equal(expectedMessage, component.Message);
- }
-
- [Fact]
- public async Task UpdateRootComponents_DoesNotUpdateExistingRootComponent_WhenComponentIdIsMissing()
- {
- // Arrange
- var serviceProvider = CreateServiceProvider();
- var renderer = GetRemoteRenderer(serviceProvider);
- var expectedMessage = "Existing message";
- var component = new DynamicallyAddedComponent()
- {
- Message = expectedMessage,
- };
- var componentId = renderer.AssignRootComponentId(component);
- var operation = new RootComponentOperation
- {
- Type = RootComponentOperationType.Update,
- Marker = CreateMarker(typeof(DynamicallyAddedComponent), new()
- {
- [nameof(DynamicallyAddedComponent.Message)] = "Some other message",
- }),
- };
- var operationsJson = JsonSerializer.Serialize(
- new[] { operation },
- ServerComponentSerializationSettings.JsonSerializationOptions);
-
- // Act
- await renderer.Dispatcher.InvokeAsync(() => renderer.UpdateRootComponents(operationsJson));
-
- // Assert
- Assert.Equal(expectedMessage, component.Message);
- }
-
- [Fact]
- public async Task UpdateRootComponents_DoesNotUpdateExistingRootComponent_WhenDescriptorComponentTypeDoesNotMatchRootComponentType()
- {
- // Arrange
- var serviceProvider = CreateServiceProvider();
- var renderer = GetRemoteRenderer(serviceProvider);
- var expectedMessage = "Existing message";
- var component1 = new DynamicallyAddedComponent()
- {
- Message = expectedMessage,
- };
- var component2 = new TestComponent();
- var component1Id = renderer.AssignRootComponentId(component1);
- var component2Id = renderer.AssignRootComponentId(component2);
- var operation = new RootComponentOperation
- {
- Type = RootComponentOperationType.Update,
- ComponentId = component1Id,
- Marker = CreateMarker(typeof(TestComponent) /* Note the incorrect component type */, new()
- {
- [nameof(DynamicallyAddedComponent.Message)] = "Updated message",
- }),
- };
- var operationsJson = JsonSerializer.Serialize(
- new[] { operation },
- ServerComponentSerializationSettings.JsonSerializationOptions);
-
- // Act
- await renderer.Dispatcher.InvokeAsync(() => renderer.UpdateRootComponents(operationsJson));
-
- // Assert
- Assert.Equal(expectedMessage, component1.Message);
- }
-
- [Fact]
- public async Task UpdateRootComponents_Throws_WhenUpdatingComponentFromInvalidDescriptor()
- {
- // Arrange
- var serviceProvider = CreateServiceProvider();
- var renderer = GetRemoteRenderer(serviceProvider);
- var component = new DynamicallyAddedComponent()
- {
- Message = "Existing message",
- };
- var componentId = renderer.AssignRootComponentId(component);
- var operation = new RootComponentOperation
- {
- Type = RootComponentOperationType.Update,
- ComponentId = componentId,
- Marker = new()
- {
- Descriptor = "some random text",
- },
- };
- var operationsJson = JsonSerializer.Serialize(
- new[] { operation },
- ServerComponentSerializationSettings.JsonSerializationOptions);
-
- // Act
- var task = renderer.Dispatcher.InvokeAsync(() => renderer.UpdateRootComponents(operationsJson));
-
- // Assert
- var ex = await Assert.ThrowsAsync(async () => await task);
- Assert.StartsWith("Failed to deserialize a component descriptor when updating", ex.Message);
- }
-
- [Fact]
- public async Task UpdateRootComponents_CanRemoveExistingRootComponent()
- {
- // Arrange
- var serviceProvider = CreateServiceProvider();
- var renderer = GetRemoteRenderer(serviceProvider);
- var component = new DynamicallyAddedComponent();
- var componentId = renderer.AssignRootComponentId(component);
- var operation = new RootComponentOperation
- {
- Type = RootComponentOperationType.Remove,
- ComponentId = componentId,
- };
- var operationsJson = JsonSerializer.Serialize(
- new[] { operation },
- ServerComponentSerializationSettings.JsonSerializationOptions);
-
- // Act
- await renderer.Dispatcher.InvokeAsync(() => renderer.UpdateRootComponents(operationsJson));
-
- // Assert
- await component.WaitForDisposeAsync().WaitAsync(Timeout); // Will timeout and throw if not disposed
- }
-
private IServiceProvider CreateServiceProvider()
{
var serviceCollection = new ServiceCollection();
@@ -685,18 +451,6 @@ private TestRemoteRenderer GetRemoteRenderer(IServiceProvider serviceProvider, C
NullLogger.Instance);
}
- private ComponentMarker CreateMarker(Type type, Dictionary parameters = null)
- {
- var serializer = new ServerComponentSerializer(_ephemeralDataProtectionProvider);
- var marker = ComponentMarker.Create(ComponentMarker.ServerMarkerType, false, null);
- serializer.SerializeInvocation(
- ref marker,
- _invocationSequence,
- type,
- parameters is null ? ParameterView.Empty : ParameterView.FromDictionary(parameters));
- return marker;
- }
-
private class TestRemoteRenderer : RemoteRenderer
{
public TestRemoteRenderer(IServiceProvider serviceProvider, ILoggerFactory loggerFactory, CircuitOptions options, CircuitClientProxy client, IServerComponentDeserializer serverComponentDeserializer, ILogger logger)
@@ -715,11 +469,6 @@ protected override void AttachRootComponentToBrowser(int componentId, string dom
{
}
- public new void UpdateRootComponents(string operationsJson)
- {
- base.UpdateRootComponents(operationsJson);
- }
-
public new ComponentState GetComponentState(int componentId)
{
return base.GetComponentState(componentId);
@@ -811,48 +560,4 @@ public void TriggerRender()
Component.TriggerRender();
}
}
-
- private class DynamicallyAddedComponent : IComponent, IDisposable
- {
- private readonly TaskCompletionSource _disposeTcs = new();
- private RenderHandle _renderHandle;
-
- [Parameter]
- public string Message { get; set; } = "Default message";
-
- private void Render(RenderTreeBuilder builder)
- {
- builder.AddContent(0, Message);
- }
-
- public void Attach(RenderHandle renderHandle)
- {
- _renderHandle = renderHandle;
- }
-
- public Task SetParametersAsync(ParameterView parameters)
- {
- if (parameters.TryGetValue(nameof(Message), out var message))
- {
- Message = message;
- }
-
- TriggerRender();
- return Task.CompletedTask;
- }
-
- public void TriggerRender()
- {
- var task = _renderHandle.Dispatcher.InvokeAsync(() => _renderHandle.Render(Render));
- Assert.True(task.IsCompletedSuccessfully);
- }
-
- public Task WaitForDisposeAsync()
- => _disposeTcs.Task;
-
- public void Dispose()
- {
- _disposeTcs.SetResult();
- }
- }
}
diff --git a/src/Components/Server/test/Circuits/ServerComponentDeserializerTest.cs b/src/Components/Server/test/Circuits/ServerComponentDeserializerTest.cs
index fc3fefc3711f..0a7168017e67 100644
--- a/src/Components/Server/test/Circuits/ServerComponentDeserializerTest.cs
+++ b/src/Components/Server/test/Circuits/ServerComponentDeserializerTest.cs
@@ -394,6 +394,93 @@ public void TryDeserializeSingleComponentDescriptor_DoesNotParseMarkerFromOldInv
Assert.False(serverComponentDeserializer.TryDeserializeSingleComponentDescriptor(firstInvocationMarkers[0], out _));
}
+ [Fact]
+ public void UpdateRootComponents_TryDeserializeRootComponentOperationsReturnsFalse_WhenAddOperationIsMissingSelectorId()
+ {
+ // Arrange
+ var operation = new RootComponentOperation
+ {
+ Type = RootComponentOperationType.Add,
+ SelectorId = 1,
+ Marker = new ComponentMarker()
+ {
+ Descriptor = "some random text",
+ },
+ };
+ var operationsJson = JsonSerializer.Serialize(
+ new[] { operation },
+ ServerComponentSerializationSettings.JsonSerializationOptions);
+ var deserializer = CreateServerComponentDeserializer();
+
+ // Act
+ var result = deserializer.TryDeserializeRootComponentOperations(operationsJson, out var parsed);
+
+ // Assert
+ Assert.False(result);
+ Assert.Null(parsed);
+ }
+
+ [Fact]
+ public void UpdateRootComponents_TryDeserializeRootComponentOperationsReturnsFalse_WhenComponentIdIsMissing()
+ {
+ // Arrange
+ var operation = new RootComponentOperation
+ {
+ Type = RootComponentOperationType.Update,
+ Marker = CreateMarker(typeof(DynamicallyAddedComponent), new()
+ {
+ ["Message"] = "Some other message",
+ }),
+ };
+ var operationsJson = JsonSerializer.Serialize(
+ new[] { operation },
+ ServerComponentSerializationSettings.JsonSerializationOptions);
+
+ var deserializer = CreateServerComponentDeserializer();
+
+ // Act
+ var result = deserializer.TryDeserializeRootComponentOperations(operationsJson, out var parsed);
+
+ // Assert
+ Assert.False(result);
+ Assert.Null(parsed);
+ }
+
+ [Fact]
+ public void UpdateRootComponents_TryDeserializeRootComponentOperationsReturnsFalse_WhenComponentIdIsRepeated()
+ {
+ // Arrange
+ var operation = new RootComponentOperation
+ {
+ Type = RootComponentOperationType.Update,
+ ComponentId = 1,
+ Marker = CreateMarker(typeof(DynamicallyAddedComponent), new()
+ {
+ ["Message"] = "Some other message",
+ }),
+ };
+
+ var other = new RootComponentOperation
+ {
+ Type = RootComponentOperationType.Remove,
+ ComponentId = 1,
+ Marker = CreateMarker(typeof(DynamicallyAddedComponent)),
+ };
+
+ var operationsJson = JsonSerializer.Serialize(
+ new[] { operation, other },
+ ServerComponentSerializationSettings.JsonSerializationOptions);
+
+ var deserializer = CreateServerComponentDeserializer();
+
+ // Act
+ var result = deserializer.TryDeserializeRootComponentOperations(operationsJson, out var parsed);
+
+ // Assert
+ Assert.False(result);
+ Assert.Null(parsed);
+ }
+
private string SerializeComponent(string assembly, string type) =>
JsonSerializer.Serialize(
new ServerComponent(0, assembly, type, Array.Empty(), Array.Empty