# 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

- [IJSRuntime, typed APIs & refs](https://rask.sh/docs/guides/js-interop-runtime/) — calling JS, the typed browser-API layer, element refs, wrapping a third-party lib.

- [Scoped CSS](#scoped-css)
- [Scoped TypeScript](#scoped-typescript)
- [Delivery & caching](#delivery--caching)

---

## 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}]`.

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

```csharp
// 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](https://rask.sh/docs/guides/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:


---

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

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

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

```ts
// ScriptCallsDemo.ts
export class Countdown {
    constructor(private seconds: number) {}
    start(onTick: (left: number) => void, onDone: () => void): void { … }
    stop(): void { … }
}
```

```csharp
_countdown ??= await NewCountdown(5);
await _countdown.Start(left => _status = $"{left} left", () => _status = "Done!");
```


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](https://rask.sh/docs/guides/diagnostics/#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](https://rask.sh/docs/guides/diagnostics/#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](https://rask.sh/docs/guides/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](https://rask.sh/docs/guides/cli/#code-that-runs-in-the-browser).

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](https://rask.sh/docs/guides/diagnostics/#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](https://rask.sh/docs/guides/js-interop-runtime/#how-the-hooks-load) 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:





---

See also: [Composition](https://rask.sh/docs/guides/composition/) for component-to-component communication, and the
[architecture notes](https://rask.sh/docs/guides/live-rendering/) for how the live runtime ships these.
