Mert Özen Her Satırda Daha İleri
Frontend Blazor — #06

Prerendering ve State Persistence: Çift Yükleme Sorunu, [PersistentState] ve Circuit State'i

Mert Özen 15 Kas 2026 13 dk 6 görüntülenme
Prerendering ve State Persistence: Çift Yükleme Sorunu, [PersistentState] ve Circuit State'i

Prerendering açık interaktif bir component iki instance olarak çalıştığı için veri iki kez yüklenir; .NET 10'daki [PersistentState] prerender'da yüklenen değeri sayfaya gömüp interaktif instance'a taşır ve aynı attribute circuit duraklayıp geri geldiğinde state'i korur. Bookstore panelinde çift API çağrısını ve tablonun yanıp sönmesini ölçüp gideriyor, RestoreBehavior ile taze kalması gereken veriyi ayırıyor, WebAssembly'de persist edilen state'in sayfa kaynağında düz JSON olduğunu gösteriyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10 (SDK 10.0.404, ASP.NET Core 10.0.15) ile derlenip denenmiştir; tarayıcı davranışı Playwright 1.65'in headless Chromium'uyla gözlenmiştir.

Önce Tahmin Et

03. yazıda stok sayfasının log'unda OnInitializedAsync'in iki kez çalıştığını görmüş, sebebini sonraya bırakmıştık. Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) yönetim panelinde /inventory sayfası InteractiveServer ve prerendering açık. Sayfa ilk açıldığında Bookstore.Api'ye kaç istek gider: bir mi, iki mi? İkinci soru: kullanıcı ekranda ne görür; tablo bir kez mi çizilir, yoksa arada bir şey yanıp söner mi? Üçüncüsü: kullanıcı eşik değerini 12 yapıp "Only low stock" kutusunu işaretledikten sonra bağlantı bir süre koparsa ve geri gelirse, filtre yerinde kalır mı? Peki sayfayı yenilerse?

Çift Yükleme: Neden Olur, Kullanıcı Ne Görür

Prerendering açık bir interaktif component iki ayrı instance olarak yaşar. İlki sunucuda, Static SSR (Static Server-Side Rendering; component'in sunucuda render edilip düz HTML olarak gönderilmesi) gibi çalışır: lifecycle döner, HTML yanıta yazılır, instance atılır. Tarayıcı sayfayı gösterdikten sonra blazor.web.js circuit'i kurar ve aynı component'ten yepyeni bir instance oluşturulur. Yeni instance önceki instance'ın field'larından habersizdir; OnInitializedAsync baştan çalışır. 05'in sonundaki hâliyle stok sayfasını açtığımda panelin log'u şöyleydi:

info: InventoryPage: OnInitializedAsync (renderer: Static, interactive: False)
info: LogicalHandler: Start processing HTTP request GET http://localhost:5080/api/inventory
info: InventoryPage: Dispose: stopping the refresh timer
info: InventoryPage: OnInitializedAsync (renderer: Server, interactive: True)
info: LogicalHandler: Start processing HTTP request GET http://localhost:5080/api/inventory
info: InventoryPage: OnAfterRender(firstRender: true): starting the refresh timer

İlk sorunun cevabı: iki istek. LogicalHandler satırları typed HttpClient'ın kendi log'u; her biri Bookstore.Api'ye giden bir GET. Aradaki Dispose, prerender instance'ının atıldığı an. İkinci soru için Playwright'ta sayfaya bir MutationObserver koyup DOM (Document Object Model; tarayıcının sayfayı tuttuğu ağaç yapısı) her değiştiğinde sayfanın durumunu kaydettim:

    1 ms  table (Refreshed at 03:23:38 UTC)
   33 ms  Loading...
   35 ms  table (Refreshed at 03:23:38 UTC)

Kullanıcı önce prerender edilmiş tabloyu görüyor. Circuit kurulunca interaktif instance ilk render'ını stock henüz null'ken yapıyor ve tablo "Loading..." yazısıyla değişiyor; API cevap verince tablo geri geliyor. Benim makinemde API aynı bilgisayarda olduğu için bu ara 2 ms sürdü ve gözle zor seçiliyor. Gerçek bir ağda bu süre API'nin yanıt süresi kadar; 300 ms'lik bir API'de kullanıcı tablonun kaybolup geri geldiğini açıkça görür. Ayrıca iki çağrı arasında veri değişirse kullanıcı aynı sayfada iki farklı tablo görür. Maliyet tarafı da var: her sayfa açılışı API'ye ve veritabanına iki kat yük demek.

Prerendering'i kapatmak (new InteractiveServerRenderMode(prerender: false)) çift yüklemeyi bitirir ama ilk yanıtta içerik olmaz; 04. yazıda gördüğümüz gibi kullanıcı circuit kurulana kadar boş sayfaya bakar. Doğru çözüm, prerender sırasında yüklenen veriyi interaktif instance'a taşımak.

[PersistentState] ile Veriyi Taşımak

.NET 10'da bunun için tek bir attribute yeterli:

@code {
    private System.Timers.Timer? timer;

    // Prerender sırasında dolar ve interaktif instance'a aktarılır; circuit geri geldiğinde yeniden yüklenir.
    [PersistentState(RestoreBehavior = RestoreBehavior.SkipLastSnapshot)]
    public List<StockView>? Stock { get; set; }

    [PersistentState(RestoreBehavior = RestoreBehavior.SkipLastSnapshot)]
    public DateTime RefreshedAt { get; set; }

    // Kullanıcının seçimi; circuit duraklayıp geri geldiğinde korunur.
    [PersistentState]
    public InventoryFilter? Filter { get; set; }

    protected override async Task OnInitializedAsync()
    {
        Filter ??= new();
        Logger.LogInformation("OnInitializedAsync (renderer: {Renderer}, interactive: {Interactive}, restored: {Restored})",
            RendererInfo.Name, RendererInfo.IsInteractive, Stock is not null);
        if (Stock is null)
        {
            await LoadAsync();
        }
    }

[PersistentState] verilen public property'ler prerender bittiğinde JSON'a serileştirilip sayfaya gömülüyor. Interaktif instance oluşturulurken Blazor bu değerleri property'lere geri yazıyor; bu, OnInitializedAsync'ten önce oluyor. Kodda kalan tek iş Stock is null kontrolü: değer geldiyse API'ye gitme, gelmediyse (prerendering yoksa ya da state kaybolduysa) yükle. Doküman da aynı şeyi Items ??= await LoadItemsAsync(); kalıbıyla gösteriyor. Aynı sayfayı tekrar açtığımda:

info: InventoryPage: OnInitializedAsync (renderer: Static, interactive: False, restored: False)
info: LogicalHandler: Start processing HTTP request GET http://localhost:5080/api/inventory
info: InventoryPage: Dispose: stopping the refresh timer
info: InventoryPage: OnInitializedAsync (renderer: Server, interactive: True, restored: True)
info: InventoryPage: OnAfterRender(firstRender: true): starting the refresh timer
    4 ms  table (Refreshed at 03:25:21 UTC)

Tek istek, "Loading..." ara durumu yok, zaman damgası da aynı kaldı çünkü RefreshedAt de taşındı. Daha önce private field olan stock ve refreshedAt şimdi public property, threshold ile onlyLow da aşağıdaki Filter nesnesine taşındı; attribute yalnız public property'lerde çalışıyor.

.NET 9 ve öncesinde aynı işi PersistentComponentState servisini inject edip RegisterOnPersisting callback'iyle elle yazıyorduk: değeri bir key ile PersistAsJson, interaktif tarafta TryTakeFromJson ile okumak. Bu API hâlâ var ve daha fazla kontrol gerektiğinde kullanılıyor; attribute aynı mekanizmanın üzerine kurulu.

Bir Analyzer Uyarısı ve Filtre Nesnesi

Eşik değerini de taşımak için ilk denemem [PersistentState] public int Threshold { get; set; } = 5; oldu. Derleme şu hatayla durdu:

src/Bookstore.Admin/Components/Pages/Inventory/InventoryPage.razor(43,16): error BL0009: Property 'Bookstore.Admin.Components.Pages.Inventory.InventoryPage.Threshold' has [PersistentState] and a property initializer. This can be overwritten during parameter binding.

BL0009 aslında bir uyarı; projede TreatWarningsAsErrors açık olduğu için hata oldu. Uyarının söylediği şu: property initializer instance oluşurken çalışır, persist edilmiş değer ise sonra yazılır. Hangi değerin kazandığı sıraya bağlı ve okuyan kişi için belirsiz. Çözüm, varsayılanı OnInitializedAsync'te ??= ile vermek. Bunun için filtre küçük bir class oldu:

namespace Bookstore.Admin.Components.Inventory;

// Stok sayfasının filtresi; [PersistentState] ile taşınabilmesi için serileştirilebilir düz bir class.
public sealed class InventoryFilter
{
    public int Threshold { get; set; } = 5;
    public bool OnlyLow { get; set; }
}

Sayfada Filter ??= new(); satırı var: persist edilmiş bir filtre geldiyse o, gelmediyse varsayılan. Class'ın kendi property initializer'ları sorun değil; uyarı yalnız [PersistentState] verilen property'nin kendisi için.

RestoreBehavior: Hangi Değer Ne Zaman Geri Gelir

Yukarıdaki kodda Stock ve RefreshedAt'te RestoreBehavior = RestoreBehavior.SkipLastSnapshot var, Filter'da yok. Sebebi .NET 10'daki ikinci özellik: circuit state persistence. Bir InteractiveServer circuit'i kapandığında (uzun bir ağ kopması, arka plana alınan sekme, uygulamanın kendisinin circuit'i duraklatması) Blazor [PersistentState] property'lerinin son değerini sunucuda saklıyor. Kullanıcı geri döndüğünde yeni bir circuit kuruluyor ve component'ler bu değerlerle başlıyor. Varsayılan depolama MemoryCache: en fazla 1.000 circuit, iki saat. Birden fazla sunucu varsa HybridCache verildiğinde dağıtık depolamaya geçiyor ve süre sekiz saat.

Burada iki farklı ihtiyaç var. Kullanıcının seçtiği filtre geri gelmeli; stok verisi ise bir saat önceki hâliyle geri gelmemeli, çünkü bu sürede depoya kitap girmiş olabilir. SkipLastSnapshot tam bunu söylüyor: prerender'dan gelen değeri kullan, circuit yeniden kurulurken saklanan değeri atla. Tersi olan SkipInitialValue, prerender'dan gelen değeri atlayıp yalnız circuit dönüşünde kullanıyor. Bir üçüncü ayar da AllowUpdates; aşağıda enhanced navigation bölümünde.

Circuit'i elle duraklatmak için Blazor.pauseCircuit() ve Blazor.resumeCircuit() var. Playwright'ta eşik 12 ve "Only low stock" seçiliyken önce duraklatıp sonra devam ettirdim, en son sayfayı yeniledim:

inventory before pause: threshold=12, onlyLow=true, rows=2
pauseCircuit() -> true
resumeCircuit() -> true
inventory after resume: threshold=12, onlyLow=true, rows=2
inventory after reload: threshold=5, onlyLow=false, rows=20
info: InventoryPage: Dispose: stopping the refresh timer
info: InventoryPage: OnInitializedAsync (renderer: Server, interactive: True, restored: False)
info: LogicalHandler: Start processing HTTP request GET http://localhost:5080/api/inventory
info: InventoryPage: OnAfterRender(firstRender: true): starting the refresh timer

Üçüncü sorunun cevabı: bağlantı dönüşünde filtre yerinde, tabloda yine iki satır var. Log'da Dispose eski circuit'in atıldığını, restored: False stok verisinin bilerek atlandığını ve API'den taze okunduğunu gösteriyor. Sayfa yenilendiğinde ise her şey varsayılana döndü. Circuit state persistence yalnız aynı sayfa yaşarken işe yarıyor; tam sayfa yenileme yeni bir oturum demek. Kullanıcının tercihi yenilemeden sonra da kalmalıysa tarayıcı storage'ı ya da sunucuda bir kayıt gerekiyor; bunu 09. yazıda yapacağız.

Kitap Detayında Interaktif Not Alanı

Kitap detay sayfası 05'ten beri Static SSR. Depo ekibine not bırakmak için sayfaya küçük bir interaktif alan ekledim:

@inject StaffNoteStore Store
@inject ILogger<StaffNotes> Logger

<h2>Staff notes</h2>

@if (Notes is null || Notes.Count == 0)
{
    <p class="text-muted">No notes yet.</p>
}
else
{
    <ul>
        @foreach (StaffNote note in Notes)
        {
            <li>@note.CreatedAtUtc.ToString("yyyy-MM-dd HH:mm") UTC: @note.Text</li>
        }
    </ul>
}

<textarea class="form-control" rows="3" placeholder="A note for the warehouse team"
          @bind="Draft" @bind:event="oninput"></textarea>
<button class="btn btn-secondary mt-2" disabled="@(!RendererInfo.IsInteractive || string.IsNullOrWhiteSpace(Draft))"
        @onclick="AddNote">Add note</button>

@code {
    [Parameter, EditorRequired]
    public string Isbn { get; set; } = "";

    [PersistentState]
    public List<StaffNote>? Notes { get; set; }

    // Gönderilmemiş not: circuit duraklayıp geri gelince korunur, tam sayfa yenilemede kaybolur.
    [PersistentState]
    public string? Draft { get; set; }

    protected override void OnInitialized()
    {
        Logger.LogInformation("StaffNotes for {Isbn} (renderer: {Renderer}, restored notes: {Restored}, draft: {Draft})",
            Isbn, RendererInfo.Name, Notes is not null, Draft ?? "(none)");
        Notes ??= [.. Store.GetNotes(Isbn)];
    }

    private void AddNote()
    {
        Store.Add(Isbn, Draft!);
        Notes = [.. Store.GetNotes(Isbn)];
        Draft = null;
    }
}

Sayfada <StaffNotes Isbn="@book.Isbn" @rendermode="InteractiveServer" /> ile kullanılıyor; sayfanın geri kalanı Static SSR kalıyor, circuit yalnız bu component için kuruluyor. Notlar şimdilik panelin belleğindeki StaffNoteStore'da: Singleton bir service, bütün kullanıcılar aynı notları görüyor ve uygulama yeniden başlayınca notlar gidiyor. Kalıcı bir yer (Bookstore.Api'de bir endpoint) bu yazının konusu değil; Singleton'ın ne zaman doğru, ne zaman tehlikeli olduğuna 09'da bakacağız.

İki property persist ediliyor. Notes çift okumayı önlüyor. Draft ise kullanıcının henüz göndermediği not; circuit duraklayıp geri geldiğinde kaybolmasın istiyoruz. Butonun !RendererInfo.IsInteractive iken disabled olması prerender edilmiş HTML'de tıklanamayan bir butonun aktif görünmesini önlüyor. Aynı Playwright akışıyla:

book page before pause: notes=1, draft="Ask Grace about the reprint"
book page after resume: notes=1, draft="Ask Grace about the reprint"
book page after reload: notes=1, draft=""
info: StaffNotes: StaffNotes for 9780141439549 (renderer: Server, restored notes: True, draft: (none))
info: StaffNotes: StaffNotes for 9780141439549 (renderer: Server, restored notes: True, draft: Ask Grace about the reprint)
info: LogicalHandler: Start processing HTTP request GET http://localhost:5080/api/books/9780141439549
info: StaffNotes: StaffNotes for 9780141439549 (renderer: Static, restored notes: False, draft: (none))
info: StaffNotes: StaffNotes for 9780141439549 (renderer: Server, restored notes: True, draft: (none))

İlk satır ilk interaktif açılış, ikincisi duraklatma sonrasındaki yeni circuit: taslak geri geldi. Yenilemeden sonra taslak boş ama not duruyor, çünkü not Singleton store'da, taslak ise yalnız circuit'in state'inde.

Enhanced Navigation Sırasında Persist Edilen State

.NET 9'a kadar persist edilen state yalnız ilk sayfa yüklemesinde işe yarıyordu; enhanced navigation ile gelinen bir sayfada interaktif component veriyi yeniden yüklüyordu. .NET 10 sürüm notlarına göre artık enhanced navigation sırasında persist edilen state de interaktif component'lere ulaşıyor. Varsayılan olarak yalnız component sayfaya ilk geldiğinde okunuyor, böylece aynı sayfaya yapılan sonraki bir enhanced navigation kullanıcının doldurduğu bir formu ezmiyor. Salt okunur veride bunu [PersistentState(AllowUpdates = true)] ile açabiliyorsun.

Ben iki yoldan denedim: önce Static SSR kitap listesinden bir kitaba tıklayarak (henüz circuit yok), sonra stok sayfasından menüyle kitap listesine, oradan bir kitaba geçerek (circuit zaten açık). İkisi de enhanced navigation'dı, sayfa baştan yüklenmedi:

from the Static SSR book list (no circuit yet):
info: StaffNotes: StaffNotes for 9780143035008 (renderer: Static, restored notes: False, draft: (none))
info: StaffNotes: StaffNotes for 9780143035008 (renderer: Server, restored notes: True, draft: (none))
from the inventory page (circuit already running):
info: StaffNotes: StaffNotes for 9780143035008 (renderer: Static, restored notes: False, draft: (none))
info: StaffNotes: StaffNotes for 9780143035008 (renderer: Server, restored notes: False, draft: (none))

Circuit henüz yokken state geldi. Circuit zaten açıkken StaffNotes notları yeniden okudu; Notes'a AllowUpdates = true verdiğim bir denemede de sonuç değişmedi. Yanıtta Blazor-Server-Component-State yorum satırı vardı; yani state gönderiliyor ama benim senaryomda açık circuit'teki yeni component'e ulaşmadı. Bunun sebebini doğrulayamadım. Pratik sonuç şu: kodunu state'in her zaman geleceğine güvenerek yazma. ??= kalıbı iki durumda da doğru çalışıyor; en kötü ihtimalle veri bir kez daha okunuyor.

Persist Edilen State'in Güvenliği

State sayfanın içinde, HTML yorum satırı olarak gidiyor. InteractiveServer'da <!--Blazor-Server-Component-State:CfDJ8...--> şeklinde ve ASP.NET Core Data Protection ile şifreli; stok sayfasındaki 20 satırlık liste için 4.656 karakterlik bir değer, içinden kitap adı okunmuyor. WebAssembly'de durum farklı. 04'teki CartPreview'a deneme için 450 liradan pahalı kitapları tutan bir [PersistentState] property'si ekleyip sayfadaki Blazor-WebAssembly-Component-State değerini base64'ten çözdüğümde:

4m+M2KR0TmJkvFMb/ugdceATRSk6LXht1IvuC9imnzk= => [{"isbn":"9780441172719","title":"Dune","price":489.0},{"isbn":"9780140449266","title":"The Count of Monte Cristo","price":459.0},{"isbn":"9781400079988","title":"War and Peace","price":689.0}]

Değer düz JSON. Şifreleme yok, çünkü tarayıcıdaki .NET runtime'ın bunu çözebilmesi gerekiyor; çözebilen her kod onu okuyabilir. InteractiveAuto da ilk ziyarette Server, sonra WebAssembly olduğu için aynı kurala tabi. Kural basit: WebAssembly ya da Auto component'inde persist ettiğin her şey sayfa kaynağında okunabilir. Maliyet, kâr marjı, başka müşterinin verisi gibi şeyleri buraya koyma; zaten WebAssembly component'ine böyle bir veri gönderiyorsan sorun persist etmekten önce başlıyor.

Service'lerdeki state için RegisterPersistentService<T>(renderMode) var: service'in [PersistentState] property'leri component'lerdeki gibi taşınıyor. Doküman burada önemli bir ayrıntı veriyor: sunucuda service scoped olmalı, ama .Client projesinde aynı service singleton kaydedilmeli. Tarayıcı tarafındaki state yöneticisi singleton olduğu ve kök provider'dan çözümleme yaptığı için scoped kayıt ya Development'ta DirectScopedResolvedFromRootException fırlatıyor ya da iki ayrı instance oluşuyor ve geri yüklenen değer component'lerin kullandığı instance'a ulaşmıyor.

Üretimde Dikkat

Persist edilen state sayfanın boyutuna ekleniyor. Stok listesi 20 kitapta 4,6 KB tuttu; 2.000 kitapta HTML'e megabaytlar eklenir ve TTFB (Time To First Byte; ilk byte'ın gelme süresi) ile birlikte sayfanın indirme süresi uzar. Prerender'da yalnız ilk ekranda görünen veriyi yükle; sayfalama ya da filtreleme varsa yalnız o sayfayı persist et.

Circuit state persistence varsayılan olarak açık ve sunucu belleğinde duruyor: 1.000 circuit, iki saat. Bu sınırları CircuitOptions.PersistedCircuitInMemoryMaxRetained ve PersistedCircuitInMemoryRetentionPeriod ile değiştirebilirsin. Birden fazla instance'ın arkasında load balancer varsa kullanıcı başka bir instance'a düştüğünde bellekteki state orada yok; HybridCache ile dağıtık bir depolama (örneğin Redis) vermek gerekiyor. Circuit'in kendisi de ayrı bir sınırla yaşıyor: bağlantısı kopan circuit varsayılan olarak üç dakika bellekte tutuluyor. Denemelerimde tarayıcıyı kapattıktan sonra stok sayfasının 30 saniyelik timer'ının log yazmaya devam ettiğini gördüm; kapanan sekmenin circuit'i bu üç dakika boyunca çalışıyor. Ayrıntısı 12. yazıda.

Duraklatma bir ölçek aracı. Kullanıcı başka sekmedeyken circuit'i sunucuda tutmak yerine Blazor.pauseCircuit() ile state'i saklayıp circuit'i bırakabilirsin; doküman bunun için visibilitychange event'ini örnek veriyor. .NET 11 bunu otomatik yapan bir seçenek getiriyor; 13. yazıda.

Sık Düşülen Tuzaklar

[PersistentState]'i private bir property'ye vermek. Proje derleniyor ama sayfa açılırken patlıyor. StaffNotes'taki Notes'u deneme için private yaptığımda:

System.InvalidOperationException: A public property 'Notes' on component type 'Bookstore.Admin.Components.Books.StaffNotes' with a public getter wasn't found.
   at Microsoft.AspNetCore.Components.Infrastructure.PersistentValueProviderComponentSubscription.PropertyGetterFactory(ValueTuple`2 key)

Stok sayfasındaki field'ları bu yüzden public property'ye çevirdim.

Persist edilen property'ye initializer vermek. BL0009 bunu yakalıyor; varsayılanı lifecycle'da ??= ile ver.

Kontrol etmeden yüklemek. Stock = await Api.GetStockAsync(); yazarsan state gelse bile üzerine yazarsın ve çift istek geri gelir. Her zaman ??= ya da is null kontrolü.

Taze olması gereken veriyi RestoreBehavior vermeden persist etmek. Varsayılan davranışta circuit geri geldiğinde stok listesi duraklatma anındaki hâliyle geri gelir; kullanıcı saatler önceki adetlere bakarak karar verebilir.

Serileştirilemeyen tipleri persist etmek. Doküman döngüsel referanslı nesnelerin ve ORM (Object-Relational Mapping; class'ları veritabanı tablolarına eşleyen araç) entity'lerinin doğru serileşmeyebileceğini söylüyor. Persist edilecek şey StockView gibi düz bir record ya da class olmalı; Entity Framework Core entity'si değil.

Küçük Bir Deneme

★☆☆ Filter'dan [PersistentState]'i kaldır ve Playwright ya da tarayıcının konsolunda Blazor.pauseCircuit(), ardından Blazor.resumeCircuit() çalıştır. Eşik değeri ne oldu? Log'da OnInitializedAsync satırı neyi gösteriyor?

★★☆ Stock'taki RestoreBehavior'ı kaldır, sayfayı açıp circuit'i duraklat, bu arada Bookstore.Api'de bir kitabın stoğunu PUT /api/inventory/{isbn} ile değiştir, sonra devam ettir. Tablo hangi adedi gösteriyor? 30 saniyelik timer tetiklenince ne oluyor?

★★★ BookListPage'i (Static SSR) bir an için InteractiveServer yap ve sonuç listesini [PersistentState] ile taşı. Arama terimine göre persist edilen state'in boyutunu sayfa kaynağından ölç: 5, 20 ve 50'lik sayfa boyutlarında HTML ne kadar büyüyor?

State taşımayı çözdük ama stok sayım formunda hâlâ bir doğrulama yok; 05'te API'nin 400'ünü elle yakalayıp mesaj göstermiştik. Bir sonraki yazıda Blazor'un form altyapısına geçiyoruz: EditForm, input component'leri, DataAnnotations ve .NET 10'daki nested validation ile yazar ve kitap ekleme formu.