JavaScript Interop in Blazor: IJSRuntime, Collocated JS Modules, DotNetObjectReference and What's New in .NET 10
In Blazor, JS is imported as a .razor.js module next to the component and called through an IJSObjectReference; calls from JS back to .NET use DotNetObjectReference and [JSInvokable], and since there is no JS during prerendering, calls start in OnAfterRenderAsync. In the Bookstore panel we build a Chart.js sales chart with .NET 10's InvokeConstructorAsync, GetValueAsync and SetValueAsync, and show an ISBN copy button, JSDisconnectedException and the 32 KB message limit with real output.
Version note: The code in this post was built and run with .NET 10 (SDK 10.0.404, ASP.NET Core 10.0.15) and Chart.js 4.6.1; the tests use bUnit 2.15.2, and browser behaviour was observed with Playwright 1.65's headless Chromium.
Predict First
In this post we add a sales chart to the admin panel of Bookstore, the online bookstore used as the shared example across this site's series, and a "copy the ISBN" button to the book page. The chart is drawn with Chart.js, a JavaScript library; the page is InteractiveServer with prerendering on. What happens if you try to read the Chart.js version from JS in OnInitializedAsync? Second question: is the copy button's JS file downloaded when the page opens, or on the first click? Third: what happens if JS in the browser sends a 40,000-character string to a .NET method on the server?
Where JS Goes: Collocated Modules and import
In Blazor you can put JavaScript next to the component as an ES module with the same name and a .razor.js extension. The copy button is two files:
// The Clipboard API needs a secure context (HTTPS or localhost) and the user's permission.
export async function copyText(text) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return false;
}
}
@implements IAsyncDisposable
@inject IJSRuntime JS
<button type="button" class="btn btn-sm btn-outline-secondary ms-2" disabled="@(!RendererInfo.IsInteractive)"
@onclick="CopyAsync">@label</button>
@code {
private IJSObjectReference? module;
private string label = "Copy";
[Parameter, EditorRequired]
public string Isbn { get; set; } = "";
private async Task CopyAsync()
{
// The module is loaded on the first click, so pages that never copy don't download it.
module ??= await JS.InvokeAsync<IJSObjectReference>("import", "./Components/Books/CopyIsbnButton.razor.js");
label = await module.InvokeAsync<bool>("copyText", Isbn) ? "Copied" : "Copy failed";
}
public async ValueTask DisposeAsync()
{
try
{
if (module is not null)
{
await module.DisposeAsync();
}
}
catch (JSDisconnectedException)
{
// The circuit is gone; the browser has already dropped the module.
}
}
}
JS.InvokeAsync<IJSObjectReference>("import", path) runs a dynamic import() in the browser and returns a reference to the module; the functions the module exports are called through that reference with InvokeAsync or InvokeVoidAsync. The return value travels as JSON; copyText's true/false became a bool in .NET. The benefit of a module over global window functions is that the code loads only where it's needed and there are no name clashes. I tried it in Playwright with two browsers, one with clipboard permission and one without:
clipboard permission granted:
module requests before the click: 0
button: Copied, module: /Components/Books/CopyIsbnButton.o2wp8bvs7c.razor.js
clipboard: 9780141439549
clipboard permission not granted:
module requests before the click: 0
button: Copy failed, module: /Components/Books/CopyIsbnButton.o2wp8bvs7c.razor.js
The answer to the second question: on the first click. There is no module request when the page opens; the module reference is taken with ??= on first use and kept afterwards. The o2wp8bvs7c in the file name is a fingerprint: the <ImportMap /> in App.razor maps the ./Components/Books/CopyIsbnButton.razor.js address we wrote in code to the file that carries the content hash, so the file can be cached for a long time. Without permission the Clipboard API throws an error; we catch it in JS and return false, and the user sees "Copy failed". The component's test runs without a browser, using bUnit's JS interop emulation:
private const string ModulePath = "./Components/Books/CopyIsbnButton.razor.js";
public JsInteropTests() => Renderer.SetRendererInfo(new RendererInfo("Server", isInteractive: true));
[Theory]
[InlineData(true, "Copied")]
[InlineData(false, "Copy failed")]
public void The_button_shows_what_the_clipboard_call_returned(bool copied, string expected)
{
// bUnit's JSInterop is strict: every call the component makes must be set up.
BunitJSModuleInterop module = JSInterop.SetupModule(ModulePath);
module.Setup<bool>("copyText", "9780141439549").SetResult(copied);
IRenderedComponent<CopyIsbnButton> button = Render<CopyIsbnButton>(p => p.Add(b => b.Isbn, "9780141439549"));
button.Find("button").Click();
Assert.Equal(expected, button.Find("button").TextContent.Trim());
Assert.Single(module.Invocations["copyText"]);
}
bUnit runs in strict mode by default; every JS call the component makes must be set up, and a call that isn't fails the test. That also catches the component making a JS call you didn't expect.
Prerendering and OnAfterRenderAsync
During prerendering the component produces plain HTML on the server; there is no browser, so there is no JavaScript to call. As an experiment I read the Chart.js version in OnInitializedAsync in the chart component:
System.InvalidOperationException: JavaScript interop calls cannot be issued at this time. This is because the component is being statically rendered. When prerendering is enabled, JavaScript interop calls can only be performed during the OnAfterRenderAsync lifecycle method.
at Microsoft.AspNetCore.Components.Server.Circuits.RemoteJSRuntime.BeginInvokeJS(JSInvocationInfo& invocationInfo)
The answer to the first question: the page returned 500. The error message also gives the fix: JS calls go in OnAfterRenderAsync, because that method is never called during prerendering, and in interactive rendering it runs after the elements are in the DOM. The firstRender check keeps the JS-side setup from being repeated on every render. The copy button doesn't have this problem because we only go to JS when the user clicks; at that point the component is already interactive.
From .NET to JS: IJSObjectReference and the .NET 10 APIs
The sales report page shows the number of copies sold per category as a bar chart with Chart.js. The data comes from EF Core through the IDbContextFactory from 09 (SalesReport, excluding cancelled orders). The chart component:
@implements IAsyncDisposable
@inject IJSRuntime JS
<p class="text-muted">Chart.js @chartVersion</p>
<canvas @ref="canvas" height="110"></canvas>
@code {
private ElementReference canvas;
private IJSObjectReference? module;
private IJSObjectReference? chart;
private DotNetObjectReference<SalesChart>? selfReference;
private string? chartVersion;
[Parameter, EditorRequired]
public IReadOnlyList<CategorySales> Sales { get; set; } = [];
[Parameter]
public EventCallback<CategorySales> OnCategorySelected { get; set; }
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender)
{
return;
}
// There is no JS during prerendering; the first interactive render is the earliest point.
module = await JS.InvokeAsync<IJSObjectReference>("import", "./Components/Reports/SalesChart.razor.js");
chartVersion = await JS.GetValueAsync<string>("Chart.version");
chart = await JS.InvokeConstructorAsync("Chart", canvas, new
{
type = "bar",
data = new
{
labels = Sales.Select(s => s.Category),
datasets = new[] { new { label = "Copies sold", data = Sales.Select(s => s.Copies) } }
}
});
selfReference = DotNetObjectReference.Create(this);
await module.InvokeVoidAsync("onBarClick", chart, selfReference);
StateHasChanged();
}
There are two APIs here that arrived in .NET 10. GetValueAsync<T> reads a JS property; we didn't write a separate JS function for Chart.version. InvokeConstructorAsync calls a JS constructor with new and returns a reference to the new object: new Chart(canvas, config) straight from C#. canvas is an ElementReference; it is filled in by @ref during rendering and reaches JS as the real DOM element. The config is an anonymous object; it is serialized to JSON and turns into the structure Chart.js expects. In .NET 9 each of these needed a small function in the module.
The third new API is SetValueAsync. When the user presses Refresh, instead of creating the chart again we change the existing object's data:
// New data from the parent: change the existing chart's data instead of creating a new chart.
protected override async Task OnParametersSetAsync()
{
if (chart is not null)
{
await chart.SetValueAsync("data.labels", Sales.Select(s => s.Category));
await chart.SetValueAsync("data.datasets.0.data", Sales.Select(s => s.Copies));
await chart.InvokeVoidAsync("update");
}
}
// Called from SalesChart.razor.js with the index of the clicked bar.
[JSInvokable]
public Task SelectCategory(int index) => OnCategorySelected.InvokeAsync(Sales[index]);
data.datasets.0.data is a path: chart.data.datasets[0].data. With Playwright I opened the page, added an order in between, pressed Refresh, then clicked a bar:
label: Chart.js 4.6.1
scripts loaded for this page: /Components/Layout/ReconnectModal.abdmv1u4y3.razor.js, /Components/Reports/SalesChart.9p3beka6z1.razor.js, /lib/chart.js/dist/chart.umd.min.xaf4rub35u.js
chart data: Classics=6, Dystopian=6, Fantasy=2, Science Fiction=5
after a new order and Refresh: Classics=6, Dystopian=6, Fantasy=6, Science Fiction=5 (same chart instance: 1 chart)
click on the Fantasy bar -> /books?category=fantasy, first book: The Hobbit
Chart.js and the module were downloaded only on this page (ReconnectModal is the layout's own script). With the new order Fantasy went from 2 to 6, and there is still a single chart instance on the page; it was updated without being re-created. The Chart.js UMD file is loaded by the import at the top of the module and defines window.Chart; InvokeConstructorAsync("Chart", ...) finds that global.
From JS to .NET: DotNetObjectReference and [JSInvokable]
Clicking a bar is an event in JS; to carry the result to .NET, we give JS a reference to the .NET object:
// The UMD build of Chart.js sets window.Chart when it is loaded.
import '../../lib/chart.js/dist/chart.umd.min.js';
// Passes the index of a clicked bar to .NET through the DotNetObjectReference.
export function onBarClick(chart, dotNetRef) {
chart.options.onClick = (event, elements) => {
if (elements.length > 0) {
dotNetRef.invokeMethodAsync('SelectCategory', elements[0].index);
}
};
chart.update();
}
DotNetObjectReference.Create(this) exposes the component to JS; dotNetRef.invokeMethodAsync('SelectCategory', index) in JS calls the method marked with [JSInvokable]. The method raises the OnCategorySelected EventCallback and the page goes to the book list with that category. We couldn't pass Chart.js's onClick function from C#; functions don't serialize to JSON. That is why a small module function takes the object and attaches the handler. Drawing the line here is a good rule: creating objects and reading or writing properties from C#, behaviour such as callbacks and DOM events in JS.
A DotNetObjectReference keeps the .NET object alive as long as the JS side holds it. If it isn't disposed, the garbage collector can't collect the component even after it has left the page; in InteractiveServer that is a memory leak that grows until the circuit closes. That is why selfReference?.Dispose() is in DisposeAsync.
Dispose and JSDisconnectedException
When the chart component goes away it destroys the Chart.js object and releases its JS references:
public async ValueTask DisposeAsync()
{
try
{
if (chart is not null)
{
await chart.InvokeVoidAsync("destroy");
await chart.DisposeAsync();
}
if (module is not null)
{
await module.DisposeAsync();
}
}
catch (JSDisconnectedException)
{
// The circuit is gone, and so is the page: there is nothing left to clean up in the browser.
}
selfReference?.Dispose();
}
The reason for the catch block is a situation specific to InteractiveServer: components are sometimes disposed after the circuit's connection is gone. When the user closes the tab, the circuit is thrown away at the end of the three minutes we saw in 06; when it is paused with Blazor.pauseCircuit(), right away. At that moment there is no connection to send a message to the browser. As an experiment I changed the catch to rethrow the exception and paused the circuit; the panel's log (trimmed):
warn: Microsoft.AspNetCore.Components.Server.Circuits.RemoteRenderer[100]
Unhandled exception rendering component: JavaScript interop calls cannot be issued at this time. This is because the circuit has disconnected and is being disposed.
Microsoft.JSInterop.JSDisconnectedException: JavaScript interop calls cannot be issued at this time. This is because the circuit has disconnected and is being disposed.
at Microsoft.AspNetCore.Components.Server.Circuits.RemoteJSRuntime.BeginInvokeJS(JSInvocationInfo& invocationInfo)
...
at Bookstore.Admin.Components.Reports.SalesChart.DisposeAsync()
fail: Microsoft.AspNetCore.Components.Server.Circuits.CircuitHost[111]
Unhandled exception in circuit '...'.
The user sees nothing, the page is already gone; but every closed tab writes a fail line to the log, and finding real errors in that noise gets harder. There is nothing left to clean up on the browser side either, since the page and the modules no longer exist. The pattern the documentation recommends is exactly this: catch and swallow JSDisconnectedException during disposal. On WebAssembly there is no circuit, so this exception doesn't happen.
Message Size on the Server and Other Rules
In InteractiveServer every call from JS to .NET is a message over SignalR, and the message size the server accepts is 32 KB by default (HubOptions.MaximumReceiveMessageSize). As an experiment I added a function to the module that sends .NET a string of a given length, and called it with 30,000 and then 40,000 characters (output trimmed, timestamp removed):
server log: ReceiveText: 30000 characters
browser console: Error: Connection disconnected with error 'Error: Server returned an error on close: Connection closed with an error.'.
The answer to the third question: the 30,000 characters arrived; when the 40,000-character message came in, the server closed the connection and the circuit dropped, and at the default log level the server didn't even show the reason. The user sees the reconnect screen. The limit protects the server from being flooded with large messages; you can raise it, but the documentation says that increases the risk of DoS attacks. Move large data (a file, a long text) from JS to .NET in chunks or as a stream (IJSStreamReference); the documentation says the framework doesn't impose such a limit on messages from .NET to JS.
The documentation has a few more rules. Don't change the DOM that Blazor renders with JS; Blazor keeps a copy of the DOM and on the next diff it either undoes your change or produces unexpected results. Chart.js is safe in this respect, because it only draws inside the <canvas> and Blazor doesn't track the canvas's pixels. Use addEventListener instead of inline event handlers (onclick="..."); inline handlers can be blocked by a CSP. Don't try to clean up the DOM with JS interop while a component is being disposed; for that the documentation recommends a MutationObserver on the JS side that watches for the element leaving the DOM.
In 05 we left scripts on Static SSR pages for this post. A Static SSR page has no circuit, so there are no calls through IJSRuntime either; and because enhanced navigation doesn't reload the page, page-specific <script> elements may not run on the second visit. The documentation gives two ways: running after every enhanced update with blazor.addEventListener("enhancedload", ...) in an afterWebStarted JS initializer, or turning the script into a module with onLoad, onUpdate and onDispose functions and attaching it to the page with a small PageScript component that calls them.
Synchronous Interop on WebAssembly and JSImport/JSExport
On WebAssembly, .NET and JS run on the same browser thread; that is why synchronous calls are possible there with IJSInProcessRuntime: Invoke, and in .NET 10 also InvokeConstructor, GetValue and SetValue. On Server every call is a network round trip, so there are no synchronous versions; if you write a component that runs in Auto or in both modes, staying with the async APIs is the safest choice.
WebAssembly also has .NET's own lower-level interop: [JSImport] and [JSExport] in the System.Runtime.InteropServices.JavaScript namespace. They skip IJSRuntime's JSON serialization and generate typed bindings with a source generator, which makes a difference in frequently called, performance-sensitive code; but they only work on WebAssembly, not on Server. For components that run in both modes, like the ones in our panel, IJSRuntime is the right choice.
In Production
On Server every JS call takes as long as the user's network latency, and the default timeout is one minute (CircuitOptions.JSInteropDefaultCallTimeout). Ten JS calls in a row in one render means ten round trips; in the chart example we gathered the settings into a single constructor call and made three calls for an update. In a frequently updated chart, combining these into one module function makes a measurable difference.
When you add a third-party JS library, host it yourself and pin its version; in this post Chart.js sits in wwwroot/lib/chart.js together with its license file, and MapStaticAssets serves it under a fingerprinted name. If you want to load it from a CDN, you need to allow that address in your CSP and use SRI.
The Clipboard API only works in a secure context (HTTPS or localhost) and with the user's permission. Telling the user when copying failed is better than them pressing the button and finding an old value on the clipboard.
Common Pitfalls
Calling JS in OnInitializedAsync or in the first run of OnParametersSetAsync. A 500 during prerendering; the place is OnAfterRenderAsync(firstRender).
Not disposing a DotNetObjectReference. The component itself, the services it references and its data stay in memory.
Disposing an IJSObjectReference without catching JSDisconnectedException. Every closed tab writes an error to the log.
Sending large data from JS to .NET. A single message over 32 KB drops the circuit; use the stream APIs for files and long texts.
Changing or deleting elements Blazor renders with JS. If you need an area that JS manages itself (a chart, a map, a rich text editor), give Blazor an empty element and leave its contents entirely to JS.
A Small Experiment
★☆☆ Put the copy button on the Static SSR book list from 05, next to every row, without a @rendermode. What happens when you click it? Compare with the @onclick experience from 01 and 05.
★★☆ Add a "Copies" / "Revenue" switch to the chart: write a method in SalesReport that calculates revenue per category and update the chart with SetValueAsync. Use a decimal sum for revenue; what happens with SQLite in the tests, and why?
★★★ Move the chart into an InteractiveWebAssembly component and use the synchronous InvokeConstructor through IJSInProcessRuntime. For the data you will need an interface in .Client and two implementations, as in 09. How does the same code behave on the first visit (Server) and on later visits (WebAssembly)?
The panel can talk to JS now, but its door is still open: the stock endpoints, the catalog endpoint and every page are open to everyone. In the next post we move on to authentication and authorization: AuthenticationStateProvider, AuthorizeView, auth state per render mode, protecting the API separately, and the passkey support in the .NET 10 template.