Skip to content

[Blazor] Support for declaratively persisting component and services state #60634

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 37 commits into from
Mar 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
b956382
Initial support for persisting component state
javiercn Feb 26, 2025
f1f5cd1
add support for specifying the render mode for the persisted service
javiercn Feb 27, 2025
0377a29
Fix solution filter
javiercn Feb 28, 2025
8736766
Capture exceptions when disposing the host that result on false failures
javiercn Feb 28, 2025
16b72f5
Fix tests
javiercn Feb 28, 2025
ce89edd
Fix build
javiercn Feb 28, 2025
9e64c8a
Fix tests
javiercn Mar 3, 2025
29dcd96
Swallow exceptions instead of failing the test during cleanup
javiercn Mar 3, 2025
e14ea2b
Undo Components.csproj changes
javiercn Mar 3, 2025
24502b5
Cleanups
javiercn Mar 3, 2025
05ac066
Undo sample changes
javiercn Mar 3, 2025
92bdc15
Undo test infrastructure changes
javiercn Mar 3, 2025
cd3a946
Fix build
javiercn Mar 3, 2025
1e78121
Apply suggestions from code review
javiercn Mar 3, 2025
5e0215d
Add PersistentServicesRegistry tests
javiercn Mar 5, 2025
5e60fee
More tests
javiercn Mar 6, 2025
f36c7fa
Add AddSupplyValueFromPersistentComponentStateProvider to wasm
javiercn Mar 7, 2025
16fe34f
Cleanup and fix unit tests in release
javiercn Mar 7, 2025
8705221
Fix trimming
javiercn Mar 7, 2025
5ed7280
Add E2E tests
javiercn Mar 7, 2025
6ba2eb4
More E2E tests
javiercn Mar 10, 2025
9fc4138
Fix build
javiercn Mar 10, 2025
94c6412
Persitent services E2E tests
javiercn Mar 10, 2025
c64f605
Fix the logic around buffering for large keys
javiercn Mar 10, 2025
ef6b70b
Cache reflection for reading component properties
javiercn Mar 10, 2025
d6d276f
Fix linker flags
javiercn Mar 10, 2025
2e91b14
Make the linker happy
javiercn Mar 10, 2025
1784ad7
Make the linker happy?
javiercn Mar 10, 2025
1444089
Undo submodule changes
javiercn Mar 11, 2025
23e00e3
Hash the prekey to avoid keeping a reference to a larger set ofbytes
javiercn Mar 11, 2025
854370a
Update trimming Justificaitons
javiercn Mar 11, 2025
afabecd
remove default method implementation
javiercn Mar 12, 2025
2ef98cd
Unify RootComponentTypeCache and PersistentServiceTypeCache
javiercn Mar 12, 2025
3717242
Rework part of the public APIs
javiercn Mar 12, 2025
44d92fc
Update public API baselines
javiercn Mar 12, 2025
adc065e
Fix build
javiercn Mar 12, 2025
3478be9
Added support for DateTimeOffset keys
javiercn Mar 13, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions AspNetCore.sln
Original file line number Diff line number Diff line change
Expand Up @@ -1772,6 +1772,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.Http.R
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.Http.ValidationsGenerator", "src\Http\Http.Extensions\gen\Microsoft.AspNetCore.Http.ValidationsGenerator\Microsoft.AspNetCore.Http.ValidationsGenerator.csproj", "{7899F5DD-AA7C-4561-BAC4-E2EC78B7D157}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Endpoints", "Endpoints", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "CustomElements", "CustomElements", "{E22DD5A6-06E2-490E-BD32-88D629FD6668}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -11768,6 +11772,15 @@ Global
{7324770C-0871-4D73-BE3D-5E2F3E9E1B1E} = {D30A658D-61F6-444B-9AC7-F66A1A1B86B6}
{B54A8F61-60DE-4AD9-87CA-D102F230678E} = {D30A658D-61F6-444B-9AC7-F66A1A1B86B6}
{D30A658D-61F6-444B-9AC7-F66A1A1B86B6} = {5E46DC83-C39C-4E3A-B242-C064607F4367}
{76C3E22D-092B-4E8A-81F0-DCF071BFF4CD} = {E22DD5A6-06E2-490E-BD32-88D629FD6668}
{A05652B3-953E-4915-9D7F-0E361D988815} = {0CE1CC26-98CE-4022-A81C-E32AAFC9B819}
{AE4D272D-6F13-42C8-9404-C149188AFA33} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{5D438258-CB19-4282-814F-974ABBC71411} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{F5AE525F-F435-40F9-A567-4D5EC3B50D6E} = {5FE1FBC1-8CE3-4355-9866-44FE1307C5F1}
{87D58D50-20D1-4091-88C5-8D88DCCC2DE3} = {6126DCE4-9692-4EE2-B240-C65743572995}
{433F91E4-E39D-4EB0-B798-2998B3969A2C} = {6126DCE4-9692-4EE2-B240-C65743572995}
{8A021D6D-7935-4AB3-BB47-38D4FF9B0D13} = {6126DCE4-9692-4EE2-B240-C65743572995}
{757CBDE0-5D0A-4FD8-99F3-6C20BDDD4E63} = {5FE1FBC1-8CE3-4355-9866-44FE1307C5F1}
{96EC4DD3-028E-6E27-5B14-08C21B07CE89} = {017429CC-C5FB-48B4-9C46-034E29EE2F06}
{1BBD75D2-429D-D565-A98E-36437448E8C0} = {96EC4DD3-028E-6E27-5B14-08C21B07CE89}
{C10EB67A-F43E-4B85-AEFD-7064C9B3DBE2} = {1BBD75D2-429D-D565-A98E-36437448E8C0}
Expand All @@ -11777,6 +11790,8 @@ Global
{01A75167-DF5A-AF38-8700-C3FBB2C2CFF5} = {225AEDCF-7162-4A86-AC74-06B84660B379}
{E6D564C0-4CA5-411C-BF40-9802AF7900CB} = {01A75167-DF5A-AF38-8700-C3FBB2C2CFF5}
{7899F5DD-AA7C-4561-BAC4-E2EC78B7D157} = {01A75167-DF5A-AF38-8700-C3FBB2C2CFF5}
{02EA681E-C7D8-13C7-8484-4AC65E1B71E8} = {60D51C98-2CC0-40DF-B338-44154EFEE2FF}
{E22DD5A6-06E2-490E-BD32-88D629FD6668} = {60D51C98-2CC0-40DF-B338-44154EFEE2FF}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {3E8720B3-DBDD-498C-B383-2CC32A054E8F}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just fixing the solution file that was broken.

Expand Down
13 changes: 6 additions & 7 deletions src/Components/Components/src/CascadingParameterState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,16 @@
namespace Microsoft.AspNetCore.Components;

internal readonly struct CascadingParameterState
(in CascadingParameterInfo parameterInfo, ICascadingValueSupplier valueSupplier, object? key)
{
private static readonly ConcurrentDictionary<Type, CascadingParameterInfo[]> _cachedInfos = new();

public CascadingParameterInfo ParameterInfo { get; }
public ICascadingValueSupplier ValueSupplier { get; }
public CascadingParameterInfo ParameterInfo { get; } = parameterInfo;
public ICascadingValueSupplier ValueSupplier { get; } = valueSupplier;
public object? Key { get; } = key;

public CascadingParameterState(in CascadingParameterInfo parameterInfo, ICascadingValueSupplier valueSupplier)
{
ParameterInfo = parameterInfo;
ValueSupplier = valueSupplier;
}
: this(parameterInfo, valueSupplier, key: null) { }

public static IReadOnlyList<CascadingParameterState> FindCascadingParameters(ComponentState componentState, out bool hasSingleDeliveryParameters)
{
Expand Down Expand Up @@ -55,7 +54,7 @@ public static IReadOnlyList<CascadingParameterState> FindCascadingParameters(Com
{
// Although not all parameters might be matched, we know the maximum number
resultStates ??= new List<CascadingParameterState>(infos.Length - infoIndex);
resultStates.Add(new CascadingParameterState(info, supplier));
resultStates.Add(new CascadingParameterState(info, supplier, componentState));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit lost here - why would componentState be the key? From the naming I was guessing that key would refer to anything supplied as @key but that can't be the case if we use componentState here.

Maybe some other naming would clarify this.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, naming is a bit odd here. I chose that in the beginning because I didn't know what I was going to use, but I think using ComponentState just makes sense, as we aren't passing anything else.


if (info.Attribute.SingleDelivery)
{
Expand Down
2 changes: 1 addition & 1 deletion src/Components/Components/src/CascadingValue.cs
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ bool ICascadingValueSupplier.CanSupplyValue(in CascadingParameterInfo parameterI
|| string.Equals(requestedName, Name, StringComparison.OrdinalIgnoreCase); // Also match on name
}

object? ICascadingValueSupplier.GetCurrentValue(in CascadingParameterInfo parameterInfo)
object? ICascadingValueSupplier.GetCurrentValue(object? key, in CascadingParameterInfo parameterInfo)
{
return Value;
}
Expand Down
2 changes: 1 addition & 1 deletion src/Components/Components/src/CascadingValueSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ bool ICascadingValueSupplier.CanSupplyValue(in CascadingParameterInfo parameterI
|| string.Equals(requestedName, _name, StringComparison.OrdinalIgnoreCase); // Also match on name
}

object? ICascadingValueSupplier.GetCurrentValue(in CascadingParameterInfo parameterInfo)
object? ICascadingValueSupplier.GetCurrentValue(object? key, in CascadingParameterInfo parameterInfo)
{
if (_initialValueFactory is not null)
{
Expand Down
2 changes: 1 addition & 1 deletion src/Components/Components/src/ICascadingValueSupplier.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ internal interface ICascadingValueSupplier

bool CanSupplyValue(in CascadingParameterInfo parameterInfo);

object? GetCurrentValue(in CascadingParameterInfo parameterInfo);
object? GetCurrentValue(object? key, in CascadingParameterInfo parameterInfo);

void Subscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
<Compile Include="$(ComponentsSharedSourceRoot)src\ArrayBuilder.cs" LinkBase="RenderTree" />
<Compile Include="$(ComponentsSharedSourceRoot)src\JsonSerializerOptionsProvider.cs" />
<Compile Include="$(ComponentsSharedSourceRoot)src\HotReloadManager.cs" LinkBase="HotReload" />
<Compile Include="$(ComponentsSharedSourceRoot)src\RootTypeCache.cs" LinkBase="Shared" />
<Compile Include="$(SharedSourceRoot)LinkerFlags.cs" LinkBase="Shared" />
<Compile Include="$(SharedSourceRoot)QueryStringEnumerable.cs" LinkBase="Shared" />
<Compile Include="$(SharedSourceRoot)Debugger\DictionaryItemDebugView.cs" LinkBase="Shared" />
Expand Down
2 changes: 1 addition & 1 deletion src/Components/Components/src/ParameterView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -437,7 +437,7 @@ public bool MoveNext()
_currentIndex = nextIndex;

var state = _cascadingParameters[_currentIndex];
var currentValue = state.ValueSupplier.GetCurrentValue(state.ParameterInfo);
var currentValue = state.ValueSupplier.GetCurrentValue(state.Key, state.ParameterInfo);
_current = new ParameterValue(state.ParameterInfo.PropertyName, currentValue!, true);
return true;
}
Expand Down
41 changes: 41 additions & 0 deletions src/Components/Components/src/PersistentComponentState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ public PersistingComponentStateSubscription RegisterOnPersisting(Func<Task> call
{
ArgumentNullException.ThrowIfNull(callback);

if (PersistingState)
{
throw new InvalidOperationException("Registering a callback while persisting state is not allowed.");
}

var persistenceCallback = new PersistComponentStateRegistration(callback, renderMode);

_registeredCallbacks.Add(persistenceCallback);
Expand Down Expand Up @@ -87,6 +92,24 @@ public PersistingComponentStateSubscription RegisterOnPersisting(Func<Task> call
_currentState.Add(key, JsonSerializer.SerializeToUtf8Bytes(instance, JsonSerializerOptionsProvider.Options));
}

[RequiresUnreferencedCode("JSON serialization and deserialization might require types that cannot be statically analyzed.")]
internal void PersistAsJson(string key, object instance, [DynamicallyAccessedMembers(JsonSerialized)] Type type)
{
ArgumentNullException.ThrowIfNull(key);

if (!PersistingState)
{
throw new InvalidOperationException("Persisting state is only allowed during an OnPersisting callback.");
}

if (_currentState.ContainsKey(key))
{
throw new ArgumentException($"There is already a persisted object under the same key '{key}'");
}

_currentState.Add(key, JsonSerializer.SerializeToUtf8Bytes(instance, type, JsonSerializerOptionsProvider.Options));
}

/// <summary>
/// Tries to retrieve the persisted state as JSON with the given <paramref name="key"/> and deserializes it into an
/// instance of type <typeparamref name="TValue"/>.
Expand Down Expand Up @@ -114,6 +137,24 @@ public PersistingComponentStateSubscription RegisterOnPersisting(Func<Task> call
}
}

[RequiresUnreferencedCode("JSON serialization and deserialization might require types that cannot be statically analyzed.")]
internal bool TryTakeFromJson(string key, [DynamicallyAccessedMembers(JsonSerialized)] Type type, [MaybeNullWhen(false)] out object? instance)
{
ArgumentNullException.ThrowIfNull(type);
ArgumentNullException.ThrowIfNull(key);
if (TryTake(key, out var data))
{
var reader = new Utf8JsonReader(data);
instance = JsonSerializer.Deserialize(ref reader, type, JsonSerializerOptionsProvider.Options);
return true;
}
else
{
instance = default;
return false;
}
}

private bool TryTake(string key, out byte[]? value)
{
ArgumentNullException.ThrowIfNull(key);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,35 @@ public class ComponentStatePersistenceManager
private readonly ILogger<ComponentStatePersistenceManager> _logger;

private bool _stateIsPersisted;
private readonly PersistentServicesRegistry? _servicesRegistry;
private readonly Dictionary<string, byte[]> _currentState = new(StringComparer.Ordinal);

/// <summary>
/// Initializes a new instance of <see cref="ComponentStatePersistenceManager"/>.
/// </summary>
/// <param name="logger"></param>
public ComponentStatePersistenceManager(ILogger<ComponentStatePersistenceManager> logger)
{
State = new PersistentComponentState(_currentState, _registeredCallbacks);
_logger = logger;
}

/// <summary>
/// Initializes a new instance of <see cref="ComponentStatePersistenceManager"/>.
/// </summary>
/// <param name="logger"></param>
/// <param name="serviceProvider"></param>
public ComponentStatePersistenceManager(ILogger<ComponentStatePersistenceManager> logger, IServiceProvider serviceProvider) : this(logger)
{
_servicesRegistry = new PersistentServicesRegistry(serviceProvider);
}

// For testing purposes only
internal PersistentServicesRegistry? ServicesRegistry => _servicesRegistry;

// For testing purposes only
internal List<PersistComponentStateRegistration> RegisteredCallbacks => _registeredCallbacks;

/// <summary>
/// Gets the <see cref="ComponentStatePersistenceManager"/> associated with the <see cref="ComponentStatePersistenceManager"/>.
/// </summary>
Expand All @@ -40,6 +58,7 @@ public async Task RestoreStateAsync(IPersistentComponentStateStore store)
{
var data = await store.GetPersistedStateAsync();
State.InitializeExistingState(data);
_servicesRegistry?.Restore(State);
}

/// <summary>
Expand All @@ -59,6 +78,9 @@ public Task PersistStateAsync(IPersistentComponentStateStore store, Renderer ren

async Task PauseAndPersistState()
{
// Ensure that we register the services before we start persisting the state.
_servicesRegistry?.RegisterForPersistence(State);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The naming of this makes me suspicious. It sounds like it would create duplicate callbacks every time the circuit is paused and persisted.

Do we somehow know this can only get called once? Or do we know that RegisterForPersistence is idempotent? Is there possibly some change of naming that would make this all clearer?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only gets called once at the end of the lifetime of the host (during prerendering in this case, in the future when we "hibernate" the circuit.

If in the future there's a situation where this gets called more than once, we can adjust


State.PersistingState = true;

if (store is IEnumerable<IPersistentComponentStateStore> compositeStore)
Expand All @@ -72,24 +94,53 @@ async Task PauseAndPersistState()
// the next store can start with a clean slate.
foreach (var store in compositeStore)
{
await PersistState(store);
var result = await TryPersistState(store);
if (!result)
{
break;
}
_currentState.Clear();
}
}
else
{
await PersistState(store);
await TryPersistState(store);
}

State.PersistingState = false;
_stateIsPersisted = true;
}

async Task PersistState(IPersistentComponentStateStore store)
async Task<bool> TryPersistState(IPersistentComponentStateStore store)
{
await PauseAsync(store);
if (!await TryPauseAsync(store))
{
_currentState.Clear();
return false;
}

await store.PersistStateAsync(_currentState);
return true;
}
}

/// <summary>
/// Initializes the render mode for state persisted by the platform.
/// </summary>
/// <param name="renderMode">The render mode to use for state persisted by the platform.</param>
/// <exception cref="InvalidOperationException">when the render mode is already set.</exception>
public void SetPlatformRenderMode(IComponentRenderMode renderMode)
{
if (_servicesRegistry == null)
{
return;
}
else if (_servicesRegistry?.RenderMode != null)
{
throw new InvalidOperationException("Render mode already set.");
}

_servicesRegistry!.RenderMode = renderMode;
}

private void InferRenderModes(Renderer renderer)
Expand Down Expand Up @@ -125,11 +176,17 @@ private void InferRenderModes(Renderer renderer)
}
}

internal Task PauseAsync(IPersistentComponentStateStore store)
internal Task<bool> TryPauseAsync(IPersistentComponentStateStore store)
{
List<Task>? pendingCallbackTasks = null;
List<Task<bool>>? pendingCallbackTasks = null;

for (var i = 0; i < _registeredCallbacks.Count; i++)
// We are iterating backwards to allow the callbacks to remove themselves from the list.
// Otherwise, we would have to make a copy of the list to avoid running into situations
// where we don't run all the callbacks because the count of the list changed while we
// were iterating over it.
// It is not allowed to register a callback while we are persisting the state, so we don't
// need to worry about new callbacks being added to the list.
for (var i = _registeredCallbacks.Count - 1; i >= 0; i--)
{
var registration = _registeredCallbacks[i];

Expand All @@ -142,31 +199,38 @@ internal Task PauseAsync(IPersistentComponentStateStore store)
continue;
}

var result = ExecuteCallback(registration.Callback, _logger);
var result = TryExecuteCallback(registration.Callback, _logger);
if (!result.IsCompletedSuccessfully)
{
pendingCallbackTasks ??= new();
pendingCallbackTasks ??= [];
pendingCallbackTasks.Add(result);
}
else
{
if (!result.Result)
{
return Task.FromResult(false);
}
}
}

if (pendingCallbackTasks != null)
{
return Task.WhenAll(pendingCallbackTasks);
return AnyTaskFailed(pendingCallbackTasks);
}
else
{
return Task.CompletedTask;
return Task.FromResult(true);
}

static Task ExecuteCallback(Func<Task> callback, ILogger<ComponentStatePersistenceManager> logger)
static Task<bool> TryExecuteCallback(Func<Task> callback, ILogger<ComponentStatePersistenceManager> logger)
{
try
{
var current = callback();
if (current.IsCompletedSuccessfully)
{
return current;
return Task.FromResult(true);
}
else
{
Expand All @@ -176,21 +240,35 @@ static Task ExecuteCallback(Func<Task> callback, ILogger<ComponentStatePersisten
catch (Exception ex)
{
logger.LogError(new EventId(1000, "PersistenceCallbackError"), ex, "There was an error executing a callback while pausing the application.");
return Task.CompletedTask;
return Task.FromResult(false);
}

static async Task Awaited(Task task, ILogger<ComponentStatePersistenceManager> logger)
static async Task<bool> Awaited(Task task, ILogger<ComponentStatePersistenceManager> logger)
{
try
{
await task;
return true;
}
catch (Exception ex)
{
logger.LogError(new EventId(1000, "PersistenceCallbackError"), ex, "There was an error executing a callback while pausing the application.");
return;
return false;
}
}
}

static async Task<bool> AnyTaskFailed(List<Task<bool>> pendingCallbackTasks)
{
foreach (var result in await Task.WhenAll(pendingCallbackTasks))
{
if (!result)
{
return false;
}
}

return true;
}
}
}
Loading
Loading