{"id":50791832,"url":"https://github.com/pal-tamas/rask","last_synced_at":"2026-06-12T12:00:17.356Z","repository":{"id":357145388,"uuid":"1235366179","full_name":"pal-tamas/rask","owner":"pal-tamas","description":"Live web apps in C# — server-rendered over WebSocket or client-side via WebAssembly, one codebase. No .razor, no JS.","archived":false,"fork":false,"pushed_at":"2026-06-11T15:05:09.000Z","size":3085,"stargazers_count":3,"open_issues_count":4,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-11T15:08:28.395Z","etag":null,"topics":["blazor-alternative","components","csharp","dotnet","dotnet10","scoped-css","server-side-rendering","source-generator","wasm","web-framework","webassembly","websockets"],"latest_commit_sha":null,"homepage":null,"language":"C#","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/pal-tamas.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":"CODE_OF_CONDUCT.md","threat_model":null,"audit":null,"citation":null,"codeowners":".github/CODEOWNERS","security":".github/SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":"AGENTS.md","dco":null,"cla":null}},"created_at":"2026-05-11T08:56:07.000Z","updated_at":"2026-06-11T14:59:39.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/pal-tamas/rask","commit_stats":null,"previous_names":["pal-tamas/rask"],"tags_count":9,"template":false,"template_full_name":null,"purl":"pkg:github/pal-tamas/rask","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pal-tamas%2Frask","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pal-tamas%2Frask/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pal-tamas%2Frask/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pal-tamas%2Frask/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/pal-tamas","download_url":"https://codeload.github.com/pal-tamas/rask/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pal-tamas%2Frask/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34243053,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-12T02:00:06.859Z","response_time":109,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["blazor-alternative","components","csharp","dotnet","dotnet10","scoped-css","server-side-rendering","source-generator","wasm","web-framework","webassembly","websockets"],"created_at":"2026-06-12T12:00:15.513Z","updated_at":"2026-06-12T12:00:17.197Z","avatar_url":"https://github.com/pal-tamas.png","language":"C#","funding_links":[],"categories":[],"sub_categories":[],"readme":"\u003cdiv align=\"center\"\u003e\n\n\u003cpicture\u003e\n  \u003csource media=\"(prefers-color-scheme: dark)\" srcset=\"assets/rask-logo-dark.svg\"\u003e\n  \u003cimg alt=\"Rask\" src=\"assets/rask-logo.svg\" width=\"300\"\u003e\n\u003c/picture\u003e\n\n### Live web apps in C#. One codebase — server-rendered over WebSockets, or client-side in the browser via WebAssembly.\n\n[![NuGet Rask.Server](https://img.shields.io/nuget/v/Rask.Server.svg?label=Rask.Server)](https://www.nuget.org/packages/Rask.Server)\n[![NuGet Rask.Wasm](https://img.shields.io/nuget/v/Rask.Wasm.svg?label=Rask.Wasm)](https://www.nuget.org/packages/Rask.Wasm)\n[![NuGet Rask.Wasm.Hosting](https://img.shields.io/nuget/v/Rask.Wasm.Hosting.svg?label=Rask.Wasm.Hosting)](https://www.nuget.org/packages/Rask.Wasm.Hosting)\n[![NuGet Rask.Templates](https://img.shields.io/nuget/v/Rask.Templates.svg?label=Rask.Templates)](https://www.nuget.org/packages/Rask.Templates)\n[![NuGet Rask.Validation.DataAnnotations](https://img.shields.io/nuget/v/Rask.Validation.DataAnnotations.svg?label=Rask.Validation.DataAnnotations)](https://www.nuget.org/packages/Rask.Validation.DataAnnotations)\n[![NuGet Rask.Validation.FluentValidation](https://img.shields.io/nuget/v/Rask.Validation.FluentValidation.svg?label=Rask.Validation.FluentValidation)](https://www.nuget.org/packages/Rask.Validation.FluentValidation)\n[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)\n![.NET](https://img.shields.io/badge/.NET-10-512BD4)\n\n**[Quick start](#-quick-start--server)** · **[Core concepts](#-core-concepts)** · **[Docs ↗](docs/)** · *\n*[Performance](#-performance)** · **[Live demo ↗](https://pal-tamas.github.io/rask/)**\n\n\u003c/div\u003e\n\n---\n\nWrite components as plain C# classes. Return a tree of HTML from `Render()`. **No `.razor`, no JSX, no JavaScript to\nwrite** — and the *same* component code runs server-rendered with live WebSocket updates or fully client-side on\nWebAssembly.\n\n```csharp\n[Route(\"/counter\")]\npublic sealed class Counter : Component\n{\n    private int _count;\n\n    protected override RenderResult Render() =\u003e\n    [\n        H1()[\"Counter\"],\n        P()[$\"Current count: {_count}\"],\n        Button(OnClick: () =\u003e _count++)[\"Click me\"]\n    ];\n}\n```\n\n\u003csub\u003e☝️ A complete, live, interactive component — routing, state, and event handling in a single C# class.\u003c/sub\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003eContents\u003c/b\u003e\u003c/summary\u003e\n\n- [Why Rask](#-why-rask)\n- [Compared to Blazor](#-compared-to-blazor)\n- [Install](#-install)\n- [Quick Start — Server](#-quick-start--server)\n- [Quick Start — WASM](#-quick-start--wasm)\n- [Troubleshooting](#-troubleshooting)\n- [Sub-path hosting \u0026 side-by-side apps](#-sub-path-hosting--side-by-side-apps)\n- [Examples](#-examples)\n- [Core concepts](#-core-concepts) — Components · Interactivity · Context · Async data · Routing · Auth · Head · Error\n  boundaries · Forms \u0026 validation · Files · Virtualization · Scoped CSS · Scoped JS · Element refs · Keyed lists · Live\n  diff codec · Lifecycle\n- [Performance](#-performance)\n- [Status](#-status)\n- [License](#-license)\n\n\u003c/details\u003e\n\n## ✨ Why Rask\n\n*Rask* is the Norwegian/Danish/Swedish word for **fast**. It's a component framework for .NET: you write components as\nplain C# classes, return a tree of HTML from `Render()`, and host the result one of three ways — server-rendered with\nlive updates over a WebSocket, fully client-side in the browser via WebAssembly, or an ASP.NET app that serves a\npublished WASM bundle. The **same component code runs under any host** — only the hosting glue changes.\n\n|     | Feature                            | What it means                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |\n|:---:|------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|\n| 🧩  | **Text-first DSL**                 | No `.razor`, no JSX. Call `Div(...)[Span(...), \"hi\"]`, `Button(...)[\"click\"]`, `H1()[\"title\"]` from C# — children attach through an indexer on every component, so the tree reads top-down like HTML and stays type-checked, refactor-safe, and IDE-friendly.                                                                                                                                                                                                                     |\n| ⚙️  | **Source-generated factories**     | Define `class Counter : Component` and a `Counter()` factory is generated for you. Required vs. optional parameters fall out of property nullability automatically.                                                                                                                                                                                                                                                                                                               |\n| 🔗  | **Type-safe URLs**                 | Every `[Route]` becomes a generated URL builder — `NavLink(HomePage(), ...)` instead of `\"/\"` strings that rot.                                                                                                                                                                                                                                                                                                                                                                   |\n| 🎨  | **Scoped CSS, colocated**          | Drop a sibling `{Component}.css` next to `{Component}.cs` and selectors are auto-scoped to that type and hot-reloaded — no class-name discipline, no BEM, no leaks.                                                                                                                                                                                                                                                                                                               |\n| 💉  | **Constructor DI**                 | `class Weather(IWeatherForecastService svc) : Component` works directly — no `[Inject]` properties, no boilerplate.                                                                                                                                                                                                                                                                                                                                                               |\n| 🛡️ | **Error boundaries**               | `ErrorBoundary(...)` catches render-time, lifecycle, and event-handler faults in its subtree and renders a fallback with a one-shot `recover` callback — no app-wide crashes from a bad descendant.                                                                                                                                                                                                                                                                               |\n|  ✅  | **Forms with async validation**    | `Form\u003cTModel\u003e(model, OnValidSubmit: …)` routes submit through validators you opt into by dropping `DataAnnotationsValidator()` or `FluentValidationValidator(...)` inside the form. Implement `IAsyncFieldValidator` for ad-hoc server-side rules — the submit bridge awaits async checks before routing, and rapid keystrokes cancel any prior in-flight validation (latest-wins).                                                                                               |\n|  ⚡  | **Live diff codec**                | After first paint, a small state change ships a minimal edit-op payload instead of re-serializing the page — a counter tick on a 50 KB page goes from ~50 KB to ~57 bytes on the wire. On by default.                                                                                                                                                                                                                                                                             |\n| 🔑  | **Keyed lists**                    | Add a `Key:` to list items (Blazor `@key` parity) and inserts/removes/reorders reconcile by identity — shipping trusted structural diffs that preserve focus and input state on the survivors. A `RASK022` analyzer flags a list item that's missing a key.                                                                                                                                                                                                                       |\n| 🔐  | **Authentication, ASP.NET-native** | Inject `IUserProvider` and read `.Current` (a never-null `ClaimsPrincipal`) plus a headless `Authorize(Roles:, Policy:, Authorized:, NotAuthorized:, Authorizing:)` component for declarative gating, and `[Authorize]` on a page for route gating. No bespoke options — wire cookies/JWT/OIDC on ASP.NET's own `AddCookie`/`AddJwtBearer`/`AddAuthorization`. Runnable samples + `dotnet new --auth` cover cookie \u0026 JWT on both Server and WASM. See **[docs/authentication.md](docs/authentication.md)**. |\n\n## ⚖️ Compared to Blazor\n\nIf you've worked in Blazor, here's how the day-to-day differs in Rask:\n\n| In Blazor                                         | In Rask                                                                                                                                                                                                                                                                                                                   |\n|---------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|\n| `.razor` files mixing markup + code               | Plain C# classes with an indexer for children — `Div(...)[Span(...), \"hi\"]`. The whole tree is C# expressions, so refactors, find-references, and IDE navigation just work.                                                                                                                                               |\n| `[Inject]` properties                             | Services come in through the **constructor** (`Counter(IClock clock) : Component`), like anywhere else in .NET. Framework services (`Navigator`, `RouteState`, `HttpClient`) inject the same way.                                                                                                                         |\n| `@page \"/path\"`                                   | `[Route(\"/path\")]` on the class, and every route gets a generated **type-safe URL builder** — `NavLink(UserPage(id: 42))` instead of `\"/users/42\"` strings that rot when the route changes.                                                                                                                               |\n| `RenderFragment` / `EventCallback`                | Children are `IEnumerable\u003cChild\u003e`; event handlers are plain delegates (`OnClick: () =\u003e _count++`). Child→parent callbacks are plain delegate props too (`Action\u003cT\u003e?` / `Func\u003cT,Task\u003e?`) — invoking one auto-re-renders the parent that owns it, no `EventCallback` wrapper. No specialised types, no `@bind-Value:event`. |\n| `.razor.css` association ceremony                 | Scoped CSS via a sibling `{Component}.css` (Blazor-parity descendant combinators) — auto-globbed at build time, hot-reloaded under `dotnet watch`. Same idea for JS: a sibling `{Component}.js` is bundled and dispatched by the framework.                                                                               |\n| Separate render modes to wire up                  | **Same component code on Server or WASM.** Pick the host package per project; you don't rewrite components when switching render mode. Server-only (multipart upload) and WASM-only (chunked file reads, inline downloads) behaviours live in the hosts, not in your tree.                                                |\n| `\u003cAuthorizeView\u003e` + `AuthenticationStateProvider` | Inject `IUserProvider` and read `.Current` (a never-null `ClaimsPrincipal`) and a headless `Authorize(...)` component with `Authorized`/`NotAuthorized`/`Authorizing` slots. Auth itself is configured on ASP.NET's **own** `AddCookie`/`AddJwtBearer`/`AddAuthorization` — Rask adds no parallel options surface.                       |\n\nRask isn't a Blazor replacement so much as a different take on the same problem space. If those trade-offs appeal, the\nrest of this README walks through what they look like in practice.\n\n## 📦 Install\n\n### Scaffold a new project with `dotnet new` (recommended)\n\nThe fastest way to start. `Rask.Templates` ships three project templates — one per host model — already wired up to the\nmatching framework package:\n\n```bash\ndotnet new install Rask.Templates\n\ndotnet new rask-server       -n MyApp    # ASP.NET live-server app\ndotnet new rask-wasm         -n MyApp    # standalone browser-WASM SPA\ndotnet new rask-wasm-hosted  -n MyApp    # browser-WASM client + ASP.NET host\n```\n\nEach template emits a runnable solution with `App` + `HomePage` + `Counter` + `Weather` (async DI demo). `rask-server`\nand `rask-wasm` are single-project; `rask-wasm-hosted` is a two-project solution (`MyApp.Wasm/` + `MyApp.Host/`)\npre-wired with the cross-TFM ProjectReference and a sample `/api/weatherforecast` endpoint.\n\n`cd MyApp \u0026\u0026 dotnet run` — that's it.\n\n### Add packages to an existing project\n\nPick one host package per project, then add validation packages as needed:\n\n| Package                            | Project type                                                        | Entry-point API                                             |\n|------------------------------------|---------------------------------------------------------------------|-------------------------------------------------------------|\n| `Rask.Server`                      | `net10.0` ASP.NET                                                   | `services.AddRask()` + `app.UseRask\u003cTApp\u003e()`                |\n| `Rask.Wasm`                        | `net10.0-browser`                                                   | `WasmHostBuilder.CreateDefault()` + `host.RunAsync\u003cTApp\u003e()` |\n| `Rask.Wasm.Hosting`                | `net10.0` ASP.NET (with a `\u003cProjectReference\u003e` to the WASM project) | `app.UseRask()`                                             |\n| `Rask.Validation.DataAnnotations`  | any host (referenced from the project that hosts your forms)        | drop `DataAnnotationsValidator()` inside a `Form\u003cT\u003e`        |\n| `Rask.Validation.FluentValidation` | any host (referenced from the project that hosts your forms)        | drop `FluentValidationValidator(new MyValidator())` inside  |\n\n```bash\ndotnet add package Rask.Server                       # server live host\ndotnet add package Rask.Wasm                         # browser WASM client\ndotnet add package Rask.Wasm.Hosting                 # ASP.NET host serving a WASM bundle\ndotnet add package Rask.Validation.DataAnnotations   # opt-in: System.ComponentModel.DataAnnotations\ndotnet add package Rask.Validation.FluentValidation  # opt-in: FluentValidation 12.x\n```\n\n`Rask.Server` and `Rask.Wasm` each pull in `Rask.Core` and the source generators transitively; `Rask.Wasm.Hosting`\npulls in `Rask.Wasm`. The validation packages add a global `using static` for their factory namespace, so\n`DataAnnotationsValidator()` / `FluentValidationValidator(...)` are in scope without extra `using` lines.\n\n## 🚀 Quick Start — Server\n\nThree files. Live, server-rendered, no JavaScript to write.\n\n**`Program.cs`**\n\n```csharp\nusing Rask.Server;\nusing MyApp;\n\nvar builder = WebApplication.CreateBuilder(args);\nbuilder.Services.AddRask();\n\nvar app = builder.Build();\napp.UseRask\u003cApp\u003e();\napp.Run();\n```\n\n**`App.cs`** — the page root. The `Rask.Server` and `Rask.Wasm` packages auto-import `Rask.Core` and the\ngenerator-emitted factory namespaces (`Rask.Core.Components.Generated`, `Rask.Core.Routing.Generated`),\nso `Component`, `Div(...)`, `H1(...)`, `Router()`, `Route\u003cT\u003e(...)` etc. are in scope project-wide with no\n`using` lines.\n\n```csharp\nnamespace MyApp;\n\npublic sealed class App : Component\n{\n    // App-level head content goes through the RenderResult Head override.\n    // Pages override their own Head to set per-page Title — singleton dedup\n    // means the page's contribution supersedes this fallback for the tab.\n    protected override RenderResult Head =\u003e [\n        Title()[\"My Rask App\"],\n        Meta(\"utf-8\")\n    ];\n\n    protected override RenderResult Render() =\u003e\n        [\n            Doctype(),\n            Html(\"en\")[\n                Head(),                       // framework-managed slot\n                Body()[\n                    Router()\n                ]\n            ]\n        ];\n}\n```\n\nBoth `\u003chead\u003e` and `\u003cbody\u003e` are framework-managed. `\u003chead\u003e` collects every component's `Head`\noverride during render, dedupes contributions, resolves singleton tags (`\u003ctitle\u003e`, `\u003cbase\u003e` — last\ncontributor wins), and splices the result plus the scoped-CSS link and scoped-JS bundle script in\nautomatically — passing children to `Head()` is a `RASK019` compile error. `\u003cbody\u003e` gets the live\nruntime `\u003cscript\u003e` injected as its last child automatically, so you no longer write\n`RaskRuntimeScript()`. The root must render the full shell (`Doctype`, `Html`, `Head`, `Body`); a\nmissing element is flagged at compile time by **RASK021** and fails fast at runtime.\n\n**`HomePage.cs`** — your first route. `Rask.Core.Routing` (for `[Route]`, `[RouteParam]`, `Navigator`, …) is the one\nnamespace you still bring in explicitly.\n\n```csharp\nusing Rask.Core.Routing;\n\nnamespace MyApp;\n\n[Route(\"/\")]\npublic sealed class HomePage : Component\n{\n    protected override RenderResult Render() =\u003e\n        [\n            H1()[\"Hello, world!\"],\n            P()[\"Welcome to your new Rask app.\"]\n        ];\n}\n```\n\nRun `dotnet run` and open the printed URL.\n\n## 🚀 Quick Start — WASM\n\nTwo flavours: a **standalone** SPA that ships as a static bundle, or a **hosted** variant where an ASP.NET project\nserves the published WASM bundle alongside your own `/api/...` endpoints. The `App.cs` and `HomePage.cs` from the\nserver quick start work under both, unchanged.\n\n### Standalone (`rask-wasm`)\n\nOne `net10.0-browser` project. `Program.cs`:\n\n```csharp\nusing Rask.Wasm;\nusing MyApp;\n\nvar host = WasmHostBuilder.CreateDefault();\nhost.Services.AddSingleton(_ =\u003e\n    new HttpClient { BaseAddress = new Uri(WasmHostBuilder.BaseAddress) });\n\nawait host.RunAsync\u003cApp\u003e();\n```\n\n`dotnet publish -c Release` emits a static `wwwroot/` directory you can serve from any static host (GitHub Pages, S3,\nnginx). No runtime server process is required for the framework itself — bring your own host for whatever public APIs\nthe client calls.\n\n### Hosted (`rask-wasm-hosted`)\n\nTwo projects: the WASM client itself, and an ASP.NET host that serves the published bundle. The host project takes a\ncross-TFM `\u003cProjectReference\u003e` to the WASM project; the auto-imported targets discover the bundle and bake the path\ninto an assembly attribute at publish.\n\n**WASM client `Program.cs`** (`net10.0-browser`):\n\n```csharp\nusing Rask.Wasm;\nusing MyApp;\n\nvar host = WasmHostBuilder.CreateDefault();\nhost.Services.AddSingleton(_ =\u003e\n    new HttpClient { BaseAddress = new Uri(WasmHostBuilder.BaseAddress) });\n\nawait host.RunAsync\u003cApp\u003e();\n```\n\n**Host `Program.cs`** (`net10.0`, with a `\u003cProjectReference\u003e` to the WASM project):\n\n```csharp\nusing Rask.Wasm.Hosting;\n\nvar builder = WebApplication.CreateBuilder(args);\nbuilder.Services.AddRask();              // opt-in: brotli/gzip response compression of the WASM payload\n\nvar app = builder.Build();\napp.UseRask();\napp.Run();\n```\n\n`AddRask()` is optional but recommended — it wires `UseResponseCompression` ahead of `UseStaticFiles` so the\nprecompressed `.br` / `.gz` siblings emitted by the WASM publish step go out with the right `Content-Encoding` and\nno runtime compression cost on the framework payload. Skip it and the host still works; you just lose the compression\nlayer.\n\n`app.UseRask()` mounts the published WASM `wwwroot` as static files with sensible MIME types, immutable caching for\nfingerprinted framework assets (no-cache revalidation for the rest), and a SPA fallback so client-side routes resolve.\nAdd your `/api/...` endpoints alongside it.\n\n## 🧰 Troubleshooting\n\nFirst-run snags and their fixes:\n\n- **`net10.0` / `net10.0-browser` won't restore, or WASM publish fails.** Rask requires the **.NET 10 SDK**. Check\n  with `dotnet --version` (≥ `10.0`). WASM projects (`rask-wasm`, the `.Wasm` half of `rask-wasm-hosted`) also need the\n  WebAssembly tooling — install it once with `dotnet workload install wasm-tools`.\n- **The IDE flags `HomePage()`, `Counter()`, `NavLink(...)`, or `Route\u003cT\u003e(...)` as undefined.** These are\n  **source-generated** — the factory for every `Component`, the URL builder for every `[Route]`. They don't exist until\n  the generator runs, which happens on build. Run `dotnet build` once, then reload the solution / restart the language\n  server so IntelliSense picks up the generated symbols.\n- **A scoped `.css` / `.js` file isn't taking effect.** The sibling file must sit in the same folder as its component\n  and share the base name (`Card.cs` ↔ `Card.css`). A `.css`/`.js` with no matching component, or one matching several,\n  is a build error: `RASK015`/`RASK016` for CSS, `RASK017`/`RASK018` for JS. Two components with scoped JS that share a\n  simple type name warn with `RASK020` (they'd collide at `window.Rask[Name]`). Check the build output.\n- **Blank page or 404s on `/_rask/...` assets behind a reverse proxy or sub-path.** The app is almost certainly running\n  under a URL prefix the framework doesn't know about — set `PathBase`. See *Sub-path hosting \u0026 side-by-side apps*\n  below.\n\n## 🌐 Sub-path hosting \u0026 side-by-side apps\n\nEvery Rask hosting model accepts a per-app URL prefix (`PathBase`). Set it once and every framework-emitted URL —\nhead asset `\u003clink\u003e`/`\u003cscript\u003e` tags, the runtime `\u003cscript\u003e` src, WebSocket connect, upload/download/auth endpoints,\nhistory `pushState` — is scoped under that prefix. The opposite direction is symmetric: paths going from the client\nback to .NET are stripped of the prefix so user-space route handlers stay unprefixed.\n\nUse cases:\n\n- **Reverse-proxy sub-path** — run two `Rask.Server` apps behind one origin: `app.UseRask\u003cAppA\u003e(pathBase: \"/appA\")`\n  and `app.UseRask\u003cAppB\u003e(pathBase: \"/appB\")` (typically separate processes).\n- **Two WASM apps in one host** — `app.UseRask\u003cAppA\u003e(pathBase: \"/appA\")` and\n  `app.UseRask\u003cAppB\u003e(pathBase: \"/appB\")` on a single `Rask.Wasm.Hosting` instance.\n- **GitHub Pages sub-path** — publish with `/p:RaskPathBase=/\u003crepo\u003e`. The framework rewrites the published\n  `index.html`'s `\u003cbase href\u003e` to `/\u003crepo\u003e/` at publish time; every asset URL and the SDK import map are\n  document-relative, so the WASM runtime auto-detects the prefix from `document.baseURI` on first paint and every\n  scoped-asset URL resolves under `/\u003crepo\u003e/_rask/a/{hash}.{ext}`.\n\n```csharp\n// Server\napp.UseRask\u003cApp\u003e(pathBase: \"/myapp\");\n\n// Wasm hosting (ASP.NET host serving a published wwwroot)\napp.UseRask\u003cApp\u003e(pathBase: \"/myapp\");\n// or the non-generic form: app.UseRask(pathBase: \"/myapp\")\n\n// WASM standalone — auto-detected from \u003cbase href\u003e; override only if needed:\nvar host = WasmHostBuilder.CreateDefault(o =\u003e o.PathBase = \"/myapp\");\n```\n\n```bash\n# WASM publish for GH Pages / sub-path deploy:\ndotnet publish MyWasmApp -c Release /p:RaskPathBase=/myapp\n```\n\nNormalization: `\"myapp\"`, `\"/myapp\"`, and `\"/myapp/\"` all become `/myapp` internally. `\"\"` and `\"/\"` mean root (the\ndefault — unchanged behaviour). The CI workflow shipping `Rask.Example.Wasm` to GitHub Pages\n(`.github/workflows/pages.yml`) uses this property — copy that pattern for your own sub-path deploys.\n\n## 🧪 Examples\n\nBeyond the quick starts, the repo ships runnable showcase apps that exercise every feature end-to-end:\n\n- **`samples/Rask.Example.Server`** / **`samples/Rask.Example.Wasm`** / **`samples/Rask.Example.Wasm.Host`** — the same\n  showcase under each\n  host model. Run one with `dotnet run --project samples/Rask.Example.Server` (or `samples/Rask.Example.Wasm.Host`) and\n  open the printed\n  URL.\n- **`samples/Rask.Example.Shared/Pages/`** — the feature-by-feature pages those hosts share: forms \u0026 validation,\n  nested-form\n  binding, routing, JS interop, virtualization (a 10K-row table), file upload/download, and **auth gating** (the `/user`\n  page shows both imperative `IUserProvider.Current` and the declarative `Authorize` component). These are the canonical\n  references cited throughout *Core concepts* below. For production auth flows see *\n  *[docs/authentication.md](docs/authentication.md)**.\n- **`samples/Rask.Example.EfCore`** — data persistence with **EF Core + SQLite** (Server host): a\n  CRUD catalogue organised as vertical slices, with a DDD aggregate + value objects, `IDbContextFactory`,\n  and money stored as integer minor units (SQLite has no decimal type). Run it with\n  `dotnet run --project samples/Rask.Example.EfCore` and open `/products`. Guide:\n  **[docs/data-access.md](docs/data-access.md)**.\n- **Runnable auth samples — one per cell of the `{Cookie, JWT} × {Server, WASM}` matrix**, each a minimal app\n  (`/login`, a protected `/members`, role-gated admin content, sign-out) backed by a browser E2E:\n    - **`Rask.Example.Auth`** — cookie + Server (the redeem handshake).\n    - **`Rask.Example.Auth.Jwt`** — JWT + Server; the token rides in **`ProtectedSessionStorage`** (encrypted, never in\n      the URL or JS).\n    - **`Rask.Example.Auth.WasmCookie(.Host)`** — cookie + WASM (HttpOnly cookie, `/api/me` hydration).\n    - **`Rask.Example.Auth.WasmJwt(.Host)`** — JWT + WASM (bearer in localStorage, `Authorization: Bearer`).\n\n  Run a server cell directly — `dotnet run --project samples/Rask.Example.Auth.Jwt` — and a WASM cell via its host —\n  `dotnet run --project samples/Rask.Example.Auth.WasmCookie.Host` — then visit `/members`. Sign in with\n  `alice` / `password` (user) or `root` / `password` (admin). To scaffold the same in a new project:\n  `dotnet new rask-server --auth`, `dotnet new rask-wasm-hosted --auth`, or `dotnet new rask-wasm --auth`. Full\n  guide: **[docs/authentication.md](docs/authentication.md)**.\n- **Live demo** — every push to `main` publishes `Rask.Example.Wasm` to GitHub Pages via\n  [`.github/workflows/pages.yml`](.github/workflows/pages.yml), so you can click through a full multi-page Rask app in\n  the browser before cloning anything.\n\n## 📚 Documentation\n\nThe collapsible sections below are a feature-by-feature tour. For step-by-step guides and reference,\nsee **[`docs/`](docs/)**:\n\n- **[Getting started](docs/getting-started.md)** — scaffold, first component, interactivity, routing.\n- **[Routing](docs/routing.md)** · **[Forms \u0026 validation](docs/forms.md)** · **[Lifecycle](docs/lifecycle.md)** · *\n  *[Authentication](docs/authentication.md)**\n- **[Testing](docs/testing.md)** · **[Migrating from Blazor](docs/migration-from-blazor.md)**\n- **[Diagnostics (RASK001–022)](docs/diagnostics.md)** — every build error/warning and its fix.\n- **[Live rendering \u0026 the diff codec](docs/architecture/live-rendering.md)** — how the runtime works under the hood.\n\n## 🧩 Core concepts\n\nThe framework, feature by feature. Each section is collapsed — click to expand the one you need.\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003e🔹 Components\u003c/b\u003e\u003c/summary\u003e\n\u003cbr\u003e\n\nEvery component is a `sealed class : Component`. Override `Render()` and return a tree. Children attach via the\n`Component this[params IEnumerable\u003cChild\u003e]` indexer — strings, `Component`s, and value types (`int`, `double`,\n`bool`, `DateOnly`, `Guid`, …) all convert implicitly to `Child`, so `H1()[\"Hello\"]`, `Div()[Span(...), \"text\"]`,\nand `Td()[f.TemperatureC]` (no `.ToString()`) all work. Value types render with `InvariantCulture`, so the HTML\nstays locale-independent.\n\nWhen you project a list into the children, no per-item cast is needed — `Tbody()[rows.Select(r =\u003e Tr(Key: r.Id)[...])]`\nbinds straight to the indexer (an `IEnumerable\u003cComponent\u003e` overload handles the LINQ-pipeline shape).\n\n`Render()` (and the `Head` override) return `RenderResult`, which accepts three shapes:\n\n- **A single component** — `Render() =\u003e Div()[...]` (converts implicitly).\n- **A collection expression** — `Render() =\u003e [Doctype(), Html(...)]` for multiple top-level nodes, with no wrapper\n  element. (This is sugar for `Fragment()[...]`; the items are grouped into a `Fragment` internally.)\n- **`default`** — render nothing / no contribution. Conditionals target-type each branch, so\n  `Render() =\u003e ready ? [Doctype(), Html(...)] : default;` works.\n\n```csharp\npublic sealed class Greeting : Component\n{\n    public string? Name { get; set; }\n    protected override RenderResult Render() =\u003e H1()[$\"Hello, {Name ?? \"world\"}!\"];\n}\n```\n\nThe source generator emits a `Greeting(...)` factory automatically:\n\n- Non-nullable property with no initialiser → **required** factory parameter.\n- Nullable property with no initialiser → optional, defaults to `null`.\n- Property with an initialiser → kept out of the factory; your default wins.\n- `[SkipFactory]` on a property excludes it explicitly.\n\nInject framework services through the **constructor**, not as properties:\n\n```csharp\npublic sealed class Weather(IWeatherForecastService service) : Component { ... }\n```\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003e⚡ Interactivity\u003c/b\u003e\u003c/summary\u003e\n\u003cbr\u003e\n\nLocal state on fields, event handlers as plain delegates. A click triggers a server round-trip (server host) or a local\nre-render (WASM host) — same code.\n\n```csharp\n[Route(\"/counter\")]\npublic sealed class Counter : Component\n{\n    private int _count;\n\n    protected override RenderResult Render() =\u003e\n        [\n            H1()[\"Counter\"],\n            P()[$\"Current count: {_count}\"],\n            Button(OnClick: () =\u003e _count++)[\"Click me\"]\n        ];\n}\n```\n\n**Child → parent callbacks are plain delegate props** — `Action`, `Action\u003cT\u003e`, `Func\u003cTask\u003e`, `Func\u003cT, Task\u003e`. There is\nno `Callback`/`EventCallback` type. The generated factory wraps a qualifying delegate so invoking it runs your handler\n**and then re-renders the component that owns it** (the lambda's `this`). The child stays oblivious to the parent, and\nthe parent never wires `StateHasChanged()` by hand:\n\n```csharp\n// Reusable child — knows nothing about the parent's state.\npublic sealed class RatingStars : Component\n{\n    public int Value { get; set; }\n    public Action\u003cint\u003e? OnRate { get; set; }   // a plain delegate prop\n\n    protected override RenderResult Render() =\u003e\n        Div()[\n            Enumerable.Range(1, 5).Select(i =\u003e (Child)Button(\n                OnClick: () =\u003e OnRate?.Invoke(i),  // child invokes; parent re-renders\n                Key: i)[i \u003c= Value ? \"★\" : \"☆\"])\n        ];\n}\n\n// Parent — the lambda captures `this`, so invoking OnRate re-renders this component.\npublic sealed class RatingDemo : Component\n{\n    private int _rating;\n\n    protected override RenderResult Render() =\u003e\n        [\n            RatingStars(Value: _rating, OnRate: n =\u003e _rating = n),\n            P()[_rating == 0 ? \"Click a star.\" : $\"You rated: {_rating}/5\"]\n        ];\n}\n```\n\nHTML element handlers (`Button.OnClick`, …) are **not** wrapped — they reach the DOM directly, where a re-render is\nalready free. Wrapping is confined to your own components, keeping the render hot path allocation-free. A static method,\nor a lambda closing over a local instead of `this`, has no component target, so no auto re-render fires — write the\nlambda inside the component that should update.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003e🧠 Context (provide / consume)\u003c/b\u003e\u003c/summary\u003e\n\u003cbr\u003e\n\nPass a value down the tree without prop-drilling, React-style. Provide it high up; read it deep, through intermediate\ncomponents that know nothing about it. `Context.Provide\u003cT\u003e(Value:)` is a transparent node (no DOM); consume with\n`Context.Get\u003cT\u003e()` (null if absent), `Context.Required\u003cT\u003e()` (throws), or `Context.Has\u003cT\u003e()`. Nearest provider wins,\nmatched by type — provide a concrete type, consume by an interface it implements.\n\n```csharp\npublic sealed record Theme(string Name, bool IsDark);\n\npublic sealed class ThemeDemo : Component\n{\n    private Theme _theme = new(\"Light\", false);\n\n    protected override RenderResult Render() =\u003e\n        Context.Provide\u003cTheme\u003e(Value: _theme)[      // provided to the whole subtree\n            Button(OnClick: () =\u003e _theme = _theme.IsDark ? new(\"Light\", false) : new(\"Dark\", true))[\n                $\"Toggle — {_theme.Name}\"],\n            ThemeCard()                             // intermediate: no theme prop passed in\n        ];\n}\n\npublic sealed class ThemeCard : Component       // theme-unaware; render-cached after first paint\n{\n    protected override RenderResult Render() =\u003e Div()[\"Nested: \", ThemeBadge()];\n}\n\npublic sealed class ThemeBadge : Component      // the consumer\n{\n    protected override RenderResult Render()\n    {\n        var theme = Context.Required\u003cTheme\u003e();  // reading latches it out of the render cache…\n        return Span()[theme.IsDark ? \"🌙 Dark\" : \"☀️ Light\"];\n    }\n}\n```\n\nReading a context value opts the consumer out of the render cache, so it re-reads when the provider re-renders — even\nstraight through a cached intermediate (here `ThemeCard` never re-renders, yet `ThemeBadge` updates on every toggle).\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003e⏳ Async data\u003c/b\u003e\u003c/summary\u003e\n\u003cbr\u003e\n\nOverride `OnMountAsync` (runs once per instance) or `OnPropsChangedAsync` (runs every render). Each `await`\ntriggers an automatic re-render after the continuation, so a loading placeholder turns into real data with no manual\n`StateHasChanged()`.\n\nThe runtime coalesces these into **one payload per handler dispatch**, and the terminal auto re-render is a\n*publish-only* walk that won't re-fire `OnRendered` on components that already rendered. That keeps an\n`OnRenderedAsync` hook which awaits a next-frame JS call (e.g. drawing a chart) from looping on itself —\nnewly-mounted children still get their first `OnRendered(firstRender: true)`.\n\n```csharp\n[Route(\"/weather\")]\npublic sealed class Weather(IWeatherForecastService service) : Component\n{\n    private WeatherForecast[]? _forecasts;\n\n    protected override async Task OnMountAsync() =\u003e\n        _forecasts = await service.GetForecastsAsync();\n\n    protected override RenderResult Render() =\u003e\n        _forecasts is null\n            ? P()[Em()[\"Loading...\"]]\n            : Table()[/* render rows */];\n}\n```\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003e🧭 Routing\u003c/b\u003e\u003c/summary\u003e\n\u003cbr\u003e\n\n`[Route]` registers a page. `[RouteParam]` and `[QueryParam]` bind URL pieces to properties. The generator emits a\nstrongly-typed URL builder for each route, so links don't carry stringly-typed paths.\n\n```csharp\n[Route(\"/users/{id}\")]\npublic sealed class UserPage : Component\n{\n    [RouteParam] public int Id { get; set; }\n    [QueryParam] public string? Tab { get; set; }\n\n    protected override RenderResult Render() =\u003e Span()[$\"User #{Id} — {Tab ?? \"overview\"}\"];\n}\n\n// elsewhere:\nNavLink(UserPage(id: 42))[\"View user\"];\n```\n\nInside event handlers, navigate via the scoped `Navigator` service: `nav.Navigate(HomePage())`,\n`nav.SetQuery(\"tab\", \"settings\")`, etc. Inject it through the constructor like any other service.\n\nMark a component `[NotFound]` to register it as the catch-all 404 page; the framework falls back to a minimal\nbuilt-in page if no app-defined one exists.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003e🔐 Auth gating (inject \u003ccode\u003eIUserProvider\u003c/code\u003e)\u003c/b\u003e\u003c/summary\u003e\n\u003cbr\u003e\n\nInject `IUserProvider` via the constructor and read `.Current` — a never-null `ClaimsPrincipal` resolved from the\nscoped provider (back it with a cookie/JWT on Server, or `/api/me` on WASM). Gate **imperatively** in `Render()` with\nplain C#, and subscribe to the provider's `Changed` event so a sign-in originating anywhere re-renders the gate:\n\n```csharp\npublic sealed class AccountPanel : Component\n{\n    private readonly IUserProvider _auth;\n    public AccountPanel(IUserProvider auth) =\u003e _auth = auth;   // inject via the ctor\n\n    protected override void OnMount() =\u003e _auth.Changed += StateHasChanged;\n    protected override void OnUnmount() =\u003e _auth.Changed -= StateHasChanged;\n\n    protected override RenderResult Render() =\u003e\n        _auth.Current.Identity?.IsAuthenticated == true\n            ? Fragment()[\n                P()[\"Signed in as \", Strong()[_auth.Current.Identity!.Name ?? \"?\"]],\n                _auth.Current.IsInRole(\"admin\")             // role-gated branch\n                    ? Div()[\"🔑 Admin-only panel\"]\n                    : (Child)Fragment()]\n            : P()[\"You are signed out.\"];\n}\n```\n\nOr gate **declaratively** with the headless `Authorize` component — three slots, no markup of its own:\n\n```csharp\nAuthorize(\n    Roles: [\"admin\"],\n    Authorized:    Div()[\"🔑 Admin tools\"],\n    NotAuthorized: A(Href: \"/login\")[\"Sign in\"],\n    Authorizing:   Spinner());                 // shown while the principal/policy resolves\n```\n\nFor whole-page gating, put `[Authorize]` (optionally `[Authorize(Roles = \"admin\")]`) or `[AllowAnonymous]` on the page\ncomponent — the `RouteAuthorizationGuard` enforces it before the page renders.\n\n**Going to production?** See **[docs/authentication.md](docs/authentication.md)** for complete, copy-pasteable flows:\ncookie \u0026 JWT on both Server and WASM, ASP.NET Identity, Keycloak/OIDC, protected token storage, the\nauth configured through ASP.NET's own AddCookie/AddJwtBearer, and a security checklist.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003e📑 Page head contributions\u003c/b\u003e\u003c/summary\u003e\n\u003cbr\u003e\n\nAny component can override `protected virtual RenderResult Head` to declare what belongs in `\u003chead\u003e` while that\ncomponent\nis in the tree. The default is `default` — no contribution; a single tag, a collection expression of several tags, or\n`default` for \"nothing\" are all valid (e.g. `Head =\u003e loggedIn ? [Meta(...)] : default`):\n\n```csharp\npublic sealed class UserDetailPage : Component\n{\n    [RouteParam] public int Id { get; set; }\n\n    // The framework dedupes by rendered HTML; \u003ctitle\u003e and \u003cbase\u003e are singleton\n    // tags — last contributor wins. So this page's Title overrides App's\n    // fallback when the user lands on /users/42.\n    protected override RenderResult Head =\u003e [\n        Title()[$\"User #{Id} — My Rask App\"],\n        Meta(Name: \"description\", Content: $\"Profile for user {Id}\"),\n        Link(Rel: \"stylesheet\", Href: \"https://cdn.example.com/profile.css\")\n    ];\n\n    protected override RenderResult Render() =\u003e /* … */;\n}\n```\n\nWhen the user navigates away, the page leaves the tree, its contributions drop from the registry, and the next\nrender's `\u003chead\u003e` reflects whatever components remain. Multiple instances of the same `Link(Href: \"...\")` dedupe to\na single emission. The `Head()` HTML element itself is **framework-managed** — passing it children is a `RASK019`\ncompile error; everything goes through the override.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003e🛡️ Error boundaries\u003c/b\u003e\u003c/summary\u003e\n\u003cbr\u003e\n\nWrap any subtree in `ErrorBoundary(...)` to catch render-time, sync/async lifecycle, and event-handler exceptions\nthrown by descendants. The fallback receives the exception plus a `recover` callback so the boundary can be reset.\n\n```csharp\nErrorBoundary(\n    Fallback: (ex, recover) =\u003e Div()[\n        Strong()[\"Something went wrong: \"], ex.Message,\n        Button(OnClick: recover)[\"Try again\"]\n    ])[\n    // any subtree — render, lifecycle, or handler faults all bubble here\n    RiskyChild()\n]\n```\n\nWithout a `Fallback`, the boundary renders a built-in default error page. The `recover` callback passed to the\nfallback is the only reset path.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003e✅ Forms \u0026amp; validation\u003c/b\u003e\u003c/summary\u003e\n\u003cbr\u003e\n\nBind inputs two-way with `Input(Bind: () =\u003e model.Field)` — the input type is inferred from the property's CLR type\n(string → text, bool → checkbox, int → number, DateOnly → date, …) and new values flow back into the model on each\nevent. `Form\u003cTModel\u003e(model, OnValidSubmit: …, OnInvalidSubmit: …)` routes submit through whichever validators are\nattached to its `EditContext`. Field errors render via `ValidationMessage` and a top-of-form digest via\n`ValidationSummary` — both are headless and take a required `Template:` lambda so you control the markup\n(e.g. `Template: errs =\u003e Div(Class: \"err\")[errs[0]]`).\n\nA bound `Select(Bind: () =\u003e model.Field)[Option(...), ...]` **pre-selects the option matching the current model value**\n— including a non-first option — even when the options are supplied through the `[...]` indexer; the marking is\ndeferred to serialize time so the rendered `\u003cselect\u003e` always reflects the bound state on first paint.\n\n`Input` / `Select` / `Textarea` also accept `AfterBind` / `AfterBindAsync` callbacks that fire **after** the new value\nis written to the model (and after validators see the change) — handy for dependent fields that need to rebind in the\nsame render. Skipped on parse failure or no-op writes.\n\nValidation comes in layers. **The lightest ships in `Rask.Core` — inline `Validate:` lambdas, no extra package:**\n\n- **Per-field inline rule** — pass a `Validate:` lambda directly to an `Input`. Three overloads cover the common\n  shapes: omit it, return `IEnumerable\u003cstring\u003e` for sync rules, or return `ValueTask\u003cIEnumerable\u003cstring\u003e\u003e` for async\n  (the `CancellationToken` cancels the in-flight check on the next keystroke). An empty sequence means valid; any\n  returned strings become the field's errors.\n- **Cross-field rule on the form** — pass `Validate:` to `Form\u003cTModel\u003e` to run a model-level check on submit (great\n  for \"passwords must match\" or \"either email or phone is required\"). `[FactoryGeneric]` narrows the lambda's\n  parameter to `TModel` so it's strongly typed.\n\n```csharp\nForm\u003cSignupModel\u003e(_model, OnValidSubmit: m =\u003e Console.WriteLine(m.Username))[\n    Input(Bind: () =\u003e _model.Username,\n          Validate: name =\u003e name.Length \u003c 3 ? [\"Username is too short\"] : []),\n    ValidationMessage(For: () =\u003e _model.Username,\n        Template: errs =\u003e Div(Class: \"field-error\")[errs[0]]),\n    Button(Type: \"submit\")[\"Sign up\"]\n]\n```\n\n**For attribute- or rules-based validation, opt into a package** and drop its validator component inside the form — it\nwires into the form's `EditContext` and covers the whole reachable model graph from one place:\n\n- **`Rask.Validation.DataAnnotations`** — `DataAnnotationsValidator()` wires `[Required]` / `[EmailAddress]` / `[Range]`\n  / `IValidatableObject` into the form's `EditContext`.\n- **`Rask.Validation.FluentValidation`** — `FluentValidationValidator(new MyValidator())` delegates to a\n  `FluentValidation.IValidator`, including async rules via `MustAsync`.\n\n```csharp\npublic sealed class SignupModel\n{\n    [Required, StringLength(20, MinimumLength = 3)] public string Username { get; set; } = \"\";\n    [Required, EmailAddress]                        public string Email    { get; set; } = \"\";\n}\n\n[Route(\"/signup\")]\npublic sealed class SignupPage : Component\n{\n    private readonly SignupModel _model = new();\n\n    protected override RenderResult Render() =\u003e\n        Form\u003cSignupModel\u003e(_model, OnValidSubmit: m =\u003e Console.WriteLine(m.Username))[\n            DataAnnotationsValidator(),                         // opt-in: DA attributes\n            Input(Bind: () =\u003e _model.Username),\n            ValidationMessage(For: () =\u003e _model.Username,\n                Template: errs =\u003e Div(Class: \"field-error\")[errs[0]]),\n            Input(Bind: () =\u003e _model.Email),\n            ValidationMessage(For: () =\u003e _model.Email,\n                Template: errs =\u003e Div(Class: \"field-error\")[errs[0]]),\n            Button(Type: \"submit\")[\"Sign up\"]\n        ];\n}\n```\n\n#### Async validation\n\nThe inline `Validate:` lambda already covers async per-field rules — return a `ValueTask\u003cIEnumerable\u003cstring\u003e\u003e` and the\nsubmit bridge awaits it before routing, with rapid keystrokes cancelling any prior in-flight check (latest-wins). Reach\nfor a full **`IAsyncFieldValidator`** when the rule needs DI (an `HttpClient`, a repository) or you want to reuse it\nacross forms: implement it and add it to a manually built `EditContext`. `ValidatingIndicator` is headless too — pass a\n`Template:` lambda for whatever should show while the field is being checked (e.g.\n`Template: () =\u003e Span()[\"Checking...\"]`).\n\n```csharp\npublic sealed class UniqueUsernameValidator : IAsyncFieldValidator\n{\n    public async ValueTask ValidateFieldAsync(\n        EditContext ctx, FieldIdentifier field, CancellationToken ct)\n    {\n        if (ctx.Model is SignupModel m \u0026\u0026 field.FieldName == nameof(SignupModel.Username))\n        {\n            await Task.Delay(400, ct);                    // pretend it's an API call\n            if (await IsTakenAsync(m.Username))\n                ctx.AddValidationMessage(field, \"Already taken.\");\n        }\n    }\n    public ValueTask ValidateAsync(EditContext c, CancellationToken ct) =\u003e default;\n}\n\nprivate readonly SignupModel _model = new();\nprivate EditContext? _ctx;\n\nprotected override void OnMount()\n{\n    _ctx = new EditContext(_model);\n    _ctx.AddValidator(new UniqueUsernameValidator());\n}\n\nprotected override RenderResult Render() =\u003e\n    Form\u003cSignupModel\u003e(_model, Context: _ctx, OnValidSubmit: m =\u003e Console.WriteLine(m.Username))[\n        DataAnnotationsValidator(),\n        Input(Bind: () =\u003e _model.Username),\n        ValidatingIndicator(For: () =\u003e _model.Username,\n            Template: () =\u003e Span(Class: \"spinner\")[\"Checking...\"]),\n        ValidationMessage(For: () =\u003e _model.Username,\n            Template: errs =\u003e Div(Class: \"field-error\")[errs[0]]),\n        Button(Type: \"submit\")[\"Sign up\"]\n    ];\n```\n\n#### Complex models — sub-objects and lists\n\n`Bind` and validation extend transparently through nested sub-objects and collections. A single\n`DataAnnotationsValidator()` or `FluentValidationValidator(...)` at the top of the form covers the whole reachable\ngraph — there's no per-level opt-in. Validation messages key off the **owner sub-instance**, not a dotted path from\nthe root, so removing or replacing a row drops its error state with it.\n\n```csharp\npublic sealed class CheckoutModel\n{\n    [Required] public string Name { get; set; } = \"\";\n    public AddressModel Address { get; set; } = new();\n    public List\u003cLineItem\u003e Items { get; set; } = new();\n}\n\npublic sealed class AddressModel\n{\n    [Required] public string Street { get; set; } = \"\";\n    [Required, RegularExpression(\"^[A-Z]{2}$\")] public string Country { get; set; } = \"\";\n}\n\npublic sealed class LineItem\n{\n    [Required] public string Description { get; set; } = \"\";\n    [Range(1, int.MaxValue)] public int Quantity { get; set; } = 1;\n}\n```\n\n**Sub-object binding** uses the same `Bind: () =\u003e ...` shape as flat models:\n\n```csharp\nInput(Bind: () =\u003e _model.Address.Street),\nValidationMessage(For: () =\u003e _model.Address.Street,\n    Template: errs =\u003e Div(Class: \"field-error\")[errs[0]]),\n```\n\n**Collection binding — foreach + per-item capture** is the canonical pattern. Each iteration captures a different\n`item` reference into its own closure, so each row's lambda points at a distinct instance:\n\n```csharp\nforeach (var item in _model.Items)\n{\n    rows.Add(Tr()[\n        Td()[Input(Bind: () =\u003e item.Description)],\n        Td()[Input(Bind: () =\u003e item.Quantity)],\n        Td()[Button(Type: \"button\", OnClick: () =\u003e _model.Items.Remove(item))[\"×\"]]\n    ]);\n}\n```\n\n**Collection binding — indexer style** is the alternative when you need the row number for UI (reorder buttons,\n\"Row #3\" labels) or when items are records that get replaced rather than mutated — `() =\u003e model.Items[i].Name`\nre-resolves the indexer every render, so the binding follows the new slot value through replacement. Watch out for\nthe classic `for (int i = …)` closure trap: copy the index into a per-iteration local before the lambda captures it.\n\n```csharp\nfor (var idx = 0; idx \u003c _model.Items.Count; idx++)\n{\n    var i = idx;                                      // \u003c-- per-iteration capture, NOT idx\n    rows.Add(Tr()[\n        Td()[$\"#{i + 1}\"],\n        Td()[Input(Bind: () =\u003e _model.Items[i].Description)],\n        Td()[Input(Bind: () =\u003e _model.Items[i].Quantity)]\n    ]);\n}\n```\n\n`foreach` doesn't have the closure trap. Records with init-only properties can't be auto-bound via the `Bind` setter\n— either declare the record properties as mutable (`{ get; set; }`), or use the indexer pattern with a manual\n`OnChange` that replaces the slot with `_model.Items[i] = _model.Items[i] with { Field = newValue }`.\n\n**FluentValidation nesting** uses `SetValidator(...)` and `RuleForEach(...).SetValidator(...)` in the user validator\n— Rask routes the dotted `error.PropertyName` (`Address.Street`, `Lines[0].Quantity`) back to the runtime sub-\ninstance so `ValidationMessage(For: () =\u003e _model.Address.Street, ...)` reads it off the right slot.\n\n**Trimming caveat.** Validating a nested graph reflects over every reachable model type. The trimming contract that\nalready applies to the root model (preserve its public properties via `[DynamicallyAccessedMembers]` or a\n`\u003cTrimmerRootDescriptor\u003e`) extends to every nested type. The full Forms/Complex-models showcase under `/nested-forms`\ndemonstrates all four patterns side-by-side.\n\n#### Radio \u0026 checkbox groups\n\n`RadioGroup\u003cTValue\u003e` binds one value from a set of options; `CheckboxGroup\u003cTItem\u003e` binds an `ICollection\u003cTItem\u003e`,\ntoggling each item in place. Both are transparent `Fragment`s built on the same `Input.Bound` machinery as the rest of\nthe form, so changes flow through the `EditContext` (validation, touched-tracking) like any bound field:\n\n```csharp\nForm(_prefs)[\n    RadioGroup(\n        () =\u003e _prefs.Plan,                                  // single value\n        Options: new[] { Plan.Free, Plan.Pro, Plan.Team },\n        OptionLabel: p =\u003e Span()[p.ToString()]),\n\n    CheckboxGroup\u003cstring\u003e(                                  // a collection — toggles in place\n        () =\u003e _prefs.Interests,\n        Options: new[] { \"Web\", \"Mobile\", \"AI\", \"Games\" },\n        OptionLabel: t =\u003e Span()[t])\n]\n```\n\n`CheckboxGroup` usually needs the explicit type argument (`CheckboxGroup\u003cstring\u003e`) when the bound collection is a\nconcrete `List\u003cT\u003e`. Membership is compared with `EqualityComparer\u003cTItem\u003e.Default`. Changing any option re-renders the\ncomponent that declared the group, so a live summary updates immediately.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003e📎 Files: upload and download\u003c/b\u003e\u003c/summary\u003e\n\u003cbr\u003e\n\n`Input(Type: \"file\", OnFiles: …)` accepts files; `Navigator.Download(...)` sends them. The same component code runs\nunchanged on Server and WASM — only the transport differs (multipart over the WebSocket on the server, JS-Map +\nchunked reads on WASM; downloads go through `/_rask/download/{token}` on the server, base64 + Blob URL on WASM).\n\n```csharp\nInput(Type: \"file\", OnFiles: async files =\u003e {\n    var file = files[0];                                         // RaskFile\n    using var s = file.OpenReadStream(maxAllowedSize: 5_000_000); // valid only inside this handler\n    await s.CopyToAsync(destination);\n})\n```\n\n```csharp\npublic sealed class ReportPage(Navigator nav) : Component\n{\n    private void Download() =\u003e\n        nav.Download(\"report.txt\",\n                     Encoding.UTF8.GetBytes(\"hello\"),\n                     \"text/plain\");\n\n    protected override RenderResult Render() =\u003e\n        Button(OnClick: Download)[\"Download report\"];\n}\n```\n\n`RaskFile` exposes `Name`, `Size`, `ContentType`, `LastModified`, plus `OpenReadStream(maxAllowedSize, ct)`. The\nstream is only valid while the handler is on the stack — read whatever you need before returning. Inside a `Form`,\nfiles also surface through `FormData.Files(name)` and participate in submit. `Navigator.Download` must be called from\nan event handler. See `samples/Rask.Example.Shared/Pages/UploadPage.cs` and `DownloadPage.cs` for the canonical demos.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003e📜 Virtualization\u003c/b\u003e\u003c/summary\u003e\n\u003cbr\u003e\n\n`Virtualize\u003cT\u003e` is a headless windowed-list primitive — it emits no DOM of its own and instead invokes the `Render`\ndelegate with the visible window of items plus the spacer offsets you wire into your own scroll container.\n\n```csharp\nVirtualize\u003cRow\u003e(\n    Items: _rows,                                  // or ItemsProvider for async paging\n    ItemSize: 32,                                  // pixel height of one row\n    OverscanCount: 4,\n    InitialClientHeight: 400,\n    Render: ctx =\u003e Div(\n        Style: \"height:400px; overflow:auto;\",\n        OnScroll: ctx.OnScroll)[\n        Div(Style: $\"height:{ctx.OffsetBefore}px\"),  // spacer for off-screen rows above\n        Table()[\n            Tbody()[\n                ctx.VisibleItems.Select(item =\u003e Tr(Key: item.Index)[\n                    Td()[$\"#{item.Index}\"],\n                    Td()[item.Value?.Name ?? \"\"]    // null while a placeholder is loading\n                ]).ToArray()\n            ]\n        ],\n        Div(Style: $\"height:{ctx.OffsetAfter}px\")    // spacer for off-screen rows below\n    ])\n```\n\nProvide exactly one of `Items` (in-memory) or `ItemsProvider` (async paging:\n`Func\u003cItemsProviderRequest, ValueTask\u003cItemsProviderResult\u003cT\u003e\u003e\u003e`). With a provider, `Virtualize` caches loaded items by\nglobal index, requests missing windows in the background, and emits placeholder rows with `IsPlaceholder = true` until\na fetch completes. See `samples/Rask.Example.Shared/Pages/VirtualizePage.cs` for a 10K-row table demo.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003e🔀 Drag \u0026 drop\u003c/b\u003e\u003c/summary\u003e\n\u003cbr\u003e\n\n`DragDrop` is a headless drag-and-drop primitive — it owns no DOM and tracks only the in-flight drag, handing your\n`Body` delegate a `DragDropContext`. You draw the draggable items and drop zones, wire the context's\n`DragStart` / `DragOver` / `Drop` / `DragEnd` onto them, and move your own data when `OnDrop` reports where the drag\nlanded. **Zones are arbitrary string keys** — one zone is a sortable list, several are a Kanban board.\n\n```csharp\nDragDrop(\n    Body: ctx =\u003e Ul()[\n        _fruits.Select((fruit, i) =\u003e Li(\n            Key: fruit,                                       // stable Key → trusted keyed reconciliation\n            Draggable: true,\n            Class: ctx.IsDropTarget(\"list\", i) ? \"drop-target\" : null,\n            OnDragStart: ctx.DragStart(\"list\", i),\n            OnDragOver: ctx.DragOver(\"list\", i),              // optional: live drop-target highlight\n            OnDrop: ctx.Drop(\"list\", i),\n            OnDragEnd: ctx.DragEnd)[fruit])\n    ],\n    OnDrop: m =\u003e Reorder(m.FromIndex, m.ToIndex))             // m: (FromZone,FromIndex) -\u003e (ToZone,ToIndex)\n```\n\nDrag handlers are **parameterless** (like `OnClick`): the dragged item's identity rides the handler closure, not the\nevent payload, so no custom wire type is needed. `Draggable` / `OnDragStart` / `OnDragOver` / `OnDrop` / `OnDragEnd` are\nuniversal attributes on every element. A multi-column Kanban board is the same primitive with one zone per column — a\nsingle `OnDrop` handler moves the card across lists. See `samples/Rask.Example.Shared/Pages/DragDropPage.cs` for both a\nsortable list and a Kanban board.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003e🎨 Scoped CSS\u003c/b\u003e\u003c/summary\u003e\n\u003cbr\u003e\n\nDrop a sibling `{Component}.css` file next to `{Component}.cs` and the source generator pairs them at compile time —\nselectors are auto-scoped to that component type, delivered per-component over a content-addressed HTTP endpoint, and\nhot-reloaded under `dotnet watch`.\n\n```csharp\n// Card.cs\npublic sealed class Card : Component\n{\n    protected override RenderResult Render() =\u003e\n        Div(Class: \"card\")[\"...\"];\n}\n```\n\n```css\n/* Card.css — sibling file, no extra wiring */\n.card { padding: 1rem; border-radius: 8px; border: 1px solid #ddd; }\n.card:hover { background: #f7f7f7; }\n```\n\nThe showcase uses this throughout: `App.css`, `HomePage.css`, `ScopedRed.css` / `ScopedBlue.css`,\nand the `Layout/ShowcaseLayout.css` for the sidebar. Two components can use the same `.box` selector — the framework\nrewrites each to `.box[data-{scopeId}]` so they never collide. An orphan `.css` file with no matching component raises\n`RASK015`; two `.css` files claiming the same component raise `RASK016`; opt the whole project out with\n`\u003cRaskScopedCssAutoInclude\u003efalse\u003c/RaskScopedCssAutoInclude\u003e` in the `.csproj`.\n\nDelivery is **per-component and content-addressed**, identical on Server and WASM. The framework auto-emits one\n`\u003clink rel=\"stylesheet\" href=\"/_rask/a/{hash}.css\" data-rask-key=\"rsk-css-{hash}\"\u003e` per mounted component type that has\na registered stylesheet, spliced into the framework-managed `\u003chead\u003e` (see *Page head contributions* above) — no call\nsite or placement required. Each URL is a 12-hex SHA-256 of the rewritten CSS, served with\n`Cache-Control: public, max-age=31536000, immutable` + an `ETag`, so two components whose rewritten CSS is byte-equal\nshare one cached file. Standalone/static-file WASM hosts (no in-process endpoint) get the same files baked into the published\n`wwwroot/_rask/a/{hash}.css` (as static web assets) at publish, so a plain static server serves exactly what the\nendpoint would.\n\n**Targeting shell tags** — selectors like `body`, `html`, `button` don't carry `data-{scopeId}` (those tags are\nintentionally excluded from stamping), so a sibling rule like `body { ... }` would never match. Wrap the selector in\n`:global(...)` to opt out of scoping:\n\n```css\n:global(body) {\n    overscroll-behavior-y: none;\n    padding-left: env(safe-area-inset-left);\n}\n:global(button), :global(a) { touch-action: manipulation; }\n```\n\nThe wrapper is stripped at compile time and the rule emits exactly the inner selector. `:global()` also works inside\n`@media` / `@supports` / `@container` / `@layer` blocks.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003e🟨 Scoped JS\u003c/b\u003e\u003c/summary\u003e\n\u003cbr\u003e\n\nDrop a sibling `{Component}.js` file next to `{Component}.cs` to colocate behavior with markup. The file exports any\nnumber of named functions; the framework wraps each file as\n`window.Rask[\"{TypeName}\"] = (function () { /* exports */ return { … }; })();` — so each\n`export function rendered(...) { ... }`\nbecomes `window.Rask.{TypeName}.rendered`. Delivery mirrors scoped CSS: per-component and content-addressed, identical\non Server and WASM. The framework auto-emits one `\u003cscript src=\"/_rask/a/{hash}.js\" defer data-rask-key=\"rsk-js-{hash}\"\u003e`\nper mounted component type with a registered script. `defer` means scoped JS runs after parse, so any CDN scripts you\nload earlier in `\u003chead\u003e` (without `defer`) initialise first.\n\nDispatch goes through the standard `Microsoft.JSInterop.IJSRuntime`. Inject it via constructor and call\n`InvokeVoidAsync(\"Rask.{TypeName}.{method}\", args...)` from a lifecycle hook (typically `OnRenderedAsync`). The\nframework does not pass an `el` argument — user JS queries the DOM itself, via a marker class or attribute the\ncomponent renders.\n\n```js\n// CodeSample.js — sibling of CodeSample.cs\nexport function rendered(firstRender) {\n    const codes = document.querySelectorAll('.sample-card code[class*=\"language-\"]');\n    codes.forEach(code =\u003e {\n        delete code.dataset.highlighted;\n        window.hljs.highlightElement(code);\n    });\n}\n```\n\n```csharp\npublic sealed class CodeSample(IJSRuntime js) : Component\n{\n    // The framework does NOT auto-fire scoped-JS hooks. OnRenderedAsync is the\n    // typical hook — it gets a firstRender bool that flows straight through to\n    // your `rendered(firstRender)` function.\n    protected override async Task OnRenderedAsync(bool firstRender) =\u003e\n        await js.InvokeVoidAsync(\"Rask.CodeSample.rendered\", firstRender);\n\n    protected override RenderResult Render() =\u003e\n        Div(Class: \"sample-card\")[ /* the marker class user JS will query */ ];\n}\n```\n\nYou can call from any lifecycle hook (`OnMount*`, `OnRendered*`, event handlers, …) and from anywhere else where\n`IJSRuntime` is in scope. Orphan `.js` files (no matching `.cs` in the same folder) raise `RASK017`; ambiguous matches\nraise `RASK018`. Opt out per-project with `\u003cRaskScopedJsAutoInclude\u003efalse\u003c/RaskScopedJsAutoInclude\u003e`.\n\n#### Async JS interop\n\nFor round-trips where C# needs a value back from JS, use `IJSRuntime.InvokeAsync\u003cT\u003e`:\n\n```csharp\npublic sealed class Measure(IJSRuntime js) : Component\n{\n    protected override async Task OnRenderedAsync(bool firstRender)\n    {\n        if (!firstRender) return;\n        int height = await js.InvokeAsync\u003cint\u003e(\"Rask.Measure.getScrollHeight\");\n        // … do something with height\n    }\n}\n```\n\n```js\n// Measure.js\nexport function getScrollHeight() {\n    return document.querySelector('.measure-target').scrollHeight;   // sync return is fine\n}\n// async functions also work — the dispatcher awaits the returned Promise\nexport async function loadFromCdn(url) {\n    const r = await fetch(url);\n    return await r.text();\n}\n```\n\nOn **WASM**, the standard Blazor-WASM trimming constraint applies: `T` in `InvokeAsync\u003cT\u003e` must be kept rooted on the\ncall site (via `[DynamicallyAccessedMembers]` or a `JsonSerializerContext`). JSON primitives (`bool`, `int`, `long`,\n`double`, `string`) are always safe. Server has no such constraint.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003e🎯 Element refs (JS interop)\u003c/b\u003e\u003c/summary\u003e\n\u003cbr\u003e\n\nEvery element takes a `Ref:` parameter so you can reach the live DOM node from C#. Mint one with `ElementRef.New()`,\nstore it in a **field** (so its id stays stable across renders), and pass it to `IJSRuntime` — it serializes to a marker\nthe client revives into the real element before your JS runs:\n\n```csharp\npublic sealed class MeasureDemo : Component\n{\n    private readonly IJSRuntime _js;\n    private readonly ElementRef _input = ElementRef.New();\n    private readonly ElementRef _box = ElementRef.New();\n\n    public MeasureDemo(IJSRuntime js) =\u003e _js = js;\n\n    protected override RenderResult Render() =\u003e\n        Div()[\n            Input(Ref: _input, Type: \"text\"),\n            Div(Ref: _box)[\"A box whose width JS will read.\"],\n            Button(OnClickAsync: () =\u003e _input.FocusAsync(_js))[\"Focus the input\"],\n            Button(OnClickAsync: MeasureBox)[\"Measure the box\"]\n        ];\n\n    private async Task MeasureBox()\n    {\n        // The ref resolves to the real element before width() is called with it.\n        var width = await _js.InvokeAsync\u003cdouble\u003e(\"Rask.MeasureDemo.width\", _box);\n        // … use width …\n    }\n}\n```\n\nBuilt-in helpers cover the common cases without any JS of your own: `ElementRefInterop.FocusAsync`, `BlurAsync`,\n`ScrollIntoViewAsync` (and `ElementRef.FocusAsync(js)` as shown). For anything else, hand the ref to your scoped JS.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003e🔑 Keyed lists \u0026amp; reconciliation\u003c/b\u003e\u003c/summary\u003e\n\u003cbr\u003e\n\nWhen you render a dynamic list, give each item a stable `Key:` so the framework can reconcile by **identity** instead of\nby position. `Key` is the last optional parameter on **every** generated factory (Blazor `@key` parity) — it takes any\n`object?`:\n\n```csharp\nUl()[\n    _todos.Select(item =\u003e Li(Key: item.Id, Class: \"list-group-item\")[\n        Input(Bind: () =\u003e item.Done),\n        Span()[item.Title]\n    ])\n]\n```\n\nWith keys, an insert / remove / reorder ships as a **trusted structural diff** (Insert/Remove/Move) instead of a\npositional full-HTML morph — so the surviving rows keep their focus, selection, scroll position, and uncommitted input\nstate across the change. Without keys, the same edit reconciles by position and can blow away that DOM/IDL state on\nevery row after the edit point.\n\nA few things worth knowing:\n\n- **It's an identity, not a reactive prop.** A `Key` change doesn't fire `OnPropsChanged` — a different key is a\n  different logical item, so it mounts fresh. Keys are excluded from the props diff, so a propertyless component keeps\n  its fast path.\n- **On elements** `Key` emits `data-rask-key`; on a **transparent component or `Fragment`** it auto-forwards onto that\n  item's first rendered element (so a keyed list item should render a single root element).\n- **`Data[\"rask-key\"]` still works** for back-compat; when both are set, `Key` wins.\n\n**RASK022** (warning) nudges you when a list item is missing a key — it fires on a `.Select(...)` / `.SelectMany(...)`\nprojection whose body becomes a `Child`, or an element `.Add(...)`-ed to a `List\u003cChild\u003e` inside a loop. Add a `Key:`\n(or a `Data` `rask-key`) to clear it. Suppress per-site with `#pragma warning disable RASK022`, or promote it to an\nerror with `\u003cWarningsAsErrors\u003eRASK022\u003c/WarningsAsErrors\u003e` in the `.csproj`.\n\nSee `samples/Rask.Example.Shared/Pages/KeyedListsPage.cs` for an interactive demo — type into a row, then reorder with\nkeys\non vs off to watch DOM state follow (or not follow) its row.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003e🔁 Live rendering \u0026amp; the diff codec\u003c/b\u003e\u003c/summary\u003e\n\u003ca id=\"live-rendering--the-diff-codec\"\u003e\u003c/a\u003e\n\u003cbr\u003e\n\nA Rask app stays live after the first paint: the server pushes re-renders over a WebSocket, and WASM re-renders in the\nbrowser — the **same component code drives both**, only the transport differs. When state changes (an event handler\nmutates a field, an `await` resolves), the runtime renders the tree again and reconciles the DOM in place.\n\nWhat it sends on the wire is the interesting part. The first render ships the full HTML. After that, a small state\nchange ships a **minimal edit-op payload** — a handful of text / attribute / subtree operations the client walks\nagainst the live DOM — instead of re-serializing the whole body. A counter tick on a large page goes from the entire\nrendered document to a few dozen bytes: an order-of-magnitude saving on every interaction, with no change to how you\nwrite components.\n\nThis is on by default. `LiveDiffMode.Auto` (the framework default) uses a choose-smaller heuristic: ship the diff when\nit beats the full HTML on bytes, otherwise fall back to full HTML transparently. You don't opt in — but you can tune it:\n\n```csharp\n// Server\nbuilder.Services.AddRask(o =\u003e o.DiffMode = LiveDiffMode.Auto);   // default; choose-smaller\n\n// WASM\nvar host = WasmHostBuilder.CreateDefault(o =\u003e o.DiffMode = LiveDiffMode.Auto);\n```\n\nThe other modes are `LiveDiffMode.DisabledFull` (always full HTML — bit-for-bit pre-codec behaviour) and\n`LiveDiffMode.Forced` (always diff when one is computable; for tests/benchmarks). The codec is transparent to your\ncomponents and is exercised end-to-end on both hosts by the Playwright E2E suite.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003e♻️ Lifecycle reference\u003c/b\u003e\u003c/summary\u003e\n\u003cbr\u003e\n\n| Hook                                     | When                                                                                        |\n|------------------------------------------|---------------------------------------------------------------------------------------------|\n| `OnMount` / `OnMountAsync`               | Once, on first instance creation                                                            |\n| `OnPropsChanged` / `OnPropsChangedAsync` | Every render after props are applied                                                        |\n| `OnRendered` / `OnRenderedAsync`         | After every render, with a `firstRender` flag; skipped on publish-only walks (loop-safe)    |\n| `OnUnmount` / `OnUnmountAsync`           | Once, on disposal (children before parents); the lifetime `CancellationToken` is still live |\n| `StateHasChanged()`                      | Call to force a re-render outside an event handler                                          |\n\nThe post-`await` auto re-render is a *publish-only* walk that does not re-fire `OnRendered`/`OnRenderedAsync` on\nalready-rendered components, so an async hook that awaits a next-frame side effect won't loop (see **Async data**).\n\n\u003c/details\u003e\n\n## 📊 Performance\n\n\u003e **Rask beats Blazor on bytes-on-the-wire in every like-for-like live-update scenario**\n\u003e (2–15× fewer bytes than Blazor's `RenderBatch`), and renders small trees faster and\n\u003e lighter than Blazor's `HtmlRenderer`. The [diff codec](#live-rendering--the-diff-codec)\n\u003e ships ~1,800× smaller payloads on a counter tick (~57 bytes where pre-codec shipped\n\u003e ~50 KB). The trade-off, reported honestly below: Rask uses more *server* memory per\n\u003e update to buy those tiny payloads.\n\nHeadline numbers from `Rask.Benchmarks.VsBlazor` — Rask vs Blazor's\n`HtmlRenderer` (Scope 1, render hot path), `RenderTreeBuilder` parameter\nchurn (Scope 2, live-diff payload). Measured 2026-05-27 on **Apple M4 Pro\n(14 logical cores, .NET 10.0.5)** with BenchmarkDotNet ShortRun (3 warmup\n\n+ 3 iteration + 1 launch, `[MemoryDiagnoser]`). Lower-is-better for both\n  columns; bold marks the winner.\n\n| Scenario                                        | Rask time          | Blazor time  | Rask alloc  | Blazor alloc |\n|-------------------------------------------------|--------------------|--------------|-------------|--------------|\n| Counter render (1 button, 1 span)               | **246 ns**         | 1 030 ns     | **1.59 KB** | 3.37 KB      |\n| AttributeHeavy 100 elements × 20 attrs          | **88 µs**          | 147 µs       | **273 KB**  | 436 KB       |\n| AttributeHeavy 100 elements × 50 attrs          | **256 µs**         | 314 µs       | **842 KB**  | 1 028 KB     |\n| LiveDiff: counter on 200-row page               | **49 µs**          | 136 µs       | **87 KB**   | 229 KB       |\n| LiveDiff: attribute update on 100 × 20 page     | **131 µs**         | 242 µs       | **343 KB**  | 589 KB       |\n| LiveDiff: input-typing burst (3-field form)     | **1.2 µs**         | 2.9 µs       | **5.81 KB** | 5.84 KB      |\n| LiveDiff: multi-attribute (5 attrs on root)     | **1.1 µs**         | 3.1 µs       | **4.47 KB** | 6.63 KB      |\n| Realistic: dashboard counter tick               | **8.9 µs**         | 23.7 µs      | **27 KB**   | 49 KB        |\n| Realistic: navigation tab switch                | **15.9 µs**        | 34.1 µs      | **25.7 KB** | 56.7 KB      |\n| Virtualize 1000 items vs render-all (Rask wins) | **1.9 µs**         | 163 µs       | **11.2 KB** | 609 KB       |\n| Scale: keyed-list reorder (1 000 rows)          | **269 µs** (0.93×) | 290 µs       | 658 KB      | **464 KB**   |\n| Scale: keyed-list reorder (5 000 rows)          | 1 625 µs (1.08×)   | **1 505 µs** | 3 391 KB    | **3 266 KB** |\n| Scale: random permutation 1 000 keyed rows      | 477 µs (1.39×)     | **343 µs**   | 632 KB      | **457 KB**   |\n\n**Where Rask wins (wire bytes):** the [diff codec](#live-rendering--the-diff-codec)\nis the main lever — a counter tick on a 200-row page ships ~41 bytes over the wire\nvs ~24 KB pre-codec, and **every like-for-like scenario in the head-to-head suite now\nships fewer bytes than Blazor's `RenderBatch`** (2–15×). A keyed-list reorder is the\nlatest to cross over: the move run collapses into one `PermutationBatch` op carrying\nthe shared parent path once, so a 200-row reverse-sort drops from 3.9 KB to **1.1 KB\n(2.99× vs Blazor)** — the last like-for-like byte loss, now a win. See the full\nper-scenario table and methodology in\n[\n`benchmarks/Rask.Benchmarks.VsBlazor/Baselines/vs-blazor.md`](benchmarks/Rask.Benchmarks.VsBlazor/Baselines/vs-blazor.md).\n\n**Where Rask wins (CPU/alloc):** small-tree renders, attribute-heavy markup, live diffs\nthat touch only a few nodes on a large page, virtualised lists, and the \"realistic\"\npatterns (dashboard tick, nav switch).\n\n**Where Rask trails (server memory):** Rask optimises *bytes on the wire*, and it pays\nfor that with server-side memory. It re-serialises the whole page to HTML on every\nrender and diffs the frame stream, so a steady-state update allocates more than Blazor\n(which diffs its retained render tree directly) — measured deterministically at ~70 KB\nvs ~31 KB per update on the 200-row counter page. It also retains a heavier tree (a\n`Component` object graph vs Blazor's packed struct render-tree frames). The trade is the\nwhole point: **far smaller wire payloads in exchange for more server CPU/RAM** — the\nright call when the bottleneck is the network, the wrong one for server RAM under many\nidle-but-mounted sessions. (The `LiveDiff` alloc rows in the table above show Rask\n*lower* — those are BenchmarkDotNet per-op figures that fold in Blazor's one-time\nfull-tree attach batch; amortised over a long-lived session, the steady-state per-update\nnumber here is the representative one. Same measurement, different baseline — not a\ncontradiction.) Reproduce with `-- mem-footprint` (below); the full allocation +\nretained-heap tables and methodology live in\n[`vs-blazor.md`](benchmarks/Rask.Benchmarks.VsBlazor/Baselines/vs-blazor.md). Sustained\n10 000-iteration churn workloads trail for the same root cause and are documented as\naccepted trade-offs in\n[`Justifications.md`](benchmarks/Rask.Benchmarks.VsBlazor/Reports/Justifications.md).\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003eReproduce\u003c/b\u003e\u003c/summary\u003e\n\u003cbr\u003e\n\n```bash\n# Scope 1 — render hot path (24 benchmarks, ~12 min):\ndotnet run -c Release --project benchmarks/Rask.Benchmarks.VsBlazor -- --filter '*RenderHotPath_*' --job short\n\n# Scope 2 — live-diff payload (32 benchmarks, ~15 min):\ndotnet run -c Release --project benchmarks/Rask.Benchmarks.VsBlazor -- --filter '*LiveDiffPayload_*' --job short\n\n# Scope 3 — scale sweeps:\ndotnet run -c Release --project benchmarks/Rask.Benchmarks.VsBlazor -- --filter '*Scale_*' --job short\n\n# Scope 6/7 — realistic patterns + sustained-load:\ndotnet run -c Release --project benchmarks/Rask.Benchmarks.VsBlazor -- --filter '*Realistic_*' '*MemoryGc_*' --job short\n\n# Deterministic reports (GC counters, no BDN timing noise):\ndotnet run -c Release --project benchmarks/Rask.Benchmarks.VsBlazor -- payload-bytes     # wire bytes per update vs Blazor\ndotnet run -c Release --project benchmarks/Rask.Benchmarks.VsBlazor -- mem-footprint     # alloc/update + retained heap vs Blazor\n```\n\nResults are written to `BenchmarkDotNet.Artifacts/results/`.\n\n\u003c/details\u003e\n\n## 📋 Status\n\nRask is pre-1.0. APIs may change between minor versions. It targets **.NET 10** (`net10.0` for ASP.NET hosts,\n`net10.0-browser` for WASM projects). Production use at your own discretion — issues and PRs welcome.\n\n- **Test coverage:** unit suites across `Rask.Core.Tests`, `Rask.Generators.Tests`, host-specific test projects, and\n  the validation packages, plus a Playwright E2E smoke suite (`Rask.Examples.E2E.Tests`) that runs against both\n  example hosts.\n- **Benchmarks:** baselines for the render hot path live in `Rask.Benchmarks` (BenchmarkDotNet); committed reports\n  under `BenchmarkDotNet.Artifacts/results/`. `Rask.Benchmarks.VsBlazor` adds a head-to-head suite measuring Rask\n  against Blazor on shared scenarios — render throughput and the wire-efficiency\n  the [diff codec](#live-rendering--the-diff-codec)\n  buys on live updates.\n- **Trimming:** `Rask.Example.Wasm` publishes with zero IL warnings. See `CLAUDE.md` for the contract that keeps it\n  that way.\n\n## 📄 License\n\nRask is released under the [MIT License](LICENSE).\n\n---\n\n\u003cdiv align=\"center\"\u003e\n\n⚡ **Rask** — *Norwegian/Danish/Swedish for \"fast\".*\n\n**[Live demo ↗](https://pal-tamas.github.io/rask/)** · **[NuGet ↗](https://www.nuget.org/packages/Rask.Server)** · *\n*[License](LICENSE)**\n\nBuilt with .NET 10. Issues and PRs welcome.\n\n\u003c/div\u003e\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpal-tamas%2Frask","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fpal-tamas%2Frask","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpal-tamas%2Frask/lists"}