feat(ui): resolve route-relative hrefs by default in useDefinition

Made href resolution always-on in useDefinition instead of requiring
each component to opt in via resolveHref: true. The hook now always
calls useInRouterContext/useHref and only applies the resolved value
when an href prop is actually provided, preventing href from leaking
into components that don't accept it.

Removed the resolveHref field from ComponentConfig, the
ResolveHrefConstraint type, and resolveHref: true from all 14
component definitions. Also removed a now-unnecessary type cast
in the Text component.

Signed-off-by: Johan Persson <johanopersson@gmail.com>
This commit is contained in:
Johan Persson
2026-04-08 15:58:20 +02:00
parent aba2f0a980
commit 2840476b04
15 changed files with 15 additions and 60 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/ui': patch
---
Resolved route-relative `href` props to absolute paths by default in all components, removing the need for the `resolveHref` option in component definitions.
-5
View File
@@ -583,7 +583,6 @@ export const ButtonLinkDefinition: {
};
readonly bg: 'consumer';
readonly analytics: true;
readonly resolveHref: true;
readonly propDefs: {
readonly noTrack: {};
readonly size: {
@@ -690,7 +689,6 @@ export const CardDefinition: {
readonly styles: {
readonly [key: string]: string;
};
readonly resolveHref: true;
readonly classNames: {
readonly root: 'bui-Card';
readonly trigger: 'bui-CardTrigger';
@@ -1559,7 +1557,6 @@ export const HeaderNavItemDefinition: {
readonly root: 'bui-HeaderNavItem';
};
readonly analytics: true;
readonly resolveHref: true;
readonly propDefs: {
readonly noTrack: {};
readonly id: {};
@@ -1682,7 +1679,6 @@ export const LinkDefinition: {
readonly root: 'bui-Link';
};
readonly analytics: true;
readonly resolveHref: true;
readonly propDefs: {
readonly noTrack: {};
readonly variant: {
@@ -1771,7 +1767,6 @@ export const ListRowDefinition: {
readonly [key: string]: string;
};
readonly bg: 'consumer';
readonly resolveHref: true;
readonly classNames: {
readonly root: 'bui-ListRow';
readonly check: 'bui-ListRowCheck';
@@ -30,7 +30,6 @@ export const ButtonLinkDefinition = defineComponent<ButtonLinkOwnProps>()({
},
bg: 'consumer',
analytics: true,
resolveHref: true,
propDefs: {
noTrack: {},
size: { dataAttribute: true, default: 'small' },
@@ -29,7 +29,6 @@ import styles from './Card.module.css';
*/
export const CardDefinition = defineComponent<CardOwnProps>()({
styles,
resolveHref: true,
classNames: {
root: 'bui-Card',
trigger: 'bui-CardTrigger',
@@ -47,7 +47,6 @@ export const HeaderNavItemDefinition = defineComponent<HeaderNavLinkProps>()({
root: 'bui-HeaderNavItem',
},
analytics: true,
resolveHref: true,
propDefs: {
noTrack: {},
id: {},
@@ -28,7 +28,6 @@ export const LinkDefinition = defineComponent<LinkOwnProps>()({
root: 'bui-Link',
},
analytics: true,
resolveHref: true,
propDefs: {
noTrack: {},
variant: { dataAttribute: true, default: 'body-medium' },
@@ -42,7 +42,6 @@ export const ListDefinition = defineComponent<ListOwnProps>()({
export const ListRowDefinition = defineComponent<ListRowOwnProps>()({
styles,
bg: 'consumer',
resolveHref: true,
classNames: {
root: 'bui-ListRow',
check: 'bui-ListRowCheck',
@@ -104,7 +104,6 @@ export const MenuItemDefinition = defineComponent<MenuItemOwnProps>()({
itemArrow: 'bui-MenuItemArrow',
},
analytics: true,
resolveHref: true,
propDefs: {
iconStart: {},
children: {},
@@ -119,7 +118,6 @@ export const MenuItemDefinition = defineComponent<MenuItemOwnProps>()({
export const MenuListBoxItemDefinition =
defineComponent<MenuListBoxItemOwnProps>()({
styles,
resolveHref: true,
classNames: {
root: 'bui-MenuItemListBox',
itemContent: 'bui-MenuItemContent',
@@ -61,7 +61,6 @@ export const SearchAutocompleteDefinition =
export const SearchAutocompleteItemDefinition =
defineComponent<SearchAutocompleteItemOwnProps>()({
styles,
resolveHref: true,
classNames: {
root: 'bui-SearchAutocompleteItem',
itemContent: 'bui-SearchAutocompleteItemContent',
@@ -90,7 +90,6 @@ export const TableBodyDefinition = defineComponent<TableBodyOwnProps>()({
*/
export const RowDefinition = defineComponent<RowOwnProps>()({
styles,
resolveHref: true,
analytics: true,
bg: 'consumer',
classNames: {
@@ -144,7 +143,6 @@ export const CellDefinition = defineComponent<CellOwnProps>()({
*/
export const CellTextDefinition = defineComponent<CellTextOwnProps>()({
styles,
resolveHref: true,
classNames: {
root: 'bui-TableCell',
cellContentWrapper: 'bui-TableCellContentWrapper',
@@ -167,7 +165,6 @@ export const CellTextDefinition = defineComponent<CellTextOwnProps>()({
*/
export const CellProfileDefinition = defineComponent<CellProfileOwnProps>()({
styles,
resolveHref: true,
classNames: {
root: 'bui-TableCell',
cellContentWrapper: 'bui-TableCellContentWrapper',
@@ -55,7 +55,6 @@ export const TabListDefinition = defineComponent<TabListOwnProps>()({
/** @internal */
export const TabDefinition = defineComponent<TabOwnProps>()({
styles,
resolveHref: true,
classNames: {
root: 'bui-Tab',
},
@@ -39,7 +39,6 @@ export const TagGroupDefinition = defineComponent<TagGroupOwnProps>()({
/** @internal */
export const TagDefinition = defineComponent<TagOwnProps>()({
styles,
resolveHref: true,
classNames: {
root: 'bui-Tag',
icon: 'bui-TagIcon',
+1 -4
View File
@@ -24,12 +24,9 @@ function TextComponent<T extends ElementType = 'span'>(
props: TextProps<T>,
ref: React.Ref<any>,
) {
// Cast to default TextProps so TypeScript can evaluate the
// ResolveHrefConstraint. The generic ElementType is only used for
// the `as` prop which doesn't include 'a', so href is never present.
const { ownProps, restProps, dataAttributes } = useDefinition(
TextDefinition,
props as TextProps,
props,
);
const { classes, as } = ownProps;
@@ -51,16 +51,6 @@ export interface ComponentConfig<
* `noTrack?: boolean`.
*/
analytics?: boolean;
/**
* Whether this component accepts an href prop that should be turned
* into an absolute path before being passed to the underlying React
* Aria component. This is necessary because React Aria's navigate
* callback receives the raw href and cannot correctly turn relative
* paths into absolute ones from where it is called. When true,
* `useDefinition` will call `useHref()` to make the href absolute
* using the current route context.
*/
resolveHref?: boolean;
}
/**
@@ -96,22 +86,6 @@ export type AnalyticsPropsConstraint<P, Analytics> = Analytics extends true
}
: {};
/**
* Type constraint that ensures components whose props include `href`
* have `resolveHref: true` in their definition. This is necessary
* because React Aria's navigate callback cannot turn relative hrefs
* into absolute paths on its own in Backstage because of how routing
* is set up the href must be made absolute before it reaches the
* React Aria layer.
*/
export type ResolveHrefConstraint<P, ResolveHref> = 'href' extends keyof P
? ResolveHref extends true
? {}
: {
__error: 'Components with href must set resolveHref: true in their definition to properly resolve relative paths.';
}
: {};
export interface UseDefinitionOptions<D extends ComponentConfig<any, any>> {
utilityTarget?: keyof D['classNames'] | null;
classNameTarget?: keyof D['classNames'] | null;
@@ -24,7 +24,6 @@ import { noopTracker } from '../../analytics/useAnalytics';
import { useInRouterContext, useHref } from 'react-router-dom';
import type {
ComponentConfig,
ResolveHrefConstraint,
UseDefinitionOptions,
UseDefinitionResult,
UtilityKeys,
@@ -34,7 +33,7 @@ export function useDefinition<
D extends ComponentConfig<any, any>,
P extends Record<string, any>,
>(
definition: D & ResolveHrefConstraint<P, D['resolveHref']>,
definition: D,
props: P,
options?: UseDefinitionOptions<D>,
): UseDefinitionResult<D, P> {
@@ -43,16 +42,14 @@ export function useDefinition<
// Turn relative href into an absolute path using the current route
// context, so that client-side navigation works correctly.
let hrefResolvedProps = props;
if (definition.resolveHref) {
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 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;
}
}