✨ Send pending edits when the user leaves the tab or window

Edits wait up to 3s in a buffer before they are sent. Closing the
laptop or switching away right after an edit could leave that batch
unsent, and a crash or a discarded tab then lost it.

The batch is now sent at once when the tab is hidden (another tab, a
minimized window, a closed tab or laptop) or the window loses focus
(another app or a plugin iframe). The 3s window itself is unchanged.
pagehide and beforeunload are left out: they come too late for an
async save, and the tab is already hidden by then.
This commit is contained in:
alonso.torres 2026-10-16 13:07:44 +02:00 committed by Andrey Antukh
parent a3fc7c8465
commit f23eb31bd7
3 changed files with 52 additions and 0 deletions

View File

@ -32,6 +32,7 @@
- Tests instant-trigger retries by stubbing `rx/timer` (recording delays to assert the schedule); dynamic bindings do not survive `await` continuations, so no dynamic var for delays.
- `save-request-timeout-ms` is 7 minutes and wraps the `update-file` request in `rx/timeout`, failing it as a transient `:network` error so the retry path resends instead of blocking the queue forever. It is deliberately longer than `saving-stall-timeout-ms` (5 min) so the stalled report fires before the retry.
- `sync-unload-guard` keeps `:persistence` in `st/ongoing-tasks` while edits are buffered or queued, which is what holds the browser leave-page warning on. It syncs on `::append-commit`, `::discard-commit` and `::release-failed-file` (every queue change). It is the one effect that must stay in `store.cljs`'s `omitset`, and `store.cljs`'s `ongoing-tasks` watcher is guarded by `exists? js/window` because tests run without a window.
- `leave-signals` (document `visibilitychange`→hidden, window `blur`) emits `::force-persist` when edits are buffered, so leaving the tab/window sends the 3 s batch at once; tests mock `dps/leave-signals` BEFORE `initialize-persistence` runs. pagehide/beforeunload are too late for an async save.
- Exports and the `.penpot` download call `force-persist-files-and-wait file-ids timeout` (waits only for those files, ends on `:error` or timeout, never raises) and then `unsaved-changes? state file-ids` to warn; it reads `refs/persistence`, so tests mock it with `(l/derived :persistence store)`.
- `persistence-failed` emits a `save-failed` audit event (`:cause-type :code :retrying :queued-commits`) whenever the user is notified (`notify-failure?`, same rule as the toast), including delegated auth/not-found failures.
- Undo transactions can stay open only temporarily; timed-out pending transactions are force-committed after about 20s. Undo entries are capped at 50.

View File

@ -838,6 +838,17 @@
(swap! st/ongoing-tasks conj :persistence)
(swap! st/ongoing-tasks disj :persistence)))))
(defn leave-signals
"Emits when the tab is hidden or the window loses focus: another tab or
app, a plugin iframe, a closed laptop. Empty outside the browser."
[]
(if (and (exists? js/window) (exists? js/document))
(rx/merge
(->> (rx/from-event js/document "visibilitychange")
(rx/filter #(= "hidden" (.-visibilityState js/document))))
(rx/from-event js/window "blur"))
(rx/empty)))
(defn initialize-persistence
[]
(ptk/reify ::initialize-persistence
@ -896,6 +907,12 @@
(rx/map #(ptk/data-event ::persistence-notification))
(rx/take-until stoper-s))
;; Sends the buffered edits when the user leaves the tab or window.
(->> (leave-signals)
(rx/filter (fn [_] @buffered?))
(rx/map (fn [_] ::force-persist))
(rx/take-until stoper-s))
;; Syncs the leave-page warning on every local commit and on every
;; queue change (append after a flush, discard after a save,
;; release of a failed file).

View File

@ -1005,6 +1005,40 @@
"the release turns the guard off"))
(t/is (= :saved (get-in @store [:persistence :status])))))))
;; Scenario: an edit waits in the 3 s buffer and the user switches tabs or
;; windows. Proves: leaving sends the buffered edit at once, well before
;; the buffer would (the bounded `wait-for` gives up long before 3 s).
(t/deftest ^:async leaving-the-tab-sends-buffered-edits-at-once
(let [leave-s (rx/subject)]
(await
(mock/with-mocks*
{dps/leave-signals (fn [] leave-s)}
(await
(with-persistence
(^:async fn [{:keys [file-id requests store]}]
(ptk/emit! store (local-commit file-id))
(await (async/settle))
(t/is (empty? @requests) "the edit waits in the buffer")
(rx/push! leave-s :hidden)
(await (async/wait-for #(= 1 (count @requests))
"leaving sends the edit at once"))
(t/is (= 1 (count @requests))))))))))
;; Scenario: nothing is buffered when the user leaves. Proves: a leave
;; signal alone sends nothing.
(t/deftest ^:async leaving-with-nothing-buffered-sends-nothing
(let [leave-s (rx/subject)]
(await
(mock/with-mocks*
{dps/leave-signals (fn [] leave-s)}
(await
(with-persistence
(^:async fn [{:keys [requests store]}]
(rx/push! leave-s :hidden)
(await (async/settle))
(t/is (empty? @requests))
(t/is (nil? (get-in @store [:persistence :status]))))))))))
(defn- wait-files-saved
"Emits into `store` an event that runs `force-persist-files-and-wait` for
`file-ids` and then flips `done`."