Merge master and update toastApiRef to new ApiRef pattern

Resolve merge conflicts from master introducing the new ApiRef_2 type
pattern with `.with()` builder. Update toastApiRef to use the same
`createApiRef<T>().with({ id, pluginId })` pattern as all other API
refs, and regenerate API reports.

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-03-17 12:28:51 +01:00
190 changed files with 8385 additions and 978 deletions
+2
View File
@@ -43,6 +43,8 @@
"@backstage/cli": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/plugin-app-react": "workspace:^",
"@backstage/plugin-permission-common": "workspace:^",
"@backstage/plugin-permission-react": "workspace:^",
"@backstage/test-utils": "workspace:^",
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^16.0.0",
+1 -1
View File
@@ -8,7 +8,7 @@ import { AppErrorTypes } from '@backstage/frontend-app-api';
import { Config } from '@backstage/config';
import { ConfigApi } from '@backstage/frontend-plugin-api';
import { CreateAppRouteBinder } from '@backstage/frontend-app-api';
import { ExtensionFactoryMiddleware } from '@backstage/frontend-app-api';
import { ExtensionFactoryMiddleware } from '@backstage/frontend-plugin-api';
import { FrontendFeature } from '@backstage/frontend-plugin-api';
import { FrontendFeatureLoader } from '@backstage/frontend-plugin-api';
import { FrontendPluginInfoResolver } from '@backstage/frontend-app-api';
+687 -28
View File
@@ -16,9 +16,14 @@
import {
AppTreeApi,
ApiBlueprint,
appTreeApiRef,
coreExtensionData,
createApiRef,
createExtensionDataRef,
createExtension,
createExtensionBlueprint,
createExtensionInput,
PageBlueprint,
createFrontendPlugin,
createFrontendFeatureLoader,
@@ -27,12 +32,22 @@ import {
FrontendPluginInfo,
} from '@backstage/frontend-plugin-api';
import { ThemeBlueprint } from '@backstage/plugin-app-react';
import { screen, waitFor } from '@testing-library/react';
import { act, screen, waitFor } from '@testing-library/react';
import { createApp } from './createApp';
import { mockApis, renderWithEffects } from '@backstage/test-utils';
import { featureFlagsApiRef, useApi } from '@backstage/core-plugin-api';
import {
featureFlagsApiRef,
IdentityApi,
useApi,
} from '@backstage/core-plugin-api';
import { default as appPluginOriginal } from '@backstage/plugin-app';
import { useState, useEffect } from 'react';
import { ComponentType, useState, useEffect } from 'react';
import { permissionApiRef } from '@backstage/plugin-permission-react';
import { AuthorizeResult } from '@backstage/plugin-permission-common';
const signInPageComponentDataRef = createExtensionDataRef<
ComponentType<{ onSignInSuccess(identity: IdentityApi): void }>
>().with({ id: 'core.sign-in-page.component' });
describe('createApp', () => {
const appPlugin = appPluginOriginal.withOverrides({
@@ -43,6 +58,25 @@ describe('createApp', () => {
],
});
function createFeatureFlagsApi(activeFlags: string[]) {
return {
isActive: jest.fn((name: string) => activeFlags.includes(name)),
registerFlag: jest.fn(),
getRegisteredFlags: () => [],
save: jest.fn(),
} as unknown as typeof featureFlagsApiRef.T;
}
function createPermissionApi(allowedPermissions: string[]) {
return {
authorize: jest.fn(async request => ({
result: allowedPermissions.includes(request.permission.name)
? AuthorizeResult.ALLOW
: AuthorizeResult.DENY,
})),
} as typeof permissionApiRef.T;
}
it('should allow themes to be installed', async () => {
const app = createApp({
advanced: {
@@ -84,6 +118,184 @@ describe('createApp', () => {
await expect(screen.findByText('Derp')).resolves.toBeInTheDocument();
});
it('should provide app APIs to sign-in pages before finalization', async () => {
const signInApiRef = createApiRef<{ value: string }>({
id: 'test.sign-in-api',
});
const app = createApp({
advanced: {
configLoader: async () => ({ config: mockApis.config() }),
},
features: [
appPluginOriginal,
createFrontendPlugin({
pluginId: 'test',
extensions: [
ApiBlueprint.make({
params: defineParams =>
defineParams({
api: signInApiRef,
deps: {},
factory: () => ({ value: 'ok' }),
}),
}),
],
}),
createFrontendModule({
pluginId: 'app',
extensions: [
appPluginOriginal.getExtension('sign-in-page:app').override({
factory: () => {
const SignInPage = () => {
const api = useApi(signInApiRef);
return <div>Sign In API: {api.value}</div>;
};
return [signInPageComponentDataRef(SignInPage)];
},
}),
],
}),
],
});
await renderWithEffects(app.createRoot());
await expect(
screen.findByText('Sign In API: ok'),
).resolves.toBeInTheDocument();
});
it('should provide feature flags to sign-in pages before finalization', async () => {
const app = createApp({
advanced: {
configLoader: async () => ({ config: mockApis.config() }),
},
features: [
appPluginOriginal,
createFrontendPlugin({
pluginId: 'test',
featureFlags: [{ name: 'test-flag' }],
extensions: [],
}),
createFrontendModule({
pluginId: 'app',
extensions: [
appPluginOriginal.getExtension('sign-in-page:app').override({
factory: () => {
const SignInPage = () => {
const flagsApi = useApi(featureFlagsApiRef);
return (
<div>
Flags:{' '}
{flagsApi
.getRegisteredFlags()
.map(flag => flag.name)
.join(', ')}
</div>
);
};
return [signInPageComponentDataRef(SignInPage)];
},
}),
],
}),
],
});
await renderWithEffects(app.createRoot());
await expect(
screen.findByText('Flags: test-flag'),
).resolves.toBeInTheDocument();
});
it('should surface sign-in bootstrap errors through the app root boundary', async () => {
const identityApi = {
getProfileInfo: async () => ({ displayName: 'Test User' }),
getBackstageIdentity: async () => ({
type: 'user' as const,
userEntityRef: 'user:default/test-user',
ownershipEntityRefs: ['user:default/test-user'],
}),
getCredentials: async () => ({ token: 'token' }),
signOut: async () => {},
};
const featureFlagsApi = {
isActive: jest.fn(() => {
throw new Error('sign-in bootstrap failed');
}),
registerFlag: jest.fn(),
getRegisteredFlags: () => [],
save: jest.fn(),
} as unknown as typeof featureFlagsApiRef.T;
let onSignInSuccess: ((identity: IdentityApi) => void) | undefined;
const app = createApp({
advanced: {
configLoader: async () => ({ config: mockApis.config() }),
},
features: [
appPluginOriginal,
createFrontendModule({
pluginId: 'app',
extensions: [
ApiBlueprint.make({
params: defineParams =>
defineParams({
api: featureFlagsApiRef,
deps: {},
factory: () => featureFlagsApi,
}),
}),
appPluginOriginal.getExtension('sign-in-page:app').override({
factory: () => {
function SignInPage(props: {
onSignInSuccess(identity: IdentityApi): void;
}) {
onSignInSuccess = props.onSignInSuccess;
return <div>Custom Sign In</div>;
}
return [signInPageComponentDataRef(SignInPage)];
},
}),
],
}),
createFrontendPlugin({
pluginId: 'test',
featureFlags: [{ name: 'test-flag' }],
extensions: [
PageBlueprint.make({
if: { featureFlags: { $contains: 'test-flag' } },
params: {
path: '/',
loader: async () => <div>Flagged Page</div>,
},
}),
],
}),
],
});
await renderWithEffects(app.createRoot());
await expect(
screen.findByText('Custom Sign In'),
).resolves.toBeInTheDocument();
if (!onSignInSuccess) {
throw new Error('Expected sign-in success callback to be captured');
}
const triggerSignInSuccess = onSignInSuccess;
act(() => {
triggerSignInSuccess(identityApi);
});
await expect(
screen.findByText('sign-in bootstrap failed'),
).resolves.toBeInTheDocument();
});
it('should deduplicate features keeping the last received one', async () => {
const duplicatedFeatureId = 'test';
const app = createApp({
@@ -283,47 +495,497 @@ describe('createApp', () => {
).resolves.toBeInTheDocument();
});
it('should warn about unknown extension config', async () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
it('should evaluate extension if predicates before rendering apps without sign-in', async () => {
const featureFlagsApi = {
isActive: jest.fn((name: string) => name === 'test-flag'),
registerFlag: jest.fn(),
getRegisteredFlags: () => [],
save: jest.fn(),
} as unknown as typeof featureFlagsApiRef.T;
const app = createApp({
advanced: {
configLoader: async () => ({ config: mockApis.config() }),
},
features: [
appPlugin,
createFrontendModule({
pluginId: 'app',
extensions: [
ApiBlueprint.make({
params: defineParams =>
defineParams({
api: featureFlagsApiRef,
deps: {},
factory: () => featureFlagsApi,
}),
}),
],
}),
createFrontendPlugin({
pluginId: 'test',
featureFlags: [{ name: 'test-flag' }],
extensions: [
PageBlueprint.make({
if: { featureFlags: { $contains: 'test-flag' } },
params: {
path: '/',
loader: async () => <div>Derp</div>,
loader: async () => <div>Flagged Page</div>,
},
}),
],
}),
],
advanced: {
configLoader: async () => ({
config: mockApis.config({
data: {
app: {
extensions: [{ 'unknown:lols/wut': false }],
},
},
}),
}),
},
});
await renderWithEffects(app.createRoot());
await expect(screen.findByText('Derp')).resolves.toBeInTheDocument();
expect(warnSpy).toHaveBeenCalledWith('App startup encountered warnings:');
expect(warnSpy).toHaveBeenCalledWith(
'INVALID_EXTENSION_CONFIG_KEY: Extension unknown:lols/wut does not exist',
);
warnSpy.mockRestore();
await expect(
screen.findByText('Flagged Page'),
).resolves.toBeInTheDocument();
expect(featureFlagsApi.isActive).toHaveBeenCalledWith('test-flag');
});
it('should support $all feature flag predicates on pages', async () => {
const partialFlagsApi = createFeatureFlagsApi(['experimental-features']);
const partialFlagsApp = createApp({
advanced: {
configLoader: async () => ({ config: mockApis.config() }),
},
features: [
appPlugin,
createFrontendModule({
pluginId: 'app',
extensions: [
ApiBlueprint.make({
params: defineParams =>
defineParams({
api: featureFlagsApiRef,
deps: {},
factory: () => partialFlagsApi,
}),
}),
],
}),
createFrontendPlugin({
pluginId: 'test',
featureFlags: [
{ name: 'experimental-features' },
{ name: 'advanced-features' },
],
extensions: [
PageBlueprint.make({
if: {
$all: [
{ featureFlags: { $contains: 'experimental-features' } },
{ featureFlags: { $contains: 'advanced-features' } },
],
},
params: {
path: '/',
loader: async () => <div>All Flags Page</div>,
},
}),
],
}),
],
});
const partialRender = await renderWithEffects(partialFlagsApp.createRoot());
await waitFor(() =>
expect(screen.queryByText('All Flags Page')).not.toBeInTheDocument(),
);
partialRender.unmount();
const allFlagsApi = createFeatureFlagsApi([
'experimental-features',
'advanced-features',
]);
const allFlagsApp = createApp({
advanced: {
configLoader: async () => ({ config: mockApis.config() }),
},
features: [
appPlugin,
createFrontendModule({
pluginId: 'app',
extensions: [
ApiBlueprint.make({
params: defineParams =>
defineParams({
api: featureFlagsApiRef,
deps: {},
factory: () => allFlagsApi,
}),
}),
],
}),
createFrontendPlugin({
pluginId: 'test',
featureFlags: [
{ name: 'experimental-features' },
{ name: 'advanced-features' },
],
extensions: [
PageBlueprint.make({
if: {
$all: [
{ featureFlags: { $contains: 'experimental-features' } },
{ featureFlags: { $contains: 'advanced-features' } },
],
},
params: {
path: '/',
loader: async () => <div>All Flags Page</div>,
},
}),
],
}),
],
});
await renderWithEffects(allFlagsApp.createRoot());
await expect(
screen.findByText('All Flags Page'),
).resolves.toBeInTheDocument();
expect(allFlagsApi.isActive).toHaveBeenCalledWith('experimental-features');
expect(allFlagsApi.isActive).toHaveBeenCalledWith('advanced-features');
});
it('should support $any feature flag predicates on pages', async () => {
const noFlagsApi = createFeatureFlagsApi([]);
const noFlagsApp = createApp({
advanced: {
configLoader: async () => ({ config: mockApis.config() }),
},
features: [
appPlugin,
createFrontendModule({
pluginId: 'app',
extensions: [
ApiBlueprint.make({
params: defineParams =>
defineParams({
api: featureFlagsApiRef,
deps: {},
factory: () => noFlagsApi,
}),
}),
],
}),
createFrontendPlugin({
pluginId: 'test',
featureFlags: [
{ name: 'experimental-features' },
{ name: 'beta-access' },
],
extensions: [
PageBlueprint.make({
if: {
$any: [
{ featureFlags: { $contains: 'experimental-features' } },
{ featureFlags: { $contains: 'beta-access' } },
],
},
params: {
path: '/',
loader: async () => <div>Any Flag Page</div>,
},
}),
],
}),
],
});
const noFlagsRender = await renderWithEffects(noFlagsApp.createRoot());
await waitFor(() =>
expect(screen.queryByText('Any Flag Page')).not.toBeInTheDocument(),
);
noFlagsRender.unmount();
const oneFlagApi = createFeatureFlagsApi(['beta-access']);
const oneFlagApp = createApp({
advanced: {
configLoader: async () => ({ config: mockApis.config() }),
},
features: [
appPlugin,
createFrontendModule({
pluginId: 'app',
extensions: [
ApiBlueprint.make({
params: defineParams =>
defineParams({
api: featureFlagsApiRef,
deps: {},
factory: () => oneFlagApi,
}),
}),
],
}),
createFrontendPlugin({
pluginId: 'test',
featureFlags: [
{ name: 'experimental-features' },
{ name: 'beta-access' },
],
extensions: [
PageBlueprint.make({
if: {
$any: [
{ featureFlags: { $contains: 'experimental-features' } },
{ featureFlags: { $contains: 'beta-access' } },
],
},
params: {
path: '/',
loader: async () => <div>Any Flag Page</div>,
},
}),
],
}),
],
});
await renderWithEffects(oneFlagApp.createRoot());
await expect(
screen.findByText('Any Flag Page'),
).resolves.toBeInTheDocument();
expect(oneFlagApi.isActive).toHaveBeenCalledWith('experimental-features');
expect(oneFlagApi.isActive).toHaveBeenCalledWith('beta-access');
});
it('should support permission predicates on pages', async () => {
const deniedPermissionApi = createPermissionApi([]);
const deniedApp = createApp({
advanced: {
configLoader: async () => ({ config: mockApis.config() }),
},
features: [
appPlugin,
createFrontendModule({
pluginId: 'app',
extensions: [
ApiBlueprint.make({
params: defineParams =>
defineParams({
api: permissionApiRef,
deps: {},
factory: () => deniedPermissionApi,
}),
}),
],
}),
createFrontendPlugin({
pluginId: 'test',
extensions: [
PageBlueprint.make({
if: { permissions: { $contains: 'catalog.entity.create' } },
params: {
path: '/',
loader: async () => <div>Permission Page</div>,
},
}),
],
}),
],
});
const deniedRender = await renderWithEffects(deniedApp.createRoot());
await waitFor(() =>
expect(screen.queryByText('Permission Page')).not.toBeInTheDocument(),
);
deniedRender.unmount();
const allowedPermissionApi = createPermissionApi(['catalog.entity.create']);
const allowedApp = createApp({
advanced: {
configLoader: async () => ({ config: mockApis.config() }),
},
features: [
appPlugin,
createFrontendModule({
pluginId: 'app',
extensions: [
ApiBlueprint.make({
params: defineParams =>
defineParams({
api: permissionApiRef,
deps: {},
factory: () => allowedPermissionApi,
}),
}),
],
}),
createFrontendPlugin({
pluginId: 'test',
extensions: [
PageBlueprint.make({
if: { permissions: { $contains: 'catalog.entity.create' } },
params: {
path: '/',
loader: async () => <div>Permission Page</div>,
},
}),
],
}),
],
});
await renderWithEffects(allowedApp.createRoot());
await expect(
screen.findByText('Permission Page'),
).resolves.toBeInTheDocument();
expect(allowedPermissionApi.authorize).toHaveBeenCalledWith({
permission: {
name: 'catalog.entity.create',
type: 'basic',
attributes: {},
},
});
});
it('should support conditional child extensions attached to pages', async () => {
const CardBlueprint = createExtensionBlueprint({
kind: 'card',
attachTo: { id: 'page:test/card-page', input: 'cards' },
output: [coreExtensionData.reactElement],
*factory(params: { title: string }) {
yield coreExtensionData.reactElement(<div>{params.title}</div>);
},
});
const page = PageBlueprint.makeWithOverrides({
name: 'card-page',
inputs: {
cards: createExtensionInput([coreExtensionData.reactElement], {
optional: false,
singleton: false,
}),
},
factory(originalFactory, { inputs }) {
return originalFactory({
path: '/',
loader: async () => (
<div>
{inputs.cards.map(card =>
card.get(coreExtensionData.reactElement),
)}
</div>
),
});
},
});
const publicCard = CardBlueprint.make({
name: 'public',
params: { title: 'Public Card' },
});
const permissionCard = CardBlueprint.make({
name: 'permission',
params: { title: 'Permission Card' },
if: { permissions: { $contains: 'catalog.entity.create' } },
});
const featureFlagCard = CardBlueprint.make({
name: 'feature-flag',
params: { title: 'Feature Flag Card' },
if: { featureFlags: { $contains: 'experimental-card' } },
});
const hiddenCardsApp = createApp({
advanced: {
configLoader: async () => ({ config: mockApis.config() }),
},
features: [
appPlugin,
createFrontendModule({
pluginId: 'app',
extensions: [
ApiBlueprint.make({
name: 'permission-api',
params: defineParams =>
defineParams({
api: permissionApiRef,
deps: {},
factory: () => createPermissionApi([]),
}),
}),
ApiBlueprint.make({
name: 'feature-flags-api',
params: defineParams =>
defineParams({
api: featureFlagsApiRef,
deps: {},
factory: () => createFeatureFlagsApi([]),
}),
}),
],
}),
createFrontendPlugin({
pluginId: 'test',
featureFlags: [{ name: 'experimental-card' }],
extensions: [page, publicCard, permissionCard, featureFlagCard],
}),
],
});
const hiddenCardsRender = await renderWithEffects(
hiddenCardsApp.createRoot(),
);
await expect(screen.findByText('Public Card')).resolves.toBeInTheDocument();
await waitFor(() =>
expect(screen.queryByText('Permission Card')).not.toBeInTheDocument(),
);
await waitFor(() =>
expect(screen.queryByText('Feature Flag Card')).not.toBeInTheDocument(),
);
hiddenCardsRender.unmount();
const visibleCardsApp = createApp({
advanced: {
configLoader: async () => ({ config: mockApis.config() }),
},
features: [
appPlugin,
createFrontendModule({
pluginId: 'app',
extensions: [
ApiBlueprint.make({
name: 'permission-api',
params: defineParams =>
defineParams({
api: permissionApiRef,
deps: {},
factory: () => createPermissionApi(['catalog.entity.create']),
}),
}),
ApiBlueprint.make({
name: 'feature-flags-api',
params: defineParams =>
defineParams({
api: featureFlagsApiRef,
deps: {},
factory: () => createFeatureFlagsApi(['experimental-card']),
}),
}),
],
}),
createFrontendPlugin({
pluginId: 'test',
featureFlags: [{ name: 'experimental-card' }],
extensions: [page, publicCard, permissionCard, featureFlagCard],
}),
],
});
await renderWithEffects(visibleCardsApp.createRoot());
await expect(
screen.findByText('Permission Card'),
).resolves.toBeInTheDocument();
await expect(
screen.findByText('Feature Flag Card'),
).resolves.toBeInTheDocument();
});
it('should make the app structure available through the AppTreeApi', async () => {
let appTreeApi: AppTreeApi | undefined = undefined;
@@ -430,9 +1092,6 @@ describe('createApp', () => {
<app-root-element:app/alert-display out=[core.reactElement] />
<app-root-element:app/dialog-display out=[core.reactElement] />
]
signInPage [
<sign-in-page:app />
]
</app/root>
]
</app>
+34 -15
View File
@@ -14,10 +14,10 @@
* limitations under the License.
*/
import { JSX, lazy, ReactNode, Suspense } from 'react';
import { JSX, lazy, ReactNode, Suspense, useEffect, useState } from 'react';
import {
ConfigApi,
coreExtensionData,
ExtensionFactoryMiddleware,
FrontendFeature,
FrontendFeatureLoader,
} from '@backstage/frontend-plugin-api';
@@ -29,8 +29,9 @@ import { overrideBaseUrlConfigs } from '../../core-app-api/src/app/overrideBaseU
import { ConfigReader } from '@backstage/config';
import {
CreateAppRouteBinder,
createSpecializedApp,
ExtensionFactoryMiddleware,
FinalizedSpecializedApp,
prepareSpecializedApp,
PreparedSpecializedApp,
FrontendPluginInfoResolver,
} from '@backstage/frontend-app-api';
import appPlugin from '@backstage/plugin-app';
@@ -119,23 +120,16 @@ export function createApp(options?: CreateAppOptions): {
features: [...discoveredFeaturesAndLoaders, ...(options?.features ?? [])],
});
const app = createSpecializedApp({
const preparedApp = prepareSpecializedApp({
features: [appPlugin, ...loadedFeatures],
config,
bindRoutes: options?.bindRoutes,
advanced: options?.advanced,
});
const errorPage = maybeCreateErrorPage(app);
if (errorPage) {
return { default: () => errorPage };
}
const rootEl = app.tree.root.instance!.getData(
coreExtensionData.reactElement,
);
return { default: () => rootEl };
return {
default: () => <PreparedAppRoot preparedApp={preparedApp} />,
};
}
const LazyApp = lazy(appLoader);
@@ -150,3 +144,28 @@ export function createApp(options?: CreateAppOptions): {
},
};
}
function PreparedAppRoot(props: {
preparedApp: PreparedSpecializedApp;
}): JSX.Element {
const bootstrapApp = props.preparedApp.getBootstrapApp();
const [finalizedApp, setFinalizedApp] = useState<
FinalizedSpecializedApp | undefined
>();
useEffect(
() => props.preparedApp.onFinalized(setFinalizedApp),
[props.preparedApp],
);
if (!finalizedApp) {
return bootstrapApp.element;
}
const errorPage = maybeCreateErrorPage(finalizedApp);
if (errorPage) {
return errorPage;
}
return finalizedApp.element;
}
@@ -24,6 +24,8 @@ const DEFAULT_WARNING_CODES: Array<keyof AppErrorTypes> = [
'EXTENSION_INPUT_DATA_IGNORED',
'EXTENSION_INPUT_INTERNAL_IGNORED',
'EXTENSION_OUTPUT_IGNORED',
'EXTENSION_BOOTSTRAP_PREDICATE_IGNORED',
'EXTENSION_BOOTSTRAP_API_UNAVAILABLE',
];
function AppErrorItem(props: { error: AppError }): JSX.Element {