From f3b762855bd23ad5a8aaa9e30f02dd7869a50466 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Thu, 26 Mar 2026 18:20:54 +0100 Subject: [PATCH 1/3] :bug: Fix problem with position data in new render --- frontend/src/app/render_wasm/api.cljs | 69 +++++++++++++-------------- render-wasm/src/shapes/text.rs | 52 ++++++-------------- 2 files changed, 48 insertions(+), 73 deletions(-) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 0254324696..53d1477c98 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -1631,45 +1631,42 @@ (+ offset POSITION-DATA-U32-SIZE))) (persistent! result))) - result - (into [] - (keep - (fn [{:keys [paragraph span start-pos end-pos direction x y width height]}] - (let [content (:content shape) - element (-> content :children - (get 0) :children ;; paragraph-set - (get paragraph) :children ;; paragraph - (get span)) - element-text (:text element)] + content (:content shape)] - ;; Add comprehensive nil-safety checks - (when (and element - element-text - (>= start-pos 0) - (<= end-pos (count element-text)) - (<= start-pos end-pos)) - (let [text (subs element-text start-pos end-pos)] - (d/patch-object - txt/default-text-attrs - (d/without-nils - {:x x - :y (+ y height) - :width width - :height height - :direction (dr/translate-direction direction) - :font-family (get element :font-family) - :font-size (get element :font-size) - :font-weight (get element :font-weight) - :text-transform (get element :text-transform) - :text-decoration (get element :text-decoration) - :letter-spacing (get element :letter-spacing) - :font-style (get element :font-style) - :fills (get element :fills) - :text text}))))))) - result)] (mem/free) - result))) + (into [] + (keep + (fn [{:keys [paragraph span start-pos end-pos direction x y width height]}] + (let [element (-> content :children + (get 0) :children ;; paragraph-set + (get paragraph) :children ;; paragraph + (get span)) + element-text (:text element)] + + ;; Add comprehensive nil-safety checks + ;; Be aware that for RTL texts `start-pos` can be greatert han `end-pos` + (when (and element element-text) + (let [text (subs element-text start-pos end-pos)] + (d/patch-object + txt/default-text-attrs + (d/without-nils + {:x x + :y (+ y height) + :width width + :height height + :direction (dr/translate-direction direction) + :font-id (get element :font-id) + :font-family (get element :font-family) + :font-size (get element :font-size) + :font-weight (get element :font-weight) + :text-transform (get element :text-transform) + :text-decoration (get element :text-decoration) + :letter-spacing (get element :letter-spacing) + :font-style (get element :font-style) + :fills (get element :fills) + :text text}))))))) + result)))) (defn apply-canvas-blur [] diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 96adb52b28..d5592b591a 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -1378,67 +1378,45 @@ pub fn calculate_text_layout_data( let current_y = para_layout.y; let text_paragraph = text_paragraphs.get(paragraph_index); if let Some(text_para) = text_paragraph { - let mut span_ranges: Vec<(usize, usize, usize, String, String)> = vec![]; + let mut span_ranges: Vec<(usize, usize, usize)> = vec![]; let mut cur = 0; for (span_index, span) in text_para.children().iter().enumerate() { - let transformed_text: String = span.apply_text_transform(); - let original_text = span.text.clone(); - let text = transformed_text.clone(); - let text_len = text.len(); - span_ranges.push((cur, cur + text_len, span_index, text, original_text)); + let text: String = span.apply_text_transform(); + let text_len = text.encode_utf16().count(); + span_ranges.push((cur, cur + text_len + 1, span_index)); cur += text_len; } - for (start, end, span_index, transformed_text, original_text) in span_ranges { - // Skip empty spans to avoid invalid rect calculations - if start >= end { - continue; - } + for (start, end, span_index) in span_ranges { let rects = para_layout.paragraph.get_rects_for_range( start..end, RectHeightStyle::Tight, RectWidthStyle::Tight, ); + for textbox in rects { let direction = textbox.direct; let mut rect = textbox.rect; let cy = rect.top + rect.height() / 2.0; // Get byte positions from Skia's transformed text layout - let glyph_start = para_layout + let start_pos = para_layout .paragraph .get_glyph_position_at_coordinate((rect.left + 0.1, cy)) - .position as usize; - let glyph_end = para_layout + .position as usize + - start; + + let end_pos = para_layout .paragraph .get_glyph_position_at_coordinate((rect.right - 0.1, cy)) - .position as usize; - - // Convert to byte positions relative to this span - let byte_start = glyph_start.saturating_sub(start); - let byte_end = glyph_end.saturating_sub(start); - - // Convert byte positions to character positions in ORIGINAL text - // This handles multi-byte UTF-8 and text transform differences - let char_start = transformed_text - .char_indices() - .position(|(i, _)| i >= byte_start) - .unwrap_or(0); - let char_end = transformed_text - .char_indices() - .position(|(i, _)| i >= byte_end) - .unwrap_or_else(|| transformed_text.chars().count()); - - // Clamp to original text length for safety - let original_char_count = original_text.chars().count(); - let final_start = char_start.min(original_char_count); - let final_end = char_end.min(original_char_count); + .position as usize + - start; rect.offset((x, current_y)); position_data.push(PositionData { paragraph: paragraph_index as u32, span: span_index as u32, - start_pos: final_start as u32, - end_pos: final_end as u32, + start_pos: start_pos as u32, + end_pos: end_pos as u32, x: rect.x(), y: rect.y(), width: rect.width(), From 3be1ae2ac1b767c52c0ee2cead726abf21163dbe Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 27 Mar 2026 07:24:03 +0000 Subject: [PATCH 2/3] :bug: Guard against null focusNode/anchorNode in text-editor --- .../src/editor/controllers/SelectionController.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/text-editor/src/editor/controllers/SelectionController.js b/frontend/text-editor/src/editor/controllers/SelectionController.js index d647e6a948..d5c5ad7db4 100644 --- a/frontend/text-editor/src/editor/controllers/SelectionController.js +++ b/frontend/text-editor/src/editor/controllers/SelectionController.js @@ -961,7 +961,7 @@ export class SelectionController extends EventTarget { * @type {boolean} */ get isTextFocus() { - return this.focusNode.nodeType === Node.TEXT_NODE; + return this.focusNode != null && this.focusNode.nodeType === Node.TEXT_NODE; } /** @@ -970,7 +970,9 @@ export class SelectionController extends EventTarget { * @type {boolean} */ get isTextAnchor() { - return this.anchorNode.nodeType === Node.TEXT_NODE; + return ( + this.anchorNode != null && this.anchorNode.nodeType === Node.TEXT_NODE + ); } /** From d67c7f1c8eb22b8bf8a267e35bf0cd2bf33f0c46 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 27 Mar 2026 11:10:26 +0100 Subject: [PATCH 3/3] :sparkles: Add retry mechanism for idenpotent get repo requests on frontend (#8792) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :recycle: Handle fetch-error gracefully with toast instead of full-page error Network-level failures (lost connectivity, DNS failure, etc.) on RPC calls were propagating as :internal/:fetch-error to the global error handler, which replaced the entire UI with a full-page error screen. Now the :internal handler distinguishes :fetch-error from other internal errors and shows a non-intrusive toast notification instead, allowing the user to continue working. * :sparkles: Add automatic retry with backoff for idempotent RPC requests Idempotent (GET) RPC requests are now automatically retried up to 3 times with exponential back-off (1s, 2s, 4s) when a transient error occurs. Retryable errors include: network-level failures (:fetch-error), 502 Bad Gateway, 503 Service Unavailable, and browser offline (status 0). Mutation (POST) requests are never retried to avoid unintended side-effects. Non-transient errors (4xx client errors, auth errors, validation errors) propagate immediately without retry. * :recycle: Make retry helpers public with configurable parameters Make retryable-error? and with-retry public functions, and replace private constants with a default-retry-config map. with-retry now accepts an optional config map (:max-retries, :base-delay-ms) enabling callers and tests to customize retry behavior. * :sparkles: Add tests for RPC retry mechanism Comprehensive tests for the retry helpers in app.main.repo: - retryable-error? predicate: covers all retryable types (fetch-error, bad-gateway, service-unavailable, offline) and non-retryable types (validation, authentication, authorization, plain errors) - with-retry observable wrapper: verifies immediate success, recovery after transient failures, max-retries exhaustion, no retry for non-retryable errors, fetch-error retry, custom config, and mixed error scenarios * :recycle: Introduce :network error type for fetch-level failures Replace the awkward {:type :internal :code :fetch-error} combination with a proper {:type :network} type in app.util.http/fetch. This makes the error taxonomy self-explanatory and removes the special-case branch in the :internal handler. Consequences: - http.cljs: emit {:type :network} instead of {:type :internal :code :fetch-error} - errors.cljs: add a dedicated ptk/handle-error :network method (toast); restore :internal handler to its original unconditional full-page error form - repo.cljs: simplify retryable-types and retryable-error? — :network replaces the former :internal special-case, no code check needed - repo_test.cljs: update tests to use {:type :network} * :books: Add comment explaining the use of bit-shift-left --- frontend/src/app/main/errors.cljs | 9 + frontend/src/app/main/repo.cljs | 108 +++++++-- frontend/src/app/util/http.cljs | 3 +- .../test/frontend_tests/data/repo_test.cljs | 217 ++++++++++++++++++ frontend/test/frontend_tests/runner.cljs | 2 + 5 files changed, 315 insertions(+), 24 deletions(-) create mode 100644 frontend/test/frontend_tests/data/repo_test.cljs diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index 63402ae3f2..b1df018682 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -139,6 +139,15 @@ :level :error :timeout 5000}))) +(defmethod ptk/handle-error :network + [error] + ;; Transient network errors (e.g. lost connectivity, DNS failure) + ;; should not replace the entire page with an error screen. Show a + ;; non-intrusive toast instead and let the user continue working. + (when-let [cause (::instance error)] + (ex/print-throwable cause :prefix "Network Error")) + (flash :cause (::instance error) :type :handled)) + (defmethod ptk/handle-error :internal [error] (st/emit! (rt/assign-exception error)) diff --git a/frontend/src/app/main/repo.cljs b/frontend/src/app/main/repo.cljs index ad252e2a04..d2a0ff5068 100644 --- a/frontend/src/app/main/repo.cljs +++ b/frontend/src/app/main/repo.cljs @@ -21,6 +21,61 @@ (log/set-level! :info) +;; -- Retry helpers ----------------------------------------------------------- + +(def ^:private retryable-types + "Set of error types that are considered transient and safe to retry + for idempotent (GET) requests." + #{:network ; js/fetch network-level failure + :bad-gateway ; 502 + :service-unavailable ; 503 + :offline}) ; status 0 (browser offline) + +(defn retryable-error? + "Return true when `error` represents a transient failure that is safe + to retry. Only errors whose `ex-data` `:type` belongs to + `retryable-types` qualify." + [error] + (contains? retryable-types (:type (ex-data error)))) + +(def default-retry-config + "Default configuration for the retry mechanism on idempotent requests." + {:max-retries 3 + :base-delay-ms 1000}) + +(defn with-retry + "Wrap `observable-fn` (a zero-arg function returning an Observable) so + that retryable errors are retried up to `:max-retries` times with + exponential back-off. Non-retryable errors propagate immediately. + + Accepts an optional `config` map with: + :max-retries – maximum number of retries (default 3) + :base-delay-ms – base delay in ms; doubles each attempt (default 1000)" + ([observable-fn] + (with-retry observable-fn default-retry-config)) + ([observable-fn config] + (with-retry observable-fn config 0)) + ([observable-fn config attempt] + (let [{:keys [max-retries base-delay-ms]} (merge default-retry-config config)] + (->> (observable-fn) + (rx/catch + (fn [cause] + (if (and (retryable-error? cause) + (< attempt max-retries)) + ;; bit-shift-left 1 N is equivalent to 2^N: shift the bits of the + ;; number 1 to the left N positions (e.g. 1 -> 2 -> 4 -> 8 -> 16), + ;; producing exponential backoff delays of 1x, 2x, 4x, 8x, 16x. + (let [delay-ms (* base-delay-ms (bit-shift-left 1 attempt))] + (log/wrn :hint "retrying request" + :attempt (inc attempt) + :delay delay-ms + :error (ex-message cause)) + (->> (rx/timer delay-ms) + (rx/mapcat (fn [_] (with-retry observable-fn config (inc attempt)))))) + (rx/throw cause)))))))) + +;; -- Response handling ------------------------------------------------------- + (defn handle-response [{:keys [status body headers uri] :as response}] (cond @@ -146,32 +201,41 @@ (log/trc :hint "make request" :id id) - (->> (http/fetch request) - (rx/map http/response->map) - (rx/mapcat (fn [{:keys [headers body] :as response}] - (log/trc :hint "response received" :id id :elapsed (tpoint)) + (let [make-request + (fn [] + (->> (http/fetch request) + (rx/map http/response->map) + (rx/mapcat (fn [{:keys [headers body] :as response}] + (log/trc :hint "response received" :id id :elapsed (tpoint)) - (let [ctype (get headers "content-type") - response-stream? (str/starts-with? ctype "text/event-stream") - tpoint (ct/tpoint-ms)] + (let [ctype (get headers "content-type") + response-stream? (str/starts-with? ctype "text/event-stream") + tpoint (ct/tpoint-ms)] - (when (and response-stream? (not stream?)) - (ex/raise :type :assertion - :code :unexpected-response - :hint "expected normal response, received sse stream" - :uri (:uri response) - :status (:status response))) + (when (and response-stream? (not stream?)) + (ex/raise :type :assertion + :code :unexpected-response + :hint "expected normal response, received sse stream" + :uri (:uri response) + :status (:status response))) - (if response-stream? - (-> (sse/create-stream body) - (sse/read-stream t/decode-str)) + (if response-stream? + (-> (sse/create-stream body) + (sse/read-stream t/decode-str)) - (->> response - (http/process-response-type response-type) - (rx/map decode-fn) - (rx/tap (fn [_] - (log/trc :hint "response decoded" :id id :elapsed (tpoint)))) - (rx/mapcat handle-response))))))))) + (->> response + (http/process-response-type response-type) + (rx/map decode-fn) + (rx/tap (fn [_] + (log/trc :hint "response decoded" :id id :elapsed (tpoint)))) + (rx/mapcat handle-response))))))))] + + ;; Idempotent (GET) requests are automatically retried on + ;; transient network / server errors. Mutations are never + ;; retried to avoid unintended side-effects. + (if (= :get method) + (with-retry make-request) + (make-request))))) (defmulti cmd! (fn [id _] id)) diff --git a/frontend/src/app/util/http.cljs b/frontend/src/app/util/http.cljs index 8b9c590895..f2810d4ac0 100644 --- a/frontend/src/app/util/http.cljs +++ b/frontend/src/app/util/http.cljs @@ -108,8 +108,7 @@ (vreset! abortable? false) (when-not (or @unsubscribed? (= (.-name ^js cause) "AbortError")) (let [error (ex-info (ex-message cause) - {:type :internal - :code :fetch-error + {:type :network :hint "unable to perform fetch operation" :uri uri :headers headers} diff --git a/frontend/test/frontend_tests/data/repo_test.cljs b/frontend/test/frontend_tests/data/repo_test.cljs new file mode 100644 index 0000000000..d4ac101086 --- /dev/null +++ b/frontend/test/frontend_tests/data/repo_test.cljs @@ -0,0 +1,217 @@ +;; 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 + +(ns frontend-tests.data.repo-test + (:require + [app.main.repo :as repo] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true])) + +;; --------------------------------------------------------------------------- +;; retryable-error? tests (synchronous) +;; --------------------------------------------------------------------------- + +(t/deftest retryable-error-network + (t/testing "network error (js/fetch failure) is retryable" + (let [err (ex-info "network" {:type :network})] + (t/is (true? (repo/retryable-error? err)))))) + +(t/deftest retryable-error-bad-gateway + (t/testing "502 bad-gateway is retryable" + (let [err (ex-info "bad gateway" {:type :bad-gateway})] + (t/is (true? (repo/retryable-error? err)))))) + +(t/deftest retryable-error-service-unavailable + (t/testing "503 service-unavailable is retryable" + (let [err (ex-info "service unavailable" {:type :service-unavailable})] + (t/is (true? (repo/retryable-error? err)))))) + +(t/deftest retryable-error-offline + (t/testing "offline (status 0) is retryable" + (let [err (ex-info "offline" {:type :offline})] + (t/is (true? (repo/retryable-error? err)))))) + +(t/deftest retryable-error-internal + (t/testing "internal error (genuine bug) is NOT retryable" + (let [err (ex-info "internal" {:type :internal :code :something})] + (t/is (not (repo/retryable-error? err)))))) + +(t/deftest retryable-error-validation + (t/testing "validation error is NOT retryable" + (let [err (ex-info "validation" {:type :validation :code :request-body-too-large})] + (t/is (not (repo/retryable-error? err)))))) + +(t/deftest retryable-error-authentication + (t/testing "authentication error is NOT retryable" + (let [err (ex-info "auth" {:type :authentication})] + (t/is (not (repo/retryable-error? err)))))) + +(t/deftest retryable-error-authorization + (t/testing "authorization/challenge error is NOT retryable" + (let [err (ex-info "auth" {:type :authorization :code :challenge-required})] + (t/is (not (repo/retryable-error? err)))))) + +(t/deftest retryable-error-no-ex-data + (t/testing "plain error without ex-data is NOT retryable" + (let [err (js/Error. "plain")] + (t/is (not (repo/retryable-error? err)))))) + +;; --------------------------------------------------------------------------- +;; with-retry tests (async, using zero-delay config for speed) +;; --------------------------------------------------------------------------- + +(def ^:private fast-config + "Retry config with zero delay for fast tests." + {:max-retries 3 :base-delay-ms 0}) + +(t/deftest with-retry-succeeds-immediately + (t/testing "returns value when observable succeeds on first try" + (t/async done + (let [call-count (atom 0) + obs-fn (fn [] + (swap! call-count inc) + (rx/of :ok))] + (->> (repo/with-retry obs-fn fast-config) + (rx/subs! + (fn [val] + (t/is (= :ok val)) + (t/is (= 1 @call-count)) + (done)) + (fn [err] + (t/is false (str "unexpected error: " (ex-message err))) + (done)))))))) + +(t/deftest with-retry-retries-on-retryable-error + (t/testing "retries and eventually succeeds after transient failures" + (t/async done + (let [call-count (atom 0) + obs-fn (fn [] + (let [n (swap! call-count inc)] + (if (< n 3) + ;; First two calls fail with retryable error + (rx/throw (ex-info "bad gateway" {:type :bad-gateway})) + ;; Third call succeeds + (rx/of :recovered))))] + (->> (repo/with-retry obs-fn fast-config) + (rx/subs! + (fn [val] + (t/is (= :recovered val)) + (t/is (= 3 @call-count)) + (done)) + (fn [err] + (t/is false (str "unexpected error: " (ex-message err))) + (done)))))))) + +(t/deftest with-retry-exhausts-retries + (t/testing "propagates error after max retries exhausted" + (t/async done + (let [call-count (atom 0) + obs-fn (fn [] + (swap! call-count inc) + (rx/throw (ex-info "offline" {:type :offline})))] + (->> (repo/with-retry obs-fn fast-config) + (rx/subs! + (fn [_val] + (t/is false "should not succeed") + (done)) + (fn [err] + ;; 1 initial + 3 retries = 4 total calls + (t/is (= 4 @call-count)) + (t/is (= :offline (:type (ex-data err)))) + (done)))))))) + +(t/deftest with-retry-no-retry-on-non-retryable + (t/testing "non-retryable errors propagate immediately without retry" + (t/async done + (let [call-count (atom 0) + obs-fn (fn [] + (swap! call-count inc) + (rx/throw (ex-info "auth" {:type :authentication})))] + (->> (repo/with-retry obs-fn fast-config) + (rx/subs! + (fn [_val] + (t/is false "should not succeed") + (done)) + (fn [err] + (t/is (= 1 @call-count)) + (t/is (= :authentication (:type (ex-data err)))) + (done)))))))) + +(t/deftest with-retry-network-error-retried + (t/testing "network error (js/fetch failure) is retried" + (t/async done + (let [call-count (atom 0) + obs-fn (fn [] + (let [n (swap! call-count inc)] + (if (= n 1) + (rx/throw (ex-info "net" {:type :network})) + (rx/of :ok))))] + (->> (repo/with-retry obs-fn fast-config) + (rx/subs! + (fn [val] + (t/is (= :ok val)) + (t/is (= 2 @call-count)) + (done)) + (fn [err] + (t/is false (str "unexpected error: " (ex-message err))) + (done)))))))) + +(t/deftest with-retry-internal-not-retried + (t/testing "internal error (genuine bug) is not retried" + (t/async done + (let [call-count (atom 0) + obs-fn (fn [] + (swap! call-count inc) + (rx/throw (ex-info "bug" {:type :internal + :code :something})))] + (->> (repo/with-retry obs-fn fast-config) + (rx/subs! + (fn [_val] + (t/is false "should not succeed") + (done)) + (fn [err] + (t/is (= 1 @call-count)) + (t/is (= :internal (:type (ex-data err)))) + (done)))))))) + +(t/deftest with-retry-respects-max-retries-config + (t/testing "respects custom max-retries setting" + (t/async done + (let [call-count (atom 0) + config {:max-retries 1 :base-delay-ms 0} + obs-fn (fn [] + (swap! call-count inc) + (rx/throw (ex-info "offline" {:type :offline})))] + (->> (repo/with-retry obs-fn config) + (rx/subs! + (fn [_val] + (t/is false "should not succeed") + (done)) + (fn [err] + ;; 1 initial + 1 retry = 2 total + (t/is (= 2 @call-count)) + (t/is (= :offline (:type (ex-data err)))) + (done)))))))) + +(t/deftest with-retry-mixed-errors + (t/testing "retries retryable errors, then stops on non-retryable" + (t/async done + (let [call-count (atom 0) + obs-fn (fn [] + (let [n (swap! call-count inc)] + (case n + 1 (rx/throw (ex-info "gw" {:type :bad-gateway})) + 2 (rx/throw (ex-info "auth" {:type :authentication})) + (rx/of :should-not-reach))))] + (->> (repo/with-retry obs-fn fast-config) + (rx/subs! + (fn [_val] + (t/is false "should not succeed") + (done)) + (fn [err] + (t/is (= 2 @call-count)) + (t/is (= :authentication (:type (ex-data err)))) + (done)))))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index ad84056110..b39ba239d5 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -2,6 +2,7 @@ (:require [cljs.test :as t] [frontend-tests.basic-shapes-test] + [frontend-tests.data.repo-test] [frontend-tests.data.workspace-colors-test] [frontend-tests.helpers-shapes-test] [frontend-tests.logic.comp-remove-swap-slots-test] @@ -35,6 +36,7 @@ [] (t/run-tests 'frontend-tests.basic-shapes-test + 'frontend-tests.data.repo-test 'frontend-tests.data.workspace-colors-test 'frontend-tests.helpers-shapes-test 'frontend-tests.logic.comp-remove-swap-slots-test