# Islands — a `.tsx`, `.vue` or `.svelte` file as a Rask component

An **island** is an ordinary Rask component whose markup is produced by a front-end
framework. Derive from one of the seven base classes below, drop the front-end file beside it, and it
goes anywhere the chain goes — a leaf inside a card, a subtree, or the whole of a `[Route]` page's
`Render()`.

| Base class | Pairs with | Compiled by | Covers |
|---|---|---|---|
| `ReactComponent` | `Chart.tsx` | `@vitejs/plugin-react` | React, and Preact through a `preact/compat` alias |
| `PreactComponent` | `Chart.tsx` | `@preact/preset-vite` | Preact directly |
| `SolidComponent` | `Chart.tsx` | `vite-plugin-solid` | Solid |
| `VueComponent` | `Chart.vue` | `@vitejs/plugin-vue` | Vue 3 |
| `SvelteComponent` | `Chart.svelte` | `@sveltejs/vite-plugin-svelte` | Svelte 5 |
| `AngularComponent` | `Chart.ts` | `@analogjs/vite-plugin-angular` | Angular, as a standalone component |
| `LitComponent` | `Chart.ts` | nothing — it is ordinary TypeScript | Lit, and any custom element with property-shaped inputs |

Which one an island uses is
decided by its **base class**, never by its file extension — three of them write `.tsx` and two write
`.ts`, so the extension names a family and only the C# says which member.

```csharp
[Route("/dashboard")]
public sealed partial class DashboardPage : Component
{
    protected override Component? Render() =>
        Div.Class("grid")[
            H1["Revenue"],                 // Rask
            Chart.Series(_points),         // Chart.tsx, hydrated in the browser
            Ui.Card[ Table.Rows(_rows) ]   // Rask again
        ];
}
```

There is deliberately **no separate page concept**. "React owns this route" is the case where the
component happens to be the page root, and it falls out of the same primitive:

```csharp
[Route("/legacy/report")]
public sealed partial class ReportPage : Component
{
    protected override Component? Render() => Report.Id(Id);
}
```

The island can also carry the `[Route]` itself, with its own title and a placeholder for the first
paint — see [An island as a whole page](#an-island-as-a-whole-page).

That is what "every component is replaceable" means here: replaceability is a property of the
*component*, not of the route, so it composes at every level of the tree.

## Declaring one

The base class **is** the declaration. It names the runtime in the one place that cannot disagree
with what actually mounts, and there is nothing else to write:

```csharp
// Features/Dashboard/Chart.cs
public sealed partial class Chart : ReactComponent
{
    /// <summary>The points to plot.</summary>
    public required IReadOnlyList<Point> Series { get; set; }

    /// <summary>Heading shown above the plot.</summary>
    public string? Heading { get; set; }
}

// Features/Dashboard/Meter.cs
public sealed partial class Meter : SvelteComponent
{
    /// <summary>The reading, 0..1.</summary>
    public double Value { get; set; }
}

// Features/Dashboard/Gauge.cs
public sealed partial class Gauge : LitComponent
{
    /// <summary>The needle position, 0..1.</summary>
    public double Value { get; set; }
}
```

`partial` is required ([RASK056](https://rask.sh/docs/guides/diagnostics/#rask056)) — the component's name, its module and its
props writer are generated into the same class.

The front-end file is found the way scoped CSS and scoped JS already are: by filename, beside the
class. `Chart.cs` pairs with `Chart.tsx`; `Meter.cs` with `Meter.svelte`; `Gauge.cs` with `Gauge.ts`.
**The base class is what makes that inference possible for Lit** — a Lit component is ordinary
TypeScript and nothing about a `.ts` extension distinguishes it, so before the runtime was stated by
the type, every Lit component
had to name its module by hand.

Override `Module` only when the file lives somewhere convention cannot reach:

```csharp
public sealed partial class Vendor : ReactComponent
{
    protected override string Module => "@acme/charts/Chart";
}
```

It has to be a **constant string** ([RASK059](https://rask.sh/docs/guides/diagnostics/#rask059)). The bundler reads it at build
time to generate the entry module, long before any of this code runs, so anything computed would
leave the browser resolving a name the bundle never built.

A `Module` that names a package rather than a file makes a **package island**, whose props can come from the
package's own TypeScript instead of being declared here — see
[Using a package component directly](#using-a-package-component-directly).

### It costs the inheritance slot, and that is the trade

A component already extending a base class of your own cannot also be a `ReactComponent` — C# gives
every class one base. That is deliberate rather than an oversight: chrome in Rask comes from the
chain, not from inheritance, so the answer is to compose.

```csharp
Ui.Card[ Chart.Series(points) ]        // ✓ kit chrome around a React component
class Themed : MyBase, ReactComponent   // ✗ does not compile, and should not
```

The alternative — a marker attribute usable on any class — was tried first. It works, but it means
writing the runtime twice (once as an attribute argument, once in the front-end file) with nothing
keeping the two in step.

### Two runtimes that share an extension need separate folders

React, Preact and Solid all compile `.tsx`. When two of them are in one project, each Vite plugin is
scoped to the directories its own islands live in — so they need folders of their own, and one must
not nest inside the other:

```
Features/Islands/React/Counter.tsx      ✓ each runtime in its own folder
Features/Islands/Solid/Spark.tsx

Features/Islands/Counter.tsx            ✗ refused: one tree, two JSX runtimes
Features/Islands/Solid/Spark.tsx
```

The build refuses the second arrangement by name rather than building it, because getting it wrong is
silent. Scoping has to be by **directory**, not by file: a file-level scope transforms the island
correctly and leaves every module it *imports* to the other plugin, so a `Row.tsx` beside a Solid
island gets compiled as Preact — which builds, ships, loads, and mounts a foreign vnode into Solid's
renderer. Astro documents the same rule for the same reason.

A runtime that owns its extension alone — Vue, Svelte — needs none of this and can live anywhere.
Angular is never scoped either: its plugin compiles the Angular island ahead of time and passes
ordinary TypeScript through untouched, so a Lit element sits happily beside one.

### React and Preact cannot share a project

Not a Rask rule. `@vitejs/plugin-react` resolves Babel 8 and `@preact/preset-vite` pins a
`@babel/core@"7.x"` peer, so **npm refuses to install both**. Pick one; the build says so by name
rather than leaving an ERESOLVE tree that names four Babel packages and neither island.

`ReactComponent` also still covers Preact the old way, for a project already built on it: alias
`react` and `react-dom` to `preact/compat` in both tsconfig and the Vite plugin, and one adapter serves
both. New code should reach for
`PreactComponent`, which imports Preact directly and needs no aliasing to be right.

This is also why the islands page on rask.sh carries no Preact island — it already carries a React
one. Preact is covered without a browser instead; see
[Preact, verified without a browser](#preact-verified-without-a-browser).

### What Angular needs

Angular's plugin imports two packages it does not depend on, so both have to be installed beside it,
at the same major:

```
@analogjs/vite-plugin-angular  @angular/compiler-cli  @angular/build
@angular/core  @angular/common  @angular/compiler  @angular/platform-browser  rxjs
```

It also pins **TypeScript below 6.1**, which constrains the whole project. And its islands are far
the heaviest: a one-component Angular chunk is around 73 kB gzipped, against 12 kB for Preact and
10 kB for Solid.

Every prop must be an `@Input()` (or `input()`). The adapter drives updates through
`ComponentRef.setInput`, which is the only route that marks the view dirty — a plain public field is
not an input at all, and Angular reports that in a development build while ignoring it silently in a
production one.

Angular is also the only runtime whose bootstrap is asynchronous. Props that arrive before
`createApplication()` resolves are held and applied on arrival rather than dropped, and an island
removed while still booting destroys the application when it appears instead of leaking it.

### What Solid needs

Never destructure `props`. Solid tracks the *access*, not the value, so pulling a prop into a local
reads it once and freezes it — the component renders correctly and then never updates again:

```tsx
export default function Spark(props: SparkProps) {   // ✓
  return <div>{props.caption}</div>
}

export default function Spark({ caption }: SparkProps) {   // ✗ renders once, never again
  return <div>{caption}</div>
}
```

## Using a package component directly

A component you did not write needs no wrapper. Point the class at the package, and its props come from the
package's own TypeScript — the way a [Blazor island](https://rask.sh/docs/guides/blazor-components/) reads `MudButton`'s `[Parameter]`s:

```csharp
// Features/Shop/MuiButton.cs — no MuiButton.tsx beside it
public sealed partial class MuiButton : ReactComponent
{
    protected override string Module => "@mui/material/Button";
}
```

```csharp
MuiButton
    .Variant(MuiButtonVariant.Contained)
    .Size(MuiButtonSize.Small)
    .Disabled(saving)
    .OnClick(() => Save())
```

A `Module` that names a package rather than a file — anything not starting with `./`, `../`, `/` or `#` — makes
this a **package island**, and it imports the package's default export. A named export goes in `Export`, the other
half of the same `import`:

```csharp
// import { HexColorPicker } from "react-colorful"
public sealed partial class ColorPicker : ReactComponent
{
    protected override string Module => "react-colorful";
    protected override string Export => "HexColorPicker";
}
```

A library that exports namespaces of parts is reached with a dot: `Export => "Switch.Root"` is the `Root` member of
the `Switch` export. Both must be constant strings ([RASK059](https://rask.sh/docs/guides/diagnostics/#rask059)) — the build reads them before
anything compiles.

### Several components from one package

Most libraries are used for more than one component. Declare the package once and list what you use from it:

```csharp
// Features/Shop/Mui.cs — no .tsx anywhere
public sealed partial class Mui : ReactPackage
{
    protected override string Module => "@mui/material";
    protected override string[] Exports => ["Button", "Card", "TextField"];
}
```

```csharp
Mui.Card[
    Mui.TextField.Label("Name").OnChange(v => _name = v),
    Mui.Button.Variant(MuiButtonVariant.Contained).OnClick(Save)["Save"],
    Button["a plain <button>"]
]
```

Each export is a package island of its own — the class `MuiButton`, its props from `MuiButton.props.json` beside
`Mui.cs` — reached through the declaration: typing `Mui.` lists everything the package gives you. Nothing it exports
takes a bare name, so `Button` stays the HTML `<button>`. Every runtime has its package class: `ReactPackage`,
`PreactPackage`, `SolidPackage`, `VuePackage`, `SveltePackage`, `AngularPackage` and `LitPackage`.

A dotted export reaches a member (`"Switch.Root"` is `Bits.SwitchRoot`), and a Lit package names tags
(`"sl-switch"` is `Shoelace.SlSwitch`). The declaration must be `partial` ([RASK056](https://rask.sh/docs/guides/diagnostics/#rask056)), and
`Module` and `Exports` must be constants — a collection expression of string literals
([RASK059](https://rask.sh/docs/guides/diagnostics/#rask059)) — because the build reads both before anything compiles.

### The snapshot is committed

The props are read from `MuiButton.props.json`, beside the class. It is committed like a lockfile: a fresh
clone, the IDE and a build with no Node installed all see every chain step, and a package upgrade shows up in
review as a diff of what the component accepts.

```json
{
  "schema": 1,
  "runtime": "react",
  "module": "@mui/material/Button",
  "export": "default",
  "package": { "name": "@mui/material", "version": "7.3.1" },
  "props": [
    { "name": "variant", "doc": "The variant to use.", "default": "'text'",
      "type": { "kind": "enum", "base": "string", "values": ["contained", "outlined", "text"] } },
    { "name": "disabled", "type": { "kind": "boolean" } },
    { "name": "onClick",
      "type": { "kind": "callback", "args": [ { "name": "event", "type": { "kind": "event", "name": "MouseEvent" } } ] } }
  ],
  "skipped": [ { "name": "children", "reason": "node" } ]
}
```

The snapshot names the runtime and module it describes. When the class beside it names another — a different
base class, a different export — nothing is generated ([RASK079](https://rask.sh/docs/guides/diagnostics/#rask079)).

### The build keeps it current

You do not write the snapshot. `dotnet build` writes it the first time, and refreshes it whenever it can read the
package — Node installed and the package in `node_modules`. It loads the TypeScript compiler Rask pins
(`RaskExternalTypeScriptVersion`, fetched once into `~/.rask/typescript` like tsgo and esbuild), never the
project's own copy, so two machines write the same file. A snapshot whose contents changed is rewritten and
announced:

```text
Rask islands: refreshed MuiButton.props.json (7.3.1 → 7.4.0) — commit it.
```

A prop the package removed then fails where you set it, as a compile error. That is the loud half of the contract.

A build told not to read the package — `RaskExternalBuild=false` or `RaskExternalPropsExtract=false` — compiles
from the committed file as it is. (Without Node the build stops at `RASKISLAND001` instead: islands cannot be
bundled without it either.) It fails only if there is no snapshot to compile from, and warns if the
snapshot was taken from another version than `package-lock.json` pins.

**A locked build never writes.** Under `ContinuousIntegrationBuild=true`, or with
`-p:RaskExternalPropsLocked=true`, the build still reads the package but a snapshot that no longer matches fails
the build instead of being refreshed, so CI proves the committed files are true rather than quietly fixing them.

Each code has its own entry, with the fix, in the [diagnostics reference](https://rask.sh/docs/guides/diagnostics/#island-build-diagnostics-raskisland).

| Code | Severity | When |
| --- | --- | --- |
| [`RASKISLAND005`](https://rask.sh/docs/guides/diagnostics/#raskisland005) | error | The class also has a front-end file beside it; its `Export` is not an identifier or a dotted path of them; it declares an `Export` but its `Module` names no package; or it still writes the export after a `#` in `Module` (the message gives the two overrides to write instead). |
| [`RASKISLAND006`](https://rask.sh/docs/guides/diagnostics/#raskisland006) | error | There is no snapshot, and this build cannot extract one; the message says why. |
| [`RASKISLAND007`](https://rask.sh/docs/guides/diagnostics/#raskisland007) | error | The package or the export could not be read, or it is not a component — reported at the `Module` line. |
| [`RASKISLAND008`](https://rask.sh/docs/guides/diagnostics/#raskisland008) | error | A locked build found an out-of-date snapshot. |
| [`RASKISLAND009`](https://rask.sh/docs/guides/diagnostics/#raskisland009) | warning | The compiler found a package island the build did not see before compiling, so its props were not read. Return `Module` as a constant from the class's own body. |
| [`RASKISLAND010`](https://rask.sh/docs/guides/diagnostics/#raskisland010) | warning | The snapshot was taken from a different package version than `package-lock.json` pins. |

The rest of the islands build reports under the same sequence:

| Code | Severity | When |
| --- | --- | --- |
| [`RASKISLAND011`](https://rask.sh/docs/guides/diagnostics/#raskisland011) | error | Two front-end files would register under one island name. Rename one. |
| [`RASKISLAND012`](https://rask.sh/docs/guides/diagnostics/#raskisland012) | error | A `<RaskExternal>` item names a runtime Rask has no adapter for. |
| [`RASKISLAND013`](https://rask.sh/docs/guides/diagnostics/#raskisland013) | error | `RaskExternalDevServerUrl` is not an `http(s)` origin that stops at the port. |
| [`RASKISLAND014`](https://rask.sh/docs/guides/diagnostics/#raskisland014) | error | React and Preact islands in one project — [npm cannot install both](#react-and-preact-cannot-share-a-project). |
| [`RASKISLAND015`](https://rask.sh/docs/guides/diagnostics/#raskisland015) | error | Two runtimes that compile the same extension [share a folder tree](#two-runtimes-that-share-an-extension-need-separate-folders). |
| [`RASKISLAND016`](https://rask.sh/docs/guides/diagnostics/#raskisland016) | error | A package island's Vite plugin cannot be kept off another runtime's files. Separate projects. |
| [`RASKISLAND017`](https://rask.sh/docs/guides/diagnostics/#raskisland017) | error | The generated prop types could not be read from the compiled assembly. Rebuild. |
| [`RASKISLAND018`](https://rask.sh/docs/guides/diagnostics/#raskisland018) | warning | The declared runtimes could not be read from the compiled assembly, so the file extension decides. Rebuild. |

Props are read from packages of **all seven runtimes**. Each runtime's declarations are read where
they put the props:

- **React, Preact, Solid** — the first parameter of the component's call signature (a plain function,
  `memo`, `forwardRef`), or a class component's `props`. A `children` typed as rendered content is
  skipped as a prop and makes the island take content.
- **Vue** — the instance's `$props` for `defineComponent` and vue-tsc's `<script setup>` output, or the first
  parameter of a generic component or a functional one. Emits declared only as `$emit` overloads become handler
  props named the way Vue matches them — `onUpdate:modelValue` — which the generator turns into `OnUpdateModelValue`.
  A default slot makes the island take content.
- **Svelte 5** — `Component<Props>`'s props. A snippet prop is skipped, and a `children` snippet makes the island
  take content. **Svelte 4** typings give their props from `$$prop_def`; their `on:` events cannot be passed as props,
  and the snapshot lists them as `legacy-event` skips.
- **Lit** — a custom element's public, writable fields, all optional. Its tag is the one `HTMLElementTagNameMap` gives
  the class; a module that only registers an element exports nothing to name, so name its tag in `Export` instead:
  `Module => "@spectrum-web-components/button/sp-button.js"` with `Export => "sp-button"`. The entry imports the module for its side effect, so the
  tag has to be registered by that module or a file it imports directly — a class module that registers nothing is
  refused rather than mounted under a tag some other module defines. Where the package ships a `custom-elements.json`,
  the events it lists become handler props — `sl-change` is `OnSlChange` — which the adapter adds as event listeners.
  A prop C# stops sending goes back to the element's own default.
- **Angular** — a standalone component's inputs and outputs, including those inherited from a base class, read from
  the declarations ng-packagr writes. An input travels under its public alias (`aria-label`), signal inputs, `model()`
  and transformed inputs included; an output becomes `On<Alias>` and the adapter subscribes to it. A directive, or a
  component that is not standalone, is refused. The build links Angular's partially compiled packages,
  so nothing is compiled in the browser.

One combination is refused: a **Solid package island cannot yet share a project with React or Preact
islands**. Solid's Vite plugin would have to be confined to folders a package does not have, and the
build refuses that by name.

### How the TypeScript maps

| TypeScript | C# step | Sent as |
|---|---|---|
| `string` | `string?` | the string |
| `number` | `double?` — `.Elevation(2)` still binds | the number |
| `boolean` | `bool?` | `true`/`false` |
| `'text' \| 'outlined' \| 'contained'` | a generated `MuiButtonVariant` enum | **the literal**, never a number |
| `string \| number` | a generated struct that takes either | the string or the number |
| `T[]`, `Record<string, T>` | `IReadOnlyList<T>?`, `IReadOnlyDictionary<string, T>?` | an array, an object |
| an object type | a generated `sealed record` | an object, unset members left out |
| `Date` | `DateTimeOffset?` | tagged, so the browser gets a `Date` |
| a callback | `Callback`, or `Callback<T>` when it passes a value — unset, it is left out | a handler reference |

A required prop in the package is a required step. Everything else is optional, and **an unset prop is left out
of the JSON** rather than sent as `null`, so the package's own default applies — `Variant` unset means `'text'`,
exactly as it would in a `.tsx`.

A name no C# identifier can spell keeps the package's spelling on the wire: `aria-label` is the step
`AriaLabel` and is sent as `"aria-label"`. A prop named after one of Rask's own members is renamed rather than
allowed to hide it — `key` becomes `KeyProp`, because a generated `Key` would silently replace the island's
reconciliation identity, and a package's own `loading` becomes `LoadingProp`, beside the island's
[`Loading`](#an-island-as-a-whole-page) placeholder.

### Callbacks never send an event

A package's first callback argument is usually a DOM or synthetic event, and an event cannot cross to C#: it
holds DOM nodes and `window`. So only values cross. `onChange(event, value: number)` is `Callback<double>`, and
the browser forwards the number; `onClick(event)` is an argless `Callback`. A value of the wrong kind — a
string where the package declared a number — is not delivered at all, rather than handed to C# as a default.

### Declaring a prop yourself

A property you declare on the island wins over the snapshot, keeps the type you gave it, and is still sent under
the package's name. It is also the way through for a prop Rask cannot generate
([RASK080](https://rask.sh/docs/guides/diagnostics/#rask080)):

```csharp
public sealed partial class MuiButton : ReactComponent
{
    protected override string Module => "@mui/material/Button";

    /// <summary>Any variant the app's theme defines, not only the three the typings list.</summary>
    public string? Variant { get; set; }
}
```

The generated `MuiButtonVariant` is not emitted then, and `.Variant("gradient")` passes a string the typings did
not list. A `[JsonPropertyName]` on it pins a wire name of your own.

An island that names a package and declares its props by hand with no snapshot — the shape the
[`Module` override](#declaring-one) above shows for a vendor component — still works, and follows the
package-island rules: unset props are omitted, and its callbacks forward their first argument only.

## C# owns the props

Props are C# properties, declared like any other component's, so the chain, the required-prop rule
([RASK001](https://rask.sh/docs/guides/diagnostics/#rask001)) and every existing analyzer apply unchanged. The generator emits a
reflection-free `Utf8JsonWriter` writer for them, which is what lets a component survive trimming and
AOT.

**And the types cross back.** The build generates an interface per component, so the front-end file
imports the shape rather than restating it:

```tsx
// Features/Dashboard/Chart.tsx
import type { ChartProps } from '@rask/Chart.props'

export default function Chart({ series, heading, onPointClick }: ChartProps) {
  return <figure><figcaption>{heading}</figcaption><Plot data={series} /></figure>
}
```

Rename `Series` to `Points` in `Chart.cs` and `Chart.tsx` **stops compiling**. That is the difference
between this and embedding React by hand: the contract is checked in both directions rather than
maintained by discipline.

The generated file carries whatever the props are composed of, so one import is enough:

```ts
// obj/rask-external/types/Chart.props.d.ts — generated, do not edit
export interface Point { label: string; value: number }

export interface ChartProps {
  series: Point[];
  heading: string | null;
  onPointClick?: (value: number) => void;
}
```

Three things there are decisions rather than formatting. `heading` is **nullable but still required**,
because the writer emits the key with a JSON `null` — "never set" and "set to nothing" are different
facts, and `heading?: string` would describe the wrong one. A callback is **optional**, because an
unwired one omits its key entirely. And it returns **`void` even for an asynchronous handler**: the callback
crosses as a handler reference and the client hands back a plain function that ships the payload, so
there is no promise on that side to await.

That null rule is one of three, and they differ on purpose:

| | A nullable prop left `null` | Why |
|---|---|---|
| Hand-written island (`Chart.tsx` beside `Chart.cs`) | sent as JSON `null` | the generated type says `T \| null` and required — "never set" and "set to nothing" are different facts |
| [Package island](#using-a-package-component-directly) | **left out** of the JSON | a `null` would override the package's own default |
| [Blazor island](https://rask.sh/docs/guides/blazor-components/#parameters-cross-as-c-not-json) | **parameter omitted** | `ParameterView` is authoritative, so a `null` would overwrite the component's own default |

An unwired callback is left out in all three.

`@rask/*` comes from a tsconfig fragment the build writes into `obj/`. Extend it once:

```jsonc
// tsconfig.json
{ "extends": "./obj/rask-external/tsconfig.paths.json" }
```

A fragment rather than an edit to your own tsconfig: rewriting someone else's config means preserving
their comments, key order and formatting, and would still surprise anyone who opened it — for a path
mapping that is one line to add and obvious to remove. Nothing generated is committed, so a fresh
clone shows the import unresolved until the first build.

### The check runs as part of `dotnet build`

`dotnet build` type-checks each front-end file against the props its C# declares, so the guarantee
holds by default rather than only for people who run `tsc`:

```
error TS2339: Property 'level' does not exist on type 'DialProps'.
```

Turn it off with `<RaskExternalTypeCheck>false</RaskExternalTypeCheck>` — for a deliberately red front
end mid-refactor, say. tsgo costs roughly 0.2s; the single-file checkers below are seconds each,
and none of the three has an up-to-date check — they run on every build.

### A single-file component needs its framework's own checker

Rask type-checks `.ts`, `.tsx` and `.jsx` with **tsgo**, the same native TypeScript it fetches for
everything else. tsgo parses TypeScript and JSX and nothing else, and there is no plugin seam to teach
it a `.vue` or a `.svelte` — so those are checked by **`vue-tsc`** and **`svelte-check`**, run from
your own `node_modules` at the version your `package.json` pinned.

That is a second and third toolchain, which is exactly the tax [Why Vite, and only
Vite](#why-vite-and-only-vite) declines for *bundlers*. It is accepted here because the alternative is
not a weaker check but no check: no single tool reads all five file types, and a compile-time contract
that silently skips two of them is the failure this whole feature exists to avoid. The reward is that
the check reaches into the **template**, not only the script block:

```
error TS2339: Property 'seriesTypo' does not exist on type 'DefineProps<LooseRequired<ChartProps>>'.
```

Rask does **not** fetch these the way it fetches tsgo — they are the same packages your front end
already builds with, and a second copy on a different version would disagree with the bundler about
the same file. If one is missing, the build says so in as many words and carries on; it never passes
silently.

**A component that imports nothing from npm is checked with nothing installed.** A Lit element or a
plain custom element resolves its generated props and no more, so the no-npm path is checked too —
which is the case it would be easiest to leave uncovered.

A project that *has* a `package.json` but has not installed it is skipped, with a message saying so.
That is not a preference: a `.tsx` importing `react` cannot be checked before the install, and there
is no weaker mode that works. TypeScript's no-resolve mode does not merely tolerate the missing
package — it stops resolving the generated props as well, so the contract error never fires and
correct and incorrect code fail identically.

Supported prop types are the wire vocabulary the CQRS codecs use: the primitives, `string`, `Guid`,
the date/time types, `Uri`, enums, `byte[]`, nullable versions of those, arrays and lists, string-keyed
dictionaries, and records composed of the same. Anything else is
[RASK057](https://rask.sh/docs/guides/diagnostics/#rask057) at compile time rather than `null` in the browser. `[SkipFactory]`
keeps a property out of the props entirely.

> **A prop named after an HTML tag is fine.** `Title`, `Label`, `Data`, `Form`, `Style` and friends
> hide the chain entry of the same name, and CS0108 used to make that fatal under `-warnaserror`.
> [RASKSUP001](https://rask.sh/docs/guides/diagnostics/#cs0108-a-member-hides-a-chain-entry) now suppresses it, so these
> natural prop names cost nothing. Inside the component the hidden name resolves to your property —
> qualify the tag on the rare occasion you want the element instead.

## Navigating from an island

An island that is a whole page has to send the user somewhere. It does that with the same `Routes` and `Go`
the C# side uses, generated from the project's `[Route]` pages at build and imported from `@rask/routes`:

```csharp
Routes.UserPage(Id: 42).Go();                    // C#
Routes.LoginPage().Go().Replacing();
Go.To(Routes.UserPage(Id: 42));
Go.With("page", "2"); Go.Without("page"); Go.Without();
NavLink.Href(Routes.UserPage(42))["View user"];
string href = Routes.UserPage(Id: 42);
```

```tsx
import { Routes, Go } from '@rask/routes'

Routes.UserPage({ Id: 42 }).Go()                 // TypeScript
Routes.LoginPage().Go().Replacing()
Go.To(Routes.UserPage({ Id: 42 }))
Go.With('page', '2'); Go.Without('page'); Go.Without()
<a {...Routes.UserPage({ Id: 42 }).Link}>View user</a>
const href = Routes.UserPage({ Id: 42 }).Url
```

The rules:

- **It is generated, and it is the C# `Routes` class.** Page names, the folder nesting two pages sharing a
  type name get (`Routes.Admin.HomePage()`), and every member's spelling — `Go()`, `Replacing()`, `Url`,
  `Link`, `Go.To`, `Go.With`, `Go.Without` — are the C# ones, capital letter included. Rename a page or a
  route parameter in C# and the island stops compiling in the type-check `dotnet build` already runs.
- **Parameters are one object whose keys are the C# parameter names.** `Routes.UserPage(Id: 42, Tab: "billing")`
  is `Routes.UserPage({ Id: 42, Tab: 'billing' })`: required where C# requires it, optional where C# defaults
  it to `null`, and a page with no parameters takes no argument. The types are the ones
  [props use](#c-owns-the-props): `number`, `string`, `boolean`, `Guid`, `DateOnly`, `TimeOnly`, `Date`.
- **Type-safe only.** There is no `navigate('/users/42')`. `Go.To` takes a value `Routes.*` made and nothing
  else, so a path typed by hand, or an object built to look like a route, does not compile.
- **The URL is the one C# formats** — same encoding, same optional segments, same query — under the app's
  path base, so `Url` and `Link.href` are right on a sub-path deploy too.
- **`Link` is for links inside the app.** It is `{ href, 'data-rask-nav': '' }`, the two attributes
  [`NavLink`](https://rask.sh/docs/guides/routing/#type-safe-urls--somepageurl-and-somepagego) writes, so a click navigates client-side
  and open-in-new-tab still has a real address. A link that leaves the app is a plain `<a href="https://…">`.
- **`Go.With` / `Go.Without` change this page's query** and leave its path alone, matching keys without
  regard to case, as [in C#](https://rask.sh/docs/guides/routing/#methods). Each is a new history entry.

Navigation runs after your handler returns, which is what lets `.Replacing()` follow `.Go()`. Called before
the Rask runtime has booted it navigates nowhere and says so in the console (`Rask islands: …`).

What has no TypeScript counterpart: the per-page `UserPage.Go(42)` / `UserPage.Url(42)` shorthands (use
`Routes.UserPage(…)`), `Go.To` with a path string, and the several-keys-at-once `Go.With(…)` overload. A
`double` or `decimal` in a URL is written the way JavaScript prints a number, which differs from .NET only
past fifteen digits or in trailing zeros, and a parameter typed with your own `IParsable<T>` is a `string`
you format yourself.

## Callbacks

A callback prop becomes a function on the front end, and calling it re-enters C#:

```csharp
public Callback<int> OnPointClick { get; set; }
```

```tsx
export default function Chart({ series, onPointClick }: ChartProps) {
  return <Plot data={series} onPointClick={onPointClick} />
}
```

```csharp
Chart.Series(_points).OnPointClick(Select)                  // synchronous
Chart.Series(_points).OnPointClick(async i => await Load(i))   // asynchronous — same property
```

**One property per callback, taking either shape.** `Callback` and `Callback<T>` hold a synchronous
handler or an asynchronous one, so there is no `…Async` sibling to declare and no pair to get wrong.
The wire is identical either way: the front end never learns whether the C# on the other side awaits,
and should not.

**The argument is a scalar**: a number, `bool`, `string`, `Guid`, `DateTime`, `DateTimeOffset` or an
enum. A record or a list as a callback argument is [RASK057](https://rask.sh/docs/guides/diagnostics/#rask057) at compile time —
send an id and look the rest up in C#.

A carrier rather than a bare `Action<T>?` because a delegate-typed property is *invocable* — C# would
read `.OnPointClick(fn)` as invoking the property and never reach the chain step of the same name
(CS1593). The step still accepts the bare shapes (`Action<T>`, `Func<T, Task>`), so only the
declaration changes.

They travel as a handler reference rather than a value, and reach C# through the **same channel every
DOM handler uses**: the open WebSocket on the Server host, a direct `[JSExport]` call into this tab's
runtime on WASM. An island never opens a channel of its own, and the `.tsx` is byte-identical on both
hosts. On the Server host a callback therefore inherits sequence stamping, the
queue-while-reconnecting and the auth suppression window. WASM has no socket to stamp or reconnect;
what it has instead is a page that is prerendered, so its islands are clickable before .NET has
booted — a callback fired in that window is held and delivered once the app has rendered its first
frame, to the island that fired it. (If the app renders a different page than the prerendered one,
the held callback is dropped with a console warning rather than risk reaching another handler.)

A callback that is not wired omits its key entirely, so the front end sees `undefined` rather than a
key that still looks callable. A **data** prop set to null stays a JSON `null`, because "never set"
and "set to nothing" are different facts.

### An island is already inside your session

There is nothing to wire for [authentication](https://rask.sh/docs/guides/authentication/), and that follows from the sentence
above rather than from anything auth-specific. A callback re-enters C# through the host's existing
channel, so it arrives in the same live session every other handler runs in — the one whose principal
was seeded from the cookie at connect and re-seeded on every reconnect. A handler invoked from a
React island sees exactly what the same handler would see if a `Button` had called it:

```csharp
public sealed partial class Dashboard(IUserProvider users) : Component
{
    // Handed to the island as an ordinary callback prop. It runs here, in C#, under this
    // visitor's identity — the island never learns who they are, and never needs to.
    private Task Save(string note) =>
        notes.Save(users.Current.Identity!.Name!, note);
}
```

Two consequences worth stating, because both are easy to assume the other way:

- **Do not send the principal into an island as a prop.** Props are serialized into the page, so a
  role or a user id put there is a value the browser can read and edit. Keep the decision in C# and
  send the island the *outcome* — the rows it may show, the buttons it may render.
- **Gating the island's host component gates the island.** `[Authorize]` on the page and the
  `Authorize` component around the subtree both work normally; an island whose parent does not render
  is never mounted.

> **What backs this.** The mechanism is the handler channel, and that channel's behaviour under
> authentication is what `Rask.Server.Tests`'s dispatch suite pins — including that a handler is not
> invoked at all once access is revoked mid-session. There is no island-specific auth path to test
> separately, because there is no island-specific channel: that is the design, stated above.

## A complete front-end file, per runtime

The `.tsx` shape is shown above. These are the other four, exactly as `rask new --islands <runtime>`
writes them, each against the same three props:

```csharp
public sealed partial class VueCounter : VueComponent   // or Svelte…, Angular…, Lit…
{
    public int Step { get; set; } = 1;
    public required string Caption { get; set; }
    public Callback<int> OnTotal { get; set; }
}
```

**Vue** — `VueCounter.vue`. Props arrive through `defineProps`, typed by the generated interface:

```vue
<!-- An ordinary Vue single-file component. The props type is generated from VueCounter.cs. -->
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { VueCounterProps } from '@rask/VueCounter.props'

const props = defineProps<VueCounterProps>()
const total = ref(0)

watch(total, value => props.onTotal?.(value))
</script>

<template>
  <div class="island-counter" data-testid="vue-counter">
    <div class="caption">{{ props.caption }}</div>
    <button type="button" data-testid="vue-counter-add" @click="total += props.step">
      add {{ props.step }}
    </button>
    <span class="total">
      total <strong data-testid="vue-counter-total">{{ total }}</strong>
    </span>
  </div>
</template>
```

**Svelte 5** — `SvelteCounter.svelte`, in runes mode:

```svelte
<!-- An ordinary Svelte 5 component, in runes mode. The props type is generated from SvelteCounter.cs. -->
<script lang="ts">
  import type { SvelteCounterProps } from '@rask/SvelteCounter.props'

  const props: SvelteCounterProps = $props()
  let total = $state(0)

  $effect(() => {
    props.onTotal?.(total)
  })
</script>

<div class="island-counter" data-testid="svelte-counter">
  <div class="caption">{props.caption}</div>
  <button type="button" data-testid="svelte-counter-add" onclick={() => (total += props.step)}>
    add {props.step}
  </button>
  <span class="total">
    total <strong data-testid="svelte-counter-total">{total}</strong>
  </span>
</div>
```

**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: `
    <div class="island-counter" data-testid="angular-counter">
      <div class="caption">{{ caption }}</div>
      <button type="button" data-testid="angular-counter-add" (click)="add()">add {{ step }}</button>
      <span class="total">
        total <strong data-testid="angular-counter-total">{{ total() }}</strong>
      </span>
    </div>
  `,
})
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`
      <span class="caption">${this.caption}</span>
      <button type="button" data-testid="lit-badge-add" @click=${this.add}>add ${this.step}</button>
      <strong data-testid="lit-badge-total">${this.total}</strong>
    `
  }
}

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)                                              // <rask-external …></rask-external>
Report.Id(Id).Loading(Ui.Skeleton.Class("h-64 w-full"))    // <rask-external …><div class="…"></div></rask-external>
```

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 `<a>` inside an island is a full page
load:

```tsx
<a href="/orders">Orders</a>                  // reloads the document
<a href="/orders" data-rask-nav>Orders</a>    // 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 `<slot>` in
Lit, `<ng-content>` 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
<div class="flex h-32 items-end gap-2">
```

**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
<ItemGroup>
  <RaskTailwindSource Include="../Shop.Web/Features/Islands/**/*.vue"/>
</ItemGroup>
```

`@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
`<RaskExternalDevServerPort>` 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/<path>`, 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 `<body>`. 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/<Name>.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/<Name>.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 `<rask-external manifest="https://elsewhere.example/m.json">`. 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
<ItemGroup>
  <RaskExternal Include="widgets/gauge.ts" Runtime="lit"/>
</ItemGroup>
```

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>$(MSBuildWarningsAsMessages);RASKISLAND004</MSBuildWarningsAsMessages>
```

`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.

`<RaskExternal Include="widgets/gauge.ts" Runtime="lit"/>` 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/<PackageId>/`, so `RaskExternalPublicBase` defaults to
`/_content/<PackageId>/_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
<rask-external name="Badge" runtime="lit"
               manifest="/_content/Acme.Ui/_rask/external/manifest.json"> … </rask-external>
```

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.
