mirror of
https://github.com/penpot/penpot.git
synced 2026-09-10 05:58:47 +00:00
♻️ Sync context ids surgically instead of rebuilding the href
Replace match->context-params and mirrored-href with an in-place sync of the file-id, team-id and project-id keys on the parsed href: present ids are set, absent ones removed, every other param untouched. The backend keeps applying its own file, project and team priority. Rewrite router tests around a href-only stub: mirror, skip, stale-strip, clear, unrelated-param preservation, every-present-id, repeated-key and subpath cases. Update the link-preview subsystem doc. AI-assisted-by: muse-spark-1.3-contributor
This commit is contained in:
parent
5913c1bb14
commit
4fc177ae43
@ -51,12 +51,13 @@ link points to. The feature is therefore built from three cooperating pieces:
|
|||||||
File: `frontend/src/app/main/router.cljs`
|
File: `frontend/src/app/main/router.cljs`
|
||||||
|
|
||||||
On every navigation, the `navigated` event reads the freshly stored
|
On every navigation, the `navigated` event reads the freshly stored
|
||||||
`(:route state)` and calls `match->context-params` to extract the
|
`(:route state)` and syncs its `file-id`/`team-id`/`project-id` fragment
|
||||||
identifiers that give sharing context to the current route, and mirrors
|
params into the query string (before the fragment) using
|
||||||
them on the query string (before the fragment) using
|
`history.replaceState`. Every other param in the URL is left untouched,
|
||||||
`history.replaceState`. The href base comes from the canonical
|
so unrelated params owned by other code survive. The write is skipped
|
||||||
`cf/public-uri`, so subpath deployments keep their prefix. The resulting
|
when the resulting href already matches the address bar. The backend
|
||||||
URLs look like:
|
applies its own file > project > team priority, so no filtering happens
|
||||||
|
on the frontend. The resulting URLs look like:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
https://design.penpot.app/?file-id=<uuid>#/workspace?team-id=...&file-id=...&page-id=...
|
https://design.penpot.app/?file-id=<uuid>#/workspace?team-id=...&file-id=...&page-id=...
|
||||||
@ -64,13 +65,9 @@ https://design.penpot.app/?team-id=<uuid>&project-id=<uuid>#/dashboard/recent?..
|
|||||||
https://design.penpot.app/?team-id=<uuid>#/dashboard/recent?team-id=...
|
https://design.penpot.app/?team-id=<uuid>#/dashboard/recent?team-id=...
|
||||||
```
|
```
|
||||||
|
|
||||||
`match->context-params` implements a priority: if the route has a `file-id`
|
Routes without any of those ids (e.g. auth pages) clear them from the
|
||||||
only that is mirrored; otherwise `project-id` (together with its `team-id`);
|
query string; `replaceState` only writes when the computed href differs
|
||||||
otherwise `team-id`. Routes without any of those ids (e.g. auth pages) mirror
|
from the current one, so no URL churn happens on navigation.
|
||||||
nothing; `replaceState` only writes when the computed href differs from the
|
|
||||||
current one, so it strips a stale query string without churning the URL on
|
|
||||||
every navigation. Ids are read from the fragment `:query-params` (routes
|
|
||||||
are static screens; the only dynamic parts are the query ids).
|
|
||||||
|
|
||||||
This way, when the user copies the URL from the address bar and shares it, the
|
This way, when the user copies the URL from the address bar and shares it, the
|
||||||
context ids travel in a part of the URL that *does* reach the server.
|
context ids travel in a part of the URL that *does* reach the server.
|
||||||
@ -288,10 +285,9 @@ indexing these preview pages, and responses are marked non-cacheable.
|
|||||||
* `backend/test/backend_tests/http_assets_test.clj`
|
* `backend/test/backend_tests/http_assets_test.clj`
|
||||||
(`objects-handler-file-thumbnail-bucket-link-preview-flag`) — the
|
(`objects-handler-file-thumbnail-bucket-link-preview-flag`) — the
|
||||||
`file-thumbnail` bucket is public only while the flag is enabled.
|
`file-thumbnail` bucket is public only while the flag is enabled.
|
||||||
* `frontend/test/frontend_tests/router_test.cljs` — `match->context-params`
|
* `frontend/test/frontend_tests/router_test.cljs` — the `navigated` URL
|
||||||
priority (file > project > team), project link without team, repeated-key
|
surgery (mirror, skip, stale-strip, clear, unrelated-param
|
||||||
handling, and the `mirrored-href`/`navigated` URL surgery (mirror, skip,
|
preservation, every-present-id, repeated-key, subpath base).
|
||||||
stale-strip, clear, subpath base).
|
|
||||||
|
|
||||||
## Relevant files
|
## Relevant files
|
||||||
|
|
||||||
|
|||||||
@ -75,34 +75,6 @@
|
|||||||
(let [v (get params k)]
|
(let [v (get params k)]
|
||||||
(if (sequential? v) (peek v) v)))
|
(if (sequential? v) (peek v) v)))
|
||||||
|
|
||||||
(defn match->context-params
|
|
||||||
"Extract the params that give sharing context to the current URL.
|
|
||||||
|
|
||||||
They are mirrored on the query string (before the fragment) because
|
|
||||||
the fragment is never sent to the server; this way shared links
|
|
||||||
carry enough context for rendering link preview metadata.
|
|
||||||
Only fragment query params are considered: non-legacy routes are
|
|
||||||
static screens and carry their ids exclusively there."
|
|
||||||
[match]
|
|
||||||
(let [query-params (get match :query-params)
|
|
||||||
file-id (get-query-param query-params :file-id)
|
|
||||||
team-id (get-query-param query-params :team-id)
|
|
||||||
project-id (get-query-param query-params :project-id)]
|
|
||||||
(cond
|
|
||||||
(some? file-id) {:file-id file-id}
|
|
||||||
(some? project-id) {:team-id team-id :project-id project-id}
|
|
||||||
(some? team-id) {:team-id team-id})))
|
|
||||||
|
|
||||||
(defn mirrored-href
|
|
||||||
"Build the path-relative href carrying the mirrored context query
|
|
||||||
before the fragment. Pure helper around the `navigated` effect so
|
|
||||||
the URL surgery stays testable without DOM."
|
|
||||||
[context-params hash base-path]
|
|
||||||
(let [query (some-> context-params u/map->query-string)]
|
|
||||||
(dm/str base-path
|
|
||||||
(if (some? query) (dm/str "?" query) "")
|
|
||||||
hash)))
|
|
||||||
|
|
||||||
(defn navigated
|
(defn navigated
|
||||||
[match send-event-info?]
|
[match send-event-info?]
|
||||||
(ptk/reify ::navigated
|
(ptk/reify ::navigated
|
||||||
@ -127,21 +99,23 @@
|
|||||||
|
|
||||||
ptk/EffectEvent
|
ptk/EffectEvent
|
||||||
(effect [_ state _]
|
(effect [_ state _]
|
||||||
;; The route is read from the state the `update` above just stored,
|
;; The route is read from the state the `update` above just stored:
|
||||||
;; not from the closed-over `match`: the effect always runs after
|
;; the effect always runs after the update. The sharing-context ids
|
||||||
;; the update. The base comes from the canonical `cf/public-uri`
|
;; are synced into the pre-fragment query (the fragment never reaches
|
||||||
;; instead of the address bar.
|
;; the server, so shared links need them there); every other param is
|
||||||
(let [context (match->context-params (:route state))
|
;; left untouched. The backend applies its own file > project > team
|
||||||
href (mirrored-href context
|
;; priority, so no filtering is needed here.
|
||||||
(.-hash globals/location)
|
(let [params (:query-params (:route state))
|
||||||
(:path cf/public-uri))
|
uri (u/uri (.-href globals/location))
|
||||||
current (dm/str (.-pathname globals/location)
|
search (reduce (fn [m k]
|
||||||
(.-search globals/location)
|
(let [v (get-query-param params k)]
|
||||||
(.-hash globals/location))]
|
(if (some? v)
|
||||||
;; The pre-fragment query string is owned by this mirroring: skip
|
(assoc m k v)
|
||||||
;; the write when nothing changed to avoid URL churn and dropping
|
(dissoc m k))))
|
||||||
;; unrelated params set by other code. Both sides are path-relative.
|
(u/query-string->map (:query uri))
|
||||||
(when (not= href current)
|
[:file-id :team-id :project-id])
|
||||||
|
href (str (assoc uri :query (u/map->query-string search)))]
|
||||||
|
(when (not= href (.-href globals/location))
|
||||||
(.replaceState js/history nil "" href))))))
|
(.replaceState js/history nil "" href))))))
|
||||||
|
|
||||||
(defn navigate
|
(defn navigate
|
||||||
|
|||||||
@ -6,153 +6,116 @@
|
|||||||
|
|
||||||
(ns frontend-tests.router-test
|
(ns frontend-tests.router-test
|
||||||
(:require
|
(:require
|
||||||
[app.common.uri :as u]
|
|
||||||
[app.config :as cf]
|
|
||||||
[app.main.router :as rt]
|
[app.main.router :as rt]
|
||||||
[app.util.globals :as globals]
|
[app.util.globals :as globals]
|
||||||
[cljs.test :as t :include-macros true]
|
[cljs.test :as t :include-macros true]
|
||||||
[potok.v2.core :as ptk]))
|
[potok.v2.core :as ptk]))
|
||||||
|
|
||||||
(t/deftest match-context-params-file-link
|
(defn- with-stubbed-href
|
||||||
;; Workspace and viewer links only mirror the file-id.
|
"Run `thunk` with the `globals/location` href replaced and a recording
|
||||||
(let [match {:query-params {:team-id "team-1"
|
`js/history.replaceState`. Only the href is stubbed: the effect parses
|
||||||
:file-id "file-1"
|
everything it needs out of it."
|
||||||
:page-id "page-1"}}]
|
[href replace-calls thunk]
|
||||||
(t/is (= {:file-id "file-1"}
|
(let [loc globals/location
|
||||||
(rt/match->context-params match)))))
|
old-href (.-href loc)
|
||||||
|
old-history (.-history js/globalThis)]
|
||||||
(t/deftest match-context-params-project-link
|
|
||||||
(let [match {:query-params {:team-id "team-1"
|
|
||||||
:project-id "project-1"}}]
|
|
||||||
(t/is (= {:team-id "team-1"
|
|
||||||
:project-id "project-1"}
|
|
||||||
(rt/match->context-params match)))))
|
|
||||||
|
|
||||||
(t/deftest match-context-params-team-link
|
|
||||||
(let [match {:query-params {:team-id "team-1"}}]
|
|
||||||
(t/is (= {:team-id "team-1"}
|
|
||||||
(rt/match->context-params match)))))
|
|
||||||
|
|
||||||
(t/deftest match-context-params-no-context
|
|
||||||
(let [match {:query-params {:token "some-token"}}]
|
|
||||||
(t/is (nil? (rt/match->context-params match)))))
|
|
||||||
|
|
||||||
(t/deftest match-context-params-project-link-without-team
|
|
||||||
;; Without a team-id the raw map keeps a nil team-id; the nil is dropped
|
|
||||||
;; later by the query-string serialization, not here.
|
|
||||||
(let [match {:query-params {:project-id "project-1"}}]
|
|
||||||
(t/is (= {:team-id nil
|
|
||||||
:project-id "project-1"}
|
|
||||||
(rt/match->context-params match)))))
|
|
||||||
|
|
||||||
(t/deftest match-context-params-file-beats-project-and-team
|
|
||||||
;; With file, project and team ids present, only the file-id is mirrored.
|
|
||||||
(let [match {:query-params {:team-id "team-1"
|
|
||||||
:project-id "project-1"
|
|
||||||
:file-id "file-1"}}]
|
|
||||||
(t/is (= {:file-id "file-1"}
|
|
||||||
(rt/match->context-params match)))))
|
|
||||||
|
|
||||||
(t/deftest match-context-params-repeated-key
|
|
||||||
;; A repeated query key arrives as a vector; the last value wins.
|
|
||||||
(let [match {:query-params {:file-id ["file-old" "file-1"]}}]
|
|
||||||
(t/is (= {:file-id "file-1"}
|
|
||||||
(rt/match->context-params match)))))
|
|
||||||
|
|
||||||
(defn- with-stubbed-browser
|
|
||||||
"Run `thunk` with the `globals/location` mock props and a recording
|
|
||||||
`js/history.replaceState`. Restores both originals afterwards: the
|
|
||||||
location mock is shared across tests and `js/history` may not exist
|
|
||||||
in the test environment at all."
|
|
||||||
[{:keys [pathname search hash href]} replace-calls thunk]
|
|
||||||
(let [loc globals/location
|
|
||||||
old-pathname (.-pathname loc)
|
|
||||||
old-search (.-search loc)
|
|
||||||
old-hash (.-hash loc)
|
|
||||||
old-href (.-href loc)
|
|
||||||
old-history (.-history js/globalThis)]
|
|
||||||
(set! (.-pathname loc) pathname)
|
|
||||||
(set! (.-search loc) search)
|
|
||||||
(set! (.-hash loc) hash)
|
|
||||||
(set! (.-href loc) href)
|
(set! (.-href loc) href)
|
||||||
(set! (.-history js/globalThis)
|
(set! (.-history js/globalThis)
|
||||||
#js {:replaceState (fn [_ _ url] (swap! replace-calls conj url))})
|
#js {:replaceState (fn [_ _ url] (swap! replace-calls conj url))})
|
||||||
(try
|
(try
|
||||||
(thunk)
|
(thunk)
|
||||||
(finally
|
(finally
|
||||||
(set! (.-pathname loc) old-pathname)
|
|
||||||
(set! (.-search loc) old-search)
|
|
||||||
(set! (.-hash loc) old-hash)
|
|
||||||
(set! (.-href loc) old-href)
|
(set! (.-href loc) old-href)
|
||||||
(set! (.-history js/globalThis) old-history)))))
|
(set! (.-history js/globalThis) old-history)))))
|
||||||
|
|
||||||
(t/deftest mirrored-href-subpath-base
|
|
||||||
;; The base comes from cf/public-uri, so subpath deployments keep
|
|
||||||
;; their prefix.
|
|
||||||
(t/is (= "/penpot/?file-id=file-1#/workspace?file-id=file-1"
|
|
||||||
(rt/mirrored-href {:file-id "file-1"} "#/workspace?file-id=file-1" "/penpot/"))))
|
|
||||||
|
|
||||||
(t/deftest mirrored-href-no-context
|
|
||||||
;; Routes without context clear the pre-fragment query.
|
|
||||||
(t/is (= "/#/auth/login"
|
|
||||||
(rt/mirrored-href nil "#/auth/login" "/"))))
|
|
||||||
|
|
||||||
(defn- emit-navigated
|
(defn- emit-navigated
|
||||||
"Run the `navigated` effect with `match` stored as the state route,
|
"Run the `navigated` effect with `match` stored as the state route.
|
||||||
pinning `cf/public-uri` so the test does not depend on the test-env
|
The closed-over match is deliberately empty to prove the effect reads
|
||||||
globals. The closed-over match is deliberately empty to prove the
|
the route from the state, not from the closure."
|
||||||
effect reads the route from the state, not from the closure."
|
[match]
|
||||||
[match public-uri]
|
(ptk/effect (rt/navigated {} false) {:route match} nil))
|
||||||
(with-redefs [cf/public-uri (u/uri public-uri)]
|
|
||||||
(ptk/effect (rt/navigated {} false) {:route match} nil)))
|
|
||||||
|
|
||||||
(t/deftest navigated-mirrors-context-on-change
|
(t/deftest navigated-mirrors-context-on-change
|
||||||
;; New context in the state route triggers exactly one mirrored write.
|
;; New context in the state route triggers exactly one mirrored write.
|
||||||
(let [calls (atom [])]
|
(let [calls (atom [])]
|
||||||
(with-stubbed-browser
|
(with-stubbed-href
|
||||||
{:pathname "/" :search "" :hash "#/workspace?file-id=file-1" :href "http://localhost/"}
|
"http://localhost/#/workspace?file-id=file-1"
|
||||||
calls
|
calls
|
||||||
(fn []
|
(fn []
|
||||||
(emit-navigated {:query-params {:file-id "file-1"}} "http://localhost/")
|
(emit-navigated {:query-params {:file-id "file-1"}})
|
||||||
(t/is (= ["/?file-id=file-1#/workspace?file-id=file-1"] @calls))))))
|
(t/is (= ["http://localhost/?file-id=file-1#/workspace?file-id=file-1"] @calls))))))
|
||||||
|
|
||||||
(t/deftest navigated-skips-write-when-mirrored
|
(t/deftest navigated-skips-write-when-mirrored
|
||||||
;; When the URL already carries the mirrored context, nothing is written.
|
;; When the URL already carries the mirrored context, nothing is written.
|
||||||
(let [calls (atom [])]
|
(let [calls (atom [])]
|
||||||
(with-stubbed-browser
|
(with-stubbed-href
|
||||||
{:pathname "/" :search "?file-id=file-1" :hash "#/workspace?file-id=file-1" :href "http://localhost/?file-id=file-1#/workspace?file-id=file-1"}
|
"http://localhost/?file-id=file-1#/workspace?file-id=file-1"
|
||||||
calls
|
calls
|
||||||
(fn []
|
(fn []
|
||||||
(emit-navigated {:query-params {:file-id "file-1"}} "http://localhost/")
|
(emit-navigated {:query-params {:file-id "file-1"}})
|
||||||
(t/is (= [] @calls))))))
|
(t/is (= [] @calls))))))
|
||||||
|
|
||||||
(t/deftest navigated-strips-stale-context
|
(t/deftest navigated-strips-stale-context
|
||||||
;; A stale pre-fragment query is replaced with the current context.
|
;; A stale pre-fragment query is replaced with the current context.
|
||||||
(let [calls (atom [])]
|
(let [calls (atom [])]
|
||||||
(with-stubbed-browser
|
(with-stubbed-href
|
||||||
{:pathname "/" :search "?file-id=old" :hash "#/dashboard/recent?team-id=team-1" :href "http://localhost/?file-id=old#/dashboard/recent?team-id=team-1"}
|
"http://localhost/?file-id=old#/dashboard/recent?team-id=team-1"
|
||||||
calls
|
calls
|
||||||
(fn []
|
(fn []
|
||||||
(emit-navigated {:query-params {:team-id "team-1"}} "http://localhost/")
|
(emit-navigated {:query-params {:team-id "team-1"}})
|
||||||
(t/is (= ["/?team-id=team-1#/dashboard/recent?team-id=team-1"] @calls))))))
|
(t/is (= ["http://localhost/?team-id=team-1#/dashboard/recent?team-id=team-1"] @calls))))))
|
||||||
|
|
||||||
(t/deftest navigated-clears-query-without-context
|
(t/deftest navigated-clears-query-without-context
|
||||||
;; Routes without context clear a stale pre-fragment query.
|
;; Routes without context clear a stale pre-fragment query.
|
||||||
(let [calls (atom [])]
|
(let [calls (atom [])]
|
||||||
(with-stubbed-browser
|
(with-stubbed-href
|
||||||
{:pathname "/" :search "?file-id=old" :hash "#/auth/login" :href "http://localhost/?file-id=old#/auth/login"}
|
"http://localhost/?file-id=old#/auth/login"
|
||||||
calls
|
calls
|
||||||
(fn []
|
(fn []
|
||||||
(emit-navigated {:query-params {:token "some-token"}} "http://localhost/")
|
(emit-navigated {:query-params {:token "some-token"}})
|
||||||
(t/is (= ["/#/auth/login"] @calls))))))
|
(t/is (= ["http://localhost/#/auth/login"] @calls))))))
|
||||||
|
|
||||||
|
(t/deftest navigated-preserves-unrelated-params
|
||||||
|
;; Params owned by other code are kept as they are.
|
||||||
|
(let [calls (atom [])]
|
||||||
|
(with-stubbed-href
|
||||||
|
"http://localhost/?debug=1&file-id=old#/workspace?file-id=file-1"
|
||||||
|
calls
|
||||||
|
(fn []
|
||||||
|
(emit-navigated {:query-params {:file-id "file-1"}})
|
||||||
|
(t/is (= ["http://localhost/?debug=1&file-id=file-1#/workspace?file-id=file-1"] @calls))))))
|
||||||
|
|
||||||
|
(t/deftest navigated-mirrors-every-present-context-id
|
||||||
|
;; Every present context id is mirrored; the backend applies its own
|
||||||
|
;; file > project > team priority, so no filtering happens here.
|
||||||
|
(let [calls (atom [])]
|
||||||
|
(with-stubbed-href
|
||||||
|
"http://localhost/#/workspace?file-id=file-1&team-id=team-1&project-id=project-1&page-id=page-1"
|
||||||
|
calls
|
||||||
|
(fn []
|
||||||
|
(emit-navigated {:query-params {:file-id "file-1"
|
||||||
|
:team-id "team-1"
|
||||||
|
:project-id "project-1"
|
||||||
|
:page-id "page-1"}})
|
||||||
|
(t/is (= ["http://localhost/?file-id=file-1&team-id=team-1&project-id=project-1#/workspace?file-id=file-1&team-id=team-1&project-id=project-1&page-id=page-1"]
|
||||||
|
@calls))))))
|
||||||
|
|
||||||
|
(t/deftest navigated-repeated-key-last-wins
|
||||||
|
;; A repeated query key arrives as a vector; the last value wins.
|
||||||
|
(let [calls (atom [])]
|
||||||
|
(with-stubbed-href
|
||||||
|
"http://localhost/#/workspace?file-id=file-1"
|
||||||
|
calls
|
||||||
|
(fn []
|
||||||
|
(emit-navigated {:query-params {:file-id ["file-old" "file-1"]}})
|
||||||
|
(t/is (= ["http://localhost/?file-id=file-1#/workspace?file-id=file-1"] @calls))))))
|
||||||
|
|
||||||
(t/deftest navigated-keeps-subpath-base
|
(t/deftest navigated-keeps-subpath-base
|
||||||
;; Under a subpath deployment the mirrored href keeps the prefix
|
;; Under a subpath deployment the prefix survives untouched.
|
||||||
;; from cf/public-uri.
|
|
||||||
(let [calls (atom [])]
|
(let [calls (atom [])]
|
||||||
(with-stubbed-browser
|
(with-stubbed-href
|
||||||
{:pathname "/penpot/" :search "" :hash "#/workspace?file-id=file-1" :href "http://localhost/penpot/"}
|
"http://localhost/penpot/#/workspace?file-id=file-1"
|
||||||
calls
|
calls
|
||||||
(fn []
|
(fn []
|
||||||
(emit-navigated {:query-params {:file-id "file-1"}} "http://localhost/penpot/")
|
(emit-navigated {:query-params {:file-id "file-1"}})
|
||||||
(t/is (= ["/penpot/?file-id=file-1#/workspace?file-id=file-1"] @calls))))))
|
(t/is (= ["http://localhost/penpot/?file-id=file-1#/workspace?file-id=file-1"] @calls))))))
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user