Merge pull request #29049 from davidfestal/create-frontend-feature-loader

Introduce a `createFrontendFeatureLoader`
This commit is contained in:
Patrik Oldsberg
2025-03-12 20:16:21 +01:00
committed by GitHub
14 changed files with 882 additions and 33 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/frontend-plugin-api': minor
'@backstage/frontend-defaults': minor
'@backstage/frontend-app-api': patch
---
Introduced a `createFrontendFeatureLoader()` function, as well as a `FrontendFeatureLoader` interface, to gather several frontend plugins, modules or feature loaders in a single exported entrypoint and load them, possibly asynchronously. This new feature, very similar to the `createBackendFeatureLoader()` already available on the backend, supersedes the previous `CreateAppFeatureLoader` type which has been deprecated.
+3 -4
View File
@@ -8,8 +8,7 @@ import { AppTree } from '@backstage/frontend-plugin-api';
import { ConfigApi } from '@backstage/core-plugin-api';
import { ExtensionFactoryMiddleware } from '@backstage/frontend-plugin-api';
import { ExternalRouteRef } from '@backstage/frontend-plugin-api';
import { FrontendModule } from '@backstage/frontend-plugin-api';
import { FrontendPlugin } from '@backstage/frontend-plugin-api';
import { FrontendFeature as FrontendFeature_2 } from '@backstage/frontend-plugin-api';
import { RouteRef } from '@backstage/frontend-plugin-api';
import { SubRouteRef } from '@backstage/frontend-plugin-api';
@@ -40,6 +39,6 @@ export function createSpecializedApp(options?: {
tree: AppTree;
};
// @public (undocumented)
export type FrontendFeature = FrontendPlugin | FrontendModule;
// @public @deprecated (undocumented)
export type FrontendFeature = FrontendFeature_2;
```
@@ -14,11 +14,13 @@
* limitations under the License.
*/
import { RouteRef } from '@backstage/frontend-plugin-api';
import { FrontendModule, FrontendPlugin } from '@backstage/frontend-plugin-api';
import { FrontendFeature as PluginApiFrontendFeature } from '@backstage/frontend-plugin-api';
import { BackstageRouteObject } from '../routing/types';
/** @public */
export type FrontendFeature = FrontendPlugin | FrontendModule;
/** @public
* @deprecated Use {@link @backstage/frontend-plugin-api#FrontendFeature} instead.
*/
export type FrontendFeature = PluginApiFrontendFeature;
/** @internal */
export type RouteInfo = {
+14 -5
View File
@@ -7,7 +7,8 @@ import { Config } from '@backstage/config';
import { ConfigApi } from '@backstage/frontend-plugin-api';
import { CreateAppRouteBinder } from '@backstage/frontend-app-api';
import { ExtensionFactoryMiddleware } from '@backstage/frontend-plugin-api';
import { FrontendFeature } from '@backstage/frontend-app-api';
import { FrontendFeature } from '@backstage/frontend-plugin-api';
import { FrontendFeatureLoader } from '@backstage/frontend-plugin-api';
import { JSX as JSX_2 } from 'react';
import { default as React_2 } from 'react';
import { ReactNode } from 'react';
@@ -17,7 +18,7 @@ export function createApp(options?: CreateAppOptions): {
createRoot(): JSX_2.Element;
};
// @public
// @public @deprecated
export interface CreateAppFeatureLoader {
getLoaderName(): string;
load(options: { config: ConfigApi }): Promise<{
@@ -38,7 +39,11 @@ export interface CreateAppOptions {
| ExtensionFactoryMiddleware
| ExtensionFactoryMiddleware[];
// (undocumented)
features?: (FrontendFeature | CreateAppFeatureLoader)[];
features?: (
| FrontendFeature
| FrontendFeatureLoader
| CreateAppFeatureLoader
)[];
loadingComponent?: ReactNode;
}
@@ -49,13 +54,17 @@ export function createPublicSignInApp(options?: CreateAppOptions): {
// @public (undocumented)
export function discoverAvailableFeatures(config: Config): {
features: FrontendFeature[];
features: (FrontendFeature | FrontendFeatureLoader)[];
};
// @public (undocumented)
export function resolveAsyncFeatures(options: {
config: Config;
features?: (FrontendFeature | CreateAppFeatureLoader)[];
features?: (
| FrontendFeature
| FrontendFeatureLoader
| CreateAppFeatureLoader
)[];
}): Promise<{
features: FrontendFeature[];
}>;
+13 -6
View File
@@ -19,6 +19,8 @@ import {
ConfigApi,
coreExtensionData,
ExtensionFactoryMiddleware,
FrontendFeature,
FrontendFeatureLoader,
} from '@backstage/frontend-plugin-api';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { defaultConfigLoaderSync } from '../../core-app-api/src/app/defaultConfigLoader';
@@ -27,7 +29,6 @@ import { overrideBaseUrlConfigs } from '../../core-app-api/src/app/overrideBaseU
import { ConfigReader } from '@backstage/config';
import {
CreateAppRouteBinder,
FrontendFeature,
createSpecializedApp,
} from '@backstage/frontend-app-api';
import appPlugin from '@backstage/plugin-app';
@@ -38,6 +39,7 @@ import { resolveAsyncFeatures } from './resolution';
* A source of dynamically loaded frontend features.
*
* @public
* @deprecated Use the {@link @backstage/frontend-plugin-api#createFrontendFeatureLoader} function instead.
*/
export interface CreateAppFeatureLoader {
/**
@@ -59,7 +61,11 @@ export interface CreateAppFeatureLoader {
* @public
*/
export interface CreateAppOptions {
features?: (FrontendFeature | CreateAppFeatureLoader)[];
features?: (
| FrontendFeature
| FrontendFeatureLoader
| CreateAppFeatureLoader
)[];
configLoader?: () => Promise<{ config: ConfigApi }>;
bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
/**
@@ -94,15 +100,16 @@ export function createApp(options?: CreateAppOptions): {
overrideBaseUrlConfigs(defaultConfigLoaderSync()),
);
const { features: discoveredFeatures } = discoverAvailableFeatures(config);
const { features: providedFeatures } = await resolveAsyncFeatures({
const { features: discoveredFeaturesAndLoaders } =
discoverAvailableFeatures(config);
const { features: loadedFeatures } = await resolveAsyncFeatures({
config,
features: options?.features,
features: [...discoveredFeaturesAndLoaders, ...(options?.features ?? [])],
});
const app = createSpecializedApp({
config,
features: [appPlugin, ...discoveredFeatures, ...providedFeatures],
features: [appPlugin, ...loadedFeatures],
bindRoutes: options?.bindRoutes,
extensionFactoryMiddleware: options?.extensionFactoryMiddleware,
});
@@ -14,7 +14,10 @@
* limitations under the License.
*/
import { createFrontendPlugin } from '@backstage/frontend-plugin-api';
import {
createFrontendFeatureLoader,
createFrontendPlugin,
} from '@backstage/frontend-plugin-api';
import { discoverAvailableFeatures } from './discovery';
import { ConfigReader } from '@backstage/config';
@@ -51,6 +54,20 @@ describe('discoverAvailableFeatures', () => {
});
});
it('should discover a frontend feature loader', () => {
const testLoader = createFrontendFeatureLoader({
loader() {
return [];
},
});
globalSpy.mockReturnValue({
modules: [{ default: testLoader }],
});
expect(discoverAvailableFeatures(config)).toEqual({
features: [testLoader],
});
});
it('should ignore garbage', () => {
globalSpy.mockReturnValueOnce({ modules: [{ default: null }] });
expect(discoverAvailableFeatures(config)).toEqual({ features: [] });
+13 -3
View File
@@ -15,7 +15,11 @@
*/
import { Config, ConfigReader } from '@backstage/config';
import { FrontendFeature } from '@backstage/frontend-app-api';
import {
FrontendFeature,
FrontendFeatureLoader,
} from '@backstage/frontend-plugin-api';
import { isBackstageFeatureLoader } from './resolution';
interface DiscoveryGlobal {
modules: Array<{ name: string; export?: string; default: unknown }>;
@@ -56,7 +60,7 @@ function readPackageDetectionConfig(config: Config) {
* @public
*/
export function discoverAvailableFeatures(config: Config): {
features: FrontendFeature[];
features: (FrontendFeature | FrontendFeatureLoader)[];
} {
const discovered = (
window as { '__@backstage/discovered__'?: DiscoveryGlobal }
@@ -80,7 +84,7 @@ export function discoverAvailableFeatures(config: Config): {
return true;
})
.map(m => m.default)
.filter(isBackstageFeature) ?? [],
.filter(isFeatureOrLoader) ?? [],
};
}
@@ -93,3 +97,9 @@ function isBackstageFeature(obj: unknown): obj is FrontendFeature {
}
return false;
}
function isFeatureOrLoader(
obj: unknown,
): obj is FrontendFeature | FrontendFeatureLoader {
return isBackstageFeature(obj) || isBackstageFeatureLoader(obj);
}
@@ -15,7 +15,9 @@
*/
import {
createFrontendFeatureLoader,
createFrontendPlugin,
FrontendFeatureLoader,
PageBlueprint,
} from '@backstage/frontend-plugin-api';
import { CreateAppFeatureLoader } from './createApp';
@@ -69,7 +71,7 @@ describe('resolveAsyncFeatures', () => {
]);
});
it('supports feature loaders', async () => {
it('supports deprecated feature loaders', async () => {
const loader: CreateAppFeatureLoader = {
getLoaderName() {
return 'test-loader';
@@ -118,7 +120,7 @@ describe('resolveAsyncFeatures', () => {
]);
});
it('should propagate errors thrown by feature loaders', async () => {
it('should propagate errors thrown by deprecated feature loaders', async () => {
const loader: CreateAppFeatureLoader = {
getLoaderName() {
return 'test-loader';
@@ -137,4 +139,65 @@ describe('resolveAsyncFeatures', () => {
`"Failed to read frontend features from loader 'test-loader', TypeError: boom"`,
);
});
it('supports feature loaders', async () => {
const loader: FrontendFeatureLoader = createFrontendFeatureLoader({
async loader({ config: _ }) {
return [
createFrontendPlugin({
id: 'test',
extensions: [
PageBlueprint.make({
params: {
defaultPath: '/',
loader: () => new Promise(() => {}),
},
}),
],
}),
];
},
});
const { features } = await resolveAsyncFeatures({
config: mockApis.config(),
features: [loader],
});
expect(features).toMatchObject([
{
$$type: '@backstage/FrontendPlugin',
id: 'test',
version: 'v1',
extensions: [
{
$$type: '@backstage/Extension',
id: 'page:test',
version: 'v2',
attachTo: {
id: 'app/routes',
input: 'routes',
},
},
],
},
]);
});
it('should propagate errors thrown by feature loaders', async () => {
const loader: FrontendFeatureLoader = createFrontendFeatureLoader({
async loader({ config: _ }) {
throw new TypeError('boom');
},
});
await expect(() =>
resolveAsyncFeatures({
config: mockApis.config(),
features: [loader],
}),
).rejects.toThrow(
/^Failed to read frontend features from loader created at .*: TypeError: boom$/,
);
});
});
+77 -9
View File
@@ -16,30 +16,98 @@
import { Config } from '@backstage/config';
import { stringifyError } from '@backstage/errors';
import { FrontendFeature } from '@backstage/frontend-app-api';
import {
FrontendFeature,
FrontendFeatureLoader,
} from '@backstage/frontend-plugin-api';
import { CreateAppFeatureLoader } from './createApp';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { isInternalFrontendFeatureLoader } from '../../frontend-plugin-api/src/wiring/createFrontendFeatureLoader';
/** @public */
export async function resolveAsyncFeatures(options: {
config: Config;
features?: (FrontendFeature | CreateAppFeatureLoader)[];
features?: (
| FrontendFeature
| FrontendFeatureLoader
| CreateAppFeatureLoader
)[];
}): Promise<{ features: FrontendFeature[] }> {
const features = [];
for (const entry of options.features ?? []) {
if ('load' in entry) {
const features: (FrontendFeature | FrontendFeatureLoader)[] = [];
// Separate deprecated CreateAppFeatureLoader elements from the frontend features,
// and manage the deprecated elements first.
for (const item of options?.features ?? []) {
if ('load' in item) {
try {
const result = await entry.load({ config: options.config });
const result = await item.load({ config: options.config });
features.push(...result.features);
} catch (e) {
throw new Error(
`Failed to read frontend features from loader '${entry.getLoaderName()}', ${stringifyError(
`Failed to read frontend features from loader '${item.getLoaderName()}', ${stringifyError(
e,
)}`,
);
}
} else {
features.push(entry);
features.push(item);
}
}
return { features };
const loadedFeatures: FrontendFeature[] = [];
const alreadyMetFeatureLoaders: FrontendFeatureLoader[] = [];
const maxRecursionDepth = 5;
async function applyFeatureLoaders(
featuresOrLoaders: (FrontendFeature | FrontendFeatureLoader)[],
recursionDepth: number,
) {
if (featuresOrLoaders.length === 0) {
return;
}
for (const featureOrLoader of featuresOrLoaders) {
if (isBackstageFeatureLoader(featureOrLoader)) {
if (alreadyMetFeatureLoaders.some(l => l === featureOrLoader)) {
continue;
}
if (isInternalFrontendFeatureLoader(featureOrLoader)) {
if (recursionDepth > maxRecursionDepth) {
throw new Error(
`Maximum feature loading recursion depth (${maxRecursionDepth}) reached for the feature loader ${featureOrLoader.description}`,
);
}
alreadyMetFeatureLoaders.push(featureOrLoader);
let result: (FrontendFeature | FrontendFeatureLoader)[];
try {
result = await featureOrLoader.loader({ config: options.config });
} catch (e) {
throw new Error(
`Failed to read frontend features from loader ${
featureOrLoader.description
}: ${stringifyError(e)}`,
);
}
await applyFeatureLoaders(result, recursionDepth + 1);
}
} else {
loadedFeatures.push(featureOrLoader);
}
}
}
await applyFeatureLoaders(features, 1);
return { features: loadedFeatures };
}
export function isBackstageFeatureLoader(
obj: unknown,
): obj is FrontendFeatureLoader {
return (
obj !== null &&
typeof obj === 'object' &&
'$$type' in obj &&
obj.$$type === '@backstage/FrontendFeatureLoader'
);
}
@@ -707,6 +707,40 @@ export function createExternalRouteRef<
}
>;
// @public (undocumented)
export function createFrontendFeatureLoader(
options: CreateFrontendFeatureLoaderOptions,
): FrontendFeatureLoader;
// @public (undocumented)
export interface CreateFrontendFeatureLoaderOptions {
// (undocumented)
loader(deps: { config: ConfigApi }):
| Iterable<
| FrontendFeature
| FrontendFeatureLoader
| Promise<{
default: FrontendFeature | FrontendFeatureLoader;
}>
>
| Promise<
Iterable<
| FrontendFeature
| FrontendFeatureLoader
| Promise<{
default: FrontendFeature | FrontendFeatureLoader;
}>
>
>
| AsyncIterable<
| FrontendFeature
| FrontendFeatureLoader
| {
default: FrontendFeature | FrontendFeatureLoader;
}
>;
}
// @public (undocumented)
export function createFrontendModule<
TId extends string,
@@ -1236,6 +1270,15 @@ export { FetchApi };
export { fetchApiRef };
// @public (undocumented)
export type FrontendFeature = FrontendPlugin | FrontendModule;
// @public (undocumented)
export interface FrontendFeatureLoader {
// (undocumented)
readonly $$type: '@backstage/FrontendFeatureLoader';
}
// @public (undocumented)
export interface FrontendModule {
// (undocumented)
@@ -0,0 +1,484 @@
/*
* Copyright 2025 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 React from 'react';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { createApp } from '../../../frontend-defaults/src/createApp';
import { screen } from '@testing-library/react';
import { createFrontendPlugin } from './createFrontendPlugin';
import { JsonObject } from '@backstage/types';
import { createExtension } from './createExtension';
import { createExtensionDataRef } from './createExtensionDataRef';
import { coreExtensionData } from './coreExtensionData';
import { mockApis, renderWithEffects } from '@backstage/test-utils';
import { createExtensionInput } from './createExtensionInput';
import {
CreateFrontendFeatureLoaderOptions,
InternalFrontendFeatureLoader,
createFrontendFeatureLoader,
FrontendFeatureLoader,
} from './createFrontendFeatureLoader';
import { FrontendFeature } from './types';
const nameExtensionDataRef = createExtensionDataRef<string>().with({
id: 'name',
});
function createTestAppRoot({
features,
config = {},
}: {
features: (FrontendFeature | FrontendFeatureLoader)[];
config: JsonObject;
}) {
return createApp({
features: [...features],
configLoader: async () => ({ config: mockApis.config({ data: config }) }),
}).createRoot();
}
describe('createFrontendFeatureLoader', () => {
it('should create several plugins with only one feature loader', async () => {
const featureLoader: FrontendFeatureLoader = createFrontendFeatureLoader({
loader({ config }) {
const pluginIdPrefix = config.getOptionalString('pluginIdPrefix');
const extensionNamePrefix = config.getOptionalString(
'extensionNamePrefix',
);
return [
createFrontendPlugin({
id: `${pluginIdPrefix}-1`,
extensions: [
createExtension({
name: '1',
attachTo: {
id: `${pluginIdPrefix}-output/output`,
input: 'names',
},
output: [nameExtensionDataRef],
factory() {
return [nameExtensionDataRef(`${extensionNamePrefix}-1`)];
},
}),
],
}) as FrontendFeature | FrontendFeatureLoader,
createFrontendPlugin({
id: `${pluginIdPrefix}-2`,
extensions: [
createExtension({
name: '2',
attachTo: {
id: `${pluginIdPrefix}-output/output`,
input: 'names',
},
output: [nameExtensionDataRef],
factory() {
return [nameExtensionDataRef(`${extensionNamePrefix}-2`)];
},
}),
],
}) as FrontendFeature | FrontendFeatureLoader,
createFrontendPlugin({
id: `${pluginIdPrefix}-output`,
extensions: [
createExtension({
name: 'output',
attachTo: { id: 'app', input: 'root' },
inputs: {
names: createExtensionInput([nameExtensionDataRef]),
},
output: [coreExtensionData.reactElement],
factory({ inputs }) {
return [
coreExtensionData.reactElement(
React.createElement('span', {}, [
`Names: ${inputs.names
.map(n => n.get(nameExtensionDataRef))
.join(', ')}`,
]),
),
];
},
}),
],
}) as FrontendFeature | FrontendFeatureLoader,
];
},
} as CreateFrontendFeatureLoaderOptions);
expect(featureLoader).toBeDefined();
expect(String(featureLoader)).toMatch(
/^FeatureLoader{description=created at '.*\/packages\/frontend-plugin-api\/src\/wiring\/createFrontendFeatureLoader\.test\.ts:.*'}$/,
);
await renderWithEffects(
createTestAppRoot({
features: [featureLoader],
config: {
app: { extensions: [{ 'app/root': false }] },
extensionNamePrefix: 'extension',
pluginIdPrefix: 'plugin',
},
}),
);
await expect(
screen.findByText('Names: extension-1, extension-2'),
).resolves.toBeInTheDocument();
});
it('should propagate errors thrown by feature loaders', async () => {
const featureLoader: FrontendFeature | FrontendFeatureLoader =
createFrontendFeatureLoader({
async loader(_) {
throw new TypeError('boom');
},
});
await expect(
renderWithEffects(
createTestAppRoot({
features: [featureLoader],
config: {},
}),
),
).rejects.toThrow(
/^Failed to read frontend features from loader created at '.*\/packages\/frontend-plugin-api\/src\/wiring\/createFrontendFeatureLoader\.test\.ts:.*': TypeError: boom$/,
);
});
it('should support loading feature loaders', async () => {
const featureLoader: FrontendFeature | FrontendFeatureLoader =
createFrontendFeatureLoader({
async loader(_) {
return [
createFrontendPlugin({
id: 'plugin-0',
extensions: [
createExtension({
name: '0',
attachTo: {
id: 'plugin-output/output',
input: 'names',
},
output: [nameExtensionDataRef],
factory() {
return [nameExtensionDataRef('extension-0')];
},
}),
],
}),
createFrontendFeatureLoader({
async *loader(__) {
yield createFrontendPlugin({
id: 'plugin-1',
extensions: [
createExtension({
name: '1',
attachTo: {
id: 'plugin-output/output',
input: 'names',
},
output: [nameExtensionDataRef],
factory() {
return [nameExtensionDataRef('extension-1')];
},
}),
],
});
yield createFrontendFeatureLoader({
loader: async ___ => [
createFrontendPlugin({
id: 'plugin-2',
extensions: [
createExtension({
name: '2',
attachTo: {
id: 'plugin-output/output',
input: 'names',
},
output: [nameExtensionDataRef],
factory() {
return [nameExtensionDataRef('extension-2')];
},
}),
],
}),
],
});
},
}),
createFrontendPlugin({
id: 'plugin-output',
extensions: [
createExtension({
name: 'output',
attachTo: { id: 'app', input: 'root' },
inputs: {
names: createExtensionInput([nameExtensionDataRef]),
},
output: [coreExtensionData.reactElement],
factory({ inputs }) {
return [
coreExtensionData.reactElement(
React.createElement('span', {}, [
`Names: ${inputs.names
.map(n => n.get(nameExtensionDataRef))
.join(', ')}`,
]),
),
];
},
}),
],
}),
];
},
});
expect(featureLoader).toBeDefined();
expect(String(featureLoader)).toMatch(
/^FeatureLoader{description=created at '.*\/packages\/frontend-plugin-api\/src\/wiring\/createFrontendFeatureLoader\.test\.ts:.*'}$/,
);
await renderWithEffects(
createTestAppRoot({
features: [featureLoader],
config: {
app: { extensions: [{ 'app/root': false }] },
},
}),
);
await expect(
screen.findByText('Names: extension-0, extension-1, extension-2'),
).resolves.toBeInTheDocument();
});
it('should guard against infinite recursion of nested feature loaders', async () => {
const nestedFeatureLoaderHolder: {
loader?: FrontendFeature | FrontendFeatureLoader;
} = {};
const featureLoader: FrontendFeature | FrontendFeatureLoader =
createFrontendFeatureLoader({
loader: () =>
[
nestedFeatureLoaderHolder.loader,
createFrontendPlugin({
id: 'plugin',
extensions: [
createExtension({
name: 'output',
attachTo: { id: 'app', input: 'root' },
inputs: {},
output: [coreExtensionData.reactElement],
factory() {
return [
coreExtensionData.reactElement(
React.createElement('span', {}, [`My Content`]),
),
];
},
}),
],
}),
].filter<FrontendFeature | FrontendFeatureLoader>(
(f): f is FrontendFeature | FrontendFeatureLoader =>
f !== undefined,
),
});
nestedFeatureLoaderHolder.loader = featureLoader;
expect(featureLoader).toBeDefined();
expect(String(featureLoader)).toMatch(
/^FeatureLoader{description=created at '.*\/packages\/frontend-plugin-api\/src\/wiring\/createFrontendFeatureLoader\.test\.ts:.*'}$/,
);
await renderWithEffects(
createTestAppRoot({
features: [featureLoader],
config: {
app: { extensions: [{ 'app/root': false }] },
},
}),
);
});
it('should support multiple output formats', async () => {
const feature = createFrontendPlugin({
id: 'test',
});
const dynamicFeature = Promise.resolve({ default: feature });
async function extractResult(f: FrontendFeature | FrontendFeatureLoader) {
const internal = f as InternalFrontendFeatureLoader;
return internal.loader({ config: mockApis.config() });
}
await expect(
extractResult(
createFrontendFeatureLoader({
loader() {
return [feature];
},
}),
),
).resolves.toEqual([feature]);
await expect(
extractResult(
createFrontendFeatureLoader({
async loader() {
return [feature];
},
}),
),
).resolves.toEqual([feature]);
await expect(
extractResult(
createFrontendFeatureLoader({
*loader() {
yield feature;
},
}),
),
).resolves.toEqual([feature]);
await expect(
extractResult(
createFrontendFeatureLoader({
async *loader() {
yield feature;
},
}),
),
).resolves.toEqual([feature]);
await expect(
extractResult(
createFrontendFeatureLoader({
loader() {
return [dynamicFeature];
},
}),
),
).resolves.toEqual([feature]);
await expect(
extractResult(
createFrontendFeatureLoader({
async loader() {
return [dynamicFeature];
},
}),
),
).resolves.toEqual([feature]);
await expect(
extractResult(
createFrontendFeatureLoader({
*loader() {
yield dynamicFeature;
},
}),
),
).resolves.toEqual([feature]);
await expect(
extractResult(
createFrontendFeatureLoader({
async *loader() {
yield dynamicFeature;
},
}),
),
).resolves.toEqual([feature]);
});
it('should limit feature loading recursion', async () => {
const plugin = createFrontendPlugin({
id: 'plugin',
extensions: [
createExtension({
name: 'output',
attachTo: { id: 'app', input: 'root' },
inputs: {},
output: [coreExtensionData.reactElement],
factory() {
return [
coreExtensionData.reactElement(
React.createElement('span', {}, [`My Content`]),
),
];
},
}),
],
});
const featureLoader: FrontendFeature | FrontendFeatureLoader =
createFrontendFeatureLoader({
loader: () => [
createFrontendFeatureLoader({
loader: () => [
createFrontendFeatureLoader({
loader: () => [
createFrontendFeatureLoader({
loader: () => [
createFrontendFeatureLoader({
loader: () => [
createFrontendFeatureLoader({
loader: () => [plugin],
}),
],
}),
],
}),
],
}),
],
}),
],
});
await expect(
renderWithEffects(
createTestAppRoot({
features: [featureLoader],
config: {
app: { extensions: [{ 'app/root': false }] },
},
}),
),
).rejects.toThrow(
/^Maximum feature loading recursion depth \(5\) reached for the feature loader created at '.*\/packages\/frontend-plugin-api\/src\/wiring\/createFrontendFeatureLoader\.test\.ts:.*'$/,
);
const nestedLoaders = await (
featureLoader as InternalFrontendFeatureLoader
).loader({ config: mockApis.config() });
await renderWithEffects(
createTestAppRoot({
features: [nestedLoaders[0]],
config: {
app: { extensions: [{ 'app/root': false }] },
},
}),
);
await expect(screen.findByText('My Content')).resolves.toBeInTheDocument();
});
});
@@ -0,0 +1,129 @@
/*
* Copyright 2025 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 { ConfigApi } from '../apis/definitions';
import { describeParentCallSite } from '../routing/describeParentCallSite';
import { FrontendFeature } from './types';
/** @public */
export interface CreateFrontendFeatureLoaderOptions {
loader(deps: {
config: ConfigApi;
}):
| Iterable<
| FrontendFeature
| FrontendFeatureLoader
| Promise<{ default: FrontendFeature | FrontendFeatureLoader }>
>
| Promise<
Iterable<
| FrontendFeature
| FrontendFeatureLoader
| Promise<{ default: FrontendFeature | FrontendFeatureLoader }>
>
>
| AsyncIterable<
| FrontendFeature
| FrontendFeatureLoader
| { default: FrontendFeature | FrontendFeatureLoader }
>;
}
/** @public */
export interface FrontendFeatureLoader {
readonly $$type: '@backstage/FrontendFeatureLoader';
}
/** @internal */
export interface InternalFrontendFeatureLoader extends FrontendFeatureLoader {
readonly version: 'v1';
readonly description: string;
readonly loader: (deps: {
config: ConfigApi;
}) => Promise<(FrontendFeature | FrontendFeatureLoader)[]>;
}
/** @public */
export function createFrontendFeatureLoader(
options: CreateFrontendFeatureLoaderOptions,
): FrontendFeatureLoader {
const description = `created at '${describeParentCallSite()}'`;
return {
$$type: '@backstage/FrontendFeatureLoader',
version: 'v1',
description,
toString() {
return `FeatureLoader{description=${description}}`;
},
async loader(deps: {
config: ConfigApi;
}): Promise<(FrontendFeature | FrontendFeatureLoader)[]> {
const it = await options.loader(deps);
const result = new Array<FrontendFeature | FrontendFeatureLoader>();
for await (const item of it) {
if (isFeatureOrLoader(item)) {
result.push(item);
} else if ('default' in item) {
result.push(item.default);
} else {
throw new Error(`Invalid item "${item}"`);
}
}
return result;
},
} as InternalFrontendFeatureLoader;
}
/** @internal */
export function isInternalFrontendFeatureLoader(opaque: {
$$type: string;
}): opaque is InternalFrontendFeatureLoader {
if (opaque.$$type === '@backstage/FrontendFeatureLoader') {
// Make sure we throw if invalid
toInternalFrontendFeatureLoader(opaque as FrontendFeatureLoader);
return true;
}
return false;
}
/** @internal */
export function toInternalFrontendFeatureLoader(
plugin: FrontendFeatureLoader,
): InternalFrontendFeatureLoader {
const internal = plugin as InternalFrontendFeatureLoader;
if (internal.$$type !== '@backstage/FrontendFeatureLoader') {
throw new Error(`Invalid plugin instance, bad type '${internal.$$type}'`);
}
if (internal.version !== 'v1') {
throw new Error(
`Invalid plugin instance, bad version '${internal.version}'`,
);
}
return internal;
}
function isFeatureOrLoader(
obj: unknown,
): obj is FrontendFeature | FrontendFeatureLoader {
if (obj !== null && typeof obj === 'object' && '$$type' in obj) {
return (
obj.$$type === '@backstage/FrontendPlugin' ||
obj.$$type === '@backstage/FrontendModule' ||
obj.$$type === '@backstage/FrontendFeatureLoader'
);
}
return false;
}
@@ -46,6 +46,11 @@ export {
type FrontendModule,
type CreateFrontendModuleOptions,
} from './createFrontendModule';
export {
createFrontendFeatureLoader,
type FrontendFeatureLoader,
type CreateFrontendFeatureLoaderOptions,
} from './createFrontendFeatureLoader';
export { type Extension } from './resolveExtensionDefinition';
export {
type AnyRoutes,
@@ -53,6 +58,7 @@ export {
type ExtensionDataContainer,
type FeatureFlagConfig,
type ExtensionFactoryMiddleware,
type FrontendFeature,
} from './types';
export {
type CreateExtensionBlueprintOptions,
@@ -23,6 +23,8 @@ import {
ExtensionDataValue,
} from './createExtensionDataRef';
import { ApiHolder, AppNode } from '../apis';
import { FrontendModule } from './createFrontendModule';
import { FrontendPlugin } from './createFrontendPlugin';
/**
* Feature flag configuration.
@@ -80,3 +82,6 @@ export type ExtensionFactoryMiddleware = (
config?: JsonObject;
},
) => Iterable<ExtensionDataValue<any, any>>;
/** @public */
export type FrontendFeature = FrontendPlugin | FrontendModule;