Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to coordinate rendering with port interactions (Elm 0.17)

Tags:

elm

I would like to integrate Elm with a Javascript library in such a way that Elm would dynamically create "cells" (html divs), and Javascript would be provided with their id-s and use them to perform custom operations. The sequence I want to have is

  1. Elm creates a cell (and assigns id)
  2. Message with id is sent to port
  3. Javascript receives message and performs its action

This is how I implemented this at the beginning (full source):

port onCellAdded : CellID -> Cmd msg

update : Msg -> Model -> (Model, Cmd Msg)
update message ({cells} as model) =
  case message of

    Push ->
      let
        uid = List.length cells
      in
      ({ model
        | cells = [uid] ++ cells
      }, onCellAdded uid)

The problem was that Javascript on the other side

var container = document.getElementById('app');
var demoApp = Elm.RenderDemo.embed(container);

demoApp.ports.onCellAdded.subscribe(function(cellID) {
   if(document.getElementById('cell:' + cellID) === null) { window.alert("Cannot find cell " + cellID) }    
});

complained that such id cannot be found. Clearly the view hasn't been rendered yet.

So I added another state (OnCellAdded) to the Elm application, hoping that the flow would be like this:

  1. Elm creates a cell (on Push) and requests (Task.perform) an asynchronous task OnCellAdded
  2. Here the view gets rendered
  3. OnCellAdded gets called and message with id gets sent to port
  4. Javascript receives the message and performs its action

The implementation looked like this (diff) (full source):

update message ({cells} as model) =
  case message of

    Push ->
      let
        uid = List.length cells
      in
      ({ model
        | cells = [uid] ++ cells
      }, msgToCmd (OnCellAdded uid))

    OnCellAdded counter ->
      (model, onCellAdded counter)

msgToCmd : msg -> Cmd msg
msgToCmd msg =
      Task.perform identity identity (Task.succeed msg)

But still OnCellAdded gets processed right after Push without the model being rendered in-between.

My last attempt was using Update.andThen (diff) (full source)

Push ->
  let
    uid = List.length cells
  in
  ({ model
    | cells = [uid] ++ cells
  }, Cmd.none)
  |> Update.andThen update (OnCellAdded uid)

Still it doesn't work. I need some help here.

like image 582
Lukasz Guminski Avatar asked Aug 15 '16 09:08

Lukasz Guminski


3 Answers

An implementation using requestAnimationFrame()

This seems to currently be the cleanest solution in my experience.

var container = document.getElementById('app');
var demoApp = Elm.RenderDemo.embed(container);
var requestAnimationFrame = 
       window.requestAnimationFrame ||
       window.mozRequestAnimationFrame || 
       window.webkitRequestAnimationFrame || 
       window.msRequestAnimationFrame;   //Cross browser support

var myPerfectlyTimedFunc = function(cellID) {
   requestAnimationFrame(function() { 
       if(document.getElementById('cell:' + cellID) === null) { 
          window.alert("Cannot find cell " + cellID) 
       }
   })
}

demoApp.ports.onCellAdded.subscribe(myPerfectlyTimedFunc);

See here for an SPA type setup with multiple pages and the need to re-render a JS interop'd graph. Also has the ability to update the data value within the graph. (The console log messages may instructive as well.)

If one is curious how this might be implemented on the Elm side instead of html/js side, see Elm Defer Command library.

As you described, the problem is:

  1. Javascript is loaded and looks for an element not yet created.
  2. Elm renders the DOM after this search, and the element you need appears.
  3. Any Elm commands you send through a port will also occur at or before a render, so any javascript called by the port subscription will suffer from the same issue.

Elm uses requestAnimationFrame (rAF) itself as a way to queue DOM renders, and for good reason. Say Elm makes several DOM manipulations in less than 1/60th of a second, instead of rendering each manipulation individually - which would be quite inefficient - Elm will pass them to the browser's rAF, which will act as a buffer/queue for overall DOM rendering. In other words, view is called on the animation frame after update, so a view call won't always happen after each update.

In the past people would use :

setInterval(someAnimationFunc, 16.6) //16.6ms for 60fps

requestAnimationFrame came about as a way for the browser to keep a queue, which it manages and cycles through at 60fps. This offer a number of improvements:

  • The browser can optimize rendering, so animations will be smoother
  • Animations in inactive tabs will stop, allowing the CPU to chill
  • More battery-friendly

More info on rAF here, and here, and a video by Google here

My personal story started when I tried to render a Chartist.js graph into a div initially created within Elm. I also wanted to have multiple pages (SPA style) and the chart would need to re-render when the div element was recreated on various page changes.

I wrote the div as straight HTML in the index, but this prevented the SPA functionality I desired. I also used ports and subscriptions with JQuery ala $(window).load(tellElmToReRender), as well as giving Arrive.js a go - but each of these resulted in various bugs and lack of desired functionality. I messed with rAF a bit, but was using it in the wrong place and wrong way. It was after listening to ElmTown - Episode 4 - JS Interop, where I an epiphany hit and realized how it should really be used.

like image 118
cDitch Avatar answered Sep 27 '22 17:09

cDitch


I wanted to do something similar to this yesterday to integrate MorrisJS into an Elm generated div

Eventually I came across Arrive JS which uses the new MutationObserver available in most modern browsers to watch the DOM for changes.

So in my case the code looks something like this (simplified):

$(document).ready(() => {
  $(document).arrive('.morris-chart', function () {
    Morris.Bar({
      element: this,
      data: [
        { y: '2006', a: 100, b: 90 },
        { y: '2007', a: 75, b: 65 },
        { y: '2008', a: 50, b: 40 },
        { y: '2009', a: 75, b: 65 },
        { y: '2010', a: 50, b: 40 },
        { y: '2011', a: 75, b: 65 },
        { y: '2012', a: 100, b: 90 }
      ],
      xkey: 'y',
      ykeys: ['a', 'b'],
      labels: ['Series A', 'Series B']
    })
  })
})

This watches the dom for any new elements with the .morris-chart class, once found it creates the chart using that new element.

So this is called only after Elm runs the view function and then re-generates the DOM.

Maybe something like this would meet your needs.

like image 36
antfx Avatar answered Sep 27 '22 17:09

antfx


As of 0.17.1, there is no good way to achieve that.

The easiest I could recommend is using setTimeout to wait at least 60ms or wait until the next requestAnimationFrame

Consider this example:

demoApp.ports.onCellAdded.subscribe(function(cellID) {
   setTimeout(function() {
      if(document.getElementById('cell:' + cellID) === null) {
         window.alert("Cannot find cell " + cellID)
      }
   }, 60);
});

There is a feature request #19 to add a hook, so it is possible to know when the HTML Node is in the DOM.

You can the progress here, most likely it will be in the upcoming releases.

like image 31
halfzebra Avatar answered Sep 27 '22 15:09

halfzebra