Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Blazor] Persisting circuit state for Blazor applications #60494

Open
javiercn opened this issue Feb 19, 2025 · 9 comments
Open

[Blazor] Persisting circuit state for Blazor applications #60494

javiercn opened this issue Feb 19, 2025 · 9 comments
Labels
area-blazor Includes: Blazor, Razor Components design-proposal This issue represents a design proposal for a different issue, linked in the description

Comments

@javiercn
Copy link
Member

javiercn commented Feb 19, 2025

Summary

We want to build on top of the existing functionality provided for preserving application state to enable opting in to hibernating server circuits under several circumstances and restoring the hibernated sessions afterwards:

  • The connection to the client has been lost. This can happen for multiple reasons:
    • Mobile app switched app and the OS terminated the connection.
    • Other tab was opened and the browser throttled the tab.
    • The user is on a location with a spotty connection.
  • The circuit has not been interacted with for a given amount of time (no event has been dispatched, no .NET interop call from the client has been received, no render update has been sent)
  • The client deems that the user is not interacting with the application and wants to proactively hibernate the circuit to save resources.
  • Proactively by the server for some other reason (like the server is restarting).

Persisting the server state is always an opt-in, best effort, progressive enhancement. The persisted state is not guaranteed to be recoverable, and in that case, the app falls back to the previous experience of loosing the state.

Motivation

Circuits reside in memory for their entire lifetime in a single server instance, when the connection to the client is lost, we keep a certain amount of circuits in memory for a given time to allow sessions to resume once the conection is re-stablished. However, if the amount of disconnected circuits goes above a threshold new disconected circuits are inmediately discarded and clients loose all their work. When the conection is lost for longer than the circuit is retained for, the circuit is again discarded.

When a circuit is discarded the session automatically goes away from memory and can't be recovered, causing users to loose all their unsaved work. This is specially true when the user is on a mobile platform like a phone or tablet. In this situation, when switching away from the browser application the connection with the server is normally terminated resulting in the loss of any unsaved work in the majority of cases.

There are other factors that contribute to potential information loss on circuits, like a server restarting, in which case all the circuits in that process are discarded, resulting on the work being lost.

Another important scenario is when a session is left opened but unused, for example when a user leaves their browser opened before going home. In that scenario the circuit is kept alive consuming resources that can be used for serving other users.

Goals

  • Provide the ability to hibernate a circuit and restore a session after the original circuit was discarded from memory.
  • Provide the ability to proactively hibernate a circuit from the server.
  • Enable library authors to create persist friendly components that can be leveraged by application developers.
  • Enable library authors to create storage mechanisms for persisting the state of the circuits.
  • Enable application developers to create more reliable apps without having to manually handle the details of persisting the application state.

Non-goals

  • Automatically hibernating and waking up circuits based on user activity.
  • Guaranteeing that the state is recoverable in all cases
  • Persisting state after each user interaction (we aren't reimplementing webforms).
  • Changing affinity requirements on Blazor Server applications.
  • Application "upgrade" scenarios (N->N+1 deployments) and reboots.

Scenarios

Server reboot

As a developer I need to reboot/update my application/operating system/container periodically. When its time for the application to update, I need to shutdown the existing application and I want to migrate existing user sessions to a different server while I perform the update. Users might get a notification about their work being partially interrupted but they can resume their session in a separate endpoint while the updates are being applied on the server.

The flow for this scenario is as follows:

  • The developer registers a service in startup to hibernate circuits to persistent storage.
  • The developer registers a service to be notified when the server is shutting down gracefully.
  • When the server emits the notification that the server is shutting down, it can access the list of existing circuits and trigger their hibernation.
  • The circuit state can be saved on the server or optionally sent to the client as part of the hibernation process (developer has the choice to decide).
  • For each hibernated circuit, the client receives a notification about the hibernation, so that the experience on the UI can be adapted (display the connection lost UI, or a different UI, enable JS components to be notified of the situation to avoid sending events to the server, etc).
  • When the hibernation is initiated by the server, the client can decide when to start the "resume" process, for example via a button on the UI or after a period of time.
  • To restart the process the client sends a message to the server with the circuit id, the original component descriptors and the persisted state if it was stored on the client.
  • When a server receives a "resume" message, it fetches the application state if necessary, restores it and re-renders the set of components (triggers an "attach component message") as well as sends the render batch for the rendered components.
  • When the client receives the first render batch after a "resume" operation, it needs to clear the component node content before re-applying the changes to the root component.
  • After this is done, the application is free to resume.

Connection lost for a longer period of time

As a developer I want to provide an improved experience on mobile browsers where its common that the connection is lost when a user switches from the browser app to a different app and comes back after a while. I want to be able to get a notification when the circuit is going to be discarded and to get the opportunity to save the circuit state into more permanent storage so that the session can be resumed afterwards when the user switches back to the browser.

Proactively hibernating circuits

As a developer I want to have a mechanism that enables me to hibernate circuits that I deem inactive to preserve server resources and enable customers to resume their session afterwards.

Detailed design

Abrupt disconnection

In this scenario, the connection from the server and the client is lost abruptly. After the initial disconnection period, when the circuit is going to be evicted from memory, a new callback is triggered to persist the circuit state. At that point, the server collects the list of root components and their parameters, as well as any state within the circuit that the app developer wants to persist, and pushes it to some storage mechanism. The details about this storage mechanism are described later in the document.

If the client is still running and tries to re-connect to the server, the server first checks if the circuit is on the disconnected pool, and if not, it performs an additional check to see if there was state persisted for that circuit. If there was, the server creates a new circuit, instantiates all the root components with the given state, attaches the components to the DOM and sends a render batch to the client to re-render the components.

sequenceDiagram
  participant Client
  participant Server

  Client->>Server: Connection lost
  Server->>Server: Check if circuit is in disconnected pool
  alt Circuit in disconnected pool
    Server->>Client: Resume session
  else Circuit not in disconnected pool
    Server->>Server: Check if state is persisted
    alt State is persisted
      Server->>Server: Create new circuit
      Server->>Server: Instantiate root components with state
      Server->>Client: Send render batch to re-render components
    else State is not persisted
      Server->>Client: Unable to resume session
    end
  end
Loading

Collaborative disconnection

In this scenario, the client and the server have an active connection. The developer might choose to hibernate a given circuit based on some criteria, like the circuit not being interacted with for a given amount of time, the window not being visible in the browser, etc.

We will provide APIs for the developer to trigger the hibernation process for a given circuit. The developer is free to choose what criteria to use to trigger the hibernation. Some options are:

  • Send a JS interop call to the server when something happens in the browser (like the window not being visible).
  • On the server, respond by hibernating the circuit.
  • Use a CircuitHandler to monitor the circuit and trigger the hibernation process if no interaction is detected (no events, no JS interop).
  • Monitor the application lifetime and trigger the hibernation process when the application is about to be shut down.

In the abrupt disconnection scenario, the server is the one that triggers the hibernation process and is forced to save that state to some storage mechanism. In the collaborative scenario, given that there is an active connection, the server might choose to push the state to the client. When the reconnection happens, the client can send the state back to the server to resume the session.

sequenceDiagram
  participant Client
  participant Server

  Client->>Server: Trigger hibernation
  Server->>Server: Persist state
  Server->>Client: Push state to client
  Server->>Server: Cleanup circuit
  Client->>Server: Reconnect (+ state)
  Server->>Server: Create new circuit
  Server->>Server: Instantiate root components with state
  Server->>Client: Send render batch to re-render components
Loading

Defining what state to persist

The data to persist can come from two locations:

  • Component state:
    • This is state that the component is using to render, for example, it might be a list of items retrieved from the database, or a form that the user is filling out.
  • Scoped services:
    • This is state that is hold on inside a service, it might be something like the current user, or any other similar piece of state.

Persisting state for components

Persisting state for components works by annotating properties in the component with the [SupplyFromPersistentComponentState] attribute. This attribute is a marker for a new CascadingValueParameter that is provided by the framework to the component. The framework uses the available PersistentComponentState (if there) to provide the value to the component, and registers a callback to persist the state when the circuit is going to be hibernated. The same cascading value provider takes care of unsubscribing the component if the component is removed from the component tree.

@if(Items == null)
{
  <div>Loading...</div>
}
else
{
<ul>
    @foreach (var item in Items)
    {
        <li>@item.Name</li>
    }
</ul>
}

@code {
    [SupplyFromPersistentComponentState]
    public List<Item> Items { get; set; }

    protected override Task OnInitializedAsync()
    {
        Items ??= await LoadItemsAsync();
    }
}

By default, the data needs to be JSON serializable. A hook to customize the serialization/deserialization process will be available to support alternative formats and customization.

We also require a key under which we store each persistent component state entry. In the case of components, we are going to use the parent component type + (@key if avilable) + component type + Property name. We use these four properties as a way to "pseudo-uniquely" identify a component inside the component tree.

This is a simplification over the more "correct" behavior that would require us to traverse the component tree to create a truly unique key. However, we already use this approach in other areas of the framework, like preserving components during enhanced page navigation, and it has proven to be good enough. If we need, in the future, we are free to change this approach to a more robust one.

With the current approach, a conflict with the keys can only happen if there are multiple instances of the same component rendered under the same parent component. The most common case for this is when rendering a component inside a loop (for/foreach). When this happens, there are a couple of ways to address the situation:

  • Move the state to be persisted into the parent component, and provide that state to the children.
    @foreach (var item in Items)
    {
      <ChildComponent Item="item" />
    }
    @code {
      [SupplyFromPersistentComponentState]
      public List<Item> Items { get; set; }
    }
  • Use a @key to provide a unique identifier for each component instance (something you should be doing anyway to help Blazor with rendering).
    • The moment you provide a key, we use can use it as input to uniquely identify the component.
    • Even in the cases where you are using some data from your model to generate the key (like an ID property) you can still append some unique identifier to the key to ensure uniqueness in that call site (you might even want to receive that unique identifier as input to your component)
    @foreach (var item in Items)
    {
        <MyComponent @key="@($"unique-prefix-{item.Id}")" Item="item" />
    }
  • Persist data imperatively.
    • This is always an option available with the current PersistingComponentState API, and for advanced use cases where more control is needed is the right choice.
    • For example, when implementing controls as part of a library where you want to allow the consumer to control if you should be persisting the state or not, and to give them control over the key to use and how that state is persisted.

Persisting state for scoped services

Persisting scope for services works by letting the service take an instance of PersistentComponentState as a parameter and using an extension method within the constructor to setup the callback to persist the state in case the circuit goes away. This same mechanism registers data to ensure that the service is re-instantiated, and the state is restored when the circuit is re-created.

The state to be persisted is identified as the public properties on the service that are annotated with [SupplyFromPersistentComponentState].

public class MyService
{
    public MyService(PersistentComponentState persistentState)
    {
        persistentState.PersistState(this)
    }
}

How is state persisted

Persisting state builds on top of the existing PersistentComponentState API used for persisting component state to the interactive render modes during prerendering of the application. In this way, the work that the user does to annotate components and services for a better prerendering experience can be reused in this context as well as with enhanced navigation (in the future).

Persistence stores

The framework will provide several built-in state persistence locations to store the state of the circuits:

  • BrowserStore: Will persist the state to the client when a connection is available.
  • InMemoryStore: Will persist the state in memory on the server. This acts as a second level of cache after the circuit has been evicted.
  • AzureBlobStore: Will persist the state to an Azure Blob Storage account.
  • RedisStore: Will persist the state to a Redis instance.
  • EntityFrameworkStore: Will persist the state to a database using Entity Framework.

Browser store

The browser store is only available in collaborative disconnection scenarios. The store will use the Data protection APIs to encrypt the state before sending it to the client, where the client will hold on to the state in memory until/after it tries to resume the session.

In memory store

This will store the state in memory on the server, with a configurable expiration time, and is a default fallback mechanism for abrupt disconnections after the circuit has been evicted. We think that it is advantageous to support this over keeping the circuit in memory for a longer time as it should require far less memory.

The current implementation will rely on MemoryCache, but it is possible that in the future we can instead rely on HybridCache to provide a more robust solution. The reason to use MemoryCache is that it is part of ASP.NET, which HybridCache is not.

The in-memory store has limits in terms of number of entries as well as the length for each of those entries.

Azure Blob Store, Redis Store, Entity Framework Store

These are all similar to the equivalent Data Protection storage providers, and will store the state in the respective storage mechanism. The developer will need to provide the necessary configuration to use these stores.

Risks

  • Failing to persist the state to a third-party storage system after the circuit has been evicted.

    • This might happen if a third-party store becomes unavailable after we've persisted the state and before we've evicted the circuit.
    • We can allow multiple storage mechanisms that are used in priority order, so that if one fails, we can try the next one.
    • Ultimately, it's acceptable if the state gets lost at that point, as the experience then becomes equivalent to the disconnected circuit scenario in the past.
  • Failing to restore the state when the circuit is re-created.

    • This can happen if for example, the storage mechanism is not available at the time of restore.
    • This can also happen if the application doesn't re-render the same component tree given the parameters and the state it stored.
  • Developers storing too much state:

    • It's up to the developer to choose and control how much they want to store. We can provide guidance and metrics to help developers make the right choice.
  • Inconsistent state persisted:

    • The data can't be partially persisted. We will always data protect the state (except maybe for pure in memory scenarios). That guarantees the integrity of the data, as any change in the data will make it unreadable.
  • State is restored multiple times:

    • The browser drives the process to resume the circuit. At the time it requests the circuit to be restored, a persistent connection to the server has been established via SignalR. The server is only going to try to resume the circuit once and will produce an error on subsequent attempts if the resumption has already started.
    • Trying to resume an already active circuit has the same implications.

Drawbacks

This feature requires the developer to actively opt-in to the state it wants persisted and requires some level of configuration to get it enabled, as opposed to it happening without user intervention.

Considered alternatives

Automatically persisting the state for the entire component tree

This is deemed unfeasible because of the general inability to serialize random state on the circuit. The state can be anything, might not be serializable, or might be to expensive to serialize.

Open questions

Potential APIs and usage scenarios

The purpose of this section is not to bike-shed on the API design, but to provide a general idea of how the API might look like.

Configuring circuit persistence

services.AddRazorComponents()
  .AddInteractiveServerComponents()

By default no gesture is needed, client and in-memory storage are enabled by default.

Configuring an external storage mechanism

services.AddRazorComponents()
  .AddInteractiveServerComponents()
  .AddAzureBlobStoragePersistenceStore(options =>
  {
    ...
  });

Proactively evicting a circuit from the client

services.TryAddEnumerable(ServiceDescriptor.Scoped<CircuitHandler, MyCircuitHandler>());
public class MyCircuitHandler(IJSRuntime runtime) : CircuitHandler
{
  public override Task OnCircuitOpenedAsync(Circuit circuit, CancellationToken cancellationToken)
  {
    _circuit = circuit;
    await runtime.InvokeVoidAsync("registerCircuit", JSObjectReference.Create(this));
  }

  [JsInvokable]
  public async Task Evict()
  {
    await _circuit.EvictAsync();
  }
}
function registerCircuit(handler) {
  window.circuitHandler = handler;
}

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') {
    window.circuitHandler.invokeAsync('Evict');
  }
});
@javiercn javiercn added design-proposal This issue represents a design proposal for a different issue, linked in the description area-blazor Includes: Blazor, Razor Components labels Feb 19, 2025
@javiercn javiercn added this to the .NET 10 Planning milestone Feb 20, 2025
@pavelsavara
Copy link
Member

pavelsavara commented Feb 20, 2025

Goals

I would like to make data consistency explicit goal. Our implementation should not lead to corrupted state.

One such scenario is when 2 server nodes will be storing the state out of order.

  • Node A) connected, working stored state 1
  • Client lost connection
  • Client made local changes -> state 2
  • Client trying to re-connect
  • Node A) shutdown -> storing last known state 1 (pending, slow)
  • Node B) received re-connect
  • Node B) applied state 2
  • Node B) storing last known state 2 (fast, finished)
  • Node A) storing last known state 1 (overruled, finished)

This would lead to mismatch between client state and persisted state.

One of the possible solutions is optimistic concurrency, essentially counter in the client, which is increased with every message/event to the server.

If we detect that version of the persisted state is ahead what the node is trying to store we have choices of

  • kill the circuit as unrecoverable (this is easiest to implement, and good enough for phase 1)
  • reverting the client back to last known server state
  • re-playing the events from client (we would have to store any unconfirmed events in the client side for this)

Scenarios

Server reboot

  • Is there existing ASP event for shutdown we could register for ?
  • Perhaps we should define some SLA for the 3rd party storage system. Making sure that we would be able to fulfill the "pending shutdown time window" SLA

If we have 1K circuits in memory with 500KB state each, there would be 500MB of traffic and JSON serializing CPU cost for doing it.
It would be good to do some early POC of it's feasibility together with real Azure backend.

By default, the data needs to be JSON serializable.

JSON doesn't deal with data cycles well. Maybe we need to call that out of scope ?
I guess this is not compatible with many ORM's out there and would require app developer to create POCO layer.

How can we validate this is feasible/valuable for large portion of our user base ?
Do we have data about existing PersistentComponentState usage patters ?

A hook to customize the serialization/deserialization process will be available to support alternative formats and customization.

Should we figure out how to integrate this with EF ? Storing only "dirty" data ?

Browser store

This should probably be out of scope for "server shutdown" scenario.

Proactively evicting a circuit from the client

I probably didn't fully understand the eviction callback API. Is that on C# on server side or in the client ?

Anyway, I think there should be TTL configuration and automatic eviction implemented by us.

@javiercn
Copy link
Member Author

Thanks @pavelsavara for taking a look, I'll try to address your concerns below:

One such scenario is when 2 server nodes will be storing the state out of order.

Node A) connected, working stored state 1
Client lost connection
Client made local changes -> state 2
Client trying to re-connect
Node A) shutdown -> storing last known state 1 (pending, slow)
Node B) received re-connect
Node B) applied state 2
Node B) storing last known state 2 (fast, finished)
Node A) storing last known state 1 (overruled, finished)
This would lead to mismatch between client state and persisted state.

I understand what you are bringing up, but I don't think what we are describing here matches the design of the feature. Here is how the system will work in a solution that is similar to what you are proposing:

sequenceDiagram
    participant Client
    participant Server A
    participant Server B
    Client -> Server A: Start session (circuit-id)
    Client -> Server A: Trigger event
    Server A -> Client: Update UI
    Client -> Client: Connection lost, at this point the UI is frozen (displaying reconnection dialog)
    Server A -> Server A: Circuit disconnected
    Server A -> Server A: Circuit evicted (Persist state (circuit-id))
    par Reconnection
      Client -> Server B: Reconnect
      Server B -> Server B: Check disconnected circuit cache
      alt circuit-id is found in cache
        Server B -> Server B: Reconnect circuit
      else
        Server B -> Server B: Check persisted component state
        alt circuit-id is found in persisted state
          Server B -> Server B: Create new circuit with persisted state
          Server B -> Client: Reconnection sucessful
        else
          Server B -> Client: Reconnection failed
        end
      end
    and Server A restarting
      Server A -> Server A: Restart
      Server A -> Server A: Waiting for new circuits
    end
Loading

There are two key points that make what you are describing impossible:

  • The client doesn't have any local state, all state for Blazor Server applications is stored on the server. If this were to be the case, even when a client loses connectivity and reconnects, your app would be in a broken state.
  • Servers only start circuits at the request of clients, so if a server like Node A shuts down and comes back, is not going to try and start a circuit unless Client starts a connection and makes a reconnect request.

The scenario that you are describing is more in line with SPA applications where state is hold on to by the client, which can update it of its own accord. In Blazor server the entire state is maintained on the server, so the client can't modify the state in any way when it's not connected to the server, hence there's no state to be reconciliated between the two.

To put it in a different way, Blazor Server is more like a traditional SSR app, where you store all the state in memory inside session state.

Is there existing ASP event for shutdown we could register for?

Yes, ASP.NET has events for this, but we wouldn't register to persist circuits on shutdown automatically. The design enables you to implement this scenario but are not planning to do this out of the box. The shutdown time is configurable in ASP.NET Core, so they can adjust it as needed.

Perhaps we should define some SLA for the 3rd party storage system. Making sure that we would be able to fulfill the "pending shutdown time window" SLA

I'm not sure what you mean by this, however, this is up to the application developer that needs to measure and adjust settings if necessary for their specific app.

To help clarify things, the only scenario that we handle automatically out of the box is the abrupt disconnection scenario (when the client loses connection for too long). In any other case, we give you the APIs and samples, but we don't implement anything automatically.

If we have 1K circuits in memory with 500KB state each, there would be 500MB of traffic and JSON serializing CPU cost for doing it.
It would be good to do some early POC of it's feasibility together with real Azure backend.

Sure, we'll do some testing and offer numbers, with that said, I'll add a bit more detail. The cost here comes from:

  • Pushing the data through the network to a 3rd party store.
  • Serializing the data.
  • Processing by 3rd party storage system

For all these things we don't offer any "guarantee" as they all scale based on your hardware and specific deployment setup. What we care about in these cases is how does your system scale:

  • For serializing data is whatever CPU you have on your server, so you can bump to a larger instance if you need it.
  • For sending data through the network, is the network bandwidth between your server and your storage backend.
  • For handling the storage, the storage can horizontally be scaled out, as all the data can be easily shared by circuit id.

To put some numbers in context, here is an article from Azure Blob Storage https://azure.microsoft.com/en-us/blog/high-throughput-with-azure-blob-storage/

Data set Time to upload Throughput
1,000 x 10MB 10 seconds 1.0 GB/s
100 x 100MB 8 seconds 1.2 GB/s
10 x 1GB 8 seconds 1.2 GB/s
1 x 10GB 8 seconds 1.2 GB/s
1 x 100GB 58 seconds 1.7 GB/s

If we extrapolate these numbers to the ones you proposed, it takes .5s to send and write the 500MB.

JSON doesn't deal with data cycles well. Maybe we need to call that out of scope ?
I guess this is not compatible with many ORM's out there and would require app developer to create POCO layer.

Yes, serializing graphs is out of scope and being JSON serializable with default settings is a general invariant for Blazor for anything that requires serialization in Blazor.

  • Root component parameters
  • Persistent component state
  • JS interop

Our guidance is clear in this sense that this is a requirement and that if you need to do something that is not supported, you need to handle serialization and deserialization of that payload yourself. In particular for this feature, we are not adding any new constraint, we are reflecting the existing constraints in PersistentComponentState.

How can we validate this is feasible/valuable for large portion of our user base ?
Do we have data about existing PersistentComponentState usage patters ?

We have a hard time evaluating these types of things because it's not usually possible for us to get this data.

Should we figure out how to integrate this with EF ? Storing only "dirty" data ?

We can't only store "dirty" data because otherwise when the app is resumed from persisted state it wouldn't render the same UI or would need to fetch the data from the original data source again, which would likely introduce a flicker on the UI.

Ultimately, it's up to the developer to decide what state they want to save.

Proactively evicting a circuit from the client
I probably didn't fully understand the eviction callback API. Is that on C# on server side or in the client ?

I'll try to clarify this a bit further. The only scenario that we handle out of the box is "abrupt disconnection" which is when you lose connection for long enough that we end up discarding the circuit.

For any other scenario ("collaborative disconnection") we offer an API on the Server to start the process. Using that API, you can build whatever policy you want to proactively start this process. For example, by:

  • Keeping track of the circuit activity through a circuit handler to hibernating idle circuits/sessions.
  • Hooking up to the application lifetime events on the server to hibernate circuits before a shutdown.
  • Reacting to events that happen in the browser (like the app switches tab or minimizes the window).

All these three examples and more, we make possible for you to implement, but we don't ship any of them out of the box.

Anyway, I think there should be TTL configuration and automatic eviction implemented by us.

Yes, this is handed already by the caching abstractions that we have in ASP.NET core, but I should have mentioned it explicitly. There are limits for all these things that we will also have to cover during threat modeling and security review.

Thanks again for taking a look. I think I've answered/clarified all the questions, but if there's anything that I missed or you want to further discuss, please let me know. As I work through more of the implementation, other details will surface, and I'll expand this from those docs.

@pavelsavara
Copy link
Member

There are two key points that make what you are describing impossible:

* The client doesn't have any local state

From browser user perspective, any key pressed is a new "state".

* Servers only start circuits at the request of clients

I still think that the new web-socket connection to server B could race the previous web-socket connection to server A

Also when you talk to storage backend via network and without transaction, network messages can get lost or race each other.

Adding version to each message is cheap and most stores support it.

@pavelsavara
Copy link
Member

We can't only store "dirty" data because otherwise when the app is resumed from persisted state it wouldn't render the same UI or would need to fetch the data from the original data source again

Are we going to have "restore" callback for the user to fine tune that DB fetch ?
I see this as very realistic for "colaborative scenario"

which would likely introduce a flicker on the UI.

This is because of element identity, right ? Can you please explain why this is not a problem for "out of the box persistence" ?

What the customers could do to avoid the flicker ?

For all these things we don't offer any "guarantee"

Agreed, but I would like to make sure there are use-cases in which it even makes sense for the customer to use.

@javiercn
Copy link
Member Author

From browser user perspective, any key pressed is a new "state".

That's not state from a Blazor Server perspective. The only state that matters is the one from the server. Once the app is disconnected no events are processed for the server so any keystroke, etc. is lost. This is how Blazor Server works today already, we aren't changing that as part of this feature.

I still think that the new web-socket connection to server B could race the previous web-socket connection to server A

This is not possible; the client will only try to connect to B after it has lost the connection to A, and won't try to reconnect to A, as it only handles 1 connection at a time.

Also when you talk to storage backend via network and without transaction, network messages can get lost or race each other.

Ordered delivery is provided by SignalR between the client and the server.
For the backend -> storage I don't know any system that used UDP or that doesn't have built-in support for guaranteed ordered delivery (basically all use TCP).

@javiercn
Copy link
Member Author

javiercn commented Feb 20, 2025

Are we going to have "restore" callback for the user to fine tune that DB fetch ?
I see this as very realistic for "colaborative scenario"

There is a well-defined pattern for this already. You can check if you have a value already and if not, perform the fetch. From the app point of view, there's no difference between a new app and a resuming app, the only delta is whether or not state has been restored.

@code {
    [SupplyFromPersistentComponentState]
    public List<Item> Items { get; set; }

    protected override Task OnInitializedAsync()
    {
        Items ??= await LoadItemsAsync();
    }
}

@javiercn
Copy link
Member Author

This is because of element identity, right ? Can you please explain why this is not a problem for "out of the box persistence" ?

It doesn't have anything to do with element identity, it has to do with the way Blazor renders

@if(Items == null)
{
  <div>Loading...</div>
}
else
{
<ul>
    @foreach (var item in Items)
    {
        <li>@item.Name</li>
    }
</ul>
}

@code {
    [SupplyFromPersistentComponentState]
    public List<Item> Items { get; set; }

    protected override Task OnInitializedAsync()
    {
        Items ??= await LoadItemsAsync();
    }
}

This produces two renders when there is no data and we call LoadItemsAsync, one with no data, after the synchronous part of LoadItemsAsync finishes, another after the async part completes.

If your app was already showing data, when the app is restored and goes through the render cycle, it will first render the loading screen for a split second, then once again after the data is loaded, that's where the flickering happens.

@pavelsavara
Copy link
Member

@if(Items == null)
{
  <div>Loading...</div>
}

Could we make sure that Items == null never happens after it was marked persistent ?
We could run the "restore" callback before we run the first render ?

@MackinnonBuck
Copy link
Member

MackinnonBuck commented Feb 20, 2025

We also require a key under which we store each persistent component state entry. In the case of components, we are going to use the parent component type + (@key if available) + component type + Property name. We use these four properties as a way to "pseudo-uniquely" identify a component inside the component tree.

This is a simplification over the more "correct" behavior that would require us to traverse the component tree to create a truly unique key. However, we already use this approach in other areas of the framework, like preserving components during enhanced page navigation, and it has proven to be good enough. If we need, in the future, we are free to change this approach to a more robust one.

Enhanced navigation is a little different because you still have the old DOM to compare against. So we can pretty easily avoid, for example, merging two components that reside at different depths in the DOM tree. I would think that key ambiguity would be much more common in the state persistence case. For example, imagine you had a file explorer app with a <Directory /> component that could render one or more child <Directory /> components. Unless you carefully craft a unique @key or persist data in a different component (or imperatively), there's bound to be ambiguity when restoring component state. Not saying the approach is invalid, I'm just noting that it's not an apples-to-apples comparison with enhanced navigation and key conflicts might be a common occurrence.

When a key conflict does occur, would we treat this as an error, as we do today? I would assume this to be the case, but just want to check. If so, this means that in addition to potentially happening more frequently, key conflicts would be more severe in state persistence than they are in enhanced navigation.

The browser store is only available in collaborative disconnection scenarios. The store will use the Data protection APIs to encrypt the state before sending it to the client, where the client will hold on to the state in memory until/after it tries to resume the session.

I see that "upgrade" scenarios are listed as a non-goal, which I agree with. When using the browser to store persisted state, should we actively prevent that state from being used to reconnect to a "newer" version of the app? There might be a risk where the updated app changes its assumptions about which values it expects for persisted properties, but the computed component keys don't change, and this causes unexpected or invalid state to get supplied to a component. The framework might be able to help with clearing caches it has control over (i.e., those directly accessible by the server), but we might want to know if persisted state from the browser comes from an older version of the app so that the server can discard it. Edit: Or maybe you could just generate a new data protection key so that old state automatically becomes indecipherable.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-blazor Includes: Blazor, Razor Components design-proposal This issue represents a design proposal for a different issue, linked in the description
Projects
None yet
Development

No branches or pull requests

3 participants