Andrey Antukh 2041473cc4
🐛 Add regression test for viewer zoom url loop (#11821)
Lock in the fix from 31b73460c3 (#11803) with a regression
test for the exact reported scenario: loading the viewer
with a URL that already contains `zoom=fill`.

At 2.18.0-RC5 `update-zoom-querystring` navigated without
any comparison, so the load sequence bundle-fetched →
zoom-to-fill → update-zoom-querystring → nav → navigated
re-ran forever and crashed the page with React error #185
("maximum update depth exceeded"). The guard added in
31b73460c3 breaks the cycle; the new test asserts that a
bundle fetch against a `zoom=fill` route emits no
navigation events.

Also updates the dashboard/viewer frontend memory to
document the guard and the loop it prevents.

AI-assisted-by: glm-5.3-flash
2026-09-22 14:58:32 +02:00

154 lines
6.9 KiB
Clojure

;; 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.data.viewer-test
(:require
[app.common.uuid :as uuid]
[app.main.data.viewer :as dv]
[app.main.router :as rt]
[beicon.v2.core :as rx]
[cljs.test :as t]
[potok.v2.core :as ptk]))
(def ^:private page-id
(uuid/custom 1 1))
(defn- base-state
"Build a minimal viewer state with the given frames and query-params."
[{:keys [frames index]}]
{:route {:params {:query {:page-id (str page-id)
:index (str index)}}}
:viewer {:pages {page-id {:frames frames}}}
:viewer-local {:viewport-size {:width 1000 :height 800}}})
(t/deftest zoom-to-fit-clamps-out-of-bounds-index
(t/testing "index exceeds frame count"
(let [state (base-state {:frames [{:selrect {:width 100 :height 100}}]
:index 1})
result (ptk/update dv/zoom-to-fit state)]
(t/is (= (get-in result [:viewer-local :zoom-type]) :fit))
(t/is (number? (get-in result [:viewer-local :zoom])))))
(t/testing "index is zero with single frame (normal case)"
(let [state (base-state {:frames [{:selrect {:width 100 :height 100}}]
:index 0})
result (ptk/update dv/zoom-to-fit state)]
(t/is (= (get-in result [:viewer-local :zoom-type]) :fit))
(t/is (number? (get-in result [:viewer-local :zoom])))))
(t/testing "index within valid range with multiple frames"
(let [state (base-state {:frames [{:selrect {:width 100 :height 100}}
{:selrect {:width 200 :height 200}}]
:index 1})
result (ptk/update dv/zoom-to-fit state)]
(t/is (= (get-in result [:viewer-local :zoom-type]) :fit))
(t/is (number? (get-in result [:viewer-local :zoom]))))))
(t/deftest zoom-to-fill-clamps-out-of-bounds-index
(t/testing "index exceeds frame count"
(let [state (base-state {:frames [{:selrect {:width 100 :height 100}}]
:index 1})
result (ptk/update dv/zoom-to-fill state)]
(t/is (= (get-in result [:viewer-local :zoom-type]) :fill))
(t/is (number? (get-in result [:viewer-local :zoom])))))
(t/testing "index is zero with single frame (normal case)"
(let [state (base-state {:frames [{:selrect {:width 100 :height 100}}]
:index 0})
result (ptk/update dv/zoom-to-fill state)]
(t/is (= (get-in result [:viewer-local :zoom-type]) :fill))
(t/is (number? (get-in result [:viewer-local :zoom])))))
(t/testing "index within valid range with multiple frames"
(let [state (base-state {:frames [{:selrect {:width 100 :height 100}}
{:selrect {:width 200 :height 200}}]
:index 1})
result (ptk/update dv/zoom-to-fill state)]
(t/is (= (get-in result [:viewer-local :zoom-type]) :fill))
(t/is (number? (get-in result [:viewer-local :zoom]))))))
(defn- watch-events
"Collect the events an event's watch emits synchronously."
[event state]
(let [out (atom [])]
(some-> (ptk/watch event state nil)
(rx/subscribe #(swap! out conj %)))
@out))
(defn- zoom-state
"Build a viewer state with the given `:zoom` query param and zoom type."
[zoom-param zoom-type]
{:route {:params {:query (cond-> {:page-id (str page-id) :index "0"}
(some? zoom-param)
(assoc :zoom zoom-param))}}
:viewer-local (cond-> {}
(some? zoom-type)
(assoc :zoom-type zoom-type))})
(t/deftest update-zoom-querystring-does-not-navigate-when-url-already-matches
(t/testing "zoom type already described by the query string"
(t/is (empty? (watch-events dv/update-zoom-querystring
(zoom-state "fit" :fit)))))
(t/testing "no zoom type and no zoom query param"
(t/is (empty? (watch-events dv/update-zoom-querystring
(zoom-state nil nil))))))
(t/deftest update-zoom-querystring-navigates-when-zoom-changes
(t/testing "zoom type differs from the query string"
(let [events (watch-events dv/update-zoom-querystring
(zoom-state "fit" :fill))
{:keys [id params options]} (some-> (first events) deref)]
(t/is (= 1 (count events)))
(t/is (= :viewer id))
(t/is (= :fill (:zoom params)))
(t/is (true? (::rt/replace options)))))
(t/testing "zoom query param absent, other params preserved"
(let [events (watch-events dv/update-zoom-querystring
(zoom-state nil :fit))
{:keys [params]} (some-> (first events) deref)]
(t/is (= 1 (count events)))
(t/is (= :fit (:zoom params)))
(t/is (= (str page-id) (:page-id params)))
(t/is (= "0" (:index params)))))
(t/testing "zoom type cleared drops the query param"
(let [events (watch-events dv/update-zoom-querystring
(zoom-state "fit" nil))
{:keys [params]} (some-> (first events) deref)]
(t/is (= 1 (count events)))
(t/is (not (contains? params :zoom))))))
(t/deftest bundle-fetched-with-zoom-fill-url-does-not-navigate
;; Regression test for the React "maximum update depth exceeded"
;; error (2.18.0-RC5): loading the viewer with a URL that already
;; contains `zoom=fill` re-entered the cycle zoom-to-fill →
;; update-zoom-querystring → nav → navigated → zoom-to-fill…,
;; because update-zoom-querystring navigated unconditionally.
;; At HEAD the guard breaks the cycle, so a bundle fetch must not
;; emit any navigation.
(let [state (-> (base-state {:frames [{:selrect {:width 100 :height 100}}]
:index 0})
(assoc-in [:route :query-params :zoom] "fill"))]
(let [events (watch-events (dv/bundle-fetched
{:file {:id page-id
:data {:pages [page-id]
:pages-index {page-id {:objects {}}}}}
:project {}
:team {:features []}
:share-links []
:libraries []
:users []
:permissions {}
:thumbnails {}})
state)
;; `update-page-position-data` and `go-to-frame-auto` are part
;; of the normal init sequence; the navigation events are the
;; ones that can restart the zoom cycle.
navigations (filter #(= :app.main.router/navigate (ptk/type %)) events)]
(t/is (empty? navigations)))))