From df705bbdbfc416652602724ab4de042979f3acc8 Mon Sep 17 00:00:00 2001 From: Johan Persson Date: Mon, 20 Apr 2026 14:24:36 +0200 Subject: [PATCH 1/2] fix(ui): preserve external hrefs in BUI link components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the app was served under a non-root base path, BUI link components rewrote absolute `href` values as in-app paths — e.g. `https://example.com` became `/basename/https:/example.com` — because every href was passed through react-router's `useHref`, which treats all strings as relative paths. External URLs (`http://`, `https://`, `//`, `mailto:`, `tel:`) now bypass href resolution. Internal hrefs are normalized to their canonical pre-basename form in `useDefinition`, so downstream resolution by react-router's `useHref` (for rendering) and `navigate` (for click-navigation) adds the basename exactly once. Signed-off-by: Johan Persson --- .../preserve-external-hrefs-useDefinition.md | 7 ++++ packages/ui/src/components/Link/Link.tsx | 3 ++ .../src/hooks/useDefinition/useDefinition.tsx | 34 +++++++++++---- packages/ui/src/hooks/useResolvedHref.ts | 42 +++++++++++++++++++ packages/ui/src/provider/BUIProvider.tsx | 5 ++- 5 files changed, 80 insertions(+), 11 deletions(-) create mode 100644 .changeset/preserve-external-hrefs-useDefinition.md create mode 100644 packages/ui/src/hooks/useResolvedHref.ts diff --git a/.changeset/preserve-external-hrefs-useDefinition.md b/.changeset/preserve-external-hrefs-useDefinition.md new file mode 100644 index 0000000000..b33e5db3ef --- /dev/null +++ b/.changeset/preserve-external-hrefs-useDefinition.md @@ -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 diff --git a/packages/ui/src/components/Link/Link.tsx b/packages/ui/src/components/Link/Link.tsx index fca4843753..a66506deb0 100644 --- a/packages/ui/src/components/Link/Link.tsx +++ b/packages/ui/src/components/Link/Link.tsx @@ -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((props, ref) => { const linkRef = (ref || internalRef) as React.RefObject; const { linkProps } = useLink(restProps, linkRef); + const resolvedHref = useResolvedHref(restProps.href); const handleClick = (e: React.MouseEvent) => { linkProps.onClick?.(e); @@ -49,6 +51,7 @@ const LinkInternal = forwardRef((props, ref) => { {...linkProps} {...dataAttributes} {...(restProps as React.AnchorHTMLAttributes)} + href={resolvedHref} ref={linkRef} title={title} className={classes.root} diff --git a/packages/ui/src/hooks/useDefinition/useDefinition.tsx b/packages/ui/src/hooks/useDefinition/useDefinition.tsx index a791343c15..a6faf58d1f 100644 --- a/packages/ui/src/hooks/useDefinition/useDefinition.tsx +++ b/packages/ui/src/hooks/useDefinition/useDefinition.tsx @@ -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 { 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 . + 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; } } diff --git a/packages/ui/src/hooks/useResolvedHref.ts b/packages/ui/src/hooks/useResolvedHref.ts new file mode 100644 index 0000000000..3e5075ed1d --- /dev/null +++ b/packages/ui/src/hooks/useResolvedHref.ts @@ -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; +} diff --git a/packages/ui/src/provider/BUIProvider.tsx b/packages/ui/src/provider/BUIProvider.tsx index 6576a9eabb..cc1d79774d 100644 --- a/packages/ui/src/provider/BUIProvider.tsx +++ b/packages/ui/src/provider/BUIProvider.tsx @@ -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 ( - + {children} ); From 20f0689b290f246038a2b3a6b38ace1ce82da266 Mon Sep 17 00:00:00 2001 From: Johan Persson Date: Wed, 22 Apr 2026 09:03:40 +0200 Subject: [PATCH 2/2] patches: add entry for #34004 Signed-off-by: Johan Persson --- .patches/pr-34004.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 .patches/pr-34004.txt diff --git a/.patches/pr-34004.txt b/.patches/pr-34004.txt new file mode 100644 index 0000000000..506fd76855 --- /dev/null +++ b/.patches/pr-34004.txt @@ -0,0 +1 @@ +Preserve external hrefs in BUI link components under non-root base path \ No newline at end of file