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

Component Tasarımı: RenderFragment, Templated Component, Generic Component, CascadingValue, CSS Isolation ve bUnit

Mert Özen 15 Kas 2026 16 dk 6 görüntülenme
Component Tasarımı: RenderFragment, Templated Component, Generic Component, CascadingValue, CSS Isolation ve bUnit

Blazor'da yeniden kullanılabilir component RenderFragment ve @typeparam ile kurulur; @key satır state'ini doğru item'a bağlar, ShouldRender gereksiz render'ı keser, cascading value'lar ise render mode sınırını geçmez. Bookstore panelinde generic bir DataTable, @key olmadan yanlış satıra kayan input, 42'den 4'e inen render sayısı, WebAssembly'de null kalan cascading value, QuickGrid, Virtualize ölçümü, ::deep ve bUnit testlerini gerçek çıktılarla gösteriyoruz.

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

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) yönetim panelinde stok tablosunun her satırı bir BookRow component'i ve her satırın kendi "kaç adet eklenecek" input'u var. Tablo en az stoğu olan kitaptan başlayarak sıralı. Don Quixote'un satırına 25 yazıp Restock'a bastığında kitap listede aşağı iniyor. 25 yazan input kimin satırında kalır: Don Quixote'un mu, onun yerine en üste çıkan kitabın mı? İkinci soru: eşik değerini 30'dan 31'e çektiğinde, hiçbir rozetin değişmediği bu durumda 20 satırdan kaçı yeniden render edilir? Üçüncüsü: layout'ta bir <CascadingValue> ile para birimini veriyorsun; aynı değer 04'teki WebAssembly CartPreview'a ulaşır mı?

Generic ve Templated Bir Tablo

Panelde benzer tablolar çoğalıyor: stok listesi, kitap listesi, yazarlar. Tablonun iskeletini bir kez yazıp içeriğini kullanan tarafa bırakmak için iki araç var. RenderFragment bir markup parçası; RenderFragment<T> ise bir değer alıp markup üreten parça. @typeparam de component'i generic yapıyor:

@typeparam TItem

<table class="table table-sm align-middle data-table">
    <thead>
        <tr>@HeaderTemplate</tr>
    </thead>
    <tbody>
        @if (Items.Count == 0)
        {
            <tr><td colspan="100" class="text-muted">@(EmptyTemplate ?? DefaultEmpty)</td></tr>
        }
        @foreach (TItem item in Items)
        {
            // Key, liste yeniden sıralandığında her satırın DOM'unu ve component state'ini kendi item'ıyla birlikte tutar.
            <tr @key="ItemKey(item)">@RowTemplate(item)</tr>
        }
    </tbody>
</table>

@code {
    private static readonly RenderFragment DefaultEmpty = builder => builder.AddContent(0, "Nothing to show.");

    [Parameter, EditorRequired]
    public IReadOnlyList<TItem> Items { get; set; } = [];

    [Parameter, EditorRequired]
    public Func<TItem, object> ItemKey { get; set; } = null!;

    [Parameter, EditorRequired]
    public RenderFragment HeaderTemplate { get; set; } = null!;

    [Parameter, EditorRequired]
    public RenderFragment<TItem> RowTemplate { get; set; } = null!;

    [Parameter]
    public RenderFragment? EmptyTemplate { get; set; }
}

Angular'daki karşılığıyla düşünürsen HeaderTemplate isimli bir ng-content slot'u, RowTemplate ise context alan bir ng-template. Stok tablosu artık şöyle:

@inject ILogger<BookList> Logger

<DataTable Items="Items" ItemKey="item => item.Isbn">
    <HeaderTemplate>
        <th>Title</th><th class="text-end">On hand</th><th class="text-end">Reserved</th><th class="text-end">Available</th><th></th>
    </HeaderTemplate>
    <RowTemplate Context="item">
        <BookRow Item="item" LowStockThreshold="LowStockThreshold" OnRestock="OnRestock" />
    </RowTemplate>
    <EmptyTemplate>No books match the filter.</EmptyTemplate>
</DataTable>
<p>@Items.Count(i => i.Available <= LowStockThreshold) of @Items.Count books are at or below the threshold.</p>

Child content'in adı parametrenin adıyla aynı element olarak yazılıyor (<RowTemplate>). Context="item" template'in içindeki değişkenin adı; vermezsen adı context oluyor, iç içe iki template'te çakıştığı için isim vermek iyi bir alışkanlık. TItem'ı yazmadık; derleyici Items="Items"'tan StockView olduğunu çıkarıyor. BookRow da artık <tr>'yi kendisi çizmiyor, yalnız hücreleri üretiyor; satır elementi DataTable'da.

@key ve ShouldRender: Hangi Satır Ne Zaman Render Edilir

Blazor bir listeyi yeniden render ettiğinde yeni render tree'yi eskisiyle karşılaştırır. Bir ipucu vermezsen elemanları sıralarına göre eşleştirir: eski listenin birinci satırı yeni listenin birinci satırı olur. Satırlar düz HTML ise bu sorun değil. Ama her satırda bir component ve o component'in içinde state varsa, state pozisyona bağlı kalır. 07'nin sonundaki hâliyle ilk soruyu Playwright'ta denedim:

before restock (first 3 rows):
   Don Quixote            available  0  amount 25
   War and Peace          available  3  amount 10
   1984                   available 25  amount 10
after restocking Don Quixote by 25 (first 3 rows, and Don Quixote):
   War and Peace          available  3  amount 25
   1984                   available 25  amount 10
   Anna Karenina          available 25  amount 10
   Don Quixote            available 25  amount 10  (row 6)

25, War and Peace'in satırına geçti. Don Quixote altıncı sıraya indi ama onu gösteren BookRow instance'ı orada önceden duran instance; amount'u 10. Kullanıcı War and Peace için 25 yazdığını sanmadığı hâlde bir sonraki tıklamada 25 adet ekler. DataTable'daki <tr @key="ItemKey(item)"> bunu çözüyor: Blazor satırları ISBN'e (International Standard Book Number; kitabın uluslararası standart numarası) göre eşleştiriyor, satırın DOM'u (Document Object Model; tarayıcının sayfayı tuttuğu ağaç yapısı) ve içindeki component birlikte yer değiştiriyor:

before restock (first 3 rows):
   Don Quixote            available  0  amount 25
   War and Peace          available  3  amount 10
   1984                   available 25  amount 10
after restocking Don Quixote by 25 (first 3 rows, and Don Quixote):
   War and Peace          available  3  amount 10
   1984                   available 25  amount 10
   Anna Karenina          available 25  amount 10
   Don Quixote            available 25  amount 25  (row 6)

İlk sorunun cevabı: @key olmadan yanlış kitabın satırında, @key ile Don Quixote'ta. Key olarak veritabanı ID'si ya da ISBN gibi kararlı ve benzersiz bir değer kullan; liste index'i key olarak işe yaramaz, çünkü sorun zaten index'le eşleştirmek.

İkinci konu render sayısı. Parent render edildiğinde child component'in parametreleri yeniden set ediliyor. Blazor değişmeyen bir parametreyi yalnız bilinen basit tiplerde (string, sayılar, DateTime gibi) anlayabiliyor; StockView gibi bir record ya da class parametre olarak geçiyorsa her seferinde "değişmiş olabilir" kabul edip child'ı render ediyor. Bunu ölçmek için deneme amaçlı bir kopyada BookRow'un her render'ında log yazdırdım ve üç adımı saydım:

threshold 5 -> 30: 20 BookRow renders
threshold 30 -> 31: 20 BookRow renders
restock Emma by 10: 42 BookRow renders

Eşik değeri hiçbir rozeti değiştirmese de 20 satır render edildi; tek bir kitabı stoklamak 42 render'a mal oldu. ShouldRender override'ı ile BookRow'a markup'ının neye bağlı olduğunu söyledim:

@code {
    private int amount = 10;
    private bool saving;
    private RowState? rendered;

    [Parameter, EditorRequired]
    public StockView Item { get; set; } = null!;

    [Parameter]
    public int LowStockThreshold { get; set; }

    [Parameter]
    public EventCallback<RestockRequest> OnRestock { get; set; }

    // Markup'ın bağlı olduğu her şey. StockView bir record; aynı veri eşit sayılır.
    private RowState Current => new(Item, Item.Available <= LowStockThreshold, saving);

    // Görünen hiçbir şey değişmediyse render'ı atla; örneğin bu satırın rozetini değiştirmeyen bir eşik değişikliği.
    protected override bool ShouldRender() => Current != rendered;

    protected override void OnAfterRender(bool firstRender) => rendered = Current;

    private async Task RestockClicked()
    {
        saving = true;
        await OnRestock.InvokeAsync(new RestockRequest(Item.Isbn, amount));
        saving = false;
    }

    private sealed record RowState(StockView Item, bool IsLow, bool Saving);
}
threshold 5 -> 30: 18 BookRow renders
threshold 30 -> 31: 0 BookRow renders
restock Emma by 10: 4 BookRow renders

İkinci sorunun cevabı: önce 20, şimdi 0. 5'ten 30'a çıkınca rozeti gerçekten değişen 18 satır render edildi; stoklama 4 render'a indi. ShouldRender ilk render'da çağrılmıyor, component her zaman en az bir kez çizilir. Bu teknik pahalı satırlar ve sık güncellenen listeler için; her component'e eklersen state'i unuttuğun bir gün ekran güncellenmez. Burada RowState'e girmeyen tek şey amount; input'un değeri zaten DOM'da durduğu için render'a gerek yok.

@ref: Component'e ve Element'e Referans

Bazen parent'ın child'a ya da bir HTML element'ine doğrudan ulaşması gerekiyor. 07'deki yazar formunda "Add another book"a basınca yeni kitabın ISBN alanına odaklanmak istedim:

            <label>ISBN <InputText @ref="lastIsbnInput" @bind-Value="book.Isbn" class="form-control" /></label>
    // Döngüde her satır bu field'a yazar; render bitince son kitabın input'unu tutar.
    private InputText? lastIsbnInput;
    private bool focusLastBook;

    protected override async Task OnInitializedAsync()
    {
        editContext = new EditContext(form);
        serverErrors = new ValidationMessageStore(editContext);
        // Kullanıcı alanı değiştirdiği anda o alanın sunucu hatası silinir.
        editContext.OnFieldChanged += (_, e) => serverErrors.Clear(e.FieldIdentifier);
        editContext.OnValidationRequested += (_, _) => serverErrors.Clear();
        categories = await Api.GetCategoriesAsync();
    }

    private void AddBook()
    {
        form.Books.Add(new());
        focusLastBook = true;
    }

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (focusLastBook && lastIsbnInput?.Element is ElementReference input)
        {
            focusLastBook = false;
            await input.FocusAsync();
        }
    }
after "Add another book": focus on Book 2, ISBN input
after "Add another book": focus on Book 3, ISBN input

@ref render sırasında atanıyor; döngüde her satır aynı field'a yazdığı için render bittiğinde field son kitabın InputText'ini tutuyor. Input component'lerinin Element property'si alttaki <input>'un ElementReference'ı; FocusAsync arka planda JS interop kullandığı için OnAfterRenderAsync'te, element DOM'a girdikten sonra çağrılıyor. Component referansını parametre yerine method çağırmak için kullanma; veri parametreyle aşağı, olaylar EventCallback ile yukarı akmalı.

CascadingValue ve Render Mode Sınırı

Fiyatlar panelde dört ayrı yerde ToString("N2") TRY ile yazılıyordu. Bunu küçük bir Money component'ine taşıdım; para birimi ve biçim her parametreye tek tek verilmek yerine cascading value olarak geliyor:

@Amount.ToString(Options.AmountFormat) @Options.Currency

@code {
    [Parameter, EditorRequired]
    public decimal Amount { get; set; }

    [CascadingParameter]
    public PanelOptions Options { get; set; } = null!;
}

İlk denemem değeri layout'tan vermekti:

<article class="content px-4">
    <CascadingValue Value="options" IsFixed="true">
        @Body
    </CascadingValue>
</article>

@code {
    private readonly PanelOptions options = new(Currency: "TRY", AmountFormat: "N2");
}

IsFixed="true" değerin hiç değişmeyeceğini söylüyor; Blazor bu durumda alıcıları değişiklik için takip etmiyor. Değer değişebiliyorsa her alıcı abone oluyor ve değer değiştiğinde hepsi yeniden render ediliyor; derin bir ağacın tepesinde sık değişen bir cascading value bu yüzden pahalı. Static SSR (Static Server-Side Rendering; sayfanın her istekte sunucuda render edilmesi) sayfalarında bu kurulum çalıştı. Teklif sayfasındaki WebAssembly CartPreview'da ise:

prerendered HTML: <option value="9780451524935" selected>1984 (279.00 TRY)</option>
after WebAssembly started: 1984 ()
renderer: WebAssembly
console error: crit: Microsoft.AspNetCore.Components.WebAssembly.Rendering.WebAssemblyRenderer[100] |       Unhandled exception rendering component: Arg_NullReferenceException
console error: crit: Microsoft.AspNetCore.Components.WebAssembly.Rendering.WebAssemblyRenderer[100] |       Unhandled exception rendering component: Arg_NullReferenceException

Üçüncü sorunun cevabı: prerender'da evet, sonra hayır. Sunucudaki prerender aynı render tree içinde çalıştığı için değer geldi ve HTML'de 279.00 TRY vardı. WebAssembly runtime'ı başlayıp component'i tarayıcıda yeniden oluşturduğunda Options null oldu. Doküman bunu açıkça söylüyor: cascading parameter'lar render mode sınırını geçmiyor ve JSON'a serileştirilmiyor. Çözüm root-level cascading value; DI (Dependency Injection; bağımlılıkların dışarıdan verilmesi) üzerinden geldiği için her render mode'da var, ama her taraf kendi kaydını yapıyor:

// Root-level cascading value, render mode'u ne olursa olsun her component'e ulaşır (WebAssembly tarafı kendi kaydını yapar).
builder.Services.AddCascadingValue(_ => new PanelOptions(Currency: "TRY", AmountFormat: "N2"));
using Bookstore.Admin.Client.Shared;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;

WebAssemblyHostBuilder builder = WebAssemblyHostBuilder.CreateDefault(args);

builder.Services.AddCascadingValue(_ => new PanelOptions(Currency: "TRY", AmountFormat: "N2"));

await builder.Build().RunAsync();
prerendered HTML: <option value="9780451524935" selected>1984 (279.00 TRY)</option>
after WebAssembly started: 1984 (279.00 TRY)
renderer: WebAssembly

AddCascadingValue ile verilen değer fixed. Çalışırken değişmesi gerekiyorsa bir CascadingValueSource<T> kaydedip NotifyChangedAsync çağırıyorsun; doküman Static SSR alıcılarının bu bildirimi alamayacağını da belirtiyor.

Uzun Listeler: Virtualize ve QuickGrid

Panelde gerçekten uzun bir liste henüz yok; ölçmek için bir diagnostics sayfasına 10.000 sentetik stok hareketi koydum:

    <table class="table table-sm mb-0">
        <tbody>
            @if (virtualize)
            {
                <Virtualize Items="movements" ItemSize="33" OverscanCount="5" SpacerElement="tr" Context="movement">
                    <tr @key="movement.Id" style="height: 33px">
                        <td>@movement.Id</td><td>@movement.Isbn</td><td class="text-end">@movement.Change</td><td>@movement.AtUtc.ToString("u")</td>
                    </tr>
                </Virtualize>
            }

Virtualize yalnız görünen satırları ve OverscanCount kadar fazlasını render ediyor; geri kalan yükseklik iki spacer elementiyle tutuluyor. Tablo içinde kullandığım için spacer'ın tr olması gerekiyor (SpacerElement="tr"); ItemSize satır yüksekliğini önceden söyleyip ilk render'ın doğru hesaplanmasını sağlıyor. Ölçüm:

Virtualize on: 28 <tr> in the DOM (including 2 spacers)
Virtualize off: 10000 <tr>, 304 ms until all rows were in the DOM, 3169 KB over the circuit
Virtualize on again: 28 <tr>, 99 ms
scrolled to the bottom: 28 <tr>, ids 9975..10000, 10 KB over the circuit

Virtualize kapalıyken InteractiveServer circuit'i tarayıcıya 3,1 MB'lık render batch gönderdi ve DOM'da 10.000 satır oluştu; açıkken 28 satır, en alta kaydırınca da yalnız görünür hâle gelen satırlar için 10 KB. Bu ölçüm aynı makinedeki bir tarayıcıyla; gerçek ağda 3 MB'lık bir fark saniyeler demek. Veri sunucudaysa Items yerine ItemsProvider ile yalnız görünen aralığı istemek de mümkün.

Sıralama ve sayfalama gereken tablolarda kendi DataTable'ını büyütmek yerine Microsoft'un QuickGrid paketine bak. Yazarlar sayfası:

@page "/authors"
@rendermode InteractiveServer
@using Microsoft.AspNetCore.Components.QuickGrid
@inject BookstoreApiClient Api

<PageTitle>Authors</PageTitle>

<h1>Authors</h1>
<p><a href="authors/new" class="btn btn-sm btn-primary">New author</a></p>

@if (Authors is null)
{
    <p>Loading...</p>
}
else
{
    <QuickGrid Items="Authors.AsQueryable()" Pagination="pagination" class="table table-sm">
        <PropertyColumn Property="a => a.Name" Sortable="true" IsDefaultSortColumn="true" />
        <PropertyColumn Property="a => a.BookCount" Title="Books" Sortable="true" Align="Align.End" />
    </QuickGrid>
    <Paginator State="pagination" />
}

@code {
    private readonly PaginationState pagination = new() { ItemsPerPage = 5 };

    // Prerender sırasında bir kez yüklenir (bkz. 06. yazı); sıralama ve sayfalama sonra circuit üzerinde bellekte yapılır.
    [PersistentState]
    public List<AuthorView>? Authors { get; set; }

    protected override async Task OnInitializedAsync() => Authors ??= [.. await Api.GetAuthorsAsync()];
}
page 1: Aldous Huxley 1, Alexandre Dumas 1, Charles Dickens 1, Charlotte Brontë 1, Emily Brontë 1
paginator: 18 items Page 1 of 4
sorted by Books (2 clicks): Jane Austen 2, Leo Tolstoy 2, Aldous Huxley 1, Alexandre Dumas 1, Charles Dickens 1
next page: Charlotte Brontë 1, Emily Brontë 1, F. Scott Fitzgerald 1, Frank Herbert 1, Fyodor Dostoevsky 1
paginator: 18 items Page 2 of 4

PropertyColumn expression'dan başlığı ve sıralamayı çıkarıyor, Paginator ile PaginationState sayfalamayı yönetiyor. Veri 07'de eklediğimiz GET /api/authors'tan bir kez geliyor ve 06'daki gibi [PersistentState] ile prerender'dan taşınıyor; sıralama ve sayfalama circuit üzerinde bellekte yapılıyor. Binlerce satırda Items yerine ItemsProvider verip sıralama ve sayfalamayı API'ye bırakmak gerekir. .NET 10'da QuickGrid'in sıralama ve sayfa butonları @onclick ile çalıştığı için interaktif bir render mode istiyor; .NET 11'de Static SSR için URL tabanlı sayfalama ve sıralama geliyor, 13. yazıda.

CSS Isolation ve ::deep

DataTable.razor.css dosyası yalnız DataTable'a ait stil. Build sırasında Blazor bu component'in render ettiği element'lere b- ile başlayan bir attribute ekliyor ve CSS selector'larını bu attribute'a bağlıyor; bütün component'lerin stilleri Bookstore.Admin.styles.css bundle'ında toplanıyor. İlk yazdığım kural .data-table thead th idi; bundle'da şuna dönüştü:

/* /Components/Shared/DataTable.razor.rz.scp.css */
.data-table thead th[b-stp7rrk8id] {
    font-size: 0.8rem;
    text-transform: uppercase;
    color: #6c757d;
}
thead th: scope=(none) text-transform=none font-size=16px
tbody td: scope=(none) padding-top=5.6px

Başlık stili uygulanmadı. Sebebi, attribute'un selector'ın en sağdaki parçasına eklenmesi: th[b-stp7rrk8id]. Ama <th>'ler DataTable'ın değil, onu kullanan BookList'in template'inden geliyor ve o component'in scoped CSS'i olmadığı için hiçbir attribute taşımıyor. Attribute'u yalnız DataTable'ın kendi yazdığı <table> taşıyor. ::deep attribute'u tam o noktaya koyuyor:

/* Başlık ve gövde hücreleri parent'ın template'lerinden gelir ve bu component'in scope attribute'unu almaz;
   bu yüzden iki kural da ::deep kullanır. Attribute'u yalnız burada render edilen <table> taşır. */
.data-table ::deep thead th {
    font-size: 0.8rem;
    text-transform: uppercase;
    color: #6c757d;
}

.data-table ::deep tbody td {
    padding-top: 0.35rem;
    padding-bottom: 0.35rem;
}
.data-table[b-stp7rrk8id]  thead th {
    font-size: 0.8rem;
    text-transform: uppercase;
    color: #6c757d;
}

.data-table[b-stp7rrk8id]  tbody td {
    padding-top: 0.35rem;
    padding-bottom: 0.35rem;
}
thead th: scope=(none) text-transform=uppercase font-size=12.8px
tbody td: scope=(none) padding-top=5.6px

Bundle çıktısı kısaltılmış (yorum satırları çıkarıldı). Kural basit: templated bir component'te template'ten gelen her şeye ::deep ile ulaşılır. ::deep yalnız attribute'u taşıyan bir element'in altındaki element'leri seçiyor; component template içeriğini hiçbir element'e sarmadan render etseydi bu kurallar onu yakalayamazdı. DataTable'da bu sarmalayıcı <table>.

bUnit ile Component Testi

Bookstore.Api'nin testleri vardı, panelin yoktu. Frontend Testleri serisi Angular odaklı olduğu için Blazor testi burada: yeni tests/Bookstore.Admin.Tests projesi, xUnit v3 ve bUnit. bUnit component'i tarayıcı olmadan, bellekte bir renderer'la çalıştırıyor; markup'ı sorgulayıp event tetikleyebiliyorsun:

    [Fact]
    public async Task Restock_raises_the_callback_and_shows_saving_until_it_completes()
    {
        TaskCompletionSource done = new();
        RestockRequest? raised = null;
        IRenderedComponent<BookRow> row = Render<BookRow>(p => p
            .Add(r => r.Item, Emma)
            .Add(r => r.LowStockThreshold, 5)
            .Add(r => r.OnRestock, (RestockRequest request) => { raised = request; return done.Task; }));

        row.Find("input").Change("15");
        row.Find("button").Click();

        Assert.Equal(new RestockRequest("9780141439587", 15), raised);
        Assert.Equal("Saving...", row.Find("button").TextContent.Trim());
        Assert.True(row.Find("button").HasAttribute("disabled"));

        done.SetResult();
        await row.WaitForAssertionAsync(() => Assert.Equal("Restock", row.Find("button").TextContent.Trim()));
    }

    [Fact]
    public void A_threshold_change_that_does_not_move_the_badge_does_not_render_the_row()
    {
        IRenderedComponent<BookRow> row = Render<BookRow>(p => p.Add(r => r.Item, Emma).Add(r => r.LowStockThreshold, 5));
        int before = row.RenderCount;

        row.Render(p => p.Add(r => r.LowStockThreshold, 6));
        Assert.Equal(before, row.RenderCount);

        row.Render(p => p.Add(r => r.LowStockThreshold, 30));
        Assert.Equal(before + 1, row.RenderCount);
        Assert.Contains("Low: 25 left", row.Markup);
    }

Render<T> component'i parametrelerle çiziyor, Find CSS selector ile element buluyor, Change ve Click event'leri tetikliyor. EventCallback parametresine bir lambda verip çağrıyı yakaladım; TaskCompletionSource ile parent'ın işini bekletip "Saving..." durumunu gördüm. RenderCount da ShouldRender'ın gerçekten render'ı atladığını doğruluyor. Sayfa testinde Bookstore.Api'yi sahte bir HttpMessageHandler ile değiştirdim:

    public NewAuthorPageTests()
    {
        // Sahte bir Bookstore.Api: seçim listesi için kategoriler, var olan bir ISBN için 400.
        StubHandler handler = new(request =>
        {
            requests.Add($"{request.Method} {request.RequestUri!.AbsolutePath}");
            return request.RequestUri.AbsolutePath switch
            {
                "/api/categories" => Json(HttpStatusCode.OK, new[] { new CategoryView("Classics", "classics") }),
                "/api/authors" => Json(HttpStatusCode.BadRequest, new
                {
                    title = "One or more validation errors occurred.",
                    status = 400,
                    errors = new Dictionary<string, string[]> { ["Books[0].Isbn"] = ["A book with ISBN 9780441172719 already exists."] }
                }),
                _ => new HttpResponseMessage(HttpStatusCode.NotFound)
            };
        });
        Services.AddSingleton(new BookstoreApiClient(new HttpClient(handler) { BaseAddress = new Uri("http://localhost:5080/") }));
        Services.AddAdminValidation();
    }

    [Fact]
    public void An_empty_form_is_not_sent_to_the_api()
    {
        IRenderedComponent<NewAuthorPage> page = Render<NewAuthorPage>();

        page.Find("form").Submit();

        Assert.Equal(6, page.FindAll("div.validation-message").Count);
        Assert.DoesNotContain("POST /api/authors", requests);
    }

Bu testin ilk çalıştırmasında boş form 6 değil 1 mesaj üretti:

failed Bookstore.Admin.Tests.NewAuthorPageTests.An_empty_form_is_not_sent_to_the_api (10ms)
  Assert.Equal() Failure: Values differ
  Expected: 6
  Actual:   1

Sebebi 07'deki source generator. AddValidation() çağrısı, çağrıldığı assembly'de üretilen validation metadata'sına bağlanıyor. Test projesinde Services.AddValidation() yazınca generator test projesinde çalıştı, orada [ValidatableType] olmadığı için nested kurallar yoktu. Çağrıyı panelin içine bir extension method olarak taşıdım; Program.cs de testler de onu kullanıyor:

namespace Bookstore.Admin.Validation;

public static class ValidationServiceExtensions
{
    // Validation source generator'ı metadata'yı AddValidation()'ı çağıran assembly için üretir.
    // Çağrıyı burada, Bookstore.Admin'in içinde yapmak Program.cs ile bUnit testlerinin aynı metadata'yı kullanmasını sağlar.
    public static IServiceCollection AddAdminValidation(this IServiceCollection services) => services.AddValidation();
}
passed InventoryComponentTests.A_threshold_change_that_does_not_move_the_badge_does_not_render_the_row
passed InventoryComponentTests.Badge_depends_on_available_stock_and_threshold(available: 0, threshold: 5, expected: "<span class=\"badge bg-danger\">Out of stock</span>")
passed InventoryComponentTests.Badge_depends_on_available_stock_and_threshold(available: 3, threshold: 5, expected: "<span class=\"badge bg-warning text-dark\">Low: 3 le"···)
passed InventoryComponentTests.Badge_depends_on_available_stock_and_threshold(available: 8, threshold: 5, expected: "")
passed InventoryComponentTests.Restock_raises_the_callback_and_shows_saving_until_it_completes
passed InventoryComponentTests.The_list_shows_its_empty_template_when_nothing_matches
passed NewAuthorPageTests.An_api_error_appears_under_the_field_it_belongs_to
passed NewAuthorPageTests.An_empty_form_is_not_sent_to_the_api
passed SharedComponentTests.Isbn13_checks_length_and_check_digit(isbn: "", valid: True)
passed SharedComponentTests.Isbn13_checks_length_and_check_digit(isbn: "978-0-15-662870-9", valid: True)
passed SharedComponentTests.Isbn13_checks_length_and_check_digit(isbn: "978015690739", valid: False)
passed SharedComponentTests.Isbn13_checks_length_and_check_digit(isbn: "9780156907391", valid: False)
passed SharedComponentTests.Isbn13_checks_length_and_check_digit(isbn: "9780441172719", valid: True)
passed SharedComponentTests.Money_formats_with_the_cascaded_panel_options
Test run summary: Passed!
  total: 14
  failed: 0
  succeeded: 14

Çıktı kısaltılmış (namespace ve süreler çıkarıldı). Testleri dotnet test tests/Bookstore.Admin.Tests ile çalıştırabilirsin; veritabanı ya da çalışan bir API gerekmiyor.

Üretimde Dikkat

InteractiveServer'da her render bir diff hesaplaması ve circuit üzerinden giden bir mesaj. Virtualize ölçümündeki 3,1 MB bunun uç örneği; günlük hâli, bir input'a her tuş vuruşunda 20 satırın yeniden render edilmesi. Darboğaz şüphesinde önce ölç: bir log satırı ya da tarayıcının WebSocket frame'leri hangi işlemin ne kadar render ve kaç byte ürettiğini gösteriyor. ShouldRender'ı ölçmeden eklemek, okuması zor ve hataya açık kod demek.

Root-level cascading value'lar DI'dan geldiği için uygulama boyunca yaşıyor. Kullanıcıya özel bir değeri (örneğin kullanıcının dil tercihi) AddCascadingValue ile kaydederken lifetime'a dikkat et; DI lifetime'larının Server ve WebAssembly'de ne anlama geldiği bir sonraki yazının konusu.

Scoped CSS bundle'ı App.razor'daki <link href="@Assets["Bookstore.Admin.styles.css"]"> ile yükleniyor ve dosya adı fingerprint'li (Bookstore.Admin.s7im66pvkc.styles.css). Bir reverse proxy ya da CDN (Content Delivery Network; statik içeriği kullanıcıya yakın sunuculardan dağıtan ağ) kurarken bu dosyanın uzun süre cache'lenebileceğini ve içerik değiştikçe adının değiştiğini bil.

Sık Düşülen Tuzaklar

Döngüde state'li component kullanıp @key vermemek. Sıralama, filtreleme ya da başa eleman ekleme state'i yanlış satıra taşır; yukarıdaki 25 gibi.

@key'e benzersiz olmayan bir değer vermek. Deneme için key'i item.Available yaptığımda sayfa açıldı, ilk yeniden render'da (eşik değerini değiştirince) circuit şu hatayla düştü:

System.InvalidOperationException: More than one sibling of element 'tr' has the same key value, '25'. Key values must be unique.

İlk render'da karşılaştırılacak eski bir liste olmadığı için hata hemen görünmüyor; başlık, yazar adı ya da adet gibi tekrar edebilen değerler key olmamalı.

Cascading value'nun interaktif bir adaya ulaşacağını varsaymak. Layout ve Routes Static SSR'dayken oradan verilen değer prerender'da çalışıp interaktif render'da null olur. Interaktif component'lere ulaşacak değerler için root-level cascading value, WebAssembly tarafında ayrı kayıtla.

Scoped CSS'i template'ten gelen element'lere ::deep'siz uygulamak. Hata vermez, sadece stil uygulanmaz.

Test projesinde AddValidation()'ı yeniden çağırmak. Uygulamanın metadata'sı test projesine gelmez; nested kurallar sessizce kapanır ve test, uygulamanın davranışını değil kendi eksik kurulumunu test eder.

Küçük Bir Deneme

★☆☆ BookListPage'teki elle yazılmış tabloyu DataTable<BookSummary> ile değiştir. Static SSR'da @key'in bir işe yarayıp yaramadığını düşün: sayfa her istekte sıfırdan render ediliyorsa karşılaştırılacak eski bir tree var mı? Enhanced navigation'da durum nedir?

★★☆ Stok sayfasında eşik değerini BookList ve BookRow üzerinden parametre olarak taşımak yerine <CascadingValue Value="Filter.Threshold"> ile ver ve LowStockBadge'de [CascadingParameter] olarak oku. Bu sefer IsFixed veremezsin; render sayısını yukarıdaki gibi ölç ve ShouldRender ile karşılaştır.

★★★ Yazarlar sayfasındaki QuickGrid'e ItemsProvider ver: GET /api/authors'a page, pageSize ve sort parametreleri ekle, sıralamayı ve sayfalamayı API yapsın. bUnit'te sahte handler ile "ikinci sayfaya geçince API'ye page=2 gidiyor" testini yaz.

Component'lerimiz artık daha düzenli, ama hepsi veriyi typed bir HttpClient ile Bookstore.Api'den alıyor ve StaffNoteStore gibi bir Singleton'ın bütün kullanıcılar arasında paylaşıldığını 06'da not etmiştik. Bir sonraki yazıda DI lifetime'larının Server ve WebAssembly'de ne demek olduğuna, circuit scope'a, DbContext'in component'lerde nasıl kullanılacağına ve Auto mode'da iki tarafta da çalışan bir veri abstraction'ına bakıyoruz.