From ef3143dcb82a11aca3b95fdf07e52ee9c09d8167 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 30 Mar 2026 12:35:39 +0200 Subject: [PATCH 1/3] :paperclip: Update changelog --- CHANGES.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index d5f46c6c3b..d774392169 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,28 @@ # CHANGELOG +## 2.14.1 + +### :sparkles: New features & Enhancements + +- Add automatic retry with backoff for idempotent RPC requests on network failures [Github #8792](https://github.com/penpot/penpot/pull/8792) +- Add scroll and zoom throttling to one state update per animation frame [Github #8812](https://github.com/penpot/penpot/pull/8812) +- Improve error handling and exception formatting [Github #8757](https://github.com/penpot/penpot/pull/8757) + +### :bug: Bugs fixed + +- Fix crash in apply-text-modifier with nil selrect or modifier [Github #8762](https://github.com/penpot/penpot/pull/8762) +- Fix incorrect attrs references on generate-sync-shape [Github #8776](https://github.com/penpot/penpot/pull/8776) +- Fix regression on subpath support [Github #8793](https://github.com/penpot/penpot/pull/8793) +- Improve error reporting on request parsing failures [Github #8805](https://github.com/penpot/penpot/pull/8805) +- Fix fetch abort errors escaping the unhandled exception handler [Github #8801](https://github.com/penpot/penpot/pull/8801) +- Fix nil deref on missing bounds in layout modifier propagation [Github #8735](https://github.com/penpot/penpot/pull/8735) +- Fix TypeError when token error map lacks :error/fn key [Github #8767](https://github.com/penpot/penpot/pull/8767) +- Fix dissoc error when detaching stroke color from library [Github #8738](https://github.com/penpot/penpot/pull/8738) +- Fix crash when pasting image into text editor +- Fix null text crash on paste in text editor +- Ensure path content is always PathData when saving +- Fix error when get-parent-with-data encounters non-Element nodes + ## 2.14.0 ### :boom: Breaking changes & Deprecations From c1044ac52299074216a168113d7722ffdcef66b1 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 17 Mar 2026 15:04:06 +0100 Subject: [PATCH 2/3] :sparkles: Add protection for stale cache of js assets loading issues (#8638) * :sparkles: Use update-when for update dashboard state This make updates more consistent and reduces possible eventual consistency issues in out of order events execution. * :bug: Detect stale JS modules at boot and force reload When the browser serves cached JS files from a previous deployment alongside a fresh index.html, code-split modules reference keyword constants that do not exist in the stale shared.js, causing TypeError crashes. This adds a compile-time version tag (via goog-define / closure-defines) that is baked into the JS bundle. At boot, it is compared against the runtime version tag from index.html (which is always fresh due to no-cache headers). If they differ, the app forces a hard page reload before initializing, ensuring all JS modules come from the same build. * :paperclip: Ensure consistent version across builds on github e2e test workflow --------- Signed-off-by: Andrey Antukh --- .github/workflows/tests.yml | 2 +- frontend/shadow-cljs.edn | 3 +- frontend/src/app/config.cljs | 55 +++++++++++++++++++- frontend/src/app/main.cljs | 24 ++++++--- frontend/src/app/main/data/dashboard.cljs | 8 +-- frontend/src/app/main/errors.cljs | 47 ++++++++++++++--- frontend/src/app/main/ui/error_boundary.cljs | 21 +++++--- frontend/src/app/util/storage.cljs | 23 +++++--- 8 files changed, 149 insertions(+), 34 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4ba57dde95..6cce286ced 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -252,7 +252,7 @@ jobs: - name: Build Bundle working-directory: ./frontend run: | - ./scripts/build 0.0.0 + ./scripts/build - name: Store Bundle Cache uses: actions/cache@v4 diff --git a/frontend/shadow-cljs.edn b/frontend/shadow-cljs.edn index bd09d46e6d..fadcb4131f 100644 --- a/frontend/shadow-cljs.edn +++ b/frontend/shadow-cljs.edn @@ -70,7 +70,8 @@ :release {:closure-defines {goog.DEBUG false - goog.debug.LOGGING_ENABLED true} + goog.debug.LOGGING_ENABLED true + app.config/compiled-version-tag #shadow/env ["VERSION_TAG" :default "develop"]} :compiler-options {:fn-invoke-direct true :optimizations #shadow/env ["PENPOT_BUILD_OPTIMIZATIONS" :as :keyword :default :advanced] diff --git a/frontend/src/app/config.cljs b/frontend/src/app/config.cljs index a0c5680204..f1c1e2b8bf 100644 --- a/frontend/src/app/config.cljs +++ b/frontend/src/app/config.cljs @@ -6,8 +6,11 @@ (ns app.config (:require + [app.common.data :as d] [app.common.data.macros :as dm] [app.common.flags :as flags] + [app.common.logging :as log] + [app.common.time :as ct] [app.common.uri :as u] [app.common.version :as v] [app.util.avatars :as avatars] @@ -15,6 +18,8 @@ [app.util.globals :refer [global location]] [app.util.navigator :as nav] [app.util.object :as obj] + [app.util.storage :as sto] + [app.util.timers :as ts] [cuerdas.core :as str])) (set! *assert* js/goog.DEBUG) @@ -81,6 +86,16 @@ "unknown" date))) +;; --- Compile-time version tag +;; +;; This value is baked into the compiled JS at build time via closure-defines, +;; so it travels with the JS bundle. In contrast, `version-tag` (below) is read +;; at runtime from globalThis.penpotVersionTag which is set by the always-fresh +;; index.html. Comparing the two lets us detect when the browser has loaded +;; stale cached JS files. + +(goog-define compiled-version-tag "develop") + ;; --- Global Config Vars (def default-theme "default") @@ -90,12 +105,50 @@ (def build-date (parse-build-date global)) (def flags (parse-flags global)) -(def version (parse-version global)) (def target (parse-target global)) (def browser (parse-browser)) (def platform (parse-platform)) +(def version (parse-version global)) (def version-tag (obj/get global "penpotVersionTag")) + +(defn stale-build? + "Returns true when the compiled JS was built with a different version + tag than the one present in the current index.html. This indicates + the browser has cached JS from a previous deployment." + ^boolean + [] + (not= compiled-version-tag version-tag)) + +;; --- Throttled reload +;; +;; A generic reload mechanism with loop protection via sessionStorage. +;; Used by both the boot-time stale-build check and the runtime +;; stale-asset error handler. + +(def ^:private reload-storage-key "penpot-last-reload-timestamp") +(def ^:private reload-cooldown-ms 30000) + +(defn throttled-reload + "Force a hard page reload unless one was already triggered within the + last 30 seconds (tracked in sessionStorage). Returns true when a + reload is initiated, false when suppressed." + [& {:keys [reason]}] + (let [now (ct/now) + prev-ts (-> (sto/get-item sto/session-storage reload-storage-key) + (d/parse-integer))] + (if (and (some? prev-ts) + (< (- now prev-ts) reload-cooldown-ms)) + (do + (log/wrn :hint "reload suppressed (cooldown active)" + :reason reason) + false) + (do + (log/wrn :hint "forcing page reload" :reason reason) + (sto/set-item sto/session-storage reload-storage-key (str now)) + (ts/asap #(.reload ^js location true)) + true)))) + (def terms-of-service-uri (obj/get global "penpotTermsOfServiceURI")) (def privacy-policy-uri (obj/get global "penpotPrivacyPolicyURI")) (def flex-help-uri (obj/get global "penpotGridHelpURI" "https://help.penpot.app/user-guide/flexible-layouts/")) diff --git a/frontend/src/app/main.cljs b/frontend/src/app/main.cljs index 9994856f60..870f8a82bb 100644 --- a/frontend/src/app/main.cljs +++ b/frontend/src/app/main.cljs @@ -100,16 +100,24 @@ (defn ^:export init [options] - (some-> (unchecked-get options "defaultTranslations") - (i18n/set-default-translations)) + ;; Before initializing anything, check if the browser has loaded + ;; stale JS from a previous deployment. If so, do a hard reload so + ;; the browser fetches fresh assets matching the current index.html. + (if (cf/stale-build?) + (cf/throttled-reload + :reason (dm/str "stale JS: compiled=" cf/compiled-version-tag + " expected=" cf/version-tag)) + (do + (some-> (unchecked-get options "defaultTranslations") + (i18n/set-default-translations)) - (mw/init!) - (i18n/init) - (cur/init-styles) + (mw/init!) + (i18n/init) + (cur/init-styles) - (init-ui) - (st/emit! (plugins/initialize) - (initialize))) + (init-ui) + (st/emit! (plugins/initialize) + (initialize))))) (defn ^:export reinit ([] diff --git a/frontend/src/app/main/data/dashboard.cljs b/frontend/src/app/main/data/dashboard.cljs index f4f6ead4be..a5ce2cd2c3 100644 --- a/frontend/src/app/main/data/dashboard.cljs +++ b/frontend/src/app/main/data/dashboard.cljs @@ -362,7 +362,7 @@ (ptk/reify ::toggle-project-pin ptk/UpdateEvent (update [_ state] - (assoc-in state [:projects id :is-pinned] (not is-pinned))) + (d/update-in-when state [:projects id] assoc :is-pinned (not is-pinned))) ptk/WatchEvent (watch [_ state _] @@ -379,7 +379,7 @@ ptk/UpdateEvent (update [_ state] (-> state - (update-in [:projects id :name] (constantly name)) + (d/update-in-when [:projects id] assoc :name name) (update :dashboard-local dissoc :project-for-edit))) ptk/WatchEvent @@ -409,7 +409,7 @@ (ptk/reify ::file-deleted ptk/UpdateEvent (update [_ state] - (update-in state [:projects project-id :count] dec)))) + (d/update-in-when state [:projects project-id :count] dec)))) (defn delete-file [{:keys [id project-id] :as params}] @@ -514,7 +514,7 @@ (-> state (assoc-in [:files id] file) (assoc-in [:recent-files id] file) - (update-in [:projects project-id :count] inc)))))) + (d/update-in-when [:projects project-id :count] inc)))))) (defn create-file [{:keys [project-id name] :as params}] diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index a4037b1f63..7671316589 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -33,6 +33,27 @@ ;; Will contain last uncaught exception (def last-exception nil) +;; --- Stale-asset error detection and auto-reload +;; +;; When the browser loads JS modules from different builds (e.g. shared.js from +;; build A and main-dashboard.js from build B because you loaded it in the +;; middle of a deploy per example), keyword constants referenced across modules +;; will be undefined. This manifests as TypeError messages containing +;; "$cljs$cst$" and "is undefined" or "is null". + +(defn stale-asset-error? + "Returns true if the error matches the signature of a cross-build + module mismatch: accessing a ClojureScript keyword constant that + doesn't exist on the shared $APP object." + [cause] + (when (some? cause) + (let [message (ex-message cause)] + (and (string? message) + (str/includes? message "$cljs$cst$") + (or (str/includes? message "is undefined") + (str/includes? message "is null") + (str/includes? message "is not a function")))))) + (defn exception->error-data [cause] (let [data (ex-data cause)] @@ -375,17 +396,31 @@ (.preventDefault ^js event) (when-let [cause (unchecked-get event "error")] (when-not (is-ignorable-exception? cause) - (set! last-exception cause) - (ex/print-throwable cause :prefix "Uncaught Exception") - (ts/schedule #(flash :cause cause :type :unhandled))))) + (if (stale-asset-error? cause) + (cf/throttled-reload :reason (ex-message cause)) + (let [data (ex-data cause) + type (get data :type)] + (set! last-exception cause) + (if (= :wasm-error type) + (on-error cause) + (do + (ex/print-throwable cause :prefix "Uncaught Exception") + (ts/asap #(flash :cause cause :type :unhandled))))))))) (on-unhandled-rejection [event] (.preventDefault ^js event) (when-let [cause (unchecked-get event "reason")] (when-not (is-ignorable-exception? cause) - (set! last-exception cause) - (ex/print-throwable cause :prefix "Uncaught Rejection") - (ts/schedule #(flash :cause cause :type :unhandled)))))] + (if (stale-asset-error? cause) + (cf/throttled-reload :reason (ex-message cause)) + (let [data (ex-data cause) + type (get data :type)] + (set! last-exception cause) + (if (= :wasm-error type) + (on-error cause) + (do + (ex/print-throwable cause :prefix "Uncaught Rejection") + (ts/asap #(flash :cause cause :type :unhandled)))))))))] (.addEventListener g/window "error" on-unhandled-error) (.addEventListener g/window "unhandledrejection" on-unhandled-rejection) diff --git a/frontend/src/app/main/ui/error_boundary.cljs b/frontend/src/app/main/ui/error_boundary.cljs index c3e9ec18b6..9d3f304489 100644 --- a/frontend/src/app/main/ui/error_boundary.cljs +++ b/frontend/src/app/main/ui/error_boundary.cljs @@ -9,6 +9,7 @@ (:require ["react-error-boundary" :as reb] [app.common.exceptions :as ex] + [app.config :as cf] [app.main.errors :as errors] [app.main.refs :as refs] [goog.functions :as gfn] @@ -35,13 +36,19 @@ ;; very small amount of time, so we debounce for 100ms for ;; avoid duplicate and redundant reports (gfn/debounce (fn [error info] - (set! errors/last-exception error) - (ex/print-throwable error) - (js/console.error - "Component trace: \n" - (unchecked-get info "componentStack") - "\n" - error)) + ;; If the error is a stale-asset error (cross-build + ;; module mismatch), force a hard page reload instead + ;; of showing the error page to the user. + (if (errors/stale-asset-error? error) + (cf/throttled-reload :reason (ex-message error)) + (do + (set! errors/last-exception error) + (ex/print-throwable error) + (js/console.error + "Component trace: \n" + (unchecked-get info "componentStack") + "\n" + error)))) 100))] [:> reb/ErrorBoundary diff --git a/frontend/src/app/util/storage.cljs b/frontend/src/app/util/storage.cljs index b67c805d25..dd97d7ea35 100644 --- a/frontend/src/app/util/storage.cljs +++ b/frontend/src/app/util/storage.cljs @@ -17,10 +17,10 @@ ;; Using ex/ignoring because can receive a DOMException like this when ;; importing the code as a library: Failed to read the 'localStorage' ;; property from 'Window': Storage is disabled inside 'data:' URLs. -(defonce ^:private local-storage-backend +(defonce local-storage (ex/ignoring (unchecked-get g/global "localStorage"))) -(defonce ^:private session-storage-backend +(defonce session-storage (ex/ignoring (unchecked-get g/global "sessionStorage"))) (def ^:dynamic *sync* @@ -69,6 +69,17 @@ (persistent! result)))) {})) +(defn set-item + [storage key val] + (when (and (some? storage) + (string? key)) + (.setItem ^js storage key val))) + +(defn get-item + [storage key] + (when (some? storage) + (.getItem storage key))) + (defn create-storage [backend prefix] (let [initial (load-data backend prefix) @@ -154,10 +165,10 @@ (-remove-watch [_ key] (.delete watches key))))) -(defonce global (create-storage local-storage-backend "penpot-global")) -(defonce user (create-storage local-storage-backend "penpot-user")) -(defonce storage (create-storage local-storage-backend "penpot")) -(defonce session (create-storage session-storage-backend "penpot")) +(defonce global (create-storage local-storage "penpot-global")) +(defonce user (create-storage local-storage "penpot-user")) +(defonce storage (create-storage local-storage "penpot")) +(defonce session (create-storage session-storage "penpot")) (defonce before-unload (letfn [(on-before-unload [_] From 084ca401fd5b66cab1fba3b57e904e0e7ea22cf8 Mon Sep 17 00:00:00 2001 From: Yamila Moreno Date: Tue, 31 Mar 2026 15:11:58 +0200 Subject: [PATCH 3/3] :books: Improve recommended settings for self-host (#8846) --- docs/technical-guide/configuration.md | 14 ++++++++++++++ .../getting-started/recommended-settings.md | 13 +++++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/docs/technical-guide/configuration.md b/docs/technical-guide/configuration.md index f48117b071..ad4579fcde 100644 --- a/docs/technical-guide/configuration.md +++ b/docs/technical-guide/configuration.md @@ -401,6 +401,20 @@ PENPOT_FLAGS: [...] enable-air-gapped-conf When Penpot starts, it will leave out the Nginx configuration related to external requests. This means that, with this flag enabled, the Penpot configuration will disable as well the libraries and templates dashboard and the use of Google fonts. +## High availability + +The mechanisms for installing Penpot in HA depend largely on how each infrastructure is managed. +In this section, we mention the key factors to consider when replicating a Penpot installation: + +The components that can be replicated are the `frontend`, the `backend`, and the `exporter`. +Replication management depends on the infrastructure, whether it's a load balancer or a Kubernetes deployment with HPA. + +In a high-availability (HA) scenario, managing the state outside of replicas is crucial. This affects the following components: + +- Database: Penpot typically operates with a single database instance. This database can also have a replica in case the primary instance fails. +- Valkey: Penpot only needs one Valkey instance to function correctly. Due to the nature of the data it manages, replication isn't even essential. +- User media storage: This should not be configured with local storage but rather with centralized storage, such as Kubernetes PVC or S3. + ## Backend This section enumerates the backend only configuration variables. diff --git a/docs/technical-guide/getting-started/recommended-settings.md b/docs/technical-guide/getting-started/recommended-settings.md index 4a36935b75..e0550ad430 100644 --- a/docs/technical-guide/getting-started/recommended-settings.md +++ b/docs/technical-guide/getting-started/recommended-settings.md @@ -3,9 +3,18 @@ title: 1.1 Recommended settings desc: Learn recommended self-hosting settings, Docker & Kubernetes installs, configuration, and troubleshooting tips in Penpot's technical guide. --- -# Recommended storage +# Recommended settings -Disk requirements depend on your usage, with the primary factors being database storage and user-uploaded files. +

+ These recommended settings do not cover specific high-availability setups, which will depend on your particular best practices. + Also, for air-gapped environments, please make sure to read this section of the guide. +

+ +Regarding **hardware requirements**, these will largely depend on the purpose of the installation. It's important to know that Penpot makes extensive use of the browser, so you need to ensure you have powerful end-user computers. To get the most out of Penpot, we recommend using the latest stable version of Chrome. + +Regarding the **server**, 4 CPUs and 16GB of RAM are sufficient to support thousands of users. So you can also be conservative when allocating resources to the instance. + +**Disk requirements** depend on your usage, with the primary factors being database storage and user-uploaded files. As a rule of thumb, start with a **minimum** database size of **50GB** to **100GB** with elastic sizing capability — this configuration should adequately support up to 10 editors. For environments with **more than 10 users**, we recommend adding approximately **5GB** of capacity per additional editor.