From 551e9a9ee6cc26feaa9ff2bf33a34f0442368c84 Mon Sep 17 00:00:00 2001 From: chrislazar25 <89318462+chrislazar25@users.noreply.github.com> Date: Wed, 19 Aug 2026 04:07:29 -0500 Subject: [PATCH] fix(backend-registry): preserve URL fragments in withBackendSelectionParams (#16619) Co-authored-by: VascoSch92 --- .../backend-registry/url-selection.test.ts | 67 +++++++++++++++++++ src/api/backend-registry/url-selection.ts | 19 +++++- 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/__tests__/api/backend-registry/url-selection.test.ts b/__tests__/api/backend-registry/url-selection.test.ts index bc00fd2739..40e6cc53da 100644 --- a/__tests__/api/backend-registry/url-selection.test.ts +++ b/__tests__/api/backend-registry/url-selection.test.ts @@ -65,6 +65,73 @@ describe("withBackendSelectionParams", () => { `/conversations/abc?tab=files&${BACKEND_QUERY_PARAM}=local-1`, ); }); + + it("keeps a fragment after existing query parameters intact and after the query", () => { + const path = withBackendSelectionParams( + "/conversations/abc?tab=files#detail", + { + backend: localBackend, + orgId: null, + }, + ); + + expect(path).toBe( + `/conversations/abc?tab=files&${BACKEND_QUERY_PARAM}=local-1#detail`, + ); + }); + + it("keeps a fragment on a path without query parameters after the query", () => { + const path = withBackendSelectionParams("/conversations/abc#detail", { + backend: localBackend, + orgId: null, + }); + + expect(path).toBe( + `/conversations/abc?${BACKEND_QUERY_PARAM}=local-1#detail`, + ); + }); + + it("does not treat a ? inside the fragment as a query separator", () => { + const path = withBackendSelectionParams("/conversations/abc#detail?x=1", { + backend: localBackend, + orgId: null, + }); + + expect(path).toBe( + `/conversations/abc?${BACKEND_QUERY_PARAM}=local-1#detail?x=1`, + ); + }); + + it("round-trips an empty fragment verbatim", () => { + const path = withBackendSelectionParams("/conversations/abc#", { + backend: localBackend, + orgId: null, + }); + + expect(path).toBe(`/conversations/abc?${BACKEND_QUERY_PARAM}=local-1#`); + }); + + it("keeps the org id and the fragment together", () => { + const path = withBackendSelectionParams("/conversations/abc#detail", { + backend: cloudBackend, + orgId: "org-7", + }); + + expect(path).toBe( + `/conversations/abc?${BACKEND_QUERY_PARAM}=prod&${ORG_QUERY_PARAM}=org-7#detail`, + ); + }); + + it("keeps query data that itself contains a ?", () => { + const path = withBackendSelectionParams("/conversations/abc?next=/a?b=1", { + backend: localBackend, + orgId: null, + }); + + expect(path).toBe( + `/conversations/abc?next=%2Fa%3Fb%3D1&${BACKEND_QUERY_PARAM}=local-1`, + ); + }); }); describe("readBackendSelectionFromUrl", () => { diff --git a/src/api/backend-registry/url-selection.ts b/src/api/backend-registry/url-selection.ts index 5c7a53a316..c6d4d20233 100644 --- a/src/api/backend-registry/url-selection.ts +++ b/src/api/backend-registry/url-selection.ts @@ -19,7 +19,8 @@ export const ORG_QUERY_PARAM = "org"; /** * Append the active backend identity to an in-app path so opening it in a new - * browsing context resolves against the same backend. + * browsing context resolves against the same backend. Any fragment on the + * path is preserved verbatim and kept after the query string. */ export function withBackendSelectionParams( path: string, @@ -28,12 +29,24 @@ export function withBackendSelectionParams( const { backend, orgId } = active; if (!backend.id) return path; - const [pathname, existingSearch = ""] = path.split("?"); + const hashIndex = path.indexOf("#"); + const fragment = hashIndex === -1 ? "" : path.slice(hashIndex); + const withoutFragment = hashIndex === -1 ? path : path.slice(0, hashIndex); + + // Split at the *first* `?` only. A later `?` is ordinary query data (a + // `next=` redirect carrying its own query, say), and `split("?")` would + // silently drop everything past it. + const queryIndex = withoutFragment.indexOf("?"); + const pathname = + queryIndex === -1 ? withoutFragment : withoutFragment.slice(0, queryIndex); + const existingSearch = + queryIndex === -1 ? "" : withoutFragment.slice(queryIndex + 1); + const params = new URLSearchParams(existingSearch); params.set(BACKEND_QUERY_PARAM, backend.id); if (orgId) params.set(ORG_QUERY_PARAM, orgId); - return `${pathname}?${params.toString()}`; + return `${pathname}?${params.toString()}${fragment}`; } /**