Skip to content

Instantly share code, notes, and snippets.

@bensu
Last active August 29, 2015 14:21
Show Gist options
  • Select an option

  • Save bensu/d1693fdd76162f2c5648 to your computer and use it in GitHub Desktop.

Select an option

Save bensu/d1693fdd76162f2c5648 to your computer and use it in GitHub Desktop.
Draft for Event Handling in Om
;; Problem
;; Event Handling in UIs. We only care about some of the events fired
;; by the user or IO and it is not always possible to handle them at
;; the point where we pick them up from the DOM.
;; Thus a messaging/piping infrastructure is need to take the events
;; from the place where they are interpreted from the DOM to the place
;; that handles them. Each application rolls it's own messaging
;; infrastructure, can we do any better?
;; Design Constraints
;; 1. Each component should only know about the events it handles/raises, nothing about it parent.
;; 2. Each parent can know about the children's events it wants to
;; handle but allow other components higher in hierarchy to handle the
;; events it can't.
;; Solution
;; Inspired by the Exception Handling analogy I thought that each
;; component could interpret the browser/IO events it cares about and
;; then raise the interpreted version for the parent
;; to handle. Before raising the event, the component tags it with a
;; `tag-fn` passed at build time by the parent. The parent now
;; receives the tagged events, queries them, and handles them
;; appropriately. If it fails to handle them, it tags them, and
;; raises them again, putting them in the `om-ch` that is globally observable.
(defn raise! [tag-fn om-ch e]
(put! om-ch (tag-fn e)))
;; As a motivation I'll explore an editable component
;; similar to the one presented
;; [here](https://github.com/omcljs/om/wiki/Intermediate-Tutorial). It
;; has a text input and a trash icon to be deleted. I'll write
;; "working" om code that (though clumpsy) exhibits the desired semantics.
(defn editable
"Editable knows about three events:
:enter - it should report when \"Enter\" is pressed.
:delete - when clicking the trash icon
:edit - fired with the new contents onChange"
[content owner {:keys [tag-fn om-ch]}]
(reify
(render [_]
(dom/div nil
(dom/i {:class "trash-icon"
:onClick (fn [_] (put! om-ch (tag-fn [:delete nil])))})
(dom/input #js {:value content
:onChange #(let [v (.. % -target -value)]
(put! om-ch (tag-fn [:edit v])))
:onKeyDown #(when (= "Enter" (.-key %))
(put! om-ch (tag-fn [:new nil])))})))))
;; editable is used by list-maker which edits a list of strings.
;; list-maker handles all the events produced by editable and manages
;; the cursor's state.
(defn list-maker
"Allows users to edit a sequence of strings"
[contents owner {:keys [tag-fn om-ch]}]
(reify
(will-mount [_]
(go-loop []
(let [e (<! (om/get-state owner :om-ch))]
;; Query & Handle
(if (= :editable (get-raiser e))
(case (get-tag e)
:new #(om/transact! contents #(conj % ""))
:edit #(om/update! contents index (get-data e))
:delete #(om/transact! contents #(drop-nth index %)))
;; If we can't handle the event, we tag it and bubble it up
(put! om-ch (tag-fn e))))))
(render [_ {:keys [om-ch]}]
(apply dom/ul nil
(map-indexed #(om/build editable %2
{:opt {:tag-fn (fn [e] (concat [:editable %1] e))
:om-ch om-ch}})
contents)))))
;; For the current event representation the query helpers would be:
(defn get-data [e] (last e))
(defn get-raiser [e] (first (take-last 2 e)))
;; To test this design, let's change the requirements and see how much
;; things need to be adapted. Now list-maker is needed to
;; work on some `buffer-contents` and only when a new "Save" button is
;; pressed should those changes be commited. We can make a higher
;; component, buffer-handler, that has list-maker and save-button as
;; children and deal with the coordination. To complicate things,
;; we'll say that for markup reasons, save-button needs to be under list-maker
(defn save-button
"Cursor contract: none.
Event contract: raises [:save] onclick"
[data owner {:keys [om-ch tag-fn]}]
(reify
(dom/i #js {:class "save-icon"
:onClick (fn [_] (put! om-ch (tag-fn [:save nil])))})))
(defn list-maker [contents owner opts]
(reify
(will-mount [_] ...) ;; Same code
(render [_ {:keys [om-ch]}]
(dom/div nil
(apply dom/ul nil
(map-indexed #(om/build editable %2
{:opts {:om-ch om-ch
:tag-fn (fn [e] (concat [:editable %1] e))}})
contents))
;; Added save-button
(om/build save-button {}
{:opts {:om-ch om-ch
:tag-fn (fn [e] (concat [:save-button] e))}})))))
(defn buffer-handler [contents owner {:keys [om-ch tag-fn]}]
(reify
(init-state [_]
{:buffer-contents @contents})
(will-mount [_]
(go-loop []
(let [e (<! om-ch)]
;; Query
(if (and (= :save-button (get-raiser e))
(= :save (get-tag e)))
;; Handle when query matches
(commit-changes! contents (om/get-state owner :buffer-contents))
;; Keep raising when no query matches
(put! om-ch (tag-fn e))))))
(render-state [_ {:keys [buffer-contents om-ch]}]
(om/build list-maker buffer-contents
{:opts {:om-ch om-ch
:tag-fn (fn [e] (concat [:list-maker] e))}}))))
;; In this example, the problem and solution are somewhat
;; isomorphic. I'm happy with the semantics but appalled by the
;; bookkeeping. How should this look?
;; Following @bbloom's lead, each component registers an event handler
;; through IHandle. We have a `match` function that runs a query over
;; the event and bind the results to some symbols. If the results are
;; non-nil, it executes the body and informs that the event was
;; handled. Otherwise, it informs that the event was not handled and
;; execution should proceed:
(defmacro match [bindings query &body]
`(if-let [bindings query]
(do
@body
::handled)
::not-handled))
;; Rewriting the components:
(defn editabe [content owner]
;; Needs no change except for (raise! [:delete nil])
;; instead of (put! om-ch (tag-fn [:delete nil]))
)
(defn list-maker [contents owner]
(reify
;; No will-mount
(render [_ {:keys [om-ch]}]
(dom/div nil
(apply dom/ul nil
(map-indexed #(om/build editable %2
{:tag-fn (fn [e] (concat [:editable %1] e))})
contents))
(om/build save-button {}
{:tag-fn (fn [e] (concat [:save-button] e))})))
IHandle
(handle [e]
(match [index (query '[:find ?i :where [?e :raiser [:editable ?i]]] e)]
(match [content (query '[:find ?c
:where [?e :tag :edit]
[?e :data ?c]]
e)]
(om/update! contents index content))
(match [] (query '[:find _ :where [?e :tag :delete]] e)
(om/transact! contents #(drop-nth index %)))
(match [] (query '[:find _ :where [?e :tag :new]] e)
(om/transact! contents #(conj % "")))))))
(defn save-button [content owner]
;; Needs no change except for (raise! [:delete nil])
;; instead of (put! om-ch (tag-fn [:delete nil]))
)
(defn buffer-handler [contents owner {:keys [om-ch tag-fn]}]
(reify
(init-state [_]
{:buffer-contents @contents})
;; No will-mount
(render-state [_ {:keys [buffer-contents om-ch]}]
(om/build list-maker buffer-contents
{:tag-fn (fn [e] (concat [:list-maker] e))}))
IHandle
(handle [e]
(match [index (query '[:find ?i
:where [?e :raiser :save-button]
[?e :tag :save]]
e)]
(commit-changes! contents (om/get-state owner :buffer-contents))))))
;; The new syntax is more declarative but it's still
;; verbose because it uses Datalog when something simpler
;; would suffice[2]. The syntax also shows the two things compoennts
;; need to do: tell their children how to tag events and handle them.
;; It hides the passing of channels, the loop in each component, the
;; bubbling up of unhandled events, and simplifies the call to raise.
;; [1] The pseudo code for match is conceptually flawed.
;; Though it allows nesting calls it returns ::handled if the top one
;; matched but the inner ones that should do the handling don't.
;; [2] TODO: Datalog can query arbitrary graphs, while we just need to
;; query on a collection. Find that Query Language.
@bensu
Copy link
Author

bensu commented May 26, 2015

Feedback from @bbloom in irc #clojurescript on 2015-05-25

Addressing particular points of my proposed design:

  1. Why core.async? It's a way of implementing the semantics I wanted but shouldn't be part of the final API. In the final version there is no core.async.
  2. You should decomplect the route from the event. Good point. Initially I couldn't figure out a way of passing the the two together. Changing some fn signatures to (handler [route event]) and doing {:route '() :event [:tag :data]} will suffice.
  3. Rather than return handled or not handled, why not return nil or a new event to bubble instead? this will let you convert events during the bubbling process. Good point. In my previous model one would call raise! from inside match. Returning the desired event value is more composable.
  4. :tag-fn? It's a non-automatic way of defining how should the route be built. When the event starts in the child it has only [tag data] and no route. Each time it bubbles up to a parent, the route gets bigger. :tag-fn defines how. By default it conjs the component type (:list-maker) to the route, but it could be custom in case indexes ([:editable 2]) or unique ids are used to discern between many children of the same type.

Bigger points concerning the design assumptions:

  • you need to decomplect 1) the identity of an event being generated and 2) the code that handles that event identity. If you have the identity of that event in the handler it's straightforward to match events and the routes become less important. Also, when using closures as event handlers a unique fn object is generated during each diff which is bad for performance. I get the benefits of doing this but I can't see how it can be implemented and how would the API look -> hammock.
  • React offers a synthetic DOM-like callback model that taps into inaccessible internals (expanded component hierarchy). The biggest design decision is: should the event handling be based on React's synth events or skew them in favor of a new event system? If this is not possible, it may be a reason to move away from React. @bbloom proposes that a cljs React replacement is perfectly reasonable.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment