fsbolero / Bolero

Bolero brings Blazor to F# developers with an easy to use Model-View-Update architecture, HTML combinators, hot reloaded templates, type-safe endpoints, advanced routing and remoting capabilities, and more.
https://fsbolero.io
Apache License 2.0
1.06k stars 53 forks source link

JS/React interop? #32

Open cmeeren opened 5 years ago

cmeeren commented 5 years ago

I have previously experimented a bit with Fable and React and really like the experience, but compiling to JS is a significant drawback for a .NET dev like me since 1) it's a different runtime environment than I'm used to and I have to constantly think about that while coding, and 2) I can't use any nugets except those few that have been specifically designed to work with Fable (i.e., includes sources in the .nupkg).

I came across Bolero in a recent F# Weekly, and was thrilled, to say the least. I know next to nothing about Blazor or WebAssembly, but AFAIK this project makes it possible to run .NET code directly in the browser, using nugets and a runtime environment that is familiar to .NET devs (please correct me if I'm wrong).

However, having delved into Fable/React/Material-UI a lot recently, I have two questions:

  1. JS interop. The Writing HTML part of the docs shows plain old HTML elements. In order to create the rich/complex UIs often needed today, one might need packages/frameworks like Bootstrap, Bulma, React, or Material-UI (which requires React). The JS ecosystem is incredibly rich. Is there a story here for Bolero? Is it possible? Simple? Are there similar/better alternatives?

  2. Elmish performance. Fable/React/Elmish uses React for rendering, which is lightning quick. Can I expect the Bolero/Elmish combo to scale similarly (using ElmishComponent and ShouldRender similarly to lazy in Elmish.React)?

weebs commented 5 years ago

For 1, check out https://docs.microsoft.com/en-us/aspnet/core/razor-components/javascript-interop?view=aspnetcore-3.0

Calling vanilla JS libraries is fairly straightforward. You include the javascript in your project, add it to the index.html, and you'll be able to call functions from .NET by obtaining an IJSRuntime.

It's been a bit since I've done work with JS in Boleo/Blazor land, but my only gripes were:

1) I had to everything asynchronously because in server-side ASP.NET will often kill your thread on synchronous calls, and client-side anything blocking the UI will crash Mono-WASM (although things may have changed). Fortunately the Elmish model works quite well with async

2) JavaScript that required callbacks can require some boilerplate and planning if you want to generate an Elmish message from them. Really just a class instance to hold a dispatch function and message type that the JavaScript can invoke, and a JavaScript function for .NET to call

3) You need to make sure you get the return type parameter on InvokeAsync<T> correct or else the app will break

The interop is quite flexible though, and you can do clever things with eval(). There also is data marshalling for plain .NET objects, which is certainly nice.

In my experience interop with JS wasn't painful once I figured out how to approach things, but it does take time. I imagine on a bigger project I'd find myself rewriting JS components rather than importing them if they were a hassle and not terribly complex

On that note though, I would really love to see Bolero create some sort of Plugin / Component import system so we can easily share work we've done to create wrappers on the JS ecosystem. Something as simple as an interface to implement where you can import JavaScript / CSS into the page before startup would go a long way.

cmeeren commented 5 years ago

Sounds like it's impossible to create and use bindings for React then, since AFAIK the Elmish view function would use synchronous React calls. Is that correct?

weebs commented 5 years ago

You can make calls into synchronous JavaScript, but you need to call those from an asynchronous context, such as a task / async computation expression.

I'm not sure if it'd be realistic to host React within a Blazor/Bolero application though since they're already fully fledged SPA frameworks. I haven't used React but I imagine you'd have to isolate it inside a Blazor/Elmish component for each React component you'd want to use, and the work / overhead might not be worth it

cmeeren commented 5 years ago

Thanks for the clarification. As I understand it, if using Bolero/Blazor, I'd have to forego UI frameworks like React. But what about Bootstrap, Bulma, MDC-Web, or Materialize? I suppose they would be fairly simple to use, being mostly CSS where AFAIK the JS for the most part (I may be wrong) enables interactivity and doesn't necessarily have to be called?

weebs commented 5 years ago

I suppose they would be fairly simple to use

I believe so, at least for non-JS frameworks. I'm not sure how things would work with some of Materialize's JS based components like dropdowns / modals. Looking at their init sample:

  document.addEventListener('DOMContentLoaded', function() {
    var elems = document.querySelectorAll('.modal');
    var instances = M.Modal.init(elems, options);
  });

I imagine this means any DOM modal needs to be initialized, which means if Blazor removes a modal element from the DOM it would need to be re-initialized (although this is probably true of any JS framework right?). Or does this event listener fire whenever new DOM content is added to the page?

granicz commented 5 years ago

On that note though, I would really love to see Bolero create some sort of Plugin / Component import system so we can easily share work we've done to create wrappers on the JS ecosystem. Something as simple as an interface to implement where you can import JavaScript / CSS into the page before startup would go a long way.

Agree this would be very useful (at least until various JS libs are reimplemented in .NET). We discussed full-blown WebSharper-style extensions that plug into the Blazor component system, but we might as well start with something easier.

weebs commented 5 years ago

@cmeeren I came across a Blazor library for Bootstrap / Material you might find interesting:

https://github.com/stsrki/Blazorise

https://blazorise.com/docs/

As far as I can see these are standard Blazor components so you should be able to use them within Bolero

Ex:

comp<TextEdit> [ "Placeholder" => "Some text value..." ] []
BentTranberg commented 5 years ago

I'm struggling with JS interop right now, which is why I googled my way to this issue. I am trying to use Bulma Extensions, which unlike Bulma itself contains some features with Javascript files and need for scripting.

I consider using the very goodlooking Bulma Extensions DatePicker instead of the Blazor component I'm currently using, if only I can figure out how to do the JS stuff needed. To keep things simple, I started experimenting with Bulma Extensions TagsInput, and actually got it to display as expected with a "manual" workaround - running JS with a button clickevent on the page. So I push on. I am thinking the next step would be to somehow be able to run a Javascript snippet from the update function of the Elmish model. Perhaps what @weebs expains about an IJSRuntime is the proper way?

If anybody is interested, this is where I'm now : https://github.com/BentTranberg/ExploreBolero/issues/2

PS: If there's no easy proper solution to my problem at this time, I'll just leave this issue for the future. I'm doing just fine with what I have.

wilsoncg commented 4 years ago

I have just gone over the JS interop scenario over the last week or so, specifically I wanted to interop by calling an .NET instance method from JS. After some head scratching, reading the blazor docs and following the approach used in the TryFSharpOnWasm application I have come up with the code below.

@BentTranberg I'd be interested to know if the approach below would work for your bulma date picker scenario?

@Tarmil The bolero docs are fantastic, but they could benefit from showing the interop from JS back to .NET. Would you accept a PR showing something like the code below? I tried searching across github for F# samples which used JSInvokable and found only 3 repositories. If it wasn't for your TryFSharpOnWasm application I would have really struggled to see all the relevant pieces required.

Hooking into window resize event requires calling into .NET from JS

First we define some Javascript, notice the callback will be provided to the JS environment. We see that the .NET framework will create the machinery for us.

window.generalFunctions = {
    env: {
      hamburgerVisible: false
    },
    getSize: function(){
      var size = { "height": window.innerHeight, "width" : window.innerWidth };
      return size;
    },
    initResizeCallback: function(onResize) {
      window.addEventListener('resize', (ev) => {         
        this.resizeCallbackJS(onResize);
      });
    },
    resizeCallbackJS: function(callback) {
      var size = this.getSize();
      if(size.width < 450 && !this.env.hamburgerVisible)
      {
        this.env.hamburgerVisible = true;
        callback.invokeMethodAsync('Invoke', size.height, size.width);
      }
      if(size.width > 450 && this.env.hamburgerVisible)
      {
        this.env.hamburgerVisible = false;
        callback.invokeMethodAsync('Invoke', size.height, size.width);
      }
    }
  };

This should be loaded after the blazor WASM framework initialization.

<script src="_framework/blazor.webassembly.js"></script>
<script src="/js/windowResize.js"></script>

We then use DotNetObjectReference.Create() to a DotNet JS interop object which is passed into the Javascript defined above. We can define a helper Callback type which will be decorated with JSInvokable, this allows the blazor framework to correctly identify & call the instance method. We create a subscription message during initialization, where the Javascript is instructed to call the Invoke() method on the .NET object. With this mechanism we have achieved JS interop, where a WindowResize message will be dispatched within Bolero on each window.resize DOM event.

type Size(h:int, w:int) =
    member this.Height with get() = h
    member this.Width with get() = w
    new() = Size(0,0)

type Callback =
    static member OfSize(f) =
        DotNetObjectReference.Create(SizeCallback(f))

and SizeCallback(f: Size -> unit) =
    [<JSInvokable>]
    member this.Invoke(arg1, arg2) =
        f (Size(arg1, arg2))

type Message =
    | Initialize
    | WindowResize of Size

let update (jsRuntime:IJSRuntime) message model =
    let setupJSCallback = 
        Cmd.ofSub (fun dispatch -> 
            // given a size, dispatch a message
            let onResize = dispatch << WindowResize
            jsRuntime.InvokeVoidAsync("generalFunctions.initResizeCallback", Callback.OfSize onResize).AsTask() |> ignore
        )

    match message with
    | Initialize -> model, setupJSCallback
    | WindowResize size ->
        // handle window resize message
        model, Cmd.none
wilsoncg commented 4 years ago

@BentTranberg Bulma calendar sample https://github.com/wilsoncg/BoleroBulmaCalendar

MichaelMay81 commented 3 years ago

@wilsoncg: Your code helped me a lot to understand this. Thx! I too would wish this was part of the Bolero documentation.

I generalized the JSInvokables, so I don't have to declare a new wrapper class for each:

type FunWrapper<'a> (f: 'a -> unit) =
    [<JSInvokable>]
    member this.Invoke(arg) =
        f(arg)

type FunWrapper2<'a, 'b> (f: 'a -> 'b -> unit) =
    [<JSInvokable>]
    member this.Invoke(arg1, arg2) =
        f(arg1, arg2)

let funWrapper (f) =
    DotNetObjectReference.Create(FunWrapper(f))

let funWrapper2 (f) =
    DotNetObjectReference.Create(FunWrapper2(f))
wilsoncg commented 3 years ago

@MichaelMay81, @Tarmil I created a pull request some time ago https://github.com/fsbolero/website/pull/18

MichaelMay81 commented 3 years ago

I think that the example is a bit to complicated for the documentation. Why not dumb it down to something like this and a link to TryFSharpOnWasm:

initResizeCallback: function(onResize) {
   window.addEventListener('resize', (ev) => {
      onResize.invokeMethodAsync('Invoke', window.innerHeight, window.innerWidth);
   });
}
type SizeCallback(f: int -> int -> unit) =
    [<JSInvokable>]
    member this.Invoke(arg1, arg2) =
        f (Size(arg1, arg2))

let ofSize f = DotNetObjectReference.Create(SizeCallback(f))

let initResizeCallback dispatch jsRuntime = 
   let onResize h w = dispatch (WindowResize (h,w))
   jsRuntime.InvokeVoidAsync("initResizeCallback", ofSize onResize).AsTask() |> ignore