aspnetcore
aspnetcore copied to clipboard
Support persistent component state across enhanced page navigations
- Right now persistent component state only works during the initial render of the components for a given runtime.
- This is because it doesn't know about enhanced navigations and because we don't have a mechanism to deliver state updates to components that are already running.
This issue tracks adding a new mechanism to deliver state updates from enhanced navigations coming from the server to running runtimes.
Thanks for contacting us.
We're moving this issue to the .NET 9 Planning milestone for future evaluation / consideration. We would like to keep this around to collect more feedback, which can help us with prioritizing this work. We will re-evaluate this issue, during our next planning meeting(s).
If we later determine, that the issue has no community involvement, or it's very rare and low-impact issue, we will close it - so that the team can focus on more important and high impact issues.
To learn more about what to expect next and how this issue will be handled you can read more about our triage process here.
This could lead to developers (at least me) making some mistakes about assuming which branch of the TryTakeFromJson condition is run at which point. In my app I initialized a SignalR connection in the true branch (cause I thought it would always run on the WASM client side), and was confused when the connection was sometimes initialized and sometimes not. This also led to double database queries when navigating inside the app to the WASM page. Hopefully this is fixed ASAP.
This could lead to developers (at least me) making some mistakes about assuming which branch of the TryTakeFromJson condition is run at which point. In my app I initialized a SignalR connection in the true branch (cause I thought it would always run on the WASM client side), and was confused when the connection was sometimes initialized and sometimes not. This also lead to double database queries when navigating inside the app to the WASM page. Hopefully this is fixed ASAP.
I agree. Not fixing this for a LTS release is a mistake IMO.
Has anyone come up with a workaround for this yet? The intense flickering on page load when navigating is a truly awful UX.
In case anyone is interested. I have created a repo both demonstrating the problem and implementing a workaround. If anyone improves on that please let me know: https://github.com/oliverw/BlazorCustomApplicationStateApp
I should add that this is a Band-Aid at best and not a particularly good one:
- State is rendered twice on initial page load. One time by
PersistentComponentStateand another copy by the workaround. Depending on the size of your state, page sizes might increase significantly. Might be able to improve if able to tell a full page load from enhanced navigation load. - Significant boiler plate in every component using this
Here is a GIF of a demo site I made, that shows the behavior on slow connections (I used fast 3G browser throttle). The first main page showing the topics is server rendered, and the pages showing the messages use WASM interactivity. Even though the WASM pages use the PersistComponentState mechanism, the mechanism is only used randomly. First and third navigation in this GIF utilize the state persistence, and second and fourth navigation fetch the data from the API, causing page flickering and user having to wait for the API call, which leads to bad UX. Please make the state persist mechanism consistent.
Here is a GIF of a demo site I made, that shows the behavior on slow connections (I used fast 3G browser throttle). The first main page showing the topics is server rendered, and the pages showing the messages use WASM interactivity. Even though the WASM pages use the PersistComponentState mechanism, the mechanism is only used randomly. Every other navigation in this GIF uses the state persistence, and every other navigation has to fetch the data from the API, causing page flickering and user having to wait for the API call, which leads to bad UX. Please make the state persist mechanism consistent.
![]()
![]()
Might be worth giving my workaround a shot.
I managed to solve my issue by moving from full WASM interactivity page to using a server rendered page, and having interactive elements as WASM components, to which I pass the required state as parameters. It removes any client side data fetching needs, usage of PersistComponentState and therefore the page flickering.
Don't know why I hadn't tried passing state from a server rendered component to a WASM component previously (too stuck on previous hosted WASM model I guess), now I'm just wondering how on earth is the state actually persisted when passing parameters to WASM from server... (Edit: I see that the parameter state is deserialized into the prerendered html, makes sense. Still I hope that the PersistComponentState would work more consistently, having all sorts of random bugs with it when using it in some places)
Thanks for contacting us.
We're moving this issue to the .NET 9 Planning milestone for future evaluation / consideration. We would like to keep this around to collect more feedback, which can help us with prioritizing this work. We will re-evaluate this issue, during our next planning meeting(s).
If we later determine, that the issue has no community involvement, or it's very rare and low-impact issue, we will close it - so that the team can focus on more important and high impact issues.
To learn more about what to expect next and how this issue will be handled you can read more about our triage process here.
@javiercn ... Due to the importance of this subject, I'm going to place a cross-link to this issue for further dev discussion, particularly so devs can find a workaround approach. If you have a workaround approach that the topic can describe and will be supported, then we can add it to the topic and drop the cross-link to this issue. I'm 👂 if you want to sanction something. If not, we can leave the cross-link in place until .NET 9 lands.
https://github.com/dotnet/AspNetCore.Docs/pull/31276
@javiercn ... Due to the importance of this subject, I'm going to place a cross-link to this issue for further dev discussion, particularly so devs can find a workaround approach. If you have a workaround approach that the topic can describe, then perhaps we could add it to the topic, saying that it's a workaround, and drop the cross-link to this issue. I'm 👂 if you want to sanction something. If not, we can leave the cross-link in place until .NET 9 lands.
@Markz878's workaround is working pretty well.
- State is rendered twice on initial page load. One time by
PersistentComponentStateand another copy by the workaround.
Is there any reason to keep the PersistentComponentState with this workaround, though? It seems to me as if your workaround does what PersistComponentState does -- but better.
I did build off of your example to try to reduce the boilerplate required. Here's what I ended up with:
app.js
function getInnerText(id) {
return document.getElementById(id).innerText;
}
PersistentComponent.cs
public abstract class PersistentComponentBase<TState> : ComponentBase
where TState : new()
{
[Inject]
private IJSRuntime Js { get; set; } = default!;
protected TState State { get; } = new();
protected abstract string StateKey { get; }
protected override void BuildRenderTree(RenderTreeBuilder builder) =>
builder.AddMarkupContent(1, Serialize());
protected virtual Task InitializeStateAsync() => Task.CompletedTask;
protected override async Task OnInitializedAsync()
{
if (!OperatingSystem.IsBrowser())
{
await InitializeStateAsync();
return;
}
// When using InvokeAsync there can still be a flash. Use Invoke if possible.
var stateJson = Js is IJSInProcessRuntime inProcJs
? inProcJs.Invoke<string?>("getInnerText", [StateKey])
: await Js.InvokeAsync<string?>("getInnerText", [StateKey]);
if (string.IsNullOrWhiteSpace(stateJson))
{
await InitializeStateAsync();
return;
}
try
{
var buffer = Convert.FromBase64String(stateJson);
var json = Encoding.UTF8.GetString(buffer);
RestoreState(JsonSerializer.Deserialize<TState>(json)!);
}
catch
{
await InitializeStateAsync();
}
}
protected abstract void RestoreState(TState state);
private string Serialize()
{
if (State is null || OperatingSystem.IsBrowser())
return "";
var json = JsonSerializer.SerializeToUtf8Bytes(State);
var base64 = Convert.ToBase64String(json);
return $"<script id=\"{StateKey}\" type=\"text/template\">{base64}</script>";
}
}
MyComponent.razor
@inherits PersistentComponentBase<State>
...
@{ base.BuildRenderTree(__builder); }
MyComponent.razor.cs
protected override string StateKey => "my.state";
protected override async Task InitializeStateAsync()
{
// Load from database or whatever.
}
protected override void RestoreState(State state)
{
// Rehydrate state.
}
- State is rendered twice on initial page load. One time by
PersistentComponentStateand another copy by the workaround.Is there any reason to keep the
PersistentComponentStatewith this workaround, though? It seems to me as if your workaround does whatPersistComponentStatedoes -- but better.I did build off of your example to try to reduce the boilerplate required. Here's what I ended up with:
app.js
function getInnerText(id) { return document.getElementById(id).innerText; }PersistentComponent.cs
public abstract class PersistentComponentBase<TState> : ComponentBase where TState : new() { [Inject] private IJSRuntime Js { get; set; } = default!; protected TState State { get; } = new(); protected abstract string StateKey { get; } protected override void BuildRenderTree(RenderTreeBuilder builder) => builder.AddMarkupContent(1, Serialize()); protected virtual Task InitializeStateAsync() => Task.CompletedTask; protected override async Task OnInitializedAsync() { if (!OperatingSystem.IsBrowser()) { await InitializeStateAsync(); return; } var stateJson = await Js.InvokeAsync<string?>("getInnerText", [StateKey]); if (string.IsNullOrWhiteSpace(stateJson)) { await InitializeStateAsync(); return; } try { var buffer = Convert.FromBase64String(stateJson); var json = Encoding.UTF8.GetString(buffer); RestoreState(JsonSerializer.Deserialize<TState>(json)!); } catch { await InitializeStateAsync(); } } protected abstract void RestoreState(TState state); private string Serialize() { if (State is null || OperatingSystem.IsBrowser()) return ""; var json = JsonSerializer.SerializeToUtf8Bytes(State); var base64 = Convert.ToBase64String(json); return $"<script id=\"{StateKey}\" type=\"text/template\">{base64}</script>"; } }MyComponent.razor
@inherits PersistentComponentBase<State> ... @{ base.BuildRenderTree(__builder); }MyComponent.razor.cs
protected override string StateKey => "my.state"; protected override async Task InitializeStateAsync() { // Load from database or whatever. } protected override void RestoreState(State state) { // Rehydrate state. }
Don't use that. @Markz878's workaround works well with none of the downsides.
Don't use that. @Markz878's workaround works well with none of the downsides.
Unless I'm missing something, that only works until you have a nested component loading data. Then you're back to square one. It's the approach I took at first, but it doesn't work for very complex applications.
What is the recommended navigation mode for Blazor if this bug (or limitation) is not high priority?
It seems like Enhanced Navigation is the equivalent of SPA like behavior for Blazor Server apps, PersistentComponentState although cumbersome and boilerplate, does work to prevent double invocations of expensive calls, but having to hand-code invidual workarounds for different types of data for Enhanced navigation feels like a large hole currently.
Should we not be using Enhanced Navigation if I assume that MS are not building apps this way if they feel like this is not important?
Just wanted to do a quick drive-by - for anyone struggling with this, we have built a nuget package that allows persisting component state between prerender and the subsequent interactive render. It uses the approaches suggested and discussed in this issue. It may not be an ideal solution, but may help if anyone needs this functionality before it is implemented in the framework.
~~For clarification, this problem occurs when using PerPage interactivity and enhanced navigation. Setting the entire app to use InteractiveServer mode allows PersistingComponentState to work with enhanced navigation. I assume the downside is that now every page is rendered with InteractiveServer mode, which would increase server resource needs?~~ edit. This comment was wrong.
Have spent around a day battling with this before realising this was known to work like this. I assumed at first that my property types were failing silently to deserialIze with a JsonException being swallowed or something...
Having this as a part of .net is something I would love to see.
Since Dan's team decided that .NET10 can't be released without it, I'm developing my app without enhanced page navigation until then.
You can disable it by
<script src="_framework/blazor.web.js" autostart="false"></script>
<script>
Blazor.start({
ssr: { disableDomPreservation: true },
});
</script>
This allows me to write my code using the .NET10 Preview features like the [SupplyParameterFromPersistentComponentState] attribute. As soon as .NET10 will be released around 11/2025, I hopefully can just enable dom preservation again and everything works fine.
Blazor Persistent Component State filtering and enhanced navigation support
Summary
This design extends Blazor's persistent component state system to support context-based state restoration, allowing developers to declaratively control when component state should be restored or updated based on specific contexts like enhanced navigation, prerendering, or server disconnection.
Motivation and goals
Currently, Blazor's persistent component state system treats all restoration events equally - when RestoreStateAsync is called, all persisted state is restored regardless of the context that triggered the restoration. This creates several pain points:
- No granular control: Developers cannot specify that certain state should only be restored during prerendering but not during enhanced navigation, or vice versa.
- Performance concerns: Expensive state restoration operations run on every restoration event, even when not needed for that specific context.
- User experience issues: State that should only be restored once (like form data after server reconnection) gets restored multiple times during enhanced navigation, potentially overwriting user changes.
- Limited extensibility: No clean way to add new restoration contexts or customize behavior per context.
Evidence of this need comes from developer requests for more granular control over when state restoration occurs, particularly in applications using enhanced navigation where frequent state restoration can degrade performance and user experience.
In scope
- Context-based restoration callbacks that allow developers to register restoration logic for specific contexts
- Declarative attributes for controlling state restoration per property based on contexts
- Support for common web contexts: enhanced navigation, prerendering, server reconnection
- Both imperative API (manual registration) and declarative API (attributes)
- Callback lifecycle management (one-time vs recurring callbacks)
- Simplified attribute-based approach with
RestoreBehaviorflags - Multiple restoration events with state dictionary replacement
Out of scope
- Changes to the core persistence mechanism or storage format
- Breaking changes to existing
PersistentComponentStatepublic API - Client-side specific contexts (focus on server-side rendering contexts)
- Complex state merging strategies beyond replacement
- Performance optimizations beyond context filtering
Risks / unknowns
- Complexity increase: Adding context-based logic increases the cognitive load for developers learning the persistent state system
- Misuse potential: Developers might over-engineer contexts or create conflicting restoration logic
- Future restrictions: The context-based approach might limit future enhancements if not designed flexibly enough
- Performance impact: Additional callback management and context checking could introduce overhead
- Correctness concerns: Multiple restoration events and callback lifecycle management could lead to state inconsistencies if not properly handled
Examples
Declarative API using attributes:
public class MyComponent : ComponentBase
{
// Always restored (default behavior)
[PersistentState]
public string InitialData { get; set; }
// Skip restoration during prerendering
[PersistentState(RestoreBehavior = RestoreBehavior.SkipInitialValue)]
public string NoPrerenderedData { get; set; }
// Skip restoration during reconnection
[PersistentState(RestoreBehavior = RestoreBehavior.SkipLastSnapshot)]
public int CounterNotRestoredOnReconnect { get; set; }
// Receive updates during enhanced navigation
[PersistentState(AllowUpdates = true)]
public int CurrentPage { get; set; }
// Combine behaviors
[PersistentState(RestoreBehavior = RestoreBehavior.SkipInitialValue | RestoreBehavior.SkipLastSnapshot)]
public string OnlyEnhancedNavData { get; set; }
}
Imperative API using registration:
public class MyComponent : ComponentBase
{
[Inject] PersistentComponentState ApplicationState { get; set; }
protected override void OnInitialized()
{
// Register for enhanced navigation updates
ApplicationState.RegisterOnRestoring(() =>
{
// Only restore navigation-specific state
CurrentPage = ApplicationState.TryTakeFromJson<int>(nameof(CurrentPage), out var page) ? page : 1;
}, new RestoreOptions { AllowUpdates = true });
// Register for prerendering only
ApplicationState.RegisterOnRestoring(() =>
{
// Only restore during prerendering
InitialData = ApplicationState.TryTakeFromJson<string>(nameof(InitialData), out var data) ? data : "";
}, new RestoreOptions { RestoreBehavior = RestoreBehavior.SkipLastSnapshot });
}
}
Detailed design and implementation
Context validation criteria
This section contains a detailed list of end to end tests that should be defined and implemented to validate the contexts in scope.
Test 1: Declarative Prerendering Context Filtering
Description: Validates that RestoreBehavior.SkipInitialValue correctly controls state restoration during prerendering contexts.
Supporting files to update:
File: src/Components/test/testassets/TestContentPackage/DeclarativePersistStateComponent.razor
<p>Application state is <span id="@KeyName">@Value</span></p>
<p>Render mode: <span id="render-mode-@KeyName">@_renderMode</span></p>
+<p>Prerendering disabled: <span id="prerendering-disabled-@KeyName">@PrerenderingDisabledValue</span></p>
@code {
[Parameter, EditorRequired]
public string InitialValue { get; set; } = "";
[Parameter, EditorRequired]
public string KeyName { get; set; } = "";
[PersistentState]
public string Value { get; set; }
+ [PersistentState(RestoreBehavior = RestoreBehavior.SkipInitialValue)]
+ public string PrerenderingDisabledValue { get; set; }
private string _renderMode = "SSR";
protected override void OnInitialized()
{
Value ??= !RendererInfo.IsInteractive ? InitialValue : "not restored";
+ PrerenderingDisabledValue ??= !RendererInfo.IsInteractive ? "prerender-disabled-initial" : "prerender-disabled-not-restored";
_renderMode = OperatingSystem.IsBrowser() ? "WebAssembly" : "Server";
}
}
Test file: src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs
[Fact]
public void CanPersistPrerenderedStateDeclaratively_Server()
{
Navigate($"{ServerPathBase}/persist-state?server=true&declarative=true");
Browser.Equal("restored", () => Browser.FindElement(By.Id("server")).Text);
Browser.Equal("Server", () => Browser.FindElement(By.Id("render-mode-server")).Text);
+ // Should not be restored during prerendering due to SkipInitialValue
+ Browser.Equal("prerender-disabled-not-restored", () => Browser.FindElement(By.Id("prerendering-disabled-server")).Text);
}
Test 2: Server Reconnection Context Filtering
Description: Validates that RestoreBehavior.SkipLastSnapshot prevents state restoration during server reconnection while default behavior preserves state.
Supporting files to update:
File: src/Components/test/testassets/TestContentPackage/PersistentCounter.razor
<p>Current count: <span id="persistent-counter-count">@State.Count</span></p>
+<p>Non-persisted counter: <span id="non-persisted-counter">@NonPersistedCounter</span></p>
<button id="increment-persistent-counter-count" @onclick="IncrementCount">Click me</button>
+<button id="increment-non-persisted-counter" @onclick="IncrementNonPersistedCount">Increment non-persisted</button>
@code {
[PersistentState] public CounterState State { get; set; }
+ [PersistentState(RestoreBehavior = RestoreBehavior.SkipLastSnapshot)]
+ public int NonPersistedCounter { get; set; }
public class CounterState
{
public int Count { get; set; } = 0;
}
protected override void OnInitialized()
{
// State is preserved across disconnections
State ??= new CounterState();
+ // Initialize non-persisted counter to 5 during SSR (before interactivity)
+ if (!RendererInfo.IsInteractive)
+ {
+ NonPersistedCounter = 5;
+ }
}
private void IncrementCount()
{
State.Count = State.Count + 1;
}
+ private void IncrementNonPersistedCount()
+ {
+ NonPersistedCounter = NonPersistedCounter + 1;
+ }
}
Test file: src/Components/test/E2ETest/ServerExecutionTests/ServerResumeTests.cs
+[Fact]
+public void NonPersistedStateIsNotRestoredAfterDisconnection()
+{
+ // Verify initial state during/after SSR - NonPersistedCounter should be 5
+ Browser.Equal("5", () => Browser.Exists(By.Id("non-persisted-counter")).Text);
+
+ // Wait for interactivity - the value should still be 5
+ Browser.Exists(By.Id("render-mode-interactive"));
+ Browser.Equal("5", () => Browser.Exists(By.Id("non-persisted-counter")).Text);
+
+ // Increment the non-persisted counter to 6 to show it works during interactive session
+ Browser.Exists(By.Id("increment-non-persisted-counter")).Click();
+ Browser.Equal("6", () => Browser.Exists(By.Id("non-persisted-counter")).Text);
+
+ // Also increment the persistent counter to show the contrast
+ Browser.Exists(By.Id("increment-persistent-counter-count")).Click();
+ Browser.Equal("1", () => Browser.Exists(By.Id("persistent-counter-count")).Text);
+
+ // Force disconnection and reconnection
+ var javascript = (IJavaScriptExecutor)Browser;
+ javascript.ExecuteScript("window.replaceReconnectCallback()");
+ TriggerReconnectAndInteract(javascript);
+
+ // After reconnection:
+ // - Persistent counter should be 2 (was 1, incremented by TriggerReconnectAndInteract)
+ Browser.Equal("2", () => Browser.Exists(By.Id("persistent-counter-count")).Text);
+
+ // - Non-persisted counter should be 0 (default value) because RestoreBehavior.SkipLastSnapshot
+ // prevented it from being restored after disconnection
+ Browser.Equal("0", () => Browser.Exists(By.Id("non-persisted-counter")).Text);
+
+ // Verify the non-persisted counter can still be incremented in the new session
+ Browser.Exists(By.Id("increment-non-persisted-counter")).Click();
+ Browser.Equal("1", () => Browser.Exists(By.Id("non-persisted-counter")).Text);
+
+ // Test repeatability - trigger another disconnection cycle
+ javascript.ExecuteScript("resetReconnect()");
+ TriggerReconnectAndInteract(javascript);
+
+ // After second reconnection:
+ // - Persistent counter should be 3
+ Browser.Equal("3", () => Browser.Exists(By.Id("persistent-counter-count")).Text);
+
+ // - Non-persisted counter should be 0 again (reset to default)
+ Browser.Equal("0", () => Browser.Exists(By.Id("non-persisted-counter")).Text);
+}
Test 3: Enhanced Navigation State Updates
Description: Validates that AllowUpdates = true enables state updates for retained components during enhanced navigation contexts.
Supporting files to update:
File: src/Components/test/testassets/TestContentPackage/PersistentComponents/NonStreamingComponentWithDeclarativePersistentState.razor
+<p>Non streaming component with persistent state</p>
+
+<p>This component demonstrates state persistence in the absence of streaming rendering.
+When the component renders it will try to restore the state and if present display that
+it succeeded in doing so and the restored value.
+If the state is not present, it will indicate it didn't find it and display a "fresh"
+value.</p>
+
+<p id="interactive">Interactive: @(RendererInfo.IsInteractive)</p>
+<p id="interactive-runtime">Interactive runtime: @_interactiveRuntime</p>
+<p id="state-value">State value:@EnhancedNavState</p>
+
+<a id="enhanced-nav-update" href="@Navigation.GetUriWithQueryParameter("server-state", "updated")">With updated server state</a>
+<br />
+
+@code {
+ private string _interactiveRuntime;
+
+ [Inject] public PersistentComponentState PersistentComponentState { get; set; }
+ [Inject] public NavigationManager Navigation { get; set; }
+
+ [Parameter] public string ServerState { get; set; }
+
+ [PersistentState(AllowUpdates = true)]
+ public string EnhancedNavState { get; set; }
+
+ protected override void OnInitialized()
+ {
+ if (!RendererInfo.IsInteractive)
+ {
+ _interactiveRuntime = "none";
+ EnhancedNavState = ServerState;
+ }
+ else
+ {
+ EnhancedNavState ??= "not found";
+ _interactiveRuntime = OperatingSystem.IsBrowser() ? "wasm" : "server";
+ }
+ }
+}
Test file: src/Components/test/E2ETest/Tests/StatePersistenceTest.cs
+ [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")]
+ public void ComponentWithAllowUpdatesReceivesStateUpdates(Type renderMode, string streaming)
+ {
+ var mode = renderMode switch
+ {
+ var t when t == typeof(InteractiveServerRenderMode) => "server",
+ var t when t == typeof(InteractiveWebAssemblyRenderMode) => "wasm",
+ var t when t == typeof(InteractiveAutoRenderMode) => "auto",
+ _ => throw new ArgumentException($"Unknown render mode: {renderMode.Name}")
+ };
+
+ // Step 1: Navigate to page with declarative state components
+ if (streaming == null)
+ {
+ Navigate($"subdir/persistent-state/page-with-declarative-state-components?render-mode={mode}&server-state=initial&suppress-autostart");
+ }
+ else
+ {
+ Navigate($"subdir/persistent-state/page-with-declarative-state-components?render-mode={mode}&streaming-id={streaming}&server-state=initial&suppress-autostart");
+ }
+
+ if (mode == "auto")
+ {
+ BlockWebAssemblyResourceLoad();
+ }
+
+ Browser.Click(By.Id("call-blazor-start"));
+
+ // Step 2: Validate initial state
+ ValidateEnhancedNavState(
+ mode: mode,
+ renderMode: renderMode.Name,
+ interactive: streaming == null,
+ enhancedNavStateValue: "initial",
+ streamingId: streaming,
+ streamingCompleted: false);
+
+ if (streaming != null)
+ {
+ Browser.Click(By.Id("end-streaming"));
+ ValidateEnhancedNavState(
+ mode: mode,
+ renderMode: renderMode.Name,
+ interactive: true,
+ enhancedNavStateValue: "initial",
+ streamingId: streaming,
+ streamingCompleted: true);
+ }
+
+ // Step 3: Navigate with enhanced navigation to update state
+ Browser.Click(By.Id("enhanced-nav-update"));
+
+ // Step 4: Validate that enhanced navigation state was updated
+ ValidateEnhancedNavState(
+ mode: mode,
+ renderMode: renderMode.Name,
+ interactive: streaming == null,
+ enhancedNavStateValue: "updated",
+ streamingId: streaming,
+ streamingCompleted: streaming == null);
+
+ if (streaming != null)
+ {
+ Browser.Click(By.Id("end-streaming"));
+ ValidateEnhancedNavState(
+ mode: mode,
+ renderMode: renderMode.Name,
+ interactive: true,
+ enhancedNavStateValue: "updated",
+ streamingId: streaming,
+ streamingCompleted: true);
+ }
+ }
+ private void ValidateEnhancedNavState(
+ string mode,
+ string renderMode,
+ bool interactive,
+ string enhancedNavStateValue,
+ string streamingId = null,
+ bool streamingCompleted = false)
+ {
+ Browser.Equal($"Render mode: {renderMode}", () => Browser.FindElement(By.Id("render-mode")).Text);
+ Browser.Equal($"Streaming id:{streamingId}", () => Browser.FindElement(By.Id("streaming-id")).Text);
+ Browser.Equal($"Interactive: {interactive}", () => Browser.FindElement(By.Id("interactive")).Text);
+
+ if (streamingId == null || streamingCompleted)
+ {
+ Browser.Equal($"State value:{enhancedNavStateValue}", () => Browser.FindElement(By.Id("state-value")).Text);
+ }
+ else
+ {
+ Browser.Equal("Streaming: True", () => Browser.FindElement(By.Id("streaming")).Text);
+ }
+ }
Test Coverage Summary
These E2E tests provide comprehensive validation for:
- Declarative prerendering context filtering - Testing
RestoreBehavior.SkipInitialValueprevents restoration during prerendering - Server reconnection context filtering - Testing
RestoreBehavior.SkipLastSnapshotprevents state restoration during reconnection - Enhanced navigation state updates - Testing
AllowUpdates = trueenables state updates for retained components during enhanced navigation
The tests validate both the opt-in and opt-out contexts for each restoration context, ensuring the context-based filtering system works correctly across all supported contexts.
Implementation
This section describes the implementation details for the context-based persistent component state system in Blazor.
First we cover the sequence of events and interactions that occur over time during state restoration, then we detail the APIs and data structures involved.
Each of the following contexts below describe the main code primitives involved in the restoration process.
Server and Browser are the main actors, but they orchestrate their actions through the involvement of other participants they are composed of.
Restoration during prerendering
This context occurs when a user navigates to a Blazor page and the server needs to restore persistent component state during the transition from prerendered HTML to interactive components.
Sequence of events
1. Initial HTTP Request
- Browser sends HTTP request to server for a Blazor page
- Server begins SSR (Server-Side Rendering) process
2. Component Rendering and State Persistence
- Server renders Blazor components during prerendering phase
- Components with
[PersistentState]properties are identified ComponentStatePersistenceManagercollects component state during rendering- State is serialized and embedded in the HTML response
3. HTML Response with Embedded State
- Server completes HTML serialization
- Persistent component state is embedded as JSON in the HTML document
- HTML response is sent to browser
4. Client-Side State Collection
- Browser receives HTML response
- JavaScript code in
blazor.web.jsextracts embedded persistent state - State is prepared for transmission back to server
5. Interactive Component Initialization
- For interactive server components: JavaScript establishes SignalR connection
- For interactive WebAssembly components: WASM runtime initializes
- Root components and persistent state are sent to server/client
6. State Restoration Process
- Server calls
ComponentStatePersistenceManager.RestoreStateAsync()withRestoreContext.InitialValue - State dictionary is loaded from the persistence store
- Framework triggers component re-rendering process
7. Component Rendering with State Restoration
- During rendering, components execute their initialization logic
- When components call
RegisterOnRestoring()orTryTakeFromJson():- Framework immediately checks if there's matching state for the current context
- For properties without
RestoreBehavior.SkipInitialValue: state is provided - For properties with
RestoreBehavior.SkipInitialValue: state is skipped - Context-specific restoration callbacks are invoked immediately
- One-time prerendering callbacks are executed and then unregistered
8. Component Completes Rendering
- Components complete their initial interactive rendering with restored state
- UI reflects the persisted state from prerendering phase
Sequence diagram
sequenceDiagram
participant Browser
participant Server
participant ComponentStatePersistenceManager
participant PersistentComponentState
participant Component
Browser->>Server: HTTP Request for Blazor page
Server->>Server: Begin SSR rendering process
Server->>Component: Render components
Component->>PersistentComponentState: RegisterOnPersisting(callback)
Server->>ComponentStatePersistenceManager: PersistState()
ComponentStatePersistenceManager->>PersistentComponentState: Run all callbacks
PersistentComponentState->>ComponentStatePersistenceManager: Return serialized state
ComponentStatePersistenceManager->>Server: Persistent state data
Server->>Browser: HTML response with embedded persistent state
Browser->>Browser: Extract persistent state (blazor.web.js)
Browser->>Server: Establish connection + send state
Server->>ComponentStatePersistenceManager: RestoreStateAsync(store, RestoreContext.InitialValue)
ComponentStatePersistenceManager->>ComponentStatePersistenceManager: Load state from store
ComponentStatePersistenceManager->>Server: State ready for restoration
Server->>Component: Trigger re-rendering
Component->>PersistentComponentState: RegisterOnRestoring() / TryTakeFromJson()
PersistentComponentState->>PersistentComponentState: Check RestoreOptions
alt Default behavior or not SkipInitialValue
PersistentComponentState->>Component: Provide state immediately
else RestoreBehavior.SkipInitialValue
PersistentComponentState->>Component: Skip state restoration
end
PersistentComponentState->>PersistentComponentState: Execute callbacks immediately
PersistentComponentState->>PersistentComponentState: Unregister one-time callbacks
Component->>Server: Complete rendering with restored state
Server->>Browser: Update UI
Key participants and interactions
// During prerendering (step 2)
ComponentStatePersistenceManager.PersistState()
└─ PersistentComponentState.RegisterOnPersisting(callback)
// During restoration (steps 6-7)
ComponentStatePersistenceManager.RestoreStateAsync(store, RestoreContext.InitialValue)
└─ Framework triggers component rendering
└─ Component calls RegisterOnRestoring() or TryTakeFromJson()
├─ Framework immediately provides state based on RestoreOptions
├─ Invoke context-specific callbacks immediately
├─ Apply RestoreBehavior.SkipInitialValue filters
└─ Unregister one-time callbacks after execution
Context-specific behavior
- RestoreContext.InitialValue represents the initial application state
- When components register callbacks during rendering, they're executed immediately if state is available
- Callbacks registered without
AllowUpdates = trueare one-time only and removed after execution - Properties without explicit
RestoreBehavior.SkipInitialValueare restored by default - The natural component rendering cycle handles the state restoration automatically
This approach leverages Blazor's existing component lifecycle - the restoration happens organically as components re-render and request their state, rather than requiring an explicit state update mechanism.
Restoration during reconnection
This context occurs when an interactive server application loses its connection to the browser, the circuit is disconnected and later evicted, and the client attempts to reconnect by resuming from persistent state.
Sequence of events
1. Interactive Server Session Established
- Browser has active SignalR connection to server
- Interactive server components are running in a circuit
- User is actively interacting with the application
2. Connection Loss
- Network connection between browser and server is lost
- Circuit is marked as disconnected on the server
3. Circuit Eviction and State Persistence
- After timeout period, disconnected circuit is evicted from memory
- Server triggers
ComponentStatePersistenceManager.PersistState()before disposal - Circuit state (root components + persistent state) is saved to storage
- Circuit is removed from server memory
4. Client Reconnection Attempt
- Browser attempts automatic reconnection
- JavaScript in
blazor.server.jsattempts to reestablish SignalR connection - Client sends circuit ID to server for reconnection
5. Circuit Not Found
- Server cannot find circuit ID in active or disconnected circuit pools
- Server returns false to indicate that there is no active circuit
6. Client Resume Attempt
- Client tries to resume the application on a new circuit sending the circuit ID
- Server checks persistent storage for circuit with circuit ID
- If found, server starts rendering the components and application using the application state to restore the data
7. State Restoration Process
- Server calls
ComponentStatePersistenceManager.RestoreStateAsync()withRestoreContext.LastSnapshot - Root components and persistent state are loaded from storage
- New circuit is created with restored root components
8. Component Rendering with State Restoration
- During rendering, components execute their initialization logic
- When components call
RegisterOnRestoring()orTryTakeFromJson():- Framework immediately checks if there's matching state for the reconnection context
- For properties without
RestoreBehavior.SkipLastSnapshot: state is provided - For properties with
RestoreBehavior.SkipLastSnapshot: state is skipped - Context-specific restoration callbacks are invoked immediately
- One-time reconnection callbacks are executed and then unregistered
9. Circuit Resume Completion
- Components complete their rendering with restored state
- New circuit is established with browser
- SignalR connection is reestablished
- UI reflects the persisted state from before disconnection
Sequence diagram
sequenceDiagram
participant Browser
participant Server
participant ComponentStatePersistenceManager
participant PersistentComponentState
participant Component
participant Storage
Browser->>Server: HTTP Request for initial page
Server->>Browser: Return prerendered HTML
Browser->>Server: Establish connection for interactive server rendering
Browser->>Server: Active SignalR connection (interactive session)
Note over Browser,Server: Connection loss occurs
Server->>Server: Mark circuit as disconnected
Note over Server: Circuit timeout - eviction triggered
Server->>ComponentStatePersistenceManager: PersistState() before disposal
ComponentStatePersistenceManager->>PersistentComponentState: Run all callbacks
PersistentComponentState->>ComponentStatePersistenceManager: Return serialized state
ComponentStatePersistenceManager->>Storage: Save circuit state (root components + state)
Server->>Server: Remove circuit from memory
Browser->>Browser: Attempt automatic reconnection
Browser->>Server: Reconnection attempt with circuit ID
Server->>Server: Check active/disconnected circuit pools
Server->>Browser: Return false (no active circuit)
Browser->>Server: Resume attempt with circuit ID
Server->>Storage: Check persistent storage for circuit ID
Storage->>Server: Circuit state found
Server->>ComponentStatePersistenceManager: RestoreStateAsync(store, RestoreContext.LastSnapshot)
ComponentStatePersistenceManager->>Storage: Load circuit state
Storage->>ComponentStatePersistenceManager: Root components + persistent state
ComponentStatePersistenceManager->>Server: State ready for restoration
Server->>Server: Create new circuit with restored root components
Server->>Component: Trigger rendering
Component->>PersistentComponentState: RegisterOnRestoring() / TryTakeFromJson()
PersistentComponentState->>PersistentComponentState: Check RestoreOptions
alt Default behavior or not SkipLastSnapshot
PersistentComponentState->>Component: Provide state immediately
else RestoreBehavior.SkipLastSnapshot
PersistentComponentState->>Component: Skip state restoration
end
PersistentComponentState->>PersistentComponentState: Execute callbacks immediately
PersistentComponentState->>PersistentComponentState: Unregister one-time callbacks
Component->>Server: Complete rendering with restored state
Server->>Browser: Update UI
Browser->>Browser: UI updated with restored state
Key participants and interactions
// During circuit eviction (step 3)
ComponentStatePersistenceManager.PersistState()
└─ PersistentComponentState.RegisterOnPersisting(callback)
└─ Storage.SaveCircuitState(circuitId, rootComponents, persistentState)
// During reconnection restoration (steps 7-8)
ComponentStatePersistenceManager.RestoreStateAsync(store, RestoreContext.LastSnapshot)
└─ Storage.LoadCircuitState(circuitId)
└─ Server creates new circuit with restored components
└─ Framework triggers component rendering
└─ Component calls RegisterOnRestoring() or TryTakeFromJson()
├─ Framework immediately provides state based on RestoreOptions
├─ Invoke context-specific callbacks immediately
├─ Apply RestoreBehavior.SkipLastSnapshot filters
└─ Unregister one-time callbacks after execution
Context-specific behavior
- RestoreContext.LastSnapshot represents the last saved state before disconnection
- Circuit is completely recreated from scratch using persisted root components
- When components register callbacks during rendering, they're executed immediately if state is available
- Callbacks registered without
AllowUpdates = trueare one-time only and removed after execution - Properties without explicit
RestoreBehavior.SkipLastSnapshotare restored by default - This context preserves user session state across network disconnections and server memory pressure
The reconnection restoration ensures that users can seamlessly continue their work after temporary network issues or server restarts, without losing their application state or having to start over.
Restoration during enhanced navigation
This context occurs when a user is in an active interactive session and navigates to another page using enhanced navigation, where components are retained between pages and need to receive updated state.
Sequence of events
1. Initial HTTP Request
- Browser sends HTTP request to server for a Blazor page
- Server begins SSR (Server-Side Rendering) process
2. Component Rendering and Interactive Session Establishment
- Server renders Blazor components during prerendering phase
- Interactive components are identified and prepared for interactivity
- Interactive rendering session starts (Server/WebAssembly/Auto mode)
- User begins interacting with the application
3. Enhanced Navigation Request
- User clicks on a link or triggers navigation to another page
- Browser initiates enhanced navigation process
4. Enhanced navigation server rendering
- Server triggers
ComponentStatePersistenceManager.PersistState()for current page - Components with persistent state register their callbacks
- State is serialized and sent to the browser
5. Enhanced Navigation Response
- Enhanced navigation system identifies components that are retained between pages
- Enhanced navigation crafts the list of component operations and sends it to the interactive host with the serialized state.
6. State Restoration Process
- Server calls
ComponentStatePersistenceManager.RestoreStateAsync()withRestoreContext.ValueUpdate - State dictionary is updated with enhanced navigation data
- Registered callbacks for existing components are run.
- Framework processes the list of operations.
7. Component Parameter Updates
- Retained components execute
OnParametersSet()lifecycle method - When components call
RegisterOnRestoring()orTryTakeFromJson():- Framework immediately checks if there's matching state for enhanced navigation context
- For properties with
AllowUpdates = true: state is provided and parameters updated - For properties without the attribute: state restoration is skipped
- Context-specific restoration callbacks are invoked immediately
- Recurring enhanced navigation callbacks remain registered for future navigations
8. Component Re-rendering with Updated State
- Retained components complete their parameter update cycle with new state
- UI reflects the updated state from enhanced navigation
- User sees seamless navigation with preserved and updated component state
Sequence diagram
sequenceDiagram
participant Browser
participant Server
participant ComponentStatePersistenceManager
participant PersistentComponentState
participant Component
Browser->>Server: HTTP Request for initial page
Server->>Browser: Return prerendered HTML
Browser->>Server: Establish interactive session
Note over Browser,Component: User interacts with application
Browser->>Browser: User clicks navigation link
Browser->>Server: Browser initiates enhanced navigation
Server->>ComponentStatePersistenceManager: PersistState() for current page
ComponentStatePersistenceManager->>PersistentComponentState: Run all callbacks
PersistentComponentState->>ComponentStatePersistenceManager: Return serialized state
ComponentStatePersistenceManager->>Server: Current page state
Server->>Browser: Updated components plus updated state
Browser->>Browser: Determine component retention
Browser->>Browser: Craft list of component operations
Browser->>Server: Send application state + component operations to interactive host
Server->>ComponentStatePersistenceManager: RestoreStateAsync(store, RestoreContext.ValueUpdate)
ComponentStatePersistenceManager->>PersistentComponentState: Update state dictionary
ComponentStatePersistenceManager->>PersistentComponentState: Run registered callbacks for existing components
Server->>Server: Process list of operations
Server->>Component: Trigger OnParametersSet()
Component->>PersistentComponentState: RegisterOnRestoring() / TryTakeFromJson()
PersistentComponentState->>PersistentComponentState: Check RestoreOptions
alt AllowUpdates = true
PersistentComponentState->>Component: Provide updated state immediately
Component->>Component: Update parameters
else No AllowUpdates
PersistentComponentState->>Component: Skip state update
end
PersistentComponentState->>PersistentComponentState: Execute callbacks immediately
PersistentComponentState->>PersistentComponentState: Keep recurring callbacks registered
Component->>Server: Complete parameter update with new state
Server->>Browser: Update UI
Key participants and interactions
// During enhanced navigation state persistence (step 4)
ComponentStatePersistenceManager.PersistState()
└─ PersistentComponentState.RegisterOnPersisting(callback)
// During enhanced navigation restoration (steps 7-8)
ComponentStatePersistenceManager.RestoreStateAsync(store, RestoreContext.ValueUpdate)
└─ Framework triggers OnParametersSet() for retained components
└─ Component calls RegisterOnRestoring() or TryTakeFromJson()
├─ Framework immediately provides state based on RestoreOptions
├─ Invoke context-specific callbacks immediately
├─ Apply AllowUpdates filter
└─ Keep recurring callbacks registered for future navigations
Context-specific behavior
- RestoreContext.ValueUpdate represents external state updates to the current application
- Components are retained between pages rather than recreated
- When retained components receive parameter updates, they can access enhanced navigation state
- Callbacks registered with
AllowUpdates = trueare recurring and remain active across multiple navigations - Only properties with explicit
AllowUpdates = truereceive state updates during enhanced navigation - Properties without the attribute maintain their current values and are not updated
- The component lifecycle uses
OnParametersSet()rather thanOnInitialized()since components are retained
This context enables sophisticated state management during enhanced navigation, allowing components to selectively receive updated state while preserving their existing state and avoiding unnecessary re-initialization. The recurring nature of the callbacks ensures consistent behavior across multiple enhanced navigation events within the same session.
APIs and data structures
public enum RestoreBehavior
{
Default = 0,
SkipInitialValue = 1,
SkipLastSnapshot = 2
}
Restore context
public sealed class RestoreContext
{
public static RestoreContext InitialValue { get; }
public static RestoreContext LastSnapshot { get; }
public static RestoreContext ValueUpdate { get; }
internal bool ShouldRestore(RestoreOptions options);
}
Restore options
public readonly struct RestoreOptions
{
public RestoreBehavior RestoreBehavior { get; init; } = RestoreBehavior.Default;
public bool AllowUpdates { get; init; } = false;
}
Declarative attribute
[AttributeUsage(AttributeTargets.Property)]
public sealed class PersistentStateAttribute : CascadingParameterAttributeBase
{
public RestoreBehavior RestoreBehavior { get; set; } = RestoreBehavior.Default;
public bool AllowUpdates { get; set; }
}
Enhanced PersistentComponentState
public sealed class PersistentComponentState
{
// Existing members unchanged...
public RestoringComponentStateSubscription RegisterOnRestoring(Action callback, RestoreOptions options);
internal void UpdateExistingState(IDictionary<string, byte[]> state, RestoreContext context);
}
Enhanced ComponentStatePersistenceManager
public sealed class ComponentStatePersistenceManager
{
// Existing members unchanged...
public Task RestoreStateAsync(IPersistentComponentStateStore store, RestoreContext context);
}
classDiagram
class RestoreBehavior {
<<enumeration>>
Default = 0
SkipInitialValue = 1
SkipLastSnapshot = 2
}
class RestoreContext {
+InitialValue : RestoreContext$
+LastSnapshot : RestoreContext$
+ValueUpdate : RestoreContext$
~ShouldRestore(options: RestoreOptions) : bool
}
class RestoreOptions {
<<struct>>
+RestoreBehavior : RestoreBehavior
+AllowUpdates : bool
}
class PersistentStateAttribute {
<<attribute>>
+RestoreBehavior : RestoreBehavior
+AllowUpdates : bool
}
class RestoringComponentStateSubscription {
<<struct>>
+Dispose() : void
}
class PersistentComponentState {
+RegisterOnRestoring(callback: Action, options: RestoreOptions) : RestoringComponentStateSubscription
~UpdateExistingState(state: IDictionary~string, byte[]~, context: RestoreContext)
}
class ComponentStatePersistenceManager {
+RestoreStateAsync(store: IPersistentComponentStateStore, context: RestoreContext) : Task
}
RestoreOptions ..> RestoreBehavior : uses
PersistentStateAttribute ..> RestoreBehavior : uses
RestoreContext ..> RestoreOptions : uses
PersistentComponentState ..> RestoreOptions : uses
PersistentComponentState ..> RestoreContext : uses
PersistentComponentState ..> RestoringComponentStateSubscription : returns
ComponentStatePersistenceManager ..> RestoreContext : uses
Methods and functions
PersistentComponentState.RegisterOnRestoring
Purpose: Registers a callback to be invoked when state is restored for contexts matching the specified options.
Inputs:
callback: The action to execute during restoration for matching contextsoptions: The options that determine when the callback should be invoked
Outputs: RestoringComponentStateSubscription that can be disposed to unregister the callback
Usage examples:
// Register for enhanced navigation contexts
PersistentComponentState.RegisterOnRestoring(() =>
{
// Handle enhanced navigation restoration
CurrentPage = PersistentComponentState.TryTakeFromJson<int>(nameof(CurrentPage), out var page) ? page : 1;
}, new RestoreOptions { AllowUpdates = true });
// Register for prerendering contexts only
PersistentComponentState.RegisterOnRestoring(() =>
{
// Handle prerendering restoration only
InitialData = PersistentComponentState.TryTakeFromJson<string>(nameof(InitialData), out var data) ? data : "";
}, new RestoreOptions { RestoreBehavior = RestoreBehavior.SkipLastSnapshot });
Implementation steps:
- Store callback with its associated options in internal collection
- Determine callback lifecycle based on
options.AllowUpdatesproperty - If current context matches options, invoke callback immediately
- Return subscription for cleanup
Test contexts:
- Callback is invoked when context matches the restoration options
- Callback is not invoked for non-matching contexts
- One-time callbacks are unregistered after first use
- Recurring callbacks persist across multiple restorations
PersistentComponentState.UpdateExistingState
Purpose: Replaces the existing state dictionary and invokes registered callbacks based on the context.
Inputs:
state: New state dictionary to replace existing statecontext: Context for this restoration event
Outputs: None (internal method)
Implementation steps:
- Replace internal state dictionary with provided state
- Invoke callbacks based on context filtering logic:
- Check
context.ShouldRestore(options)for each callback - Invoke callbacks that match the current context
- Check
- Determine callback lifecycle:
- For callbacks without
AllowUpdates = true: Unregister after execution - For callbacks with
AllowUpdates = true: Keep registered for future updates
- For callbacks without
Test contexts:
- State dictionary is properly replaced
- Only callbacks with matching options are invoked
- One-time callbacks are removed after execution
- Recurring callbacks remain registered
ComponentStatePersistenceManager.RestoreStateAsync (new overload)
Purpose: Restores component state for a specific restoration context.
Inputs:
store: The persistence store containing state datacontext: The context for this restoration
Outputs: Task representing the async operation
Usage examples:
// Enhanced navigation update
await manager.RestoreStateAsync(store, RestoreContext.ValueUpdate);
// Prerendering context
await manager.RestoreStateAsync(store, RestoreContext.InitialValue);
// Reconnection context
await manager.RestoreStateAsync(store, RestoreContext.LastSnapshot);
Implementation steps:
- Load state data from store
- For each component with persistent state:
- Call
UpdateExistingStatewith context - Apply declarative restoration filters based on context
- Call
- Coordinate with existing restoration pipeline
Test contexts:
- State is restored only for components matching context filters
- Context is properly passed to components
- Integration with existing restoration mechanism works correctly
- Multiple contexts can be processed independently
RestoreContext static properties
Purpose: Provide predefined contexts for common restoration scenarios.
Properties:
RestoreContext.InitialValue: Initial application state (prerendering)RestoreContext.LastSnapshot: Last saved state (reconnection)RestoreContext.ValueUpdate: External state updates (enhanced navigation)
Usage examples:
// Prerendering
await RestoreStateAsync(store, RestoreContext.InitialValue);
// Reconnection
await RestoreStateAsync(store, RestoreContext.LastSnapshot);
// Enhanced navigation
await RestoreStateAsync(store, RestoreContext.ValueUpdate);
Implementation details:
InitialValue: Used when restoring initial application stateLastSnapshot: Used when restoring from a previous sessionValueUpdate: Used when providing external state updates
Test contexts:
- Each context correctly filters callbacks based on RestoreOptions
- Context behavior matches intended scenario
- Contexts can be used interchangeably with the restoration API
Declarative attribute processing
Purpose: Automatically applies restoration logic based on property attributes.
Supported attribute:
[PersistentState]: Controls state restoration withRestoreBehaviorandAllowUpdatesproperties
Usage examples:
public class MyComponent : ComponentBase
{
// Always restored (default)
[PersistentState]
public string? InitialData { get; set; }
// Skip during prerendering
[PersistentState(RestoreBehavior = RestoreBehavior.SkipInitialValue)]
public int NoPrerenderedData { get; set; }
// Skip during reconnection
[PersistentState(RestoreBehavior = RestoreBehavior.SkipLastSnapshot)]
public string NoReconnectData { get; set; }
// Receive updates during enhanced navigation
[PersistentState(AllowUpdates = true)]
public int CurrentPage { get; set; }
// Combined behaviors
[PersistentState(RestoreBehavior = RestoreBehavior.SkipInitialValue | RestoreBehavior.SkipLastSnapshot, AllowUpdates = true)]
public List<string> EnhancedNavOnlyData { get; set; } = new();
}
Implementation approach:
PersistentStateAttributeextendsCascadingParameterAttributeBase- Extend value provider to check for
RestoreBehaviorandAllowUpdatesproperties - Filter restoration based on context matching attribute settings
- Integrate with existing parameter supply mechanism
Test contexts:
- Properties with matching attributes are restored for appropriate contexts
- Properties with filtering attributes are skipped in specified contexts
AllowUpdatesenables recurring updates- Integration with manual restoration callbacks
WebAssemblyHost.StartAsync
Purpose: Initialize WebAssembly host and restore state during prerendering transition
Inputs:
store: The persistence store containing state data
Outputs: Task representing the async operation
Change: Update to use context-based overload with prerendering context
Code:
// Before
await ComponentStatePersistenceManager.RestoreStateAsync(store);
// After
await ComponentStatePersistenceManager.RestoreStateAsync(store, RestoreContext.InitialValue);
CircuitFactory.CreateCircuitHostAsync
Purpose: Create new circuit host and restore state during prerendering transition
Inputs:
store: The persistence store containing state data
Outputs: Task representing the async operation
Change: Update to use context-based overload with prerendering context
Code:
// Before
await manager.RestoreStateAsync(store);
// After
await manager.RestoreStateAsync(store, RestoreContext.InitialValue);
JavaScript Interop Changes
Purpose: Update JavaScript interop to support enhanced navigation with persistent state
Files and Changes:
1. JavaScript Boot Integration
File: src/Components/Web.JS/src/Boot.WebAssembly.Common.ts
Change: Update the internal API to accept separate persistent state parameter
Code:
// Before
Blazor._internal.updateRootComponents = (operations: string) =>
Blazor._internal.dotNetExports?.UpdateRootComponentsCore(operations);
// After
Blazor._internal.updateRootComponents = (operations: string, persistentState?: string) =>
Blazor._internal.dotNetExports?.UpdateRootComponents(operations, persistentState || null);
2. DefaultWebAssemblyJSRuntime Integration
File: src/Components/WebAssembly/WebAssembly/src/Services/DefaultWebAssemblyJSRuntime.cs
Change: Update the event signature and JSExport method to handle persistent state
Code:
// Update the event to include persistent state parameter
public event Action<RootComponentOperationBatch, string?>? OnUpdateRootComponents;
// Update the JSExport method signature
[JSExport]
public static void UpdateRootComponents(string operations, string? persistentState)
{
var batch = JsonSerializer.Deserialize<RootComponentOperationBatch>(operations, JsonSerializerOptionsProvider.Options);
Instance.OnUpdateRootComponents?.Invoke(batch, persistentState);
}
3. WebAssemblyRenderer State Restoration
File: src/Components/WebAssembly/WebAssembly/src/Rendering/WebAssemblyRenderer.cs
Change: Add _isFirstUpdate field and update OnUpdateRootComponents method to handle state restoration for both prerendering and enhanced navigation scenarios
Code:
internal sealed partial class WebAssemblyRenderer : WebRenderer
{
private readonly ILogger _logger;
private readonly Dispatcher _dispatcher;
private readonly ResourceAssetCollection _resourceCollection;
private readonly IInternalJSImportMethods _jsMethods;
private static readonly RendererInfo _componentPlatform = new("WebAssembly", isInteractive: true);
private bool _isFirstUpdate = true; // Add this field
// Update constructor to use new event signature
public WebAssemblyRenderer(IServiceProvider serviceProvider, ResourceAssetCollection resourceCollection, ILoggerFactory loggerFactory, JSComponentInterop jsComponentInterop)
: base(serviceProvider, loggerFactory, DefaultWebAssemblyJSRuntime.Instance.ReadJsonSerializerOptions(), jsComponentInterop)
{
// ...existing code...
DefaultWebAssemblyJSRuntime.Instance.OnUpdateRootComponents += OnUpdateRootComponents;
} // Update method signature and add state restoration logic
[UnconditionalSuppressMessage("Trimming", "IL2072:RequiresUnreferencedCode message", Justification = "These are root components which belong to the user and are in assemblies that don't get trimmed.")]
private async void OnUpdateRootComponents(RootComponentOperationBatch batch, string? persistentState)
{
var shouldClearStore = false;
// Handle state restoration if present
if (persistentState is not null)
{
var componentStatePersistenceManager = Services.GetRequiredService<ComponentStatePersistenceManager>();
var store = new PrerenderComponentApplicationStore(persistentState);
if (_isFirstUpdate)
{
_isFirstUpdate = false;
shouldClearStore = true;
// This is the initial render after prerendering
await componentStatePersistenceManager.RestoreStateAsync(store, RestoreContext.InitialValue);
}
else
{
shouldClearStore = true;
// This is a subsequent enhanced navigation update
await componentStatePersistenceManager.RestoreStateAsync(store, RestoreContext.ValueUpdate);
}
}
else if (_isFirstUpdate)
{
_isFirstUpdate = false;
}
// Continue with existing component operation processing
var webRootComponentManager = GetOrCreateWebRootComponentManager();
for (var i = 0; i < batch.Operations.Length; i++)
{
var operation = batch.Operations[i];
switch (operation.Type)
{
case RootComponentOperationType.Add:
_ = webRootComponentManager.AddRootComponentAsync(
operation.SsrComponentId,
operation.Descriptor!.ComponentType,
operation.Marker!.Value.Key!,
operation.Descriptor!.Parameters);
break;
case RootComponentOperationType.Update:
_ = webRootComponentManager.UpdateRootComponentAsync(
operation.SsrComponentId,
operation.Descriptor!.ComponentType,
operation.Marker?.Key,
operation.Descriptor!.Parameters);
break;
case RootComponentOperationType.Remove:
webRootComponentManager.RemoveRootComponent(operation.SsrComponentId);
break;
}
}
NotifyEndUpdateRootComponents(batch.BatchId);
// Clear the state store after all operations are complete to reclaim memory
if (shouldClearStore && persistentState is not null)
{
var store = new PrerenderComponentApplicationStore(persistentState);
store.ExistingState.Clear();
}
}
}
4. Enhanced Navigation JavaScript Integration
File: src/Components/Web.JS/src/Boot.WebAssembly.Common.ts
Purpose: Update WebAssembly JavaScript integration to discover and pass persistent state during enhanced navigation
Changes Required:
- Update
updateWebAssemblyRootComponentsfunction signature:
// Before
export function updateWebAssemblyRootComponents(operations: string): void
// After
export function updateWebAssemblyRootComponents(operations: string, persistentState?: string): void
- Update internal
updateRootComponentsassignment:
// Before
Blazor._internal.updateRootComponents = (operations: string) =>
Blazor._internal.dotNetExports?.UpdateRootComponentsCore(operations);
// After
Blazor._internal.updateRootComponents = (operations: string, persistentState?: string) =>
Blazor._internal.dotNetExports?.UpdateRootComponents(operations, persistentState || null);
- Update
scheduleAfterStartedfunction:
// Before
async function scheduleAfterStarted(operations: string): Promise<void>
// After
async function scheduleAfterStarted(operations: string, persistentState?: string): Promise<void>
- Update enhanced navigation call sites to discover and pass state:
// In enhanced navigation logic, when calling updateWebAssemblyRootComponents:
const webAssemblyState = discoverWebAssemblyPersistedState(newPageContent);
updateWebAssemblyRootComponents(operations, webAssemblyState);
Context: The WebAssembly JavaScript integration needs to be updated to discover persistent state during enhanced navigation and pass it through the call chain to the .NET runtime. This mirrors the server-side approach where CircuitManager already handles state transmission correctly.
Server-Side Enhanced Navigation Integration
Purpose: Handle enhanced navigation state restoration for server-side Blazor components, ensuring the JavaScript CircuitManager passes discovered state during enhanced navigation rather than empty strings.
Files and Changes:
1. CircuitManager JavaScript Integration
File: src/Components/Web.JS/src/Platform/Circuits/CircuitManager.ts
Purpose: Update server-side JavaScript integration to discover and pass persistent state during enhanced navigation
Context: The CircuitManager already contains logic to discover persistent state from page content during enhanced navigation. However, the updateRootComponents method needs to ensure it passes this discovered state, not just an empty string, when invoking the server-side .NET method.
Changes Required:
- Ensure
updateRootComponentssends discovered state during enhanced navigation:
// In the enhanced navigation logic within CircuitManager:
// The state discovery logic already exists and works correctly
const discoveredState = this.discoverPersistedState(newPageContent);
// The call to updateRootComponents must pass the discovered state, not empty string
await this.updateRootComponents(operations, discoveredState);
- Update state cleanup after enhanced navigation:
// After successful enhanced navigation update:
if (discoveredState) {
// Clear the state from the page to prevent memory leaks
this.clearPersistedStateFromPage();
}
Context: The server-side CircuitManager's enhanced navigation logic already discovers persistent state from the new page content. The critical requirement is ensuring that this discovered state is passed through to the CircuitHost.UpdateRootComponents method on the .NET side, not just an empty string. This ensures that components receive the correct state during enhanced navigation transitions, allowing for proper scenario-based restoration.
State Cleanup: After enhanced navigation completes successfully, the CircuitManager must clean up the persisted state from the page content to prevent memory accumulation and ensure state doesn't persist beyond its intended lifecycle.
SupplyParameterFromPersistentComponentStateValueProvider
Purpose: Update the cascading value supplier to support context-based filtering through declarative attributes and use RegisterOnRestoring callbacks.
Inputs:
- Property filter attributes for context-based restoration control
- Current restoration context
Outputs: Restored values based on context filtering
Change: Split into multiple classes for better organization and register restoration callbacks based on attribute settings.
Code:
// File: src/Components/Components/src/PersistentState/PersistentStateValueProvider.cs
internal sealed class PersistentStateValueProvider(PersistentComponentState state, ILogger<PersistentStateValueProvider> logger, IServiceProvider serviceProvider) : ICascadingValueSupplier
{
private readonly Dictionary<ComponentSubscriptionKey, PersistentValueProviderComponentSubscription> _subscriptions = [];
public bool IsFixed => false;
// For testing purposes only
internal Dictionary<ComponentSubscriptionKey, PersistentValueProviderComponentSubscription> Subscriptions => _subscriptions;
public bool CanSupplyValue(in CascadingParameterInfo parameterInfo)
=> parameterInfo.Attribute is PersistentStateAttribute;
[UnconditionalSuppressMessage(
"ReflectionAnalysis",
"IL2026:RequiresUnreferencedCode message",
Justification = "JSON serialization and deserialization might require types that cannot be statically analyzed.")]
[UnconditionalSuppressMessage(
"Trimming",
"IL2072:Target parameter argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value of the source method does not have matching annotations.",
Justification = "JSON serialization and deserialization might require types that cannot be statically analyzed.")]
public object? GetCurrentValue(object? key, in CascadingParameterInfo parameterInfo)
{
var componentState = (ComponentState)key!;
if (_subscriptions.TryGetValue(new(componentState, parameterInfo.PropertyName), out var subscription))
{
return subscription.GetOrComputeLastValue();
}
return null;
}
public void Subscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo)
{
var propertyName = parameterInfo.PropertyName;
var componentSubscription = new PersistentValueProviderComponentSubscription(
state,
subscriber,
parameterInfo,
serviceProvider,
logger);
_subscriptions.Add(new ComponentSubscriptionKey(subscriber, propertyName), componentSubscription);
}
public void Unsubscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo)
{
if (_subscriptions.TryGetValue(new(subscriber, parameterInfo.PropertyName), out var subscription))
{
subscription.Dispose();
_subscriptions.Remove(new(subscriber, parameterInfo.PropertyName));
}
}
}