Open isberg opened 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.
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.
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. 😀
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.
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.
I will update my version and take it for a spin. :-)
Some initial thoughts and feedback on the changes in #25:
runController
.Cmd.batch
, to make it similar to Elm.Cmd
s (together with an inital model) instead of having to specify a mandatory event. (So far I think I like the Elm way a bit more.)I will play around some more and compare how I would write code in both Elm, Idris2 and possibly Haskell. :-)
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.
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
... :-)
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
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
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.
I also found two more papers in a comment to the video mentioned above:
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 Cmd
s. :-)
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
Cmd
s. :-)
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.
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. :-)