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'sprops. Achildrentyped as rendered content is skipped as a prop and makes the island take content. - Vue — the instance's
$propsfordefineComponentand vue-tsc's<script setup>output, or the first parameter of a generic component or a functional one. Emits declared only as$emitoverloads become handler props named the way Vue matches them —onUpdate:modelValue— which the generator turns intoOnUpdateModelValue. A default slot makes the island take content. - Svelte 5 —
Component<Props>'s props. A snippet prop is skipped, and achildrensnippet makes the island take content. Svelte 4 typings give their props from$$prop_def; theiron:events cannot be passed as props, and the snapshot lists them aslegacy-eventskips. - Lit — a custom element's public, writable fields, all optional. Its tag is the one
HTMLElementTagNameMapgives the class; a module that only registers an element exports nothing to name, so name its tag inExportinstead:Module => "@spectrum-web-components/button/sp-button.js"withExport => "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 acustom-elements.json, the events it lists become handler props —sl-changeisOnSlChange— 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 becomesOn<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,Styleand 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.
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:
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#
Routesclass. 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-checkdotnet buildalready runs. - Parameters are one object whose keys are the C# parameter names.
Routes.UserPage(Id: 42, Tab: "billing")isRoutes.UserPage({ Id: 42, Tab: 'billing' }): required where C# requires it, optional where C# defaults it tonull, 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.Totakes a valueRoutes.*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
UrlandLink.hrefare right on a sub-path deploy too. Linkis for links inside the app. It is{ href, 'data-rask-nav': '' }, the two attributesNavLinkwrites, 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.Withoutchange 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 theAuthorizecomponent 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
LitElementrenders 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 ownstatic styles, or render to light DOM by overridingcreateRenderRoot().
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.
@customElementandaccessorare 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. Usestatic propertiespluscustomElements.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.tsisGauge's module only ifGauge.cssits beside it, and only ifGauge's runtime is one that writes a plain.ts— aChart.tsnext to aChart : ReactComponentstays a scoped asset, because that component's module is./Chart.tsx. - A component that overrides
Moduleto 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
useEffectruns once; - a
propsattribute change reconciles — the heading changes while the component's ownuseStatekeeps 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
StaticWebAssetBasePathorStaticWebAssetProjectMode. Both are empty at evaluation, and onceResolveStaticWebAssetsConfigurationhas run a browser-WASM app resolves to_content/Rask.Site/Default— character-for-character what a class library resolves to, while itswwwrootis served at the site root.OutputTypeis 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:
ReportUnbuiltIslandswarns 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
EditOppaths are positionalchildNodesindices — 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.Nonebroadly useful.