Merge pull request #34004 from backstage/bui-fix-external-links
fix(ui): preserve external hrefs in BUI link components
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@backstage/ui': patch
|
||||
---
|
||||
|
||||
Fixed external URLs in BUI link components being rewritten as in-app paths when the app is served under a non-root base path. Absolute URLs (`http://`, `https://`, `//`, `mailto:`, `tel:`) are now passed through unchanged. Internal `href` values are resolved against the current `basename` exactly once, which also fixes a latent issue where internal link clicks under a non-root base path could navigate to a URL with the `basename` prefix doubled.
|
||||
|
||||
**Affected components:** ButtonLink, Card, Link, Menu, Tab, Table, Tag
|
||||
@@ -0,0 +1 @@
|
||||
Preserve external hrefs in BUI link components under non-root base path
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user