diff --git a/.changeset/fluffy-bikes-laugh.md b/.changeset/fluffy-bikes-laugh.md new file mode 100644 index 0000000000..40be65c126 --- /dev/null +++ b/.changeset/fluffy-bikes-laugh.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-app-api': minor +--- + +Removed `featureLoader` from `createApp`, `features` instead accepts both `FrontendFeature` and `CreateAppFeatureLoader` diff --git a/packages/frontend-app-api/api-report.md b/packages/frontend-app-api/api-report.md index a4654e9f8d..e7dd584734 100644 --- a/packages/frontend-app-api/api-report.md +++ b/packages/frontend-app-api/api-report.md @@ -14,16 +14,23 @@ import { SubRouteRef } from '@backstage/frontend-plugin-api'; // @public (undocumented) export function createApp(options?: { - features?: FrontendFeature[]; + features?: (FrontendFeature | CreateAppFeatureLoader)[]; configLoader?: () => Promise<{ config: ConfigApi; }>; bindRoutes?(context: { bind: CreateAppRouteBinder }): void; - featureLoader?: (ctx: { config: ConfigApi }) => Promise; }): { createRoot(): JSX_2.Element; }; +// @public +export interface CreateAppFeatureLoader { + getLoaderName(): string; + load(options: { config: ConfigApi }): Promise<{ + features: FrontendFeature[]; + }>; +} + // @public export type CreateAppRouteBinder = < TExternalRoutes extends { diff --git a/packages/frontend-app-api/package.json b/packages/frontend-app-api/package.json index 11f7260a20..db4dc931f2 100644 --- a/packages/frontend-app-api/package.json +++ b/packages/frontend-app-api/package.json @@ -38,6 +38,7 @@ "@backstage/core-app-api": "workspace:^", "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", + "@backstage/errors": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", "@backstage/theme": "workspace:^", "@backstage/types": "workspace:^", diff --git a/packages/frontend-app-api/src/wiring/createApp.test.tsx b/packages/frontend-app-api/src/wiring/createApp.test.tsx index dafad5365c..dc39ab3ae5 100644 --- a/packages/frontend-app-api/src/wiring/createApp.test.tsx +++ b/packages/frontend-app-api/src/wiring/createApp.test.tsx @@ -25,7 +25,7 @@ import { createThemeExtension, } from '@backstage/frontend-plugin-api'; import { screen, waitFor } from '@testing-library/react'; -import { createApp } from './createApp'; +import { CreateAppFeatureLoader, createApp } from './createApp'; import { MockConfigApi, renderWithEffects } from '@backstage/test-utils'; import React from 'react'; import { featureFlagsApiRef, useApi } from '@backstage/core-plugin-api'; @@ -99,6 +99,66 @@ describe('createApp', () => { ); }); + it('should support feature loaders', async () => { + const loader: CreateAppFeatureLoader = { + getLoaderName() { + return 'test-loader'; + }, + async load({ config }) { + return { + features: [ + createPlugin({ + id: 'test', + extensions: [ + createPageExtension({ + defaultPath: '/', + loader: async () =>
{config.getString('key')}
, + }), + ], + }), + ], + }; + }, + }; + + const app = createApp({ + configLoader: async () => ({ + config: new MockConfigApi({ key: 'config-value' }), + }), + features: [loader], + }); + + await renderWithEffects(app.createRoot()); + + await expect( + screen.findByText('config-value'), + ).resolves.toBeInTheDocument(); + }); + + it('should propagate errors thrown by feature loaders', async () => { + const loader: CreateAppFeatureLoader = { + getLoaderName() { + return 'test-loader'; + }, + async load() { + throw new TypeError('boom'); + }, + }; + + const app = createApp({ + configLoader: async () => ({ + config: new MockConfigApi({}), + }), + features: [loader], + }); + + await expect( + renderWithEffects(app.createRoot()), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to read frontend features from loader 'test-loader', TypeError: boom"`, + ); + }); + it('should register feature flags', async () => { const app = createApp({ configLoader: async () => ({ config: new MockConfigApi({}) }), diff --git a/packages/frontend-app-api/src/wiring/createApp.tsx b/packages/frontend-app-api/src/wiring/createApp.tsx index 0f8e3de953..572e88e647 100644 --- a/packages/frontend-app-api/src/wiring/createApp.tsx +++ b/packages/frontend-app-api/src/wiring/createApp.tsx @@ -101,6 +101,7 @@ import { toInternalBackstagePlugin } from '../../../frontend-plugin-api/src/wiri // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { toInternalExtensionOverrides } from '../../../frontend-plugin-api/src/wiring/createExtensionOverrides'; import { DefaultComponentsApi } from '../apis/implementations/ComponentsApi'; +import { stringifyError } from '@backstage/errors'; export const builtinExtensions = [ Core, @@ -238,12 +239,30 @@ function deduplicateFeatures( .reverse(); } +/** + * A source of dynamically loaded frontend features. + * + * @public + */ +export interface CreateAppFeatureLoader { + /** + * Returns name of this loader. suitable for showing to users. + */ + getLoaderName(): string; + + /** + * Loads a number of features dynamically. + */ + load(options: { config: ConfigApi }): Promise<{ + features: FrontendFeature[]; + }>; +} + /** @public */ export function createApp(options?: { - features?: FrontendFeature[]; + features?: (FrontendFeature | CreateAppFeatureLoader)[]; configLoader?: () => Promise<{ config: ConfigApi }>; bindRoutes?(context: { bind: CreateAppRouteBinder }): void; - featureLoader?: (ctx: { config: ConfigApi }) => Promise; }): { createRoot(): JSX.Element; } { @@ -255,15 +274,28 @@ export function createApp(options?: { ); const discoveredFeatures = getAvailableFeatures(config); - const loadedFeatures = (await options?.featureLoader?.({ config })) ?? []; + + const providedFeatures: FrontendFeature[] = []; + for (const entry of options?.features ?? []) { + if ('load' in entry) { + try { + const result = await entry.load({ config }); + providedFeatures.push(...result.features); + } catch (e) { + throw new Error( + `Failed to read frontend features from loader '${entry.getLoaderName()}', ${stringifyError( + e, + )}`, + ); + } + } else { + providedFeatures.push(entry); + } + } const app = createSpecializedApp({ config, - features: [ - ...discoveredFeatures, - ...loadedFeatures, - ...(options?.features ?? []), - ], + features: [...discoveredFeatures, ...providedFeatures], bindRoutes: options?.bindRoutes, }).createRoot(); @@ -285,6 +317,7 @@ export function createApp(options?: { /** * Synchronous version of {@link createApp}, expecting all features and * config to have been loaded already. + * * @public */ export function createSpecializedApp(options?: { diff --git a/packages/frontend-app-api/src/wiring/index.ts b/packages/frontend-app-api/src/wiring/index.ts index 412e643523..00c6458bd5 100644 --- a/packages/frontend-app-api/src/wiring/index.ts +++ b/packages/frontend-app-api/src/wiring/index.ts @@ -18,6 +18,7 @@ export { createApp, createSpecializedApp, createExtensionTree, + type CreateAppFeatureLoader, type ExtensionTreeNode, type ExtensionTree, } from './createApp'; diff --git a/yarn.lock b/yarn.lock index 4719742e16..ad150e9504 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4158,6 +4158,7 @@ __metadata: "@backstage/core-app-api": "workspace:^" "@backstage/core-components": "workspace:^" "@backstage/core-plugin-api": "workspace:^" + "@backstage/errors": "workspace:^" "@backstage/frontend-plugin-api": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/theme": "workspace:^"