Fix unsafe type casts via shared entityPresentationSnapshot utility

Extract entityPresentationSnapshot helper to eliminate unsafe `as Entity`
casts when passing CompoundEntityRef to EntityPresentationApi.forEntity(),
which only accepts Entity | string. The helper safely discriminates input
types and stringifies CompoundEntityRef before calling the API.

- Add entityPresentationSnapshot as a public export from catalog-react
- Remove duplicated getEntityTitle/getTitle helpers across 5 files
- Fix useAsync dependency array in TemplateFormPreviewer
- Update TSDoc across all presentation API surfaces to reference the
  new helper
- Update entity-presentation.md docs with usage guidance and migration
  table

Made-with: Cursor
Signed-off-by: Marat Dyatko <maratd@spotify.com>
This commit is contained in:
Marat Dyatko
2026-04-07 12:41:29 +02:00
parent f55c195f03
commit b32ab39670
16 changed files with 371 additions and 111 deletions
@@ -52,24 +52,12 @@ function MyComponent({ entityRef }: { entityRef: string }) {
The hook subscribes to the `EntityPresentationApi` and returns a snapshot
that may update over time as additional data is fetched in the background.
### Using the API directly
### Using the API directly (async, preferred for non-React)
In contexts where hooks are not available, you can use the
`entityPresentationApiRef` API directly. The API provides two access
patterns:
- **`.snapshot`** for synchronous access (for example in sort comparators or
filter callbacks):
```ts
import { entityPresentationApiRef } from '@backstage/plugin-catalog-react';
const title = entityPresentationApi.forEntity(entity, {
defaultKind: 'Component',
}).snapshot.primaryTitle;
```
- **`.promise`** for async contexts (for example inside data loaders):
In non-React **async** contexts where you can `await` -- such as data
loaders, `useAsync` callbacks, or event handlers -- use the
`entityPresentationApiRef` API directly with `.promise` for the richest
possible presentation:
```ts
const presentation = await entityPresentationApi.forEntity(entity, {
@@ -78,9 +66,39 @@ const presentation = await entityPresentationApi.forEntity(entity, {
const title = presentation.primaryTitle;
```
The `.snapshot` path uses cached data when available, so it performs well
even in tight loops like sorting. The `.promise` path resolves to a richer
presentation that may include data fetched from the catalog.
The `.promise` path resolves to a full presentation that may include data
fetched from the catalog. **This is the preferred approach whenever an
async context is available.**
### `entityPresentationSnapshot` helper (synchronous fallback)
When a synchronous return value is required and `await` is not possible --
such as in sort comparators, column factories, or filter callbacks -- use
`entityPresentationSnapshot` as a fallback. It accepts `Entity`,
`CompoundEntityRef`, or string ref inputs and uses the presentation API
when available, falling back to `defaultEntityPresentation` otherwise:
```ts
import {
entityPresentationSnapshot,
entityPresentationApiRef,
} from '@backstage/plugin-catalog-react';
// In a column factory or sort comparator where you have
// the API instance (or undefined if not registered):
const title = entityPresentationSnapshot(
entity,
{
defaultKind: 'Component',
},
entityPresentationApi,
).primaryTitle;
```
Because this function is synchronous, it uses cached data from the
presentation API. If the entity has been seen before, the snapshot will
contain the full resolved title; otherwise it falls back to what can be
extracted from the ref alone.
## Customizing entity presentation
@@ -124,6 +142,6 @@ Replace them as follows:
| :---------------------------------------------------- | :--------------------------------------------------------------------- |
| `humanizeEntityRef(entity)` in JSX | `<EntityDisplayName entityRef={entity} />` |
| `humanizeEntityRef(entity)` in a React component | `useEntityPresentation(entity).primaryTitle` |
| `humanizeEntityRef(entity)` in a sort/filter callback | `entityPresentationApi.forEntity(entity).snapshot.primaryTitle` |
| `humanizeEntityRef(entity)` in an async loader | `(await entityPresentationApi.forEntity(entity).promise).primaryTitle` |
| `humanizeEntityRef(entity)` in a sort/filter callback | `entityPresentationSnapshot(entity, ctx, api).primaryTitle` |
| `humanizeEntity(entity, fallback)` | `useEntityPresentation(entity).primaryTitle` |
+10
View File
@@ -593,6 +593,16 @@ export interface EntityPresentationApi {
// @public
export const entityPresentationApiRef: ApiRef_2<EntityPresentationApi>;
// @public
export function entityPresentationSnapshot(
entityOrRef: Entity | CompoundEntityRef | string,
context?: {
defaultKind?: string;
defaultNamespace?: string;
},
entityPresentationApi?: EntityPresentationApi,
): EntityRefPresentationSnapshot;
// @public (undocumented)
export const EntityProcessingStatusPicker: () => JSX_2.Element;
@@ -37,8 +37,8 @@ import { Observable } from '@backstage/types';
* and tooltip.
*
* - In non-React contexts such as sort comparators or data mappers, use the
* API directly via `forEntity().snapshot` for synchronous access, or
* `forEntity().promise` in async loaders.
* {@link entityPresentationSnapshot} helper for synchronous access, or
* the API directly via `forEntity().promise` in async loaders.
*
* @public
*/
@@ -143,8 +143,8 @@ export interface EntityRefPresentation {
* - {@link EntityDisplayName} — React component that renders an entity name
* with optional icon and tooltip.
*
* For non-React contexts, you can use the API directly via
* `forEntity().snapshot` for synchronous access, or `forEntity().promise`
* For non-React contexts, use the {@link entityPresentationSnapshot} helper
* for synchronous access, or the API directly via `forEntity().promise`
* for async contexts.
*
* Implement this interface to customize how entities are displayed throughout
@@ -35,10 +35,10 @@ import { EntityRefPresentationSnapshot } from './EntityPresentationApi';
*
* This function is primarily used as the internal fallback within the
* {@link EntityPresentationApi} when no custom implementation is registered.
* Prefer using the API directly via `forEntity().snapshot` or
* `forEntity().promise`, which respects custom presentation overrides.
* In React components, use the {@link useEntityPresentation} hook or the
* {@link EntityDisplayName} component.
* {@link EntityDisplayName} component. In non-React contexts, use
* {@link entityPresentationSnapshot} which respects custom presentation
* overrides and falls back to this function when no API is registered.
*
* @public
* @param entityOrRef - Either an entity, or a ref to it.
@@ -0,0 +1,173 @@
/*
* 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 { CompoundEntityRef, Entity } from '@backstage/catalog-model';
import { entityPresentationSnapshot } from './entityPresentationSnapshot';
import {
EntityPresentationApi,
EntityRefPresentation,
EntityRefPresentationSnapshot,
} from './EntityPresentationApi';
function createMockApi(): EntityPresentationApi & {
calls: Array<{ entityOrRef: Entity | string; context?: object }>;
} {
const calls: Array<{ entityOrRef: Entity | string; context?: object }> = [];
return {
calls,
forEntity(entityOrRef, context) {
calls.push({ entityOrRef, context });
const snapshot: EntityRefPresentationSnapshot = {
entityRef: typeof entityOrRef === 'string' ? entityOrRef : 'mock-ref',
primaryTitle: 'from-api',
secondaryTitle: undefined,
Icon: undefined,
};
return {
snapshot,
promise: Promise.resolve(snapshot),
} as EntityRefPresentation;
},
};
}
describe('entityPresentationSnapshot', () => {
describe('with EntityPresentationApi', () => {
it('passes Entity directly to forEntity', () => {
const api = createMockApi();
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: { name: 'test', namespace: 'default' },
};
const result = entityPresentationSnapshot(entity, undefined, api);
expect(result.primaryTitle).toBe('from-api');
expect(api.calls).toHaveLength(1);
expect(api.calls[0].entityOrRef).toBe(entity);
});
it('stringifies CompoundEntityRef before passing to forEntity', () => {
const api = createMockApi();
const ref: CompoundEntityRef = {
kind: 'group',
namespace: 'default',
name: 'my-team',
};
const result = entityPresentationSnapshot(ref, undefined, api);
expect(result.primaryTitle).toBe('from-api');
expect(api.calls).toHaveLength(1);
expect(api.calls[0].entityOrRef).toBe('group:default/my-team');
expect(typeof api.calls[0].entityOrRef).toBe('string');
});
it('passes string ref directly to forEntity', () => {
const api = createMockApi();
const result = entityPresentationSnapshot(
'component:default/test',
undefined,
api,
);
expect(result.primaryTitle).toBe('from-api');
expect(api.calls).toHaveLength(1);
expect(api.calls[0].entityOrRef).toBe('component:default/test');
});
it('forwards context to forEntity', () => {
const api = createMockApi();
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: { name: 'test', namespace: 'default' },
};
entityPresentationSnapshot(
entity,
{ defaultKind: 'component', defaultNamespace: 'custom' },
api,
);
expect(api.calls[0].context).toEqual({
defaultKind: 'component',
defaultNamespace: 'custom',
});
});
});
describe('without EntityPresentationApi', () => {
it('falls back to defaultEntityPresentation for Entity', () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'test',
namespace: 'default',
title: 'My Component',
},
};
const result = entityPresentationSnapshot(entity);
expect(result).toEqual({
entityRef: 'component:default/test',
primaryTitle: 'My Component',
secondaryTitle: 'component:default/test',
Icon: undefined,
});
});
it('falls back to defaultEntityPresentation for CompoundEntityRef', () => {
const ref: CompoundEntityRef = {
kind: 'group',
namespace: 'default',
name: 'my-team',
};
const result = entityPresentationSnapshot(ref);
expect(result).toEqual({
entityRef: 'group:default/my-team',
primaryTitle: 'my-team',
secondaryTitle: 'group:default/my-team',
Icon: undefined,
});
});
it('falls back to defaultEntityPresentation for string ref', () => {
const result = entityPresentationSnapshot('component:default/test');
expect(result).toEqual({
entityRef: 'component:default/test',
primaryTitle: 'test',
secondaryTitle: 'component:default/test',
Icon: undefined,
});
});
it('forwards context to defaultEntityPresentation', () => {
const result = entityPresentationSnapshot('component:default/test', {
defaultKind: 'component',
});
expect(result.primaryTitle).toBe('test');
});
});
});
@@ -0,0 +1,70 @@
/*
* 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 {
CompoundEntityRef,
Entity,
stringifyEntityRef,
} from '@backstage/catalog-model';
import {
EntityPresentationApi,
EntityRefPresentationSnapshot,
} from './EntityPresentationApi';
import { defaultEntityPresentation } from './defaultEntityPresentation';
/**
* Returns a synchronous presentation snapshot for an entity in non-React
* contexts.
*
* @remarks
*
* This is the synchronous, non-React counterpart to
* {@link useEntityPresentation}. It handles `Entity`, `CompoundEntityRef`,
* and string ref inputs uniformly, using the provided
* {@link EntityPresentationApi} when available and falling back to
* {@link defaultEntityPresentation} otherwise.
*
* Because this function is synchronous, it uses cached data from the
* presentation API (via `.snapshot`). If the entity has been seen before,
* the snapshot will contain the full resolved title; otherwise it falls
* back to what can be extracted from the ref alone. This is the correct
* trade-off for sort comparators, column factories, filter callbacks, and
* data mappers where a synchronous return value is required.
*
* In async contexts such as data loaders where you can `await`, prefer
* using the {@link EntityPresentationApi} directly via
* `forEntity().promise` for the richest possible presentation.
*
* @public
* @param entityOrRef - An entity, a compound entity ref, or a string entity ref.
* @param context - Optional context that may affect the presentation.
* @param entityPresentationApi - Optional presentation API instance. When not
* provided, falls back to {@link defaultEntityPresentation}.
*/
export function entityPresentationSnapshot(
entityOrRef: Entity | CompoundEntityRef | string,
context?: { defaultKind?: string; defaultNamespace?: string },
entityPresentationApi?: EntityPresentationApi,
): EntityRefPresentationSnapshot {
if (entityPresentationApi) {
const ref =
typeof entityOrRef === 'string' || 'metadata' in entityOrRef
? entityOrRef
: stringifyEntityRef(entityOrRef);
return entityPresentationApi.forEntity(ref, context).snapshot;
}
return defaultEntityPresentation(entityOrRef, context);
}
@@ -21,4 +21,5 @@ export {
type EntityRefPresentationSnapshot,
} from './EntityPresentationApi';
export { defaultEntityPresentation } from './defaultEntityPresentation';
export { entityPresentationSnapshot } from './entityPresentationSnapshot';
export { useEntityPresentation } from './useEntityPresentation';
@@ -43,7 +43,7 @@ import { useUpdatingObservable } from './useUpdatingObservable';
* component instead, which wraps this hook with icon and tooltip support.
*
* For non-React contexts such as sort comparators or data mappers, use
* the {@link EntityPresentationApi} directly via `forEntity().snapshot`.
* {@link entityPresentationSnapshot}.
*
* @public
* @param entityOrRef - The entity to represent, or an entity ref to it. If you
@@ -22,7 +22,7 @@ import {
import { Cell, CellText, Column, ColumnConfig, TableItem } from '@backstage/ui';
import { EntityRefLink, EntityRefLinks } from '../EntityRefLink';
import {
defaultEntityPresentation,
entityPresentationSnapshot,
EntityPresentationApi,
} from '@backstage/plugin-catalog-react';
import { EntityTableColumnTitle } from '../EntityTable/TitleColumn';
@@ -36,18 +36,6 @@ export interface EntityColumnConfig extends ColumnConfig<EntityRow> {
sortValue?: (entity: EntityRow) => string;
}
function getEntityTitle(
entityOrRef: Entity | { kind: string; namespace?: string; name: string },
context: { defaultKind?: string },
entityPresentationApi?: EntityPresentationApi,
): string {
if (entityPresentationApi) {
return entityPresentationApi.forEntity(entityOrRef as Entity, context)
.snapshot.primaryTitle;
}
return defaultEntityPresentation(entityOrRef as Entity, context).primaryTitle;
}
/** @public */
export const columnFactories = Object.freeze({
createEntityRefColumn(options: {
@@ -76,11 +64,11 @@ export const columnFactories = Object.freeze({
</Cell>
),
sortValue: entity =>
getEntityTitle(
entityPresentationSnapshot(
entity,
{ defaultKind: options.defaultKind },
options.entityPresentationApi,
),
).primaryTitle,
};
},
@@ -115,12 +103,13 @@ export const columnFactories = Object.freeze({
),
sortValue: entity =>
getEntityRelations(entity, options.relation, options.filter)
.map(r =>
getEntityTitle(
r,
{ defaultKind: options.defaultKind },
options.entityPresentationApi,
),
.map(
r =>
entityPresentationSnapshot(
r,
{ defaultKind: options.defaultKind },
options.entityPresentationApi,
).primaryTitle,
)
.join(', '),
};
@@ -70,7 +70,7 @@ export type EntityDisplayNameProps = {
*
* For more control over the presentation data, use the
* {@link useEntityPresentation} hook directly. For non-React contexts, use
* the {@link EntityPresentationApi} directly via `forEntity().snapshot`.
* {@link entityPresentationSnapshot}.
*
* @public
*/
@@ -34,8 +34,8 @@ import { useDebouncedEffect } from '@react-hookz/web';
import PersonIcon from '@material-ui/icons/Person';
import GroupIcon from '@material-ui/icons/Group';
import {
defaultEntityPresentation,
entityPresentationApiRef,
entityPresentationSnapshot,
} from '../../apis';
import { useFetchEntities } from './useFetchEntities';
import { withStyles } from '@material-ui/core/styles';
@@ -209,11 +209,11 @@ export const EntityOwnerPicker = (props?: EntityOwnerPickerProps) => {
defaultNamespace: 'default',
})
: o;
if (entityPresentationApi) {
return entityPresentationApi.forEntity(entity as Entity).snapshot
.primaryTitle;
}
return defaultEntityPresentation(entity).primaryTitle;
return entityPresentationSnapshot(
entity,
undefined,
entityPresentationApi,
).primaryTitle;
}}
onChange={(_: object, owners) => {
setText('');
@@ -26,9 +26,10 @@ import get from 'lodash/get';
* if set to string which matches namespace of entity then omitted
*
* @deprecated Use {@link useEntityPresentation} or {@link EntityDisplayName}
* in React components, or access the {@link entityPresentationApiRef} directly.
* These provide richer display names using `metadata.title` and
* `spec.profile.displayName` in addition to the entity ref.
* in React components. In non-React contexts such as sort comparators or
* data mappers, use {@link entityPresentationSnapshot}. These provide richer
* display names using `metadata.title` and `spec.profile.displayName` in
* addition to the entity ref.
*
* @public
**/
@@ -82,7 +83,8 @@ export function humanizeEntityRef(
* If neither of those are found or populated, fallback to `defaultName`.
*
* @deprecated Use {@link useEntityPresentation} or {@link EntityDisplayName}
* in React components, or access the {@link entityPresentationApiRef} directly.
* in React components. In non-React contexts, use
* {@link entityPresentationSnapshot}.
*
* @param entity - Entity to convert.
* @param defaultName - If entity readable name is not available, `defaultName` will be returned.
@@ -23,21 +23,9 @@ import {
import { OverflowTooltip, TableColumn } from '@backstage/core-components';
import { getEntityRelations } from '../../utils';
import { EntityRefLink, EntityRefLinks } from '../EntityRefLink';
import { defaultEntityPresentation, EntityPresentationApi } from '../../apis';
import { entityPresentationSnapshot, EntityPresentationApi } from '../../apis';
import { EntityTableColumnTitle } from './TitleColumn';
function getEntityTitle(
entityOrRef: Entity | CompoundEntityRef,
context: { defaultKind?: string },
entityPresentationApi?: EntityPresentationApi,
): string {
if (entityPresentationApi) {
return entityPresentationApi.forEntity(entityOrRef as Entity, context)
.snapshot.primaryTitle;
}
return defaultEntityPresentation(entityOrRef, context).primaryTitle;
}
/** @public */
export const columnFactories = Object.freeze({
createEntityRefColumn<T extends Entity>(options: {
@@ -46,7 +34,11 @@ export const columnFactories = Object.freeze({
}): TableColumn<T> {
const { defaultKind, entityPresentationApi } = options;
function formatContent(entity: T): string {
return getEntityTitle(entity, { defaultKind }, entityPresentationApi);
return entityPresentationSnapshot(
entity,
{ defaultKind },
entityPresentationApi,
).primaryTitle;
}
return {
@@ -96,7 +88,14 @@ export const columnFactories = Object.freeze({
function formatContent(entity: T): string {
return getRelations(entity)
.map(r => getEntityTitle(r, { defaultKind }, entityPresentationApi))
.map(
r =>
entityPresentationSnapshot(
r,
{ defaultKind },
entityPresentationApi,
).primaryTitle,
)
.join(', ');
}
@@ -16,7 +16,6 @@
import {
ANNOTATION_EDIT_URL,
ANNOTATION_VIEW_URL,
CompoundEntityRef,
Entity,
RELATION_OWNED_BY,
RELATION_PART_OF,
@@ -30,8 +29,8 @@ import {
WarningPanel,
} from '@backstage/core-components';
import {
defaultEntityPresentation,
entityPresentationApiRef,
entityPresentationSnapshot,
getEntityRelations,
useEntityList,
useStarredEntities,
@@ -73,21 +72,13 @@ export interface CatalogTableProps {
subtitle?: string;
}
function getTitle(
entityOrRef: Entity | CompoundEntityRef,
context: { defaultKind?: string },
api?: EntityPresentationApi,
): string {
if (api) {
const ref =
'metadata' in entityOrRef ? entityOrRef : stringifyEntityRef(entityOrRef);
return api.forEntity(ref, context).snapshot.primaryTitle;
}
return defaultEntityPresentation(entityOrRef, context).primaryTitle;
}
const sortEntities = (entities: Entity[], api?: EntityPresentationApi) => {
return sortBy(entities, e => getTitle(e, { defaultKind: 'Component' }, api));
return sortBy(
entities,
e =>
entityPresentationSnapshot(e, { defaultKind: 'Component' }, api)
.primaryTitle,
);
};
/**
@@ -307,14 +298,26 @@ function toEntityRow(entity: Entity, api?: EntityPresentationApi) {
// This name is here for backwards compatibility mostly; the
// presentation of refs in the table should in general be handled with
// EntityRefLink / EntityName components
name: getTitle(entity, { defaultKind: 'Component' }, api),
name: entityPresentationSnapshot(
entity,
{ defaultKind: 'Component' },
api,
).primaryTitle,
entityRef: stringifyEntityRef(entity),
ownedByRelationsTitle: ownedByRelations
.map(r => getTitle(r, { defaultKind: 'group' }, api))
.map(
r =>
entityPresentationSnapshot(r, { defaultKind: 'group' }, api)
.primaryTitle,
)
.join(', '),
ownedByRelations,
partOfSystemRelationTitle: partOfSystemRelations
.map(r => getTitle(r, { defaultKind: 'system' }, api))
.map(
r =>
entityPresentationSnapshot(r, { defaultKind: 'system' }, api)
.primaryTitle,
)
.join(', '),
partOfSystemRelations,
},
@@ -186,7 +186,7 @@ export const TemplateFormPreviewer = ({
severity: 'error',
}),
),
[catalogApi],
[catalogApi, entityPresentationApi, alertApi],
);
const handleSelectChange = useCallback(
@@ -14,13 +14,9 @@
* limitations under the License.
*/
import { RELATION_OWNED_BY, Entity } from '@backstage/catalog-model';
import {
RELATION_OWNED_BY,
Entity,
stringifyEntityRef,
} from '@backstage/catalog-model';
import {
defaultEntityPresentation,
entityPresentationSnapshot,
getEntityRelations,
type EntityPresentationApi,
} from '@backstage/plugin-catalog-react';
@@ -54,15 +50,14 @@ export function entitiesToDocsMapper(
}),
ownedByRelations,
ownedByRelationsTitle: ownedByRelations
.map(r => {
if (entityPresentationApi) {
return entityPresentationApi.forEntity(stringifyEntityRef(r), {
defaultKind: 'group',
}).snapshot.primaryTitle;
}
return defaultEntityPresentation(r, { defaultKind: 'group' })
.primaryTitle;
})
.map(
r =>
entityPresentationSnapshot(
r,
{ defaultKind: 'group' },
entityPresentationApi,
).primaryTitle,
)
.join(', '),
},
};