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

JavaScript interop: element refs, scoped CSS & TypeScript

Reaching the DOM and shipping component-scoped styles and scripts. Scoped scripts are TypeScript; a .js sibling is refused at build time (RASK055). The same code runs on both transports — Server (WebSocket) and WASM (JSImport/JSExport).

On this page


Scoped CSS

Drop a {Component}.css next to {Component}.cs and it is auto-included and scoped to that component — Blazor-parity isolation, no build step:

Pages/HomePage.cs
Pages/HomePage.css      ← styles here only apply to HomePage's elements

Each component gets a stable r-{8hex} scope id. The serializer stamps data-{scopeId} on the component's elements and rewrites every selector to selector[data-{scopeId}].

.card { padding: 1rem; }            /* scoped to this component */

@media / @supports / @container / @layer recurse into their bodies; @keyframes, @font-face, and @import pass through unscoped. Opt a project out of auto-globbing with <RaskScopedCssAutoInclude>false</RaskScopedCssAutoInclude>.

Auto-globbing arrives with the host package's build integration, so it needs a direct PackageReference to Rask.Server or Rask.Wasm — NuGet applies a package's build/ folder only to the project that references it. A component class library that picks a host package up transitively needs its own reference (the same reach the implicit global usings have). bin/, obj/, node_modules/ and wwwroot/ are excluded from the glob.

Global styles (a brand palette, :root variables, shell tags like body, or framework classes from a third-party stylesheet) don't belong in a scoped {Component}.css — there is no opt-out selector. Put them in a plain stylesheet under wwwroot and link it from your App component's <Head>, exactly as you would any other static stylesheet:

// wwwroot/global.css is a normal, unscoped stylesheet.
Link.Rel("stylesheet").Href(LiveOptions.PathBase + "/global.css")

LiveOptions.PathBase keeps the URL correct under a reverse-proxy prefix (Server) or a sub-path deploy like GitHub Pages (WASM). User <Head> contributions are spliced in before the auto-injected scoped links, so global.css sits earlier in the cascade than any scoped component CSS.

An orphan .css with no matching component, or two that match ambiguously, raises RASK015 / RASK016. See diagnostics.

Don't let a script mutate a <Head> element Rask renders. Head contributions are reconciled by key on every full-document morph — the WASM first frame, and every cross-route navigation — so an attribute some JavaScript changed at runtime is put back to what your component rendered, and then changed again when whatever mutated it runs a second time. The pattern this bites is the common one for a render-blocking third-party stylesheet: <link media="print" onload="this.media='all'">. On a Rask page that link un-applies at every morph and re-applies a frame later — a full-page reflow that reads as a flicker "when it hydrates", which is exactly what it cost rask.sh's own type. If you need an asset the framework must not touch, either host it yourself and link it plainly (a same-origin file preloaded from the head needs no deferral trick at all), or inject it from a script so the head observer tags it data-rask-managed and the reconciler leaves it alone.

Two components declare the same .box selector in their own .css; each is scoped to its own data-r-{id}, so they never collide — one paints red, the other blue:

namespace Rask.Site.Features;

public sealed partial class ScopedRed : Component
{
    protected override Component? Render() =>
        Div.Class("box")[
            Span.Class("dot"),
            "I think .box should be red."
        ];
}
Live result
I think .box should be red.
I think .box should be blue.

Scoped TypeScript

A sibling {Component}.ts is compiled, then wrapped onto window.Rask["{TypeName}"], with every export function NAME (or export async function NAME) becoming a method:

// ElementRefDemo.ts
export function width(el: HTMLElement | null): number {
    return el ? el.getBoundingClientRect().width : 0;
}

// async exports work too — e.g. CodeSample.ts
export async function copy(text: string): Promise<void> {
    await navigator.clipboard.writeText(text);
}

becomes callable as Rask.ElementRefDemo.width — and, from the component that owns the file, as a typed private method (next section). Two scoped components that share a simple type name collide at window.Rask[Name] — RASK020 warns about this (RASK017 / RASK018 cover orphan / ambiguous .ts).

Calling your script from C#

The component that owns a scoped .ts calls its exports like its own private methods — no IJSRuntime to inject, no string to spell, and a renamed or retyped export is a compile error:

// before
public ElementRefDemo(IJSRuntime js) => _js = js;
var width = await _js.InvokeAsync<double>("Rask.ElementRefDemo.width", _box);

// after — Width is generated from `export function width(el: HTMLElement | null): number`
var width = await Width(_box);

Each call returns a ValueTask (it crosses to the browser), and the export's JSDoc becomes the method's IntelliSense. The component must be partial, which rask new components already are.

TypeScript C#
string · number · boolean · Date string · double · bool · DateTimeOffset
T \| null, x?: T T?, T? x = null
T[], Record<string, T> IReadOnlyList<T>, IReadOnlyDictionary<string, T>
"a" \| "b" string
HTMLElement and every other element, as a parameter ElementRef?
[number, string], [x: number, y: string] — a whole parameter or return (double, string), (double X, string Y)
void, Promise<T> ValueTask, ValueTask<T>
any / unknown object? in, JsonElement out
(n: number) => void a sync and an async overload — OnTick(n => _n = n) or OnTick(async n => …)
export interface Point { … } a nested record Point, with required members
export class Chart { … } a nested Chart proxy with its methods, created by NewChart(…)
export const double = (x: number) => x * 2 Double(double x) — an arrow in a const is a function like any other

number is always double: TypeScript has one numeric type, and a value that crosses as JSON keeps no trace of being whole.

A callback re-enters the component and repaints it after it runs, in order with its event handlers. It lives until the component unmounts, so hand one over once (from OnFirstRender, or a start button) rather than on every render. An optional argument you leave unset reaches the script as undefined, so its defaults apply. A class instance lives in the browser; the proxy calls its methods and is released when the component unmounts (or on await chart.DisposeAsync()), as is every callback it was handed:

// ScriptCallsDemo.ts
export class Countdown {
    constructor(private seconds: number) {}
    start(onTick: (left: number) => void, onDone: () => void): void { … }
    stop(): void { … }
}
_countdown ??= await NewCountdown(5);
await _countdown.Start(left => _status = $"{left} left", () => _status = "Done!");
namespace Rask.Site.Features;

/// <summary>
///     Calls its own scoped TypeScript like private methods: <c>WindowSize()</c>, <c>HalfSize()</c>,
///     <c>NewCountdown(5)</c> and the countdown's <c>Start</c>/<c>Stop</c> are generated from <c>ScriptCallsDemo.ts</c>.
/// </summary>
public sealed partial class ScriptCallsDemo : Component
{
    private Countdown? _countdown;
    private string _status = "Idle";

    protected override Component? Render() =>
        Div[
            Div.Class("flex gap-2 flex-wrap items-center mb-2")[
                Ui.Button.Primary.OnClick(StartCountdown)["Count down from 5"],
                Ui.Button.OnClick(StopCountdown)["Stop"],
                Ui.Button.OnClick(ReadViewport)["Read the window size"],
                Ui.Button.OnClick(ReadHalf)["Half the window"]
            ],
            P.Class("text-sm text-zinc-500 dark:text-white/70 mb-0 script-calls-status")[_status]
        ];

    private async Task StartCountdown()
    {
        _countdown ??= await NewCountdown(5);
        await _countdown.Start(left => _status = $"{left} left", () => _status = "Done!");
        _status = "5 left";
    }

    private async Task StopCountdown()
    {
        if (_countdown is not null)
        {
            await _countdown.Stop();
            _status = "Stopped";
        }
    }

    private async Task ReadHalf()
    {
        var (width, height) = await HalfSize();
        _status = $"Half: {width:F0} × {height:F0}";
    }

    private async Task ReadViewport()
    {
        var size = await WindowSize();
        _status = $"Window: {size.Width:F0} × {size.Height:F0}";
    }
}
Live result

Idle

An export that has no C# shape — a generic, a tuple inside other data, an element returned, a plain value — is left out with RASK094 naming the reason; the others still generate, and the string call reaches it. A name the component only inherits is hidden, not refused: export function stop() gives Stop(), and Markup.Stop still writes the SVG tag.

What your script writes on a rendered element stays. A canvas your script sizes — canvas.width = 640 on the element a Canvas.Ref(_ground) names — keeps that size through every re-render: a render removes only attributes it wrote itself. Once your component renders the attribute (.Width(…)), it is the render's, and leaving it out of a later render removes it.

A .js sibling is a build error — RASK055. TypeScript is a superset of JavaScript, so migrating an existing scoped script is the rename and nothing else; add annotations at whatever pace suits you. The reason it is an error rather than a quiet fallback is that the failure has nowhere else to surface: an unregistered scoped script leaves window.Rask["Name"] with no methods, so the component renders a control that does nothing, with no error anywhere.

What compiles it

tsgo — the Go build of the TypeScript compiler — fetched once as a native binary into ~/.rask/typescript and verified against an integrity recorded in Rask itself for the pinned version (a registry's own checksum comes from where the tarball does; an overridden RaskTsgoVersion is checked against that alone, with a warning, unless RaskTsgoIntegrity pins it). No npm, no Node, no node_modules, the same arrangement Tailwind uses. RaskTypeScriptBuild=false turns it off, and RaskTypeScriptOffline=true refuses to fetch and fails naming the file to put in place.

Ordinary builds compile without type-checking, so the inner loop stays fast; the check itself belongs in your test gate, where a failure is loud and attributable. Rask's own gate runs tsgo --noEmit --strict over every scoped file in the repository.

A Debug build emits a source map for every scoped file, so devtools and VS Code's debugger show — and stop on — your .ts, not the emitted JavaScript. The map is inline in the compiled text, the registry lifts it out, and the bundle is served with an index map beside it at /_rask/a/{hash}.js.map (baked beside the bundle for a WebAssembly app). Its sources are file:// URLs to the .ts on your machine, with the source text inlined too. A Release build emits none; RaskScopedTsSourceMap=true|false overrides either way. See Debugging in VS Code.

A Release build also strips comments from the emitted JavaScript (--removeComments) — on rask.sh that halves the scoped assets' gzipped size — while Debug keeps them. /*! … */ license headers are kept. RaskScopedTsRemoveComments=true|false overrides either way. It is not minified: a minifier rewrites export function NAME( into a trailing export { … } clause, and that line-start form is exactly how the registry finds the methods to put on window.Rask[Name].

Rask ships ambient declarations for its own browser globals (window.DotNet, window.Rask), so calling a [JSInvokable] needs no declaration of your own. For a third-party library, write a narrow .d.ts beside your code describing what you actually call — any .d.ts in the project is compiled alongside your scoped files. A hand-written .d.ts for a vendored UMD bundle is a worked example.

What your editor reads

rask new writes a tsconfig.json. The build never reads it — Rask hands tsgo an explicit file list and explicit flags, which is what keeps the emitted form the one the asset registry parses. It is there so your editor checks what the gate checks, with the same strict.

Its include covers obj/rask/types, where the build stages Rask's ambient declarations. The real file ships inside the NuGet package, under a versioned cache directory no tsconfig.json can name, so the staged copy is what makes window.Rask and window.DotNet resolve while you are typing. It appears after the first build — before that, expect your editor not to know them yet.

noEmit is set deliberately. An editor that decided to emit would write a .js beside your .ts, and that is RASK055 — a confusing way to meet it.


Delivery & caching

Scoped CSS and JS each ship as one content-addressed bundle. The generator registers every component's scoped asset; the framework concatenates all registered scoped CSS into a single bundle and all registered scoped JS into another (hash-sorted, so the bytes — and the URL — are deterministic across builds). Each bundle is served at /_rask/a/{hash}.{ext} with Cache-Control: immutable, an ETag, nosniff, and .AllowAnonymous() — and brotli/gzip compressed when the client advertises it (negotiated per request, with each compressed representation built once and cached by content hash since the bytes never change). The page <head> emits exactly one <link rel="stylesheet"> and one <script defer> — the two bundles — keyed rsk-css / rsk-js so the client morph updates them in place when the hash changes (hot reload). Static-file and WASM hosts get the same two files baked to disk by the BakeScopedAssetsTask MSBuild task, so any static-asset host (MapStaticAssets, a CDN) serves them.

A Server app's client runtime is cached the same way. The page asks for it as /rask/rask.js?v={hash}, served immutable with an ETag and brotli/gzip; the bare /rask/rask.js still answers, with Cache-Control: no-cache, so it is revalidated (a 304 when unchanged) rather than pinned. The behaviour hooks are a second script beside it, /rask/rask-hooks.js?v={hash}, fetched only by a page that asks for a hook and served the same way; one hash names the pair, so it moves when either file does.

No navigation FOUC

Because the whole bundle ships up front, a component that mounts later — client-side navigation, a conditionally rendered section — is styled the instant its node is inserted: its rule is already in the applied CSSOM, so there is no per-component lazy fetch and no flash of unstyled content, and the scoped-JS namespace (window.Rask[...]) is ready on first interaction. Scoped CSS is selector-rewritten to [data-r-xxxx], so a bundle rule for an unmounted component has no visual effect until its elements exist.

The demos below all draw from that one shared bundle. A component with only scoped CSS; a component with scoped JS keeping module state (window.Rask.JsOnlyDemo.bump); two components declaring the same .twin-tag selector, each isolated to its own scope; and a lazily-mounted child whose rule already rides the bundle, so it paints the instant it mounts — no per-component fetch, no FOUC:

namespace Rask.Site.Features;

/// <summary>
///     Single component with a sibling <c>BasicScopedCss.css</c>. When this component is
///     mounted, the framework emits one
///     <c>&lt;link href="/_rask/a/{hash}.css" data-rask-key="rsk-css-{hash}"&gt;</c>
///     into <c>&lt;head&gt;</c>. The browser fetches the bytes from the endpoint with
///     <c>Cache-Control: immutable</c>.
/// </summary>
public sealed partial class BasicScopedCss : Component
{
    protected override Component? Render() =>
        Div.Class("basic-card")[
            P["This card's pink background and rounded corners come from a sibling ",
                Code["BasicScopedCss.css"],
                " file. The framework hashes the rewritten CSS and emits a ",
                Code["<link>"],
                " into the page head — open DevTools and you should see a request to ",
                Code["/_rask/a/{12-hex}.css"],
                " with ",
                Code["cache-control: public, max-age=31536000, immutable"],
                "."
            ]
        ];
}
Live result

This card's pink background and rounded corners come from a sibling BasicScopedCss.css file. The framework hashes the rewritten CSS and emits a <link> into the page head — open DevTools and you should see a request to /_rask/a/{12-hex}.css with cache-control: public, max-age=31536000, immutable.

using System.Globalization;

namespace Rask.Site.Features;

/// <summary>
///     Component with only a sibling <c>JsOnlyDemo.js</c> — no CSS. Regression case:
///     pre-cutover, JS-only components silently dropped out of head emission because the
///     mounted-set was populated from a CSS-presence gate. Now they emit a
///     <c>&lt;script src="/_rask/a/{hash}.js" defer&gt;</c> tag like any other.
/// </summary>
public sealed partial class JsOnlyDemo : Component
{
    private string _clicks = "0";

    protected override Component? Render() =>
        Div.Class("flex gap-3 items-center flex-wrap items-center")[
            Ui.Button.Class("js-only-btn").OnClick(HandleClick)["Click to bump (via scoped JS)"],
            Span.Class("text-zinc-500 dark:text-white/70")["Bumped ", Strong[_clicks], " times"]
        ];

    private async Task HandleClick()
    {
        var next = await Bump();
        _clicks = next.ToString(CultureInfo.InvariantCulture);
    }
}
Live result
Bumped 0 times
namespace Rask.Site.Features;

/// <summary>
///     Twin A — paired with <see cref="TwinB" /> to demonstrate two components with
///     different scoped CSS each get their own content-addressed URL. Two
///     <c>&lt;link&gt;</c> tags in <c>&lt;head&gt;</c>, two distinct hashes.
/// </summary>
public sealed partial class TwinA : Component
{
    protected override Component? Render() =>
        Div.Class("twin-tag")["Twin A — independent hash"];
}
Live result
Twin A — independent hash
Twin B — different colors, different hash
namespace Rask.Site.Features;

/// <summary>
///     Lazy mount demo: a Show/Hide toggle that mounts/unmounts <see cref="LazyChild" />.
///     When mounted, the framework emits LazyChild's <c>&lt;link&gt;</c> into <c>&lt;head&gt;</c>
///     (browser fetches the CSS for the first time). When unmounted, the morph removes
///     the tag — but the browser keeps the bytes cached, so re-mounting is a cache hit.
/// </summary>
public sealed partial class LazyMount : Component
{
    private bool _shown;

    protected override Component? Render() =>
        Div[
            Ui.Button
                .Class("mb-3")
                .OnClick(() => _shown = !_shown)[_shown ? "Hide LazyChild" : "Show LazyChild"],
            _shown ? LazyChild : Div
        ];
}
Live result

See also: Composition for component-to-component communication, and the architecture notes for how the live runtime ships these.

Confirm