Der Zustand einer Blazor-App liegt an mehr Orten, als es aussieht: in Feldern, in Diensten, in der Seite, die der Server geschickt hat, und im Speicher einer Verbindung, die jederzeit abreißen kann. Diese Lektion folgt ihm durch alle Übergänge, vom Vorrendern zur Insel, vom Server in den Browser und über eine abgerissene Verbindung hinweg, und sagt für jeden, was überlebt.

Wo die Kladde steht

Der Wochenkalender aus K2 2.5.8 ist eine Server-Insel unter /intern/kalender mit [PersistentState] auf Woche und Terminen; die Zustandsdienste aus K2 2.5.11 sind Scoped. Neu ist hier: Der Kalender bekommt einen Filter nach Schlagwort (Tags der Kontakte), und Woche und Filter sollen das Vorrendern und einen Verbindungsabbruch überstehen. Behalten Sie Ihre Namen und übertragen Sie die Schritte.

Das doppelte Laden und was die Seite trägt

Warum eine interaktive Komponente zweimal lädt, steht in K2 2.5.8. Neu ist ein Blick darauf, wo der gerettete Zustand liegt: als Kommentar am Ende der vorgerenderten Seite, bei Server und WebAssembly auf sehr verschiedene Art.

Im Wegwerfprojekt dieser Lektion ruft der Kalender seine Quelle beim Vorrendern genau einmal auf (ein Test zählt die Aufrufe), und die Seite trägt den Kommentar <!--Blazor-Server-Component-State:…-->, dessen Wert mit CfDJ8 beginnt, dem Kennzeichen von verschlüsseltem Data-Protection-Text; der Inhalt des Termins ist darin nicht zu finden. Bei einer WebAssembly-Seite steht stattdessen <!--Blazor-WebAssembly-Component-State:…-->, und der Wert ist Base64 von JSON (Abschnitt „Was nie in den Browser darf“). Laut Learn schützt bei Interactive Server ASP.NET Core Data Protection die Daten; bei WebAssembly liegen sie dem Browser offen und dürfen nichts Sensibles enthalten. InteractiveAuto zählt als WebAssembly.

[PersistentState] und seine Optionen

Die Regeln des Attributs aus Learn, in der Reihenfolge, in der sie beißen:

  • Öffentliche Eigenschaften. Das Framework findet sie über Reflection, auch für Trimmen und Quellgeneratoren; private geht nicht.
  • null heißt „nicht da“. Die Komponente prüft mit ??= oder is null, ob sie laden muss.
  • System.Text.Json mit Standardeinstellungen; nicht trimmsicher, die Typen müssen erhalten bleiben (W8.3.1).
  • Mehrere Komponenten desselben Typs brauchen @key.
  • Eigene Wege. PersistentComponentStateSerializer<T> ersetzt das JSON für einen Typ; der Dienst PersistentComponentState bietet RegisterOnPersisting und RegisterOnRestoring.

Seit .NET 10 hat das Attribut zwei Optionen:

C#
// Read-only data that may change with each enhanced navigation to the same page.
[PersistentState(AllowUpdates = true)]
public WeatherForecast[]? Forecasts { get; set; }

// Not restored when the circuit first starts after prerendering.
[PersistentState(RestoreBehavior = RestoreBehavior.SkipInitialValue)]
public string? NoPrerenderedData { get; set; }

// Not restored after a reconnect: fresh data once the connection is back.
[PersistentState(RestoreBehavior = RestoreBehavior.SkipLastSnapshot)]
public int CounterNotRestoredOnReconnect { get; set; }

AllowUpdates erlaubt, den Zustand auch bei einer Enhanced Navigation zur selben Seite zu aktualisieren; sonst lädt eine Komponente ihn nur beim ersten Erscheinen, damit Eingaben nicht überschrieben werden. Es gehört an Daten, die man nur liest. SkipLastSnapshot ist die Antwort auf die Frage, ob nach einem Abbruch die alten Daten oder frische kommen sollen: Der Filter einer Person gehört zu ihr und soll zurückkommen, die Terminliste soll neu gelesen werden.

Beim Wert gilt eine Grenze, die Learn eigens nennt: Ein zu großer vorgerenderter Zustand kann das Nachrichtenlimit von SignalR überschreiten (die Voreinstellung von MaximumReceiveMessageSize ist 32 KB). Dann startet der Schaltkreis nicht („Circuit host not initialized“). Learn rät, weniger zu persistieren, und warnt, dass ein höheres Limit das Risiko von Denial-of-Service-Angriffen steigert. Persistieren Sie Schlüssel und Eingaben, nicht ganze Tabellen.

Zustandsdienste: RegisterPersistentService

Zustand, der in einem Dienst liegt (wie der Filter, den mehrere Komponenten des Kalenders teilen), bekommt dasselbe Attribut an der öffentlichen Eigenschaft des Dienstes und eine Registrierung mit dem Modus, in dem der Dienst gebraucht wird. CalendarState.cs des Wegwerfprojekts:

C#
public sealed class CalendarState
{
    [PersistentState]
    public string? TagFilter { get; set; }

    [PersistentState]
    public DateOnly? Monday { get; set; }
}
C#
builder.Services.AddScoped<CalendarState>();

builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents()
    .AddInteractiveWebAssemblyComponents()
    .RegisterPersistentService<CalendarState>(RenderMode.InteractiveServer);

Learn nennt als Werte RenderMode.Server, RenderMode.Webassembly und RenderMode.InteractiveAuto (beide Welten); im Wegwerfprojekt hat RenderMode.InteractiveServer die Registrierung angenommen. Auf dem Server werden nur Scoped-Dienste persistiert. In WebAssembly muss derselbe Dienst im Projekt .Client als Singleton registriert sein: Der Zustandsverwalter im Browser holt seine Abhängigkeiten aus dem Wurzelanbieter, und ein Scoped-Dienst ergäbe zwei Instanzen, von denen die Komponenten die ungefüllte benutzen.

Was nie in den Browser darf

Im Wegwerfprojekt trug die vorgerenderte WebAssembly-Seite /intern/kontakte vier Einträge im Kommentar, jeder Wert Base64 von JSON. Ein Test hat sie entschlüsselt, nur mit Convert.FromBase64String und System.Text.Json:

[{"id":1,"name":"Anna Beispiel","company":"Muster GmbH"}]
{"claims":[{"type":".../claims/name","value":"Ada"},{"type":".../claims/role","value":"Staff"}],"nameClaimType":"...","roleClaimType":"..."}
[{"assembly":"Kladde.Web","fullTypeName":"Kladde.Web.CalendarState"},{"assembly":"Microsoft.AspNetCore.Components.Endpoints", ... }]
{"value":"CfDJ8…","formFieldName":"__RequestVerificationToken"}

(Die Typen der Ansprüche sind gekürzt.) Das sind der Zustand der Seite, der Anmeldezustand (nur Name und Rolle, W8.2.1), die Liste der persistierten Dienste und das Antifälschungs-Token des Frameworks, alles lesbar für jeden, der die Seite öffnet. Daraus folgt eine Regel für jede Eigenschaft mit [PersistentState] in einer WebAssembly- oder Auto-Komponente: Alles, was dort steht, gehört dem Browser. Nicht dorthin gehören Zugriffstoken und Geheimnisse (sie bleiben auf dem Server), Daten anderer Personen, interne Felder eines Datensatzes (darum ContactResponse mit drei Feldern statt der Entität) und Daten, die nur bestimmte Rollen sehen dürfen. Was eine Komponente vorgerendert aus einem Dienst liest, steht in der Seite, bevor der Browser überhaupt anfragt; die Rechte gehören in die API (W8.2.1).

Der Lebenslauf eines Schaltkreises

Ein Schaltkreis entsteht, wenn die erste Server-Komponente einer Seite gerendert wird, und lebt bis zum Schließen der Seite. Jeder Tab ist eine eigene Sitzung mit eigenem Schaltkreis. Dazwischen kann die Verbindung abreißen, und Learn beschreibt drei Ausgänge:

  1. Wiederverbunden. Bei einem kurzen Abbruch verbindet blazor.web.js sich wieder mit dem Schaltkreis, der auf dem Server noch steht, samt Zustand. Der Server hält getrennte Schaltkreise laut den Voreinstellungen von CircuitOptions höchstens 3 Minuten und höchstens 100 an der Zahl.
  2. Abgelehnt. Der Server ist erreichbar, kennt den Schaltkreis aber nicht mehr: Er war zu lange weg, der Server wurde neu gestartet, der Prozess recycelt oder der Schaltkreis abgestürzt. Der Zustand im Speicher ist weg; Learn vergleicht das mit dem Schließen und Öffnen einer Desktop-App.
  3. Fehlgeschlagen. Die Verbindung kam nicht zustande, wahrscheinlich ein Netzfehler; die Seite kann es erneut versuchen.

Bei mehreren Servern kommt hinzu: Der Schaltkreis liegt im Speicher genau eines Servers, die Wiederverbindung muss dorthin zurück (Sticky Sessions, W8.3.3). Die Anzeige dazu bringt die Vorlage mit: die Komponente ReconnectModal samt Stylesheet und Skript. Learn beschreibt die Zustände, die sie als CSS-Klassen und als Ereignis components-reconnect-state-changed meldet:

Zustand (detail.state) Bedeutung Übliche Antwort
show Verbindung verloren, die Anzeige erscheint Dialog öffnen
retrying ein neuer Versuch läuft Zähler zeigen
hide die Verbindung steht wieder Dialog schließen
failed Wiederverbinden gescheitert Blazor.reconnect() aufrufen
rejected der Server lehnt ab, der Zustand dort ist weg location.reload()
paused der Schaltkreis ist pausiert Fortsetzen anbieten: Blazor.resumeCircuit()

Weil ein Skript dazugehört und JavaScript in der Kladde TypeScript ist, steht es so da, reconnect.ts (strikt übersetzt):

TypeScript
type ReconnectState = "show" | "hide" | "retrying" | "failed" | "rejected" | "paused";

interface BlazorApi {
  reconnect(): Promise<boolean>;
  resumeCircuit(): Promise<boolean>;
}

declare const Blazor: BlazorApi;

export function watchReconnect(modal: HTMLDialogElement): void {
  modal.addEventListener("components-reconnect-state-changed", (event) => {
    const state = (event as CustomEvent<{ state: ReconnectState }>).detail.state;
    switch (state) {
      case "show":
        modal.showModal();
        break;
      case "hide":
        modal.close();
        break;
      case "failed":
        void Blazor.reconnect();
        break;
      case "rejected":
        location.reload();
        break;
      case "paused":
        void Blazor.resumeCircuit();
        break;
    }
  });
}

Nach rejected lädt die Seite neu und beginnt mit frischem Zustand: Was dann noch da sein soll, muss außerhalb des Speichers dieser Verbindung liegen. Die Zahl und den Abstand der Versuche stellt Blazor.start({ circuit: { reconnectionOptions: { maxRetries: 3, retryIntervalMilliseconds: 2000 } } }) ein (mit autostart="false" am Skript). Die voreingestellte Staffelung ist laut Learn ein Implementierungsdetail, das sich ohne Ankündigung ändern kann. Wann die Anzeige erscheint, hängt an den Zeiten von SignalR (Voreinstellungen: ClientTimeoutInterval 30 Sekunden, KeepAliveInterval 15 Sekunden); Zeitüberschreitungen sollen laut Learn mindestens das Doppelte des Keep-Alive-Intervalls betragen.

Zustand von Schaltkreisen (.NET 10)

Neu in .NET 10: Eine Sitzung kann einen langen Abbruch überleben oder gezielt pausiert werden, solange die Seite nicht neu geladen wird. Gedacht ist das für gedrosselte Tabs, App-Wechsel auf dem Telefon, Netzunterbrechungen und das Freigeben von Ressourcen: Ein getrennter Schaltkreis braucht laut Learn weiter Speicher, gespeicherter Zustand nur einen festen Betrag. Die Bedingungen nach Learn:

  • Es wirkt nur bei Interactive Server. Lädt die Person die Seite neu, ist der Zustand weg.
  • Der Zustand muss JSON-serialisierbar sein; Zyklen und ORM-Entitäten serialisieren möglicherweise nicht richtig.
  • Komponenten in Schleifen brauchen @key.
  • Persistieren Sie nur das Nötige. Es gibt keine Garantie: Scheitert es, bleibt es beim gewohnten Verhalten nach einem Abbruch.
  • Persistieren lässt sich nur App-Zustand, nie die Oberfläche selbst; den ausgeklappten Knoten eines Baums modellieren Sie als serialisierbaren Zustand.

Voreingestellt ist MemoryCache für eine einzelne App-Instanz mit bis zu 1.000 gemerkten Schaltkreisen für zwei Stunden. Beides ändert CircuitOptions:

C#
builder.Services.Configure<CircuitOptions>(options =>
{
    options.PersistedCircuitInMemoryMaxRetained = 2000;
    options.PersistedCircuitInMemoryRetentionPeriod = TimeSpan.FromHours(3);
});

Für mehrere Instanzen nennt Learn ein HybridCache als verteilten Speicher, mit der Aufbewahrung PersistedCircuitDistributedRetentionPeriod (voreingestellt acht Stunden);; ob der Schaltkreis darüber nach einem Neustart der App wieder aufgenommen wird, ist hier nicht gezeigt (offen). Gezieltes Pausieren geht im Browser mit Blazor.pauseCircuit() und Blazor.resumeCircuit(), zum Beispiel beim Ausblenden der Seite (visibilitychange, so in Learn). Pausieren speichert den Zustand im Speicher des Browsers und gibt den Schaltkreis auf dem Server frei; das Fortsetzen baut einen neuen Schaltkreis aus diesem Zustand.

Lebensdauer von Diensten je Modus

Wo ein Zustand lebt, hängt an der Lebensdauer des Dienstes. Der Rahmen aus Learn:

Lebensdauer Statisches SSR Interactive Server WebAssembly
Scoped eine Anfrage ein Schaltkreis (ein Tab) wie Singleton: es gibt keine Scopes
Singleton die ganze App die ganze App, alle Personen ein Browser-Tab
Transient je Anforderung je Anforderung je Anforderung
  • Ein Singleton auf dem Server teilen sich alle Personen. Learn rät ab, Zustand über Singletons zu teilen, außer mit äußerster Sorgfalt, weil Zustand zwischen Schaltkreisen lecken kann. Zulässig ist etwa ein Speichercache mit einem Schlüssel, den Nutzende nicht steuern. Zustand je Person gehört in Scoped.
  • Neuer Schaltkreis, neue Instanz. Ein neues Fenster, ein neuer Tab und ein Neuladen erzeugen einen neuen Schaltkreis und damit neue Scoped-Dienste.
  • Transient und IDisposable. Solche Dienste hält der Container bis zum Ende des Schaltkreises, ein Speicherleck; benutzen Sie sie nicht. Einen Scoped-Dienst mit der Lebensdauer einer Komponente liefert OwningComponentBase.
  • Datenbank. In einer Insel wäre ein Scoped-DbContext derselbe über die ganze Verbindung; K2 2.5.8 benutzt deshalb eine Fabrik.

Was das Vorrendern für Scoped heißt, beschreibt Learn nicht eigens; wir sichern alles, was überleben soll, mit [PersistentState] (offen: nicht gemessen).

Die Kladde: Woche und Filter überleben

Die Seite lädt nur, wenn nichts da ist, und liest die Termine nach einem Abbruch neu (SkipLastSnapshot), während Woche und Filter im Dienst bleiben, Kalender.razor des Wegwerfprojekts:

Razor
@page "/intern/kalender"
@attribute [Authorize(Roles = "Staff,Admin")]
@rendermode InteractiveServer
@inject IWeekSource Source
@inject CalendarState State

@code {
    // After a reconnect the entries are read again; the filter and the week come back from the snapshot.
    [PersistentState(RestoreBehavior = RestoreBehavior.SkipLastSnapshot)]
    public List<CalendarEntry>? Entries { get; set; }

    protected override async Task OnInitializedAsync()
    {
        State.Monday ??= new DateOnly(2025, 3, 3);
        Entries ??= await Source.LoadAsync(State.Monday.Value, State.TagFilter, CancellationToken.None);
    }
}

Übung 1: Wo ist der Zustand jetzt?

Der Kalender ist gebaut wie oben, der Filter steht auf „Beratung“. Sagen Sie vorher, ob der Filter danach noch „Beratung“ ist.

  1. Die Person drückt F5.
  2. Das WLAN fällt für 20 Sekunden aus.
  3. Die Person öffnet den Kalender in einem zweiten Tab.
  4. Der Server wird neu gestartet, die Person wartet mit offenem Tab.
  5. Jemand ändert CalendarState von Scoped zu Singleton, damit der Filter „überall“ ankommt.

Lösung 1

  1. Nein. Neuladen schließt den alten Schaltkreis und startet einen neuen; der Zustand von Schaltkreisen gilt nur, solange die Seite nicht neu geladen wird. Soll der Filter ein Neuladen überleben, gehört er in die Adresse oder in den Speicher des Browsers.
  2. Ja, wenn die Verbindung binnen der Frist wieder steht (bis zu 3 Minuten bei den Voreinstellungen): derselbe Schaltkreis, derselbe Dienst.
  3. Nein, ein zweiter Tab ist ein zweiter Schaltkreis mit eigenem Dienst.
  4. Der Speicher des Servers ist weg: Das Wiederverbinden endet mit „abgelehnt“, und die Seite lädt neu. Der Filter bleibt nur, wenn er außerhalb des Speichers liegt, etwa in einem verteilten Speicher (HybridCache, nicht ausgeführt, offen).
  5. Alle Personen teilen sich jetzt den Filter: Was die eine wählt, sieht die andere. Das ist das Leck, vor dem Learn warnt.

Übung 2: Zustand im Klartext und verschlüsselt

Legen Sie in einem Wegwerfprojekt CalendarState und Kalender.razor an, registrieren Sie den Dienst mit RegisterPersistentService und schreiben Sie einen Test mit WebApplicationFactory (eine Entwicklungsanmeldung genügt; W8.2.1 baut die richtige). Er soll prüfen: (a) Die Quelle wird beim Abruf von /intern/kalender genau einmal aufgerufen. (b) Der Kommentar Blazor-Server-Component-State enthält den Titel des Termins nicht. Sagen Sie dann voraus, was (b) bei einer WebAssembly-Seite ergäbe, und prüfen Sie es mit einer zweiten Seite.

Lösung 2

C#
string html = await client.GetStringAsync("/intern/kalender");

((CountingWeekSource)factory.Services.GetRequiredService<IWeekSource>()).Calls.ShouldBe(1);
string? state = StateComment(html, "Server");
state.ShouldNotBeNull();
state.ShouldNotContain("Beratung");

StateComment holt mit einem regulären Ausdruck den Text zwischen <!--Blazor-Server-Component-State: und -->. Bei einer WebAssembly-Seite ist der Wert Base64 von JSON, dessen Werte wieder Base64 von JSON sind; nach dem Entschlüsseln steht der Name des Kontakts im Klartext. Steht ein Wort im Kommentar, das nicht dorthin gehört, ist das ein Fehler der Gestaltung: Entweder ist die Seite die falsche Stelle dafür, oder die Daten gehören hinter eine API mit Rechten.

Stand der Prüfung. Gebaut und ausgeführt wurde ein Wegwerfprojekt außerhalb des Repositorys mit dem .NET-SDK 10.0.400 auf net10.0 (Server, .Client, Testprojekt mit xunit, Shouldly, bUnit und WebApplicationFactory); dotnet test endete mit dem Exit-Code 0 (Bestanden! : Fehler: 0, erfolgreich: 20, übersprungen: 0, gesamt: 20). Gesehen wurde: CalendarState mit RegisterPersistentService<CalendarState>(RenderMode.InteractiveServer) und [PersistentState(RestoreBehavior = RestoreBehavior.SkipLastSnapshot)] übersetzen; ein Abruf von /intern/kalender ruft die Quelle einmal auf und liefert den Kommentar Blazor-Server-Component-State, der mit CfDJ8 beginnt und den Titel nicht enthält; die WebAssembly-Seite lieferte vier Einträge, die sich mit Base64 und JSON entschlüsseln ließen (Kontaktliste, Name und Rolle, Liste der persistierten Dienste, Antifälschungs-Token). Die Optionen von [PersistentState] und die Konfiguration von CircuitOptions wurden übersetzt; reconnect.ts wurde mit TypeScript 5.9.3 strikt übersetzt (Exit-Code 0); pauseCircuit und resumeCircuit kommen nur im Text vor. Nicht ausgeführt: ein Browser, eine echte Verbindung, ein Abbruch, das Wiederverbinden, der Zustand von Schaltkreisen, HybridCache, Pausieren und Fortsetzen, die Wiederherstellung auf der interaktiven Seite (der Test sieht nur den vorgerenderten Teil) und Azure. Fristen und Limits stammen aus Microsoft Learn. Offen: Scoped beim Vorrendern, HybridCache nach einem Neustart, die Namen RenderMode.Server und RenderMode.Webassembly in Learn gegenüber RenderMode.InteractiveServer im Code.

Was Sie jetzt können

  • Sie nennen für jeden Übergang, welcher Zustand überlebt: Vorrendern, Wechsel zu WebAssembly, Abbruch, Neuladen, Neustart.
  • Sie setzen [PersistentState] mit AllowUpdates und RestoreBehavior ein und persistieren Zustandsdienste mit RegisterPersistentService.
  • Sie wissen, dass der Zustand bei WebAssembly im Klartext in der Seite steht, und halten Tokens, Geheimnisse und fremde Daten heraus.
  • Sie beschreiben Wiederverbunden, Abgelehnt und Fehlgeschlagen und steuern die Anzeige in TypeScript.
  • Sie ordnen den Zustand von Schaltkreisen ein und wählen die Lebensdauer von Diensten je Modus.

Die Kladde: Woche und Filter des Kalenders überstehen das Vorrendern, und der Kalender liest nach einem Abbruch seine Termine neu. Als Nächstes geht es um die Anmeldung, die in diesen Modi unterschiedlich ankommt.

Weiterlesen