From 5320aa84a34eef99d5c7e06ce08f470bf8f884bb Mon Sep 17 00:00:00 2001 From: Johan Persson Date: Mon, 19 Jan 2026 14:50:41 +0100 Subject: [PATCH] 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 --- .changeset/tasty-crabs-occur.md | 7 +++ .../src/components/ButtonLink/ButtonLink.tsx | 47 ++++++---------- .../InternalLinkProvider.tsx | 55 +++++++++++++++++++ .../components/InternalLinkProvider/index.ts | 17 ++++++ packages/ui/src/components/Link/Link.tsx | 17 +----- .../src/components/Table/components/Row.tsx | 26 ++------- 6 files changed, 103 insertions(+), 66 deletions(-) create mode 100644 .changeset/tasty-crabs-occur.md create mode 100644 packages/ui/src/components/InternalLinkProvider/InternalLinkProvider.tsx create mode 100644 packages/ui/src/components/InternalLinkProvider/index.ts diff --git a/.changeset/tasty-crabs-occur.md b/.changeset/tasty-crabs-occur.md new file mode 100644 index 0000000000..22036e3c34 --- /dev/null +++ b/.changeset/tasty-crabs-occur.md @@ -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 diff --git a/packages/ui/src/components/ButtonLink/ButtonLink.tsx b/packages/ui/src/components/ButtonLink/ButtonLink.tsx index 1f6e375062..ac691897c4 100644 --- a/packages/ui/src/components/ButtonLink/ButtonLink.tsx +++ b/packages/ui/src/components/ButtonLink/ButtonLink.tsx @@ -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) => { - const navigate = useNavigate(); - const { ownProps, restProps, dataAttributes } = useDefinition( ButtonLinkDefinition, props, ); const { classes, iconStart, iconEnd, children } = ownProps; - const isExternal = isExternalLink(restProps.href); - - const linkButton = ( - - - {iconStart} - {children} - {iconEnd} - - - ); - - // If it's an external link, render RALink without RouterProvider - if (isExternal) { - return linkButton; - } - - // For internal links, use RouterProvider return ( - - {linkButton} - + + + + {iconStart} + {children} + {iconEnd} + + + ); }, ); diff --git a/packages/ui/src/components/InternalLinkProvider/InternalLinkProvider.tsx b/packages/ui/src/components/InternalLinkProvider/InternalLinkProvider.tsx new file mode 100644 index 0000000000..d2e35d4ac0 --- /dev/null +++ b/packages/ui/src/components/InternalLinkProvider/InternalLinkProvider.tsx @@ -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 ( + + {children} + + ); +} + +/** + * 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 {children}; +} diff --git a/packages/ui/src/components/InternalLinkProvider/index.ts b/packages/ui/src/components/InternalLinkProvider/index.ts new file mode 100644 index 0000000000..72b30fd7c2 --- /dev/null +++ b/packages/ui/src/components/InternalLinkProvider/index.ts @@ -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'; diff --git a/packages/ui/src/components/Link/Link.tsx b/packages/ui/src/components/Link/Link.tsx index 9dd9926f97..fb9d25e805 100644 --- a/packages/ui/src/components/Link/Link.tsx +++ b/packages/ui/src/components/Link/Link.tsx @@ -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((props, ref) => { @@ -83,19 +81,10 @@ LinkInternal.displayName = 'LinkInternal'; /** @public */ export const Link = forwardRef((props, ref) => { - const navigate = useNavigate(); - const isExternal = isExternalLink(props.href); - - // If it's an external link, render without RouterProvider - if (isExternal) { - return ; - } - - // For internal links, wrap in RouterProvider so useLink can access the router return ( - + - + ); }); diff --git a/packages/ui/src/components/Table/components/Row.tsx b/packages/ui/src/components/Table/components/Row.tsx index 6f7a4ce108..df07e5ead2 100644 --- a/packages/ui/src/components/Table/components/Row.tsx +++ b/packages/ui/src/components/Table/components/Row.tsx @@ -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(props: RowProps) { 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(props: RowProps) { ); - if (!href || isExternal) { - return ( - - {content} - - ); - } - return ( - + {content} - + ); }