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} - + ); }