Rask
Rask
v0.23.1-alpha.0.496
You're reading the Rask v0.23.1-alpha.0.496 guides. Updated .View source
All guides

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.

[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:

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

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:

// 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) — 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:

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

It has to be a constant string (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.

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.

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.

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:

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 reads MudButton's [Parameter]s:

// Features/Shop/MuiButton.cs — no MuiButton.tsx beside it
public sealed partial class MuiButton : ReactComponent
{
    protected override string Module => "@mui/material/Button";
}
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:

// 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) — 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:

// 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"];
}
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), and Module and Exports must be constants — a collection expression of string literals (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.

{
  "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).

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:

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.

Code Severity When
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 error There is no snapshot, and this build cannot extract one; the message says why.
RASKISLAND007 error The package or the export could not be read, or it is not a component — reported at the Module line.
RASKISLAND008 error A locked build found an out-of-date snapshot.
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 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 error Two front-end files would register under one island name. Rename one.
RASKISLAND012 error A <RaskExternal> item names a runtime Rask has no adapter for.
RASKISLAND013 error RaskExternalDevServerUrl is not an http(s) origin that stops at the port.
RASKISLAND014 error React and Preact islands in one project — npm cannot install both.
RASKISLAND015 error Two runtimes that compile the same extension share a folder tree.
RASKISLAND016 error A package island's Vite plugin cannot be kept off another runtime's files. Separate projects.
RASKISLAND017 error The generated prop types could not be read from the compiled assembly. Rebuild.
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 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):

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 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) 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:

// 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:

// 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 left out of the JSON a null would override the package's own default
Blazor island 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:

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

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:

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);
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: 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 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#. 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#:

public Callback<int> OnPointClick { get; set; }
export default function Chart({ series, onPointClick }: ChartProps) {
  return <Plot data={series} onPointClick={onPointClick} />
}
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 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, 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:

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:

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:

<!-- 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:

<!-- 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), a callback included:

// 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):

// 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

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:

[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:

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: 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:

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:

<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, 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:

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, reported at the brackets:

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:

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:

<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:

@import "tailwindcss";
@source "../../Shop.Web/Features/Islands";

And name it again to the build, which cannot read that line:

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

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), 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+ 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: 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.

By hand, if you are adding islands to a project you already have:

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: 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) 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 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.

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 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:

<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:

<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'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. 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:

<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). 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.
  • A callback argument richer than a scalar — a record or a list is RASK057; see callbacks.
  • Server-side rendering for the bundler-backed runtimes, which is what would make Hydration.None broadly useful.

Confirm