You're reading the Rask v1.0.0 guides.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.

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. Two scoped components that share a simple type name collide at window.Rask[Name]RASK020 warns about this (RASK017 / RASK018 cover orphan / ambiguous .ts).

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 the checksum its registry publishes. 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.

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.

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 Microsoft.JSInterop;

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(IJSRuntime js) : Component
{
    private string _clicks = "0";

    protected override Component? Render() =>
        Div.Class("flex gap-3 items-center flex-wrap items-center")[
            Button.Class($"{Tw.BtnOutlinePrimary} js-only-btn").Type("button").OnClickAsync(HandleClickAsync)[
                "Click to bump (via scoped JS)"],
            Span.Class("text-ui-muted")["Bumped ", Strong[_clicks], " times"]
        ];

    private async Task HandleClickAsync()
    {
        var next = await js.InvokeAsync<int>("Rask.JsOnlyDemo.bump");
        _clicks = next.ToString();
    }
}
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 static readonly Component Empty = Div;
    private bool _shown;

    protected override Component? Render() =>
        Div[
            Button.Class($"{Tw.BtnOutlineSecondary} mb-3").Type("button").OnClick(() => _shown = !_shown)[
                _shown ? "Hide LazyChild" : "Show LazyChild"
            ],
            _shown ? LazyChild : Empty
        ];
}
Live result

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