Overthunk

A Brave Start to Athens: Digging into an Issue

Jul 3, 2020


Contents

Re-Frame

So what is re-frame? From the re-frame repo -

re-frame is a ClojureScript framework for building user interfaces. It is has a data-oriented, functional design.

That doesn’t seem like a particularly useful description. Athens’ internal docs have a better comparison - re-frame is to reagent what redux is to React. It’s basically a state and event management abstraction.

Athens uses it quite heavily to manage internal state in a declarative way. re-frame is combined with posh and DataScript to make a really powerful combo! posh lets you use a DataScript database to store app state and has functions that allow components to access relevant data and update global state if needed.

Understanding the Athens Code

Now let’s take a look at the issue code (the code was located ever so quickly by @adrien saving us quite some time). The function that we’re looking at starts like this -

;; TODO: no-op when indenting as the right-most child
(reg-event-fx     ;; register an event handler
  :indent         ;; for events with this name
  (fn [_ [_ uid]] ;; get the cofx and destructure the event
    ...

If you’re familiar with re-frame, you’ll know that reg-event-fx is how you register an event handler for re-frame. Here we’re registering an :indent event which takes in two args, the Co-Effects (which is being elided here since we’re not interested in it) and the Event vector which is destructured to get the uid of the block being indented.

There’s quite a hefty let expression that follows -

(let [
        block (get-block [:block/uid uid]) ;; get current block
        parent (get-parent [:block/uid uid]) ;; get parent
        older-sib (->> parent
                    :block/children
                    (filter #(= (dec (:block/order block)) (:block/order %)))
                    first
                    :db/id
                    get-block) ;; older sibling of current block
        new-block {:db/id (:db/id block) :block/order (count (:block/children older-sib))} ;; where the block goes after indent
        reindex-blocks (->> (d/q '[:find ?ch ?new-o
                                    :in $ % ?p ?at
                                    :where (dec-after ?p ?at ?ch ?new-o)]
                            @db/dsdb rules (:db/id parent) (:block/order block))
                        (map (fn [[id order]] {:db/id id :block/order order})))]

When I first saw this function, I was a little taken aback. I didn’t really recognize what was going in the reindex-blocks binding especially in this odd looking function call -

(d/q '[:find ?ch ?new-o
        :in $ % ?p ?at
        :where (dec-after ?p ?at ?ch ?new-o)]
@db/dsdb rules (:db/id parent) (:block/order block))

That was the section that prompted me to learn DataLog since I really wanted to understand what was going on.

Some quick DataScript info:

After learning some DataLog, reindex-blocks started to make sense. It makes sure that after the current block is indented, the order for the other blocks makes sense.

However, the more I thought about what the function did, this little snippet didn’t seem quite relevant to the issue at hand.

.
└── Blah
    ├── This is top node
    ├── Hello
    └── Iron Man was not good

The issue here is that, currently Athens lets you indent a block that is at the top of it’s level. Looking at the diagram above, we could indent the block “This is top node”. But that doesn’t really make sense. That block doesn’t have any blocks directly above it that could become it’s parent.

Maybe the older-sib expression has some clues…

older-sib (->> parent
            :block/children
            (filter #(= (dec (:block/order block)) (:block/order %)))
            first
            :db/id
            get-block)

Let’s break this down. older-sib is bound to the result of a threader macro. This macro starts off with the parent block for the current block, gets the children of the parent block, filters the list to find the block whose block order is one less than the block order of the current block, gets the id and then gets the block object.

However, there’s a little issue here. What if the current block did not have an older sibling at all.filter would return an empty list. first on an empty list returns nil. And clearly the db is not going to give us a valid response when asked for the id of the nil block. And it doesn’t seem like any of the rest of the function is handling this issue.

So the million-dollar question is - “How do we indent a block that does not have an older sibling?“. The answer is that we don’t.

Each block has an attribute called block order. The top-most block at a given indentation level starts off at 0. And as you add blocks below, the block order number increments for each block.

.
├── 0
├── 1
├── 2
│   ├── 0
│   └── 1
└── 3
    └── 0
        └── 0

The above diagram illustrates how Athens assigns block orders for blocks. I ended up staring at a bunch of hand-drawn diagrams of nested blocks and how different levels of indentation could affect how the blocks are rendered.

Ultimately I realized that the “don’t indent a block that doesn’t have an older sibling” could be simplified to “don’t indent a block which has block order 0”.

Now handling the indent issue becomes as simple as figuring out how to check if block order equals 0 for the given block and not performing the re-ordering transaction if so. In fact, we can do this in a way where we don’t even have to evaluate the full beefy let expression.

(fn [_ [_ uid]]
    (let [block (get-block [:block/uid uid])
          parent (get-parent [:block/uid uid])]
        (if (= (:block/order block) 0) ;; check block order = 0
            {}  ;; return no-op if so
            (let [older-sib (->> parent
                            :block/children
                            (filter #(= (dec (:block/order block)) (:block/order %)))
                            first
                            :db/id
                            get-block)])))
                    ;; rest of the original function
)

Adding the if form here makes sure that we only pull data and perform other calculations if we need to. When we don’t need to, our :indent event handler returns a no-op.

Takeaways

Turns out that studying DataLog to understand this issue wasn’t really needed (except to understand the query in reindex-blocks). But the time spent on that was still valuable since I poked around a bunch of different files in the codebase to understand how the state of the application is initialized. This has definitely deepened my understanding of Athens and ClojureScript in general.

I also solved some of the exercises in LearnDataLog.com which I’m sure will come in handy when I need to decipher some queries or when I write queries myself.

It feels pretty great to leap into a codebase blind and pick up the pieces by digging through files and the documentation for various libraries. This is the kind of problem solving that gets me excited!

It was also fun to discuss this issue with @adrien. We ended up coming to the same conclusion on what the issue was and how to solve it.

Next Steps - Figure out how to solve the :unindent issue.

P.S - Looking at the re-frame event handlers and the code got me thinking about what end-to-end testing for declarative state could look like. This might be another interesting topic to discuss on the Athens discord.