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) {