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 <johanopersson@gmail.com>
This commit is contained in:
Johan Persson
2026-02-27 10:44:04 +01:00
parent f2588154a2
commit d4fa5b4ee0
3 changed files with 64 additions and 4 deletions
+7
View File
@@ -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
@@ -454,6 +454,51 @@ export const RootPathMatching = meta.story({
),
});
export const HrefWithQueryParams = meta.story({
args: {
children: '',
},
render: () => (
<MemoryRouter initialEntries={['/cost-insights/dashboard?group=bar']}>
<Tabs>
<TabList>
<Tab
id="dashboard"
href="/cost-insights/dashboard?group=foo"
matchStrategy="prefix"
>
Dashboard
</Tab>
<Tab
id="alerts"
href="/cost-insights/alerts?group=foo"
matchStrategy="prefix"
>
Alerts
</Tab>
</TabList>
</Tabs>
<Box mt="6" pl="2">
<Text as="p">
Current URL: <strong>/cost-insights/dashboard?group=bar</strong>
</Text>
<Text as="p">
Tab hrefs include query params (e.g., ?group=foo) but the current URL
has different query params (?group=bar).
</Text>
<Text as="p">
• "Dashboard" tab: IS active — matching ignores query params and
compares only the pathname.
</Text>
<Text as="p">
• "Alerts" tab: NOT active — pathname /cost-insights/alerts doesn't
match /cost-insights/dashboard.
</Text>
</Box>
</MemoryRouter>
),
});
export const AutoSelectionOfTabs = meta.story({
args: {
children: '',
+12 -4
View File
@@ -79,6 +79,12 @@ const TabSelectionContext = createContext<TabSelectionContextValue | null>(
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) {