From b79680eeb70f8fe6776d3df29a8feca2a7b7c1ab Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 25 Aug 2026 13:10:55 +0200 Subject: [PATCH 1/3] :bug: Fix asset 404 for unauthenticated share-link viewers (#11342) PR #11036 added a per-request permission check to the file-media asset endpoints (/assets/by-file-media-id/:id and the /thumbnail variant) using bfc/get-file-permissions. Anonymous share-link viewers were then rejected because they have neither a session nor an access token, the asset URL carries no share context, and the 2-arg get-file-permissions short-circuits to nil when profile-id is nil. Make the asset endpoints share-link aware, mirroring how get-view-only-bundle already authorizes the same scenario: read the share-id from the query string, validate it as a UUID, and call the 3-arg perms/get-file-read-permissions (which chains the existing 2-arg bfc lookup, the bfc share-link fallback, and the Nitrate org-owner fallback). On the frontend, extend cf/resolve-file-media with an optional share-id arg and pass it from the WASM viewer render path using the share-id already present in [:viewer-local :share-id]. Non-viewer call sites (workspace, clipboard, code-gen) keep the original URL shape because the new arg defaults to nil. Closes #11338 AI-assisted-by: minimax-m3 --- backend/src/app/http/assets.clj | 11 +- .../test/backend_tests/http_assets_test.clj | 106 ++++++++++++++++++ frontend/src/app/config.cljs | 20 +++- frontend/src/app/main/data/viewer.cljs | 11 +- 4 files changed, 143 insertions(+), 5 deletions(-) diff --git a/backend/src/app/http/assets.clj b/backend/src/app/http/assets.clj index 6258760548..22783be1e2 100644 --- a/backend/src/app/http/assets.clj +++ b/backend/src/app/http/assets.clj @@ -7,7 +7,6 @@ (ns app.http.assets "Assets related handlers." (:require - [app.binfile.common :as bfc] [app.common.data :as d] [app.common.exceptions :as ex] [app.common.time :as ct] @@ -15,6 +14,7 @@ [app.db :as db] [app.http.access-token :as actoken] [app.http.session :as session] + [app.rpc.permissions :as perms] [app.storage :as sto] [integrant.core :as ig] [yetti.response :as-alias yres])) @@ -41,6 +41,12 @@ (ex/raise :type :not-found :hint "object not found"))) +(defn- get-share-id + "Extract and validate the optional `share-id` query param. Returns a UUID + or `nil` for missing/malformed values." + [{:keys [query-params]}] + (some-> query-params :share-id d/parse-uuid)) + (defn- get-file-media-object [pool id] (db/get* pool :file-media-object {:id id} {::db/remove-deleted false})) @@ -125,7 +131,8 @@ (let [file-id (:file-id mobj) profile-id (or (::session/profile-id request) (::actoken/profile-id request)) - perms (bfc/get-file-permissions pool profile-id file-id)] + share-id (get-share-id request) + perms (perms/get-file-read-permissions pool profile-id file-id share-id)] (if-not (:can-read perms) {::yres/status 404} (let [sobj (sto/get-object storage (kf mobj))] diff --git a/backend/test/backend_tests/http_assets_test.clj b/backend/test/backend_tests/http_assets_test.clj index 94510d73d6..e4f5ebff43 100644 --- a/backend/test/backend_tests/http_assets_test.clj +++ b/backend/test/backend_tests/http_assets_test.clj @@ -13,6 +13,7 @@ [app.http.access-token :as actoken] [app.http.assets :as assets] [app.http.session :as session] + [app.rpc :as-alias rpc] [app.rpc.commands.access-token :as access-token] [app.storage :as sto] [backend-tests.helpers :as th] @@ -588,6 +589,111 @@ response (assets/file-objects-handler cfg request)] (t/is (= 404 (::yres/status response))))) +;; ---------------------------------------------------------------- +;; Tests: file-objects-handler — share-link authz (issue #11338) +;; ---------------------------------------------------------------- + +(t/deftest file-objects-handler-anonymous-with-valid-share-id-succeeds + ;; Anonymous request with a valid share-id matching the file must + ;; succeed (share-link viewers are unauthenticated by definition). + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + owner (th/create-profile* 1) + team (th/create-team* 1 {:profile-id (:id owner)}) + project (th/create-project* 1 {:profile-id (:id owner) + :team-id (:id team)}) + file (th/create-file* 1 {:profile-id (:id owner) + :project-id (:id project)}) + media-storage (create-storage-object! storage "file-media-object" "image data") + media-obj (th/create-file-media-object* {:file-id (:id file) + :media-id (:id media-storage)}) + slink (:result (th/command! {::th/type :create-share-link + ::rpc/profile-id (:id owner) + :file-id (:id file) + :pages #{} + :who-comment "team" + :who-inspect "all"})) + request {:path-params {:id (str (:id media-obj))} + :query-params {:share-id (str (:id slink))}} + response (assets/file-objects-handler cfg request)] + (t/is (= 204 (::yres/status response))))) + +(t/deftest file-objects-handler-anonymous-with-share-id-for-other-file-returns-404 + ;; A share-id from file A must not grant access to assets of file B. + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + owner (th/create-profile* 1) + team (th/create-team* 1 {:profile-id (:id owner)}) + project (th/create-project* 1 {:profile-id (:id owner) + :team-id (:id team)}) + file-a (th/create-file* 1 {:profile-id (:id owner) + :project-id (:id project)}) + file-b (th/create-file* 2 {:profile-id (:id owner) + :project-id (:id project)}) + media-a (create-storage-object! storage "file-media-object" "image A") + media-obj-a (th/create-file-media-object* {:file-id (:id file-a) + :media-id (:id media-a)}) + media-b (create-storage-object! storage "file-media-object" "image B") + media-obj-b (th/create-file-media-object* {:file-id (:id file-b) + :media-id (:id media-b)}) + slink (:result (th/command! {::th/type :create-share-link + ::rpc/profile-id (:id owner) + :file-id (:id file-a) + :pages #{} + :who-comment "team" + :who-inspect "all"})) + request {:path-params {:id (str (:id media-obj-b))} + :query-params {:share-id (str (:id slink))}} + response (assets/file-objects-handler cfg request)] + (t/is (= 404 (::yres/status response))))) + +(t/deftest file-objects-handler-anonymous-with-malformed-share-id-returns-404 + ;; Malformed share-id must not raise; it must short-circuit to 404. + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + profile (th/create-profile* 1) + team (th/create-team* 1 {:profile-id (:id profile)}) + project (th/create-project* 1 {:profile-id (:id profile) + :team-id (:id team)}) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:id project)}) + media-storage (create-storage-object! storage "file-media-object" "image data") + media-obj (th/create-file-media-object* {:file-id (:id file) + :media-id (:id media-storage)}) + request {:path-params {:id (str (:id media-obj))} + :query-params {:share-id "not-a-uuid"}} + response (assets/file-objects-handler cfg request)] + (t/is (= 404 (::yres/status response))))) + +(t/deftest file-thumbnails-handler-anonymous-with-valid-share-id-succeeds + ;; Thumbnail endpoint must also honor the share-id query param. + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + owner (th/create-profile* 1) + team (th/create-team* 1 {:profile-id (:id owner)}) + project (th/create-project* 1 {:profile-id (:id owner) + :team-id (:id team)}) + file (th/create-file* 1 {:profile-id (:id owner) + :project-id (:id project)}) + thumb-storage (create-storage-object! storage "file-object-thumbnail" "thumb data") + media-obj (th/create-file-media-object* {:file-id (:id file) + :media-id (:id thumb-storage)}) + slink (:result (th/command! {::th/type :create-share-link + ::rpc/profile-id (:id owner) + :file-id (:id file) + :pages #{} + :who-comment "team" + :who-inspect "all"})) + request {:path-params {:id (str (:id media-obj))} + :query-params {:share-id (str (:id slink))}} + response (assets/file-thumbnails-handler cfg request)] + ;; Falls back to media-id since no thumbnail-id, but still serves + (t/is (= 204 (::yres/status response))))) + (t/deftest objects-handler-expired-object ;; Expired objects should return 404 (get-object filters them out). (let [storage (-> (:app.storage/storage th/*system*) diff --git a/frontend/src/app/config.cljs b/frontend/src/app/config.cljs index dc2c5a237a..97bb2eb0f6 100644 --- a/frontend/src/app/config.cljs +++ b/frontend/src/app/config.cljs @@ -258,6 +258,23 @@ [id] (dm/str (u/join public-uri "assets/by-id/" (str id)))) +;; Current share-id for asset URL building. The share-link viewer sets +;; this in `app.main.data.viewer/initialize` so every caller of +;; `resolve-file-media` (inspector, code panel, image previews, +;; code generators, etc.) automatically receives a share-id without +;; having to thread it through every call site. Workspace callers +;; leave it nil and continue to get the original URL shape. +(defonce ^:private ^{:doc "Active share-id used by `resolve-file-media`." + :dynamic true} + current-share-id + nil) + +(defn set-current-share-id! + "Set the share-id used by `resolve-file-media`. Pass `nil` to clear it + (e.g. when leaving the viewer)." + [share-id] + (set! current-share-id share-id)) + (defn resolve-file-media ([media] (resolve-file-media media false)) @@ -266,7 +283,8 @@ (dm/str (cond-> (u/join public-uri "assets/by-file-media-id/") (true? thumbnail?) (u/join (dm/str id "/thumbnail")) - (false? thumbnail?) (u/join (dm/str id))))))) + (false? thumbnail?) (u/join (dm/str id)) + (some? current-share-id) (u/join (dm/str "?share-id=" current-share-id))))))) (defn resolve-href [resource] diff --git a/frontend/src/app/main/data/viewer.cljs b/frontend/src/app/main/data/viewer.cljs index 1d9c49f9d3..f847cd862d 100644 --- a/frontend/src/app/main/data/viewer.cljs +++ b/frontend/src/app/main/data/viewer.cljs @@ -95,14 +95,21 @@ ;; browser just focus the opened tab instead of creating new ;; tab. (let [name (str "viewer-" file-id)] - (unchecked-set ug/global "name" name))))) + (unchecked-set ug/global "name" name)) + ;; Make every `cf/resolve-file-media` call (inspector, code panel, + ;; image previews, ...) share-link aware for the lifetime of this + ;; viewer. Cleared by `finalize` below. + (cf/set-current-share-id! share-id)))) (defn finalize [_] (ptk/reify ::finalize ptk/UpdateEvent (update [_ state] - (dissoc state :viewer)))) + (dissoc state :viewer)) + ptk/EffectEvent + (effect [_ _ _] + (cf/set-current-share-id! nil)))) ;; --- Data Fetching From 85a68ea3b20150850c175b5a851de0ad3c6d5b1b Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 25 Aug 2026 13:41:22 +0000 Subject: [PATCH 2/3] :bug: Normalize toast HTML prop to boolean Ensure toast components always receive a boolean `is-html` prop so nil or truthy notification values do not violate the Rumext schema. AI-assisted-by: gpt-5.6-luna --- frontend/src/app/main/ui/notifications.cljs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/main/ui/notifications.cljs b/frontend/src/app/main/ui/notifications.cljs index a7cead63cc..7a05787464 100644 --- a/frontend/src/app/main/ui/notifications.cljs +++ b/frontend/src/app/main/ui/notifications.cljs @@ -35,7 +35,7 @@ [:> toast* {:level (or (:level notification) :info) :type (:type notification) - :is-html (:is-html notification) + :is-html (boolean (:is-html notification)) :detail (:detail notification) :on-close on-close} content] @@ -58,6 +58,6 @@ [:> toast* {:level (or (:level notification) :info) :type (:type notification) - :is-html (:is-html notification) + :is-html (boolean (:is-html notification)) :detail (:detail notification) :on-close on-close} content])))) From 44dfc04300362c1e08dfeb85c598728e68fc8489 Mon Sep 17 00:00:00 2001 From: Luis de Dios Date: Tue, 25 Aug 2026 16:55:34 +0200 Subject: [PATCH 3/3] :bug: Fix font selector dropdown takes noticeably long to open when changing font (#11073) * :bug: Fix font selector dropdown takes noticeably long to open when changing font * :recycle: Refactor detach-preview-sprite! to use atomic swap Use idiomatic atomic swap! update instead of non-atomic read-then-write pattern. The new implementation computes the decremented refs inside swap! and only removes the node when the result reaches zero. AI-assisted-by: mimo-v2.5-pro --------- Co-authored-by: Andrey Antukh --- frontend/src/app/main/fonts.cljs | 78 +++++++----- .../sidebar/options/menus/typography.cljs | 32 +++-- frontend/test/frontend_tests/fonts_test.cljs | 118 +++++++++++++++++- frontend/test/frontend_tests/runner.cljs | 2 + 4 files changed, 186 insertions(+), 44 deletions(-) diff --git a/frontend/src/app/main/fonts.cljs b/frontend/src/app/main/fonts.cljs index 15fa05534b..644169d3de 100644 --- a/frontend/src/app/main/fonts.cljs +++ b/frontend/src/app/main/fonts.cljs @@ -138,10 +138,11 @@ ;; uploads, ones that fail to bake) use the runtime fallback. ;; ;; The sprite is heavy (~2000 nodes), so we DON'T keep it in the DOM: the fetched -;; markup is cached here as a string (`:svg`) and the nodes are materialized only -;; while the picker is open (attach/detach below). `:ids` are the font ids it -;; covers, so the UI can pick sprite vs fallback. -(defonce preview-sprite (l/atom {:status :idle :ids #{} :svg nil})) +;; markup is parsed once eagerly into a cached node (`:node`) so attaching is a +;; cheap appendChild. `:ids` are the font ids it covers (also pre-computed), so +;; the UI can pick sprite vs fallback. `:refs` counts open dropdowns sharing the +;; node, so the last one to close is the one that detaches it. +(defonce preview-sprite (l/atom {:status :idle :ids #{} :node nil :refs 0})) ;; Id prefix shared with the generator and the UI's ``; referenced here ;; rather than re-declared so the contract stays in one place. @@ -163,7 +164,7 @@ [] ;; :error → the UI shows plain names (no previews, no per-font load storm); a ;; later `prefetch-preview-sprite!` call can retry. - (reset! preview-sprite {:status :error :ids #{} :svg nil})) + (reset! preview-sprite {:status :error :ids #{} :node nil :refs 0})) (defn- parse-sprite-svg "Parse the cached sprite markup as SVG (not HTML, so no innerHTML injection @@ -177,10 +178,10 @@ root))) (defn prefetch-preview-sprite! - "Fetch the font-preview sprite markup and cache it in memory (no DOM yet — see - `attach-preview-sprite!`). Idempotent: fetches only when nothing is cached yet - (`:idle`) or a previous attempt failed (`:error`); no-op while `:loading` or - `:ready`." + "Fetch the font-preview sprite markup, pre-parse it on idle, and cache the + parsed DOM node with the font ids it covers. Idempotent: fetches only when + nothing is cached yet (`:idle`) or a previous attempt failed (`:error`); no-op + while `:loading` or `:ready`." [] (when (and (globals/browser?) (contains? #{:idle :error} (:status @preview-sprite))) @@ -192,9 +193,24 @@ (rx/subs! (fn [response] ;; http/send! doesn't reject on non-2xx; guard so an error body isn't - ;; cached as the sprite. + ;; cached as the sprite. The parse is deferred to idle so the + ;; ~2000-node import doesn't spike the main thread at load time; + ;; `:status` stays `:loading` until it's done. (if (http/success? response) - (swap! preview-sprite assoc :status :ready :svg (:body response)) + (let [svg (:body response)] + (tm/schedule-on-idle + (fn [] + (if-let [node (some-> (parse-sprite-svg svg) (dom/import-node))] + (do + (dom/set-attribute! node "id" "font-preview-sprite") + (let [ids (collect-preview-ids node)] + (swap! preview-sprite assoc + :status :ready + :node node + :ids ids))) + (do + (log/wrn :hint "cannot parse font preview sprite") + (reset-preview-sprite-error!)))))) (do (log/wrn :hint "cannot load font preview sprite" :status (:status response)) (reset-preview-sprite-error!)))) @@ -203,32 +219,28 @@ (reset-preview-sprite-error!)))))) (defn attach-preview-sprite! - "Materialize the cached sprite into the DOM (hidden) so rows can reference its - glyph groups via ``, and record the covered font ids. Returns the injected - node (pass it to `detach-preview-sprite!` on close), or nil if not ready / the - markup is invalid. Parsing happens here, not on prefetch, so the cost is paid - only while the picker is open." + "Append the pre-parsed sprite node into the DOM (hidden) so rows can reference + its glyph groups via ``. Returns the node (pass it to + `detach-preview-sprite!` on close), or nil if not ready. Parsing and id + collection happen once during `prefetch-preview-sprite!`, so this is just a + cheap appendChild. Multiple dropdowns may share the node; each attach + increments `:refs` so the node is only detached when the last one closes." [] - (let [{:keys [status svg]} @preview-sprite] - (when (and (globals/browser?) (= :ready status) (some? svg)) - (if-let [node (some-> (parse-sprite-svg svg) (dom/import-node))] - ;; The node already carries display:none + aria-hidden from the generator. - (do - (dom/set-attribute! node "id" "font-preview-sprite") - (when-let [body-el (unchecked-get globals/document "body")] - (dom/append-child! body-el node)) - (swap! preview-sprite assoc :ids (collect-preview-ids node)) - node) - (do - (log/wrn :hint "cannot parse font preview sprite") - (reset-preview-sprite-error!) - nil))))) + (let [{:keys [status node]} @preview-sprite] + (when (and (globals/browser?) (= :ready status) (some? node)) + (when-let [body-el (unchecked-get globals/document "body")] + (dom/append-child! body-el node)) + (swap! preview-sprite update :refs inc) + node))) (defn detach-preview-sprite! - "Remove the sprite node injected by `attach-preview-sprite!` from the DOM. The - cached markup and `:ids` stay, so reopening re-attaches without a refetch." + "Remove the sprite node injected by `attach-preview-sprite!` from the DOM when + the last open dropdown closes. The cached node and `:ids` stay, so reopening + re-attaches without a refetch or re-parse." [node] - (dom/remove! node)) + (let [new-state (swap! preview-sprite update :refs #(max 0 (dec %)))] + (when (zero? (:refs new-state)) + (dom/remove! node)))) (defn- add-font-css! "Creates a style element and attaches it to the dom." diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs index 4dc3473644..1a085ef1c4 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs @@ -105,13 +105,18 @@ [{:keys [font]}] (let [font-id (:id font) sprite (mf/deref fonts/preview-sprite) - in-sprite? (contains? (:ids sprite) font-id) - ;; Fallback is ONLY for custom fonts: ones the (ready) sprite doesn't - ;; cover. If the sprite isn't ready (loading/error) we show the plain name - ;; rather than runtime-loading the whole catalog. - fallback? (and (= :ready (:status sprite)) - (not in-sprite?)) + ;; The sprite is only referenceable once it's been attached to the DOM, + ;; so the `` glyph is gated on `attached?`. Until then we show the + ;; plain name: no blank rows, and no per-font load storm either (see + ;; `fallback?` below). + attached? (pos? (:refs sprite)) + + ;; Fallback is ONLY for custom fonts: ones the (attached) sprite doesn't + ;; cover. If the sprite isn't ready (loading/error) or not yet attached, + ;; we show the plain name rather than runtime-loading the whole catalog. + in-sprite? (and attached? (contains? (:ids sprite) font-id)) + fallback? (and (= :ready (:status sprite)) attached? (not in-sprite?)) loaded? (use-font-lazy-load font-id fallback?)] (if in-sprite? ;; `fill: currentColor` (scss) makes the sprite glyph follow the row color. @@ -257,13 +262,20 @@ ;; FLAG :font-preview — materialize the preview sprite into the DOM only while ;; the picker is open (markup is prefetched on workspace load), removing it on - ;; close so its ~2000 nodes aren't kept around idle. Remove the flag clause to - ;; drop the feature. + ;; close so its ~2000 nodes aren't kept around idle. The attachment is deferred + ;; so the dropdown can paint first with plain names, then the sprite swaps in + ;; on the next tick. Remove the flag clause to drop the feature. (mf/with-effect [sprite-status] (when (and (contains? cf/flags :font-preview) (= :ready sprite-status)) - (let [node (fonts/attach-preview-sprite!)] - #(fonts/detach-preview-sprite! node)))) + (let [node* (volatile! nil) + task (tm/schedule + (fn [] + (vreset! node* (fonts/attach-preview-sprite!))))] + (fn [] + (tm/dispose! task) + (when-some [n @node*] + (fonts/detach-preview-sprite! n)))))) (mf/with-effect [@selected] (when-let [inst (mf/ref-val flist)] diff --git a/frontend/test/frontend_tests/fonts_test.cljs b/frontend/test/frontend_tests/fonts_test.cljs index e2de0217e0..40645fcfc4 100644 --- a/frontend/test/frontend_tests/fonts_test.cljs +++ b/frontend/test/frontend_tests/fonts_test.cljs @@ -7,7 +7,11 @@ (ns frontend-tests.fonts-test (:require [app.main.fonts :as fonts] - [cljs.test :as t :include-macros true])) + [app.util.globals :as globals] + [app.util.http :as http] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.mock :as mock])) (def sample-font {:id "sourcesanspro" @@ -124,3 +128,115 @@ result (fonts/find-closest-variant font "200" nil)] (t/is (= "200" (:weight result))) (t/is (= "italic" (:style result)))))) + +;; --- preview sprite ---------------------------------------------------------- +;; +;; The sprite feature (FLAG :font-preview) caches a pre-parsed SVG node shared by +;; every open font dropdown. `:refs` counts the open dropdowns so the node is only +;; detached when the last one closes. The unit test runner has no browser DOM, so +;; the environment boundary (`globals/browser?`) is mocked and DOM nodes are +;; replaced with minimal fakes exposing only what attach/detach touches. + +(t/use-fixtures + :each + (fn [test-fn] + (reset! fonts/preview-sprite {:status :idle :ids #{} :node nil :refs 0}) + (test-fn))) + +(defn- fake-node + "A minimal DOM-like node exposing only what the sprite attach/detach touches." + [] + #js {:remove (fn [] nil)}) + +(t/deftest attach-preview-sprite-returns-nil-while-sprite-is-not-ready + (mock/with-mocks + {globals/browser? (mock/stub (constantly true))} + (fn [done] + (reset! fonts/preview-sprite {:status :loading :ids #{} :node nil :refs 0}) + (t/is (nil? (fonts/attach-preview-sprite!))) + (t/is (= 0 (:refs @fonts/preview-sprite))) + + (reset! fonts/preview-sprite {:status :error :ids #{} :node nil :refs 0}) + (t/is (nil? (fonts/attach-preview-sprite!))) + (t/is (= 0 (:refs @fonts/preview-sprite))) + (done)) + (fn [] nil))) + +(t/deftest attach-preview-sprite-increments-refs-and-returns-the-node + (mock/with-mocks + {globals/browser? (mock/stub (constantly true))} + (fn [done] + (let [node (fake-node)] + (reset! fonts/preview-sprite {:status :ready :ids #{"a"} :node node :refs 0}) + (t/is (identical? node (fonts/attach-preview-sprite!))) + (t/is (= 1 (:refs @fonts/preview-sprite))) + (t/is (identical? node (fonts/attach-preview-sprite!))) + (t/is (= 2 (:refs @fonts/preview-sprite))) + (done))) + (fn [] nil))) + +(t/deftest detach-preview-sprite-removes-node-only-when-last-reference-drops + (mock/with-mocks + {globals/browser? (mock/stub (constantly true))} + (fn [done] + (let [removed? (volatile! false) + node #js {:remove (fn [] (vreset! removed? true))}] + (reset! fonts/preview-sprite {:status :ready :ids #{"a"} :node node :refs 0}) + (fonts/attach-preview-sprite!) + (fonts/attach-preview-sprite!) + + ;; First detach keeps the node: another dropdown is still open. + (fonts/detach-preview-sprite! node) + (t/is (= 1 (:refs @fonts/preview-sprite))) + (t/is (false? @removed?)) + + ;; Second detach reaches zero refs, so the node is removed from the DOM. + (fonts/detach-preview-sprite! node) + (t/is (= 0 (:refs @fonts/preview-sprite))) + (t/is (true? @removed?)) + (done))) + (fn [] nil))) + +(t/deftest detach-preview-sprite-clamps-refs-at-zero + (mock/with-mocks + {globals/browser? (mock/stub (constantly true))} + (fn [done] + (let [removed? (volatile! false) + node #js {:remove (fn [] (vreset! removed? true))}] + (reset! fonts/preview-sprite {:status :ready :ids #{"a"} :node node :refs 0}) + (fonts/detach-preview-sprite! node) + (t/is (= 0 (:refs @fonts/preview-sprite))) + (t/is (true? @removed?)) + (done))) + (fn [] nil))) + +(t/deftest prefetch-preview-sprite-fetches-only-from-idle-or-error + (let [calls (volatile! 0) + fetch (mock/stub (fn [& _] + (vswap! calls inc) + (rx/empty)))] + (mock/with-mocks + {globals/browser? (mock/stub (constantly true)) + http/fetch fetch} + (fn [done] + ;; :ready → no refetch + (reset! fonts/preview-sprite {:status :ready :ids #{"a"} :node (fake-node) :refs 0}) + (fonts/prefetch-preview-sprite!) + (t/is (= 0 @calls)) + + ;; :loading → no refetch (an earlier request is in flight) + (reset! fonts/preview-sprite {:status :loading :ids #{} :node nil :refs 0}) + (fonts/prefetch-preview-sprite!) + (t/is (= 0 @calls)) + + ;; :error → retries + (reset! fonts/preview-sprite {:status :error :ids #{} :node nil :refs 0}) + (fonts/prefetch-preview-sprite!) + (t/is (= 1 @calls)) + + ;; :idle → first fetch + (reset! fonts/preview-sprite {:status :idle :ids #{} :node nil :refs 0}) + (fonts/prefetch-preview-sprite!) + (t/is (= 2 @calls)) + (done)) + (fn [] nil)))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index f4ae117eda..73749f9810 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -28,6 +28,7 @@ [frontend-tests.data.workspace-texts-test] [frontend-tests.data.workspace-thumbnails-test] [frontend-tests.errors-test] + [frontend-tests.fonts-test] [frontend-tests.helpers-shapes-test] [frontend-tests.logic.comp-remove-swap-slots-test] [frontend-tests.logic.components-and-tokens] @@ -125,6 +126,7 @@ 'frontend-tests.data.workspace-texts-test 'frontend-tests.data.workspace-thumbnails-test 'frontend-tests.errors-test + 'frontend-tests.fonts-test 'frontend-tests.helpers-shapes-test 'frontend-tests.logic.comp-remove-swap-slots-test 'frontend-tests.logic.components-and-tokens