From d4fa5b4ee091e8f92317c3fe7ee3dc45a80beff9 Mon Sep 17 00:00:00 2001 From: Johan Persson Date: Fri, 27 Feb 2026 10:44:04 +0100 Subject: [PATCH] fix(ui): strip query params from tab href before active-state matching Tab matchStrategy ('exact' and 'prefix') compared the raw tab href against location.pathname, which never includes query params. This meant tabs with query params in their href could never be matched as active. Extract an hrefPathname helper that strips query params and hash fragments, and use it in both isTabActive and the segment count computation. Signed-off-by: Johan Persson --- .changeset/fix-tab-match-query-params.md | 7 +++ .../ui/src/components/Tabs/Tabs.stories.tsx | 45 +++++++++++++++++++ packages/ui/src/components/Tabs/Tabs.tsx | 16 +++++-- 3 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 .changeset/fix-tab-match-query-params.md diff --git a/.changeset/fix-tab-match-query-params.md b/.changeset/fix-tab-match-query-params.md new file mode 100644 index 0000000000..6e01a202f5 --- /dev/null +++ b/.changeset/fix-tab-match-query-params.md @@ -0,0 +1,7 @@ +--- +'@backstage/ui': patch +--- + +Fixed tab `matchStrategy` matching to ignore query parameters and hash fragments in tab `href` values. Previously, tabs with query params in their `href` (e.g., `/page?group=foo`) would never show as active since matching compared the full `href` string against `location.pathname` which never includes query params. + +**Affected components:** Tabs, PluginHeader diff --git a/packages/ui/src/components/Tabs/Tabs.stories.tsx b/packages/ui/src/components/Tabs/Tabs.stories.tsx index 2a02bedc8c..fcc919f294 100644 --- a/packages/ui/src/components/Tabs/Tabs.stories.tsx +++ b/packages/ui/src/components/Tabs/Tabs.stories.tsx @@ -454,6 +454,51 @@ export const RootPathMatching = meta.story({ ), }); +export const HrefWithQueryParams = meta.story({ + args: { + children: '', + }, + render: () => ( + + + + + Dashboard + + + Alerts + + + + + + Current URL: /cost-insights/dashboard?group=bar + + + Tab hrefs include query params (e.g., ?group=foo) but the current URL + has different query params (?group=bar). + + + • "Dashboard" tab: IS active — matching ignores query params and + compares only the pathname. + + + • "Alerts" tab: NOT active — pathname /cost-insights/alerts doesn't + match /cost-insights/dashboard. + + + + ), +}); + export const AutoSelectionOfTabs = meta.story({ args: { children: '', diff --git a/packages/ui/src/components/Tabs/Tabs.tsx b/packages/ui/src/components/Tabs/Tabs.tsx index cf6263b9a4..d625d2abf0 100644 --- a/packages/ui/src/components/Tabs/Tabs.tsx +++ b/packages/ui/src/components/Tabs/Tabs.tsx @@ -79,6 +79,12 @@ const TabSelectionContext = createContext( null, ); +/** + * Strips query params and hash from a href, leaving only the pathname. + * Tab matching always compares against location.pathname which never includes them. + */ +const hrefPathname = (href: string) => href.split('?')[0].split('#')[0]; + /** * Utility function to determine if a tab should be active based on the matching strategy. * This follows the pattern used in WorkaroundNavLink from the sidebar. @@ -88,18 +94,20 @@ const isTabActive = ( currentPathname: string, matchStrategy: 'exact' | 'prefix', ): boolean => { + const pathname = hrefPathname(tabHref); + if (matchStrategy === 'exact') { - return tabHref === currentPathname; + return pathname === currentPathname; } // Prefix matching - similar to WorkaroundNavLink behavior - if (tabHref === currentPathname) { + if (pathname === currentPathname) { return true; } // Check if current path starts with tab href followed by a slash // This prevents /foo matching /foobar - return currentPathname.startsWith(`${tabHref}/`); + return currentPathname.startsWith(`${pathname}/`); }; /** @@ -304,7 +312,7 @@ function RoutedTabEffects({ // Register as active tab when URL matches (for tab selection) const isActive = isTabActive(href, location.pathname, matchStrategy); - const segmentCount = href.split('/').filter(Boolean).length; + const segmentCount = hrefPathname(href).split('/').filter(Boolean).length; useEffect(() => { if (isActive && selectionCtx) {