JS interop — IJSRuntime, typed APIs & refs
Calling JS from C#, the typed browser-API layer, element refs, and wrapping a third-party JS library.
The browser-side half is TypeScript — a .js sibling is RASK054.
‹ Back to JavaScript interop
Calling JS from C# (IJSRuntime)
Inject IJSRuntime through the constructor (not a property — a non-nullable settable
property would become a required chain step) and dispatch from a lifecycle hook or
event handler:
public sealed partial class CodeSample : Component
{
private readonly IJSRuntime _js;
public CodeSample(IJSRuntime js) => _js = js;
protected override async Task OnRenderedAsync(bool firstRender) =>
await _js.InvokeVoidAsync("Rask.CodeSample.rendered", firstRender);
}
Nothing (no el) is passed automatically — pass what the function needs. For a return
value use InvokeAsync<T>. On WASM a non-primitive T must be rooted for the trimmer
(DAM annotation or a JsonSerializerContext).
A sessionStorage round-trip through the unified IJSRuntime — set, read, and remove, each a plain
InvokeVoidAsync / InvokeAsync<string?> against a built-in browser API, identical on both transports:
using Microsoft.JSInterop;
namespace Rask.Site.Features;
/// <summary>
/// Round-trips <see cref="IJSRuntime" /> against <c>sessionStorage</c>. Works on both Server
/// (per-session WS-bound <c>RaskJSRuntime</c>) and WASM (in-process bridge via <c>JSImport</c>) —
/// the unified IJSRuntime surface keeps the component identical across hosts. IJSRuntime is
/// injected through the ctor (the framework's DI seam), mirroring <c>ElementRefDemo</c>.
/// </summary>
public sealed partial class JsRuntimeDemo(IJSRuntime js) : Component
{
private string _input = string.Empty;
private string? _lastRead;
private string? _status;
protected override async Task OnRenderedAsync(bool firstRender)
{
if (!firstRender)
{
return;
}
try
{
_lastRead = await js.InvokeAsync<string?>("sessionStorage.getItem", "rask.jsruntime.demo");
_status = _lastRead is null ? "(no value yet — try Set)" : $"Read on mount: {_lastRead}";
}
catch (Exception ex)
{
_status = "Read failed: " + ex.Message;
}
}
protected override Component? Render() =>
Div.Class($"{Tw.Card} shadow-sm border-0")[
Div.Class(Tw.CardBody)[
Div.Class("mb-3")[
Label.Class(Tw.Label).For("demo-input")["sessionStorage value"],
Input
.Value(_input)
.Id("demo-input")
.Class(Tw.Input)
.OnInput(v => _input = v)
],
Div.Class("flex gap-2 flex-wrap items-center mb-3")[
Button.Type("button").Class(Tw.BtnPrimary).Id("demo-set").OnClickAsync(SetAsync)[
UiIcon.Name(UiIconName.Save).Class("me-1"), "Set"],
Button.Type("button").Class(Tw.BtnOutlinePrimary)
.Id("demo-read")
.OnClickAsync(ReadAsync)[
UiIcon.Name(UiIconName.Retry).Class("me-1"), "Read"],
Button.Type("button").Class(Tw.BtnOutlineDanger)
.Id("demo-remove")
.OnClickAsync(RemoveAsync)[
UiIcon.Name(UiIconName.Trash).Class("me-1"), "Remove"]
],
Div.Class("mb-2")[
Span.Class("text-ui-muted text-sm uppercase")["Last read"],
Div[Code.Class("text-base").Id("demo-last-read")[_lastRead ?? "(null)"]]
],
Div[
Span.Class("text-ui-muted text-sm uppercase")["Status"],
Div[Code.Class("text-base").Id("demo-status")[_status ?? "(idle)"]]
]
]
];
private async Task SetAsync()
{
try
{
await js.InvokeVoidAsync("sessionStorage.setItem", "rask.jsruntime.demo", _input);
_status = $"Set to: {_input}";
}
catch (Exception ex)
{
_status = "Set failed: " + ex.Message;
}
}
private async Task ReadAsync()
{
try
{
_lastRead = await js.InvokeAsync<string?>("sessionStorage.getItem", "rask.jsruntime.demo");
_status = _lastRead is null ? "Read: (null)" : $"Read: {_lastRead}";
}
catch (Exception ex)
{
_status = "Read failed: " + ex.Message;
}
}
private async Task RemoveAsync()
{
try
{
await js.InvokeVoidAsync("sessionStorage.removeItem", "rask.jsruntime.demo");
_lastRead = null;
_status = "Removed";
}
catch (Exception ex)
{
_status = "Remove failed: " + ex.Message;
}
}
}
(null)(idle)Typed browser APIs
For the full map of every wrapper (shared vs WASM-only, one-shot vs subscription), see the Browser APIs overview. This section covers the shared set and the transport "why".
Rather than spelling out raw IJSRuntime identifiers ("localStorage.getItem",
"navigator.clipboard.writeText") and getting the JSON shape right by hand, inject one of the
built-in typed wrappers through a component constructor. Each is a thin, awaitable layer over
the same unified IJSRuntime, so it behaves identically on Server and WASM. These are the
Web APIs that work on both transports; WASM-only PWA APIs (service worker, cache, manifest) are a
later step on the same pattern.
| Service | Wraps | Key members |
|---|---|---|
IBrowserStorage |
localStorage / sessionStorage |
.Local / .Session → GetAsync, SetAsync, RemoveAsync, ClearAsync, KeyAsync, LengthAsync |
ICookies |
document.cookie |
GetAsync, SetAsync(name, value, CookieOptions?), DeleteAsync, GetAllAsync |
IClipboard |
navigator.clipboard |
WriteTextAsync, ReadTextAsync |
IGeolocation |
navigator.geolocation |
GetCurrentPositionAsync(GeolocationOptions?) → GeolocationPosition; WatchAsync(Func<GeolocationPosition,Task>, …) → IAsyncDisposable (live tracking) |
IPermissions |
navigator.permissions |
QueryAsync(PermissionName) → PermissionState |
IVibration |
navigator.vibrate |
VibrateAsync(params int[]), CancelAsync |
IPageVisibility |
document.visibilityState |
GetStateAsync() → PageVisibility, IsHiddenAsync |
INavigatorInfo |
window.navigator |
OnLineAsync, LanguageAsync, UserAgentAsync |
INetworkInfo |
navigator.connection |
IsSupportedAsync, GetStatusAsync() → NetworkStatus? (effective type, downlink, RTT, Data Saver) |
IMediaQuery |
window.matchMedia |
MatchesAsync(query), PrefersDarkAsync, PrefersReducedMotionAsync |
ISpeechSynthesis |
window.speechSynthesis |
IsSupportedAsync, SpeakAsync(text, SpeechOptions?), CancelAsync |
IScreenInfo |
window.screen |
GetAsync() → ScreenInfo (width/height, avail size, color depth, device pixel ratio) |
IStorageEstimator |
navigator.storage.estimate |
IsSupportedAsync, EstimateAsync() → StorageEstimate? (quota / usage bytes + UsageRatio) |
IVisualViewport |
window.visualViewport |
IsSupportedAsync, GetAsync() → VisualViewport? (visible size/offset/zoom after the soft keyboard) |
IBroadcastChannel |
BroadcastChannel |
OpenAsync(name, Func<string,Task>) → connection (PostAsync, IAsyncDisposable) — cross-tab messaging |
IIntersectionObserver |
IntersectionObserver |
ObserveAsync(ElementRef, Func<IntersectionEntry,Task>, IntersectionOptions?) → IAsyncDisposable — element enters/leaves the viewport |
IResizeObserver |
ResizeObserver |
ObserveAsync(ElementRef, Func<ResizeEntry,Task>) → IAsyncDisposable — element's size changes |
IMutationObserver |
MutationObserver |
ObserveAsync(ElementRef, Func<MutationEntry,Task>, MutationOptions?) → IAsyncDisposable — element's children/attributes/text change |
IMediaSession |
navigator.mediaSession |
SetMetadataAsync/SetPlaybackStateAsync + SetActionHandlerAsync(MediaSessionAction, Func<Task>) → IAsyncDisposable — now-playing metadata + media keys |
IDeviceOrientation |
deviceorientation |
RequestPermissionAsync() + WatchAsync(Func<OrientationReading,Task>) → IAsyncDisposable — gyroscope/compass tilt |
IDeviceMotion |
devicemotion |
RequestPermissionAsync() + WatchAsync(Func<MotionReading,Task>) → IAsyncDisposable — accelerometer / rotation |
ICrypto |
crypto / crypto.subtle |
RandomUuidAsync, RandomBytesAsync(length), DigestHexAsync(HashAlgorithm, text) |
IPerformance |
performance |
NowAsync() (high-res clock), GetNavigationTimingAsync() → NavigationTiming? (TTFB / DCL / load) |
IIndexedDb |
IndexedDB |
IsSupportedAsync, OpenStoreAsync(name) → IKeyValueStore (Set/Get/SetBytes/GetBytes/Delete/Keys/Clear) — large async persistent storage, text or raw bytes |
public sealed partial class ThemeToggle(IBrowserStorage storage, INavigatorInfo navigator) : Component
{
private async Task Save() => await storage.Local.SetAsync("theme", "dark");
protected override async Task OnRenderedAsync(bool firstRender)
{
if (firstRender)
{
var theme = await storage.Local.GetAsync("theme"); // string?, null if absent
var online = await navigator.OnLineAsync(); // bool
}
}
}
Call them from an event handler or lifecycle hook (not from Render()). Clipboard and
geolocation are browser-gated — they need a secure context (HTTPS or localhost) and the user's
permission; a denial or timeout surfaces as a JSException from the awaited task, so wrap those
calls in try/catch.
User-activation and the transport — why one API is WASM-only. Some browser APIs require transient activation: they must run inside the live user-gesture task. On WASM an event handler's interop call runs synchronously in that gesture's call stack, so it qualifies; on Server the click is forwarded over the WebSocket and the interop call runs a round-trip later, after the transient activation has expired. The practical effect:
- Sharing splits by when you fire it. The headless declarative
Shareable(Rask.Core) attachesdata-rask-shareto your element and the shared client firesnavigator.shareinside the click's own call stack, so the activation is still live — it therefore works on every host, Server included. The imperativeIShare(Rask.Wasm.Browser) lets you share from code (a lifecycle hook, after anawait), which needs the in-process transport to keep the activation — so it's registered only by the WASM host (on Servernavigator.sharewould reject with "Must be handling a user gesture"). IBadge(app icon badge),IWakeLock(keep the screen awake),IScreenOrientation(read/lock orientation),IFullscreen(present an element/page fullscreen — likeIShare,requestFullscreenneeds transient activation), andIInstallPrompt(capture/replay the deferredbeforeinstallpromptfor a custom install button) are likewise WASM-only inRask.Wasm.Browser— they depend on the installed-PWA instance or the live document the Server round-trip can't carry. See the Mobile & PWA guide.IClipboard.WriteTextAsyncneeds transient activation or a grantedclipboard-writepermission — the permission lets it work across the Server round-trip, so it stays shared.IVibrationneeds only sticky activation (the page was interacted with at some point), so it works on both transports (on devices with a vibration motor).- Everything else here (storage, cookies, geolocation, permissions, navigator info, network info, media queries, speech synthesis, screen info, storage estimate, visual viewport, broadcast channel, crypto, performance, indexeddb, page visibility) is unaffected by activation and behaves identically on both transports.
Most of these are one-shot request/response calls. IBroadcastChannel, IIntersectionObserver,
IResizeObserver, IMutationObserver, IDeviceOrientation, IDeviceMotion, IMediaSession's
action handlers, and IGeolocation.WatchAsync are the exceptions — they're subscriptions: you
open/observe/watch (returning an IAsyncDisposable) and the browser pushes each change back to a C#
handler (via a static [JSInvokable], so one wiring works on both transports — the observers additionally
hand the observed element across as an ElementRef). Open from a lifecycle hook and dispose on unmount; a
handler
that updates state calls StateHasChanged() — the same pattern as subscribing to a background feed (it's a
subscription, not a render/binding callback, so RASK026 doesn't apply).
This is the rule for the whole surface: shared APIs live in Rask.Core.Browser; APIs that can't
work on Server live in Rask.Wasm.Browser (the home for upcoming PWA-only APIs too).
Under the hood: storage/clipboard methods are plain function calls; navigator.onLine and
localStorage.length are property reads the client returns directly; and the callback-based
getCurrentPosition is wrapped in a Promise by the framework helper __raskApi.geolocation. That
helper (and __raskEl) lives in src/Rask.Core/Resources/rask-api.ts and is imported by both
client runtimes at build time, so the two transports never drift. GeolocationPosition is rooted
for the WASM trimmer by the framework, so it deserializes correctly in a PublishTrimmed app.
Runnable demos: the Browser APIs section of the showcase at
rask.sh/docs — one page per wrapper, from
site/Rask.Site/Features/Browser/.
Element refs
Every element exposes a Ref: parameter. Mint a ref with ElementRef.New() and store it
in a field (so its id is stable across renders), then hand it to JS — it serializes as
{"__raskRef__":"id"} and both clients revive it to the live DOM element before your
function runs:
public sealed partial class FocusDemo : Component
{
private readonly IJSRuntime _js;
private readonly ElementRef _input = ElementRef.New();
private readonly ElementRef _box = ElementRef.New();
public FocusDemo(IJSRuntime js) => _js = js;
protected override Component? Render() =>
Div[
Input<string>().Type(InputType.Text).Ref(_input),
Div.Ref(_box)["measure me"],
Button.OnClickAsync(Focus)["Focus"],
Button.OnClickAsync(Measure)["Measure"]
];
// Built-in helpers: ElementRefInterop.{FocusAsync, BlurAsync, ScrollIntoViewAsync}.
private async Task Focus() => await _input.FocusAsync(_js);
// Hand the ref to your own scoped JS — it resolves to the element before width() runs.
private async Task Measure() => await _js.InvokeAsync<double>("Rask.FocusDemo.width", _box);
}
Focus a built-in element, then hand a ref to a sibling .ts that measures it — the ref revives to the
live DOM node before the function runs:
using Microsoft.JSInterop;
namespace Rask.Site.Features;
// Demonstrates element refs end to end: a built-in (FocusAsync) and a hand-off to user scoped
// JS (ElementRefDemo.js receives the resolved DOM element to measure it). The refs are fields so
// their ids stay stable across renders.
public sealed partial class ElementRefDemo : Component
{
private readonly ElementRef _box = ElementRef.New();
private readonly ElementRef _input = ElementRef.New();
private readonly IJSRuntime _js;
private string _measured = "";
public ElementRefDemo(IJSRuntime js) => _js = js;
protected override Component? Render() =>
Div[
Input.Value<string>(null)
.Type(InputType.Text)
.Class($"{Tw.Input} mb-2")
.Placeholder("Focus me from C#")
.Ref(_input),
Div.Class("flex gap-2 flex-wrap items-center mb-3")[
Button.Type("button").Class(Tw.BtnPrimary).OnClickAsync(FocusInput)["Focus the input"],
Button.Type("button").Class(Tw.BtnOutlineSecondary).OnClickAsync(MeasureBox)["Measure the box"]
],
Div.Ref(_box).Class("border rounded p-3 bg-ui-well")[
"A box carrying an ElementRef — its width is read by passing the ref to JS."
],
_measured.Length > 0
? P.Class("text-sm text-ui-muted mt-2 mb-0")[_measured]
: null
];
// Built-in helper: passes the ref to __raskEl.focus, which receives the resolved element.
private async Task FocusInput() => await _input.FocusAsync(_js);
// User scoped JS: the ref resolves to the element before width() is called with it.
private async Task MeasureBox()
{
var width = await _js.InvokeAsync<double>("Rask.ElementRefDemo.width", _box);
_measured = $"Box width: {width:F0}px (measured in JS from the passed element)";
}
}
Wrapping a third-party JS library
Everything above is enough to wrap a library that owns its own DOM — a chart, a code editor, a map. Two
questions come up every time: what happens to the DOM the library builds, and what happens to the
<style> it injects. Rask answers the second for you; the first is one rule.
Describing the library to TypeScript
There is no node_modules in a Rask app, so a library's own typings are not there to install. Write a
.d.ts beside your component describing only what you actually call — any .d.ts in the project is
compiled alongside your scoped files, and a narrow declaration that is true is worth more than a
complete one copied from upstream that drifts, because the compiler believes either equally.
A hand-written .d.ts beside the scoped file is a worked example: about fifty lines
covering one constructor, two methods and three callbacks. Rask's own globals (window.DotNet,
window.Rask) are already declared for you and need no work.
Give the library a leaf to own
Render the host element with no children and let the library fill it. That's the whole rule, and it works because of how the diff addresses nodes: ops are computed from your C# render tree and applied by positional path, so a node your components never render is a node the diff can never reach.
private readonly ElementRef _host = ElementRef.New(); // a field — the id must be stable across renders
// A leaf: no children here, ever. The library owns everything inside it.
protected override Component? Render() => Div.Ref(_host).Class("chart");
// Mount in OnRendered, not OnMount — OnMount runs *before* the first render, so the element doesn't
// exist yet and the ref would resolve to null. firstRender guards against re-mounting.
protected override async Task OnRenderedAsync(bool firstRender)
{
if (!firstRender) return;
await _js.InvokeVoidAsync("Rask.Chart.mount", _host, DataAsJson());
}
// Fires only on a real prop change — push new data at the library instead of re-mounting it.
protected override async Task OnPropsChangedAsync() => await _js.InvokeVoidAsync("Rask.Chart.update", _host, DataAsJson());
// Sync and fire-and-forget — see the note below on why this must not be an awaited DisposeAsync.
protected override void OnUnmount() => _ = DestroyQuietlyAsync();
There is one exception to "the diff can't reach it", and it is not optional. Not every frame is a diff: the first interactive frame after page load always ships the body in full, and a structural change can too. The client applies a full frame by morphing the document, and a morph pairs each live child against the rendered one — your host has live children where the render says none, so the morph clears it. Skip that and the chart is deleted seconds after it draws. Tag the library's nodes; the reconciler leaves marked nodes alone:
// Right after the library builds its DOM. Mark what it created — never the host itself, which your
// component *does* render (marking that makes the morph treat it as missing and append a duplicate).
for (const child of host.children) child.setAttribute("data-rask-managed", "");
One more identity rule, because it bites stateful wrappers specifically: a component's identity is its
(type, position) among its parent's children. A sibling rendered as cond ? node : null shifts every
later child's position when it vanishes, so the wrapper gets matched against the wrong slot and rebuilt —
remounting the widget on an unrelated click. Prefer disabling to un-rendering a sibling above a
stateful component.
For events coming back the other way, a library callback can't reach an instance method — the JS shim
dispatches to static [JSInvokable]s by assembly and name. Hand JS a token at mount, keep a static
ConcurrentDictionary<string, YourComponent>, route on it, and unregister on unmount. Two things to get
right, because a [JSInvokable] is callable by any script on the page with any arguments:
- Make the token unguessable (
Guid.NewGuid().ToString("N")). That dictionary is static, so on the Server host it is shared by every live session — with a sequentialint, one visitor could drive another's widget by counting from 1. Holding the token is what proves ownership. - Unregister on unmount, or the entry pins the component for the life of the process.
Keep the boundary to primitives and JSON strings and a trimmed WASM publish stays clean. Prefer callbacks
that take one argument (bundle extras into a record): the generated chain step only wraps arity-≤1
delegates for auto-re-render, so a two-arg callback silently leaves the caller reaching for
StateHasChanged().
Finally, tear down from OnUnmount without awaiting the interop call. An IAsyncDisposable
component is awaited by the framework's dispose walk, and that walk also runs for a session whose socket
has already closed — where an interop call has nobody to answer it and never completes.
A scoped
{Component}.csscannot style the library's internals: scoping works by stampingdata-{scopeId}on the elements your component renders, and the library's nodes never get it. Size the host in scoped CSS; let the library's own stylesheet handle the rest.
What it injects into <head>
Rask treats <head> as authoritative: on every re-render the live-diff reconciler morphs the live
head back to what your components rendered, which keeps <title>/<meta>/scoped-CSS links correct. A
<style>/<link>/<script> that a JS library injects into <head> at runtime (a code editor's
theme colours, a charting library, a syntax highlighter, an analytics tag) isn't part of that render —
so Rask preserves it for you automatically. The reconciler watches <head> and tags anything a
library injects with data-rask-managed (the same marker it uses for its own scoped-asset tags), so it
survives every re-render with no code on your side. A code editor keeping its injected theme across
a re-render is the usual case.
The mechanism only preserves nodes injected after an initial render (the common case — libraries set up
once your component has mounted). If you need to keep something present at first paint, or want to be
explicit, mark it yourself — the reconciler never touches a head child carrying data-rask-managed:
// You rarely need this — runtime-injected head nodes are preserved automatically. Use it only to opt a
// node out explicitly (e.g. one present before the app's first render).
styleEl.setAttribute("data-rask-managed", "");