Skip to content

Commit 52ea667

Browse files
authored
[Blazor] Support persisting component state on enhanced navigation (#62824)
* [Blazor] Implement scenario-based persistent state filtering for Blazor * Adds support for ignoring persisted values during prerendering * Adds support for ignoring persisted values during resume * Adds support for receiving value updates during enhanced navigation Fixes #51584, #62393, #62330, #62781 * Disable AVX512 vectorization on the test pipeline until we pick up a new SDK
1 parent d6f0d4b commit 52ea667

File tree

62 files changed

+3184
-1117
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

62 files changed

+3184
-1117
lines changed

.azure/pipelines/components-e2e-tests.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,13 +98,17 @@ jobs:
9898
exit 1
9999
fi
100100
displayName: Run E2E tests
101+
env:
102+
DOTNET_EnableAVX512: 0
101103
- script: .dotnet/dotnet test ./src/Components/test/E2ETest -c $(BuildConfiguration) --no-build --filter 'Quarantined=true' -p:RunQuarantinedTests=true
102104
-p:VsTestUseMSBuildOutput=false
103105
--logger:"trx%3BLogFileName=Microsoft.AspNetCore.Components.E2ETests.trx"
104106
--logger:"html%3BLogFileName=Microsoft.AspNetCore.Components.E2ETests.html"
105107
--results-directory $(Build.SourcesDirectory)/artifacts/TestResults/$(BuildConfiguration)/Quarantined
106108
displayName: Run Quarantined E2E tests
107109
continueOnError: true
110+
env:
111+
DOTNET_EnableAVX512: 0
108112
- task: PublishTestResults@2
109113
displayName: Publish E2E Test Results
110114
inputs:
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Diagnostics;
5+
using Microsoft.AspNetCore.Components.Rendering;
6+
7+
namespace Microsoft.AspNetCore.Components.Infrastructure;
8+
9+
[DebuggerDisplay("{GetDebuggerDisplay(),nq}")]
10+
internal readonly struct ComponentSubscriptionKey(ComponentState subscriber, string propertyName) : IEquatable<ComponentSubscriptionKey>
11+
{
12+
public ComponentState Subscriber { get; } = subscriber;
13+
14+
public string PropertyName { get; } = propertyName;
15+
16+
public bool Equals(ComponentSubscriptionKey other)
17+
=> Subscriber == other.Subscriber && PropertyName == other.PropertyName;
18+
19+
public override bool Equals(object? obj)
20+
=> obj is ComponentSubscriptionKey other && Equals(other);
21+
22+
public override int GetHashCode()
23+
=> HashCode.Combine(Subscriber, PropertyName);
24+
25+
private string GetDebuggerDisplay()
26+
=> $"{Subscriber.Component.GetType().Name}.{PropertyName}";
27+
}

src/Components/Components/src/PersistentComponentState.cs

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.Diagnostics;
45
using System.Diagnostics.CodeAnalysis;
56
using System.Text.Json;
67
using static Microsoft.AspNetCore.Internal.LinkerFlags;
@@ -16,24 +17,30 @@ public class PersistentComponentState
1617
private readonly IDictionary<string, byte[]> _currentState;
1718

1819
private readonly List<PersistComponentStateRegistration> _registeredCallbacks;
20+
private readonly List<RestoreComponentStateRegistration> _registeredRestoringCallbacks;
1921

2022
internal PersistentComponentState(
21-
IDictionary<string , byte[]> currentState,
22-
List<PersistComponentStateRegistration> pauseCallbacks)
23+
IDictionary<string, byte[]> currentState,
24+
List<PersistComponentStateRegistration> pauseCallbacks,
25+
List<RestoreComponentStateRegistration> restoringCallbacks)
2326
{
2427
_currentState = currentState;
2528
_registeredCallbacks = pauseCallbacks;
29+
_registeredRestoringCallbacks = restoringCallbacks;
2630
}
2731

2832
internal bool PersistingState { get; set; }
2933

30-
internal void InitializeExistingState(IDictionary<string, byte[]> existingState)
34+
internal RestoreContext CurrentContext { get; private set; } = RestoreContext.InitialValue;
35+
36+
internal void InitializeExistingState(IDictionary<string, byte[]> existingState, RestoreContext context)
3137
{
3238
if (_existingState != null)
3339
{
3440
throw new InvalidOperationException("PersistentComponentState already initialized.");
3541
}
3642
_existingState = existingState ?? throw new ArgumentNullException(nameof(existingState));
43+
CurrentContext = context;
3744
}
3845

3946
/// <summary>
@@ -68,6 +75,30 @@ public PersistingComponentStateSubscription RegisterOnPersisting(Func<Task> call
6875
return new PersistingComponentStateSubscription(_registeredCallbacks, persistenceCallback);
6976
}
7077

78+
/// <summary>
79+
/// Register a callback to restore the state when the application state is being restored.
80+
/// </summary>
81+
/// <param name="callback"> The callback to invoke when the application state is being restored.</param>
82+
/// <param name="options">Options that control the restoration behavior.</param>
83+
/// <returns>A subscription that can be used to unregister the callback when disposed.</returns>
84+
public RestoringComponentStateSubscription RegisterOnRestoring(Action callback, RestoreOptions options)
85+
{
86+
Debug.Assert(CurrentContext != null);
87+
if (CurrentContext.ShouldRestore(options))
88+
{
89+
callback();
90+
}
91+
92+
if (options.AllowUpdates)
93+
{
94+
var registration = new RestoreComponentStateRegistration(callback);
95+
_registeredRestoringCallbacks.Add(registration);
96+
return new RestoringComponentStateSubscription(_registeredRestoringCallbacks, registration);
97+
}
98+
99+
return default;
100+
}
101+
71102
/// <summary>
72103
/// Serializes <paramref name="instance"/> as JSON and persists it under the given <paramref name="key"/>.
73104
/// </summary>
@@ -214,4 +245,17 @@ private bool TryTake(string key, out byte[]? value)
214245
return false;
215246
}
216247
}
248+
249+
internal void UpdateExistingState(IDictionary<string, byte[]> state, RestoreContext context)
250+
{
251+
ArgumentNullException.ThrowIfNull(state);
252+
253+
if (_existingState == null || _existingState.Count > 0)
254+
{
255+
throw new InvalidOperationException("Cannot update existing state: previous state has not been cleared or state is not initialized.");
256+
}
257+
258+
_existingState = state;
259+
CurrentContext = context;
260+
}
217261
}

src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@ namespace Microsoft.AspNetCore.Components.Infrastructure;
1212
public class ComponentStatePersistenceManager
1313
{
1414
private readonly List<PersistComponentStateRegistration> _registeredCallbacks = new();
15+
private readonly List<RestoreComponentStateRegistration> _registeredRestoringCallbacks = new();
1516
private readonly ILogger<ComponentStatePersistenceManager> _logger;
1617

1718
private bool _stateIsPersisted;
19+
private bool _stateIsInitialized;
1820
private readonly PersistentServicesRegistry? _servicesRegistry;
1921
private readonly Dictionary<string, byte[]> _currentState = new(StringComparer.Ordinal);
2022

@@ -24,7 +26,7 @@ public class ComponentStatePersistenceManager
2426
/// <param name="logger"></param>
2527
public ComponentStatePersistenceManager(ILogger<ComponentStatePersistenceManager> logger)
2628
{
27-
State = new PersistentComponentState(_currentState, _registeredCallbacks);
29+
State = new PersistentComponentState(_currentState, _registeredCallbacks, _registeredRestoringCallbacks);
2830
_logger = logger;
2931
}
3032

@@ -55,10 +57,38 @@ public ComponentStatePersistenceManager(ILogger<ComponentStatePersistenceManager
5557
/// <param name="store">The <see cref="IPersistentComponentStateStore"/> to restore the application state from.</param>
5658
/// <returns>A <see cref="Task"/> that will complete when the state has been restored.</returns>
5759
public async Task RestoreStateAsync(IPersistentComponentStateStore store)
60+
{
61+
await RestoreStateAsync(store, RestoreContext.InitialValue);
62+
}
63+
64+
/// <summary>
65+
/// Restores the application state.
66+
/// </summary>
67+
/// <param name="store"> The <see cref="IPersistentComponentStateStore"/> to restore the application state from.</param>
68+
/// <param name="context">The <see cref="RestoreContext"/> that provides additional context for the restoration.</param>
69+
/// <returns>A <see cref="Task"/> that will complete when the state has been restored.</returns>
70+
public async Task RestoreStateAsync(IPersistentComponentStateStore store, RestoreContext context)
5871
{
5972
var data = await store.GetPersistedStateAsync();
60-
State.InitializeExistingState(data);
61-
_servicesRegistry?.Restore(State);
73+
74+
if (_stateIsInitialized)
75+
{
76+
if (context != RestoreContext.ValueUpdate)
77+
{
78+
throw new InvalidOperationException("State already initialized.");
79+
}
80+
State.UpdateExistingState(data, context);
81+
foreach (var registration in _registeredRestoringCallbacks)
82+
{
83+
registration.Callback();
84+
}
85+
}
86+
else
87+
{
88+
State.InitializeExistingState(data, context);
89+
_servicesRegistry?.RegisterForPersistence(State);
90+
_stateIsInitialized = true;
91+
}
6292
}
6393

6494
/// <summary>
@@ -78,9 +108,6 @@ public Task PersistStateAsync(IPersistentComponentStateStore store, Renderer ren
78108

79109
async Task PauseAndPersistState()
80110
{
81-
// Ensure that we register the services before we start persisting the state.
82-
_servicesRegistry?.RegisterForPersistence(State);
83-
84111
State.PersistingState = true;
85112

86113
if (store is IEnumerable<IPersistentComponentStateStore> compositeStore)
@@ -271,4 +298,5 @@ static async Task<bool> AnyTaskFailed(List<Task<bool>> pendingCallbackTasks)
271298
return true;
272299
}
273300
}
301+
274302
}

src/Components/Components/src/PersistentState/PersistentServicesRegistry.cs

Lines changed: 41 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,11 @@ namespace Microsoft.AspNetCore.Components.Infrastructure;
1818
internal sealed class PersistentServicesRegistry
1919
{
2020
private static readonly string _registryKey = typeof(PersistentServicesRegistry).FullName!;
21-
private static readonly RootTypeCache _persistentServiceTypeCache = new RootTypeCache();
21+
private static readonly RootTypeCache _persistentServiceTypeCache = new();
2222

2323
private readonly IServiceProvider _serviceProvider;
2424
private IPersistentServiceRegistration[] _registrations;
25-
private List<PersistingComponentStateSubscription> _subscriptions = [];
25+
private List<(PersistingComponentStateSubscription, RestoringComponentStateSubscription)> _subscriptions = [];
2626
private static readonly ConcurrentDictionary<Type, PropertiesAccessor> _cachedAccessorsByType = new();
2727

2828
static PersistentServicesRegistry()
@@ -54,7 +54,9 @@ internal void RegisterForPersistence(PersistentComponentState state)
5454
return;
5555
}
5656

57-
var subscriptions = new List<PersistingComponentStateSubscription>(_registrations.Length + 1);
57+
UpdateRegistrations(state);
58+
var subscriptions = new List<(PersistingComponentStateSubscription, RestoringComponentStateSubscription)>(
59+
_registrations.Length + 1);
5860
for (var i = 0; i < _registrations.Length; i++)
5961
{
6062
var registration = _registrations[i];
@@ -67,20 +69,29 @@ internal void RegisterForPersistence(PersistentComponentState state)
6769
var renderMode = registration.GetRenderModeOrDefault();
6870

6971
var instance = _serviceProvider.GetRequiredService(type);
70-
subscriptions.Add(state.RegisterOnPersisting(() =>
71-
{
72-
PersistInstanceState(instance, type, state);
73-
return Task.CompletedTask;
74-
}, renderMode));
72+
subscriptions.Add((
73+
state.RegisterOnPersisting(() =>
74+
{
75+
PersistInstanceState(instance, type, state);
76+
return Task.CompletedTask;
77+
}, renderMode),
78+
// In order to avoid registering one callback per property, we register a single callback with the most
79+
// permissive options and perform the filtering inside of it.
80+
state.RegisterOnRestoring(() =>
81+
{
82+
RestoreInstanceState(instance, type, state);
83+
}, new RestoreOptions { AllowUpdates = true })));
7584
}
7685

7786
if (RenderMode != null)
7887
{
79-
subscriptions.Add(state.RegisterOnPersisting(() =>
80-
{
81-
state.PersistAsJson(_registryKey, _registrations);
82-
return Task.CompletedTask;
83-
}, RenderMode));
88+
subscriptions.Add((
89+
state.RegisterOnPersisting(() =>
90+
{
91+
state.PersistAsJson(_registryKey, _registrations);
92+
return Task.CompletedTask;
93+
}, RenderMode),
94+
default));
8495
}
8596

8697
_subscriptions = subscriptions;
@@ -92,7 +103,7 @@ private static void PersistInstanceState(object instance, Type type, PersistentC
92103
var accessors = _cachedAccessorsByType.GetOrAdd(instance.GetType(), static (runtimeType, declaredType) => new PropertiesAccessor(runtimeType, declaredType), type);
93104
foreach (var (key, propertyType) in accessors.KeyTypePairs)
94105
{
95-
var (setter, getter) = accessors.GetAccessor(key);
106+
var (setter, getter, options) = accessors.GetAccessor(key);
96107
var value = getter.GetValue(instance);
97108
if (value != null)
98109
{
@@ -105,33 +116,12 @@ private static void PersistInstanceState(object instance, Type type, PersistentC
105116
"IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code",
106117
Justification = "Types registered for persistence are preserved in the API call to register them and typically live in assemblies that aren't trimmed.")]
107118
[DynamicDependency(LinkerFlags.JsonSerialized, typeof(PersistentServiceRegistration))]
108-
internal void Restore(PersistentComponentState state)
119+
private void UpdateRegistrations(PersistentComponentState state)
109120
{
110121
if (state.TryTakeFromJson<PersistentServiceRegistration[]>(_registryKey, out var registry) && registry != null)
111122
{
112123
_registrations = ResolveRegistrations(_registrations.Concat(registry));
113124
}
114-
115-
RestoreRegistrationsIfAvailable(state);
116-
}
117-
118-
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "Types registered for persistence are preserved in the API call to register them and typically live in assemblies that aren't trimmed.")]
119-
private void RestoreRegistrationsIfAvailable(PersistentComponentState state)
120-
{
121-
foreach (var registration in _registrations)
122-
{
123-
var type = ResolveType(registration);
124-
if (type == null)
125-
{
126-
continue;
127-
}
128-
129-
var instance = _serviceProvider.GetService(type);
130-
if (instance != null)
131-
{
132-
RestoreInstanceState(instance, type, state);
133-
}
134-
}
135125
}
136126

137127
[RequiresUnreferencedCode("Calls Microsoft.AspNetCore.Components.PersistentComponentState.TryTakeFromJson(String, Type, out Object)")]
@@ -140,9 +130,13 @@ private static void RestoreInstanceState(object instance, Type type, PersistentC
140130
var accessors = _cachedAccessorsByType.GetOrAdd(instance.GetType(), static (runtimeType, declaredType) => new PropertiesAccessor(runtimeType, declaredType), type);
141131
foreach (var (key, propertyType) in accessors.KeyTypePairs)
142132
{
133+
var (setter, getter, options) = accessors.GetAccessor(key);
134+
if (!state.CurrentContext.ShouldRestore(options))
135+
{
136+
continue;
137+
}
143138
if (state.TryTakeFromJson(key, propertyType, out var result))
144139
{
145-
var (setter, getter) = accessors.GetAccessor(key);
146140
setter.SetValue(instance, result!);
147141
}
148142
}
@@ -165,12 +159,12 @@ private sealed class PropertiesAccessor
165159
{
166160
internal const BindingFlags BindablePropertyFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.IgnoreCase;
167161

168-
private readonly Dictionary<string, (PropertySetter, PropertyGetter)> _underlyingAccessors;
162+
private readonly Dictionary<string, (PropertySetter, PropertyGetter, RestoreOptions)> _underlyingAccessors;
169163
private readonly (string, Type)[] _cachedKeysForService;
170164

171165
public PropertiesAccessor([DynamicallyAccessedMembers(LinkerFlags.Component)] Type targetType, Type keyType)
172166
{
173-
_underlyingAccessors = new Dictionary<string, (PropertySetter, PropertyGetter)>(StringComparer.OrdinalIgnoreCase);
167+
_underlyingAccessors = new Dictionary<string, (PropertySetter, PropertyGetter, RestoreOptions)>(StringComparer.OrdinalIgnoreCase);
174168

175169
var keys = new List<(string, Type)>();
176170
foreach (var propertyInfo in GetCandidateBindableProperties(targetType))
@@ -204,10 +198,16 @@ public PropertiesAccessor([DynamicallyAccessedMembers(LinkerFlags.Component)] Ty
204198
$"The type '{targetType.FullName}' declares a property matching the name '{propertyName}' that is not public. Persistent service properties must be public.");
205199
}
206200

201+
var restoreOptions = new RestoreOptions
202+
{
203+
RestoreBehavior = parameterAttribute.RestoreBehavior,
204+
AllowUpdates = parameterAttribute.AllowUpdates,
205+
};
206+
207207
var propertySetter = new PropertySetter(targetType, propertyInfo);
208208
var propertyGetter = new PropertyGetter(targetType, propertyInfo);
209209

210-
_underlyingAccessors.Add(key, (propertySetter, propertyGetter));
210+
_underlyingAccessors.Add(key, (propertySetter, propertyGetter, restoreOptions));
211211
}
212212

213213
_cachedKeysForService = [.. keys];
@@ -236,7 +236,7 @@ internal static IEnumerable<PropertyInfo> GetCandidateBindableProperties(
236236
[DynamicallyAccessedMembers(LinkerFlags.Component)] Type targetType)
237237
=> MemberAssignment.GetPropertiesIncludingInherited(targetType, BindablePropertyFlags);
238238

239-
internal (PropertySetter setter, PropertyGetter getter) GetAccessor(string key) =>
239+
internal (PropertySetter setter, PropertyGetter getter, RestoreOptions options) GetAccessor(string key) =>
240240
_underlyingAccessors.TryGetValue(key, out var result) ? result : default;
241241
}
242242

0 commit comments

Comments
 (0)