Skip to content

Fix Blazor persistent component state restoration for components without keys and add E2E test coverage #63194

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

Draft
wants to merge 6 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -143,9 +143,9 @@ internal void RestoreProperty()
Log.RestoringValueFromState(_logger, _storageKey, _propertyType.Name, _propertyName);
var sequence = new ReadOnlySequence<byte>(data!);
_lastValue = _customSerializer.Restore(_propertyType, sequence);
_ignoreComponentPropertyValue = true;
if (!skipNotifications)
{
_ignoreComponentPropertyValue = true;
_subscriber.NotifyCascadingValueChanged(ParameterViewLifetime.Unbound);
}
}
Expand All @@ -160,9 +160,9 @@ internal void RestoreProperty()
{
Log.RestoredValueFromPersistentState(_logger, _storageKey, _propertyType.Name, "null", _propertyName);
_lastValue = value;
_ignoreComponentPropertyValue = true;
if (!skipNotifications)
{
_ignoreComponentPropertyValue = true;
_subscriber.NotifyCascadingValueChanged(ParameterViewLifetime.Unbound);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,8 @@ public async Task GetOrComputeLastValue_FollowsCorrectValueTransitionSequence()
var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string));

// Act & Assert - First call: Returns restored value from state
var firstCall = provider.GetCurrentValue(componentState, cascadingParameterInfo);
Assert.Equal("first-restored-value", firstCall);
Assert.Equal("first-restored-value", component.State);

// Change the component's property value
Expand Down Expand Up @@ -708,4 +710,213 @@ public void Constructor_WorksCorrectly_ForPublicProperty()
Assert.NotNull(subscription);
subscription.Dispose();
}

[Fact]
public async Task ComponentRecreation_PreservesPersistedState_WhenComponentIsRecreatedDuringNavigation()
{
// This test simulates the scenario where a component is destroyed and recreated (like during navigation)
// and verifies that the persisted state is correctly restored in the new component instance

// Arrange
var appState = new Dictionary<string, byte[]>();
var manager = new ComponentStatePersistenceManager(NullLogger<ComponentStatePersistenceManager>.Instance);
var serviceProvider = PersistentStateProviderServiceCollectionExtensions.AddSupplyValueFromPersistentComponentStateProvider(new ServiceCollection())
.AddSingleton(manager)
.AddSingleton(manager.State)
.AddFakeLogging()
.BuildServiceProvider();
var renderer = new TestRenderer(serviceProvider);
var provider = (PersistentStateValueProvider)renderer.ServiceProviderCascadingValueSuppliers.Single();
var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string));

// Setup initial persisted state
var component1 = new TestComponent { State = "initial-property-value" };
var componentId1 = renderer.AssignRootComponentId(component1);
var componentState1 = renderer.GetComponentState(component1);
var key = PersistentStateValueProviderKeyResolver.ComputeKey(componentState1, nameof(TestComponent.State));

appState[key] = JsonSerializer.SerializeToUtf8Bytes("persisted-value-from-previous-session", JsonSerializerOptions.Web);
await manager.RestoreStateAsync(new TestStore(appState), RestoreContext.InitialValue);

// Act & Assert - First component instance should get the persisted value
await renderer.Dispatcher.InvokeAsync(() => renderer.RenderRootComponentAsync(componentId1, ParameterView.Empty));
Assert.Equal("persisted-value-from-previous-session", component1.State);

// Simulate component destruction (like during navigation away)
renderer.RemoveRootComponent(componentId1);

// Simulate component recreation (like during navigation back) - NEW SUBSCRIPTION CREATED
var component2 = new TestComponent { State = "new-component-initial-value" };
var componentId2 = renderer.AssignRootComponentId(component2);
var componentState2 = renderer.GetComponentState(component2);

// Verify the key is the same (important for components without @key)
var key2 = PersistentStateValueProviderKeyResolver.ComputeKey(componentState2, nameof(TestComponent.State));
Assert.Equal(key, key2);

// The state should still be available for restoration
await renderer.Dispatcher.InvokeAsync(() => renderer.RenderRootComponentAsync(componentId2, ParameterView.Empty));

// Assert - The new component instance should get the same persisted value
var providerForSecondComponent = (PersistentStateValueProvider)renderer.ServiceProviderCascadingValueSuppliers.Single();
var cascadingParameterInfoForSecondComponent = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string));
var restoredCall = providerForSecondComponent.GetCurrentValue(componentState2, cascadingParameterInfoForSecondComponent);
Assert.Equal("persisted-value-from-previous-session", restoredCall);
Assert.Equal("persisted-value-from-previous-session", component2.State);
}

[Fact]
public async Task ComponentRecreation_WithStateUpdates_PreservesCorrectValueTransitionSequence()
{
// This test simulates the full lifecycle with component recreation and state updates
// following the pattern from GetOrComputeLastValue_FollowsCorrectValueTransitionSequence
// but with subscription recreation between state restorations

// Arrange
var appState = new Dictionary<string, byte[]>();
var manager = new ComponentStatePersistenceManager(NullLogger<ComponentStatePersistenceManager>.Instance);
var serviceProvider = PersistentStateProviderServiceCollectionExtensions.AddSupplyValueFromPersistentComponentStateProvider(new ServiceCollection())
.AddSingleton(manager)
.AddSingleton(manager.State)
.AddFakeLogging()
.BuildServiceProvider();
var renderer = new TestRenderer(serviceProvider);
var provider = (PersistentStateValueProvider)renderer.ServiceProviderCascadingValueSuppliers.Single();
var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string));

// First component lifecycle
var component1 = new TestComponent { State = "initial-property-value" };
var componentId1 = renderer.AssignRootComponentId(component1);
var componentState1 = renderer.GetComponentState(component1);
var key = PersistentStateValueProviderKeyResolver.ComputeKey(componentState1, nameof(TestComponent.State));

// Pre-populate with first persisted value
appState[key] = JsonSerializer.SerializeToUtf8Bytes("first-restored-value", JsonSerializerOptions.Web);
await manager.RestoreStateAsync(new TestStore(appState), RestoreContext.InitialValue);

await renderer.Dispatcher.InvokeAsync(() => renderer.RenderRootComponentAsync(componentId1, ParameterView.Empty));

// Act & Assert - First component gets restored value
var firstCall = provider.GetCurrentValue(componentState1, cascadingParameterInfo);
Assert.Equal("first-restored-value", firstCall);
Assert.Equal("first-restored-value", component1.State);

// Update component property
component1.State = "updated-by-component-1";
Assert.Equal("updated-by-component-1", provider.GetCurrentValue(componentState1, cascadingParameterInfo));

// Simulate component destruction and recreation (NEW SUBSCRIPTION CREATED)
renderer.RemoveRootComponent(componentId1);

var component2 = new TestComponent { State = "new-component-initial-value" };
var componentId2 = renderer.AssignRootComponentId(component2);
var componentState2 = renderer.GetComponentState(component2);

// Restore state with a different value
appState.Clear();
appState[key] = JsonSerializer.SerializeToUtf8Bytes("second-restored-value", JsonSerializerOptions.Web);
await manager.RestoreStateAsync(new TestStore(appState), RestoreContext.ValueUpdate);

await renderer.Dispatcher.InvokeAsync(() => renderer.RenderRootComponentAsync(componentId2, ParameterView.Empty));

// Assert - New component gets the updated restored value
var secondComponentCall = provider.GetCurrentValue(componentState2, cascadingParameterInfo);
Assert.Equal("second-restored-value", secondComponentCall);
Assert.Equal("second-restored-value", component2.State);

// Continue with property updates on the new component
component2.State = "updated-by-component-2";
Assert.Equal("updated-by-component-2", provider.GetCurrentValue(componentState2, cascadingParameterInfo));
}

[Fact]
public async Task ComponentRecreation_WithSkipNotifications_StillRestoresCorrectly()
{
// This test verifies that the fix works even when skipNotifications is true during component recreation,
// which is the core scenario that was broken before our fix

// Arrange
var appState = new Dictionary<string, byte[]>();
var manager = new ComponentStatePersistenceManager(NullLogger<ComponentStatePersistenceManager>.Instance);
var serviceProvider = PersistentStateProviderServiceCollectionExtensions.AddSupplyValueFromPersistentComponentStateProvider(new ServiceCollection())
.AddSingleton(manager)
.AddSingleton(manager.State)
.AddFakeLogging()
.BuildServiceProvider();
var renderer = new TestRenderer(serviceProvider);
var provider = (PersistentStateValueProvider)renderer.ServiceProviderCascadingValueSuppliers.Single();
var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string));

// Setup persisted state
var component1 = new TestComponent { State = "component-initial-value" };
var componentId1 = renderer.AssignRootComponentId(component1);
var componentState1 = renderer.GetComponentState(component1);
var key = PersistentStateValueProviderKeyResolver.ComputeKey(componentState1, nameof(TestComponent.State));

appState[key] = JsonSerializer.SerializeToUtf8Bytes("persisted-value", JsonSerializerOptions.Web);
await manager.RestoreStateAsync(new TestStore(appState), RestoreContext.InitialValue);

// First component gets the persisted value
await renderer.Dispatcher.InvokeAsync(() => renderer.RenderRootComponentAsync(componentId1, ParameterView.Empty));
var firstCall = provider.GetCurrentValue(componentState1, cascadingParameterInfo);
Assert.Equal("persisted-value", firstCall);
Assert.Equal("persisted-value", component1.State);

// Destroy and recreate component (simulating navigation or component without @key)
renderer.RemoveRootComponent(componentId1);

// Create new component instance - this will create a NEW SUBSCRIPTION
var component2 = new TestComponent { State = "different-initial-value" };
var componentId2 = renderer.AssignRootComponentId(component2);
var componentState2 = renderer.GetComponentState(component2);

// Render the new component - this should restore the persisted value even if skipNotifications is true
await renderer.Dispatcher.InvokeAsync(() => renderer.RenderRootComponentAsync(componentId2, ParameterView.Empty));

// Assert - The new component should get the persisted value, not its initial property value
var providerForLastComponent = (PersistentStateValueProvider)renderer.ServiceProviderCascadingValueSuppliers.Single();
var cascadingParameterInfoForLastComponent = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string));
var restoredCall2 = providerForLastComponent.GetCurrentValue(componentState2, cascadingParameterInfoForLastComponent);
Assert.Equal("persisted-value", restoredCall2);
Assert.Equal("persisted-value", component2.State);
}

[Fact]
public async Task DebugTest_UnderstandIgnoreComponentPropertyValueFlag()
{
// Simple test to understand the _ignoreComponentPropertyValue flag behavior
var appState = new Dictionary<string, byte[]>();
var manager = new ComponentStatePersistenceManager(NullLogger<ComponentStatePersistenceManager>.Instance);
var serviceProvider = PersistentStateProviderServiceCollectionExtensions.AddSupplyValueFromPersistentComponentStateProvider(new ServiceCollection())
.AddSingleton(manager)
.AddSingleton(manager.State)
.AddFakeLogging()
.BuildServiceProvider();
var renderer = new TestRenderer(serviceProvider);
var provider = (PersistentStateValueProvider)renderer.ServiceProviderCascadingValueSuppliers.Single();
var component = new TestComponent { State = "initial-property-value" };
var componentId = renderer.AssignRootComponentId(component);
var componentState = renderer.GetComponentState(component);
var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string));

// Set up state to restore
var key = PersistentStateValueProviderKeyResolver.ComputeKey(componentState, nameof(TestComponent.State));
appState[key] = JsonSerializer.SerializeToUtf8Bytes("restored-value", JsonSerializerOptions.Web);
await manager.RestoreStateAsync(new TestStore(appState), RestoreContext.InitialValue);

// Render component - this should restore the value
await renderer.Dispatcher.InvokeAsync(() => renderer.RenderRootComponentAsync(componentId, ParameterView.Empty));

// First call should return restored value
var firstCall = provider.GetCurrentValue(componentState, cascadingParameterInfo);
Assert.Equal("restored-value", firstCall);
Assert.Equal("restored-value", component.State);

// Update the component's property manually
component.State = "manually-updated-value";

// Second call should return the manually updated value
var secondCall = provider.GetCurrentValue(componentState, cascadingParameterInfo);
Assert.Equal("manually-updated-value", secondCall);
}
}
Loading
Loading