# Rask — every guide in one file > Rask is the full-stack .NET web framework: UI, data, auth, background jobs, realtime and deploy in one C# codebase, for a team of one or fifty. 90 guides, in reading order. Each starts with a `Source:` line naming its page; the index is https://rask.sh/llms.txt --- Source: https://rask.sh/docs/guides/installation/ # Installing Rask One command, on a machine with nothing on it: ```bash curl -sSL https://rask.sh/rask.sh | sh ``` ```powershell # Windows irm https://rask.sh/rask.ps1 | iex ``` That installs the `rask` CLI **and the dependencies the CLI actually shells out to**, then runs [`rask doctor`](https://rask.sh/docs/guides/cli/) so you can see the result. Everything lands in your home directory: no `sudo`, no elevated prompt, no distro package manager, and nothing written outside `$HOME`. Already have the .NET 10 SDK and want only the tool? `dotnet tool install -g Rask.Cli` is still the whole story — the script exists for the case where you don't. ## What it installs | | What | Why | If it's already there | |---|---|---|---| | **Always** | **.NET SDK** into `~/.dotnet` | `rask` is a `net10.0` [global tool](https://learn.microsoft.com/dotnet/core/tools/global-tools), and every command shells out to `dotnet` | any SDK 10 or newer satisfies it. One **outside `$HOME`** is left alone; the one in `~/.dotnet` is this installer's own, so re-running brings it to the latest patch | | **Always** | **`Rask.Cli`** as a global tool | the `rask` command itself | updated instead of installed | | **Always** | **`dotnet-ef`** | `rask db add` / `update` / `list` / `drop` | installed if missing; `rask db` updates it when it is older than the EF Core runtime the app uses, rather than letting every command open with a version notice | | **Always** | **`wasm-tools` workload** | every browser build (`net10.0-browser`) — the `wasm` and `wasm-hosted` templates | left alone | | **Always** | **Node.js LTS** into `~/.local/share/rask/node` | [islands](https://rask.sh/docs/guides/islands/) (`rask new --islands react\|vue\|svelte\|…`): their build bundles them, and `rask dev` serves them from Vite. Also the front-end templates (`react`, `vue`, `angular`, …), whose client npm builds. Only at build time — a published app does not run Node. | left alone if `node --version` is ≥ 24.22 (the newest Active LTS release; an app builds on 24.11 or later) | | **Never** | Docker | `rask deploy`, `rask db backup --remote` | detected and reported only | Docker is deliberately not installed. Putting a container runtime on someone's workstation is a big, hard-to-reverse change, and only two commands need it — the script tells you the one line to run if you want it. The .NET SDK is only installed **when there isn't one**. An SDK you installed with `brew`, `apt`, or the Microsoft installer is detected and used as-is; the script never touches it, and never changes which `dotnet` your other projects resolve. Node is downloaded from `nodejs.org` and its **SHA-256 is verified against the published `SHASUMS256.txt`** before anything is unpacked. ## Options ```bash curl -sSL https://rask.sh/rask.sh | sh -s -- --prerelease ``` The `-s --` is how you pass an option through a pipe: `sh` reads the script from stdin, and everything after `--` becomes the script's arguments. | Option | Environment equivalent | Effect | |---|---|---| | `--version ` | `RASK_INSTALL_PACKAGE` pins the package | install a specific `Rask.Cli` version | | `--prerelease` | | install the latest [nightly](https://rask.sh/docs/guides/development-workflow/) prerelease | | `--no-sdk` | | never install the .NET SDK, even when none is found | | `--no-ef` | | skip `dotnet-ef` (`rask db` installs it on first use anyway) | | `--no-wasm-tools` | | skip the workload — a server-only app never needs it | | `--no-node` | | skip Node — only islands need it | | `--no-path` | | never write to a shell profile | | `--dry-run` | | print every step and change nothing | | `--quiet` | | print only errors and the final summary | | `--help` | | the option list | On Windows the same options are PowerShell switches (`-Prerelease`, `-NoNode`, `-DryRun`, …). `iex` cannot pass arguments, so use a script block when you need one: ```powershell & ([scriptblock]::Create((irm https://rask.sh/rask.ps1))) -Prerelease ``` ### Install locations Every path is overridable from the environment, on both scripts: | Variable | Default (Unix) | Default (Windows) | |---|---|---| | `RASK_INSTALL_DOTNET_ROOT` | `$DOTNET_ROOT`, else `~/.dotnet` | `%USERPROFILE%\.dotnet` | | `RASK_INSTALL_PREFIX` | `~/.local/share/rask` | `%LOCALAPPDATA%\rask` | | `RASK_INSTALL_DOTNET_CHANNEL` | `10.0` | `10.0` | | `RASK_INSTALL_DOTNET_MAJOR` | `10` | `10` | | `RASK_INSTALL_DOTNET_QUALITY` | *(unset)* | *(unset)* | | `RASK_INSTALL_NODE_MIN` | `24.22.1` | `24.22.1` | | `RASK_INSTALL_PACKAGE` | `Rask.Cli` | `Rask.Cli` | `RASK_INSTALL_DOTNET_QUALITY` is passed to Microsoft's `dotnet-install` only when you set it, because a released channel needs no quality and an empty one is an argument error. Rask ships for .NET 10, the LTS release, and that is what `rask new` scaffolds, so the defaults are the ones to keep. ### Which shell profile it writes The block goes between `# >>> rask installer >>>` and `# <<< rask installer <<<`, and it adds each directory only if it is not already on `PATH`, so reading it twice costs nothing. | Your shell | Files written | |---|---| | zsh | `~/.zshrc` | | fish | `~/.config/fish/config.fish` | | bash, macOS | `~/.bash_profile` and `~/.bashrc` | | bash, Linux | `~/.bashrc`, plus `~/.bash_profile`, `~/.bash_login` or `~/.profile` if one exists | | anything else | `~/.profile` | bash gets two files because it reads different ones depending on how it was started: a login shell (ssh, a tty, a macOS Terminal tab) reads the first of `~/.bash_profile`, `~/.bash_login` and `~/.profile` that exists, and never `~/.bashrc`, while a terminal emulator reads `~/.bashrc` and never a login profile. On a stock box you never notice, because Debian, Arch and Fedora all ship a skeleton login profile that sources `~/.bashrc`. If you keep your own dotfiles and yours does not, one file would mean ssh has no `rask` while the terminal on the same machine does. On Linux the installer will not *create* a login profile that is not already there: bash reads `~/.profile` only when no `~/.bash_profile` exists, so inventing one would silently shadow your login environment. `--no-path` writes nothing at all and prints the lines to add yourself. ## Upgrading Re-run the same one-liner. The script is idempotent: it updates the tool rather than failing on an existing install, rewrites its `PATH` block instead of appending a second copy, and leaves every dependency that is already current untouched. `dotnet tool update -g Rask.Cli` still works and is faster if the tool is all you want to move. ## Uninstalling ```bash dotnet tool uninstall -g Rask.Cli rm -rf ~/.local/share/rask # the Node the installer unpacked, if any ``` Then delete the block between `# >>> rask installer >>>` and `# <<< rask installer <<<` from your shell profile. Leave `~/.dotnet` alone unless you are sure nothing else uses it. On Windows: `dotnet tool uninstall -g Rask.Cli`, remove `%LOCALAPPDATA%\rask`, and drop the two rask entries from your user `Path`. ## Troubleshooting **`rask: command not found` right after installing.** Expected, and not a failed install — `dotnet` and `node` will be missing from that shell too. A shell reads its profile when it *starts*, and the installer ran as a child process, which cannot reach into the environment of the shell that launched it. No installer can. Open a new terminal, or reload the current one with the exact line the script printed just above `Then:` — `source ~/.bashrc`, `source ~/.zshrc`, `source ~/.config/fish/config.fish` or `. ~/.profile`, whichever matches your shell. If a *new* terminal still cannot find it, the block went into a profile that terminal does not read. Check which file has it: ```bash grep -l 'rask installer' ~/.bashrc ~/.bash_profile ~/.profile ~/.zshrc ~/.config/fish/config.fish 2>/dev/null ``` **`rask` says "You must install .NET to run this application" — but `dotnet` works.** A .NET global tool is an *apphost*, and an apphost does not look on `PATH` for a runtime: it reads `DOTNET_ROOT`, then the registered location, then the default install directory. With the SDK in `~/.dotnet` it needs `DOTNET_ROOT` set, which the installer writes into your profile alongside the `PATH` lines. If you passed `--no-path`, set it yourself: ```bash export DOTNET_ROOT="$HOME/.dotnet" ``` The installer only writes `DOTNET_ROOT` when it actually installed an SDK into `~/.dotnet` — setting it on a machine whose SDK lives elsewhere would break the SDK that was working. **"installing the .NET SDK needs `bash`."** Microsoft's `dotnet-install.sh` is a bash script, even though `rask.sh` itself is POSIX `sh`. Nearly every system has bash; a minimal container may not. Install it, or install the SDK yourself and re-run with `--no-sdk`. **Which profile does it write?** `~/.zshrc` for zsh, `~/.bashrc` for bash on Linux, `~/.bash_profile` for bash on macOS (a Terminal tab there is a login shell, which never reads `.bashrc`), `~/.config/fish/config.fish` for fish, and `~/.profile` otherwise. `--no-path` skips it entirely. **"could not install the wasm-tools workload."** You have a machine-wide .NET SDK, so its workload directory is root-owned. Run `sudo dotnet workload install wasm-tools` (Windows: an Administrator terminal). The script never elevates on your behalf. Only browser-wasm builds need it — a server app builds fine without. **Behind a proxy, or offline.** The script needs `nuget.org`, `dot.net` and `nodejs.org`. With an SDK and Node already installed, `--no-sdk --no-node` reduces that to `nuget.org` alone. **Reviewing it before running it.** It is a single POSIX `sh` file with no dependencies, served from this repository — read it at [`rask.sh`](https://github.com/pal-tamas/rask/blob/main/rask.sh), or: ```bash curl -sSL https://rask.sh/rask.sh -o rask.sh less rask.sh sh rask.sh --dry-run # prints every step, changes nothing sh rask.sh ``` `--dry-run` is exact rather than approximate: every command that would change anything on your machine goes through one wrapper, and that wrapper prints instead of running. > **A note on `curl | sh`.** Piping a download into a shell has a real hazard: a connection that > drops halfway leaves `sh` executing *half a script*. `rask.sh` closes that structurally — every > statement lives inside a function and the file's last line is the only thing that calls one, so a > truncated read defines some functions and runs nothing. A test asserts that property on the file > itself, and the same reasoning is why the script downloads Microsoft's `dotnet-install.sh` to a > file before running it rather than piping it onward. ## Without the script Nothing here is magic, and the manual path stays supported: ```bash # 1. the .NET 10 SDK — https://dot.net # 2. the tool dotnet tool install -g Rask.Cli # 3. what your project needs dotnet tool install -g dotnet-ef # rask db dotnet workload install wasm-tools # browser-wasm builds # 4. Node 24 LTS — https://nodejs.org (islands and front ends only) ``` `rask doctor` reports what it finds either way. ## Next - [Getting started](https://rask.sh/docs/guides/getting-started/) — the first project, end to end. - [The `rask` CLI](https://rask.sh/docs/guides/cli/) — every command. - [Deployment](https://rask.sh/docs/guides/deployment/) — `rask deploy` onto a bare box. --- Source: https://rask.sh/docs/guides/getting-started/ # Getting started with Rask Rask is **the full-stack .NET web framework** — UI, data, auth, background work, realtime and deploy, all in C#, for a team of one or fifty ([read the philosophy](https://rask.sh/docs/guides/one-person-framework/)). This guide starts with the UI; the [tutorial](https://rask.sh/docs/guides/00-overview/) adds the rest of the stack. You build the UI as plain C# classes — no `.razor`, no JSX, no JavaScript to write. A component is a class that returns a tree of HTML from `Render()`, and the *same* component runs either server-rendered (live updates over a WebSocket) or fully client-side in the browser on WebAssembly. This is a zero-to-running guide for someone new to Rask. By the end you'll have an app on screen, you'll understand the files the template gave you, and you'll have written your own component, an event handler that updates the UI, and a route. It assumes you're comfortable with C# — we explain the Rask-specific ideas, not the language. > **Coming from Blazor?** Skim [migrating from Blazor](https://rask.sh/docs/guides/migration-from-blazor/) for the concept > mapping (`@page` → `[Route]`, `[Parameter]` → a property, `EventCallback` → `Callback`). **Just > want to look first?** Click through the [live demo](https://rask.sh/docs/) — a full > multi-page Rask app, no install needed. ## Before you start Rask requires the **.NET 10 SDK** — every package ships for .NET 10, the LTS release. Confirm you have it: ```bash dotnet --version # must be ≥ 10.0 ``` If that prints an older version (or errors), install the .NET 10 SDK from [dotnet.microsoft.com](https://dotnet.microsoft.com/download) first. > **WASM only:** the `wasm` and `wasm-hosted` templates also need the browser > WebAssembly tooling — install it once with `dotnet workload install wasm-tools`. If you're starting > with the server template (recommended below), you can skip this. ## 1. Scaffold a project Scaffolding is done by the [`rask` CLI](https://rask.sh/docs/guides/cli/) (`Rask.Cli`, a global .NET tool). **Not sure which host to pick? Choose the default `server` template** — it's a single ASP.NET project that runs with no extra setup, and the components you write are identical across hosts, so nothing you learn here is wasted if you switch later. ```bash curl -sSL https://rask.sh/rask.sh | sh # one-time: the rask CLI + what it needs rask new MyApp # create a server app in ./MyApp (server is the default) ``` The installer adds the .NET SDK if you don't have one, plus `dotnet-ef`, the `wasm-tools` workload and Node — all under `$HOME`, no `sudo`. With the SDK already in place, `dotnet tool install -g Rask.Cli` is enough. See [Installing Rask](https://rask.sh/docs/guides/installation/). `rask new` ships three C# templates and a front-end template per framework: | `--template` | What you get | |---------------------|-----------------------------------------------------------------------------------------------| | `server` (default) | One ASP.NET project. Components render on the server; live updates ship over a WebSocket. **Best default.** | | `wasm` | One browser-WASM project that publishes to a static `wwwroot/` you can host anywhere (GitHub Pages, S3, nginx). Bring your own API. | | `wasm-hosted` | A WebAssembly app in `Client/`, served by its own ASP.NET host — the browser runs the UI, the server keeps the database, the handlers and every battery. | | `react`, `preact`, `vue`, `angular`, `solid`, `svelte`, `lit` | That framework's TypeScript app in `client/`, on the same kind of host, calling your C# messages through a generated typed client — see [single-page app front ends](https://rask.sh/docs/guides/spa/#scaffolding-one). | The first three emit the same starter pages in C#, so the rest of this guide applies whichever of them you chose. **Each arrives with every battery it can carry.** On `server` that is a SQLite database, the [Rask.Cqrs](https://rask.sh/docs/guides/cqrs/) mediator, background jobs, transactional email, a cache, a transactional outbox, [file storage](https://rask.sh/docs/guides/file-storage/) for uploads, scheduled backups, a durable log store, the [operator dashboard](https://rask.sh/docs/guides/dashboard/), an installable [PWA](https://rask.sh/docs/guides/pwa/) with Web Push, a Dockerfile for [`rask deploy`](https://rask.sh/docs/guides/cli/), and the localization machinery — wiring, not sample pages. `wasm` takes the PWA and the Dockerfile; the rest need a host to put a database in. Languages are configured rather than chosen on the command line — a server app starts with English in `appsettings.json` (`Rask:Cultures`), and adding another is an entry in that list. A browser-WASM app registers none, because a language there means shipping ICU: roughly a megabyte of extra download that an app formatting nothing culture-sensitive should not pay by default. See [localization](https://rask.sh/docs/guides/localization/). Almost nothing is left to you. Sign-in comes with the app — register, sign in and sign out work out of the box, and the first account you create is the administrator (see [authentication](https://rask.sh/docs/guides/authentication/)). Styling is not a flag either: every project is Tailwind. To leave a battery out, name it: `rask new MyApp --no-push --no-ops`. The full flag list is in [the CLI reference](https://rask.sh/docs/guides/cli/). ## 2. Run it ```bash cd MyApp rask dev ``` Open the URL printed in the console. **You should see** a single **"Hello, Rask! 👋"** welcome card that lists the `rask` commands you'll use next. The starter app is deliberately minimal — a clean shell with one page — so there's nothing to delete before you start building. > **Edit-and-refresh with hot reload.** `rask dev` is the live inner loop (`dotnet run` starts the app > without it): edit a component's `Render()` (or its scoped `.css`/`.ts`), a `[Route]` template, or a CQRS handler and save — > **C# Hot Reload** applies the change to the running app and Rask re-renders the open session in place, no > manual rebuild or browser refresh. A small "Hot reload applied" pill confirms it landed. Edits the > runtime can't apply (adding a type, changing a signature) restart the app instead, and the page reloads > itself. The full list is in [what hot-reloads](https://rask.sh/docs/guides/cli/#what-hot-reloads). > **First build is slower, and the IDE may look broken — that's expected.** The first build is when > Rask's source generators run. Until then your IDE may flag `HomePage` or > `Routes.HomePage()` as undefined — they're *generated* members that don't exist until you build. Build > once, then reload the solution so IntelliSense picks them up. (More on this in > [Troubleshooting](#troubleshooting) below.) ## 3. Tour of what the template generated Before writing code, here's what's in the project and why. The `server` template is small on purpose (the WASM templates differ mainly in `Program.cs`): - **`Program.cs`** — the host, in one line: ```csharp using MyApp.Features.Shared; RaskApp.Create(args).Run(); ``` `RaskApp` is every battery the template supports, **on by default** — the database, mediator, accounts, background jobs, transactional email, cache, outbox, file storage, operator dashboard, durable logs, Web Push, snapshots and continuous backup (inert until `Rask:Litestream:ReplicaUrl` is set) — plus the host work every app repeats: health checks at `/health`, the exception handler and HSTS outside Development, HTTPS redirection, static assets, authentication, `App` mounted as the whole site, and the document around it — charset, viewport, the UI kit's stylesheet and theme, your `Styles/app.css` — so `App.cs` is a title and `Render() => Router`. An app that does without a battery says so here, and a scaffold made with `--no-` ([`rask new`](https://rask.sh/docs/guides/cli/#rask-new--scaffold-a-project)) has that line written for it: ```csharp var app = RaskApp.Create(args); app.Configure(c => c.Jobs.Off()); app.Run(); ``` Your own services go on `app.Services` and your own endpoints in `app.MapEndpoints(e => …)`, between `Create` and `Run`. **Settings are not code.** A From address, a connection string, a session cap — every Rask setting lives in `appsettings.json` under `Rask`, each battery reading its own section (mail reads `Rask:Mail`), and an environment variable overrides any of them with double underscores (`Rask__Mail__From`). A callback — `app.Configure(c => c.Mail.Configure(o => …))` — runs after configuration and wins over both, for the rare value that has to be code. The scaffolded `appsettings.json` starts on development defaults — a local `app.db` at `Rask:ConnectionStrings:App` — that every one of those sources overrides. See [Configuration](https://rask.sh/docs/guides/configuration/). - **No database context to write.** `RaskAppDbContext` maps every aggregate you declare plus every battery's tables; code that needs the context itself injects `IDbContextFactory` ([Rask.Data](https://rask.sh/docs/guides/data/)). - **`Features/Shared/App.cs`** — the **root component** `App`: it renders straight into `` — Rask builds the document around it — and drops a `Router` where the current page appears. `` is framework-managed — Rask writes the charset, viewport and stylesheets, and the tab's title — the page's `PageTitle`, then the app's name — is its one `HeadAssets` line (more in [section 7](#7-the-document-and-the-headassets-override)). Beside it, `Features/Shared/User.cs` is the app's account type. - **`Features/Home/HomePage.cs`** — the `/` route, a small welcome card. Edit or replace it; it's your starting point. - **`{Project}.csproj`** and **`Properties/launchSettings.json`** — the project file (framework package references, source generators) and the local run profile (URLs, environment). - **`{Project}.Tests/`** — a test project with one passing test, `Home_page_greets_the_visitor`, which renders `HomePage` in-process with [`Rask.Testing`](https://rask.sh/docs/guides/testing/) and checks its greeting. `dotnet test` runs it; `{Project}.slnx` lists both projects. `rask new --no-tests` leaves it out. That's the whole starter app — no example `Counter` or `Weather` pages to clean up. You'll add your own screens next; a **scoped `.css`** or **`.ts`** file is as easy as dropping `{Component}.css` next to a `{Component}.cs` (same folder, same base name) — its selectors apply only to that component, no leaks. ## 4. Your first component Every component is a `sealed partial class : Component`. Override `Render()` and return a tree of HTML written as a **chain**: name a component and dot onto it — `Div.Class("greeting")`. The name *is* the component, so pressing `.` lists everything it has, each step carrying its own doc comment. A tag you set nothing on needs no parentheses at all (`H1["Hi"]`). Children attach through an **indexer** on every component — `Div[ ... ]` — and strings, other components, and value types all convert to a child node automatically: ```csharp public sealed partial class Greeting : Component { protected override Component? Render() => Div.Class("greeting")[ H1["Hello, world!"], P["Welcome to your new Rask app — ", Strong["it's all C#"], "."], Span[42] // value types convert too — no .ToString() ]; } ``` `Render()` returns `Component?`, which accepts three shapes — you'll mostly use the first two: - a single node — `Render() => Div[...]`; - a **collection expression** for several top-level nodes with no wrapper — `Render() => [H1["Title"], P["Body"]]`; - `null` — render nothing. > **Safe by default — good to know, not needed yet.** Two security defaults are worth knowing about but > won't get in your way: > - **Strings are HTML-encoded.** A plain string becomes a `Text` node, so `P["hi"]` shows the > angle brackets as text. When you genuinely need verbatim markup, use `Raw("hi")`. > - **URL attributes are scheme-sanitized.** `href`/`src`/etc. neutralize dangerous schemes > (`javascript:` → `about:blank`) so a user-supplied URL can't run script on click. For a URL you > fully control, opt out per-call with `RaskUrl.Trusted(...)`. > > See [best practices](https://rask.sh/docs/guides/best-practices/) for the full security picture. ## 5. Add interactivity Keep local state in fields and wire event handlers as lambdas. After the handler runs, **the component that owns it re-renders automatically** — you never call `StateHasChanged()` by hand for a local update. A click does a server round-trip (server host) or a local re-render (WASM host); the same code works for both. ```csharp [Route("/counter")] public sealed partial class Counter : Component { private int _count; protected override Component? Render() => [ H1["Counter"], P[$"Current count: {_count}"], Button.OnClick(() => _count++)["Click me"] ]; } ``` ### Going further: child → parent communication A child declares a `Callback` property (`Callback` when the event carries nothing), and the chain step that sets it wraps the handler so invoking it re-renders the **parent** that owns the lambda. The caller hands it a sync or an async handler through the same step, an unset one does nothing when invoked, and the child stays oblivious to the parent: ```csharp public sealed partial class RatingStars : Component { public int Value { get; set; } public Callback OnRate { get; set; } // an event: optional, never a required step protected override Component? Render() => Div[ Enumerable.Range(1, 5).Select(i => Button.Key(i) .OnClick(() => OnRate.Invoke(i))[i <= Value ? "★" : "☆"]) // child invokes; parent re-renders ]; } public sealed partial class RatingDemo : Component { private int _rating; protected override Component? Render() => [ RatingStars.Value(_rating).OnRate(n => _rating = n), // lambda captures this P[_rating == 0 ? "Click a star." : $"You rated: {_rating}/5"] ]; } ``` ## 6. Why `HomePage` already chains (the generated surface) You never write a builder by hand. For each concrete `Component`, the generator emits a **chain entry** and a step per public settable property — that's why `HomePage`, `Counter`, and your own `Greeting` can be named and dotted onto. Which shape a property takes is derived from its declaration: | Property shape | In the chain | |---------------------------------------------------|-------------------------------------------------| | Non-nullable, **no initializer** | a **step** — required before the component exists | | Nullable (`T?` / `Nullable`), no initializer | an optional setter | | Has an initializer (`= ...`) | an optional setter — your default wins | | `[SkipFactory]` (property or class) | **excluded** — no step, no setter | | `Children` | always excluded (children attach via the indexer) | ```csharp public sealed partial class Card : Component { public required string Title { get; set; } // a step: Card.Title("…") opens the chain public string? Subtitle { get; set; } // an optional setter public int Elevation { get; set; } = 1; // an optional setter — your default wins [SkipFactory] public int Internal { get; set; }// excluded explicitly // → Card.Title("Pricing").Subtitle("per seat").Elevation(2) } ``` The steps come first and in any order; miss one and there is no component to render, so the mistake is a compile error where you made it rather than a null at runtime. The class must be `partial` — that is where the generator puts the surface. Live — a `Greeting` with a required `Name` and an optional `Title`, built with `Greeting.Name("Ada")…`: **Inject framework services (`HttpClient`, `RouteState`, `IJSRuntime`) through the constructor, not as properties** — a non-nullable settable property would become a *required step* (and `required` on a property with a DI-only constructor is the **RASK002** warning). Inject through the primary constructor instead: ```csharp public sealed partial class Weather(IWeatherForecastService service) : Component { ... } ``` `[SkipFactory]` keeps a property settable in code but off the chain — useful for seeding cached internal state the caller shouldn't pass. The counter below starts at 7 (its `Initial` is `[SkipFactory]`, seeded in `OnMount`) and keeps its state across re-renders like any private field: ## 7. The document and the `HeadAssets` override Your root component (the `TApp` you pass to the host — `App` in the template) renders straight into ``. Rask composes the document around it: the doctype, ``, a `` filled from every mounted component's `HeadAssets` override (plus the charset, viewport, the UI kit's stylesheet, your `Styles/app.css`, and the scoped CSS and JS the page needs), and a `` holding what the root rendered and the auto-appended runtime ` ``` **Svelte 5** — `SvelteCounter.svelte`, in runes mode: ```svelte
{props.caption}
total {total}
``` **Angular** — `AngularCounter.ts`, a standalone component that is the module's default export. Every prop is an `@Input()` ([What Angular needs](#what-angular-needs)), a callback included: ```ts // An ordinary standalone Angular component, compiled by @analogjs/vite-plugin-angular. import { Component, Input, signal } from '@angular/core' import type { AngularCounterProps } from '@rask/AngularCounter.props' @Component({ selector: 'angular-counter', standalone: true, template: `
{{ caption }}
total {{ total() }}
`, }) export default class AngularCounter implements AngularCounterProps { @Input() step = 1 @Input() caption = '' @Input() onTotal?: (total: number) => void readonly total = signal(0) add() { this.total.update(t => t + this.step) this.onTotal?.(this.total()) } } ``` **Lit** — `LitBadge.ts`. No decorators, and the module default-exports the tag it registered ([why](#what-the-build-does)): ```ts // An ordinary Lit element. Lit needs no bundler plugin — this is plain TypeScript — but it still // pairs with a .cs by filename, which is what tells Rask it is an island rather than scoped script. // // Written without decorators on purpose: lowering `@customElement` is the bundler's job, and an // element whose decorator was not lowered never upgrades — the island renders empty with nothing in // the console. `static properties` plus `customElements.define` is the same API with no transform. import { LitElement, css, html } from 'lit' import type { LitBadgeProps } from '@rask/LitBadge.props' class LitBadge extends LitElement implements LitBadgeProps { static properties = { step: { type: Number }, caption: { type: String }, onTotal: { attribute: false }, total: { state: true }, } static styles = css` :host { display: inline-flex; align-items: center; gap: 0.5rem; } ` // `declare`, never an initializer: a class field would shadow the getter and setter Lit installs // for each reactive property, and assigning it would stop re-rendering. declare step: number declare caption: string declare onTotal?: (total: number) => void private declare total: number constructor() { super() this.step = 1 this.caption = '' this.total = 0 } private add() { this.total += this.step this.onTotal?.(this.total) } render() { return html` ${this.caption} ${this.total} ` } } customElements.define('lit-badge', LitBadge) // A Lit-runtime module default-exports its registered tag name: a custom element registers its own // tag and nothing else about the file reveals it. export default 'lit-badge' ``` ## Hydration ```csharp Chart.Series(_points).Hydration(ExternalHydration.Visible) ``` | Mode | When the adapter mounts | |---|---| | `Load` (default) | As soon as the component's chunk has loaded. | | `Idle` | On `requestIdleCallback`. | | `Visible` | On `IntersectionObserver` — the chunk is not even **fetched** until the component is scrolled to. | | `None` | Never. Server markup only, and no JavaScript is requested at all. | ## An island as a whole page An island can carry the `[Route]` itself. Route parameters are ordinary props, so `/reports/41` reaches the front-end file as `id: 41`, and `Report.Url(41)` / `Routes.Report(41)` are generated as for any page: ```csharp [Route("/reports/{id:int}")] public sealed partial class Report : ReactComponent { [RouteParam] public int Id { get; set; } protected override Component? HeadAssets => Title[$"Report {Id}"]; } ``` **The title and meta come from `HeadAssets`,** exactly as on a Rask page. Overriding it used to replace the script that boots the island runtime, so the page rendered and nothing mounted; the script is registered separately now. **`Loading` is what the first response shows.** A JavaScript island is rendered in the browser, so until its chunk has loaded the host element is empty — a blank page, when the island is the page: ```csharp Report.Id(Id) // Report.Id(Id).Loading(Ui.Skeleton.Class("h-64 w-full")) //
``` It is plain Rask markup, rendered once on the server and never sent in the props. The runtime removes it right before the island mounts, so under `Hydration(ExternalHydration.Visible)` or `Idle` it stays for as long as the island waits. It sits below the [diff boundary](#the-diff-boundary): a later render does not patch it and a handler inside it does not run. The router builds a routed island, so there is no chain to take the step on — that island sets it in its constructor: ```csharp public Report() => Loading = Ui.Skeleton.Class("h-64 w-full"); ``` **A link navigates in-app with `data-rask-nav`.** Without it an `` inside an island is a full page load: ```tsx Orders // reloads the document Orders // swaps the page, like NavLink ``` **A page a crawler must read needs real markup in the first response.** A JavaScript island has none of its own, so give `Loading` the content rather than a skeleton, write the page in Rask, or use a [Blazor island](https://rask.sh/docs/guides/blazor-components/), which *is* rendered on the server. ## Children An island takes children **of its own runtime**: a React island accepts React islands, text, numbers and dates; a Vue island accepts Vue islands and text; and so on. They travel inside the island's props and its framework renders them, so a card from one package can hold a button from another: ```csharp MuiCard[ "Revenue ", _total, MuiButton.Variant(MuiButtonVariant.Contained).OnClick(Save)["Save"] ] MuiList[_people.Select(p => MuiListItem.Key(p.Id)[p.Name])] MuiList[_names] // a list of text works too ``` Every hand-written island accepts children, and its front-end file decides where they go: `children` in React, Preact and Solid, the default slot in Vue, the `children` snippet in Svelte, a `` in Lit, `` in Angular. A package island accepts them when its component takes content, which its snapshot records; one that takes none has no children indexer at all. **Anything else is a compile error** — [RASK062](https://rask.sh/docs/guides/diagnostics/#rask062), reported at the brackets: ```csharp MuiCard[ Span["Revenue"] ] // RASK062: Rask markup MuiCard[ VueChip.Label("new") ] // RASK062: another runtime's island MuiIcon[ "label" ] // RASK062: this component takes no children ``` Rask markup cannot go inside an island. It would have to be handed across the diff boundary below, and once a front-end framework has moved those nodes into its own tree every path Rask holds into them is wrong — the diff addresses DOM nodes by `childNodes` index from the document. An island's own children are never Rask DOM: the whole tree ships in the host's one `props` attribute, a change anywhere in it is one attribute update, and the framework reconciles. A child island's callbacks reach C# like the host's. Give it a `.Key(...)` in a projected list so its framework matches it across updates rather than by position. Its `.Hydration(...)` is ignored: it mounts with the host that renders it. When the markup is Rask's, compose the other way round. It costs nothing, and everything on the Rask side stays live: ```csharp Ui.Card[ Panel.Heading("Sales"), Table.Rows(_rows), Ui.Button.OnClick(Save)["Save"] ] ``` ## Tailwind A utility written inside an island works like any other. Tailwind v4 detects sources from the project it runs in, and a `.vue`, `.tsx` or `.svelte` is an ordinary source to it, so an island in the same project as your stylesheet needs nothing: ```vue
``` **An island in a different project needs two statements, not one.** Tailwind scans one project, so a front-end file in a sibling one is invisible to it — every utility used there would be dropped from the sheet, with the island rendering unstyled and nothing reporting why. Name the directory in the stylesheet: ```css @import "tailwindcss"; @source "../../Shop.Web/Features/Islands"; ``` And name it again to the build, which cannot read that line: ```xml ``` `@source` decides what Tailwind **scans**; `RaskTailwindSource` decides what the build **watches**. With only the first, the sheet is built from those files once and then never rebuilt when one changes — the same staleness as before, one project further out. > **A Lit island is the exception, and it is Lit's rule rather than Rask's.** A `LitElement` renders > into a shadow root, and page-level CSS does not cross that boundary — so the app's Tailwind sheet > cannot reach inside one. Style it with Lit's own `static styles`, or render to light DOM by > overriding `createRenderRoot()`. ## The diff boundary Rask's live runtime diffs the server's render against the browser's DOM. Its subtree is **not Rask's to diff**: React owns those nodes and reconciles them on its own schedule, and two writers on one subtree does not throw — it corrupts on the next parent re-render. So the host element is a boundary. Nothing inside it is ever patched, removed or re-keyed by Rask. What crosses is **props, and only props**: a changed prop travels as a single attribute op, and the client routes it to the adapter's `update` rather than letting it land as an attribute nobody reads. An update is therefore a reconcile, never a remount — the component keeps its scroll position, its focus, its open dropdown and its half-typed field. Callbacks keep their identity across updates for the same reason. React compares props by identity, so a fresh closure per render would invalidate every `useCallback` and `memo` keyed on it and re-fire every `useEffect` that lists it. ## Hot reload `rask dev` runs a Vite dev server for the islands beside the app, so editing a `.tsx` or a `.svelte` hot-replaces in place instead of rebuilding: ``` $ rask dev Serving islands from http://localhost:5174 (hot reload)… ``` Nothing to configure. The dev server starts when the project has islands, dies with the host, and listens on **5174** — not Vite's default 5173, so another Vite project on the machine does not fight it for the port. Override it with `` if something else is already there. **Under VS Code's F5 the app starts it.** No `rask dev` runs beside an app the debugger launched, so a dev-session build starts the same Vite server itself once the build has written its config, and stops it with the app — see [debugging in VS Code](https://rask.sh/docs/guides/cli/#debugging-in-vs-code). The page stays on localhost there, as it does under `rask dev`: an HTTPS page could not load modules from this plain-HTTP server. **It answers loopback origins only** — `localhost`, `127.0.0.1`, `[::1]`. Not a default worth loosening: the dev server also serves `/@fs/`, so an allow-everything CORS policy would let any website open in your browser fetch files from under your workspace root while `rask dev` is running. The cost is that an app you serve on a non-loopback address in development (a LAN IP, a custom hostname) will not load its islands; run it on localhost, or bundle normally with a plain `dotnet run`. What changes during a dev session is one step: the production `vite build` is skipped, because it would rebuild every island in the project on every save and nothing would read the result. Everything else still runs — the entry modules, the prop types, the type-check — and the manifest is still written, pointing at the dev server rather than at chunks. **It works the same on both hosts.** The page has to know where the dev server is before it will load a module from it, and it learns that from the manifest: a dev session's manifest names the server under a reserved `$dev` key, beside the islands. That is all a WASM app has — its page is a static file — and a Rask.Server page is also stamped with `data-rask-islands-dev` on ``. Either way the origin is used only when it is a loopback one, and a built manifest never carries the key. **How much you get back depends on the runtime, and that is upstream's call, not Rask's.** Once the modules are served by the dev server, each framework's own refresh integration owns them: | Runtime | On save | |---|---| | React, Preact | Fast Refresh — component state survives | | Solid | `solid-refresh` — signals survive | | Vue, Svelte | plugin HMR — component state survives | | Lit | full page reload (a custom element cannot be re-registered) | | Angular | full page reload | Even the reload cases skip the C# rebuild, which is the slow half. Rask deliberately does not replace a mounted island itself. Doing so would fight the framework integrations and lose: they preserve component state, and a remount is precisely what the [diff boundary](#the-diff-boundary) exists to avoid. ## What the build does `dotnet build` writes one entry module per component — pairing your component with its runtime's adapter, so `Chart.tsx` stays a plain React component with no Rask import in it — and hands Vite a generated config that builds them into one chunk each, plus the manifest the client runtime resolves names through. **A project using them needs Node and a `package.json`.** That single file is also the gate: a project without one runs no npm, probes for no node, and never learns this package has a build step. A Rask app with no islands is unaffected, which is most of them. A [package island](#using-a-package-component-directly) adds one step, before the compile: the build finds the classes whose `Module` names a package, installs, and reads their props into the snapshots the compile generates from. Its entry module imports the package itself — there is no file of yours to bundle. A project that *does* have both is checked before `npm` runs: too old a Node fails with **`RASKISLAND001`** naming the version it found, rather than failing later inside vite with an engines error nobody reads. The floor is `RaskExternalMinimumNode`, **24.11.0**: the first release of the Active LTS line, the same floor a [front end](https://rask.sh/docs/guides/spa/#which-node) takes, and it follows that line. A Node whose version is not exactly `X.Y.Z` — a nightly, a release candidate — is allowed through rather than refused. Override the bar with `-p:RaskExternalMinimumNode=…` if you have a reason to. `rask new` does all of this. Naming a runtime scaffolds the pair, the dependencies, the tsconfig mapping that makes `@rask/.props` resolve, and the package reference, and puts the island on the home page (`Features/Home/HomePage.cs`), so the first `rask dev` shows it running: ```bash rask new Shop --islands react # one rask new Shop --islands react angular blazor # several, on a server, wasm or wasm-hosted app ``` It refuses `--islands react preact` by name, because npm cannot install both plugins ([above](#react-and-preact-cannot-share-a-project)), and gives each runtime its own folder so two that share an extension never overlap. `blazor` needs no npm at all: the Razor SDK compiles the `.razor` and Rask renders it into the first response. **Checking them.** A scaffold with npm islands also gets [Vite+](https://viteplus.dev/guide/) for the checks: `npm run check` formats and lints in one pass (`vp check`), `npm run lint`, `npm run format` and `npm run format:check` one at a time, and a pre-commit hook in `.vite-hooks/` runs `vp staged` on what is committed. Its config is `vite.config.mjs` — a `.mjs`, because every `.ts` in a host project is a component's scoped TypeScript — and it reaches `Features/` and nothing else, so `appsettings.json` and the rest of the C# side stay `dotnet format`'s. The lint rules are each runtime's own, as in its [front-end template](https://rask.sh/docs/guides/spa/#the-clients-toolchain-vite): Oxlint's React and Vue plugins, and `eslint-plugin-solid` and `eslint-plugin-lit` run inside it. Solid and React islands are both `.tsx`, so in a project holding both, Solid's rules apply under a `Solid/` folder only. The bundle is Vite+ too, and only Vite+: the island build runs `vp build` over the config Rask generates, a dev session serves it with `vp dev`, and a project that does not install Vite+ stops with [`RASKISLAND019`](https://rask.sh/docs/guides/diagnostics/#raskisland019). By hand, if you are adding islands to a project you already have: ```bash npm init -y npm install -D vite-plus vite@npm:@voidzero-dev/vite-plus-core typescript # every runtime npm install -D @vitejs/plugin-react react react-dom @types/react @types/react-dom # React npm install -D @preact/preset-vite preact # Preact (not beside React) npm install -D vite-plugin-solid solid-js # Solid npm install -D @vitejs/plugin-vue vue vue-tsc # Vue npm install -D @sveltejs/vite-plugin-svelte svelte svelte-check # Svelte npm install -D lit # Lit npm install -D @analogjs/vite-plugin-angular @angular/compiler-cli @angular/build \ @angular/core @angular/common @angular/compiler @angular/platform-browser rxjs # Angular ``` These are the packages `rask new --islands` writes for each runtime. It picks their versions the way it does for a [front end](https://rask.sh/docs/guides/spa/#which-versions-a-new-app-gets): the newest releases that install together, written as `^x.y.z` — except `vite-plus`, exact, with `vite` aliased to the core at that same version and the alias repeated under `"overrides"`, so every plugin resolves the one Vite+ ships. Then extend the generated tsconfig fragment ([above](#c-owns-the-props)) so `@rask/.props` resolves, and reference `Rask.External`. Install only what you use. A plugin is written into the generated Vite config **only** when an island of that runtime exists, so a Lit-only app is never asked for `@vitejs/plugin-react`, and a Vue-only app is not either. Package islands of React or Preact need no plugin at all: the package is already compiled JavaScript, so `vite` and the runtime itself are enough. ### Why Vite, and only Vite Rask itself stays npm-free — [#871](https://github.com/pal-tamas/rask/pull/871) fetches esbuild and tsgo as verified native binaries, and the framework's own browser code compiles with no package manager anywhere. That does not change here. What changes is that **you** asked for a React component, and React is an npm package: needing it is inherent to the choice rather than a gap in the framework. A second, esbuild-based path with no npm was designed and rejected. esbuild can do the whole job — `--splitting` gives one hashed chunk per component plus a shared chunk, `--metafile` maps each entry to what was actually emitted, `--jsx=automatic` handles `.tsx`, and two components bundle in about a millisecond. The problem is not capability, it is having two of everything: two bundlers, two failure modes, and "why does it work in project A but not B" for the life of the feature. That tax outlasts a one-time install. The no-npm audience was also narrower than it first appeared. A real Lit component starts `import { LitElement, html } from 'lit'` — which is npm. Only a dependency-free custom element qualified, which is a genuine case but a small one, and not worth a permanent second toolchain. Vite is also what made Vue and Svelte cheap when they landed: a single-file component is compiled by a *Vite plugin*, so each was an adapter rather than a compiler integration. Angular arrived the same way, through `@analogjs/vite-plugin-angular` — and it is the one whose plugin asks for more than itself; see [What Angular needs](#what-angular-needs). **Plugin order is not cosmetic.** A Vue or Svelte plugin claims one extension it alone understands; the React plugin installs a *general* JSX transform. Rask registers the single-file compilers first, because the other order sends a `.vue` to the JSX parser and fails as `Unexpected JSX expression` at line 1 — naming neither Vue nor the plugin that should have handled it. The bundle is written after `wwwroot` has already been globbed, so the build registers it as a **static web asset** rather than relying on that glob. Without it `app.MapStaticAssets()` — which serves only what reached the endpoints manifest — would answer every chunk request with the page's own HTML, and the client would report `Unexpected token '<'` with nothing mounted. Nothing extra is needed in `Program.cs`; in particular `app.UseStaticFiles()` is **not** required. A publish that somehow produced no endpoint for the bundle fails the build with `RASKISLAND003` rather than shipping an app whose components silently never mount. | Property | Default | | |---|---|---| | `RaskExternalBuild` | `true` | `false` skips node entirely. They still render their host elements. | | `RaskExternalOutputDir` | `wwwroot/_rask/external` | Under `wwwroot` so the SDK publishes it with no publish target of its own. | | `RaskExternalPublicBase` | `/_rask/external/` | The URL prefix the manifest gives each chunk. A path on the app's own origin — see below. | **Islands load from the page's own origin, and nowhere else.** The client resolves a manifest and every chunk it names against the page and refuses one another origin serves, with a console error naming the URL; the only exception is the loopback island dev server, while a `rask dev` session's manifest (or the Server host's stamp on the page) names it. An island is code and its `manifest` attribute is markup, so without that rule an app that renders sanitized user HTML could be handed ``. A bundle on a CDN host is therefore not a supported deployment, and a `RaskExternalPublicBase` that names another origin fails the build. **An app served under a sub-path needs nothing extra.** With a [path base](https://rask.sh/docs/guides/deployment/) of `/shop` the runtime script is written as `/shop/_content/Rask.External/rask-external.js`, and the client reads the base back off that URL and asks for the manifest and every chunk under it — on Rask.Server and on a WASM app alike. The manifest itself is unchanged: its chunk URLs are baked at build, before anyone knows where the app will be deployed, so one bundle serves any base. A `.ts` is picked up when a `.cs` of the same name sits beside it. Declare one explicitly only when it lives somewhere that pairing cannot reach — the build-side counterpart of overriding `Module`: ```xml ``` A Lit module default-exports its registered tag name, which is how the generated entry knows what to create — a custom element registers its own tag and nothing about the file reveals it. > **Write a Lit element without decorators.** `@customElement` and `accessor` are standard-decorator > syntax, and the *bundler* is what has to lower them — Vite's oxc-based transform does not. The > failure is silent in the worst way: the chunk builds, ships, and loads, and the element simply never > upgrades, so the island renders empty with nothing in the console and nothing in the build log. Use > `static properties` plus `customElements.define('my-tag', MyElement)`, which is the same API with no > transform to depend on. ### A Lit island and scoped TypeScript in one project Both features are spelled `Name.ts` beside `Name.cs`. The only difference is whether the class derives from `LitComponent` — which Roslyn knows and a glob does not — so the build used to claim files in both directions: the scoped pipeline compiled an island's module as a component asset, and island discovery offered every scoped file to the bundler as a Lit module that never default-exported a tag name. Two opt-outs let a project say which *one* of the features it had, and a project that wanted both could not have them. **Nothing to set now.** The build reads the base list out of the C# and each `.ts` goes to exactly one pipeline. `RaskExternalLitAutoPair` is **retired**: setting it does nothing, and it can be deleted from any project that carries it. `RaskScopedTsAutoInclude` stays — it is Rask.Core's own switch, for a project whose `.ts` files are not component assets at all — but it is no longer something an islands project has to reach for. The reading happens *before* the compile, because the scoped-TypeScript list is compiled and handed to the C# compiler and so cannot wait for the assembly that compile produces. It is therefore a scan of the source rather than a semantic answer, and the build checks it against the real one: after the compile, an island whose declared module is not among the files being built is reported by name as **`RASKISLAND004`**. That report is a warning, so a component written before the file beside it tells you rather than stops you. Because it carries a code, it can also be *suppressed* — which until #1042 it could not, a task warning with no code being reachable by neither `MSBuildWarningsAsMessages` nor `NoWarn` nor anything else. The only lever was `RaskExternalPropTypes=false`, and that switches off the generated prop types too, so quieting the warning meant giving up the type-check it sits beside. Demote it where an island deliberately has no module — a test fixture that exists to be rendered rather than bundled: ```xml $(MSBuildWarningsAsMessages);RASKISLAND004 ``` `MSBuildWarningsAsMessages`, not `NoWarn`: `NoWarn` is the Roslyn switch and does not reach a warning an MSBuild task logs. Scope it to the one project that needs it — the check is the only thing standing between an island's markup and a chunk that was never built. Two consequences worth knowing: - The pairing is per **directory** and per **name**. `Gauge.ts` is `Gauge`'s module only if `Gauge.cs` sits beside it, and only if `Gauge`'s runtime is one that writes a plain `.ts` — a `Chart.ts` next to a `Chart : ReactComponent` stays a scoped asset, because that component's module is `./Chart.tsx`. - A component that overrides `Module` to name something else still has its sibling file claimed. The build says so rather than leaving it silent: the declared module has no file, and that is reported. `` is still there for a file the convention cannot reach. The other runtimes have extensions of their own and were never ambiguous. ### What is verified, and by which test Stated exactly, because "it works on both hosts" is a claim worth being able to check. **WebAssembly, in a browser** — `tests/Rask.Site.E2E.Tests/WasmIslandsExampleTests.cs` drives the islands page of the site (`src/Rask.Site`): Vue, React, Svelte, Solid and a Lit-runtime custom element mount and take their C# props; a Vue and a Lit callback re-enter C# through a `[JSExport]` call into this tab's runtime; a prop change reconciles rather than remounting, each component keeping its own state; and a package island (react-colorful) nested inside the React island calls back into C#. **Server, in a browser** — narrower. `tests/Rask.DevTools.E2E.Tests/ServerIslandJourneyTests.cs` mounts a Lit-runtime island on the Server host and reads the props C# passed, reports one that fails to mount, and clicks a [Blazor island](https://rask.sh/docs/guides/blazor-components/)'s own `@onclick` through to its callback. No browser test drives a React, Vue, Svelte, Solid, Preact or Angular island, or a JS island's callback, on the Server host — what covers those there is the unit suites: the React, Preact, Solid, Vue and Lit adapters under Node (`tests/Rask.External.Tests`) and the handler channel they share with every DOM handler (`tests/Rask.Server.Tests`). **Neither host, in a browser** — Preact and Angular. The site carries no island of either. Preact is covered under Node, below; nothing in this repository mounts an Angular island — its rendering, build plan and prop extraction are unit-tested, its adapter in a browser is not. Both browser suites run in CI after a push to `main` (the browser E2E and devtools E2E jobs), not on a commit. ### Preact, verified without a browser The site cannot carry a Preact island: it carries a React one, and [React and Preact cannot share a project](#react-and-preact-cannot-share-a-project). That is a constraint on a bundled **app**, not on a test — the conflict is between the two Vite plugins, and a test harness installs neither — so it is covered instead by `PreactAdapterTests` in `tests/Rask.External.Tests`, a Node harness that drives the shipped client runtime, the shipped adapter, real Preact and a real DOM (happy-dom): - it mounts into the island element and the component's own `useEffect` runs once; - a `props` attribute change **reconciles** — the heading changes while the component's own `useState` keeps the value a click put there, and the mount effect does not re-run; - a callback called inside the component arrives on the host dispatch channel with its arguments, and stops firing once C# stops passing the delegate; - unmount is `render(null, element)`, so the component's cleanup effects actually run. The packages are pinned and installed under `obj/` on first build; with no npm, no network, or `-p:RaskPreactFixture=false` the tests report **skipped** rather than passing quietly. What this does not reach is `@preact/preset-vite` itself: nothing builds a Preact island through Vite, so a change in its transform is still only caught by building one. ## Islands in a shared class library An island can be owned by a Razor-SDK class library and used by any app that references it. The library bundles it once; the app just renders the component. Nothing special is required in the library — a `Badge.cs` deriving one of the runtime base classes, with `Badge.ts` (or `.tsx`) beside it, exactly as in an app. The build does the rest: it writes `Badge.props.d.ts`, runs the prop type-check (rename a C# property and the *library's* build fails with `TS2339`), bundles with Vite, and registers the chunks as static web assets. What differs is only **where they are served from**. A library's static web assets live under `_content//`, so `RaskExternalPublicBase` defaults to `/_content//_rask/external/` in a class library, and to `/_rask/external/` in an app. Set it yourself to override either. That base is also what the island's host element carries, as a `manifest` attribute: ```html … ``` An app's own islands write no such attribute — the client already assumes the app's manifest. Naming it per element is what lets **one page carry islands from the app and from several libraries at once**: "the manifest" is not a single document, and the client caches one fetch per distinct URL. Two things worth knowing: - The library's base cannot be derived from `StaticWebAssetBasePath` or `StaticWebAssetProjectMode`. Both are empty at evaluation, and once `ResolveStaticWebAssetsConfiguration` has run a browser-WASM app resolves to `_content/Rask.Site` / `Default` — character-for-character what a class library resolves to, while its `wwwroot` is served at the site root. `OutputType` is what separates them, so the default is chosen in the targets rather than the props. - A component whose C# and front-end file are in *different* projects is still not supported, and is reported: `ReportUnbuiltIslands` warns in the project that declares the island, because the module it names is not among the files being built. ## What is not here yet - **Rask markup inside an island.** An island takes children of its own runtime, never Rask elements ([RASK062](https://rask.sh/docs/guides/diagnostics/#rask062)). Handing Rask-owned nodes to a framework that then owns them needs updates addressed by MARKER rather than by DOM path, since `EditOp` paths are positional `childNodes` indices — see [children](#children). - **A callback argument richer than a scalar** — a record or a list is [RASK057](https://rask.sh/docs/guides/diagnostics/#rask057); see [callbacks](#callbacks). - **Server-side rendering** for the bundler-backed runtimes, which is what would make `Hydration.None` broadly useful. --- Source: https://rask.sh/docs/guides/blazor-components/ # Blazor components `Rask.Blazor` hosts a **real Blazor component** inside a Rask page — one from a Razor Class Library, from MudBlazor or Radzen, or any `ComponentBase` you already have. The Razor SDK is neither replaced nor reconfigured: it compiles `.razor` exactly as it always has, and Rask renders the result. This is the [islands](https://rask.sh/docs/guides/islands/) contract with a different runtime behind it. A `.tsx` is a React component rendered by React; a `.razor` is a Blazor component rendered by Blazor. To be exact about what changed and what did not: **you still never write `.razor` to write Rask.** A `.razor` here is a component you are *hosting* — someone else's, or your own from a class library — not the way you author a Rask page. The chain is still the only authoring surface. ```csharp public sealed partial class Chart : BlazorComponent { } ``` ```csharp Div.Class("grid")[ H1["Revenue"], Chart.ChartSeries(_series).Width("100%"), Button.OnClick(Refresh)["Refresh"], // ordinary Rask, right beside it ] ``` ## What works, and what does not Read this table before anything else — it is the whole shape of the feature. | | Hosted Blazor component | |---|---| | Renders, styled, in the **first HTTP response** | ✅ | | `OnInitialized`, `OnInitializedAsync`, `OnParametersSet`, `BuildRenderTree` | ✅ | | Reacts to a Rask prop change, keeping its own state | ✅ | | Its own `@onclick`, `@onkeydown`, `@onwheel`, … firing from the browser, with their real event args | ✅ (see [events](#events)) | | Rask children inside it | ❌ — a compile error ([RASK062](#an-island-takes-no-children)) | | `[Inject]` services — `IJSRuntime`, `NavigationManager`, Rask's browser APIs | ✅ (see [services](#services)) | | `OnAfterRenderAsync` | ✅ — **once**, after the first paint (see [services](#services)) | | `ElementReference`, and anything that takes one | ❌ — captures are discarded | | Its own `@onsubmit`, or an event whose args are a library's own type | ❌ — see [events](#events) | | `@bind` writing a value back | ✅ (see [binding](#binding)) | | WebAssembly, published **trimmed** | ✅ annotated for it, but not gated end to end — see [both hosts](#both-hosts) | ## A worked example End to end, and compiled: the component below is a real `.razor` in the repository, its island is declared in the test suite, and the output printed here is asserted by `tests/Rask.Blazor.Tests/DocExampleTests.cs`. A test also pins this document against the file, so the two cannot drift apart. **1. The Blazor component**, in a Razor Class Library — an ordinary `.razor`, compiled by the Razor SDK, with nothing in it that knows Rask exists: ```razor @using System.Globalization
@Symbol @Price.ToString("0.00", CultureInfo.InvariantCulture)
@code { [Parameter, EditorRequired] public string Symbol { get; set; } = ""; [Parameter] public decimal Price { get; set; } [Parameter] public string? Tone { get; set; } } ``` **2. The island** — the whole declaration. Nothing is redeclared; the chain steps are read from the component's own `[Parameter]`s: ```csharp public sealed partial class Quote : BlazorComponent; ``` There is no third step in `Program.cs`: referencing `Rask.Blazor` is the whole setup. **3. Use it** anywhere the chain goes — a leaf, a subtree, or a whole page: ```csharp Div.Class("grid")[ H1["Watchlist"], Quote.Symbol("RASK").Price(12.5m).Tone("up") ] ``` **What comes back**, in the first HTTP response: ```html
RASK 12.50
``` Three things in that are worth naming: - **`Symbol` opened the chain** because `PriceTag` marked it `[EditorRequired]`. `Price` and `Tone` are ordinary optional steps, and omitting `Tone` leaves the component's own default alone rather than passing null. - **The island is a leaf.** `Quote[ … ]` would be a compile error: an island renders the markup of the component it hosts and takes no Rask children — see [children](#an-island-takes-no-children). - **`12.50` is in the first response**, not painted in later. Had `PriceTag` awaited in `OnInitializedAsync`, that would be finished too. ## Declaring one Derive a `partial` class from `BlazorComponent`. The base class *is* the declaration — there is no attribute — and `T` names the component being hosted. ```csharp public sealed partial class Chart : BlazorComponent { } ``` **You do not redeclare its parameters.** A Blazor component already states its surface, so the chain steps are read from its own `[Parameter]` properties. `MudChart` declaring `[Parameter] public List ChartSeries { get; set; }` is what makes `Chart.ChartSeries(...)` a step. ### Required parameters A parameter the hosted component marked `[EditorRequired]` becomes a **required** chain step — one the call site cannot omit, taken before any optional step. Blazor already has a word for "mandatory", so it maps onto Rask's own and neither framework has to learn the other's idiom. ```csharp // MudBlazor: [Parameter, EditorRequired] public string Label { get; set; } Badge.Label("New") // ✓ required, so it opens the chain Badge.Label("New").Tone("warn") // ✓ optional steps follow Badge.Tone("warn") // ✗ RASK038 — Label is never set ``` Everything not marked stays optional. An `EventCallback` is never required even when marked: "you must handle this event" is not something the chain can usefully insist on, and an unwired callback is simply not wired, exactly as for any other Rask component. The type argument is how the hosted component is named — not a sibling file, the way a `.tsx` island pairs by filename. That is deliberate: the common case has no `.razor` in your project at all, only a referenced type. ### Renaming a step When the hosted parameter's name is not what you want to write at the call site — or collides with a chain entry of the same name, which is what happens for parameters called `Title`, `Label`, `Form` or `Select` — name it explicitly: ```csharp public sealed partial class Chart : BlazorComponent { [BlazorParameter("ChartSeries")] public List? Series { get; set; } } ``` A property you declare yourself always wins over the generated one. The name is checked: one the hosted component does not declare as a `[Parameter]` is a compile error ([RASK100](https://rask.sh/docs/guides/diagnostics/#rask100)) rather than a chain step that sets nothing. ### Where the `.razor` has to live For parameters to be checked at compile time, the hosted component must come from a **referenced** project or package. A `.razor` in the *same* project as the island is produced by the Razor source generator, and one source generator never sees another's output — so Rask's generator cannot read its parameters. The island still works; it just is not verified, and says so through [RASK066](https://rask.sh/docs/guides/diagnostics/#rask066). Move the `.razor` into a Razor Class Library and reference it. Hosting MudBlazor or Radzen — packages, therefore referenced — is fully checked with no warning. ## It is in the first response The component is rendered on the server, during the page render, and its markup is in the HTML that is sent. A hosted component that awaits in `OnInitializedAsync` has **finished** before the response goes out rather than appearing a frame later: ```csharp protected override async Task OnInitializedAsync() { Rows = await _api.Load(); // done before the page is sent } ``` That works because the island does its rendering in `OnUpdated`, whose task is registered in the page's quiescence scope — Rask renders, waits for outstanding work, and renders again, sending the settled wave. See [lifecycle](https://rask.sh/docs/guides/lifecycle/). It is also why a Blazor island suits a whole page a crawler must read. Put `[Route]` on the island and it is the page — a `[RouteParam]` property is a step like any other and can feed a `[Parameter]`: ```csharp [Route("/tickers/{symbol}")] public sealed partial class TickerPage : BlazorComponent { [RouteParam] public string Symbol { get; set; } = ""; protected override Component? HeadAssets => Title[$"{Symbol} — quotes"]; } ``` Overriding `HeadAssets` keeps the stylesheets in `RaskBlazorOptions.HeadAssets`: they are registered on their own, as a JavaScript island's runtime script is, so there is no `base.HeadAssets` to remember. A route parameter that feeds a value-typed `[Parameter]` can be declared as it is (`[RouteParam] public int Id { get; set; }`): a non-nullable value type has no "unset" state, so it is always passed on. Declare it nullable (`int? Id`) only when the hosted component should keep its own default until a value is set. URL attributes in that markup get the treatment a Rask element gives them: `javascript:` and `data:` become `about:blank`, and an inline `data:image/…` survives only where it can do nothing but draw — `src` on `img`, `audio`, `video`, `source`, `track` and `input`, a `video`'s `poster`, an SVG `image`'s `href`. An `