Skip to content

[Feature Request] JS module adapter for Blazor #45120

@lonix1

Description

@lonix1

Is there an existing issue for this?

  • 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 he'd like something like that in v8, and he also published his code. 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:

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<Task<IJSObjectReference>> _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<IJSObjectReference>("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<T> InvokeAsync<T>(string jsFunctionName, params object[]? args)
  {
    if (string.IsNullOrWhiteSpace(jsFunctionName)) throw new ArgumentNullException(nameof(jsFunctionName));
    var module = await _jsModuleProvider.Value;
    return await module.InvokeAsync<T>(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:

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<int> 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<int>(nameof(Bar), arg1, arg2);
  }

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

This works nicely, and greatly simplifies subclasses.

Additional context

No.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Pillar: New BlazorPriority:1Work that is critical for the release, but we could probably ship withoutarea-blazorIncludes: Blazor, Razor ComponentsenhancementThis issue represents an ask for new feature or an enhancement to an existing onefeature-blazor-jsinteropThis issue is related to JSInterop in Blazor

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions