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

Blazor components

Rask.Blazor hosts a real Blazor component inside a Rask page — one from a Razor Class Library, from MudBlazor or Radzen, or any ComponentBase you already have. The Razor SDK is neither replaced nor reconfigured: it compiles .razor exactly as it always has, and Rask renders the result.

This is the islands contract with a different runtime behind it. A .tsx is a React component rendered by React; a .razor is a Blazor component rendered by Blazor.

To be exact about what changed and what did not: you still never write .razor to write Rask. A .razor here is a component you are hosting — someone else's, or your own from a class library — not the way you author a Rask page. The chain is still the only authoring surface.

public sealed partial class Chart : BlazorComponent<MudChart> { }
Div.Class("grid")[
    H1["Revenue"],
    Chart.ChartSeries(_series).Width("100%"),
    Button.OnClick(Refresh)["Refresh"],     // ordinary Rask, right beside it
]

What works, and what does not

Read this table before anything else — it is the whole shape of the feature.

Hosted Blazor component
Renders, styled, in the first HTTP response ✅
OnInitialized, OnInitializedAsync, OnParametersSet, BuildRenderTree ✅
Reacts to a Rask prop change, keeping its own state ✅
Its own @onclick, @onkeydown, @onwheel, … firing from the browser, with their real event args ✅ (see events)
Rask children inside it ❌ — a compile error (RASK062)
[Inject] services — IJSRuntime, NavigationManager, Rask's browser APIs ✅ (see services)
OnAfterRenderAsync ✅ — once, after the first paint (see services)
ElementReference, and anything that takes one ❌ — captures are discarded
Its own @onsubmit, or an event whose args are a library's own type ❌ — see events
@bind writing a value back ✅ (see binding)
WebAssembly, published trimmed ✅ annotated for it, but not gated end to end — see both hosts

A worked example

End to end, and compiled: the component below is a real .razor in the repository, its island is declared in the test suite, and the output printed here is asserted by tests/Rask.Blazor.Tests/DocExampleTests.cs. A test also pins this document against the file, so the two cannot drift apart.

1. The Blazor component, in a Razor Class Library — an ordinary .razor, compiled by the Razor SDK, with nothing in it that knows Rask exists:

@using System.Globalization

<div class="price-tag @Tone">
    <strong>@Symbol</strong>
    <span>@Price.ToString("0.00", CultureInfo.InvariantCulture)</span>
</div>

@code {
    [Parameter, EditorRequired]
    public string Symbol { get; set; } = "";

    [Parameter]
    public decimal Price { get; set; }

    [Parameter]
    public string? Tone { get; set; }
}

2. The island — the whole declaration. Nothing is redeclared; the chain steps are read from the component's own [Parameter]s:

public sealed partial class Quote : BlazorComponent<PriceTag>;

There is no third step in Program.cs: referencing Rask.Blazor is the whole setup.

3. Use it anywhere the chain goes — a leaf, a subtree, or a whole page:

Div.Class("grid")[
    H1["Watchlist"],
    Quote.Symbol("RASK").Price(12.5m).Tone("up")
]

What comes back, in the first HTTP response:

<rask-blazor name="Quote" component="…PriceTag">
  <div class="price-tag up">
    <strong>RASK</strong>
    <span>12.50</span>
  </div>
</rask-blazor>

Three things in that are worth naming:

  • Symbol opened the chain because PriceTag marked it [EditorRequired]. Price and Tone are ordinary optional steps, and omitting Tone leaves the component's own default alone rather than passing null.
  • The island is a leaf. Quote[ … ] would be a compile error: an island renders the markup of the component it hosts and takes no Rask children — see children.
  • 12.50 is in the first response, not painted in later. Had PriceTag awaited in OnInitializedAsync, that would be finished too.

Declaring one

Derive a partial class from BlazorComponent<T>. The base class is the declaration — there is no attribute — and T names the component being hosted.

public sealed partial class Chart : BlazorComponent<MudChart> { }

You do not redeclare its parameters. A Blazor component already states its surface, so the chain steps are read from its own [Parameter] properties. MudChart declaring [Parameter] public List<ChartSeries> ChartSeries { get; set; } is what makes Chart.ChartSeries(...) a step.

Required parameters

A parameter the hosted component marked [EditorRequired] becomes a required chain step — one the call site cannot omit, taken before any optional step. Blazor already has a word for "mandatory", so it maps onto Rask's own and neither framework has to learn the other's idiom.

// MudBlazor: [Parameter, EditorRequired] public string Label { get; set; }
Badge.Label("New")              // ✓ required, so it opens the chain
Badge.Label("New").Tone("warn") // ✓ optional steps follow
Badge.Tone("warn")              // ✗ RASK038 — Label is never set

Everything not marked stays optional. An EventCallback is never required even when marked: "you must handle this event" is not something the chain can usefully insist on, and an unwired callback is simply not wired, exactly as for any other Rask component.

The type argument is how the hosted component is named — not a sibling file, the way a .tsx island pairs by filename. That is deliberate: the common case has no .razor in your project at all, only a referenced type.

Renaming a step

When the hosted parameter's name is not what you want to write at the call site — or collides with a chain entry of the same name, which is what happens for parameters called Title, Label, Form or Select — name it explicitly:

public sealed partial class Chart : BlazorComponent<MudChart>
{
    [BlazorParameter("ChartSeries")]
    public List<ChartSeries>? Series { get; set; }
}

A property you declare yourself always wins over the generated one. The name is checked: one the hosted component does not declare as a [Parameter] is a compile error (RASK100) rather than a chain step that sets nothing.

Where the .razor has to live

For parameters to be checked at compile time, the hosted component must come from a referenced project or package. A .razor in the same project as the island is produced by the Razor source generator, and one source generator never sees another's output — so Rask's generator cannot read its parameters. The island still works; it just is not verified, and says so through RASK066.

Move the .razor into a Razor Class Library and reference it. Hosting MudBlazor or Radzen — packages, therefore referenced — is fully checked with no warning.

It is in the first response

The component is rendered on the server, during the page render, and its markup is in the HTML that is sent. A hosted component that awaits in OnInitializedAsync has finished before the response goes out rather than appearing a frame later:

protected override async Task OnInitializedAsync()
{
    Rows = await _api.Load();   // done before the page is sent
}

That works because the island does its rendering in OnUpdated, whose task is registered in the page's quiescence scope — Rask renders, waits for outstanding work, and renders again, sending the settled wave. See lifecycle.

It is also why a Blazor island suits a whole page a crawler must read. Put [Route] on the island and it is the page — a [RouteParam] property is a step like any other and can feed a [Parameter]:

[Route("/tickers/{symbol}")]
public sealed partial class TickerPage : BlazorComponent<Ticker>
{
    [RouteParam] public string Symbol { get; set; } = "";

    protected override Component? HeadAssets => Title[$"{Symbol} — quotes"];
}

Overriding HeadAssets keeps the stylesheets in RaskBlazorOptions.HeadAssets: they are registered on their own, as a JavaScript island's runtime script is, so there is no base.HeadAssets to remember. A route parameter that feeds a value-typed [Parameter] can be declared as it is ([RouteParam] public int Id { get; set; }): a non-nullable value type has no "unset" state, so it is always passed on. Declare it nullable (int? Id) only when the hosted component should keep its own default until a value is set.

URL attributes in that markup get the treatment a Rask element gives them: javascript: and data: become about:blank, and an inline data:image/… survives only where it can do nothing but draw — src on img, audio, video, source, track and input, a video's poster, an SVG image's href. An <iframe src="@Url"> fed a data: URL renders blank.

Parameters cross as C#, not JSON

Unlike a .tsx island, whose props are serialized, parameters here are passed as live CLR objects. List<ChartSeries>, a Func<>, your own domain type — all cross intact, and there is no wire vocabulary to violate.

One rule worth knowing, because it is the opposite of the islands feature's: a nullable property that is null omits its parameter entirely rather than passing null. ParameterView is authoritative, so passing null would overwrite the hosted component's own default. Not specifying a value means the component keeps whatever it would have used.

Events

The hosted component's own event handlers work, and no Blazor circuit is involved — no SignalR, no blazor.web.js, no second connection.

Blazor assigns a real handler id to every @onclick even in a static render; its own HTML writer simply discards them. Rask walks the render tree instead and writes its own data-rask-on-* attribute for each, so a click goes through the delegated listener already in the page, travels the WebSocket that is already open, and is dispatched back into Blazor. It is the same channel the React and Lit islands use for their callbacks.

Table.Rows(_rows).OnRowClick(row => _selected = row)   // fires

Every event a Rask element has an On… step for is wired, and the handler receives the event it asked for. The browser sends each event's own fields — the same payload a Rask OnKeyDown(e => …) reads — and the island turns it into the args type the hosted handler declares:

<input @onkeydown="OnKey" />

@code {
    void OnKey(KeyboardEventArgs e) => _last = e.Key;   // "Enter", not ""
}
Blazor's args Filled with
MouseEventArgs, PointerEventArgs, WheelEventArgs, DragEventArgs position, buttons, modifier keys; the pointer, the deltas, the DataTransfer
KeyboardEventArgs Key, Code, Location, Repeat, IsComposing, modifier keys
TouchEventArgs the three touch lists, modifier keys
FocusEventArgs, ClipboardEventArgs Type — all Blazor declares on them
ProgressEventArgs, ErrorEventArgs Type only: Rask models these two as a plain event
EventArgs, or a handler that takes nothing nothing is asked of the browser at all

Two things are not wired, and both render no attribute rather than pretending — a component that looks wired and does nothing on the first click is the failure this package exists to avoid:

  • An args type outside that table — a component library's own [EventHandler] args. Building one would take reflection, which a trimmed WebAssembly publish does not survive. The island logs one warning naming the island, the event and the type.
  • @onsubmit. A form's submit travels Rask's form channel, which the island does not bridge.

Each event is one round trip to .NET, as it is for any Rask handler. That is nothing for a click and worth a thought for @onpointermove, @onmousemove or @onwheel, which fire many times a second.

Binding

@bind works, including the write-back. It travels a different channel from the click above, and that is the whole reason it works: change and input are deliberately absent from Rask's DOM-event table, because a value-carrying event goes through Rask's input channel instead — the one that ships the element's value alongside the handler id.

So a bound input renders with data-rask-on-change (or data-rask-on-input under @bind:event="oninput"), the browser sends the value, Rask hands it to the island, and the island turns it into the ChangeEventArgs the binder @bind generated is waiting for.

<input @bind="Text" />
<p>echo: @Text</p>

Typing updates Text inside the hosted component and the echo follows, with no circuit involved.

Blazor's binder casts rather than parses, so the value arrives in the type it expects: a string for a text input or a single select, a bool for <input type="checkbox">, a string[] for <select multiple>.

<input type="checkbox" @bind="Done" />
<select multiple @bind="Tags">…</select>

One limit: @bind:event="oninput" on a checkbox or a multi-select still receives a string, because only the change frame carries the checked state and the whole selection. Leave those two on the default event.

An island takes no children

An island renders the markup of the component it hosts, and nothing else. Writing children into one is a compile error — RASK062 — rather than something that binds, compiles and then silently renders nothing:

Chart.Series(_series)[ H2["Revenue"] ]   // RASK062

Children would have to cross as a RenderFragment, and there is no answer that is right for every component. The hosted type may have no fragment parameter at all, one under a name only it knows (Content, Body), or several of them (HeaderContent, ToolBarContent, RowTemplate). Picking among those is how an island ends up looking composable while quietly dropping what it was given — and a silent drop is the one failure this package is built to avoid.

Compose the other way round instead. It costs nothing, and it reads better:

Div.Class("rounded-xl border p-4")[
    H2["Revenue"],
    Chart.Series(_series),
    Button.OnClick(Refresh)["Refresh"],
]

The Rask markup stays Rask's — ordinary elements, ordinary handlers, an ordinary diff — and the island stays a leaf that owns exactly the DOM its hosted component wrote.

A JS island draws the line in a different place: it takes children of its own runtime — a React island takes React islands and text — because those travel in its props and its framework renders them. Rask markup stays out of both families, for the same reason — see islands.md.

Tailwind

Worth knowing before you write a utility class in a hosted component, because the failure is silent: Tailwind only emits the classes it can see, and it detects sources from the project directory it runs in. A Razor Class Library is a different project directory, so the app's Tailwind never reads it — and a component written in flex gap-4 renders with classes no stylesheet defines.

A .razor in the same project as the island is already inside the scanned directory and needs nothing. For a class library, pick one:

The library compiles its own stylesheet. Give it a Styles/app.css with @import "tailwindcss"; and it compiles on its own build, shipping the result as a static web asset at _content/<PackageId>/css/app.css — which the app links once.

services.AddRaskBlazor(o => o.HeadAssets.Add(
    Link.Rel("stylesheet").Href("_content/MyComponents/css/app.css")));

Or the app scans the library. Tailwind v4 takes extra sources as a directive, so one stylesheet can cover both projects — no second compile, no _content link:

@import "tailwindcss";
@source "../MyComponents";

The first keeps the library self-contained and shippable on its own; the second keeps one stylesheet for the whole app. Neither is more correct, and a third-party library (MudBlazor, Radzen) needs neither — it ships its own CSS, which you add through HeadAssets the same way.

Services

There is nothing to register. An island supplies what a library component demands and an app has no reason to provide — most notably NavigationManager, which many components inject and throw without — and steps aside for anything the app registers itself. AddRaskBlazor(o => …) exists only to change an option.

NavigationManager is Rask's own routing. Uri is the page on screen under the app's path base, so a hosted NavLink, breadcrumb or tab strip highlights the right entry; NavigateTo from a hosted handler moves the browser exactly as Go.To does; and a navigation Rask makes raises LocationChanged. Rask's route carries no scheme or host, so those come from RaskBlazorOptions.BaseUri (http://localhost/ unless you set it) — set it if a hosted component prints absolute URLs. NavigateTo to another site, or with forceLoad, only updates Uri.

[Inject] works, and resolves out of your app's own container. The island builds its hosted component through Blazor's own activator, so anything you registered is available — IJSRuntime, your own services, and Rask's typed browser APIs:

@inject IViewTransitions ViewTransitions

@code {
    private bool _animated;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (!firstRender) return;
        _animated = await ViewTransitions.IsSupported();
    }
}

This did not work before #956: the component was constructed with new(), which skips the injection path entirely, so every [Inject] property was null until the first use threw somewhere that named nothing. If you want to control construction yourself, register an IComponentActivator — Blazor's own seam, which the island honours.

OnAfterRenderAsync fires once, after the island's first paint. Rask drives it, because StaticHtmlRenderer never does. Once rather than after every render, deliberately: a Rask render walk is not a Blazor render — the island is walked whenever anything on the page changes — so "after every render" would fire far more often than a .razor author expects, for reasons that have nothing to do with the component. Use OnParametersSet to react to later prop changes.

Calling StateHasChanged from the hook is safe. The island holds back the repaint a hosted component asks for while its hook is running and publishes it once when the hook returns — and because the hook fires once, that repaint cannot feed it again. So the usual shape works: read what you need, assign it, call StateHasChanged.

IJSRuntime only falls back to a runtime that throws with a message naming the fix when the app registered none of its own. Both hosts register a real one, so in practice you get that.

What still does not work is ElementReference. BlazorFrameWriter discards element-reference captures, so JS.InvokeVoidAsync("thing.init", elementRef) — the shape most component libraries use to bootstrap themselves against their own JavaScript — has no reference to pass. Interop that does not need one works.

Both hosts

Rask.Blazor targets net10.0 and net10.0-browser, and the two share one code path with no #if: a hosted component is rendered to markup in process, which browser-WebAssembly does as readily as a server. The only difference is where the renderer comes from — the ASP.NET shared framework on the server, the Microsoft.AspNetCore.Components.Web package in the browser.

Trimmed, which is the default a WASM app publishes with. That took an annotation, and the reason is worth stating: [Parameter] discovery reflects inside Microsoft.AspNetCore.Components, on the hosted type — so the trimmer removes the very property setters ParameterView is about to call, and nothing reports it. The trim analyser has nothing to point at (the reflection is in someone else's assembly), the build stays green, no exception is thrown, and the island renders as an empty <rask-blazor> element. BlazorComponent<TComponent> therefore annotates its type parameter:

public abstract partial class BlazorComponent<
    [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TComponent>

which moves the requirement to the one place that knows the concrete type — your island's own declaration — and the trimmer keeps the hosted component's members in whatever assembly it lives in. All rather than only the public properties, because [Inject] reaches non-public properties too and Blazor's own activation path asks for exactly that. Nothing to configure.

What that does not cover is a component library reaching for members by name beyond its parameters — reflection Rask cannot see and cannot annotate for. If a hosted component from a large library renders wrong only after publishing, confirm it by publishing once with:

<PublishTrimmed>false</PublishTrimmed>

and, if that is the difference, root the library with a TrimmerRootAssembly rather than turning trimming off for the whole app.

The trimmed path has no gate in this repository. It used to: the WASM showcase hosted a real .razor from a Razor Class Library on a Blazor island page, published trimmed on every build, and a browser E2E checked the hosted component's output — its parameters, its own @onclick, its @bind — because an empty island is exactly what a "the element is there" check would pass on. Both projects were deleted with the rest of the samples. The site cannot carry a replacement without a second project: a .razor in the same project as its island is RASK066, a warning, and this repository builds with warnings as errors. What remains is TrimmingContractTests, which pins that the annotation is still on the type parameter — not that a trimmed app renders. If you host a Blazor component in a trimmed publish, verify it renders content yourself; a green build proves nothing here.

It is deliberately not among the batteries Rask.Server or Rask.Wasm bring. Everything there is referenced by every app on that host, and an app that wants nothing to do with Blazor should not carry its renderer.

What is not here yet

The Blazor features a hosted component may reach for, and where each stands:

Blazor feature In an island Why
A cascading value supplied from Rask not addressed Only [Parameter]s become chain steps, and nothing maps Rask's Context.Provide onto a [CascadingParameter].
<PageTitle>, <HeadContent> not supported They render into a HeadOutlet, and the page has none — the <head> is Rask's. Set the title from the Rask page.
<Virtualize> not supported It drives its spacers from Blazor's own JavaScript through ElementReference, and neither is there.
Element @ref / ElementReference not supported The frame writer discards the capture, so the reference never points at an element.
RenderFragment, ChildContent, RenderFragment<T> not supported No chain step is generated, and children are RASK062.
A circuit, @rendermode not supported There is no circuit and no blazor.web.js; the island's renderer resolves no render mode.
  • ElementReference, and interop that needs one. Element-reference captures are discarded by the frame writer, so a component that hands its own JavaScript a reference to bootstrap against — which is how most menus, dialogs and autocompletes work — still renders inert. [Inject], IJSRuntime and OnAfterRenderAsync do work now; see services.
  • A prop change replaces the island's DOM. The update ships as a subtree replace rather than a fine diff, so scroll position and text selection inside the island are lost when a prop changes.
  • No RenderFragment parameter gets a chain step — not ChildContent, not a named one, not a templated RenderFragment<T>. See children.
  • @onsubmit, and an event whose args are a library's own type, emit no attribute. See events.
  • Circuit mode. There is none: a hosted component never gets a Blazor circuit of its own.

Diagnostics

RASK061 · RASK062 · RASK064 · RASK066

Why .razor is not compiled into the chain

A reasonable thing to want: lower .razor into Rask's own chain, so a Razor-authored component is an ordinary Rask component with the full live diff and WebAssembly support. It was investigated and rejected on facts, not taste:

  • Razor's syntax layer is internal in every shipped version — RazorSyntaxTree.Root returns a type no third party can name. There is no public way to walk a parsed Razor document.
  • The .NET 10 SDK's Razor compiler is closed. It exposes neither the intermediate document nor a way to register a pass that would receive one. Its 23 InternalsVisibleTo friends — rzc, Blazor's source generator, the VS and VS Code Razor extensions — reach those instead.
  • There is no open version to pin. The last published Microsoft.AspNetCore.Razor.Language is 6.0.36, a .NET 6-era package out of support since November 2024.

Hosting the SDK's own output costs nothing that can rot, and gains every Razor feature for free — including ones a hand-written front end would have taken years to reach.

Confirm