aspnetcore icon indicating copy to clipboard operation
aspnetcore copied to clipboard

Support persistent component state across enhanced page navigations

Open javiercn opened this issue 2 years ago • 16 comments

  • 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.

javiercn avatar Oct 23 '23 15:10 javiercn

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.

ghost avatar Oct 23 '23 15:10 ghost

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.

Markz878 avatar Oct 24 '23 07:10 Markz878

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.

oliverw avatar Oct 24 '23 15:10 oliverw

Has anyone come up with a workaround for this yet? The intense flickering on page load when navigating is a truly awful UX.

oliverw avatar Nov 03 '23 18:11 oliverw

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 PersistentComponentState and 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

oliverw avatar Nov 04 '23 12:11 oliverw

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. StatePersistBug

Markz878 avatar Nov 10 '23 09:11 Markz878

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. StatePersistBug StatePersistBug

Might be worth giving my workaround a shot.

oliverw avatar Nov 10 '23 10:11 oliverw

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)

Markz878 avatar Nov 15 '23 16:11 Markz878

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.

ghost avatar Dec 19 '23 18:12 ghost

@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

guardrex avatar Dec 20 '23 16:12 guardrex

@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.

oliverw avatar Dec 20 '23 16:12 oliverw

  • State is rendered twice on initial page load. One time by PersistentComponentState and 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.
}

ShawnTheBeachy avatar May 03 '24 20:05 ShawnTheBeachy

  • State is rendered twice on initial page load. One time by PersistentComponentState and 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;
        }

        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.

oliverw avatar May 03 '24 21:05 oliverw

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.

ShawnTheBeachy avatar May 04 '24 01:05 ShawnTheBeachy

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?

jamescarter-le avatar Aug 25 '24 20:08 jamescarter-le

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.

YuriyDurov avatar Nov 07 '24 18:11 YuriyDurov

~~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.

MattCost avatar Nov 27 '24 16:11 MattCost

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.

pingu2k4 avatar Jan 13 '25 18:01 pingu2k4

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.

mixedHans avatar May 24 '25 10:05 mixedHans

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 RestoreBehavior flags
  • Multiple restoration events with state dictionary replacement

Out of scope

  • Changes to the core persistence mechanism or storage format
  • Breaking changes to existing PersistentComponentState public 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:

  1. Declarative prerendering context filtering - Testing RestoreBehavior.SkipInitialValue prevents restoration during prerendering
  2. Server reconnection context filtering - Testing RestoreBehavior.SkipLastSnapshot prevents state restoration during reconnection
  3. Enhanced navigation state updates - Testing AllowUpdates = true enables 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
  • ComponentStatePersistenceManager collects 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.js extracts 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() with RestoreContext.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() or TryTakeFromJson():
    • 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 = true are one-time only and removed after execution
  • Properties without explicit RestoreBehavior.SkipInitialValue are 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.js attempts 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() with RestoreContext.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() or TryTakeFromJson():
    • 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 = true are one-time only and removed after execution
  • Properties without explicit RestoreBehavior.SkipLastSnapshot are 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() with RestoreContext.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() or TryTakeFromJson():
    • 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 = true are recurring and remain active across multiple navigations
  • Only properties with explicit AllowUpdates = true receive state updates during enhanced navigation
  • Properties without the attribute maintain their current values and are not updated
  • The component lifecycle uses OnParametersSet() rather than OnInitialized() 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 contexts
  • options: 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:

  1. Store callback with its associated options in internal collection
  2. Determine callback lifecycle based on options.AllowUpdates property
  3. If current context matches options, invoke callback immediately
  4. 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 state
  • context: Context for this restoration event

Outputs: None (internal method)

Implementation steps:

  1. Replace internal state dictionary with provided state
  2. Invoke callbacks based on context filtering logic:
    • Check context.ShouldRestore(options) for each callback
    • Invoke callbacks that match the current context
  3. Determine callback lifecycle:
    • For callbacks without AllowUpdates = true: Unregister after execution
    • For callbacks with AllowUpdates = true: Keep registered for future updates

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 data
  • context: 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:

  1. Load state data from store
  2. For each component with persistent state:
    • Call UpdateExistingState with context
    • Apply declarative restoration filters based on context
  3. 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 state
  • LastSnapshot: Used when restoring from a previous session
  • ValueUpdate: 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 with RestoreBehavior and AllowUpdates properties

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:

  • PersistentStateAttribute extends CascadingParameterAttributeBase
  • Extend value provider to check for RestoreBehavior and AllowUpdates properties
  • 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
  • AllowUpdates enables 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:

  1. Update updateWebAssemblyRootComponents function signature:
// Before
export function updateWebAssemblyRootComponents(operations: string): void

// After
export function updateWebAssemblyRootComponents(operations: string, persistentState?: string): void
  1. Update internal updateRootComponents assignment:
// 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);
  1. Update scheduleAfterStarted function:
// Before
async function scheduleAfterStarted(operations: string): Promise<void>

// After
async function scheduleAfterStarted(operations: string, persistentState?: string): Promise<void>
  1. 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:

  1. Ensure updateRootComponents sends 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);
  1. 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));
        }
    }
}

javiercn avatar Jul 01 '25 17:07 javiercn