Merge pull request #34004 from backstage/bui-fix-external-links

fix(ui): preserve external hrefs in BUI link components
This commit is contained in:
Fredrik Adelöw
2026-04-22 14:28:36 +02:00
committed by GitHub
6 changed files with 81 additions and 11 deletions
+3
View File
@@ -18,6 +18,7 @@ import { forwardRef, useRef } from 'react';
import { useLink } from 'react-aria';
import type { LinkProps } from './types';
import { useDefinition } from '../../hooks/useDefinition';
import { useResolvedHref } from '../../hooks/useResolvedHref';
import { LinkDefinition } from './definition';
import { getNodeText } from '../../analytics/getNodeText';
@@ -32,6 +33,7 @@ const LinkInternal = forwardRef<HTMLAnchorElement, LinkProps>((props, ref) => {
const linkRef = (ref || internalRef) as React.RefObject<HTMLAnchorElement>;
const { linkProps } = useLink(restProps, linkRef);
const resolvedHref = useResolvedHref(restProps.href);
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
linkProps.onClick?.(e);
@@ -49,6 +51,7 @@ const LinkInternal = forwardRef<HTMLAnchorElement, LinkProps>((props, ref) => {
{...linkProps}
{...dataAttributes}
{...(restProps as React.AnchorHTMLAttributes<HTMLAnchorElement>)}
href={resolvedHref}
ref={linkRef}
title={title}
className={classes.root}
@@ -21,7 +21,8 @@ import { useBgProvider, useBgConsumer, BgProvider } from '../useBg';
import { resolveDefinitionProps, processUtilityProps } from './helpers';
import { useAnalytics } from '../../analytics/useAnalytics';
import { noopTracker } from '../../analytics/useAnalytics';
import { useInRouterContext, useHref } from 'react-router-dom';
import { useHref, useInRouterContext } from 'react-router-dom';
import { isExternalLink } from '../../utils/linkUtils';
import type {
ComponentConfig,
UseDefinitionOptions,
@@ -39,17 +40,32 @@ export function useDefinition<
): UseDefinitionResult<D, P> {
const { breakpoint } = useBreakpoint();
// Turn relative href into an absolute path using the current route
// context, so that client-side navigation works correctly.
// Pre-resolve href at component render time (where route context is
// correct), so that click-navigation has a correct absolute path
// regardless of where useNavigate is called. `useHref` returns the
// path with basename prepended; strip it so the output is the
// canonical pre-basename form that react-router's downstream useHref
// and navigate both expect as input (avoids double-prefixing).
// External URLs bypass resolution.
let hrefResolvedProps = props;
const hasRouter = useInRouterContext();
// useHref throws outside a Router, so we guard with useInRouterContext.
// The guard is safe because a component's router context does not
// change during its lifetime, keeping the hook call count stable.
if (hasRouter) {
const absoluteHref = useHref((props as any).href ?? '');
if ((props as any).href !== undefined) {
hrefResolvedProps = { ...props, href: absoluteHref } as P;
const rawHref = (props as any).href;
// useHref('/') returns the router's basename. Strip trailing slashes
// so the prefix check works regardless of how the consumer configured
// their <Router basename>.
const basename = useHref('/').replace(/\/+$/, '') || '/';
const absoluteHref = useHref(rawHref ?? '');
if (rawHref !== undefined && !isExternalLink(rawHref)) {
let stripped = absoluteHref;
if (
basename !== '/' &&
(absoluteHref === basename || absoluteHref.startsWith(`${basename}/`))
) {
stripped =
absoluteHref === basename ? '/' : absoluteHref.slice(basename.length);
}
hrefResolvedProps = { ...props, href: stripped } as P;
}
}
+42
View File
@@ -0,0 +1,42 @@
/*
* Copyright 2026 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 { useHref, useInRouterContext } from 'react-router-dom';
import { isExternalLink } from '../utils/linkUtils';
/**
* Resolves an href for rendering. External URLs are returned unchanged;
* internal paths are resolved through react-router's useHref so they
* respect the current basename and route context.
*
* @internal
*/
export function useResolvedHref(href: string): string;
export function useResolvedHref(href: string | undefined): string | undefined;
export function useResolvedHref(href: string | undefined): string | undefined {
const hasRouter = useInRouterContext();
// useHref throws outside a Router, so we guard with useInRouterContext.
// The guard is safe because a component's router context does not
// change during its lifetime, keeping the hook call count stable.
if (!hasRouter) {
return href;
}
const resolved = useHref(href ?? '');
if (!href || isExternalLink(href)) {
return href;
}
return resolved;
}
+3 -2
View File
@@ -16,9 +16,10 @@
import { useMemo, type ReactNode } from 'react';
import { RouterProvider } from 'react-aria-components';
import { useInRouterContext, useNavigate, useHref } from 'react-router-dom';
import { useInRouterContext, useNavigate } from 'react-router-dom';
import { createVersionedValueMap } from '@backstage/version-bridge';
import { BUIContext } from '../analytics/useAnalytics';
import { useResolvedHref } from '../hooks/useResolvedHref';
import type { UseAnalyticsFn } from '../analytics/types';
/** @public */
@@ -70,7 +71,7 @@ export function BUIProvider(props: BUIProviderProps) {
function RoutedContent({ children }: { children: ReactNode }) {
const navigate = useNavigate();
return (
<RouterProvider navigate={navigate} useHref={useHref}>
<RouterProvider navigate={navigate} useHref={useResolvedHref}>
{children}
</RouterProvider>
);