Fork me on GitHub
#humbleui
<
2024-01-05
>
JAtkins05:01:07

IDK if y'all are still experimenting here, but using electric might be a nice way to avoid requiring a vdom. It's a compiler that basically lifts (in the monad sense) clojure code into streams with managed lifecycles for endpoints... a little hard to explain, but the CLJS dom library is about 300 LOC without any react vdom style state management. probably worth a quick look. but, if electric was used top-to-bottom for humble-ui, many state management issues might just disappear.

💡 4
Niki01:01:39

Is it substantially different from https://tonsky.me/blog/humble-signals/?

JAtkins01:01:08

That looks super interesting. I’ll read it tomorrow. On first blush, no electric isn’t super different. It’s an incremental computation signal graph. However, electric is based on the missionary library, which handles both continuous time and discrete time events. The main difference between those is the system is ok with dropping continuous time signals for performance reasons. Anyway, electric is a clojure-like dsl that compiles to a missionary graph. Probably interesting to review at least, if not actually use.

JAtkins01:01:59

This is likely the easiest entry point: https://youtu.be/xtTCdT6e9-0?feature=shared The video is solely about missionary and how it handles signal graphs over time.

JAtkins03:01:04

One other thing I just thought of - it prevents visual glitches. If you have A and B (which depends on A), the UI is prevented from being in a state showing AB*, and waits until A*B* is shown.

leonoel19:01:53

;; humbleUI
(def *area (s/signal (* @*width @*height)))

;; missionary
(def *area (m/signal (m/cp (* (m/?< *width) (m/?< *height)))))
On surface it is no different, the syntax is similar and the semantics match. The difference is, in missionary the subscription lifecycle is managed and propagated by functional composition. Therefore, if at any point in time there is no active subscription on *area, the subscriptions to *width and *height are also discarded, removing the bidirectional references (the edge in the DAG), which solves the memory leak problem. Of course, it only works if all resources comply to this model, including event sources and effects (the inputs and outputs of the DAG). That's why missionary is an effect system that also provides operators for discrete streams, so you can treat any long-lived process as a managed resource and have it automatically cleaned up when it's not used anymore.

Niki02:01:04

@U5P29DSUS How do signals help with vdom?

Niki02:01:48

@U053XQP4S how do you know that “there are no active subscriptions”? Is there an explicit “unsubscribe” method?

JAtkins05:01:33

Re. VDOM: Here's an electric example using the built in js/dom library for usage reference. https://electric.hyperfiddle.net/user.tutorial-7guis-2-temperature!TemperatureConverter Nothing too exotic, it looks kinda like react. Here's the dom library: https://github.com/hyperfiddle/electric/blob/master/src/hyperfiddle/electric_dom2.cljc#L52 I've selected and commented a couple functions:

#?(:cljs (defn dom-listener [node typ f opts]
           (.addEventListener node typ f (clj->js opts))
           #(.removeEventListener node typ f))) ;; How to setup and teardown an event listener on js/dom

#?(:cljs (defn listen> ; Listen returns an event stream. 
           ([node event-type opts]
            ;; I'm not fully sure what relieve is. 
            (m/relieve {}
              ;; observe creates a new signal based on a subject. it expects a function (fn [new-value-supplied-f])
              ;; The function can also return a no arg fn for teardown (see dom-listener) 
              ;;   when the observe is cleaned up.
              ;; It's expected that `(new-value-supplied-f new-changed-value)` is called when the value changes.
              (m/observe #(dom-listener node event-type % opts)))))) 
(defmacro on
  "Run the given electric function on event.
  (on \"click\" (e/fn [event] ...))"
  ;; 
  ([typ F] `(let [[state# v#] ;; F is a signal processor. For each new value spawned by `listen>`, send it to F,
                              ;; BUT if that signal hasn't finished propagating and a new value comes along, discard
                              ;; the older process and continue only with the new one. 
                              ;; @leonoel will have to check me on this understanding
                  (e/for-event-pending-switch [e# (listen> node ~typ)] 
                    (new ~F e#))]

    ;; the real impl has more code handling `::pending` states but those aren't required to understand this
    )))

(defmacro element
  {:style/indent 1}
  [t & body]
  `(with (new-node node ~(name t))
     ; hack: speed up streamy unmount by removing from layout first
     ; it also feels faster visually
     (e/on-unmount (partial hide node)) ; hack
     ~@body))

(defmacro button {:style/indent 0} [& body] `(element :button ~@body))

;; This function creates a stream depending on the atom !hover-enabled?. When that value changes, the when branch 
;; is hit. If hover-enabled=false, then the consumer of the `listen>` stream is killed. Since no consumers are left,
;; the whole stream is closed and the consumer fn is un-mounted from the js/dom
(e/defn EnableOnHover []
  (let [!hover-enabled? (atom false) hover-enabled? (watch !hover-enabled?)]
    (button
      (when hover-enabled?
        (on "hover" (e/fn [_] (println "hovered!")))
      (on "click" (e/fn [_] (swap! !hover-enabled not))
      (dom/text (str "Hover enabled? " hover-enabled?)))
It's a rather long example, but it shows that electric/missionary are managing streams all the way down to the leaves. There's no need for a react-style vdom because it's streams all the way down, and create/change/teardown signals are maintained to the edges of the js/dom graph.

JAtkins06:01:25

I'm super interested now after reading your article... I may try my hand at implementing your incremental-app example in electric

leonoel11:01:47

> Is there an explicit “unsubscribe” method? No. sub/unsub are not exposed to the user, they are managed by the operators. Each operator defines the lifecycle of its upstream subscriptions, in the case of *area it is the same as the parent but the logic can be more complex (e.g. with conditionals - when the predicate switches, signals in the previous branch are unsub and signals in the current branch are sub). > how do you know that “there are no active subscriptions”? Each signal has access to its active downstream subscriptions, so it's basically a refcount.

leonoel15:01:22

@U050UBKAA also feel free to correct me about your implementation, I've not read it just inferred it from your blog post