diff --git a/.serena/memories/frontend/workspace-state-persistence-subtleties.md b/.serena/memories/frontend/workspace-state-persistence-subtleties.md index 33e977bee9..eee6de48b8 100644 --- a/.serena/memories/frontend/workspace-state-persistence-subtleties.md +++ b/.serena/memories/frontend/workspace-state-persistence-subtleties.md @@ -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. diff --git a/frontend/src/app/main/data/persistence.cljs b/frontend/src/app/main/data/persistence.cljs index 82e9013cb5..475bf9b45c 100644 --- a/frontend/src/app/main/data/persistence.cljs +++ b/frontend/src/app/main/data/persistence.cljs @@ -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). diff --git a/frontend/test/frontend_tests/data/persistence_test.cljs b/frontend/test/frontend_tests/data/persistence_test.cljs index 32150ad8dd..44307d42c9 100644 --- a/frontend/test/frontend_tests/data/persistence_test.cljs +++ b/frontend/test/frontend_tests/data/persistence_test.cljs @@ -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`."