Last active
March 26, 2026 19:18
-
-
Save shaunlebron/d6a681ce74f46cf1b23b2b7f07f14759 to your computer and use it in GitHub Desktop.
Flashing intermediate changes inside a render batch in Reagent
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| (ns example.reagent-flash | |
| (:require | |
| [reagent.core :as r] | |
| [reagent.ratom :as ra] | |
| )) | |
| (defn- resolve-ratom [r* path] | |
| (cond | |
| (instance? Atom r*) [r* path] | |
| (instance? ra/RAtom r*) [r* path] | |
| (instance? ra/RCursor r*) (resolve-ratom (.-ratom r*) | |
| (into (.-path r*) path)) | |
| :else (throw (ex-info (str "unsupported reference type in flash, expected Atom or RAtom: " | |
| (type r*)) | |
| {:r* r*})))) | |
| (defn flash | |
| "A reaction that returns the result of pred whenever r* changes: | |
| * r* must be an Atom, RAtom, or RCursor (so we can use add-watch to call ALL intermediate changes in a frame) | |
| * A successful flash condition remains on for a duration to avoid being turned off in the same frame. | |
| " | |
| [r* pred flash-method ms] | |
| (let [[src* path] (resolve-ratom r* []) | |
| result* (r/atom nil) ;; ratom indicating the non-nil value being flashed | |
| watch-k (gensym "flash-watch") | |
| remove-watch! #(remove-watch src* watch-k) | |
| add-watch! (delay | |
| ;; NOTE: `add-watch` must be delayed to run inside our returned reaction to avoid a memory leak, | |
| ;; because we can only run `remove-watch` in our returned reaction’s `:on-dispose` handler, | |
| ;; and Reagent only disposes reactions that have first been deref'd. | |
| (let [fadeout* (volatile! nil)] ;; the currently scheduled "timeout" for the next fadeout | |
| (add-watch src* watch-k | |
| (fn [_ _ oldv newv] | |
| (let [result (pred (get-in oldv path) | |
| (get-in newv path))] | |
| (case flash-method | |
| ;; Any non-nil result resets to nil after `ms` milliseconds, unless interrupted by another non-nil. | |
| :flash-on-some (when (some? result) | |
| (reset! result* result) | |
| (js/clearTimeout @fadeout*) | |
| (vreset! fadeout* | |
| (js/setTimeout #(reset! result* nil) | |
| ms))) | |
| ;; Any non-nil value persists until `ms` milliseconds after a nil occurs, unless interrupted by a non-nil. | |
| :fadeout-on-nil (if (some? result) | |
| (do (reset! result* result) | |
| (js/clearTimeout @fadeout*) | |
| (vreset! fadeout* nil)) | |
| ;; NOTE: schedule a fadeout if not already in progress | |
| (when (and (some? @result*) (nil? @fadeout*)) | |
| (vreset! fadeout* | |
| (js/setTimeout #(do (reset! result* nil) | |
| (vreset! fadeout* nil)) | |
| ms)))) | |
| (throw (ex-info (str "unrecognized flash-method: " (pr-str flash-method)) {}))))))))] | |
| (ra/make-reaction | |
| (fn [] | |
| (force add-watch!) ;; NOTE: runs add-watch only once when this reaction is first deref'd | |
| @result*) | |
| :on-dispose remove-watch! ;; NOTE: disposal happens if and only if this reaction was deref'd | |
| ))) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| (ns example.test-flash | |
| (:require-macros [cljs.core.async :refer [go]]) | |
| (:require [clojure.core.async :refer [<! timeout]] | |
| [example.reagent-flash :refer [flash]] | |
| [reagent.core :as r])) | |
| (defonce state* (r/atom {:outer 0 | |
| :inner {:foo 1 | |
| :bar 2 | |
| :baz 3} | |
| :n 0})) | |
| (defn flash-style [on? bg] | |
| (if on? | |
| {:background bg} | |
| {:transition "background 1s"})) | |
| ;; NOTE: making sure this works with a top-level ratom | |
| (defn test-outer-flash [] | |
| (r/with-let [k :outer | |
| flash?* (flash state* | |
| (fn [oldv newv] (when-not (= (k oldv) (k newv)) newv)) | |
| :flash-on-some 100)] | |
| [:div.container | |
| [:div.cell {:style (flash-style @flash?* "lime")} | |
| (name k) ": " (get @state* k)]])) | |
| ;; NOTE: making sure this works on multiple tracked values on a single nested cursor | |
| (defn test-inner-flashes [inner*] | |
| (r/with-let [flash-path? (memoize (fn [k] | |
| (flash (r/cursor inner* [k]) ;; <-- testing cursor of a cursor | |
| (fn [oldv newv] (when-not (= oldv newv) newv)) | |
| #_#_:flash-on-some 300 | |
| :fadeout-on-nil 100)))] | |
| [:div.container | |
| (doall | |
| (for [[k v] @inner*] | |
| [:div.cell {:key k :style (flash-style @(flash-path? k) "lime")} | |
| (name k) ": " (pr-str v)]))])) | |
| ;; NOTE: making sure this works for always showing the most recent delta | |
| (defn test-delta-flash [n*] | |
| (r/with-let [delta* (flash n* (fn [a b] (when (and a b (not= a b)) (- b a))) | |
| :fadeout-on-nil 100)] | |
| (let [delta @delta*] | |
| [:div.container | |
| [:div.cell {:style (if delta | |
| {:background (cond (pos? delta) "lime" | |
| (neg? delta) "red" | |
| :else "lightgray")} | |
| {:transition "background 1s"})} | |
| "n: " (pr-str @n*)]]))) | |
| (defn main-view [n*] | |
| (r/with-let [inner* (r/cursor state* [:inner]) | |
| n* (r/cursor state* [:n])] | |
| (with-css [:main {:display "grid" | |
| :place-items "center" | |
| :width "content-width" | |
| :gap "1em" | |
| :font-size "24pt"} | |
| [:div.container {:display "flex" | |
| :padding "1em" | |
| :gap "1em" | |
| :border "1px solid black"} | |
| [:div.cell {:padding "1em" | |
| :border "1px solid black"}]]] | |
| [:main | |
| [test-outer-flash] | |
| [test-inner-flashes inner*] | |
| [test-delta-flash n*]]))) | |
| (comment | |
| (swap! state* update :n inc) | |
| (swap! state* update :n dec) | |
| (swap! state* update :n identity) | |
| (do | |
| (swap! state* update :n dec) | |
| (swap! state* update :n identity) | |
| ) | |
| (do | |
| (swap! state* update :outer inc) | |
| (swap! state* assoc :outer 0) | |
| ) | |
| (do | |
| (swap! state* update-in [:inner :foo] inc) | |
| (swap! state* update-in [:inner :bar] inc) | |
| (swap! state* update-in [:inner :baz] inc)) | |
| (go | |
| (dotimes [_ 3] | |
| (doseq [k [:foo :bar :baz]] | |
| (swap! state* update-in [:inner k] inc) | |
| (<! (timeout 50))) | |
| (<! (timeout 400)) | |
| ) | |
| ) | |
| (swap! state* assoc-in [:inner :foo] nil) | |
| (swap! state* assoc-in [:inner :foo] 3) | |
| (do | |
| (swap! state* update-in [:inner :foo] inc) | |
| (swap! state* assoc-in [:inner :foo] 3) | |
| ) | |
| ) | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment