ml-in-barcelona / jsoo-react

js_of_ocaml bindings for ReactJS. Based on ReasonReact.
https://ml-in-barcelona.github.io/jsoo-react
MIT License
138 stars 19 forks source link

Bindings to external JS components #77

Closed jchavarri closed 2 years ago

jchavarri commented 2 years ago

One missing part is binding to JavaScript components. Right now there are some examples in core.mli (e.g. to Fragment) using gen_js_api, but they are written manually, and use some magic conversions due to gen_js_api inability to parse some types like < children: element Js_of_ocaml.Js.readonly_prop > Js_of_ocaml.Js.t.

It'd be nice to have some solution that decorates externals (or some other type of node) to bind to these JS components, but some questions quickly arise:

zbaylin commented 2 years ago

@jchavarri and I had a conversation about this on Discord and thought maybe a good start would be a syntax that looks something like this:

module MyReactComponent = struct
  external%component make: name: Js.js_string Js.t -> React.element =
    {|require("my-react-library").MyReactComponent|}
end

That should generate something that looks like this:

module MyReactComponent = struct
  type makeProps

  let makeProps ~(name: Js.js_string Js.t) () : makeProps =
    Js_of_ocaml.Js.Unsafe.obj [| ("name", name); |]

  let make : makeProps React.component = 
    Js_of_ocaml.Js.Unsafe.js_expr {|require("my-react-library").MyReactComponent|}
end
jchavarri commented 2 years ago

@zbaylin thanks for the write up! I love the idea of using the external string payload to pass the call to require

Just some additional context about the transformation. After introducing first-class support for OCaml syntax we moved the createElement call inside the component definitions, so we can create new elements from OCaml by just doing Foo.make (no need for ppx on the element creation side, which is more ergonomic and easier to maintain).

To preserve this behavior, we should do the same for externals.

Including additional things like key etc, the full transformation could look like:

module MyReactComponent = struct
  let make =
    let make_props :
           name:'name
        -> ?key:string
        -> unit
        -> < name: 'name Js_of_ocaml.Js.readonly_prop > Js_of_ocaml.Js.t =
     fun ~name ?key () ->
      let open Js_of_ocaml.Js.Unsafe in
      obj
        [| ( "key"
           , inject
               (Js_of_ocaml.Js.Optdef.option
                  (Option.map Js_of_ocaml.Js.string key) ) )
         ; ("name", inject name) |]
    in
    let make
        (props : < name: 'name Js_of_ocaml.Js.readonly_prop > Js_of_ocaml.Js.t)
        =
      let f :
             < name: 'name Js_of_ocaml.Js.readonly_prop > Js_of_ocaml.Js.t
          -> React.element =
        Js_of_ocaml.Js.Unsafe.js_expr
          {|require("my-react-library").MyReactComponent|}
      in
      f props
    in
    fun ~name ?key () -> React.createElement make (make_props ?key ~name ())
end

The good news is that the ppx already has functions to generate most of this code, the only thing that would need changing is the body of the inner make:

One possible path to implement this could be to start by parsing the external AST node + payload, and from there see which pieces of AST data become unavailable (compiler will complain). For example, the following function make_ml_comp does not make sense under the external context because we have no component implementation in this case: https://github.com/ml-in-barcelona/jsoo-react/blob/b20594789b7edf63ec73ec1aac2f3d7d5e7b76ee/ppx/ppx.ml#L795-L798

Another thing is that in terms of workflow, it's very useful to me to work with the ppx test setup that is available, I usually go to input_ocaml.ml, write some component or element code, run make test, and make sure the output is as I would expect (or works backwards until I fix it).

I hope all this makes sense, if there are questions please lmk! :)

zbaylin commented 2 years ago

Awesome @jchavarri -- that makes a ton of sense! I'll look into that last part 👍

zbaylin commented 2 years ago

@jchavarri the only thing I'm worried about regarding calling require directly is apparently webpack doesn't de-dupe by default: https://stackoverflow.com/questions/25509471/webpack-multiple-requires-resolving-to-same-file-but-being-imported-twice

Maybe a first step is to just use that de-duping optimizer, but I think we should try to think of a way to de-dupe in the bindings themselves rather than externally

jchavarri commented 2 years ago

the only thing I'm worried about regarding calling require directly is apparently webpack doesn't de-dupe by default

Hm I'm not sure. That would be quite unexpected, as regular apps call require("react") multiple times across different modules. Here's a related answer: https://stackoverflow.com/a/33314481/617787

zbaylin commented 2 years ago

the only thing I'm worried about regarding calling require directly is apparently webpack doesn't de-dupe by default

Hm I'm not sure. That would be quite unexpected, as regular apps call require("react") multiple times across different modules. Here's a related answer: https://stackoverflow.com/a/33314481/617787

Huh, I must have misunderstood the original answer. That makes our lives a lot easier 😅