From c26d1758b79a36252f9f319298dd59e420c206da Mon Sep 17 00:00:00 2001 From: BALAJI BASHYAM Date: Sun, 2 Aug 2026 12:01:52 +0000 Subject: [PATCH 1/5] Add lightweight Blazor SSR islands sample --- .../BlazorCatalogIslands.slnx | 8 + blazor/ssr-interactive-islands/README.md | 208 +++++++++ .../BlazorCatalogIslands.csproj | 9 + .../BlazorCatalogIslands/Components/App.razor | 18 + .../Components/Pages/Catalog.razor | 61 +++ .../Components/Pages/NotFound.razor | 15 + .../Components/Routes.razor | 9 + .../Components/Shared/CartIsland.razor | 198 ++++++++ .../Components/Shared/ProductCard.razor | 27 ++ .../Components/Shared/ReviewsSection.razor | 46 ++ .../Components/_Imports.razor | 11 + .../Models/CatalogModels.cs | 13 + .../src/BlazorCatalogIslands/Program.cs | 32 ++ .../Properties/launchSettings.json | 23 + .../Services/CatalogService.cs | 66 +++ .../BlazorCatalogIslands.Tests.csproj | 53 +++ .../CatalogIslandTests.cs | 424 ++++++++++++++++++ 17 files changed, 1221 insertions(+) create mode 100644 blazor/ssr-interactive-islands/BlazorCatalogIslands.slnx create mode 100644 blazor/ssr-interactive-islands/README.md create mode 100644 blazor/ssr-interactive-islands/src/BlazorCatalogIslands/BlazorCatalogIslands.csproj create mode 100644 blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Components/App.razor create mode 100644 blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Components/Pages/Catalog.razor create mode 100644 blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Components/Pages/NotFound.razor create mode 100644 blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Components/Routes.razor create mode 100644 blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Components/Shared/CartIsland.razor create mode 100644 blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Components/Shared/ProductCard.razor create mode 100644 blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Components/Shared/ReviewsSection.razor create mode 100644 blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Components/_Imports.razor create mode 100644 blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Models/CatalogModels.cs create mode 100644 blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Program.cs create mode 100644 blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Properties/launchSettings.json create mode 100644 blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Services/CatalogService.cs create mode 100644 blazor/ssr-interactive-islands/tests/BlazorCatalogIslands.Tests/BlazorCatalogIslands.Tests.csproj create mode 100644 blazor/ssr-interactive-islands/tests/BlazorCatalogIslands.Tests/CatalogIslandTests.cs diff --git a/blazor/ssr-interactive-islands/BlazorCatalogIslands.slnx b/blazor/ssr-interactive-islands/BlazorCatalogIslands.slnx new file mode 100644 index 0000000..6991672 --- /dev/null +++ b/blazor/ssr-interactive-islands/BlazorCatalogIslands.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/blazor/ssr-interactive-islands/README.md b/blazor/ssr-interactive-islands/README.md new file mode 100644 index 0000000..79fc2b6 --- /dev/null +++ b/blazor/ssr-interactive-islands/README.md @@ -0,0 +1,208 @@ +# Blazor Streaming SSR and Interactive Cart Island + +A focused .NET 10 companion showing how a Blazor Web App can keep a product +catalog statically server-rendered, stream delayed reviews, and apply +Interactive Server only to a small cart island. + +## Full tutorial + +[Blazor SSR & Interactive Islands: Streaming Rendering, Auto Render Mode & Progressive Enhancement](https://www.dotnet-guide.com/tutorials/blazor/ssr-interactive-islands/) + +## Framework note + +The tutorial explains Blazor render modes on .NET 8. + +This companion targets .NET 10 so it can use the DOTNET GUIDE repository's +current SDK and CI toolchain. The static SSR, streaming-rendering, +serializable-boundary, Interactive Server island, and component-testing +concepts demonstrated here are the same core Blazor patterns. + +For streaming attributes: + +```text +.NET 8: [StreamRendering(true)] +.NET 9+: [StreamRendering] +``` + +## What this sample demonstrates + +- a statically rendered catalog route; +- server-rendered product content and metadata; +- `[StreamRendering]`; +- an initial reviews placeholder; +- a delayed streamed review update; +- one Interactive Server cart island; +- JSON-serializable parameters crossing the render-mode boundary; +- add, increment, decrement, and remove interactions; +- deterministic item totals; +- a custom direct-route 404 page; +- bUnit and ASP.NET Core integration tests. + +## Render boundaries + +```text +Product catalog: Static Server +Reviews: Static SSR + streaming update +Cart: Interactive Server island +``` + +The app registers Interactive Server support but does not make the route tree +globally interactive. + +## Why this sample excludes Auto and WebAssembly + +Interactive WebAssembly and Interactive Auto require a separate `.Client` +project and client-compatible component and service implementations. + +This lightweight companion keeps one project so the render boundary remains +easy to inspect and test. + +The complete tutorial discusses the broader hybrid architecture. + +## Serializable island parameters + +The static catalog passes a `ProductSummary[]` value to the cart island. + +Parameters crossing from a static parent to an interactive child must be JSON +serializable. Render fragments and arbitrary runtime services cannot cross this +boundary. + +## Streaming demonstration delay + +The catalog service intentionally waits about 700 milliseconds before returning +featured reviews. + +This delay exists only to make the streaming phases visible. It is not a +production recommendation. + +## Buffering limitation + +Streaming requires the host and intermediaries to let response data flow as it +is generated. + +If a reverse proxy buffers the response, the page still renders correctly, but +the placeholder and final review content may appear together. + +## Cart-state boundary + +Cart state exists only inside the Interactive Server component. + +It resets when the component or circuit is replaced. + +The sample does not provide persistence, checkout, cross-tab state, or offline +support. + +## Prerequisites + +- .NET 10 SDK +- a modern browser for optional manual checks +- Python 3 only for the optional incremental-response observation command + +## Restore, build, and test + +```powershell +dotnet restore ` + .\BlazorCatalogIslands.slnx + +dotnet build ` + .\BlazorCatalogIslands.slnx ` + --configuration Release ` + --no-restore + +dotnet test ` + .\BlazorCatalogIslands.slnx ` + --configuration Release ` + --no-build +``` + +## Run + +```powershell +dotnet run ` + --project .\src\BlazorCatalogIslands\BlazorCatalogIslands.csproj ` + --urls http://localhost:5148 +``` + +Open: + +```text +http://localhost:5148/ +``` + +## Testing boundary + +The ten tests cover: + +- product rendering; +- review loading and completed states; +- cart interactions and totals; +- streaming-attribute configuration; +- complete HTTP output; +- direct unknown-route handling. + +They don't launch a graphical browser or measure network chunk timing. + +Use the Kestrel streaming command in this README for a local response-flow +check. + +## Project structure + +```text +BlazorCatalogIslands.slnx +README.md +src/ +└── BlazorCatalogIslands/ + ├── BlazorCatalogIslands.csproj + ├── Program.cs + ├── Models/ + │ └── CatalogModels.cs + ├── Services/ + │ └── CatalogService.cs + └── Components/ + ├── _Imports.razor + ├── App.razor + ├── Routes.razor + ├── Pages/ + │ ├── Catalog.razor + │ └── NotFound.razor + └── Shared/ + ├── CartIsland.razor + ├── ProductCard.razor + └── ReviewsSection.razor +tests/ +└── BlazorCatalogIslands.Tests/ + ├── BlazorCatalogIslands.Tests.csproj + └── CatalogIslandTests.cs +``` + +## Deliberately omitted + +- Interactive WebAssembly; +- Interactive Auto; +- offline support; +- JavaScript interop; +- APIs; +- databases; +- cart persistence; +- structured data; +- load testing; +- WebAssembly AOT; +- Docker; +- cloud deployment. + +These topics remain in the full tutorial. + +## Verification + +- Companion target framework: .NET 10 +- Tutorial framework: .NET 8 +- Route render mode: Static Server +- Island render mode: Interactive Server +- Application NuGet packages: none +- External services required: none +- Database required: none +- API keys required: none +- Expected tests: 10 +- Last reviewed: 2026-08-02 + +This sample is educational and should be reviewed before production use. \ No newline at end of file diff --git a/blazor/ssr-interactive-islands/src/BlazorCatalogIslands/BlazorCatalogIslands.csproj b/blazor/ssr-interactive-islands/src/BlazorCatalogIslands/BlazorCatalogIslands.csproj new file mode 100644 index 0000000..84d2766 --- /dev/null +++ b/blazor/ssr-interactive-islands/src/BlazorCatalogIslands/BlazorCatalogIslands.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + \ No newline at end of file diff --git a/blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Components/App.razor b/blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Components/App.razor new file mode 100644 index 0000000..b37ad96 --- /dev/null +++ b/blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Components/App.razor @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Components/Pages/Catalog.razor b/blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Components/Pages/Catalog.razor new file mode 100644 index 0000000..ee8129f --- /dev/null +++ b/blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Components/Pages/Catalog.razor @@ -0,0 +1,61 @@ +@page "/" +@attribute [StreamRendering] +@inject ICatalogService CatalogService + +Product Catalog + + + + + +
+

Product Catalog

+ +

+ Product content is rendered with static SSR. + Reviews arrive through streaming rendering. + The cart is a focused Interactive Server island. +

+ +
+

+ Products +

+ +
+ @foreach (ProductSummary product + in _products) + { + + } +
+
+ + + + +
+ +@code { + private ProductSummary[] _products = + []; + + private ReviewSummary[]? _reviews; + + protected override async Task + OnInitializedAsync() + { + _products = + CatalogService.GetProducts(); + + _reviews = + await CatalogService + .GetFeaturedReviewsAsync(); + } +} \ No newline at end of file diff --git a/blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Components/Pages/NotFound.razor b/blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Components/Pages/NotFound.razor new file mode 100644 index 0000000..90c54d6 --- /dev/null +++ b/blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Components/Pages/NotFound.razor @@ -0,0 +1,15 @@ +@page "/not-found" + +Not Found + +
+

Page not found

+ +

+ The requested catalog page does not exist. +

+ + + Return to the product catalog + +
\ No newline at end of file diff --git a/blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Components/Routes.razor b/blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Components/Routes.razor new file mode 100644 index 0000000..724d5b2 --- /dev/null +++ b/blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Components/Routes.razor @@ -0,0 +1,9 @@ + + + + + + + \ No newline at end of file diff --git a/blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Components/Shared/CartIsland.razor b/blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Components/Shared/CartIsland.razor new file mode 100644 index 0000000..be518e1 --- /dev/null +++ b/blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Components/Shared/CartIsland.razor @@ -0,0 +1,198 @@ +@using Microsoft.AspNetCore.Components.Web + +
+

+ Interactive cart island +

+ +

+ The catalog remains static SSR. These controls + become interactive after the server circuit connects. +

+ +
+ @foreach (ProductSummary product + in Products.OrderBy( + product => + product.Id)) + { + + } +
+ +

+ @TotalItems + @(TotalItems == 1 + ? "item" + : "items") + · USD @FormatMoney(Total) +

+ + @if (Lines.Count == 0) + { +

+ Your cart is empty. +

+ } + else + { +
+ @foreach (CartLine line + in Lines) + { +
+

@line.Product.Name

+ +

+ Quantity: + + @line.Quantity + +

+ + + + + + +
+ } +
+ } +
+ +@code { + [Parameter] + [EditorRequired] + public ProductSummary[] Products + { + get; + set; + } = []; + + private readonly Dictionary< + int, + CartLine> _lines = + []; + + private IReadOnlyList + Lines => + _lines.Values + .OrderBy( + line => + line.Product.Id) + .ToArray(); + + private int TotalItems => + _lines.Values.Sum( + line => + line.Quantity); + + private decimal Total => + _lines.Values.Sum( + line => + line.Product.Price + * line.Quantity); + + private void Add( + ProductSummary product) + { + if (_lines.TryGetValue( + product.Id, + out CartLine? line)) + { + line.Quantity++; + + return; + } + + _lines[product.Id] = + new CartLine( + product); + } + + private void Increment( + int productId) + { + if (_lines.TryGetValue( + productId, + out CartLine? line)) + { + line.Quantity++; + } + } + + private void Decrement( + int productId) + { + if (!_lines.TryGetValue( + productId, + out CartLine? line)) + { + return; + } + + line.Quantity--; + + if (line.Quantity == 0) + { + _lines.Remove( + productId); + } + } + + private void Remove( + int productId) + { + _lines.Remove( + productId); + } + + private static string FormatMoney( + decimal value) => + value.ToString( + "0.00", + CultureInfo.InvariantCulture); + + private sealed class CartLine + { + public CartLine( + ProductSummary product) + { + Product = + product; + } + + public ProductSummary Product + { + get; + } + + public int Quantity + { + get; + set; + } = 1; + } +} \ No newline at end of file diff --git a/blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Components/Shared/ProductCard.razor b/blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Components/Shared/ProductCard.razor new file mode 100644 index 0000000..aeb3abf --- /dev/null +++ b/blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Components/Shared/ProductCard.razor @@ -0,0 +1,27 @@ +
+

@Product.Name

+ +

@Product.Description

+ +

+ + USD @PriceValue + +

+
+ +@code { + [Parameter] + [EditorRequired] + public ProductSummary Product + { + get; + set; + } = default!; + + private string PriceValue => + Product.Price.ToString( + "0.00", + CultureInfo.InvariantCulture); +} \ No newline at end of file diff --git a/blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Components/Shared/ReviewsSection.razor b/blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Components/Shared/ReviewsSection.razor new file mode 100644 index 0000000..c2b9f47 --- /dev/null +++ b/blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Components/Shared/ReviewsSection.razor @@ -0,0 +1,46 @@ +
+

+ Featured reviews +

+ + @if (Reviews is null) + { +
+ Loading featured reviews… +
+ } + else + { +
+ @foreach (ReviewSummary review + in Reviews) + { +
+

@review.Author

+ +

+ Rating: + + @review.Rating / 5 + +

+ +

@review.Comment

+
+ } +
+ } +
+ +@code { + [Parameter] + public ReviewSummary[]? Reviews + { + get; + set; + } +} \ No newline at end of file diff --git a/blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Components/_Imports.razor b/blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Components/_Imports.razor new file mode 100644 index 0000000..573ace1 --- /dev/null +++ b/blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Components/_Imports.razor @@ -0,0 +1,11 @@ +@using System.Globalization +@using System.Reflection +@using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using BlazorCatalogIslands +@using BlazorCatalogIslands.Models +@using BlazorCatalogIslands.Services +@using BlazorCatalogIslands.Components +@using BlazorCatalogIslands.Components.Shared \ No newline at end of file diff --git a/blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Models/CatalogModels.cs b/blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Models/CatalogModels.cs new file mode 100644 index 0000000..1f0f8b2 --- /dev/null +++ b/blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Models/CatalogModels.cs @@ -0,0 +1,13 @@ +namespace BlazorCatalogIslands.Models; + +public sealed record ProductSummary( + int Id, + string Name, + decimal Price, + string Description); + +public sealed record ReviewSummary( + int Id, + string Author, + int Rating, + string Comment); \ No newline at end of file diff --git a/blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Program.cs b/blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Program.cs new file mode 100644 index 0000000..6386739 --- /dev/null +++ b/blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Program.cs @@ -0,0 +1,32 @@ +using BlazorCatalogIslands.Components; +using BlazorCatalogIslands.Services; + +var builder = + WebApplication.CreateBuilder(args); + +builder.Services + .AddRazorComponents() + .AddInteractiveServerComponents(); + +builder.Services.AddSingleton< + ICatalogService, + CatalogService>(); + +var app = + builder.Build(); + +app.UseStatusCodePagesWithReExecute( + "/not-found", + createScopeForStatusCodePages: true); + +app.UseStaticFiles(); +app.UseAntiforgery(); + +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +app.Run(); + +public partial class Program +{ +} \ No newline at end of file diff --git a/blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Properties/launchSettings.json b/blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Properties/launchSettings.json new file mode 100644 index 0000000..ae020a5 --- /dev/null +++ b/blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5225", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7036;http://localhost:5225", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } + } diff --git a/blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Services/CatalogService.cs b/blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Services/CatalogService.cs new file mode 100644 index 0000000..6080522 --- /dev/null +++ b/blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Services/CatalogService.cs @@ -0,0 +1,66 @@ +using BlazorCatalogIslands.Models; + +namespace BlazorCatalogIslands.Services; + +public interface ICatalogService +{ + ProductSummary[] GetProducts(); + + Task + GetFeaturedReviewsAsync(); +} + +public sealed class CatalogService : + ICatalogService +{ + private static readonly + ProductSummary[] Products = + [ + new ProductSummary( + 1, + "Mechanical Keyboard", + 89.00m, + "A compact keyboard with tactile switches."), + + new ProductSummary( + 2, + "USB-C Dock", + 129.00m, + "A desktop dock with display and network ports."), + + new ProductSummary( + 3, + "Monitor Arm", + 79.00m, + "An adjustable arm for a single display.") + ]; + + private static readonly + ReviewSummary[] Reviews = + [ + new ReviewSummary( + 1, + "Asha", + 5, + "The keyboard feels excellent for long coding sessions."), + + new ReviewSummary( + 2, + "Daniel", + 4, + "The dock keeps my desk setup simple and reliable.") + ]; + + public ProductSummary[] GetProducts() => + Products.ToArray(); + + public async Task + GetFeaturedReviewsAsync() + { + await Task.Delay( + TimeSpan.FromMilliseconds( + 700)); + + return Reviews.ToArray(); + } +} \ No newline at end of file diff --git a/blazor/ssr-interactive-islands/tests/BlazorCatalogIslands.Tests/BlazorCatalogIslands.Tests.csproj b/blazor/ssr-interactive-islands/tests/BlazorCatalogIslands.Tests/BlazorCatalogIslands.Tests.csproj new file mode 100644 index 0000000..fa6130b --- /dev/null +++ b/blazor/ssr-interactive-islands/tests/BlazorCatalogIslands.Tests/BlazorCatalogIslands.Tests.csproj @@ -0,0 +1,53 @@ + + + + net10.0 + enable + enable + false + true + Exe + + + + + + + + + + + + + all + + runtime; + build; + native; + contentfiles; + analyzers; + buildtransitive + + + + + + + + + + + + + \ No newline at end of file diff --git a/blazor/ssr-interactive-islands/tests/BlazorCatalogIslands.Tests/CatalogIslandTests.cs b/blazor/ssr-interactive-islands/tests/BlazorCatalogIslands.Tests/CatalogIslandTests.cs new file mode 100644 index 0000000..c1e86cb --- /dev/null +++ b/blazor/ssr-interactive-islands/tests/BlazorCatalogIslands.Tests/CatalogIslandTests.cs @@ -0,0 +1,424 @@ +using System.Net; +using System.Reflection; +using System.Text.RegularExpressions; +using BlazorCatalogIslands.Components.Pages; +using BlazorCatalogIslands.Components.Shared; +using BlazorCatalogIslands.Models; +using Bunit; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace BlazorCatalogIslands.Tests; + +public sealed class CatalogIslandTests +{ + private static readonly + ProductSummary[] Products = + [ + new ProductSummary( + 1, + "Mechanical Keyboard", + 89.00m, + "A compact keyboard with tactile switches."), + + new ProductSummary( + 2, + "USB-C Dock", + 129.00m, + "A desktop dock with display and network ports.") + ]; + + private static readonly + ReviewSummary[] Reviews = + [ + new ReviewSummary( + 1, + "Asha", + 5, + "Excellent for long coding sessions."), + + new ReviewSummary( + 2, + "Daniel", + 4, + "A reliable desk accessory.") + ]; + + [Fact] + public void + Product_card_renders_semantic_catalog_content() + { + using var context = + new BunitContext(); + + var cut = + context.Render< + ProductCard>( + parameters => + parameters.Add( + component => + component.Product, + Products[0])); + + Assert.Contains( + "Mechanical Keyboard", + cut.Markup, + StringComparison.Ordinal); + + Assert.Contains( + "USD 89.00", + cut.Markup, + StringComparison.Ordinal); + + Assert.Equal( + "1", + cut.Find( + "[data-testid='product-card']") + .GetAttribute( + "data-product-id")); + } + + [Fact] + public void + Reviews_section_renders_loading_placeholder() + { + using var context = + new BunitContext(); + + var cut = + context.Render< + ReviewsSection>(); + + Assert.Single( + cut.FindAll( + "[data-testid='reviews-loading']")); + + Assert.Empty( + cut.FindAll( + "[data-testid='review']")); + } + + [Fact] + public void + Reviews_section_renders_completed_reviews() + { + using var context = + new BunitContext(); + + var cut = + context.Render< + ReviewsSection>( + parameters => + parameters.Add( + component => + component.Reviews, + Reviews)); + + Assert.Equal( + 2, + cut.FindAll( + "[data-testid='review']") + .Count); + + Assert.Contains( + "Excellent for long coding sessions.", + cut.Markup, + StringComparison.Ordinal); + } + + [Fact] + public void + Cart_island_starts_empty() + { + using var context = + new BunitContext(); + + var cut = + RenderCart( + context); + + Assert.Contains( + "0 items", + Regex.Replace( + cut.Find( + "[data-testid='cart-summary']") + .TextContent + .Trim(), + @"\s+", + " "), + StringComparison.Ordinal); + + Assert.Single( + cut.FindAll( + "[data-testid='cart-empty']")); + } + + [Fact] + public void + Cart_island_adds_a_product() + { + using var context = + new BunitContext(); + + var cut = + RenderCart( + context); + + cut.Find( + "[data-action='add'][data-product-id='1']") + .Click(); + + cut.WaitForAssertion( + () => + { + Assert.Contains( + "1 item", + Regex.Replace( + cut.Find( + "[data-testid='cart-summary']") + .TextContent + .Trim(), + @"\s+", + " "), + StringComparison.Ordinal); + + Assert.Contains( + "USD 89.00", + Regex.Replace( + cut.Find( + "[data-testid='cart-summary']") + .TextContent + .Trim(), + @"\s+", + " "), + StringComparison.Ordinal); + + Assert.Equal( + "1", + cut.Find( + "[data-cart-product-id='1'] [data-testid='cart-quantity']") + .TextContent + .Trim()); + }); + } + + [Fact] + public void + Repeated_add_updates_quantity_and_total() + { + using var context = + new BunitContext(); + + var cut = + RenderCart( + context); + + var add = + cut.Find( + "[data-action='add'][data-product-id='1']"); + + add.Click(); + add.Click(); + + cut.WaitForAssertion( + () => + { + Assert.Contains( + "2 items", + Regex.Replace( + cut.Find( + "[data-testid='cart-summary']") + .TextContent + .Trim(), + @"\s+", + " "), + StringComparison.Ordinal); + + Assert.Contains( + "USD 178.00", + Regex.Replace( + cut.Find( + "[data-testid='cart-summary']") + .TextContent + .Trim(), + @"\s+", + " "), + StringComparison.Ordinal); + + Assert.Equal( + "2", + cut.Find( + "[data-cart-product-id='1'] [data-testid='cart-quantity']") + .TextContent + .Trim()); + }); + } + + [Fact] + public void + Decrement_and_remove_update_cart_state() + { + using var context = + new BunitContext(); + + var cut = + RenderCart( + context); + + var add = + cut.Find( + "[data-action='add'][data-product-id='1']"); + + add.Click(); + add.Click(); + + cut.Find( + "[data-cart-product-id='1'] [data-action='decrement']") + .Click(); + + Assert.Equal( + "1", + cut.Find( + "[data-cart-product-id='1'] [data-testid='cart-quantity']") + .TextContent + .Trim()); + + cut.Find( + "[data-cart-product-id='1'] [data-action='remove']") + .Click(); + + cut.WaitForAssertion( + () => + { + Assert.Empty( + cut.FindAll( + "[data-cart-product-id='1']")); + + Assert.Contains( + "0 items", + Regex.Replace( + cut.Find( + "[data-testid='cart-summary']") + .TextContent + .Trim(), + @"\s+", + " "), + StringComparison.Ordinal); + }); + } + + [Fact] + public void + Catalog_component_declares_stream_rendering() + { + StreamRenderingAttribute? attribute = + typeof(Catalog) + .GetCustomAttribute< + StreamRenderingAttribute>(); + + Assert.NotNull( + attribute); + } + + [Fact] + public async Task + Root_response_contains_catalog_reviews_and_blazor_script() + { + using var factory = + new WebApplicationFactory< + Program>(); + + using HttpClient client = + factory.CreateClient(); + + CancellationToken ct = + Xunit.TestContext.Current + .CancellationToken; + + HttpResponseMessage response = + await client.GetAsync( + "/", + ct); + + string body = + await response.Content + .ReadAsStringAsync( + ct); + + Assert.Equal( + HttpStatusCode.OK, + response.StatusCode); + + Assert.Contains( + "Mechanical Keyboard", + body, + StringComparison.Ordinal); + + Assert.Contains( + "The keyboard feels excellent for long coding sessions.", + body, + StringComparison.Ordinal); + + Assert.Contains( + "Interactive cart island", + body, + StringComparison.Ordinal); + + Assert.Contains( + "_framework/blazor.web.js", + body, + StringComparison.Ordinal); + } + + [Fact] + public async Task + Unknown_path_returns_custom_not_found_page() + { + using var factory = + new WebApplicationFactory< + Program>(); + + using HttpClient client = + factory.CreateClient( + new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = + false + }); + + CancellationToken ct = + Xunit.TestContext.Current + .CancellationToken; + + HttpResponseMessage response = + await client.GetAsync( + "/this-page-does-not-exist", + ct); + + string body = + await response.Content + .ReadAsStringAsync( + ct); + + Assert.Equal( + HttpStatusCode.NotFound, + response.StatusCode); + + Assert.Contains( + "Page not found", + body, + StringComparison.Ordinal); + } + + private static + IRenderedComponent + RenderCart( + BunitContext context) => + context.Render< + CartIsland>( + parameters => + parameters.Add( + component => + component.Products, + Products)); +} \ No newline at end of file From 5c4959a3aa75251d37fb2d36ae79783468263667 Mon Sep 17 00:00:00 2001 From: BALAJI BASHYAM Date: Sun, 2 Aug 2026 12:01:52 +0000 Subject: [PATCH 2/5] Add SSR islands sample to repository README --- README.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/README.md b/README.md index 6f2586b..d867391 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Each sample folder contains a focused implementation of one tutorial topic. The | [`aspnet-core/minimal-apis-real-world`](aspnet-core/minimal-apis-real-world/) | Focused .NET 10 Orders API demonstrating endpoint filters, FluentValidation, URL-segment versioning, typed results, and partitioned rate limiting | [ASP.NET Core / Minimal APIs in the Real World: Filters, Validation, Versioning & Rate Limiting](https://www.dotnet-guide.com/tutorials/aspnet-core/minimal-apis-real-world/) | | [`blazor/create-interactive-ui-csharp-12`](blazor/create-interactive-ui-csharp-12/) | Interactive .NET 10 Todo Dashboard demonstrating Razor components, form binding, DataAnnotations validation, scoped state, EventCallback communication, filtering, and bUnit component tests | [Blazor Web Development: Create Interactive UIs with C# 12](https://www.dotnet-guide.com/tutorials/blazor/create-interactive-ui-csharp-12/) | | [`blazor/forms-validation-masterclass`](blazor/forms-validation-masterclass/) | Focused .NET 10 Profile Settings form demonstrating manual EditContext management, DataAnnotations, FluentValidation, backend field-error mapping, accessible inputs, dirty state, and bUnit testing | [Blazor .NET 8 Forms & Validation: EditForm, FluentValidation & Server Error Handling](https://www.dotnet-guide.com/tutorials/blazor/forms-validation-masterclass/) | +| [`blazor/ssr-interactive-islands`](blazor/ssr-interactive-islands/) | Focused .NET 10 catalog demonstrating static SSR, streaming review updates, a serializable render-mode boundary, an Interactive Server cart island, and component/integration testing | [Blazor SSR & Interactive Islands: Streaming Rendering, Auto Render Mode & Progressive Enhancement](https://www.dotnet-guide.com/tutorials/blazor/ssr-interactive-islands/) | ## Companion articles - [Common Microsoft.Extensions.AI mistakes](https://www.dotnet-guide.com/articles/dotnet-ai/microsoft-extensions-ai-common-mistakes/) @@ -174,6 +175,32 @@ tutorials/ | `-- BlazorProfileValidation.Tests/ | |-- BlazorProfileValidation.Tests.csproj | `-- ProfileSettingsTests.cs +| `-- ssr-interactive-islands/ +| |-- BlazorCatalogIslands.slnx +| |-- README.md +| |-- src/ +| | `-- BlazorCatalogIslands/ +| | |-- BlazorCatalogIslands.csproj +| | |-- Program.cs +| | |-- Models/ +| | | `-- CatalogModels.cs +| | |-- Services/ +| | | `-- CatalogService.cs +| | `-- Components/ +| | |-- _Imports.razor +| | |-- App.razor +| | |-- Routes.razor +| | |-- Pages/ +| | | |-- Catalog.razor +| | | `-- NotFound.razor +| | |-- Shared/ +| | | |-- CartIsland.razor +| | | |-- ProductCard.razor +| | | `-- ReviewsSection.razor +| `-- tests/ +| `-- BlazorCatalogIslands.Tests/ +| |-- BlazorCatalogIslands.Tests.csproj +| `-- CatalogIslandTests.cs |-- aspnet-core/ | |-- api-security-in-practice/ | | |-- ApiSecurityMinimal.slnx From 10aca727e937a8c649fa363b94f15f64ef3efd8f Mon Sep 17 00:00:00 2001 From: BALAJI BASHYAM Date: Sun, 2 Aug 2026 12:01:52 +0000 Subject: [PATCH 3/5] Test Blazor SSR islands sample in GitHub Actions --- .github/workflows/build-samples.yml | 34 +++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/.github/workflows/build-samples.yml b/.github/workflows/build-samples.yml index e2dfdda..00139a8 100644 --- a/.github/workflows/build-samples.yml +++ b/.github/workflows/build-samples.yml @@ -16,6 +16,7 @@ on: - "aspnet-core/minimal-apis-real-world/**" - "blazor/create-interactive-ui-csharp-12/**" - "blazor/forms-validation-masterclass/**" + - "blazor/ssr-interactive-islands/**" - ".github/workflows/build-samples.yml" pull_request: @@ -32,6 +33,7 @@ on: - "aspnet-core/minimal-apis-real-world/**" - "blazor/create-interactive-ui-csharp-12/**" - "blazor/forms-validation-masterclass/**" + - "blazor/ssr-interactive-islands/**" - ".github/workflows/build-samples.yml" workflow_dispatch: @@ -421,3 +423,35 @@ jobs: blazor/forms-validation-masterclass/BlazorProfileValidation.slnx --configuration Release --no-build + + test-blazor-ssr-islands: + name: Test Blazor SSR and interactive island sample + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v5 + + - name: Install .NET 10 SDK + uses: actions/setup-dotnet@v5 + with: + dotnet-version: "10.0.x" + + - name: Restore + run: > + dotnet restore + blazor/ssr-interactive-islands/BlazorCatalogIslands.slnx + + - name: Build + run: > + dotnet build + blazor/ssr-interactive-islands/BlazorCatalogIslands.slnx + --configuration Release + --no-restore + + - name: Test + run: > + dotnet test + blazor/ssr-interactive-islands/BlazorCatalogIslands.slnx + --configuration Release + --no-build From 3f6453a88d7c4281c38f2e0c476e96ff8a6b9be3 Mon Sep 17 00:00:00 2001 From: BALAJI BASHYAM Date: Sun, 2 Aug 2026 12:02:26 +0000 Subject: [PATCH 4/5] Remove generated launch settings --- .../Properties/launchSettings.json | 23 ------------------- 1 file changed, 23 deletions(-) delete mode 100644 blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Properties/launchSettings.json diff --git a/blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Properties/launchSettings.json b/blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Properties/launchSettings.json deleted file mode 100644 index ae020a5..0000000 --- a/blazor/ssr-interactive-islands/src/BlazorCatalogIslands/Properties/launchSettings.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "http://localhost:5225", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "https://localhost:7036;http://localhost:5225", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } - } From bbdced3f34b44776d48360ebba982af619e12888 Mon Sep 17 00:00:00 2001 From: BALAJI BASHYAM Date: Sun, 2 Aug 2026 12:15:15 +0000 Subject: [PATCH 5/5] Address final Blazor SSR islands review feedback --- README.md | 56 ++++---- blazor/ssr-interactive-islands/README.md | 71 +++++++++- .../CatalogIslandTests.cs | 127 +++++++++++++++--- 3 files changed, 205 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index d867391..ee567f9 100644 --- a/README.md +++ b/README.md @@ -147,34 +147,34 @@ tutorials/ | | `-- BlazorTodoMinimal.Tests/ | | |-- BlazorTodoMinimal.Tests.csproj | | `-- TodoDashboardTests.cs -| `-- forms-validation-masterclass/ -| |-- BlazorProfileValidation.slnx -| |-- README.md -| |-- src/ -| | `-- BlazorProfileValidation/ -| | |-- BlazorProfileValidation.csproj -| | |-- Program.cs -| | |-- Models/ -| | | `-- ProfileModel.cs -| | |-- Services/ -| | | `-- ProfileService.cs -| | |-- Validation/ -| | | `-- ProfileValidator.cs -| | `-- Components/ -| | |-- _Imports.razor -| | |-- App.razor -| | |-- Routes.razor -| | |-- Pages/ -| | | |-- NotFound.razor -| | | `-- ProfileSettings.razor -| | |-- Shared/ -| | | `-- FormTextField.razor -| | `-- Validation/ -| | `-- FluentValidationBridge.razor -| `-- tests/ -| `-- BlazorProfileValidation.Tests/ -| |-- BlazorProfileValidation.Tests.csproj -| `-- ProfileSettingsTests.cs +| |-- forms-validation-masterclass/ +| | |-- BlazorProfileValidation.slnx +| | |-- README.md +| | |-- src/ +| | | `-- BlazorProfileValidation/ +| | | |-- BlazorProfileValidation.csproj +| | | |-- Program.cs +| | | |-- Models/ +| | | | `-- ProfileModel.cs +| | | |-- Services/ +| | | | `-- ProfileService.cs +| | | |-- Validation/ +| | | | `-- ProfileValidator.cs +| | | `-- Components/ +| | | |-- _Imports.razor +| | | |-- App.razor +| | | |-- Routes.razor +| | | |-- Pages/ +| | | | |-- NotFound.razor +| | | | `-- ProfileSettings.razor +| | | |-- Shared/ +| | | | `-- FormTextField.razor +| | | `-- Validation/ +| | | `-- FluentValidationBridge.razor +| | `-- tests/ +| | `-- BlazorProfileValidation.Tests/ +| | |-- BlazorProfileValidation.Tests.csproj +| | `-- ProfileSettingsTests.cs | `-- ssr-interactive-islands/ | |-- BlazorCatalogIslands.slnx | |-- README.md diff --git a/blazor/ssr-interactive-islands/README.md b/blazor/ssr-interactive-islands/README.md index 79fc2b6..87e64e7 100644 --- a/blazor/ssr-interactive-islands/README.md +++ b/blazor/ssr-interactive-islands/README.md @@ -129,6 +129,74 @@ Open: http://localhost:5148/ ``` +## Observe the streamed response + +Start the application: + +```powershell +dotnet run ` + --project .\src\BlazorCatalogIslands\BlazorCatalogIslands.csproj ` + --urls http://localhost:5148 +``` + +Run this Python 3 incremental HTTP command in a separate terminal: + +```bash +python3 - <<'PY' +import http.client +import time + +connection = http.client.HTTPConnection( + "127.0.0.1", + 5148, + timeout=10, +) + +connection.request("GET", "/") +response = connection.getresponse() + +started = time.monotonic() +buffer = "" +seen = set() + +markers = ( + "Loading featured reviews", + "The keyboard feels excellent for long coding sessions.", +) + +while True: + chunk = response.read(128) + if not chunk: + break + buffer += chunk.decode("utf-8", errors="ignore") + for marker in markers: + if marker in buffer and marker not in seen: + elapsed = time.monotonic() - started + print(f"{elapsed:.3f}s {marker}") + seen.add(marker) + +connection.close() +PY +``` + +Expected output (exact timings vary): + +```text +0.000s Loading featured reviews +0.696s The keyboard feels excellent for long coding sessions. +``` + +The marker order is the important result: the loading placeholder +appears first, and the final review text arrives separately on the +same connection. + +If a reverse proxy or Kestrel host buffers the complete response, both +markers may arrive together. The page still renders correctly, but the +visible streaming benefit can disappear. + +This command observes response flow but is not a load or performance +test. + ## Testing boundary The ten tests cover: @@ -142,9 +210,6 @@ The ten tests cover: They don't launch a graphical browser or measure network chunk timing. -Use the Kestrel streaming command in this README for a local response-flow -check. - ## Project structure ```text diff --git a/blazor/ssr-interactive-islands/tests/BlazorCatalogIslands.Tests/CatalogIslandTests.cs b/blazor/ssr-interactive-islands/tests/BlazorCatalogIslands.Tests/CatalogIslandTests.cs index c1e86cb..821123a 100644 --- a/blazor/ssr-interactive-islands/tests/BlazorCatalogIslands.Tests/CatalogIslandTests.cs +++ b/blazor/ssr-interactive-islands/tests/BlazorCatalogIslands.Tests/CatalogIslandTests.cs @@ -1,6 +1,7 @@ using System.Net; using System.Reflection; using System.Text.RegularExpressions; +using AngleSharp.Dom; using BlazorCatalogIslands.Components.Pages; using BlazorCatalogIslands.Components.Shared; using BlazorCatalogIslands.Models; @@ -256,7 +257,7 @@ public void [Fact] public void - Decrement_and_remove_update_cart_state() + Cart_increment_decrement_and_remove_update_state() { using var context = new BunitContext(); @@ -265,23 +266,94 @@ public void RenderCart( context); - var add = - cut.Find( - "[data-action='add'][data-product-id='1']"); + cut.Find( + "[data-action='add'][data-product-id='1']") + .Click(); - add.Click(); - add.Click(); + cut.WaitForAssertion( + () => + { + Assert.Equal( + "1", + cut.Find( + "[data-cart-product-id='1'] [data-testid='cart-quantity']") + .TextContent + .Trim()); + + Assert.Contains( + "USD 89.00", + Normalize( + cut.Find( + "[data-testid='cart-summary']")), + StringComparison.Ordinal); + }); + + cut.Find( + "[data-cart-product-id='1'] [data-action='increment']") + .Click(); + + cut.WaitForAssertion( + () => + { + Assert.Equal( + "2", + cut.Find( + "[data-cart-product-id='1'] [data-testid='cart-quantity']") + .TextContent + .Trim()); + + Assert.Contains( + "USD 178.00", + Normalize( + cut.Find( + "[data-testid='cart-summary']")), + StringComparison.Ordinal); + }); cut.Find( "[data-cart-product-id='1'] [data-action='decrement']") .Click(); - Assert.Equal( - "1", - cut.Find( - "[data-cart-product-id='1'] [data-testid='cart-quantity']") - .TextContent - .Trim()); + cut.WaitForAssertion( + () => + { + Assert.Equal( + "1", + cut.Find( + "[data-cart-product-id='1'] [data-testid='cart-quantity']") + .TextContent + .Trim()); + + Assert.Contains( + "USD 89.00", + Normalize( + cut.Find( + "[data-testid='cart-summary']")), + StringComparison.Ordinal); + }); + + cut.Find( + "[data-cart-product-id='1'] [data-action='decrement']") + .Click(); + + cut.WaitForAssertion( + () => + { + Assert.Empty( + cut.FindAll( + "[data-cart-product-id='1']")); + + Assert.Contains( + "0 items", + Normalize( + cut.Find( + "[data-testid='cart-summary']")), + StringComparison.Ordinal); + }); + + cut.Find( + "[data-action='add'][data-product-id='1']") + .Click(); cut.Find( "[data-cart-product-id='1'] [data-action='remove']") @@ -296,13 +368,9 @@ public void Assert.Contains( "0 items", - Regex.Replace( + Normalize( cut.Find( - "[data-testid='cart-summary']") - .TextContent - .Trim(), - @"\s+", - " "), + "[data-testid='cart-summary']")), StringComparison.Ordinal); }); } @@ -354,6 +422,21 @@ await response.Content body, StringComparison.Ordinal); + Assert.Contains( + "USB-C Dock", + body, + StringComparison.Ordinal); + + Assert.Contains( + "Monitor Arm", + body, + StringComparison.Ordinal); + + Assert.Contains( + "A server-rendered product catalog with streamed reviews and an Interactive Server cart island.", + body, + StringComparison.Ordinal); + Assert.Contains( "The keyboard feels excellent for long coding sessions.", body, @@ -421,4 +504,12 @@ private static component => component.Products, Products)); + + private static string Normalize( + IElement element) => + Regex.Replace( + element.TextContent + .Trim(), + @"\s+", + " "); } \ No newline at end of file