stefan-hoeck / idris2-dom-mvc

Single Page Web Applications in Idris
BSD 3-Clause "New" or "Revised" License
17 stars 2 forks source link

Smoothen the learning curve #24

Open isberg opened 1 year ago

isberg commented 1 year ago

Thanks a lot for bringing the combination of Idris2 and the Elm Architecture to the browser. I am currently trying to map my current Elm knowledge onto Idris and this library with the help of your examples as well as Elm examples. Realizing that others might be helped by some of the examples and utility functions I might create in the process I wonder if you have some thoughts on how I should package them. I can see that there are a few differences from how it works in Elm, one being that the view function returns commands (both for updating html and other things) and another being that the update functions does not have the ability to generate commands. There is also the difference in that the view is based on both the event and the state in this library. I would be most interested to know a bit more about your thinking around that. After having pondered Event Sourcing as well as Mealy and Moore machines a bit I realize that these differences are perhaps intentional. :-)

stefan-hoeck commented 1 year ago

Thank you very much for your interest in this library. Before I try to answer your questions, please know that I am by no means an Elm expert. I never wrote a single line of Elm code but have read some introductory material and tried to adapt these concepts to Idris.

At the core, an interactive user interface reacts on events (or messages as they are called in Elm) of type Ev, which leads to an update of the application state St and some side effects (output to the UI, messages being sent to other servers and so on). Updating the state might include accessing some additional resource such as a random number generator or similar. This boils down to the following function type describing the effectful handling of a single event:

controller : Ev -> St -> IO St

In JavaScript land, many functions can fail, so we might want to add some error handling:

controller : Ev -> St -> JSIO St

Finally, if the UI is not static, that is, some interactive components will be added at a later stage only after certain events fired, we need a way to inform the runtime, that those new components have fired an event: We need access to the event handler in order to register it at the new elements of the UI:

controller : Handler Ev => Ev -> St -> JSIO St

If you look at the type of Web.MVC.runController, it takes a function of the type above plus some initial event and state to set the whole UI up.

Now, in Elm, what we described as our controller above is broken up into two pure functions:

update : Ev -> St -> St

view : St -> Node Ev

So, we have a pure function for updating the state based on the current event, and we have a function for discplaying the current state as a node in the DOM (a node firing events of type Ev). Elm's internal machinery will convert this into something similar to what our controller type describes: Displaying the new view will be effectful and it will include adding event listeners to react to new events from the UI.

Now, for large applications with many nodes and components, recreating and redrawing the whole view on every event occurence can be too slow, especially when we want to quickly react on events such as times from animations or mouse movement. In order to speed things up, Elm uses a virtual DOM for storing the current view and uses DOM diffing to only update those parts in the real DOM that actually changed. This speeds things up considerably (at least, that's what they write in their tutorials).

We can reproduce Elm's behavior (modulo the DOM diffing) with runController:

export covering
elm :
     {0 e,s : Type}
  -> (ref     : Ref t)
  -> (update  : e -> s -> s)
  -> (display : s -> Node e)
  -> (initEv  : e)
  -> (initSt  : s)
  -> JSIO ()
elm ref upd disp = runMVC upd (\_ => pure . child ref . disp)

In the code above, ref is the Id of a component in the DOM, for instance, the <body> element.

Personally, I'd like to have some finer-grained control over the updates that happen in the DOM. Therefore, instead of having a function of type St -> Node Ev for the view, we use a function of type Ev -> St -> List (Cmd Ev), where a Cmd Ev describes an arbitrary JSIO () action in the presence of an event handler. This allows us to update a few small components or even just change a single attribute of one element in a huge DOM, without having to recompute everything from scratch.

There is an example application in the docs, where we check the performance of creating a huge number (tens of thousands) of buttons. When clicking one of the buttons, a counter should be increased and the button disabled. Creating the buttons takes some time if course, but clicking a single button is more or less instantaneous, exactly because we only change a single text node and one attribute in the DOM. I expect this to be slower in Elm, although I have not verified it and they might have found a clever way to do these kinds of updates quickly.

One could of course argue that the example described above is not very realistic. It is, however, realistic to have very large DOMS where a single event may only affect a very small part of the UI. In such use cases, having perfect control over what gets updated when might pay off in terms of performance.

stefan-hoeck commented 1 year ago

Addendum: Please note also that this library is very fresh and still bound to evolve. Any suggestion how to make things easier/more convenient are highly welcome.

isberg commented 1 year ago

Thanks for clarifying Stefan. I suppose my next step is understanding and using a Controller directly, as well as figuring out exactly what it means for a type to implement Handler. Which probably means going back and fiddle with the existing example. 😀

stefan-hoeck commented 1 year ago

Thanks for clarifying Stefan. I suppose my next step is understanding and using a Controller directly, as well as figuring out exactly what it means for a type to implement Handler. Which probably means going back and fiddle with the existing example. grinning

And my next step will be to read more about elm, learn abound commands and subscriptions and try to figure out how they are implemented. :-) The difference to Idris is of course, that Elm has a runtime system designed to implement these things, while here im implement the "runtime system" in Idris itself.

stefan-hoeck commented 1 year ago

I had a closer look at Elm's commands and subscriptions and how they are implemented. It makes a lot more sense now, so I merged a rather big refactoring.

Everything now boils down to the following function type for describing the whole UI:

controller : e -> s -> (s, Cmd e)

This is just like in Elm, but there is not additional View involved, because a Cmd e includes updates to the DOM. In the tutorial, the above is broken up into two separate functions, although that's not necessary:

update : e -> s -> s

display : e -> s -> Cmd e

And that's it. I tried to simplify the tutorial some more by getting rid of lenses an the monocle library as well as heterogeneous sums. Feel free to ask if anything is still unclear.

Note: There is no need for Elm's subscribtions: Even in Elm they are treated the same as commands internally, with the exception that a subscription is run once when the UI is set up. Since here we are starting everything with an initialization event, the destinction between command and subscription is not necessary.

isberg commented 1 year ago

I will update my version and take it for a spin. :-)

isberg commented 1 year ago

Some initial thoughts and feedback on the changes in #25:

I will play around some more and compare how I would write code in both Elm, Idris2 and possibly Haskell. :-)

stefan-hoeck commented 1 year ago

Any feedback and insights from your experiments will be highly welcome.

Im not sure about having to specify an initial event myself. This is a relict from the rhone-js library, where I found initial events to be necessary. But lets see how things evolve. Personally, I'd rather not give up the fine-grained control over DOM updates we currently have, but maybe it will turn out to be indeed easier to do it the same way as Elm does and use a virtual DOM.

On the other hand, this library can always be used a starting point for another package, that will be even more similar to Elm.

isberg commented 1 year ago

I think it would be nice to have one mode that mimics Elm and one using the fine-grained control over DOM updates (at least until not necessary). One benefits of using a view : state -> Node event is that it is totally fine to just skip calling it in times of stress. I am a couple of minutes away from having an alternative version of runController, without an initEv... :-)

isberg commented 1 year ago
export covering
runController' :
     {0 e,s  : Type}
  -> (update   : e -> s -> (s, Cmd e))
  -> (view     : s -> Cmd e)
  -> (onErr    : JSErr -> IO ())
  -> (initCmd  : Cmd e)
  -> (initST   : s)
  -> IO ()
runController' update view onErr initCmd initST = Prelude.do
  state <- newIORef initST
  flag  <- newIORef False
  queue <- newIORef $ Queue.empty {a = e}

  let covering handle : e -> IO ()
      handle ev = Prelude.do

        -- Enqueue synchronously fired events if we are already handling
        -- an event
        False <- readIORef flag | True => modifyIORef queue (enqueue ev)

        -- Start handing the event and prevent others from currently
        -- being handled
        writeIORef flag True

        -- read current application state
        stOld <- readIORef state

        -- compute new application state and the command to run
        let (stNew, cmd) := update ev stOld

        -- update application state
        writeIORef state stNew

        -- schedule gui update 
        let cmd = Cmd.batch [cmd, view stNew]

        -- run the command by invoking it with this very event handler
        -- the command might fire one or more events synchronously. these
        -- will be enqueued and processed in a moment.
        ei <- runEitherT (run cmd (liftIO . handle))

        case ei of
          Left err => onErr err
          Right () => pure ()

        -- we are do with handling the current event so we set the flag
        -- back to false.
        writeIORef flag False

        -- we are now going to process the next enqueued command (if any)
        Just (ev2,q) <- dequeue <$> readIORef queue | Nothing => pure ()
        writeIORef queue q
        handle ev2

      covering initialize : Cmd e -> IO ()
      initialize cmd = Prelude.do

        -- Prevent events from currently being handled
        writeIORef flag True

        -- read current application state
        st <- readIORef state

        -- schedule gui update 
        let cmd = Cmd.batch [view st, cmd]

        -- run the command by invoking it with this very event handler
        -- the command might fire one or more events synchronously. these
        -- will be enqueued and processed in a moment.
        ei <- runEitherT (run cmd (liftIO . handle))

        case ei of
          Left err => onErr err
          Right () => pure ()

        -- we are do with handling the current event so we set the flag
        -- back to false.
        writeIORef flag False

        -- we are now going to process the next enqueued command (if any)
        Just (ev2,q) <- dequeue <$> readIORef queue | Nothing => pure ()
        writeIORef queue q
        handle ev2

  initialize initCmd
isberg commented 1 year ago

I think that me being able to create the alternative runController above outside of your library proves that it is indeed possible (and pretty straightforward) to build on top of your library. :-D

JGood9001 commented 1 year ago

I'm still not quite up to speed on Idris to provide meaningful input on the current design of the library, but out of curiosity, have you ever used PureScript's Halogen before?

If I'm not mistaken, the ideas in this paper are what's used in that library, and there's a video covering the concepts as well.

I don't know enough Idris and Category Theory yet to be able to determine whether your approach or the comonadic approach is better/more flexible for whatever use cases you're optimizing for, but I just figured I'd chime in with this, as I just found this repo the other day and noticed this recent thread in the issues section.

isberg commented 12 months ago

I also found two more papers in a comment to the video mentioned above:

isberg commented 12 months ago

The bouncing balls example proves that subscriptions are not necessary, however I am afraid I utterly fail to grasp why it works and where the timer or delay that I believe must exists is configured. Stefan would you mind clarifying? I want to understand how I can mimic Elm subscriptions using Cmds. :-)

stefan-hoeck commented 12 months ago

The bouncing balls example proves that subscriptions are not necessary, however I am afraid I utterly fail to grasp why it works and where the timer or delay that I believe must exists is configured. Stefan would you mind clarifying? I want to understand how I can mimic Elm subscriptions using Cmds. :-)

A subscription in Elm is just a command that is being run when initializing the UI. In our case, initialization happens with an initialization event. In case of the balls example, this happens on line 331:

display BallsInit      _ =
  child exampleDiv content <+> animateWithCleanup GotCleanup Next

Function animateWithCleanup sets up the animation (event Next is being fired on every animation frame) and provides a cleanup hook for stopping the animation (event GotCleanup) is fired synchronously after the animation was started.