fix: guard stale web-image responses and avoid layout jump

Two related bugs around the web-images section:

1. Fast typing produced overlapping fetches where a late 'do' response
   could overwrite results for 'donald trump'. Stamp each request with
   a generation id in a component-local atom and drop responses whose
   id no longer matches.

2. The section unmounted during transition states — while the parent's
   debounced query hadn't caught up yet, or after the user typed but
   before loading? flipped to true — so the layout below jumped up,
   then back down when skeletons appeared. Compute a pending? flag
   from both conditions, keep the section mounted whenever loading? or
   pending?, and mirror avatar-mode on skeletons so circle-mode loads
   land in the exact spot the circular skeletons occupied. CSS mirror
   the same geometry (transparent 2px border + avatar-mode 50% radius)
   on .web-image-placeholder.
This commit is contained in:
scheinriese
2026-04-23 14:49:43 +02:00
parent 605226fc6e
commit d66af6ec6f
2 changed files with 64 additions and 22 deletions

View File

@@ -1865,61 +1865,87 @@
(rum/local nil ::images)
(rum/local true ::loading?)
(rum/local nil ::current-query)
;; Generation counter — responses whose id no longer matches are stale
;; (e.g. a "do" prefix response arriving after "donald trump" was issued)
;; and must not overwrite the current images.
(rum/local 0 ::request-id)
{:did-mount (fn [state]
(let [[{:keys [query]}] (:rum/args state)
*images (::images state)
*loading? (::loading? state)
*current-query (::current-query state)]
*current-query (::current-query state)
*request-id (::request-id state)]
(when-not (string/blank? query)
(reset! *current-query query)
(reset! *loading? true)
(-> (<search-web-images query)
(p/then (fn [results]
(reset! *images results)
(reset! *loading? false)))
(p/catch (fn [_err]
(reset! *images [])
(reset! *loading? false))))))
(let [my-id (swap! *request-id inc)]
(-> (<search-web-images query)
(p/then (fn [results]
(when (= my-id @*request-id)
(reset! *images results)
(reset! *loading? false))))
(p/catch (fn [_err]
(when (= my-id @*request-id)
(reset! *images [])
(reset! *loading? false))))))))
state)
:did-update (fn [state]
(let [[{:keys [query]}] (:rum/args state)
*images (::images state)
*loading? (::loading? state)
*current-query (::current-query state)
*request-id (::request-id state)
current-query @*current-query]
;; Only refetch if query changed
(when (and (not= query current-query)
(not (string/blank? query)))
(reset! *current-query query)
(reset! *loading? true)
(-> (<search-web-images query)
(p/then (fn [results]
(reset! *images results)
(reset! *loading? false)))
(p/catch (fn [_err]
(reset! *images [])
(reset! *loading? false))))))
(let [my-id (swap! *request-id inc)]
(-> (<search-web-images query)
(p/then (fn [results]
(when (= my-id @*request-id)
(reset! *images results)
(reset! *loading? false))))
(p/catch (fn [_err]
(when (= my-id @*request-id)
(reset! *images [])
(reset! *loading? false))))))))
state)}
"Renders the web images section with loading states.
query: search query (page title or user input)
on-select: callback when user selects a web image
avatar-context: if set, picker is in avatar mode
on-popover-change: callback when confirmation popover opens/closes"
[state {:keys [query on-select avatar-context on-popover-change]}]
[state {:keys [query on-select avatar-context on-popover-change user-typing?]}]
(let [*images (::images state)
*loading? (::loading? state)
*current-query (::current-query state)
images (rum/react *images)
loading? (rum/react *loading?)
current-query (rum/react *current-query)
;; `pending?` captures two transition states where skeletons should
;; show even though `loading?` hasn't flipped yet:
;; 1. `user-typing?` — user has typed but the 500ms debounce hasn't
;; caught up yet; no fetch has been issued.
;; 2. `(not= query current-query)` — parent passed a new query but
;; `:did-update` hasn't yet set `*loading? true`.
pending? (or user-typing?
(and (not (string/blank? query))
(not= query current-query)))
show-loading? (or loading? pending?)
avatar-mode? (some? avatar-context)
skip-confirm? (get-web-image-skip-confirm)
web-expanded? (get (rum/react *section-states) "Web images" true)]
;; Don't render section if no query or empty results after loading
(when-not (and (not loading?) (empty? images) (not (string/blank? query)))
;; Hide only when a settled fetch returned no results. During any
;; transition (loading? or pending?) we keep the section mounted and
;; show skeletons so the layout below doesn't jump.
(when-not (and (not show-loading?) (empty? images))
[:div.pane-section.web-images-section
;; Section header with info icon
[:div.section-header-row
(section-header {:title "Web images"
:count (when-not loading? (count images))
:count (when-not show-loading? (count images))
:expanded? web-expanded?
:on-toggle #(swap! *section-states update "Web images" (fn [v] (if (nil? v) false (not v))))})
(shui/tooltip-provider
@@ -1937,10 +1963,12 @@
(when web-expanded?
[:div.asset-picker-grid.web-images-row
{:class (when avatar-mode? "avatar-mode")}
(if loading?
;; Loading skeletons
(if show-loading?
;; Loading skeletons — inherit avatar-mode so they render as circles
(for [i (range 5)]
[:div.web-image-placeholder {:key (str "skeleton-" i)}
[:div.web-image-placeholder
{:key (str "skeleton-" i)
:class (when avatar-mode? "avatar-mode")}
(shui/skeleton {:class "w-full h-full rounded"})])
;; Actual images
(for [web-image images]
@@ -2499,6 +2527,11 @@
(when-not (string/blank? effective-web-query)
(web-images-section
{:query effective-web-query
;; True on the first keystroke, before the 500ms debounce has
;; caught web-query up to search-q. Lets the child switch to
;; skeletons immediately instead of waiting for the debounce.
:user-typing? (and (not (string/blank? search-q))
(not= search-q web-query))
:avatar-context avatar-context
:on-select handle-web-image-select
:on-popover-change #(reset! *popover-open? %)}))

View File

@@ -1033,10 +1033,14 @@
}
/* Web image placeholder (loading skeleton) */
/* Mirrors `.web-image-item` geometry (border + aspect ratio) so the
skeleton circle lands in the exact same spot as the loaded image — no
vertical jump when results come in. */
.web-image-placeholder {
position: relative;
width: 100%;
padding-bottom: 100%; /* Square aspect ratio */
border: 2px solid transparent;
border-radius: 6px;
overflow: hidden;
@@ -1044,6 +1048,11 @@
position: absolute;
inset: 0;
}
&.avatar-mode,
&.avatar-mode > * {
border-radius: 50%;
}
}
/* Bordered tooltip arrow - CSS-positioned (Base UI technique) */