Показаны сообщения с ярлыком frontend. Показать все сообщения
Показаны сообщения с ярлыком frontend. Показать все сообщения

понедельник, 12 июня 2017 г.

The Simplest Way to Mess Up om/build

I played with ClojureScript Om recently and, while working on a stupidly simple task, stumbled into a strange problem when my components losed state and got re-rendered whenver a user attempted any interactions that caused the underlying data to change.

I had a component that was bound to a path in app state holding a vector of maps. It consisted of several identical components, whose purpose was to edit values in these maps. Each of these editors updated the corresponding value during the onChange event - whenever a user would edit the text in them. An important detail is also that these editors had state - they could either be in "view" mode (rendered as <span>) or in the "edit" mode (rendered as <input>). The thing transitions to the "edit" mode when it is clicked and jumps back after user hits Enter in the input or moves focus somewhere else. It all worked pretty fine until I actually started editing stuff - whenever I pressed a key all the components would get re-rendered and, more importantly, lose the editing state.

I attempted some debugging and code tweaking, but nothing helped or even shed the light on what was going on - I saw that the controls were re-rendering, saw that their state was lost immediately after edits and later even noticed that they are being mounted as new ones after edits. This later observation explained the problem with lost state, but didn't give me much clue onto why that was happenning. However the thing got clear after I read the documentation for om's build and build-all functions a couple times:

build
(defn build
  ([f x] ...)
  ([f x m] ...))
Constructs an Om component. f must be a function that returns an instance of om.core/IRender or om.core/IRenderState. f must take two arguments - a value and the backing Om component usually referred to as the owner. f can take a third argument if :opts is specified in m. The component is identified by the function f. Changing f to a different function will construct a new component, while changing the return value will not change component. x can be any value. m is an optional map of options.

At some point I noticed my dumb mistake - I was calling build-all like this:
(defn component [data owner]
   (reify
     om/IRender
     (render [_]
       (html
        [:div.container
         (om/build-all (fn [data owner opts]
                        (if some-condition
                          (edit-component1 data owner opts)
                          (edit-component2 data owner opts)))
                       (:attributes data)
                       {:key :attribute-id
                        :opts {}})]))))

Which means that on each render of the parent component I would pass a new function to build-all. Because components "are identified by their functions", that meant that on every change of the underlying state the parent component will start re-rendering the child ones, see a new function created by the (fn []) form and believe that it is rendering new components. Thus it won't even want to connect the old state to them and they will be rendered in the default "view" state.

Simply changing my code to the following dirty thing immediately resolved the issue:
(defn edit-component [data owner opts]
 (if some-condition
   (edit-component1 data owner opts)
   (edit-component2 data owner opts)))

(defn component [data owner]
   (reify
     om/IRender
     (render [_]
       (html
        [:div.container
         (om/build-all edit-component
                       (:attributes data)
                       {:key :attribute-id
                        :opts {}})]))))

The conclusion is simple: never ever pass to build or build-all a function that you create in the render method of the parent component or in any other bit of code that is called multiple times over the lifetime of your components. If you do this you will get new components built everytime and thus lose whatever information you associate with them (plus some time for their creation). I hope that you never commit this mistake, because that's one of the dumb things that require time to spot, but if you are unfortunate to run into it, this note may help.

воскресенье, 15 мая 2016 г.

Using an external JavaScript library in your ClojureScript application

My recent playing with ClojureScript and Om was a great trip into the field of functional front-end programming. However it was disrupted by a small yet irritating problem. I wanted to consume an external JavaScript library - the client for Trello API - in my Om application and call a method of the object defined there. It all worked fine while I was developing the thing, but the first attempt to deploy it to the production server resulted in my application not working and spitting errors like "Trello.rf is not a function".

This problem is caused by ClojureScript munging names during compilation, which is disabled - as well as other optimizations - in the default dev profile. Because the idea of using an external JS library is not something completely stupid, there are some ways to avoid munging external names - outlined here and here. The approach with the externs file is quite easy to adopt, but it took me some experimenting to make it work, so I will describe it step-by-step here to have a reference in future.

Inputs first. I have a ClojureScript Om application enabled by cljsbuild 1.1.3. The compiled JavaScript is served from the /resources/public/js folder (or from somewhere under it). My application uses the Trello client JS library to make one call to the Trello.authorize method. The Trello library has to be brought in through the index.html like this:

<script src="https://api.trello.com/1/client.js?key=myappkey"></script>
<script src="js/compiled/trellodonelist.js" type="text/javascript"></script>

To prevent ClojureScript compiler from turning the names defined in the external library into strange symbols we only need to introduce an externs file and supply its name to the compiler. All this is done in three simple steps:

1. Create a plain JavaScript file and store it in a place where the compiler will be able to find it. I chose /resources/public/js/externs.js (the name doesn't matter that much).

2. In the externs.js file touch all the names from external libraries that you plan to refer in your app. For me that looked like:

var Trello = {};
Trello.authorize = function() {};

3. In your html refer the externs file along with the external library and your compiled ClojureScript:

<script src="js/externs.js" type="text/javascript"></script>
<script src="https://api.trello.com/1/client.js?key=myappkey"></script>
<script src="js/compiled/trellodonelist.js" type="text/javascript"></script>

4. In the project.clj file add the path to the externs file in a vector under [:cljsbuild :builds :app :compiler :externs]. You aim for something like this:

(defproject trellodonelist "0.1.0-SNAPSHOT"
  ; various meaningful things
  
  :cljsbuild 
   {:builds
    {:app
      {:source-paths ["src/cljs"]
       :compiler {:main trellodonelist.core
                  :asset-path "js/compiled/out"
                  :output-to "resources/public/js/compiled/trellodonelist.js"
                  :output-dir "resources/public/js/compiled/out"
                  :source-map-timestamp true

                  ; this one
                  :externs ["resources/public/js/externs.js"]}}}}

  ; other meaningful things
)

I had to play a bit with various locations of externs.js and missed one thing or another, but the above setup finally did the trick. Now, even if the ClojureScript is compiled with all the optimizations, the compiler is aware of the names brought in from the external libs and won't change them.

There are some other ways to make external names work and I recommend to read the articles mentioned above to get a better understanding of the reasons behind the issue and a wider set of alternative solutions. For my simple case the self-made externs file seemed the easiest and the most concise approach - maybe it will suit you as well. Happy coding!