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