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

Merged
merged 13 commits into from
Aug 15, 2025
Merged
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 @@ -256,14 +256,14 @@ public async Task GetOrComputeLastValue_FollowsCorrectValueTransitionSequence()

// Pre-populate the state with serialized data
var key = PersistentStateValueProviderKeyResolver.ComputeKey(componentState, nameof(TestComponent.State));
appState[key] = JsonSerializer.SerializeToUtf8Bytes("first-restored-value", JsonSerializerOptions.Web);
appState[key] = JsonSerializer.SerializeToUtf8Bytes("first-persisted-value", JsonSerializerOptions.Web);
await manager.RestoreStateAsync(new TestStore(appState), RestoreContext.InitialValue);

await renderer.Dispatcher.InvokeAsync(() => renderer.RenderRootComponentAsync(componentId, ParameterView.Empty));
var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string));

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

// Change the component's property value
component.State = "updated-property-value";
Expand All @@ -279,7 +279,7 @@ public async Task GetOrComputeLastValue_FollowsCorrectValueTransitionSequence()
};
// Simulate invoking the callback with a value update.
await renderer.Dispatcher.InvokeAsync(() => manager.RestoreStateAsync(new TestStore(newState), RestoreContext.ValueUpdate));
Assert.Equal("second-restored-value", component.State);
Assert.Equal("second-restored-value", provider.GetCurrentValue(componentState, cascadingParameterInfo));

component.State = "another-updated-value";
// Other calls: Returns the updated value from state
Expand Down
47 changes: 34 additions & 13 deletions src/Components/test/E2ETest/Tests/StatePersistenceTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -116,15 +116,22 @@ public void CanRenderComponentWithPersistedState(bool suppressEnhancedNavigation
// In each case, we validate that the state is available until the initial set of components first render reaches quiescence. Similar to how it works for Server and WebAssembly.
// For server we validate that the state is provided every time a circuit is initialized.
[Theory]
[InlineData(typeof(InteractiveServerRenderMode), (string)null)]
[InlineData(typeof(InteractiveServerRenderMode), "ServerStreaming")]
[InlineData(typeof(InteractiveWebAssemblyRenderMode), (string)null)]
[InlineData(typeof(InteractiveWebAssemblyRenderMode), "WebAssemblyStreaming")]
[InlineData(typeof(InteractiveAutoRenderMode), (string)null)]
[InlineData(typeof(InteractiveAutoRenderMode), "AutoStreaming")]
[InlineData(typeof(InteractiveServerRenderMode), (string)null, "yes")]
[InlineData(typeof(InteractiveServerRenderMode), "ServerStreaming", "yes")]
[InlineData(typeof(InteractiveWebAssemblyRenderMode), (string)null, "yes")]
[InlineData(typeof(InteractiveWebAssemblyRenderMode), "WebAssemblyStreaming", "yes")]
[InlineData(typeof(InteractiveAutoRenderMode), (string)null, "yes")]
[InlineData(typeof(InteractiveAutoRenderMode), "AutoStreaming", "yes")]
[InlineData(typeof(InteractiveServerRenderMode), (string)null, null)]
[InlineData(typeof(InteractiveServerRenderMode), "ServerStreaming", null)]
[InlineData(typeof(InteractiveWebAssemblyRenderMode), (string)null, null)]
[InlineData(typeof(InteractiveWebAssemblyRenderMode), "WebAssemblyStreaming", null)]
[InlineData(typeof(InteractiveAutoRenderMode), (string)null, null)]
[InlineData(typeof(InteractiveAutoRenderMode), "AutoStreaming", null)]
public void CanUpdateComponentsWithPersistedStateAndEnhancedNavUpdates(
Type renderMode,
string streaming)
string streaming,
string key)
{
var mode = renderMode switch
{
Expand All @@ -136,7 +143,7 @@ public void CanUpdateComponentsWithPersistedStateAndEnhancedNavUpdates(

// Navigate to a page without components first to make sure that we exercise rendering components
// with enhanced navigation on.
NavigateToInitialPage(streaming, mode);
NavigateToInitialPage(streaming, mode, key);
if (mode == "auto")
{
BlockWebAssemblyResourceLoad();
Expand All @@ -156,22 +163,36 @@ public void CanUpdateComponentsWithPersistedStateAndEnhancedNavUpdates(

UnblockWebAssemblyResourceLoad();
Browser.Navigate().Refresh();
NavigateToInitialPage(streaming, mode);
NavigateToInitialPage(streaming, mode, key);
Browser.Click(By.Id("call-blazor-start"));
Browser.Click(By.Id("page-with-components-link-and-declarative-state"));

RenderComponentsWithDeclarativePersistentStateAndValidate(mode, renderMode, streaming, interactiveRuntime: "wasm", stateValue: "other");
}

void NavigateToInitialPage(string streaming, string mode)
void NavigateToInitialPage(string streaming, string mode, string key)
{
if (streaming == null)
if (key == null)
{
Navigate($"subdir/persistent-state/page-no-components?render-mode={mode}&suppress-autostart");
if (streaming == null)
{
Navigate($"subdir/persistent-state/page-no-components?render-mode={mode}&suppress-autostart");
}
else
{
Navigate($"subdir/persistent-state/page-no-components?render-mode={mode}&streaming-id={streaming}&suppress-autostart");
}
}
else
{
Navigate($"subdir/persistent-state/page-no-components?render-mode={mode}&streaming-id={streaming}&suppress-autostart");
if (streaming == null)
{
Navigate($"subdir/persistent-state/page-no-components?render-mode={mode}&key={key}&suppress-autostart");
}
else
{
Navigate($"subdir/persistent-state/page-no-components?render-mode={mode}&key={key}&streaming-id={streaming}&suppress-autostart");
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,30 @@
<p id="streaming-id">Streaming id:@StreamingId</p>
@if (_renderMode != null)
{
@if (!string.IsNullOrEmpty(StreamingId))
@if(!string.IsNullOrEmpty(KeyValue))
{
<StreamingComponentWithDeclarativePersistentState @key="0" @rendermode="@_renderMode" StreamingId="@StreamingId" ServerState="@ServerState" />
@if (!string.IsNullOrEmpty(StreamingId))
{
<StreamingComponentWithDeclarativePersistentState @key="0" @rendermode="@_renderMode" StreamingId="@StreamingId" ServerState="@ServerState" />
}
else
{
<NonStreamingComponentWithDeclarativePersistentState @key="0" @rendermode="@_renderMode" ServerState="@ServerState" />
}
}
else
{
<NonStreamingComponentWithDeclarativePersistentState @key="0" @rendermode="@_renderMode" ServerState="@ServerState" />
@if (!string.IsNullOrEmpty(StreamingId))
{
<StreamingComponentWithDeclarativePersistentState @rendermode="@_renderMode" StreamingId="@StreamingId" ServerState="@ServerState" />
}
else
{
<NonStreamingComponentWithDeclarativePersistentState @rendermode="@_renderMode" ServerState="@ServerState" />
}
}
}

@if (!string.IsNullOrEmpty(StreamingId))
{
<a id="end-streaming" href="@($"persistent-state/end-streaming?streaming-id={StreamingId}")" target="_blank">End streaming</a>
Expand All @@ -41,6 +56,8 @@

[SupplyParameterFromQuery(Name = "server-state")] public string ServerState { get; set; }

[SupplyParameterFromQuery(Name = "key")] public string KeyValue { get; set; }

protected override void OnInitialized()
{
if (!string.IsNullOrEmpty(RenderMode))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,19 @@

<h3>This page does not render any component. We use it to test that persisted state is only provided at the time interactive components get activated on the page.</h3>

<a id="page-with-components-link" href=@($"persistent-state/page-with-components?render-mode={RenderMode}&streaming-id={StreamingId}")>Go to page with components</a>

<a id="page-with-components-link-and-state" href=@($"persistent-state/page-with-components?render-mode={RenderMode}&streaming-id={StreamingId}&server-state=other")>Go to page with components and state</a>

<a id="page-with-components-link-and-declarative-state" href=@($"persistent-state/page-with-declarative-state-components?render-mode={RenderMode}&streaming-id={StreamingId}&server-state=other")>Go to page with declarative state components</a>
<ul>
<li><a id="page-with-components-link" href=@($"persistent-state/page-with-components?render-mode={RenderMode}&streaming-id={StreamingId}")>Go to page with components</a></li>

<li><a id="page-with-components-link-and-state" href=@($"persistent-state/page-with-components?render-mode={RenderMode}&streaming-id={StreamingId}&server-state=other")>Go to page with components and state</a></li>

<li><a id="page-with-components-link-and-declarative-state" href=@($"persistent-state/page-with-declarative-state-components?render-mode={RenderMode}&streaming-id={StreamingId}&key={KeyValue}&server-state=other")>Go to page with declarative state components</a></li>

</ul>

@code {
[SupplyParameterFromQuery(Name = "render-mode")] public string RenderMode { get; set; }

[SupplyParameterFromQuery(Name = "streaming-id")] public string StreamingId { get; set; }

[SupplyParameterFromQuery(Name = "key")] public string KeyValue { get; set; }
}
Loading