fix(ui): fix client-side navigation for container components

Fixed client-side navigation by wrapping container components
(not individual items) in RouterProvider. Components now
conditionally provide routing context only when children have
internal hrefs.

Changes:
- Added createRoutingRegistration factory for reusable routing
  registration pattern
- Refactored Menu, MenuAutocomplete to use RoutingProvider
- Refactored TagGroup, Tag to use RoutingProvider
- Refactored Tabs, Tab with RoutedTabEffects for URL-based
  tab selection

Signed-off-by: Johan Persson <johanopersson@gmail.com>
This commit is contained in:
Johan Persson
2026-01-19 17:31:11 +01:00
parent 5320aa84a3
commit da30862f74
8 changed files with 395 additions and 161 deletions
+9
View File
@@ -0,0 +1,9 @@
---
'@backstage/ui': patch
---
Fixed client-side navigation for container components by wrapping the container (not individual items) in RouterProvider. Components now conditionally provide routing context only when children have internal links, removing the Router context requirement when not needed. This also removes the need to wrap these components in MemoryRouter during tests when they are not using the `href` prop.
Additionally, when multiple tabs match the current URL via prefix matching, the tab with the most specific path (highest segment count) is now selected. For example, with URL `/catalog/users/john`, a tab with path `/catalog/users` is now selected over a tab with path `/catalog`.
Affected components: Tabs, Tab, TagGroup, Tag, Menu, MenuItem, MenuAutocomplete
@@ -521,23 +521,28 @@ export const WithTabsPrefixMatchingDeep = meta.story({
<MemoryRouter initialEntries={['/catalog/users/john/details']}>
<Header {...args} />
<Container>
<Text>
<Text as="p">
<strong>Current URL:</strong> /catalog/users/john/details
</Text>
<br />
<Text>Both "Catalog" and "Users" tabs are active because:</Text>
<Text>
<strong>Catalog</strong>: URL starts with /catalog
<Text as="p">
Active tab is <strong>Users</strong> because:
</Text>
<Text>
<strong>Users</strong>: URL starts with /catalog/users
</Text>
<Text>
<strong>Components</strong>: not active (URL doesn't start with
/catalog/components)
</Text>
<br />
<Text>
<ul>
<li>
<strong>Catalog</strong>: Matches since URL starts with /catalog
</li>
<li>
<strong>Users</strong>: Is active since URL starts with
/catalog/users, and is more specific (has more url segments) than
"Catalog"
</li>
<li>
<strong>Components</strong>: not active (URL doesn't start with
/catalog/components)
</li>
</ul>
<Text as="p">
This demonstrates how prefix matching works with deeply nested routes.
</Text>
</Container>
@@ -1,4 +1,3 @@
import preview from '../../../../../.storybook/preview';
/*
* Copyright 2025 The Backstage Authors
*
@@ -15,6 +14,7 @@ import preview from '../../../../../.storybook/preview';
* limitations under the License.
*/
import preview from '../../../../../.storybook/preview';
import type { StoryFn } from '@storybook/react-vite';
import { HeaderPage } from './HeaderPage';
import type { HeaderTab } from '../Header/types';
@@ -336,23 +336,28 @@ export const WithTabsPrefixMatchingDeep = meta.story({
<MemoryRouter initialEntries={['/catalog/users/john/details']}>
<HeaderPage {...args} />
<Container>
<Text>
<Text as="p">
<strong>Current URL:</strong> /catalog/users/john/details
</Text>
<br />
<Text>Both "Catalog" and "Users" tabs are active because:</Text>
<Text>
• <strong>Catalog</strong>: URL starts with /catalog
<Text as="p">
Active tab is <strong>Users</strong> because:
</Text>
<Text>
• <strong>Users</strong>: URL starts with /catalog/users
</Text>
<Text>
• <strong>Components</strong>: not active (URL doesn't start with
/catalog/components)
</Text>
<br />
<Text>
<ul>
<li>
<strong>Catalog</strong>: Matches since URL starts with /catalog
</li>
<li>
<strong>Users</strong>: Is active since URL starts with
/catalog/users, and is more specific (has more url segments) than
"Catalog"
</li>
<li>
<strong>Components</strong>: not active (URL doesn't start with
/catalog/components)
</li>
</ul>
<Text as="p">
This demonstrates how prefix matching works with deeply nested routes.
</Text>
</Container>
@@ -14,16 +14,44 @@
* limitations under the License.
*/
import { ReactNode } from 'react';
import {
ReactNode,
createContext,
useCallback,
useContext,
useEffect,
useState,
} from 'react';
import { RouterProvider } from 'react-aria-components';
import { useNavigate, useHref } from 'react-router-dom';
import { isExternalLink } from '../../utils/isExternalLink';
/**
* Inner component that uses router hooks.
* Separated so hooks are only called when this component mounts.
* Checks if an href is an internal link (not external and not empty).
*
* @internal
*/
function InternalLinkProviderInner({ children }: { children: ReactNode }) {
export function isInternalLink(href: string | undefined): href is string {
return !!href && !isExternalLink(href);
}
/**
* Context value type for routing registration.
* Used by container components to track children that need RouterProvider.
*
* @internal
*/
export type RoutingContextValue = {
register: () => () => void;
};
/**
* Wraps children in a RouterProvider for client-side navigation.
* Must be rendered within a React Router context.
*
* @internal
*/
export function RoutedContainer({ children }: { children: ReactNode }) {
const navigate = useNavigate();
return (
<RouterProvider navigate={navigate} useHref={useHref}>
@@ -32,6 +60,82 @@ function InternalLinkProviderInner({ children }: { children: ReactNode }) {
);
}
/**
* Hook for container components that need to conditionally provide routing.
*
* Usage:
* 1. Call this hook in the container component
* 2. Pass `contextValue` to a RoutingContextValue context provider
* 3. Children call `register()` via context when they have internal hrefs
* 4. If `hasRoutedChildren` is true, wrap content in RoutedContainer
*
* @internal
*/
export function useRoutingRegistration(): {
hasRoutedChildren: boolean;
contextValue: RoutingContextValue;
} {
const [count, setCount] = useState(0);
const register = useCallback(() => {
setCount(c => c + 1);
return () => setCount(c => c - 1);
}, []);
return { hasRoutedChildren: count > 0, contextValue: { register } };
}
/**
* Creates a routing registration context and provider for container components.
*
* Usage:
* ```tsx
* // At module level
* const { RoutingProvider, useRoutingRegistrationEffect } = createRoutingRegistration();
*
* // Container component wraps content with provider
* <RoutingProvider>{content}</RoutingProvider>
*
* // Child items register when they have internal hrefs
* useRoutingRegistrationEffect(href);
* ```
*
* @internal
*/
export function createRoutingRegistration() {
const RoutingContext = createContext<RoutingContextValue | null>(null);
function RoutingProvider({ children }: { children: ReactNode }) {
const { hasRoutedChildren, contextValue } = useRoutingRegistration();
const content = (
<RoutingContext.Provider value={contextValue}>
{children}
</RoutingContext.Provider>
);
if (hasRoutedChildren) {
return <RoutedContainer>{content}</RoutedContainer>;
}
return content;
}
function useRoutingRegistrationEffect(href: string | undefined) {
const routingCtx = useContext(RoutingContext);
const hasInternalHref = isInternalLink(href);
useEffect(() => {
if (hasInternalHref && routingCtx) {
return routingCtx.register();
}
return undefined;
}, [hasInternalHref, routingCtx]);
}
return { RoutingContext, RoutingProvider, useRoutingRegistrationEffect };
}
/**
* Conditionally wraps children in a RouterProvider for internal link navigation.
* Only mounts the router hooks when `href` is an internal link, avoiding the
@@ -46,10 +150,8 @@ export function InternalLinkProvider({
href: string | undefined;
children: ReactNode;
}) {
const hasInternalHref = !!href && !isExternalLink(href);
if (!hasInternalHref) {
if (!isInternalLink(href)) {
return <>{children}</>;
}
return <InternalLinkProviderInner>{children}</InternalLinkProviderInner>;
return <RoutedContainer>{children}</RoutedContainer>;
}
@@ -14,4 +14,11 @@
* limitations under the License.
*/
export { InternalLinkProvider } from './InternalLinkProvider';
export {
InternalLinkProvider,
RoutedContainer,
useRoutingRegistration,
isInternalLink,
createRoutingRegistration,
} from './InternalLinkProvider';
export type { RoutingContextValue } from './InternalLinkProvider';
+32 -30
View File
@@ -30,7 +30,6 @@ import {
ListBox as RAListBox,
ListBoxItem as RAListBoxItem,
useFilter,
RouterProvider,
Virtualizer,
ListLayout,
} from 'react-aria-components';
@@ -53,11 +52,16 @@ import {
RiCheckLine,
RiCloseCircleLine,
} from '@remixicon/react';
import { useNavigate, useHref } from 'react-router-dom';
import { isExternalLink } from '../../utils/isExternalLink';
import {
isInternalLink,
createRoutingRegistration,
} from '../InternalLinkProvider';
import styles from './Menu.module.css';
import clsx from 'clsx';
const { RoutingProvider, useRoutingRegistrationEffect } =
createRoutingRegistration();
// The height will be used for virtualized menus. It should match the size set in CSS for each menu item.
const rowHeight = 32;
@@ -94,7 +98,6 @@ export const Menu = (props: MenuProps<object>) => {
...rest
} = cleanedProps;
const navigate = useNavigate();
let newMaxWidth = maxWidth || (virtualized ? '260px' : 'undefined');
const menuContent = (
@@ -107,15 +110,15 @@ export const Menu = (props: MenuProps<object>) => {
);
return (
<RAPopover
className={clsx(
classNames.popover,
styles[classNames.popover],
className,
)}
placement={placement}
>
<RouterProvider navigate={navigate} useHref={useHref}>
<RoutingProvider>
<RAPopover
className={clsx(
classNames.popover,
styles[classNames.popover],
className,
)}
placement={placement}
>
{virtualized ? (
<Virtualizer
layout={ListLayout}
@@ -128,8 +131,8 @@ export const Menu = (props: MenuProps<object>) => {
) : (
menuContent
)}
</RouterProvider>
</RAPopover>
</RAPopover>
</RoutingProvider>
);
};
@@ -196,7 +199,6 @@ export const MenuAutocomplete = (props: MenuAutocompleteProps<object>) => {
} = cleanedProps;
const { contains } = useFilter({ sensitivity: 'base' });
let newMaxWidth = maxWidth || (virtualized ? '260px' : 'undefined');
const navigate = useNavigate();
const menuContent = (
<RAMenu
@@ -208,15 +210,15 @@ export const MenuAutocomplete = (props: MenuAutocompleteProps<object>) => {
);
return (
<RAPopover
className={clsx(
classNames.popover,
styles[classNames.popover],
className,
)}
placement={placement}
>
<RouterProvider navigate={navigate} useHref={useHref}>
<RoutingProvider>
<RAPopover
className={clsx(
classNames.popover,
styles[classNames.popover],
className,
)}
placement={placement}
>
<RAAutocomplete filter={contains}>
<RASearchField
className={clsx(
@@ -254,8 +256,8 @@ export const MenuAutocomplete = (props: MenuAutocompleteProps<object>) => {
menuContent
)}
</RAAutocomplete>
</RouterProvider>
</RAPopover>
</RAPopover>
</RoutingProvider>
);
};
@@ -349,10 +351,10 @@ export const MenuItem = (props: MenuItemProps) => {
...rest
} = cleanedProps;
const isLink = href !== undefined;
const isExternal = isExternalLink(href);
useRoutingRegistrationEffect(href);
if (isLink && isExternal) {
// External links open in new tab via window.open instead of client-side routing
if (href && !isInternalLink(href)) {
return (
<RAMenuItem
className={clsx(classNames.item, styles[classNames.item], className)}
+179 -67
View File
@@ -17,13 +17,16 @@
import {
useRef,
useState,
useMemo,
Children,
cloneElement,
isValidElement,
ReactNode,
createContext,
useContext,
useEffect,
useCallback,
} from 'react';
import type { ReactNode } from 'react';
import type {
TabsProps,
TabListProps,
@@ -31,21 +34,27 @@ import type {
TabsContextValue,
TabProps,
} from './types';
import { useLocation, useNavigate, useHref } from 'react-router-dom';
import { useLocation } from 'react-router-dom';
import { TabsIndicators } from './TabsIndicators';
import {
Tabs as AriaTabs,
TabList as AriaTabList,
Tab as AriaTab,
TabPanel as AriaTabPanel,
RouterProvider,
TabProps as AriaTabProps,
} from 'react-aria-components';
import { useStyles } from '../../hooks/useStyles';
import { TabsDefinition } from './definition';
import {
isInternalLink,
createRoutingRegistration,
} from '../InternalLinkProvider';
import styles from './Tabs.module.css';
import clsx from 'clsx';
const { RoutingProvider, useRoutingRegistrationEffect } =
createRoutingRegistration();
const TabsContext = createContext<TabsContextValue | undefined>(undefined);
const useTabsContext = () => {
@@ -56,6 +65,17 @@ const useTabsContext = () => {
return context;
};
type TabSelectionContextValue = {
registerRoutedTab: (id: string) => void;
unregisterRoutedTab: (id: string) => void;
registerActiveTab: (id: string, segmentCount: number) => void;
unregisterActiveTab: (id: string) => void;
};
const TabSelectionContext = createContext<TabSelectionContextValue | null>(
null,
);
/**
* 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.
@@ -91,8 +111,14 @@ export const Tabs = (props: TabsProps) => {
const tabRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const [hoveredKey, setHoveredKey] = useState<string | null>(null);
const prevHoveredKey = useRef<string | null>(null);
let navigate = useNavigate();
const location = useLocation();
// State for tracking routed tabs (tabs with hrefs)
const [routedTabs, setRoutedTabs] = useState<Set<string>>(() => new Set());
// State for tracking active tabs reported by TabRouteRegistration components
const [activeTabs, setActiveTabs] = useState<Map<string, number>>(
() => new Map(),
);
const setTabRef = (key: string, element: HTMLDivElement | null) => {
if (element) {
@@ -102,42 +128,59 @@ export const Tabs = (props: TabsProps) => {
}
};
// If selectedKey is not provided, try to determine it from the current route
const computedSelectedKey = (() => {
const childrenArray = Children.toArray(children as ReactNode);
for (const child of childrenArray) {
if (isValidElement(child) && child.type === TabList) {
const tabListChildren = Children.toArray(child.props.children);
for (const tabChild of tabListChildren) {
if (isValidElement(tabChild) && tabChild.props.href) {
// Use tab-specific strategy, defaulting to 'exact'
const strategy = tabChild.props.matchStrategy || 'exact';
if (isTabActive(tabChild.props.href, location.pathname, strategy)) {
return tabChild.props.id;
}
}
}
//No route matches - check if all tabs have hrefs (pure navigation)
const allTabsHaveHref = tabListChildren.every(
child => isValidElement(child) && child.props.href,
);
if (allTabsHaveHref) {
// Pure navigation tabs, no route match
return null;
} else {
// Mixed tabs or pure local state
return undefined;
}
}
// Compute the selected tab based on active tabs with highest segment count
const selectedTabId = useMemo(() => {
// No routed tabs - let React Aria handle selection (uncontrolled mode)
if (routedTabs.size === 0) {
return undefined;
}
return undefined;
})();
// Has routed tabs but none are active - controlled mode with no selection
if (activeTabs.size === 0) {
return null;
}
let selectedId: string | null = null;
let maxSegments = -1;
activeTabs.forEach((segmentCount, id) => {
// Pick tab with highest segment count, first one wins on tie
if (segmentCount > maxSegments) {
maxSegments = segmentCount;
selectedId = id;
}
});
return selectedId;
}, [routedTabs, activeTabs]);
const registerRoutedTab = useCallback((id: string) => {
setRoutedTabs(prev => new Set(prev).add(id));
}, []);
const unregisterRoutedTab = useCallback((id: string) => {
setRoutedTabs(prev => {
const next = new Set(prev);
next.delete(id);
return next;
});
}, []);
const registerActiveTab = useCallback((id: string, segmentCount: number) => {
setActiveTabs(prev => new Map(prev).set(id, segmentCount));
}, []);
const unregisterActiveTab = useCallback((id: string) => {
setActiveTabs(prev => {
const next = new Map(prev);
next.delete(id);
return next;
});
}, []);
if (!children) return null;
const contextValue: TabsContextValue = {
const tabsContextValue: TabsContextValue = {
tabsRef,
tabRefs,
hoveredKey,
@@ -146,20 +189,41 @@ export const Tabs = (props: TabsProps) => {
setTabRef,
};
const selectionContextValue: TabSelectionContextValue = useMemo(
() => ({
registerRoutedTab,
unregisterRoutedTab,
registerActiveTab,
unregisterActiveTab,
}),
[
registerRoutedTab,
unregisterRoutedTab,
registerActiveTab,
unregisterActiveTab,
],
);
return (
<TabsContext.Provider value={contextValue}>
<RouterProvider navigate={navigate} useHref={useHref}>
<AriaTabs
className={clsx(classNames.tabs, styles[classNames.tabs], className)}
keyboardActivation="manual"
selectedKey={computedSelectedKey}
ref={tabsRef}
{...rest}
>
{children as ReactNode}
</AriaTabs>
</RouterProvider>
</TabsContext.Provider>
<RoutingProvider>
<TabsContext.Provider value={tabsContextValue}>
<TabSelectionContext.Provider value={selectionContextValue}>
<AriaTabs
className={clsx(
classNames.tabs,
styles[classNames.tabs],
className,
)}
keyboardActivation="manual"
selectedKey={selectedTabId}
ref={tabsRef}
{...rest}
>
{children as ReactNode}
</AriaTabs>
</TabSelectionContext.Provider>
</TabsContext.Provider>
</RoutingProvider>
);
};
@@ -214,6 +278,51 @@ export const TabList = (props: TabListProps) => {
);
};
/**
* Internal component for tabs with internal hrefs.
* Handles routing registration and active tab tracking.
* Separated to avoid conditional hook usage in Tab component.
* @internal
*/
function RoutedTabEffects({
id,
href,
matchStrategy = 'exact',
}: {
id: string;
href: string;
matchStrategy?: 'exact' | 'prefix';
}) {
const selectionCtx = useContext(TabSelectionContext);
const location = useLocation();
// Register with RoutingProvider for conditional RouterProvider wrapping
useRoutingRegistrationEffect(href);
// Register as a routed tab (for controlled vs uncontrolled mode)
useEffect(() => {
if (selectionCtx) {
selectionCtx.registerRoutedTab(id);
return () => selectionCtx.unregisterRoutedTab(id);
}
return undefined;
}, [id, selectionCtx]);
// Register as active tab when URL matches (for tab selection)
const isActive = isTabActive(href, location.pathname, matchStrategy);
const segmentCount = href.split('/').filter(Boolean).length;
useEffect(() => {
if (isActive && selectionCtx) {
selectionCtx.registerActiveTab(id, segmentCount);
return () => selectionCtx.unregisterActiveTab(id);
}
return undefined;
}, [isActive, id, segmentCount, selectionCtx]);
return null;
}
/**
* A component that renders a tab.
*
@@ -221,26 +330,29 @@ export const TabList = (props: TabListProps) => {
*/
export const Tab = (props: TabProps) => {
const { classNames, cleanedProps } = useStyles(TabsDefinition, props);
const {
className,
href,
children,
id,
matchStrategy: _matchStrategy,
...rest
} = cleanedProps;
const { className, href, children, id, matchStrategy, ...rest } =
cleanedProps;
const { setTabRef } = useTabsContext();
return (
<AriaTab
id={id}
className={clsx(classNames.tab, styles[classNames.tab], className)}
ref={el => setTabRef(id as string, el as HTMLDivElement)}
href={href}
{...rest}
>
{children}
</AriaTab>
<>
{isInternalLink(href) && (
<RoutedTabEffects
id={id as string}
href={href}
matchStrategy={matchStrategy}
/>
)}
<AriaTab
id={id}
className={clsx(classNames.tab, styles[classNames.tab], className)}
ref={el => setTabRef(id as string, el as HTMLDivElement)}
href={href}
{...rest}
>
{children}
</AriaTab>
</>
);
};
@@ -20,17 +20,18 @@ import {
TagList as ReactAriaTagList,
Tag as ReactAriaTag,
Button as ReactAriaButton,
RouterProvider,
} from 'react-aria-components';
import type { ReactNode } from 'react';
import { RiCloseCircleLine } from '@remixicon/react';
import clsx from 'clsx';
import { useStyles } from '../../hooks/useStyles';
import { TagGroupDefinition } from './definition';
import { isExternalLink } from '../../utils/isExternalLink';
import { useNavigate, useHref } from 'react-router-dom';
import { createRoutingRegistration } from '../InternalLinkProvider';
import styles from './TagGroup.module.css';
const { RoutingProvider, useRoutingRegistrationEffect } =
createRoutingRegistration();
/**
* A component that renders a list of tags.
*
@@ -41,18 +42,20 @@ export const TagGroup = <T extends object>(props: TagGroupProps<T>) => {
const { items, children, renderEmptyState, ...rest } = cleanedProps;
return (
<ReactAriaTagGroup
className={clsx(classNames.group, styles[classNames.group])}
{...rest}
>
<ReactAriaTagList
className={clsx(classNames.list, styles[classNames.list])}
items={items}
renderEmptyState={renderEmptyState}
<RoutingProvider>
<ReactAriaTagGroup
className={clsx(classNames.group, styles[classNames.group])}
{...rest}
>
{children}
</ReactAriaTagList>
</ReactAriaTagGroup>
<ReactAriaTagList
className={clsx(classNames.list, styles[classNames.list])}
items={items}
renderEmptyState={renderEmptyState}
>
{children}
</ReactAriaTagList>
</ReactAriaTagGroup>
</RoutingProvider>
);
};
@@ -68,11 +71,10 @@ export const Tag = (props: TagProps) => {
});
const { children, className, icon, size, href, ...rest } = cleanedProps;
const textValue = typeof children === 'string' ? children : undefined;
const navigate = useNavigate();
const isLink = href !== undefined;
const isExternal = isExternalLink(href);
const content = (
useRoutingRegistrationEffect(href);
return (
<ReactAriaTag
textValue={textValue}
className={clsx(classNames.tag, styles[classNames.tag], className)}
@@ -105,14 +107,4 @@ export const Tag = (props: TagProps) => {
)}
</ReactAriaTag>
);
if (isLink && !isExternal) {
return (
<RouterProvider navigate={navigate} useHref={useHref}>
{content}
</RouterProvider>
);
}
return content;
};