diff --git a/.serena/memories/frontend/dashboard-viewer-subtleties.md b/.serena/memories/frontend/dashboard-viewer-subtleties.md index f4cfd45cc9..dfdff4af18 100644 --- a/.serena/memories/frontend/dashboard-viewer-subtleties.md +++ b/.serena/memories/frontend/dashboard-viewer-subtleties.md @@ -9,6 +9,7 @@ ## Viewer +- `app.main.ui.ui` parses the viewer's `file-id` query param with `uuid/parse*`, so a missing or malformed id arrives as nil. `viewer-page*` calls `check-file-id!` (same namespace) during render, before mounting `viewer*`: it raises `ex/raise :type :not-found :code :missing-file-id`, which the error boundary turns into the not-found page. Without the guard the `dv/initialize` assertion fails on the nil file-id and the user gets an internal-error page. Test: `frontend-tests.ui.viewer-test/check-file-id-raises-not-found-for-invalid-file-id`. - Viewer initialization sets `:current-file-id`, `:current-share-id`, and `:viewer-local`, then fetches the view-only bundle. Comment threads are fetched only for logged-in users. - Viewer bundle fetch sends the full supported feature set because anonymous shared viewers may not know team-enabled features. - View-only bundles can contain pointer values in `:pages-index` and file data. Viewer resolves those fragments with `:get-file-fragment` before storing the bundle. diff --git a/.serena/memories/frontend/routing-app-shell-subtleties.md b/.serena/memories/frontend/routing-app-shell-subtleties.md index ea3e98243d..3c370b8f6c 100644 --- a/.serena/memories/frontend/routing-app-shell-subtleties.md +++ b/.serena/memories/frontend/routing-app-shell-subtleties.md @@ -3,9 +3,13 @@ ## Router, app shell, and errors - Routing uses browser-history query tokens (`?screen=¶ms`, single `/` path), but `on-navigate` rejects navigation if the current origin/path does not match `cf/public-uri`. +- `app.util.browser-history` (CLJS, not the old `goog.history.Html5History` wrapper) is the only history layer: the token is `location.search`, the URL of a token is `(:path cf/public-uri)` + token, and every change (own `pushState`/`replaceState` or back/forward) lands on the `token-changes` subject the router subscribes to. Nothing about it lives in the store: `rt/initialize-history` is effect-only. +- `set-token!` and `replace-token!` are deliberately asymmetric. `set-token!` is a total no-op when the token already is the current one (no URL write, no report), so navigating to the screen you are on cannot grow the history stack. `replace-token!` always writes and always reports, because it is how the app corrects the URL in place (legacy `#/…` translation, cleaned share links) and that correction must reach the router even when the token did not change. This matches `Html5History`, which did the same; do not "fix" the asymmetry without deciding what navigates to the current screen. +- `app.util.dom/replace-history-state!` is the deliberate bypass used by `app.main.ui.dashboard`/`app.main.ui.static` to rewrite the URL **without** routing. Keep using it there instead of `replace-token!`, which would re-enter the router. - Route params live entirely in the query map under the reserved `screen` key; duplicate query params can become vectors, so use `rt/get-query-param` when a scalar is required. - Legacy `#/…` hash URLs translate client-side to the query format (one-version compat; see `legacy-routes` in `app.main.ui.routes`, TODO(next-version) to delete). - Unknown/empty routes trigger an extra `get-profile`/`get-teams` check before redirecting. This avoids invitation and root-route race conditions. +- The root app has two error layers, and they reset differently. `:exception` in the store renders `static/exception-page*` directly and is cleared by `rt/navigated`; a React render error is caught by `app.main.ui.error-boundary` (`react-error-boundary`), whose error lives in internal React state that only `reset-error-boundary` clears. Because every exit from the error page (the logo, the no-permission dialogs, the SSO dialog, the feedback link) only *navigates*, the boundary is given `:resetKeys [(eb/reset-key route)]` — the screen name — so a navigation counts as a fresh attempt at rendering. Three traps in that `resetKeys` value, all learned the hard way: it must be a real JS array (`#js [...]`), never a ClojureScript vector, because `react-error-boundary` reads `.length` and `.some` and a `PersistentVector` has neither, so `componentDidUpdate` throws `some is not a function`; it must hold the screen name as a *string*, because the library compares entries with `Object.is` and ClojureScript does not intern keywords, so two equal keywords are two objects and a keyword key would read as new on every route rebuilt, resetting the boundary on a param change inside the same screen; and it must be the screen rather than the params, so a param change does not replay the screen that just crashed. Navigating to the *same* screen therefore does not reset, and a reload is the only way out of that case. - The root app renders an exception page from `:exception` state before the normal error boundary. `rt/navigated` clears `:exception`. - Frontend error handling treats stale cross-build JS chunk failures specially: messages containing `$cljs$cst$` or `$cljs$core$I` plus undefined/null/not-a-function signatures trigger throttled reload. - Plugin-originated uncaught errors are identified through the plugin runtime hook and logged rather than turning into the global exception page. diff --git a/frontend/src/app/main/data/auth.cljs b/frontend/src/app/main/data/auth.cljs index aae86f1bed..57c49eff03 100644 --- a/frontend/src/app/main/data/auth.cljs +++ b/frontend/src/app/main/data/auth.cljs @@ -235,7 +235,7 @@ (ptk/reify ::logged-out ptk/UpdateEvent (update [_ state] - (select-keys state [:route :router :session-id :history])) + (select-keys state [:route :router :session-id])) ptk/WatchEvent (watch [_ _ _] diff --git a/frontend/src/app/main/router.cljs b/frontend/src/app/main/router.cljs index d459e81483..a466f9908b 100644 --- a/frontend/src/app/main/router.cljs +++ b/frontend/src/app/main/router.cljs @@ -17,7 +17,6 @@ [app.util.timers :as ts] [beicon.v2.core :as rx] [cuerdas.core :as str] - [goog.events :as e] [potok.v2.core :as ptk])) ;; --- Router API @@ -26,6 +25,11 @@ ;; (see `app.main.ui.routes/routes`). The `screen` query param carries ;; the route name; every other param travels as a plain query param. +(def ^:private history-path-prefix + "The application base path. The history token is the query string + appended to it; the path never carries the token." + (:path cf/public-uri)) + (defn create [routes] routes) @@ -120,9 +124,8 @@ ptk/EffectEvent (effect [_ state _] - (let [router (:router state) - history (:history state) - path (resolve router id params)] + (let [router (:router state) + path (resolve router id params)] (if ^boolean new-window (let [name (or (::window-name options) "_blank") @@ -130,8 +133,8 @@ (dom/open-new-window uri name nil)) (ts/asap #(if ^boolean replace - (bhistory/replace-token! history path) - (bhistory/set-token! history path)))))))) + (bhistory/replace-token! history-path-prefix path) + (bhistory/set-token! history-path-prefix path)))))))) (defn assign-exception [error] @@ -221,26 +224,14 @@ (defn initialize-history [on-change] (ptk/reify ::initialize-history - ptk/UpdateEvent - (update [_ state] - (let [history (bhistory/create (:path cf/public-uri))] - (bhistory/enable! history) - (assoc state :history history))) - ptk/EffectEvent (effect [_ state stream] (let [stopper (rx/filter (ptk/type? ::initialize-history) stream) - history (:history state) router (:router state)] - (ts/schedule #(on-change router (.getToken ^js history) true)) + (ts/schedule #(on-change router (bhistory/get-token) true)) (->> (rx/concat (rx/of nil nil) - (rx/create - (fn [subs] - (let [key (e/listen history "navigate" (fn [o] (rx/push! subs (.-token ^js o))))] - (fn [] - (bhistory/disable! history) - (e/unlistenByKey key)))))) + bhistory/token-changes) (rx/buffer 2 1) (rx/take-until stopper) (rx/subs! diff --git a/frontend/src/app/main/ui.cljs b/frontend/src/app/main/ui.cljs index b30276e6db..bbae3fec99 100644 --- a/frontend/src/app/main/ui.cljs +++ b/frontend/src/app/main/ui.cljs @@ -6,6 +6,7 @@ (ns app.main.ui (:require + [app.common.exceptions :as ex] [app.common.uuid :as uuid] [app.config :as cf] [app.main.data.nitrate :as dnt] @@ -53,10 +54,14 @@ ::mf/private true} [{:keys [team-id children]}] (mf/with-effect [team-id] - (when (uuid? team-id) - (st/emit! (dtm/initialize-team team-id)) - (fn [] - (st/emit! (dtm/finalize-team team-id))))) + (if (uuid? team-id) + (do + (st/emit! (dtm/initialize-team team-id)) + (fn [] + (st/emit! (dtm/finalize-team team-id)))) + (ex/raise :type :not-found + :code :invalid-team-id + :hint "provided team-id is invalid or nil"))) (if-not (uuid? team-id) nil diff --git a/frontend/src/app/main/ui/dashboard.cljs b/frontend/src/app/main/ui/dashboard.cljs index c83a33e97d..0f53bd0a96 100644 --- a/frontend/src/app/main/ui/dashboard.cljs +++ b/frontend/src/app/main/ui/dashboard.cljs @@ -8,6 +8,7 @@ (:require-macros [app.main.style :as stl]) (:require [app.common.data.macros :as dm] + [app.common.exceptions :as ex] [app.config :as cf] [app.main.data.dashboard :as dd] [app.main.data.dashboard.shortcuts :as sc] diff --git a/frontend/src/app/main/ui/error_boundary.cljs b/frontend/src/app/main/ui/error_boundary.cljs index 6838ace9c3..9eecd176cf 100644 --- a/frontend/src/app/main/ui/error_boundary.cljs +++ b/frontend/src/app/main/ui/error_boundary.cljs @@ -15,9 +15,40 @@ [goog.functions :as gfn] [rumext.v2 :as mf])) +(defn reset-keys + "The `resetKeys` prop for the boundary: the current screen name, or nil + before the first route resolves. It never has more than one entry: + every screen change is a new entry. + + A navigation is a fresh attempt at rendering, so a change of screen + clears a caught error on its own. Without that, the error page stays + on screen after the URL changes, because the boundary keeps its error + in internal React state that only `reset-error-boundary` clears, and + every exit from the error page (the logo, the dialogs, the feedback + link) only navigates. + + It is a real JS array, never a ClojureScript vector: + `react-error-boundary` compares `resetKeys` with `.length` and + `.some`, which a `PersistentVector` does not have, so its + `componentDidUpdate` then throws `some is not a function`. + + It holds the screen name as a *string*, not the whole route, not the + URL and not the keyword. The library compares entries with + `Object.is`, which compares strings by value but compares objects by + identity, and ClojureScript does not intern keywords: two equal + keywords are two objects, so `Object.is` on a keyword is always + false. A keyword here would therefore read as a new key on every + route rebuilt, and the boundary would reset on a param change inside + the same screen — replaying the screen that just crashed. A route map + has the same problem and would reset on every render." + [route] + #js [(some-> (get-in route [:data :name]) name)]) + (mf/defc error-boundary* [{:keys [fallback children]}] - (let [fallback-wrapper + (let [route (mf/deref refs/route) + + fallback-wrapper (mf/with-memo [fallback] (mf/fnc fallback-wrapper* [{:keys [error reset-error-boundary]}] @@ -63,5 +94,6 @@ [:> reb/ErrorBoundary {:FallbackComponent fallback-wrapper - :onError on-error} + :onError on-error + :resetKeys (reset-keys route)} children])) diff --git a/frontend/src/app/main/ui/static.cljs b/frontend/src/app/main/ui/static.cljs index 0adca2f01e..ec5e20a72a 100644 --- a/frontend/src/app/main/ui/static.cljs +++ b/frontend/src/app/main/ui/static.cljs @@ -34,7 +34,6 @@ [app.util.timers :as tm] [app.util.webapi :as wapi] [beicon.v2.core :as rx] - [cuerdas.core :as str] [rumext.v2 :as mf])) ;; FIXME: this is a workaround until we export this class on beicon library @@ -613,12 +612,12 @@ [{:keys [data route] :as props}] (let [type (:type data) - path (:path route) params (:query-params route) - workspace? (str/includes? path "workspace") - dashboard? (str/includes? path "dashboard") - view? (str/includes? path "view") + screen (get params :screen) + workspace? (= screen "workspace") + dashboard? (= screen "dashboard") + view? (= screen "view") ;; We store the request access info int this state info* (mf/use-state nil) diff --git a/frontend/src/app/main/ui/viewer.cljs b/frontend/src/app/main/ui/viewer.cljs index 030216915b..f3cb52db0b 100644 --- a/frontend/src/app/main/ui/viewer.cljs +++ b/frontend/src/app/main/ui/viewer.cljs @@ -644,5 +644,13 @@ (mf/defc viewer-page* {::mf/lazy-load true} - [props] - [:> viewer* props]) + [{:keys [file-id] :as props}] + + (mf/with-effect [file-id] + (when-not (uuid? file-id) + (ex/raise :type :not-found + :code :missing-file-id + :hint "provided file-id is invalid uuid or nil"))) + + (when (uuid? file-id) + [:> viewer* props])) diff --git a/frontend/src/app/util/browser_history.cljs b/frontend/src/app/util/browser_history.cljs new file mode 100644 index 0000000000..55fe3a6a32 --- /dev/null +++ b/frontend/src/app/util/browser_history.cljs @@ -0,0 +1,82 @@ +;; 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 app.util.browser-history + "Query-string browser history. + + The history token is the query string (`?screen=¶ms`) and the + path always stays the application base, so the URL of a token is just + the application base followed by the token. + + Replaces `goog.history.Html5History`, which is a fragment (hash) + router. Once fragment navigation was dropped, its token, URL and + popstate logic reduced to `location.search` plus + `pushState`/`replaceState`, while it kept a `hashchange` listener, + gated popstate on a fragment that was then always `null`, and + asserted on `history.pushState` in its constructor, which made it + unusable outside a browser and therefore untestable." + (:require + [app.common.data.macros :as dm] + [app.util.globals :as globals] + [beicon.v2.core :as rx])) + +(def ^:dynamic *browser* + "The browser handles this namespace needs. Bound in tests, so they can + drive a fake History API and assert on the URLs written." + {:location globals/location + :history (.-history globals/window)}) + +(defn get-token + "The current history token: the query string, `\"\"` when the URL + carries none." + [] + (or (.-search ^js (:location *browser*)) "")) + +(defn url + "The URL of `token` under `path-prefix`, the application base path. + The path never carries the token; only the query string does." + [path-prefix token] + (dm/str path-prefix token)) + +(def token-changes + "Emits the new token on every history change. + + `pushState` and `replaceState` never fire `popstate`, so this + namespace reports its own writes here too. Subscribers cannot tell the + two apart, and do not need to." + (rx/subject)) + +(defn- report! + [token] + (rx/push! token-changes token)) + +(defn set-token! + "Set `token` as a new history entry and report it. + + Does nothing when `token` already is the current one: navigating to + the screen you are on must not grow the history stack, and there is + nothing new for a subscriber to re-read from the URL." + [path-prefix token] + (when-not (= token (get-token)) + (.pushState ^js (:history *browser*) nil "" (url path-prefix token)) + (report! token))) + +(defn replace-token! + "Replace the current history entry with `token` and report it. + + Unlike [[set-token!]], this always writes and always reports. A replace + is how the app corrects the URL in place — legacy `#/…` translation, a + cleaned share link — and that correction has to reach the router even + when the token did not change." + [path-prefix token] + (.replaceState ^js (:history *browser*) nil "" (url path-prefix token)) + (report! token)) + +;; Back/forward. A `popstate` event carries no token, so re-read it from +;; the URL. Installed once: the application has a single history. +(rx/sub! (->> (rx/from-event globals/window "popstate") + (rx/map (fn [_] (get-token)))) + token-changes) diff --git a/frontend/src/app/util/browser_history.js b/frontend/src/app/util/browser_history.js deleted file mode 100644 index 2790376926..0000000000 --- a/frontend/src/app/util/browser_history.js +++ /dev/null @@ -1,52 +0,0 @@ -/** - * 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 - */ - -"use strict"; - -goog.provide("app.util.browser_history"); -goog.require("goog.history.Html5History"); - -goog.scope(function() { - const self = app.util.browser_history; - const Html5History = goog.history.Html5History; - - class TokenTransformer { - // Query-string routing: the history token is the query string - // ("?screen=...&..."), the path always stays the application base. - retrieveToken(pathPrefix, location) { - return location.search; - } - - createUrl(token, pathPrefix, location) { - return pathPrefix + token; - } - } - - self.create = function(pathPrefix) { - const instance = new Html5History(null, new TokenTransformer()); - instance.setUseFragment(false); - instance.setPathPrefix(pathPrefix); - return instance; - }; - - self.enable_BANG_ = function(instance) { - instance.setEnabled(true); - }; - - self.disable_BANG_ = function(instance) { - instance.setEnabled(false); - }; - - self.set_token_BANG_ = function(instance, token) { - instance?.setToken(token); - } - - self.replace_token_BANG_ = function(instance, token) { - instance?.replaceToken(token); - } -}); diff --git a/frontend/src/app/util/globals.js b/frontend/src/app/util/globals.js index 260a930ea4..5ac6cd3130 100644 --- a/frontend/src/app/util/globals.js +++ b/frontend/src/app/util/globals.js @@ -74,6 +74,16 @@ goog.scope(function () { mediaObj.onchange = null; return mediaObj; }; + // The History API. `app.util.browser-history` writes the router + // through `history.pushState`/`replaceState`, so the mock only has + // to accept the calls; nothing reads the resulting URL back. + mockWindow.history = { + pushState() {}, + replaceState() {}, + back() {}, + forward() {}, + go() {}, + }; return mockWindow; } })(); diff --git a/frontend/test/frontend_tests/browser_history_test.cljs b/frontend/test/frontend_tests/browser_history_test.cljs new file mode 100644 index 0000000000..1270503318 --- /dev/null +++ b/frontend/test/frontend_tests/browser_history_test.cljs @@ -0,0 +1,105 @@ +;; 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.browser-history-test + (:require + [app.util.browser-history :as bh] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true])) + +(defn- fake-browser + "A browser handle map whose `location.search` is `search`, and a + `history` that records every write and keeps `search` in sync. The + returned atom holds `{:calls [...] :search \"...\"}` so a test can + assert on both the write and the resulting token." + [search] + (let [state (atom {:search search :calls []})] + [state + {:location #js {:search search} + :history #js {:pushState (fn [_ _ url] + (swap! state assoc :search (subs url 1)) + (swap! state update :calls conj [:push url])) + :replaceState (fn [_ _ url] + (swap! state assoc :search (subs url 1)) + (swap! state update :calls conj [:replace url]))}}])) + +(defn- tokens-reported + "Runs `f` and returns the tokens it reported on `token-changes`, in + order. That subject is a module-level stream shared with the app, so + the subscription is dropped again once the collected sequence is + realised. Note `rx/subs!` takes the callbacks before the observable." + [f] + (let [reported (atom []) + sub (rx/subs! #(swap! reported conj %) bh/token-changes)] + (try + (f) + (finally + (rx/dispose! sub))) + @reported)) + +(t/deftest get-token-reads-the-current-query-string + (let [[_state handles] (fake-browser "?screen=workspace")] + (binding [bh/*browser* handles] + (t/is (= "?screen=workspace" (bh/get-token)))))) + +(t/deftest get-token-is-empty-when-the-url-has-no-query + ;; `location.search` is `""`, never nil, on a URL with no query. + (let [[_state handles] (fake-browser "")] + (binding [bh/*browser* handles] + (t/is (= "" (bh/get-token)))))) + +(t/deftest url-appends-the-token-to-the-base-path + ;; The path stays the application base; only the query carries the token. + (t/is (= "/?screen=workspace&team-id=t1" + (bh/url "/" "?screen=workspace&team-id=t1"))) + (t/is (= "/design/?screen=viewer" (bh/url "/design/" "?screen=viewer"))) + (t/is (= "/" (bh/url "/" "")))) + +(t/deftest set-token-pushes-a-new-entry-and-reports-it + (let [[state handles] (fake-browser "?screen=dashboard-recent") + reported (tokens-reported + #(binding [bh/*browser* handles] + (bh/set-token! "/" + "?screen=workspace&team-id=t1")))] + (t/is (= [[:push "/?screen=workspace&team-id=t1"]] (:calls @state))) + (t/is (= ["?screen=workspace&team-id=t1"] reported)))) + +(t/deftest set-token-keeps-the-path-on-the-base + (let [[state handles] (fake-browser "")] + (binding [bh/*browser* handles] + (bh/set-token! "/design/" "?screen=viewer")) + (let [[[_op written-url]] (:calls @state)] + (t/is (= "/design/?screen=viewer" written-url))))) + +(t/deftest set-token-does-nothing-when-the-token-already-is-current + ;; Navigating to the screen you are on must not grow the history stack, + ;; so neither the URL nor the subscribers see a change. + (let [[state handles] (fake-browser "?screen=dashboard-recent") + reported (tokens-reported + #(binding [bh/*browser* handles] + (bh/set-token! "/" "?screen=dashboard-recent")))] + (t/is (= [] (:calls @state))) + (t/is (= [] reported)))) + +(t/deftest replace-token-always-writes-and-reports-on-a-new-token + (let [[state handles] (fake-browser "?screen=dashboard-recent") + reported (tokens-reported + #(binding [bh/*browser* handles] + (bh/replace-token! "/" "?screen=auth-login")))] + (t/is (= [[:replace "/?screen=auth-login"]] (:calls @state))) + (t/is (= ["?screen=auth-login"] reported)))) + +(t/deftest replace-token-also-writes-and-reports-the-current-token + ;; Replacing corrects the URL in place — legacy `#/…` translation, a + ;; cleaned share link — so it has to reach the router even when the + ;; token did not change. That is the deliberate difference against + ;; `set-token!`. + (let [[state handles] (fake-browser "?screen=dashboard-recent") + reported (tokens-reported + #(binding [bh/*browser* handles] + (bh/replace-token! "/" "?screen=dashboard-recent")))] + (t/is (= [[:replace "/?screen=dashboard-recent"]] (:calls @state))) + (t/is (= ["?screen=dashboard-recent"] reported)))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 6f916c1c19..bbde1e17fd 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -5,6 +5,7 @@ [clojure.string :as str] [clojure.tools.cli :refer [parse-opts]] [frontend-tests.basic-shapes-test] + [frontend-tests.browser-history-test] [frontend-tests.code-gen-style-test] [frontend-tests.composable-tests.comp.sync-test] [frontend-tests.copy-as-svg-test] @@ -106,6 +107,7 @@ [frontend-tests.ui.comments-position-modifier-test] [frontend-tests.ui.drawarea-test] [frontend-tests.ui.ds-controls-numeric-input-test] + [frontend-tests.ui.error-boundary-test] [frontend-tests.ui.gradient-handlers-test] [frontend-tests.ui.history-test] [frontend-tests.ui.inspect-geometry-test] @@ -225,6 +227,7 @@ 'frontend-tests.render-wasm.serialization-test 'frontend-tests.render-wasm.text-editor-apply-styles-test 'frontend-tests.render-wasm.text-editor-caret-color-test + 'frontend-tests.browser-history-test 'frontend-tests.router-test 'frontend-tests.svg-fills-test 'frontend-tests.svg-filters-test @@ -246,6 +249,7 @@ 'frontend-tests.ui.comments-position-modifier-test 'frontend-tests.ui.drawarea-test 'frontend-tests.ui.ds-controls-numeric-input-test + 'frontend-tests.ui.error-boundary-test 'frontend-tests.ui.gradient-handlers-test 'frontend-tests.ui.history-test 'frontend-tests.ui.inspect-geometry-test diff --git a/frontend/test/frontend_tests/ui/error_boundary_test.cljs b/frontend/test/frontend_tests/ui/error_boundary_test.cljs new file mode 100644 index 0000000000..d7b4af04da --- /dev/null +++ b/frontend/test/frontend_tests/ui/error_boundary_test.cljs @@ -0,0 +1,55 @@ +;; 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.ui.error-boundary-test + (:require + [app.main.ui.error-boundary :as eb] + [cljs.test :as t :include-macros true])) + +(defn- match + "A route match like the one `refs/route` holds, with `screen` as the + screen name and `params` as the query map. `rt/match` builds a fresh + keyword for the screen on every navigation, so the caller gets a + different object each time — like the real one." + [screen params] + {:data {:name (keyword (name screen))} + :params {:path {} :query params} + :query-params params}) + +(t/deftest reset-keys-is-a-real-js-array + ;; `react-error-boundary` reads `resetKeys` with `.length` and `.some` + ;; inside `componentDidUpdate`. A ClojureScript vector has neither, so + ;; passing one throws `some is not a function` the first time the + ;; boundary is in error state and the route changes. + (t/is (array? (eb/reset-keys nil))) + (t/is (array? (eb/reset-keys (match :workspace {}))))) + +(t/deftest reset-keys-holds-the-screen-name + (t/is (= "workspace" + (aget (eb/reset-keys (match :workspace {:team-id "team-1"})) 0))) + ;; `app` renders `page*` only once a route exists, so the first render + ;; has no match yet. The boundary has to get through that and reset + ;; when the first screen arrives. + (t/is (nil? (aget (eb/reset-keys nil) 0)))) + +(t/deftest reset-keys-ignores-params-of-the-same-screen + ;; A param change on the same screen is not a new screen. Replaying + ;; the screen that just crashed is the one thing to avoid, so the + ;; entries the library compares with `Object.is` must stay put when + ;; only params move — even though the route map, and the keyword in + ;; it, are fresh objects on every navigation. + (let [a (eb/reset-keys (match :workspace {:team-id "team-1"})) + b (eb/reset-keys (match :workspace {:team-id "team-2"}))] + (t/is (= (alength a) (alength b))) + (t/is (identical? (aget a 0) (aget b 0))))) + +(t/deftest reset-keys-changes-when-the-screen-changes + ;; This is what lets every exit from the error page work: each of them + ;; only navigates, and none of them reaches `reset-error-boundary`. + (let [a (eb/reset-keys (match :workspace {:team-id "team-1"})) + b (eb/reset-keys (match :dashboard-recent {}))] + (t/is (= (alength a) (alength b))) + (t/is (not (identical? (aget a 0) (aget b 0))))))