fix(ui): remove Router context requirement for Link, ButtonLink, Row

Introduced InternalLinkProvider component that conditionally wraps
children in RouterProvider only when an internal href is present.
This allows Link, ButtonLink, and Row components to render without
requiring a Router context when used with external or no hrefs.

Signed-off-by: Johan Persson <johanopersson@gmail.com>
This commit is contained in:
Johan Persson
2026-01-19 14:50:41 +01:00
parent b1f723b3f1
commit 5320aa84a3
6 changed files with 103 additions and 66 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/ui': patch
---
Fixed components to not require a Router context when rendering without internal links.
Affected components: Link, ButtonLink, Row
@@ -15,51 +15,36 @@
*/
import { forwardRef, Ref } from 'react';
import { Link as RALink, RouterProvider } from 'react-aria-components';
import { useNavigate, useHref } from 'react-router-dom';
import { Link as RALink } from 'react-aria-components';
import type { ButtonLinkProps } from './types';
import { useDefinition } from '../../hooks/useDefinition';
import { ButtonLinkDefinition } from './definition';
import { isExternalLink } from '../../utils/isExternalLink';
import { InternalLinkProvider } from '../InternalLinkProvider';
/** @public */
export const ButtonLink = forwardRef(
(props: ButtonLinkProps, ref: Ref<HTMLAnchorElement>) => {
const navigate = useNavigate();
const { ownProps, restProps, dataAttributes } = useDefinition(
ButtonLinkDefinition,
props,
);
const { classes, iconStart, iconEnd, children } = ownProps;
const isExternal = isExternalLink(restProps.href);
const linkButton = (
<RALink
className={classes.root}
ref={ref}
{...dataAttributes}
{...restProps}
>
<span className={classes.content}>
{iconStart}
{children}
{iconEnd}
</span>
</RALink>
);
// If it's an external link, render RALink without RouterProvider
if (isExternal) {
return linkButton;
}
// For internal links, use RouterProvider
return (
<RouterProvider navigate={navigate} useHref={useHref}>
{linkButton}
</RouterProvider>
<InternalLinkProvider href={restProps.href}>
<RALink
className={classes.root}
ref={ref}
{...dataAttributes}
{...restProps}
>
<span className={classes.content}>
{iconStart}
{children}
{iconEnd}
</span>
</RALink>
</InternalLinkProvider>
);
},
);
@@ -0,0 +1,55 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ReactNode } 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.
*/
function InternalLinkProviderInner({ children }: { children: ReactNode }) {
const navigate = useNavigate();
return (
<RouterProvider navigate={navigate} useHref={useHref}>
{children}
</RouterProvider>
);
}
/**
* Conditionally wraps children in a RouterProvider for internal link navigation.
* Only mounts the router hooks when `href` is an internal link, avoiding the
* requirement for a Router context when rendering components without internal hrefs.
*
* @internal
*/
export function InternalLinkProvider({
href,
children,
}: {
href: string | undefined;
children: ReactNode;
}) {
const hasInternalHref = !!href && !isExternalLink(href);
if (!hasInternalHref) {
return <>{children}</>;
}
return <InternalLinkProviderInner>{children}</InternalLinkProviderInner>;
}
@@ -0,0 +1,17 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { InternalLinkProvider } from './InternalLinkProvider';
+3 -14
View File
@@ -16,13 +16,11 @@
import { forwardRef, useRef } from 'react';
import { useLink } from 'react-aria';
import { RouterProvider } from 'react-aria-components';
import clsx from 'clsx';
import { useStyles } from '../../hooks/useStyles';
import { LinkDefinition } from './definition';
import type { LinkProps } from './types';
import { useNavigate, useHref } from 'react-router-dom';
import { isExternalLink } from '../../utils/isExternalLink';
import { InternalLinkProvider } from '../InternalLinkProvider';
import styles from './Link.module.css';
const LinkInternal = forwardRef<HTMLAnchorElement, LinkProps>((props, ref) => {
@@ -83,19 +81,10 @@ LinkInternal.displayName = 'LinkInternal';
/** @public */
export const Link = forwardRef<HTMLAnchorElement, LinkProps>((props, ref) => {
const navigate = useNavigate();
const isExternal = isExternalLink(props.href);
// If it's an external link, render without RouterProvider
if (isExternal) {
return <LinkInternal {...props} ref={ref} />;
}
// For internal links, wrap in RouterProvider so useLink can access the router
return (
<RouterProvider navigate={navigate} useHref={useHref}>
<InternalLinkProvider href={props.href}>
<LinkInternal {...props} ref={ref} />
</RouterProvider>
</InternalLinkProvider>
);
});
@@ -20,14 +20,12 @@ import {
useTableOptions,
Cell as ReactAriaCell,
Collection,
RouterProvider,
} from 'react-aria-components';
import { Checkbox } from '../../Checkbox';
import { useStyles } from '../../../hooks/useStyles';
import { TableDefinition } from '../definition';
import { useNavigate } from 'react-router-dom';
import { useHref } from 'react-router-dom';
import { isExternalLink } from '../../../utils/isExternalLink';
import { InternalLinkProvider } from '../../InternalLinkProvider';
import styles from '../Table.module.css';
import clsx from 'clsx';
import { Flex } from '../../Flex';
@@ -36,8 +34,7 @@ import { Flex } from '../../Flex';
export function Row<T extends object>(props: RowProps<T>) {
const { classNames, cleanedProps } = useStyles(TableDefinition, props);
const { id, columns, children, href, ...rest } = cleanedProps;
const navigate = useNavigate();
const isExternal = isExternalLink(href);
const hasInternalHref = !!href && !isExternalLink(href);
let { selectionBehavior, selectionMode } = useTableOptions();
@@ -62,30 +59,17 @@ export function Row<T extends object>(props: RowProps<T>) {
</>
);
if (!href || isExternal) {
return (
<ReactAriaRow
id={id}
href={href}
className={clsx(classNames.row, styles[classNames.row])}
{...rest}
>
{content}
</ReactAriaRow>
);
}
return (
<RouterProvider navigate={navigate} useHref={useHref}>
<InternalLinkProvider href={href}>
<ReactAriaRow
id={id}
href={href}
className={clsx(classNames.row, styles[classNames.row])}
data-react-aria-pressable="true"
data-react-aria-pressable={hasInternalHref ? 'true' : undefined}
{...rest}
>
{content}
</ReactAriaRow>
</RouterProvider>
</InternalLinkProvider>
);
}