dotnet / dotnet/aspnetcore

[Feature Request] JS module adapter for Blazor

Open
#45,120 5 comments 18 reactions 0 assignees View on GitHub
area-blazor enhancement feature-blazor-jsinterop Pillar: New Blazor Priority:1
Dominant language
C#
Stars
38.4k
Forks
10.9k
Avg merge
2d 6h
Merged PRs (30d)
290

Description

### Is there an existing issue for this?

- [X] I have searched the existing issues

### Is your feature request related to a problem? Please describe the problem.

It would be helpful to have an easy way to import JS modules - for any given `fooModule.js` to have an adapter `FooModule.cs`.

I wrote some code for that, and then I heard @SteveSandersonMS [say](https://youtube.com/clip/UgkxNVp2yunt8GAZ2ZmdDduJ_DNWRS7jOJTj) he'd like something like that in v8, and he also published [his code](https://github.com/SteveSandersonMS/AudioBrowser/blob/dd7a03d93b4de5e97efb333f1120792ee48c70ba/MediaFilesAPI/Util/JSModule.cs). Mine is very similar, below.

*I'm opening this tracking issue so the idea doesn't get lost.* It would be a nice addition.

### Describe the solution you'd like

I've linked Steve's code above. Mine's below.

`JsModule.cs`:
````cs
public abstract class JsModule : IAsyncDisposable
{

private bool _isDisposed = false;

// From `dotnet new razorclasslib` template. It's nice because it
// avoids the loading hit when the user does not create a need for
// the JS module.
private readonly Lazy> _jsModuleProvider;

protected JsModule(IJSRuntime jsRuntime, string? modulePath = null)
{
if (jsRuntime == null) throw new ArgumentNullException(nameof(jsRuntime), "Argument was null.");
if (modulePath != null && string.IsNullOrWhiteSpace(modulePath)) throw new ArgumentException("Argument was empty or whitespace.", nameof(modulePath));

var path = modulePath ?? GetPathOfJsModule();
_jsModuleProvider = new(
() => jsRuntime.InvokeAsync("import", path)
.AsTask()
);
}

private string GetPathOfJsModule()
{
var nameAssembly = this.GetType().Assembly.GetName().Name; // e.g. "MyCompany.MyProject"
var nameType = this.GetType().Name; // e.g. "MyJsModule"
var nameScript = Camelise(nameType) + ".js"; // => "myJsModule.js"
var path = $"./_content/{nameAssembly}/{nameScript}"; // => "./_content/MyCompany.MyProject/myJsModule.js"
Debug.Assert(nameAssembly != null);
return path;
}

private string Camelise(string input) => input[0].ToString().ToLowerInvariant() + input[1..];

protected async ValueTask InvokeVoidAsync(string jsFunctionName, params object[]? args)
{
if (string.IsNullOrWhiteSpace(jsFunctionName)) throw new ArgumentNullException(nameof(jsFunctionName));
var module = await _jsModuleProvider.Value;
await module.InvokeVoidAsync(Camelise(jsFunctionName), args);
}

protected async ValueTask InvokeAsync(string jsFunctionName, params object[]? args)
{
if (string.IsNullOrWhiteSpace(jsFunctionName)) throw new ArgumentNullException(nameof(jsFunctionName));
var module = await _jsModuleProvider.Value;
return await module.InvokeAsync(Camelise(jsFunctionName), args);
}

public async ValueTask DisposeAsync()
{
await DisposeAsyncCore().ConfigureAwait(false);
GC.SuppressFinalize(this);
}

protected virtual async ValueTask DisposeAsyncCore() {
if (_isDisposed) return;
if (_jsModuleProvider.IsValueCreated)
{
var module = await _jsModuleProvider.Value;
await module.DisposeAsync().ConfigureAwait(false);
}
_isDisposed = true;
}

}
````
- I added the async dispose pattern
- To make it easy for users, I inferred the module file's path, to match that of the c# class; many people do it that way, e.g. `MyProject/Foo/Bar/SomeModule.cs` and its corresponding `MyProject/wwwroot/Foo/Bar/someModule.js`
- But user can supply a custom path (into ctor) to override that assumption
- The js module is assumed to be camel cased, as is the convention for JS
- The JS object reference is lazy, as is done in the `dotnet new razorclasslib` template; this avoids an unnecessary load when the user doesn't use that module
- I wanted to use `[CallerMemberName] string jsFunctionName = ""` to further simplify callsites, but couldn't find a way to do that because of the `params` array

`MyJsModule.cs`:
````cs
public sealed class MyJsModule : JsModule
{

public MyJsModule(IJSRuntime jsRuntime) : base(jsRuntime /*, custom js file path here if needed*/) { }

public async ValueTask Foo(string arg)
{
if (string.IsNullOrWhiteSpace(arg)) throw new ArgumentNullException(nameof(arg));
await InvokeVoidAsync(nameof(Foo), arg);
}

public async ValueTask Bar(string arg1, string arg2) {
if (string.IsNullOrWhiteSpace(arg1)) throw new ArgumentNullException(nameof(arg1));
if (string.IsNullOrWhiteSpace(arg2)) throw new ArgumentNullException(nameof(arg2));
await InvokeVoidAsync(nameof(Bar), arg1, arg2);
}

}
````
- I use `nameof(MethodName)` to ease refactoring/renaming

This works nicely, and greatly simplifies subclasses.

### Additional context

No.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.