mirror of
https://github.com/penpot/penpot.git
synced 2026-10-28 21:40:34 +00:00
✨ 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:
parent
a3fc7c8465
commit
f23eb31bd7
@ -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.
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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`."
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user