Guard against unsaved changes¶
You know the route model. This page is one job: "are you sure? unsaved changes" before leaving an editor.
Shape: :can-leave boolean sub (true = leave is fine) → false parks the attempt
in [:rf/pending-navigation] → dialog dispatches :rf.route/continue or
:rf.route/cancel. Data all the way; tests with zero DOM.
Prerequisites. The model → Blocking.
1. Write the "is it safe to leave?" sub¶
Ordinary subscription. Read it positively: true
means leaving is fine. Here, leaving is fine when the draft matches what was last
saved:
(rf/reg-sub :editor/can-leave?
(fn [db _]
(= (get-in db [:editor :draft])
(get-in db [:editor :saved])))) ;; true when clean ⇒ safe to leave
Strict boolean
true allows, false blocks. Anything else (nil, map, truthy non-boolean) →
runtime blocks and emits :rf.error/can-leave-non-boolean. Deny the leave,
raise the error. Keep a real true/false.
2. Declare the guard on the route¶
(rf/reg-route :app/article-editor
{:params [:map [:id :string]]
:can-leave [:editor/can-leave?]} ;; the sub from step 1
"/articles/:id/edit")
When the guard returns false, URL and state stay put — the navigation does not
commit. The attempt parks, and the runtime dispatches :rf.route/navigation-blocked
(with a matching trace) so you can react beyond the dialog (toast, analytics).
3. Render the prompt from the pending slot¶
Blocked navigation lands in :rf/pending-navigation. Non-nil → show the dialog.
No window.confirm, no beforeunload — ordinary view over
state:
(rf/reg-view leave-guard-dialog []
(when-let [pending @(rf/subscribe [:rf/pending-navigation])]
[:div.modal
[:p "You have unsaved changes. Leave anyway?"]
;; Both events take the pending-nav *id* — not a bare keyword.
[:button {:on-click #(dispatch [:rf.route/cancel (:id pending)])} "Stay"]
[:button {:on-click #(dispatch [:rf.route/continue (:id pending)])} "Discard & leave"]]))
Choice is a dispatch carrying (:id pending):
[:rf.route/continue <id>]— proceed (re-runs:can-enteron the target if any).[:rf.route/cancel <id>]— drop it; stay put.
Bare [:rf.route/continue] / [:rf.route/cancel] without the id is wrong — the
runtime keys the pending slot by that id (stale ids are safe no-ops).
Mount leave-guard-dialog once near the root; it covers every guarded route and
renders nothing until something is pending.
4. Add a "save, then leave" path¶
"Save & close" should leave without the prompt. Save first, then navigate with
:bypass-leave? true:
(rf/reg-event :editor/save-and-close
(fn [{:keys [db]} _]
{:db (assoc-in db [:editor :saved] (get-in db [:editor :draft])) ;; now clean
:fx [[:dispatch [:rf.route/navigate {:to :app/article
:params {:id (get-in db [:editor :id])}
:bypass-leave? true}]]]}))
Saving would make the guard pass anyway — :bypass-leave? true makes the intent
explicit, and it is the only bypass there is. It skips this route's :can-leave
for this one navigation; the target's :can-enter still runs, because an "enter
anyway" flag would be a hole straight through the auth gate.
5. Cover the hard exit too¶
A pending value is a fact inside your app, so it cannot stop the browser closing the
tab, following an external link, or reloading. That is a host concern, and the honest
pairing is a beforeunload listener reading the same dirty state the guard reads:
(defn install-unload-warning!
"Ask the browser to confirm a hard exit while the draft is dirty.
Same source of truth as :editor/can-leave? — one dirty flag, two exits."
[]
(.addEventListener js/window "beforeunload"
(fn [e]
(when-not @(rf/subscribe [:editor/can-leave?])
(.preventDefault e)
(set! (.-returnValue e) ""))))) ;; the browser owns the wording
Call it once at boot. Routing deliberately does not wrap this: the browser shows its own non-customisable dialog, only when the user has interacted with the page, and only on a real unload — a second confirmation API pretending otherwise would be lying. Deriving both exits from one sub is what keeps them from disagreeing.
Test it with zero DOM¶
;; Land on the editor and make the draft dirty.
(rf/dispatch-sync [:rf.route/navigate {:to :app/article-editor :params {:id "intro"}}])
(rf/dispatch-sync [:editor/edit-field :title "changed"]) ;; draft ≠ saved
;; Try to leave — blocked and parked, not committed.
(rf/dispatch-sync [:rf.route/navigate {:to :app/home}])
(is (= :app/article-editor @(rf/subscribe [:rf.route/id])))
(is (some? @(rf/subscribe [:rf/pending-navigation])))
;; Reader confirms — continue takes the pending id.
(rf/dispatch-sync [:rf.route/continue
(:id @(rf/subscribe [:rf/pending-navigation]))])
(is (= :app/home @(rf/subscribe [:rf.route/id])))
(is (nil? @(rf/subscribe [:rf/pending-navigation])))
Every branch — blocked, confirmed, cancelled, bypassed — is a dispatch and an assertion. Also under Testing routes.