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
diff --git a/README.md b/README.md
index 6f2586b..ee567f9 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/)
@@ -146,34 +147,60 @@ tutorials/
| | `-- BlazorTodoMinimal.Tests/
| | |-- BlazorTodoMinimal.Tests.csproj
| | `-- TodoDashboardTests.cs
-| `-- forms-validation-masterclass/
-| |-- BlazorProfileValidation.slnx
+| |-- 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
| |-- src/
-| | `-- BlazorProfileValidation/
-| | |-- BlazorProfileValidation.csproj
+| | `-- BlazorCatalogIslands/
+| | |-- BlazorCatalogIslands.csproj
| | |-- Program.cs
| | |-- Models/
-| | | `-- ProfileModel.cs
+| | | `-- CatalogModels.cs
| | |-- Services/
-| | | `-- ProfileService.cs
-| | |-- Validation/
-| | | `-- ProfileValidator.cs
+| | | `-- CatalogService.cs
| | `-- Components/
| | |-- _Imports.razor
| | |-- App.razor
| | |-- Routes.razor
| | |-- Pages/
-| | | |-- NotFound.razor
-| | | `-- ProfileSettings.razor
+| | | |-- Catalog.razor
+| | | `-- NotFound.razor
| | |-- Shared/
-| | | `-- FormTextField.razor
-| | `-- Validation/
-| | `-- FluentValidationBridge.razor
+| | | |-- CartIsland.razor
+| | | |-- ProductCard.razor
+| | | `-- ReviewsSection.razor
| `-- tests/
-| `-- BlazorProfileValidation.Tests/
-| |-- BlazorProfileValidation.Tests.csproj
-| `-- ProfileSettingsTests.cs
+| `-- BlazorCatalogIslands.Tests/
+| |-- BlazorCatalogIslands.Tests.csproj
+| `-- CatalogIslandTests.cs
|-- aspnet-core/
| |-- api-security-in-practice/
| | |-- ApiSecurityMinimal.slnx
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..87e64e7
--- /dev/null
+++ b/blazor/ssr-interactive-islands/README.md
@@ -0,0 +1,273 @@
+# 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/
+```
+
+## 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:
+
+- 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.
+
+## 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/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..821123a
--- /dev/null
+++ b/blazor/ssr-interactive-islands/tests/BlazorCatalogIslands.Tests/CatalogIslandTests.cs
@@ -0,0 +1,515 @@
+using System.Net;
+using System.Reflection;
+using System.Text.RegularExpressions;
+using AngleSharp.Dom;
+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
+ Cart_increment_decrement_and_remove_update_state()
+ {
+ using var context =
+ new BunitContext();
+
+ var cut =
+ RenderCart(
+ context);
+
+ cut.Find(
+ "[data-action='add'][data-product-id='1']")
+ .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();
+
+ 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']")
+ .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);
+ });
+ }
+
+ [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(
+ "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,
+ 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));
+
+ private static string Normalize(
+ IElement element) =>
+ Regex.Replace(
+ element.TextContent
+ .Trim(),
+ @"\s+",
+ " ");
+}
\ No newline at end of file