From 41efc3558ad506b38b52e791f5d9d58679d9a4fa Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Thu, 8 Oct 2026 09:54:46 +0200 Subject: [PATCH] :bug: Fix nested libraries vanishing from the libraries modal (#12031) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :bug: Fix nested libraries vanishing from the libraries modal The "This file" tab nests each linked library under a linked library that uses it, taken from the team shared files. A library with such a parent was removed from the top level and only drawn under its parent if the parent was at the top level itself. So: - In a chain of two or more levels (file -> A -> B -> C), C was lost. - In a cycle (A uses B and B uses A), both had a parent, none was at the top level, and both were lost. The libraries showed while the shared files were loading, and vanished when they arrived. Build the list as a tree instead: direct libraries and libraries not used by any other linked library are roots, nested libraries follow their parent at any depth, and each library is added only once, even when the dependencies form a cycle. AI-assisted-by: claude-opus-5-5 * :recycle: Move the linked libraries tree to sort-linked-libraries Extract the code that orders the linked libraries as a tree from the file tab to a public function, so it can be tested on its own. Add tests for a cycle with a direct root, a cycle with no root, a deep chain and no dependencies yet. AI-assisted-by: claude-opus-5-5 * :recycle: Use connected-to to find library parents when sorting The caller already computes :connected-to for each linked library, the reverse index of the dependencies map. sort-linked-libraries rebuilt the same relation from dependencies. Read it from :connected-to instead, so the function takes only the libraries and the component memo no longer depends on dependencies. Reword the cycle-without-root test to say it models the unknown direct flag, and add tests for a diamond, a self-dependency, a cycle of three without root, a parent that is not a linked library, and for no library being lost or duplicated. AI-assisted-by: claude-sonnet-5-5 --------- Co-authored-by: Andrés Moya --- .../src/app/main/ui/workspace/libraries.cljs | 91 ++++++++++------ frontend/test/frontend_tests/runner.cljs | 2 + .../ui/workspace_libraries_test.cljs | 101 ++++++++++++++++++ 3 files changed, 159 insertions(+), 35 deletions(-) create mode 100644 frontend/test/frontend_tests/ui/workspace_libraries_test.cljs diff --git a/frontend/src/app/main/ui/workspace/libraries.cljs b/frontend/src/app/main/ui/workspace/libraries.cljs index 5c09d951ff..71c7f32405 100644 --- a/frontend/src/app/main/ui/workspace/libraries.cljs +++ b/frontend/src/app/main/ui/workspace/libraries.cljs @@ -107,6 +107,58 @@ :token-sets token-sets :token-themes token-themes})) +(defn sort-linked-libraries + "Given the linked libraries of a file, each with a `:connected-to` + collection of the ids of the files that use it, return the libraries + ordered as a tree: each library is followed by the libraries nested + under it, marked with `:nested?` and `:parent-name`. + + Direct libraries (`:is-indirect` false) and libraries not used by + any other linked library are roots. Every library is returned + exactly once, at any depth and even when the dependencies form a + cycle." + [libraries] + (let [by-id (d/index-by :id libraries) + sort-by-name (partial sort-by (comp str/lower :name)) + + parents + (d/update-vals by-id + (fn [{:keys [id connected-to]}] + (into #{} + (filter #(and (not= % id) + (contains? by-id %))) + connected-to))) + + root? + (fn [{:keys [id is-indirect]}] + (or (false? is-indirect) + (empty? (get parents id)))) + + children + (fn [parent-id] + (->> libraries + (remove root?) + (filter #(contains? (get parents (:id %)) parent-id)) + (sort-by-name))) + + visit + (fn visit [[result visited :as acc] library parent] + (if (contains? visited (:id library)) + acc + (reduce #(visit %1 %2 library) + [(conj result (assoc library + :nested? (some? parent) + :parent-name (:name parent))) + (conj visited (:id library))] + (children (:id library)))))] + + ;; The second pass picks up libraries only reachable through a + ;; cycle, which have no root to hang from. + (as-> [[] #{}] $ + (reduce #(visit %1 %2 nil) $ (sort-by-name (filter root? libraries))) + (reduce #(visit %1 %2 nil) $ (sort-by-name libraries)) + (first $)))) + (defn- describe-library [components-count graphics-count colors-count typography-count] (let [all-zero? (and (zero? components-count) @@ -666,11 +718,6 @@ dependencies (mf/with-memo [shared-libraries] (into {} (map (juxt :id :library-file-ids) (vals shared-libraries)))) - library-names (mf/with-memo [shared-libraries] - (into {} (map (fn [{:keys [id name]}] - [id name]) - (vals shared-libraries)))) - find-connected-to (mf/use-fn (mf/deps dependencies) @@ -679,36 +726,10 @@ (keep (fn [[k v]] (when (contains? v library-id) k)))))) linked-libraries - (mf/with-memo [linked-libraries find-connected-to library-names] - (let [libs - (->> (vals linked-libraries) - (map #(assoc % :connected-to (find-connected-to (:id %))))) - - linked-ids - (into #{} (map :id) libs) - - sort-by-name - (partial sort-by (comp str/lower :name)) - - parent-id - (fn [{:keys [connected-to]}] - (first (filter linked-ids connected-to))) - - nested-by-parent - (->> libs - (filter parent-id) - (map #(assoc % - :nested? true - :parent-name (library-names (parent-id %)))) - (group-by parent-id) - (d/mapm (fn [_ v] (sort-by-name v))))] - - (->> libs - (remove parent-id) - (map #(assoc % :nested? false)) - (sort-by-name) - (mapcat (fn [{:keys [id] :as library}] - (cons library (get nested-by-parent id []))))))) + (mf/with-memo [linked-libraries find-connected-to] + (->> (vals linked-libraries) + (map #(assoc % :connected-to (find-connected-to (:id %)))) + (sort-linked-libraries))) linked-libraries-ids (mf/with-memo [linked-libraries] diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 493dddeb42..6a897ee8a1 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -120,6 +120,7 @@ [frontend-tests.ui.shortcuts-labels-test] [frontend-tests.ui.sidebar-scroll-test] [frontend-tests.ui.stroke-menu-test] + [frontend-tests.ui.workspace-libraries-test] [frontend-tests.util-clipboard-test] [frontend-tests.util-object-test] [frontend-tests.util-queue-test] @@ -260,6 +261,7 @@ 'frontend-tests.ui.shortcuts-labels-test 'frontend-tests.ui.sidebar-scroll-test 'frontend-tests.ui.stroke-menu-test + 'frontend-tests.ui.workspace-libraries-test 'frontend-tests.util-clipboard-test 'frontend-tests.util-object-test 'frontend-tests.util-queue-test diff --git a/frontend/test/frontend_tests/ui/workspace_libraries_test.cljs b/frontend/test/frontend_tests/ui/workspace_libraries_test.cljs new file mode 100644 index 0000000000..1ed82c488f --- /dev/null +++ b/frontend/test/frontend_tests/ui/workspace_libraries_test.cljs @@ -0,0 +1,101 @@ +;; 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 SUBSIDIARY SL + +(ns frontend-tests.ui.workspace-libraries-test + (:require + [app.main.ui.workspace.libraries :as wl] + [cljs.test :as t :include-macros true])) + +(defn- lib + "A linked library; `connected-to` lists the ids of the files using it." + [id indirect? connected-to] + {:id id :name (name id) :is-indirect indirect? :connected-to connected-to}) + +(defn- summarize + [libraries] + (mapv (juxt :id :nested? :parent-name) libraries)) + +(t/deftest sort-linked-libraries-cycle-with-direct-root + ;; file -> App, Foundations; App -> Foundations; + ;; Foundations <-> Comments (cycle, as left by an import) + (let [libraries [(lib :foundations false [:file :app :comments]) + (lib :app false [:file]) + (lib :comments true [:foundations])] + result (wl/sort-linked-libraries libraries)] + (t/is (= [[:app false nil] + [:foundations false nil] + [:comments true "foundations"]] + (summarize result))) + (t/is (= (count libraries) (count result))))) + +(t/deftest sort-linked-libraries-cycle-without-root + ;; The direct flag is still unknown (nil) while the shared files load, + ;; so both libraries are only reachable through the cycle. `:app` is + ;; not a linked library, so it is ignored as a parent. + (let [libraries [(lib :foundations nil [:app :comments]) + (lib :comments nil [:foundations])] + result (wl/sort-linked-libraries libraries)] + (t/is (= [[:comments false nil] + [:foundations true "comments"]] + (summarize result))) + (t/is (= (count libraries) (count result))))) + +(t/deftest sort-linked-libraries-cycle-of-three-without-root + ;; a -> b -> c -> a, all indirect: no root, nothing is lost + (let [libraries [(lib :c true [:b]) + (lib :b true [:a]) + (lib :a true [:c])] + result (wl/sort-linked-libraries libraries)] + (t/is (= [[:a false nil] + [:b true "a"] + [:c true "b"]] + (summarize result))) + (t/is (= (count libraries) (count result))))) + +(t/deftest sort-linked-libraries-deep-chain + ;; file -> a -> b -> c + (let [libraries [(lib :c true [:b]) + (lib :b true [:a]) + (lib :a false [:file])]] + (t/is (= [[:a false nil] + [:b true "a"] + [:c true "b"]] + (summarize (wl/sort-linked-libraries libraries)))))) + +(t/deftest sort-linked-libraries-diamond + ;; a -> b, c; b -> d; c -> d: d is listed once, under its first parent + (let [libraries [(lib :d true [:b :c]) + (lib :c true [:a]) + (lib :b true [:a]) + (lib :a false [:file])] + result (wl/sort-linked-libraries libraries)] + (t/is (= [[:a false nil] + [:b true "a"] + [:d true "b"] + [:c true "a"]] + (summarize result))) + (t/is (= (count libraries) (count result))))) + +(t/deftest sort-linked-libraries-self-dependency + (let [libraries [(lib :a true [:a])]] + (t/is (= [[:a false nil]] + (summarize (wl/sort-linked-libraries libraries)))))) + +(t/deftest sort-linked-libraries-parent-not-linked + ;; The only user of each library is not a linked library, so both are roots + (let [libraries [(lib :b true [:other]) + (lib :a true [:file])]] + (t/is (= [[:a false nil] + [:b false nil]] + (summarize (wl/sort-linked-libraries libraries)))))) + +(t/deftest sort-linked-libraries-without-dependencies + ;; Before get-team-shared-files answers, all libraries are flat + (let [libraries [(lib :b true nil) + (lib :a false nil)]] + (t/is (= [[:a false nil] + [:b false nil]] + (summarize (wl/sort-linked-libraries libraries))))))