🐛 Fix missing text in legacy SVG board thumbnails (#11552)

* 🐛 Fix missing text in legacy SVG board thumbnails

Board thumbnails rendered by frame-imposter used React's
renderToStaticMarkup, a synchronous pass with no live DOM to measure
text against. Text shapes without a persisted position-data value
therefore rendered as nothing, so a cached board thumbnail silently
lost its text until the board was hovered, selected, or the canvas
was zoomed past 130%, all of which bypass the cached thumbnail in
favor of live content.

frame-imposter now provides the same is-render? context the
standalone exporter already sets, so text without position-data
falls back to the synchronous foreignObject renderer instead of
rendering nothing.

Thumbnails cached before this fix stay broken until something
regenerates them, so on each page load, board thumbnails containing
text are opportunistically regenerated once per browser (tracked via
local-storage) so existing files self-heal without requiring an
edit.

AI-assisted-by: claude-sonnet-5

* ♻️ Use a transducer in heal-stale-text-thumbnails

Fixes a PR review comment: the frame filtering/mapping was spread
across four separate rx operators. Collapse it into a single
transducer pass over frame-ids, leaving only one rx/map to perform
the mark-healed side effect and build the update-thumbnail action.
This commit is contained in:
Eva Marco 2026-09-09 09:56:10 +02:00 committed by GitHub
parent e96a75d366
commit 011feeaf71
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 65 additions and 11 deletions

View File

@ -12,6 +12,7 @@
[app.common.thumbnails :as thc]
[app.common.time :as ct]
[app.common.types.component :as ctc]
[app.common.types.shape-tree :as ctt]
[app.common.uuid :as uuid]
[app.main.data.changes :as dch]
[app.main.data.helpers :as dsh]
@ -23,6 +24,7 @@
[app.main.render :as render]
[app.main.repo :as rp]
[app.util.queue :as q]
[app.util.storage :as storage]
[app.util.timers :as tm]
[app.util.webapi :as wapi]
[beicon.v2.core :as rx]
@ -289,13 +291,60 @@
(mapcat get-frame-ids-cached))
changes))))
;; Board thumbnails used to render text shapes without position-data as
;; nothing instead of falling back to the foreignObject renderer (see
;; frame-imposter in app.main.render), so any board thumbnail cached before
;; that fix may be missing its text. The backend doesn't tell the client
;; when a fetched thumbnail was generated, so we can't tell stale apart from
;; fresh by inspecting it; instead each board thumbnail with text content is
;; regenerated at most once per browser, tracked via local-storage so repeat
;; visits (once healed) don't keep re-rendering it.
(def ^:private healed-storage-key ::healed-text-thumbnails)
(defn- frame-has-text?
[objects frame-id]
(->> (cfh/get-children-with-self objects frame-id)
(some cfh/text-shape?)
(some?)))
(defn- unhealed-text-thumbnail?
[state object-id]
(and (some? (dm/get-in state [:thumbnails object-id :uri]))
(not (contains? (get @storage/global healed-storage-key) object-id))))
(defn- mark-thumbnail-healed!
[object-id]
(swap! storage/global update healed-storage-key (fnil conj #{}) object-id))
(defn- heal-stale-text-thumbnails
"Emits an `update-thumbnail` for every board on the page that has text
content and hasn't already been healed (see `healed-storage-key`) in this
browser."
[state file-id page-id]
(let [objects (-> (dsh/lookup-file-data state file-id)
(dsh/get-page page-id)
:objects)
frame-ids (ctt/get-root-frames-ids objects)
xf (comp
(filter #(frame-has-text? objects %))
(keep
(fn [frame-id]
(let [object-id (thc/fmt-object-id file-id page-id frame-id "frame")]
(when (unhealed-text-thumbnail? state object-id)
[frame-id object-id])))))]
(->> (rx/from (eduction xf frame-ids))
(rx/map
(fn [[frame-id object-id]]
(mark-thumbnail-healed! object-id)
(update-thumbnail file-id page-id frame-id "frame" "heal-stale-text-thumbnails"))))))
(defn watch-state-changes
"Watch the state for changes inside frames. If a change is detected will force a rendering
of the frame data so the thumbnail can be updated."
[file-id page-id]
(ptk/reify ::watch-state-changes
ptk/WatchEvent
(watch [_ _ stream]
(watch [_ state stream]
(let [stopper-s (rx/filter
(fn [event]
(as-> (ptk/type event) type
@ -330,6 +379,10 @@
(rx/tap #(l/trc :hint "buffer initialized")))]
(->> (rx/merge
;; Heal boards with text whose cached thumbnail may predate the
;; text-position fix (see heal-stale-text-thumbnails).
(heal-stale-text-thumbnails state file-id page-id)
;; Perform instant thumbnail cleaning of affected frames
;; and interrupt any ongoing update-thumbnail process
;; related to current frame-id

View File

@ -266,16 +266,17 @@
[{:keys [objects frame vbox x y width height background]}]
(let [shape-wrapper (shape-wrapper-factory objects)]
[:& (mf/provider muc/render-thumbnails) {:value false}
[:svg {:view-box vbox
:width (ust/format-precision width viewbox-decimal-precision)
:height (ust/format-precision height viewbox-decimal-precision)
:version "1.1"
:xmlns "http://www.w3.org/2000/svg"
:xmlnsXlink "http://www.w3.org/1999/xlink"
:fill "none"}
(when (some? background)
[:rect {:x x :y y :width width :height height :fill background}])
[:& shape-wrapper {:shape frame}]]]))
[:& (mf/provider muc/is-render?) {:value true}
[:svg {:view-box vbox
:width (ust/format-precision width viewbox-decimal-precision)
:height (ust/format-precision height viewbox-decimal-precision)
:version "1.1"
:xmlns "http://www.w3.org/2000/svg"
:xmlnsXlink "http://www.w3.org/1999/xlink"
:fill "none"}
(when (some? background)
[:rect {:x x :y y :width width :height height :fill background}])
[:& shape-wrapper {:shape frame}]]]]))
;; Component that serves for render frame thumbnails, mainly used in
;; the viewer and inspector