🐛 Fix stalled saving states (#11699)

This commit is contained in:
Alonso Torres 2026-09-15 16:04:00 +02:00 committed by GitHub
parent 1c8b0c1844
commit ff88a055fd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 979 additions and 105 deletions

View File

@ -323,7 +323,7 @@
// STATUS WIDGET // STATUS WIDGET
--status-widget-background-color-success: var(--status-color-success-500); --status-widget-background-color-success: var(--status-color-success-500);
--status-widget-background-color-warning: var(--status-color-warning-500); --status-widget-background-color-warning: var(--status-color-warning-500);
--status-widget-background-color-pending: var(--status-color-info-500); --status-widget-background-color-pending: var(--status-color-warning-500);
--status-widget-background-color-error: var(--status-color-error-500); --status-widget-background-color-error: var(--status-color-error-500);
--status-widget-icon-foreground-color: var(--color-background-primary); --status-widget-icon-foreground-color: var(--color-background-primary);

View File

@ -245,8 +245,10 @@
features (get state :features) features (get state :features)
permissions (get state :permissions)] permissions (get state :permissions)]
;; Prevent commit changes by a viewer team member (it really should never happen) ;; Historical previews must not create edits to the live file. Check
(when (:can-edit permissions) ;; this when creating commits so previously queued edits can still save.
(when (and (:can-edit permissions)
(not (dm/get-in state [:workspace-global :preview-id])))
(log/trace :hint "commit-changes" :redo-changes redo-changes) (log/trace :hint "commit-changes" :redo-changes redo-changes)
(let [selected (dm/get-in state [:workspace-local :selected])] (let [selected (dm/get-in state [:workspace-local :selected])]
(rx/of (-> params (rx/of (-> params

View File

@ -9,9 +9,13 @@
[app.common.data :as d] [app.common.data :as d]
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.logging :as log] [app.common.logging :as log]
[app.common.time :as ct]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.main.data.changes :as dch] [app.main.data.changes :as dch]
[app.main.data.common :as-alias dc]
[app.main.data.helpers :as dsh] [app.main.data.helpers :as dsh]
[app.main.data.workspace :as-alias dw]
[app.main.errors :as errors]
[app.main.refs :as refs] [app.main.refs :as refs]
[app.main.repo :as rp] [app.main.repo :as rp]
[beicon.v2.core :as rx] [beicon.v2.core :as rx]
@ -21,24 +25,46 @@
(log/set-level! :warn) (log/set-level! :warn)
(def running (atom false))
(def revn-data (atom {})) (def revn-data (atom {}))
(defonce ^:private active-requests (atom #{}))
(def queue-conj (fnil conj #queue [])) (def queue-conj (fnil conj #queue []))
(def force-persist? #(= % ::force-persist)) (def force-persist? #(= % ::force-persist))
(defn wait-persisted (def ^:private saving-stall-timeout-ms (* 5 60 1000))
(def ^:private saving-check-interval-ms 30000)
(def ^:private save-wait-timeout-ms (* 2 60 1000))
(defn wait-persisted-or-error
"Returns an observable that emits the first terminal persistence status "Returns an observable that emits the first terminal persistence status
(nil | :saved) and completes. With a timeout-ms, if persistence doesn't (nil | :saved) and completes. Raises when the queue has failed and, with
settle in time the observable completes silently without emitting." a timeout-ms, when persistence does not settle in time."
([] (wait-persisted-or-error save-wait-timeout-ms))
([timeout-ms]
(let [base (->> (rx/from-atom refs/persistence {:emit-current-value? true})
(rx/filter (fn [{:keys [status queue]}]
(or (= status :error)
(and (empty? queue)
(or (nil? status) (= status :saved))))))
(rx/take 1)
(rx/mapcat (fn [{:keys [status error]}]
(if (= status :error)
(rx/throw (ex-info "Changes could not be saved"
(merge {:type :persistence :code :save-failed} error)))
(rx/of status)))))]
(cond->> base
timeout-ms
(rx/timeout timeout-ms
(rx/throw (ex-info "Timed out waiting for changes to be saved"
{:type :persistence :code :save-timeout})))))))
(defn wait-persisted
"Best-effort variant of `wait-persisted-or-error`: a failed or timed out
save completes the observable silently instead of raising."
([] (wait-persisted nil)) ([] (wait-persisted nil))
([timeout-ms] ([timeout-ms]
(let [base (->> (rx/from-atom refs/persistence-state {:emit-current-value? true}) (->> (wait-persisted-or-error timeout-ms)
(rx/filter #(or (nil? %) (= :saved %))) (rx/catch (fn [_] (rx/empty))))))
(rx/take 1))]
(if timeout-ms
(->> base (rx/timeout timeout-ms (rx/empty)))
base))))
(defn force-persist-and-wait (defn force-persist-and-wait
"Convenience that emits the force-persist event and then waits for "Convenience that emits the force-persist event and then waits for
@ -47,25 +73,75 @@
([timeout-ms] ([timeout-ms]
(rx/concat (rx/of ::force-persist) (wait-persisted timeout-ms)))) (rx/concat (rx/of ::force-persist) (wait-persisted timeout-ms))))
(defn- next-status
"Refuses downgrades: a save in progress stays :saving, and a failed save
stays :error until persistence is resumed."
[from to]
(cond
(and (= to :pending) (= from :saving)) from
(and (= from :error) (#{:pending :saving} to)) from
:else to))
(defn- update-status (defn- update-status
[status] [status]
(ptk/reify ::update-status (ptk/reify ::update-status
ptk/UpdateEvent ptk/UpdateEvent
(update [_ state] (update [_ state]
(update state :persistence (fn [pstate] (update state :persistence
(log/trc :hint "update-status" (fn [pstate]
:from (:status pstate) (log/trc :hint "update-status"
:to status) :from (:status pstate)
(let [status (if (and (= status :pending) :to status)
(= (:status pstate) :saving)) (let [status (next-status (:status pstate) status)]
(:status pstate) (cond-> (assoc pstate :status status)
status)] (#{:pending :saving} status)
(update :last-progress-at d/nilv (inst-ms (ct/now)))
(-> (assoc pstate :status status) (#{:error :saved} status)
(cond-> (= status :error) (dissoc :run-id :last-progress-at :stall-reported?))))))))
(dissoc :run-id))
(cond-> (= status :saved) (defn- report-stalled-persistence
(dissoc :run-id))))))))) [now]
(ptk/reify ::report-stalled-persistence
ptk/UpdateEvent
(update [_ state]
(assoc-in state [:persistence :stall-reported?] true))
ptk/EffectEvent
(effect [_ state _]
(let [{:keys [queue index status run-id last-progress-at]} (:persistence state)
commit-id (peek queue)
commit (get index commit-id)
hint "File saving has made no progress for more than five minutes"
cause (ex-info hint
{:type :persistence
:code :saving-stalled
:file-id (or (:file-id commit) (:current-file-id state))
:commit-id commit-id
:run-id run-id
:status status
:queued-commits (count queue)
:elapsed-ms (- now last-progress-at)
:can-edit (dm/get-in state [:permissions :can-edit])
:read-only? (dm/get-in state [:workspace-global :read-only?])
:preview-id (dm/get-in state [:workspace-global :preview-id])
:render-context-lost? (dm/get-in state [:render-state :lost])})]
(errors/submit-report :event-name "handled-exception"
:hint hint
:report (errors/generate-report cause))))))
(defn- check-persistence
[]
(ptk/reify ::check-persistence
ptk/WatchEvent
(watch [_ state _]
(let [{:keys [status last-progress-at stall-reported?]} (:persistence state)
now (inst-ms (ct/now))]
(when (and (#{:pending :saving} status)
last-progress-at
(not stall-reported?)
(> (- now last-progress-at) saving-stall-timeout-ms))
(rx/of (report-stalled-persistence now)))))))
(defn- update-file-revn (defn- update-file-revn
[file-id revn] [file-id revn]
@ -90,7 +166,9 @@
(if (= commit-id (peek queue)) (if (= commit-id (peek queue))
(pop queue) (pop queue)
(throw (ex-info "invalid state" {}))))) (throw (ex-info "invalid state" {})))))
(update :index dissoc commit-id))))))) (update :index dissoc commit-id)
(assoc :last-progress-at (inst-ms (ct/now)))
(dissoc :stall-reported?)))))))
(defn- append-commit (defn- append-commit
"Event used internally to append the current change to the "Event used internally to append the current change to the
@ -104,62 +182,152 @@
(update state :persistence (update state :persistence
(fn [pstate] (fn [pstate]
(-> pstate (-> pstate
(update :run-id d/nilv run-id) (cond-> (not= :error (:status pstate))
(update :run-id d/nilv run-id))
(update :queue queue-conj id) (update :queue queue-conj id)
(update :index assoc id commit))))) (update :index assoc id commit)))))
ptk/WatchEvent ptk/WatchEvent
(watch [_ state _] (watch [_ state _]
(let [pstate (:persistence state)] (let [pstate (:persistence state)]
(when (= run-id (:run-id pstate)) (when (and (not= :error (:status pstate))
(rx/of (run-persistence-task) (= run-id (:run-id pstate)))
(update-status :saving)))))))) (rx/of (update-status :saving)
(run-persistence-task))))))))
(defn- discard-persistence-state (defn- persistence-failed
[] [commit-id cause]
(ptk/reify ::discard-persistence-state (ptk/reify ::persistence-failed
ptk/UpdateEvent ptk/UpdateEvent
(update [_ state] (update [_ state]
(dissoc state :persistence)))) (let [data (ex-data cause)]
(update state :persistence
(fn [pstate]
(-> pstate
(assoc :status :error
:error (assoc data
:type :persistence
:code (:code data :save-failed)
:cause-type (:type data)
:commit-id commit-id
:hint (ex-message cause)
::errors/handled? true))
(dissoc :run-id :last-progress-at :stall-reported?))))))
ptk/WatchEvent
(watch [_ _ _]
(rx/of (ptk/data-event ::error cause)))
ptk/EffectEvent
(effect [_ _ _]
;; Report without invoking global handlers that may reload the file or
;; navigate away before the user can recover the retained changes.
(errors/flash-persistence cause))))
(defn- commit-persisted
[commit]
(ptk/reify ::commit-persisted
IDeref
(-deref [_] commit)
ptk/UpdateEvent
(update [_ state]
;; Keep the acknowledgment even if the queue runner has stopped.
(d/update-in-when state [:persistence :index (:id commit)]
assoc ::acknowledged? true))))
(defn- update-file-request
"Issues the `update-file` request, tracked as active for its lifetime."
[request-id params]
(rx/create
(fn [subscriber]
(swap! active-requests conj request-id)
(let [source (try
(rp/cmd! :update-file params)
(catch :default cause
(rx/throw cause)))
subscription (.subscribe source subscriber)]
(fn []
(swap! active-requests disj request-id)
(rx/dispose! subscription))))))
(defn- attempt-state
"Classifies what should happen with a queued commit before sending it.
The attempt stamp and the send decision both read this, so a commit is
only ever stamped with a request that is actually going to be sent."
[state commit-id request-id]
(let [commit (dm/get-in state [:persistence :index commit-id])]
(cond
(= :error (dm/get-in state [:persistence :status])) :halted
(nil? commit) :missing-commit
(::acknowledged? commit) :acknowledged
(contains? @active-requests (::request-id commit)) :in-flight
(and (::request-id commit)
(not= request-id (::request-id commit))) :unknown-outcome
(not (dm/get-in state [:permissions :can-edit])) :permission-denied
:else :ready)))
(defn- send-queued-commit
"Sends one queued commit and maps its outcome to persistence events."
[request-id session-id {:keys [id file-id file-revn file-vern changes features] :as commit}]
(let [params {:id file-id
:revn (max file-revn (get @revn-data file-id 0))
:vern file-vern
:session-id session-id
:origin (:origin commit)
:created-at (:created-at commit)
:commit-id id
:changes (vec changes)
:features features}]
;; UI read-only mode does not invalidate already queued edits.
(->> (update-file-request request-id params)
(rx/take 1)
;; A response that carries no revision, including one that never
;; arrived, is treated as a failed save rather than a saved file.
(rx/if-empty nil)
(rx/mapcat (fn [{:keys [revn]}]
(if (and (int? revn) (<= 0 revn))
(rx/of (update-file-revn file-id revn)
(commit-persisted commit))
(rx/throw (ex-info "The save response has no valid revision"
{:type :persistence
:code :invalid-save-response
:file-id file-id})))))
(rx/catch (fn [cause]
(rx/of (persistence-failed id cause)))))))
(defn- persist-commit (defn- persist-commit
[commit-id] [commit-id]
(ptk/reify ::persist-commit (let [request-id (uuid/next)]
ptk/WatchEvent (ptk/reify ::persist-commit
(watch [_ state _] ptk/UpdateEvent
(log/dbg :hint "persist-commit" :commit-id (dm/str commit-id)) (update [_ state]
(when-let [{:keys [file-id file-revn file-vern changes features] :as commit} (dm/get-in state [:persistence :index commit-id])] (if (= :ready (attempt-state state commit-id request-id))
(let [sid (:session-id state) ;; Record the attempt before starting I/O. An interrupted request
revn (max file-revn (get @revn-data file-id 0)) ;; may have reached the server and must not be replayed blindly.
params {:id file-id (assoc-in state [:persistence :index commit-id ::request-id] request-id)
:revn revn state))
:vern file-vern
:session-id sid
:origin (:origin commit)
:created-at (:created-at commit)
:commit-id commit-id
:changes (vec changes)
:features features}
permissions (:permissions state)]
;; Prevent saving changes when in version preview (read-only) mode ptk/WatchEvent
;; or when the user does not have edition permission. (watch [_ state _]
(when (and (:can-edit permissions) (let [commit (dm/get-in state [:persistence :index commit-id])
(not (get-in state [:workspace-global :read-only?]))) fail (fn [code hint]
(->> (rp/cmd! :update-file params) (rx/of (persistence-failed commit-id
(rx/mapcat (fn [{:keys [revn lagged] :as response}] (ex-info hint {:type :persistence
(log/debug :hint "changes persisted" :commit-id (dm/str commit-id) :lagged (count lagged)) :code code
(rx/of (ptk/data-event ::commit-persisted commit) :commit-id commit-id
(update-file-revn file-id revn)))) :file-id (:file-id commit)}))))]
(case (attempt-state state commit-id request-id)
(rx/catch (fn [cause] :halted (rx/empty)
(rx/concat :missing-commit (fail :missing-commit "A queued save has no change data")
(if (= :authentication (:type cause)) :acknowledged (rx/of (commit-persisted commit))
(rx/empty) ;; The replacement runner listens for the original request's result.
(rx/of (ptk/data-event ::error cause) :in-flight (rx/empty)
(update-status :error))) ;; Even :network and :offline do not prove that the server
(rx/of (discard-persistence-state)) ;; skipped the write. Keep the attempt stamp to prevent replay.
(rx/throw cause))))))))))) :unknown-outcome (fail :save-outcome-unknown "An interrupted save has an unknown outcome")
:permission-denied (fail :save-permission-denied "Edit permission was lost before changes could be saved")
:ready (send-queued-commit request-id (:session-id state) commit)))))))
(defn- run-persistence-task (defn- run-persistence-task
@ -168,14 +336,18 @@
ptk/WatchEvent ptk/WatchEvent
(watch [_ state stream] (watch [_ state stream]
(let [queue (-> state :persistence :queue)] (let [queue (-> state :persistence :queue)]
(if-let [commit-id (peek queue)] (cond
(let [stoper-s (rx/merge (= :error (dm/get-in state [:persistence :status]))
(rx/empty)
(seq queue)
(let [commit-id (peek queue)
stoper-s (rx/merge
(rx/filter (ptk/type? ::run-persistence-task) stream) (rx/filter (ptk/type? ::run-persistence-task) stream)
(rx/filter (ptk/type? ::error) stream))] (rx/filter (ptk/type? ::error) stream))]
(log/dbg :hint "run-persistence-task" :commit-id (dm/str commit-id)) (log/dbg :hint "run-persistence-task" :commit-id (dm/str commit-id))
(->> (rx/merge (->> (rx/merge
(rx/of (persist-commit commit-id))
(->> stream (->> stream
(rx/filter (ptk/type? ::commit-persisted)) (rx/filter (ptk/type? ::commit-persisted))
(rx/map deref) (rx/map deref)
@ -183,8 +355,47 @@
(rx/take 1) (rx/take 1)
(rx/mapcat (fn [_] (rx/mapcat (fn [_]
(rx/of (discard-commit commit-id) (rx/of (discard-commit commit-id)
(run-persistence-task)))))) (run-persistence-task)))))
(rx/of (persist-commit commit-id)))
(rx/take-until stoper-s))) (rx/take-until stoper-s)))
:else
(rx/of (update-status :saved)))))))
(defn- resume-persistence
[]
(ptk/reify ::resume-persistence
ptk/UpdateEvent
(update [_ state]
(update state :persistence
(fn [pstate]
(-> pstate
(dissoc :error)
(assoc :run-id (uuid/next) :status :saving)
(update :last-progress-at d/nilv (inst-ms (ct/now)))))))
ptk/WatchEvent
(watch [_ _ _]
(rx/of (run-persistence-task)))))
(defn- recover-persistence
[]
(ptk/reify ::recover-persistence
ptk/WatchEvent
(watch [_ state _]
(let [{:keys [queue index status error run-id]} (:persistence state)
commit (get index (peek queue))]
(cond
(and (seq queue)
(or (not= status :error)
(and (= :save-permission-denied (:code error))
(not (::request-id commit))
(= (:file-id commit) (:current-file-id state))
(dm/get-in state [:permissions :can-edit]))))
(rx/of (resume-persistence))
(and (empty? queue)
(not= status :error)
(or run-id (#{:pending :saving} status)))
(rx/of (update-status :saved))))))) (rx/of (update-status :saved)))))))
(def ^:private xf-mapcat-undo (def ^:private xf-mapcat-undo
@ -229,8 +440,21 @@
(rx/filter #(= % ::force-persist))))] (rx/filter #(= % ::force-persist))))]
(rx/merge (rx/merge
(rx/of (recover-persistence))
(->> stream
(rx/filter #(or (ptk/type? ::dc/change-team-role %)
(ptk/type? ::dw/workspace-initialized %)))
(rx/map (fn [_] (recover-persistence)))
(rx/take-until stoper-s))
(->> (rx/interval saving-check-interval-ms)
(rx/map (fn [_] (check-persistence)))
(rx/take-until stoper-s))
(->> notifier-s (->> notifier-s
(rx/map #(ptk/data-event ::persistence-notification))) (rx/map #(ptk/data-event ::persistence-notification))
(rx/take-until stoper-s))
(->> local-commits-s (->> local-commits-s
(rx/debounce 200) (rx/debounce 200)
@ -242,10 +466,10 @@
;; chunks (very near in time commits) and append them to the ;; chunks (very near in time commits) and append them to the
;; persistence queue ;; persistence queue
(->> local-commits-s (->> local-commits-s
(rx/take-until stoper-s)
(rx/buffer-until notifier-s) (rx/buffer-until notifier-s)
(rx/mapcat merge-commit) (rx/mapcat merge-commit)
(rx/map append-commit) (rx/map append-commit)
(rx/take-until (rx/delay 100 stoper-s))
(rx/finalize (fn [] (rx/finalize (fn []
(log/debug :hint "finalize persistence: changes watcher")))) (log/debug :hint "finalize persistence: changes watcher"))))

View File

@ -604,12 +604,11 @@
:workspace-editor-state :workspace-editor-state
:workspace-wasm-editor-styles :workspace-wasm-editor-styles
:workspace-media-objects :workspace-media-objects
:workspace-persistence
:workspace-presence :workspace-presence
:workspace-tokens :workspace-tokens
:workspace-undo :workspace-undo
:workspace-versions) :workspace-versions)
(update :workspace-global dissoc :read-only? :default-font) (update :workspace-global dissoc :read-only? :preview-id :default-font)
(assoc-in [:workspace-global :options-mode] :design) (assoc-in [:workspace-global :options-mode] :design)
(update :files d/update-vals #(dissoc % :data)))) (update :files d/update-vals #(dissoc % :data))))

View File

@ -72,7 +72,7 @@
(rx/of ::dwp/force-persist (rx/of ::dwp/force-persist
(ev/event {::ev/name "create-version"})) (ev/event {::ev/name "create-version"}))
(->> (dwp/wait-persisted) (->> (dwp/wait-persisted-or-error)
(rx/mapcat #(rp/cmd! :create-file-snapshot {:file-id file-id :label label})) (rx/mapcat #(rp/cmd! :create-file-snapshot {:file-id file-id :label label}))
(rx/mapcat (rx/mapcat
(fn [{:keys [id]}] (fn [{:keys [id]}]
@ -95,6 +95,15 @@
(->> (rp/cmd! :update-file-snapshot {:id id :label label}) (->> (rp/cmd! :update-file-snapshot {:id id :label label})
(rx/map fetch-versions))))))) (rx/map fetch-versions)))))))
(defn- clear-preview-state
[]
(ptk/reify ::clear-preview-state
ptk/UpdateEvent
(update [_ state]
(-> state
(update :workspace-versions dissoc :backup)
(update :workspace-global dissoc :read-only? :preview-id)))))
(defn- initialize-version (defn- initialize-version
[] []
(ptk/reify ::initialize-version (ptk/reify ::initialize-version
@ -108,7 +117,10 @@
(->> stream (->> stream
(rx/filter (ptk/type? ::dw/bundle-fetched)) (rx/filter (ptk/type? ::dw/bundle-fetched))
(rx/take 1) (rx/take 1)
(rx/map #(dwpg/initialize-page file-id page-id))) ;; Keep historical content read-only until the restored file
;; has loaded, including when saving or loading fails.
(rx/mapcat #(rx/of (clear-preview-state)
(dwpg/initialize-page file-id page-id))))
(rx/of (ntf/hide :tag :restore-dialog) (rx/of (ntf/hide :tag :restore-dialog)
(dw/initialize-file team-id file-id))))) (dw/initialize-file team-id file-id)))))
@ -200,12 +212,6 @@
[id] [id]
(assert (uuid? id) "expected valid uuid for `id`") (assert (uuid? id) "expected valid uuid for `id`")
(ptk/reify ::restore-version (ptk/reify ::restore-version
ptk/UpdateEvent
(update [_ state]
;; Clear preview state if we're restoring from preview mode
(-> state
(update :workspace-versions dissoc :backup)
(update :workspace-global dissoc :read-only? :preview-id)))
ptk/WatchEvent ptk/WatchEvent
(watch [_ state _] (watch [_ state _]
(let [file-id (:current-file-id state)] (let [file-id (:current-file-id state)]
@ -213,9 +219,12 @@
(rx/of ::dwp/force-persist (rx/of ::dwp/force-persist
(dw/remove-layout-flag :document-history)) (dw/remove-layout-flag :document-history))
(->> (dwp/wait-persisted) (->> (dwp/wait-persisted-or-error)
(rx/mapcat #(rp/cmd! :restore-file-snapshot {:file-id file-id :id id})) (rx/mapcat #(rp/cmd! :restore-file-snapshot {:file-id file-id :id id}))
(rx/map #(initialize-version)))))))) (rx/map #(initialize-version))
(rx/catch (fn [cause]
(rx/concat (rx/of (exit-preview))
(rx/throw cause))))))))))
(defn enter-restore (defn enter-restore
[id] [id]
@ -356,7 +365,7 @@
(rx/of ::dwp/force-persist)) (rx/of ::dwp/force-persist))
(->> (if (= file-id current-file-id) (->> (if (= file-id current-file-id)
(dwp/wait-persisted) (dwp/wait-persisted-or-error)
(rx/of :nothing)) (rx/of :nothing))
(rx/mapcat (rx/mapcat
(fn [_] (fn [_]
@ -386,7 +395,7 @@
::ev/origin "plugins"}) ::ev/origin "plugins"})
::dwp/force-persist) ::dwp/force-persist)
(->> (dwp/wait-persisted) (->> (dwp/wait-persisted-or-error)
(rx/mapcat #(rp/cmd! :restore-file-snapshot {:file-id file-id :id id})) (rx/mapcat #(rp/cmd! :restore-file-snapshot {:file-id file-id :id id}))
(rx/map #(initialize-version))) (rx/map #(initialize-version)))
@ -394,10 +403,11 @@
(rx/tap resolve) (rx/tap resolve)
(rx/ignore))) (rx/ignore)))
;; On error reject the promise and empty the stream ;; Restore the live file before rejecting the plugin promise.
(rx/catch (fn [error] (rx/catch (fn [error]
(reject error) (rx/concat
(rx/empty))))))) (rx/of (exit-preview))
(->> (rx/of error)
(rx/observe-on :async)
(rx/tap reject)
(rx/ignore)))))))))

View File

@ -172,6 +172,7 @@
(defn flash (defn flash
"Show error notification banner and emit error report. "Show error notification banner and emit error report.
A nil timeout keeps the notification visible until dismissed or replaced.
The notification is scheduled asynchronously (via tm/schedule) to The notification is scheduled asynchronously (via tm/schedule) to
avoid pushing a new event into the potok store while the store's own avoid pushing a new event into the potok store while the store's own
@ -179,7 +180,7 @@
synchronously from inside an error handler creates a re-entrant synchronously from inside an error handler creates a re-entrant
event-processing cycle that can exhaust the JS call stack event-processing cycle that can exhaust the JS call stack
(RangeError: Maximum call stack size exceeded)." (RangeError: Maximum call stack size exceeded)."
[& {:keys [type hint cause] :or {type :handled}}] [& {:keys [type hint cause timeout] :or {type :handled timeout 5000}}]
(when (ex/exception? cause) (when (ex/exception? cause)
(when-let [event-name (case type (when-let [event-name (case type
:handled "handled-exception" :handled "handled-exception"
@ -195,7 +196,7 @@
(ntf/show {:content (or ^boolean hint (tr "errors.generic")) (ntf/show {:content (or ^boolean hint (tr "errors.generic"))
:type :toast :type :toast
:level :error :level :error
:timeout 5000})))) :timeout timeout}))))
(defmethod ptk/handle-error :network (defmethod ptk/handle-error :network
[error] [error]
@ -206,6 +207,21 @@
(ex/print-throwable cause :prefix "Network Error")) (ex/print-throwable cause :prefix "Network Error"))
(flash :cause (::instance error) :type :handled)) (flash :cause (::instance error) :type :handled))
(defn flash-persistence
[cause]
(let [{:keys [type cause-type]} (ex-data cause)]
;; Authentication has its own UI. `flash :silent` only skips reporting;
;; it still shows a toast, so do not call it for these failures.
(when-not (or (= :authentication type) (= :authentication cause-type))
(flash :cause cause :type :handled :timeout nil :hint (tr "errors.save-failed")))))
(defmethod ptk/handle-error :persistence
[error]
;; The persistence failure event reports the original cause. Waiters still
;; reject, but must not report that same incident again.
(when-not (::handled? error)
(flash-persistence (::instance error))))
(defmethod ptk/handle-error :internal (defmethod ptk/handle-error :internal
[error] [error]
(st/emit! (rt/assign-exception error)) (st/emit! (rt/assign-exception error))

View File

@ -669,9 +669,6 @@
(def updating-library (def updating-library
(l/derived :updating-library st/state)) (l/derived :updating-library st/state))
(def persistence-state
(l/derived (comp :status :persistence) st/state))
(def progress (def progress
(l/derived :progress st/state)) (l/derived :progress st/state))

View File

@ -34,8 +34,9 @@
persistence persistence
(mf/deref refs/persistence) (mf/deref refs/persistence)
;; Nothing queued to save means the file is up to date.
persistence-status persistence-status
(get persistence :status) (or (:status persistence) :saved)
editing* (mf/use-state false) editing* (mf/use-state false)
editing? (deref editing*) editing? (deref editing*)
@ -109,7 +110,7 @@
{:class (stl/css :file-name) {:class (stl/css :file-name)
:title file-name :title file-name
:on-double-click start-editing-name} :on-double-click start-editing-name}
;;-- Persistende state widget ;; Persistence state widget
[:div {:class (case persistence-status [:div {:class (case persistence-status
:pending (stl/css :status-notification :pending-status) :pending (stl/css :status-notification :pending-status)
:saving (stl/css :status-notification :saving-status) :saving (stl/css :status-notification :saving-status)

View File

@ -103,7 +103,6 @@
border-radius: 50%; border-radius: 50%;
margin-right: deprecated.$s-4; margin-right: deprecated.$s-4;
flex-shrink: 0; flex-shrink: 0;
background-color: var(--status-widget-background-color-pending);
&.pending-status { &.pending-status {
background-color: var(--status-widget-background-color-warning); background-color: var(--status-widget-background-color-warning);

View File

@ -0,0 +1,378 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns frontend-tests.data.persistence-test
(:require
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.main.data.changes :as dch]
[app.main.data.persistence :as dps]
[app.main.data.render-wasm :as drw]
[app.main.errors :as errors]
[app.main.repo :as rp]
[app.main.store :as st]
[app.util.i18n :as i18n]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.mock :as mock]
[potok.v2.core :as ptk]))
(defn- local-commit
[file-id]
(ptk/data-event ::dch/commit
{:id (uuid/next)
:file-id file-id
:file-revn 0
:file-vern 0
:source :local
:features #{}
:redo-changes [{:type :mod-page :id (uuid/next) :name "Edited"}]
:undo-changes []}))
(t/deftest queued-edits-save-during-temporary-read-only-mode
(doseq [read-only-event [(drw/context-lost)
#(assoc-in % [:workspace-global :read-only?] true)
#(assoc % :workspace-global {:read-only? true
:preview-id (uuid/next)})]]
(let [file-id (uuid/next)
response (rx/subject)
errors (atom [])
store (ptk/store {:state {:permissions {:can-edit true}
:files {file-id {:id file-id :revn 0}}}
:on-error #(swap! errors conj %)})]
(with-redefs [rp/cmd! (mock/stub (fn [_ _] (rx/take 1 response)))]
(try
(ptk/emit! store (dps/initialize-persistence)
(local-commit file-id)
read-only-event
::dps/force-persist)
(rx/push! response {:revn 1})
(t/is (= :saved (get-in @store [:persistence :status])))
(t/is (empty? (get-in @store [:persistence :queue])))
(ptk/emit! store (drw/context-restored)
#(assoc-in % [:workspace-global :read-only?] false)
(local-commit file-id)
::dps/force-persist)
(rx/push! response {:revn 2})
(t/is (= :saved (get-in @store [:persistence :status])))
(t/is (empty? (get-in @store [:persistence :queue])))
(t/is (empty? @errors))
(finally
(rx/dispose! store)
(rx/end! response)))))))
(t/deftest historical-preview-cannot-create-local-commits
(let [file-id (uuid/next)
output (atom [])
state {:current-file-id file-id
:permissions {:can-edit true}
:files {file-id {:id file-id :revn 0 :vern 0}}
:workspace-global {:read-only? true :preview-id (uuid/next)}}
event (dch/commit-changes {:redo-changes [] :undo-changes []})]
(when-let [result (ptk/watch event state (rx/empty))]
(->> result (rx/subs! #(swap! output conj %))))
(t/is (empty? @output))))
(defn- with-watchdog
[f]
(let [clock (atom 0)
ticks (rx/subject)
response (rx/subject)
reports (atom [])
causes (atom [])
render errors/generate-report
file-id (uuid/next)
store (ptk/store {:state {:current-file-id file-id
:permissions {:can-edit true}
:files {file-id {:id file-id :revn 0}}}
:on-error #(t/is false (str %))})]
(with-redefs [ct/now (mock/stub #(ct/inst @clock))
rx/interval (mock/stub (fn [_] ticks))
rp/cmd! (mock/stub (fn [_ _] (rx/take 1 response)))
st/state store
errors/generate-report (fn [cause]
(swap! causes conj cause)
(render cause))
errors/submit-report (fn [& params]
(swap! reports conj (apply hash-map params)))]
(try
(ptk/emit! store (dps/initialize-persistence))
(f {:clock clock :ticks ticks :response response :causes causes
:reports reports :store store :file-id file-id})
(finally
(rx/dispose! store)
(rx/end! ticks)
(rx/end! response))))))
(t/deftest stalled-request-is-reported-once-without-discarding-edits
(with-watchdog
(fn [{:keys [clock ticks reports causes store file-id]}]
(ptk/emit! store (local-commit file-id) ::dps/force-persist)
(reset! clock 300000)
(rx/push! ticks :tick)
(t/is (empty? @reports) "Five minutes must elapse before reporting")
;; More local edits must not reset the stalled request's clock.
(reset! clock 300001)
(ptk/emit! store (drw/context-lost)
(local-commit file-id) ::dps/force-persist)
(rx/push! ticks :tick)
(t/is (= 1 (count @reports)))
(t/is (= "handled-exception" (:event-name (first @reports))))
(let [data (ex-data (first @causes))]
(t/is (= :saving-stalled (:code data)))
(t/is (= file-id (:file-id data)))
(t/is (true? (:render-context-lost? data))))
(reset! clock 900000)
(rx/push! ticks :tick)
(t/is (= 1 (count @reports)) "Do not repeat a report for the same stall")
(t/is (= :saving (get-in @store [:persistence :status])))
(t/is (= 2 (count (get-in @store [:persistence :queue])))))))
(t/deftest successful-saves-reset-the-stall-clock-and-allow-a-new-report
(with-watchdog
(fn [{:keys [clock ticks response reports store file-id]}]
(ptk/emit! store (local-commit file-id) ::dps/force-persist
(local-commit file-id) ::dps/force-persist)
(reset! clock 290000)
(rx/push! response {:revn 1})
(reset! clock 300001)
(rx/push! ticks :tick)
(t/is (empty? @reports) "The queue is making progress")
(reset! clock 590001)
(rx/push! ticks :tick)
(t/is (= 1 (count @reports)) "The second request has now stalled")
(rx/push! response {:revn 2})
(t/is (= :saved (get-in @store [:persistence :status])))
(reset! clock 1000000)
(rx/push! ticks :tick)
(t/is (= 1 (count @reports)) "A saved file must not be reported")
(ptk/emit! store (local-commit file-id) ::dps/force-persist)
(reset! clock 1300001)
(rx/push! ticks :tick)
(t/is (= 2 (count @reports)) "A later stall gets its own report"))))
(t/deftest pending-edits-are-monitored-without-extending-the-deadline
(with-watchdog
(fn [{:keys [clock ticks reports store]}]
(rx/push! ticks :tick)
(t/is (empty? @reports) "An idle file must not be reported")
(ptk/emit! store (#'dps/update-status :pending))
(reset! clock 300001)
(ptk/emit! store (#'dps/update-status :pending))
(rx/push! ticks :tick)
(t/is (= 1 (count @reports)))
(ptk/emit! store (#'dps/update-status :error))
(reset! clock 900000)
(rx/push! ticks :tick)
(t/is (= 1 (count @reports)) "Do not report an already failed save"))))
(t/deftest reinitializing-persistence-replaces-the-watchdog
(let [active-timers (atom 0)
ticks (rx/subject)
store (ptk/store {:state {} :on-error #(t/is false (str %))})]
(with-redefs [rx/interval (mock/stub
(fn [_]
(rx/create
(fn [subscriber]
(swap! active-timers inc)
(let [subscription (.subscribe ticks subscriber)]
(fn []
(rx/dispose! subscription)
(swap! active-timers dec)))))))]
(try
(ptk/emit! store (dps/initialize-persistence))
(t/is (= 1 @active-timers))
(ptk/emit! store (dps/initialize-persistence))
(t/is (= 1 @active-timers))
(finally
(rx/dispose! store)
(rx/end! ticks))))
(t/is (zero? @active-timers))))
(defn- with-persistence
[f]
(let [file-id (uuid/next)
response (rx/subject)
failures (atom [])
requests (atom [])
store (ptk/store {:state {:current-file-id file-id
:permissions {:can-edit true}
:files {file-id {:id file-id :revn 0}}}
:on-error #(t/is false (str %))})]
(with-redefs [rp/cmd! (mock/stub (fn [cmd params]
(swap! requests conj [cmd params])
(rx/take 1 response)))
errors/flash (fn [& {:keys [cause]}]
(swap! failures conj cause))]
(try
(ptk/emit! store (dps/initialize-persistence))
(f {:file-id file-id :response response :failures failures
:requests requests :store store})
(finally
(rx/dispose! store)
(rx/end! response))))))
(t/deftest permission-loss-fails-without-discarding-queued-edits
(with-persistence
(fn [{:keys [file-id requests failures store]}]
(ptk/emit! store (local-commit file-id)
#(assoc-in % [:permissions :can-edit] false)
::dps/force-persist)
(t/is (= :error (get-in @store [:persistence :status])))
(t/is (= 1 (count (get-in @store [:persistence :queue]))))
(t/is (empty? @requests))
(t/is (= 1 (count @failures)))
(ptk/emit! store (local-commit file-id) ::dps/force-persist
(#'dps/update-status :pending))
(t/is (= :error (get-in @store [:persistence :status])))
(t/is (= 2 (count (get-in @store [:persistence :queue])))))))
(t/deftest failed-request-retains-the-queue-and-is-not-retried-on-initialization
(with-persistence
(fn [{:keys [file-id response requests store]}]
(ptk/emit! store (local-commit file-id) ::dps/force-persist
(local-commit file-id) ::dps/force-persist)
(.error response (ex-info "Connection lost" {:type :network}))
(ptk/emit! store (dps/initialize-persistence))
(t/is (= :error (get-in @store [:persistence :status])))
(t/is (= 2 (count (get-in @store [:persistence :queue]))))
(t/is (= 1 (count @requests))))))
(t/deftest save-failures-use-a-translated-warning-except-for-authentication
(doseq [cause-type [:network :offline :authentication]]
(with-persistence
(fn [{:keys [file-id response store]}]
(let [notifications (atom [])]
(with-redefs [errors/flash (fn [& params]
(swap! notifications conj (apply hash-map params)))
i18n/tr (mock/stub #(str "translated:" %))]
(ptk/emit! store (local-commit file-id) ::dps/force-persist)
(.error response (ex-info "Raw transport details" {:type cause-type}))
(t/is (= :error (get-in @store [:persistence :status])))
(let [data (get-in @store [:persistence :error])]
(ptk/handle-error (assoc data ::errors/instance (ex-info "Save failed" data))))
(if (= cause-type :authentication)
(t/is (empty? @notifications))
(t/is (= ["translated:errors.save-failed"]
(mapv :hint @notifications))))))))))
(t/deftest missing-commit-is-an-error-instead-of-skipping-changes
(with-persistence
(fn [{:keys [requests store]}]
(let [id (uuid/next)]
(ptk/emit! store
#(assoc % :persistence {:queue (conj #queue [] id)
:index {} :run-id (uuid/next)
:status :saving})
(dps/initialize-persistence))
(t/is (= :error (get-in @store [:persistence :status])))
(t/is (= [id] (vec (get-in @store [:persistence :queue]))))
(t/is (empty? @requests))))))
(t/deftest initialization-recovers-an-unsent-commit-with-a-dangling-run-id
(with-persistence
(fn [{:keys [file-id response requests store]}]
(let [commit (assoc @(local-commit file-id) :changes [])
id (:id commit)]
(ptk/emit! store
#(assoc % :persistence {:queue (conj #queue [] id)
:index {id commit} :run-id (uuid/next)
:status :saving})
(dps/initialize-persistence))
(t/is (= 1 (count @requests)))
(rx/push! response {:revn 1})
(t/is (= :saved (get-in @store [:persistence :status])))
(t/is (empty? (get-in @store [:persistence :queue])))))))
(t/deftest an-active-request-is-never-sent-twice
(doseq [interrupt [[(dps/initialize-persistence)]
[(ptk/data-event ::dps/error)]]]
(with-persistence
(fn [{:keys [file-id response requests store]}]
(apply ptk/emit! store (local-commit file-id) ::dps/force-persist interrupt)
(ptk/emit! store (dps/initialize-persistence))
(t/is (= 1 (count @requests)))
(rx/push! response {:revn 1})
(t/is (= :saved (get-in @store [:persistence :status])))
(t/is (empty? (get-in @store [:persistence :queue])))))))
(t/deftest permission-restoration-resumes-only-unsent-edits
(with-persistence
(fn [{:keys [file-id response requests store]}]
(ptk/emit! store (local-commit file-id) ::dps/force-persist
(local-commit file-id) ::dps/force-persist
#(assoc-in % [:permissions :can-edit] false))
(rx/push! response {:revn 1})
(t/is (= :error (get-in @store [:persistence :status])))
(t/is (= 1 (count (get-in @store [:persistence :queue]))))
(ptk/emit! store
#(assoc-in % [:permissions :can-edit] true)
(ptk/data-event :app.main.data.common/change-team-role))
(t/is (= 2 (count @requests)))
(rx/push! response {:revn 2})
(t/is (= :saved (get-in @store [:persistence :status])))
(t/is (empty? (get-in @store [:persistence :queue]))))))
(t/deftest recovery-keeps-an-acknowledgment-received-without-a-runner
(with-persistence
(fn [{:keys [file-id response requests store]}]
(ptk/emit! store (local-commit file-id) ::dps/force-persist
(ptk/data-event ::dps/error))
(rx/push! response {:revn 1})
(t/is (= 1 (count (get-in @store [:persistence :queue]))))
(ptk/emit! store (dps/initialize-persistence))
(t/is (= 1 (count @requests)) "The acknowledged changes must not be sent again")
(t/is (= :saved (get-in @store [:persistence :status])))
(t/is (empty? (get-in @store [:persistence :queue]))))))
(t/deftest recovery-reports-an-unknown-request-outcome-without-replaying-it
(with-persistence
(fn [{:keys [file-id requests store]}]
(let [commit (assoc @(local-commit file-id) ::dps/request-id (uuid/next))
id (:id commit)]
(ptk/emit! store
#(assoc % :persistence {:queue (conj #queue [] id)
:index {id commit} :status :saving})
(dps/initialize-persistence))
(t/is (= :error (get-in @store [:persistence :status])))
(t/is (= :save-outcome-unknown (get-in @store [:persistence :error :code])))
(t/is (= [id] (vec (get-in @store [:persistence :queue]))))
(t/is (empty? @requests))))))
(t/deftest initialization-flushes-buffered-edits-without-duplicating-them
(with-persistence
(fn [{:keys [file-id response requests store]}]
(ptk/emit! store (local-commit file-id)
(dps/initialize-persistence)
::dps/force-persist)
(t/is (= 1 (count @requests)))
(t/is (= 1 (count (get-in @store [:persistence :queue]))))
(rx/push! response {:revn 1})
(t/is (= :saved (get-in @store [:persistence :status]))))))
(t/deftest synchronous-save-results-do-not-leave-a-dangling-runner
(with-persistence
(fn [{:keys [file-id store]}]
(with-redefs [rp/cmd! (mock/stub (fn [_ _] (rx/of {:revn 1})))]
(ptk/emit! store (local-commit file-id) ::dps/force-persist)
(t/is (= :saved (get-in @store [:persistence :status])))
(t/is (empty? (get-in @store [:persistence :queue])))))))
(t/deftest empty-or-invalid-save-responses-preserve-the-queue-as-failed
(doseq [result [(rx/empty) (rx/of nil) (rx/of {:revn -1})]]
(with-persistence
(fn [{:keys [file-id store]}]
(with-redefs [rp/cmd! (mock/stub (fn [_ _] result))]
(ptk/emit! store (local-commit file-id) ::dps/force-persist)
(t/is (= :error (get-in @store [:persistence :status])))
(t/is (= :invalid-save-response (get-in @store [:persistence :error :code])))
(t/is (= 1 (count (get-in @store [:persistence :queue])))))))))

View File

@ -0,0 +1,189 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns frontend-tests.data.workspace-versions-test
(:require
[app.common.uuid :as uuid]
[app.main.data.changes :as dch]
[app.main.data.persistence :as dps]
[app.main.data.workspace :as dw]
[app.main.data.workspace.pages :as dwpg]
[app.main.data.workspace.versions :as versions]
[app.main.refs :as refs]
[app.main.repo :as rp]
[app.util.timers :as tm]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.mock :as mock]
[potok.v2.core :as ptk]))
(t/deftest version-creation-rejects-save-failure-and-timeout
(doseq [failure [:error :timeout]
operation [:create :plugin-create]]
(t/testing (str operation " after " failure)
(let [file-id (uuid/next)
resolved (atom [])
rejected (atom [])
requests (atom [])
state {:current-file-id file-id}
event (case operation
:create (versions/create-version)
:plugin-create (versions/create-version-from-plugins
file-id "Version"
#(swap! resolved conj %) #(swap! rejected conj %)))]
(with-redefs [refs/persistence (atom {:status (if (= failure :error) :error :saving)
:error {:type :persistence :code :save-permission-denied}})
rp/cmd! (mock/stub (fn [cmd _]
(swap! requests conj cmd)
(rx/of nil)))
rx/timeout (mock/stub (fn [_ fallback source]
(if (= failure :timeout) fallback source)))]
(->> (ptk/watch event state (rx/empty))
(rx/subs! (fn [_]) #(swap! rejected conj %)))
(t/is (empty? @requests) "Do not create a version without saving")
(t/is (empty? @resolved) "Do not resolve the plugin promise on failure")
(t/is (= 1 (count @rejected)))
(t/is (= (if (= failure :error) :save-permission-denied :save-timeout)
(:code (ex-data (first @rejected))))))))))
(t/deftest leaving-the-workspace-clears-the-preview-flags
(let [team-id (uuid/next)
file-id (uuid/next)
other-id (uuid/next)
output (atom [])
state (-> {:current-file-id file-id
:current-team-id team-id
:permissions {:can-edit true}
:files {file-id {:id file-id :revn 0 :vern 0}}
:workspace-global {:read-only? true :preview-id (uuid/next)}}
(as-> $ (ptk/update (dw/finalize-workspace team-id file-id) $))
(assoc :current-file-id other-id)
(assoc-in [:files other-id] {:id other-id :revn 0 :vern 0}))]
(t/is (nil? (get-in state [:workspace-global :preview-id])))
(when-let [result (ptk/watch (dch/commit-changes {:redo-changes [] :undo-changes []})
state (rx/empty))]
(->> result (rx/subs! #(swap! output conj %))))
(t/is (= 1 (count @output))
"Editing must work again on the next file opened in the tab")))
(t/deftest strict-persistence-wait-rejects-a-real-timeout
(t/async done
(mock/with-mocks
{refs/persistence (atom {:status :saving})}
(fn [finish]
(->> (dps/wait-persisted-or-error 0)
(rx/subs! (fn [_]
(t/is false "The stalled save must not succeed")
(finish))
(fn [cause]
(t/is (= :save-timeout (:code (ex-data cause))))
(finish)))))
done)))
(t/deftest strict-persistence-wait-observes-a-later-failure
(let [pstate (atom {:status :saving})
rejected (atom [])
resolved (atom [])]
(with-redefs [refs/persistence pstate]
(let [subscription (->> (dps/wait-persisted-or-error)
(rx/subs! #(swap! resolved conj %) #(swap! rejected conj %)))]
(try
(reset! pstate {:status :error :error {:code :missing-commit}})
(t/is (empty? @resolved))
(t/is (= :missing-commit (:code (ex-data (first @rejected)))))
(finally
(rx/dispose! subscription)))))))
(t/deftest failed-restore-returns-to-the-live-file-before-reporting
;; The plugin promise is rejected on a later tick, so every assertion about
;; the rejection runs once the event loop has drained.
(t/async done
(let [deferred (atom [])]
(doseq [operation [:ui :plugin]
failure [:saving :timeout :restore]
preview? [true false]]
(let [label (str operation " after " failure ", preview=" preview?)
file-id (uuid/next)
version-id (uuid/next)
backup {:id file-id :revn 10}
snapshot {:id file-id :revn 5}
rejected (atom [])
resolved (atom [])
requests (atom [])
initialized (atom [])
at-reject (atom nil)
store (ptk/store {:state (cond-> {:current-file-id file-id
:files {file-id backup}}
preview?
(assoc :files {file-id snapshot}
:workspace-global {:read-only? true :preview-id version-id}
:workspace-versions {:backup backup}))
:on-error #(swap! rejected conj %)})
reject (fn [cause]
(reset! at-reject
{:file (get-in @store [:files file-id])
:preview-id (get-in @store [:workspace-global :preview-id])})
(swap! rejected conj cause))
event (if (= operation :ui)
(#'versions/restore-version version-id)
(versions/restore-version-from-plugin
file-id version-id #(swap! resolved conj %) reject))]
(t/testing label
(with-redefs [refs/persistence (atom {:status (case failure
:saving :error :timeout :saving :saved)})
rp/cmd! (mock/stub (fn [cmd _]
(swap! requests conj cmd)
(rx/throw (ex-info "Restore failed" {:type :internal}))))
rx/timeout (mock/stub (fn [_ fallback source]
(if (= failure :timeout) fallback source)))
dwpg/initialize-page (mock/stub
(fn [_ _]
(fn [state]
(swap! initialized conj (get-in state [:files file-id]))
state)))]
(ptk/emit! store event)
(t/is (empty? @resolved))
(t/is (= (if (= failure :restore) [:restore-file-snapshot] []) @requests)
"Do not restore a version without saving")
(t/is (= backup (get-in @store [:files file-id])))
(t/is (nil? (get-in @store [:workspace-global :read-only?])))
(t/is (nil? (get-in @store [:workspace-global :preview-id])))
(t/is (nil? (get-in @store [:workspace-versions :backup])))
(t/is (= (if preview? [backup] []) @initialized))))
(swap! deferred conj
(fn []
(t/testing label
(t/is (= 1 (count @rejected)))
(when (= operation :plugin)
(t/is (= {:file backup :preview-id nil} @at-reject)
"The live file is back before the plugin promise rejects")))
(rx/dispose! store)))))
(tm/schedule 50 (fn []
(doseq [check @deferred] (check))
(done))))))
(t/deftest successful-restore-clears-preview-only-after-loading
(let [file-id (uuid/next)
state {:current-file-id file-id
:current-team-id (uuid/next)
:current-page-id (uuid/next)
:workspace-global {:read-only? true :preview-id (uuid/next)}
:workspace-versions {:backup {:id file-id}}}
stream (rx/subject)
events (atom [])
cleanup #(filter (ptk/type? ::versions/clear-preview-state) @events)
sub (->> (ptk/watch (#'versions/initialize-version) state stream)
(rx/subs! #(swap! events conj %)))]
(try
(t/is (empty? (cleanup)))
(rx/push! stream (ptk/data-event ::dw/bundle-fetched nil))
(t/is (= 1 (count (cleanup))))
(let [result (ptk/update (first (cleanup)) state)]
(t/is (nil? (get-in result [:workspace-global :read-only?])))
(t/is (nil? (get-in result [:workspace-global :preview-id])))
(t/is (nil? (get-in result [:workspace-versions :backup]))))
(finally
(rx/dispose! sub)))))

View File

@ -15,7 +15,10 @@
- organization SSO recovery expired SSO sessions go back to the provider - organization SSO recovery expired SSO sessions go back to the provider
- invalid-sso-config handler requires :organization-id to promote to :sso-error" - invalid-sso-config handler requires :organization-id to promote to :sso-error"
(:require (:require
[app.common.uuid :as uuid]
[app.main.data.persistence :as dps]
[app.main.errors :as errors] [app.main.errors :as errors]
[app.main.refs :as refs]
[app.main.repo :as rp] [app.main.repo :as rp]
[app.main.router :as rt] [app.main.router :as rt]
[app.main.store :as st] [app.main.store :as st]
@ -396,3 +399,48 @@
(t/is (= :validation (:type assigned))) (t/is (= :validation (:type assigned)))
(t/is (nil? (:organization-id assigned))) (t/is (nil? (:organization-id assigned)))
(t/is (= :invalid-sso-config (:code assigned)))))) (t/is (= :invalid-sso-config (:code assigned))))))
(t/deftest persistence-notifications-do-not-expire-but-other-flashes-do
(doseq [[notify timeout] [[#(errors/flash :hint "Ordinary error") 5000]
[#(errors/flash :hint "Custom error" :timeout 1000) 1000]
[#(errors/flash-persistence nil) nil]]]
(let [scheduled (atom [])
events (atom [])]
(with-redefs [tm/schedule (mock/stub #(swap! scheduled conj %))
st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))]
(notify)
(t/is (empty? @events) "Keep notification delivery asynchronous")
(doseq [callback @scheduled] (callback))
(t/is (= 1 (count @events)))
(let [state (ptk/update (first @events) {})]
(t/is (= timeout (get-in state [:notification :timeout])))
(t/is (= :visible (get-in state [:notification :status]))))))))
(t/deftest persistence-waiters-do-not-report-an-already-handled-failure
(let [reports (atom [])
rejected (atom [])
pstate (atom {:status :saving})
store (ptk/store {:state {} :on-error errors/on-error})
cause (ex-info "Save failed" {:type :network})]
(with-redefs [refs/persistence pstate
errors/submit-report (fn [& params]
(swap! reports conj (apply hash-map params)))
tm/schedule (mock/stub (fn [_]))]
(try
(ptk/emit! store (#'dps/persistence-failed (uuid/next) cause))
(reset! pstate (:persistence @store))
(dotimes [_ 2]
(->> (dps/wait-persisted-or-error)
(rx/subs! (fn [_] (t/is false "A failed save must still reject"))
(fn [error]
(swap! rejected conj error)
(errors/on-error error)))))
(t/is (= 2 (count @rejected)))
(t/is (= 1 (count @reports)))
;; A standalone timeout and a later save failure are new incidents.
(errors/on-error (ex-info "Save timed out" {:type :persistence :code :save-timeout}))
(t/is (= 2 (count @reports)))
(ptk/emit! store (#'dps/persistence-failed (uuid/next) cause))
(t/is (= 3 (count @reports)))
(finally
(rx/dispose! store))))))

View File

@ -12,6 +12,7 @@
[frontend-tests.data.dashboard-test] [frontend-tests.data.dashboard-test]
[frontend-tests.data.exports-assets-test] [frontend-tests.data.exports-assets-test]
[frontend-tests.data.nitrate-test] [frontend-tests.data.nitrate-test]
[frontend-tests.data.persistence-test]
[frontend-tests.data.profile-test] [frontend-tests.data.profile-test]
[frontend-tests.data.repo-test] [frontend-tests.data.repo-test]
[frontend-tests.data.store-test] [frontend-tests.data.store-test]
@ -28,6 +29,7 @@
[frontend-tests.data.workspace-shortcuts-test] [frontend-tests.data.workspace-shortcuts-test]
[frontend-tests.data.workspace-texts-test] [frontend-tests.data.workspace-texts-test]
[frontend-tests.data.workspace-thumbnails-test] [frontend-tests.data.workspace-thumbnails-test]
[frontend-tests.data.workspace-versions-test]
[frontend-tests.errors-test] [frontend-tests.errors-test]
[frontend-tests.fonts-test] [frontend-tests.fonts-test]
[frontend-tests.helpers-shapes-test] [frontend-tests.helpers-shapes-test]
@ -122,6 +124,7 @@
'frontend-tests.data.comments-filters-test 'frontend-tests.data.comments-filters-test
'frontend-tests.data.dashboard-test 'frontend-tests.data.dashboard-test
'frontend-tests.data.nitrate-test 'frontend-tests.data.nitrate-test
'frontend-tests.data.persistence-test
'frontend-tests.data.profile-test 'frontend-tests.data.profile-test
'frontend-tests.data.repo-test 'frontend-tests.data.repo-test
'frontend-tests.data.store-test 'frontend-tests.data.store-test
@ -139,6 +142,7 @@
'frontend-tests.data.workspace-shortcuts-test 'frontend-tests.data.workspace-shortcuts-test
'frontend-tests.data.workspace-texts-test 'frontend-tests.data.workspace-texts-test
'frontend-tests.data.workspace-thumbnails-test 'frontend-tests.data.workspace-thumbnails-test
'frontend-tests.data.workspace-versions-test
'frontend-tests.errors-test 'frontend-tests.errors-test
'frontend-tests.fonts-test 'frontend-tests.fonts-test
'frontend-tests.helpers-shapes-test 'frontend-tests.helpers-shapes-test

View File

@ -1825,6 +1825,13 @@ msgstr "Your profile has emails muted (spam reports or high bounces)."
msgid "errors.registration-disabled" msgid "errors.registration-disabled"
msgstr "The registration is currently disabled." msgstr "The registration is currently disabled."
#: src/app/main/errors.cljs:216
msgid "errors.save-failed"
msgstr ""
"Your latest changes are not confirmed as saved. Saving may not resume "
"automatically. Do not close or reload this tab: you may lose changes. "
"Contact support for help."
#: src/app/main/errors.cljs:337 #: src/app/main/errors.cljs:337
msgid "errors.svg-parser.invalid-svg" msgid "errors.svg-parser.invalid-svg"
msgstr "SVG is invalid or malformed" msgstr "SVG is invalid or malformed"