diff --git a/.changeset/polite-cooks-perform.md b/.changeset/polite-cooks-perform.md new file mode 100644 index 0000000000..40766c84a3 --- /dev/null +++ b/.changeset/polite-cooks-perform.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-app-api': minor +--- + +The `createApp` config option has been replaced by a new `configLoader` option. There is now also a `pluginLoader` option that can be used to dynamically load plugins into the app. diff --git a/packages/frontend-app-api/api-report.md b/packages/frontend-app-api/api-report.md index 12cf853f9d..2e77ef904b 100644 --- a/packages/frontend-app-api/api-report.md +++ b/packages/frontend-app-api/api-report.md @@ -12,7 +12,8 @@ import { JSX as JSX_2 } from 'react'; // @public (undocumented) export function createApp(options: { plugins: BackstagePlugin[]; - config?: ConfigApi; + configLoader?: () => Promise; + pluginLoader?: (ctx: { config: ConfigApi }) => Promise; }): { createRoot(): JSX_2.Element; }; diff --git a/packages/frontend-app-api/src/wiring/createApp.tsx b/packages/frontend-app-api/src/wiring/createApp.tsx index 462bfcdccc..f722d3594e 100644 --- a/packages/frontend-app-api/src/wiring/createApp.tsx +++ b/packages/frontend-app-api/src/wiring/createApp.tsx @@ -247,50 +247,67 @@ export function createInstances(options: { /** @public */ export function createApp(options: { plugins: BackstagePlugin[]; - config?: ConfigApi; + configLoader?: () => Promise; + pluginLoader?: (ctx: { config: ConfigApi }) => Promise; }): { createRoot(): JSX.Element; } { - const discoveredPlugins = getAvailablePlugins(); - const allPlugins = Array.from( - new Set([...discoveredPlugins, ...options.plugins]), - ); - const appConfig = - options?.config ?? - ConfigReader.fromConfigs(overrideBaseUrlConfigs(defaultConfigLoaderSync())); + async function appLoader() { + const config = + (await options?.configLoader?.()) ?? + ConfigReader.fromConfigs( + overrideBaseUrlConfigs(defaultConfigLoaderSync()), + ); - const { rootInstances } = createInstances({ - plugins: allPlugins, - config: appConfig, - }); + const discoveredPlugins = getAvailablePlugins(); + const loadedPlugins = (await options.pluginLoader?.({ config })) ?? []; + const allPlugins = Array.from( + new Set([...discoveredPlugins, ...options.plugins, ...loadedPlugins]), + ); - const routePaths = extractRouteInfoFromInstanceTree(rootInstances); + const { rootInstances } = createInstances({ + plugins: allPlugins, + config, + }); - const coreInstance = rootInstances.find(({ id }) => id === 'core'); - if (!coreInstance) { - throw Error('Unable to find core extension instance'); + const routePaths = extractRouteInfoFromInstanceTree(rootInstances); + + const coreInstance = rootInstances.find(({ id }) => id === 'core'); + if (!coreInstance) { + throw Error('Unable to find core extension instance'); + } + + const apiHolder = createApiHolder(coreInstance, config); + + const appContext = createLegacyAppContext(allPlugins); + + const rootElements = rootInstances + .map(e => e.getData(coreExtensionData.reactElement)) + .filter((x): x is JSX.Element => !!x); + + const App = () => ( + + + + + {/* TODO: set base path using the logic from AppRouter */} + {rootElements} + + + + + ); + + return { default: App }; } - const apiHolder = createApiHolder(coreInstance, appConfig); - - const appContext = createLegacyAppContext(allPlugins); - return { createRoot() { - const rootElements = rootInstances - .map(e => e.getData(coreExtensionData.reactElement)) - .filter((x): x is JSX.Element => !!x); + const LazyApp = React.lazy(appLoader); return ( - - - - - {/* TODO: set base path using the logic from AppRouter */} - {rootElements} - - - - + + + ); }, }; diff --git a/packages/frontend-plugin-api/src/wiring/createPlugin.test.ts b/packages/frontend-plugin-api/src/wiring/createPlugin.test.ts index 1d45c221af..b14838c17a 100644 --- a/packages/frontend-plugin-api/src/wiring/createPlugin.test.ts +++ b/packages/frontend-plugin-api/src/wiring/createPlugin.test.ts @@ -16,14 +16,14 @@ import React from 'react'; import { createApp } from '@backstage/frontend-app-api'; -import { render, screen } from '@testing-library/react'; +import { screen } from '@testing-library/react'; import { createSchemaFromZod } from '../schema/createSchemaFromZod'; import { createPlugin, BackstagePlugin } from './createPlugin'; import { JsonObject } from '@backstage/types'; import { createExtension } from './createExtension'; import { createExtensionDataRef } from './createExtensionDataRef'; import { coreExtensionData } from './coreExtensionData'; -import { MockConfigApi } from '@backstage/test-utils'; +import { MockConfigApi, renderWithEffects } from '@backstage/test-utils'; import { createExtensionInput } from './createExtensionInput'; const nameExtensionDataRef = createExtensionDataRef('name'); @@ -112,7 +112,7 @@ function createTestAppRoot({ }) { return createApp({ plugins: plugins, - config: new MockConfigApi(config), + configLoader: async () => new MockConfigApi(config), }).createRoot(); } @@ -123,24 +123,26 @@ describe('createPlugin', () => { expect(plugin).toBeDefined(); }); - it('should create a plugin with extension instances', () => { + it('should create a plugin with extension instances', async () => { const plugin = createPlugin({ id: 'empty', extensions: [TechRadarPage, CatalogPage, outputExtension], }); expect(plugin).toBeDefined(); - render( + await renderWithEffects( createTestAppRoot({ plugins: [plugin], config: { app: { extensions: [{ 'core.layout': false }] } }, }), ); - expect(screen.getByText('Names: TechRadar, Catalog')).toBeInTheDocument(); + await expect( + screen.findByText('Names: TechRadar, Catalog'), + ).resolves.toBeInTheDocument(); }); - it('should create a plugin with nested extension instances', () => { + it('should create a plugin with nested extension instances', async () => { const plugin = createPlugin({ id: 'empty', extensions: [ @@ -153,7 +155,7 @@ describe('createPlugin', () => { }); expect(plugin).toBeDefined(); - render( + await renderWithEffects( createTestAppRoot({ plugins: [plugin], config: { @@ -171,10 +173,10 @@ describe('createPlugin', () => { }), ); - expect( - screen.getByText( + await expect( + screen.findByText( 'Names: TechRadar, CatalogRenamed, TechDocs-TechDocsAddon', ), - ).toBeInTheDocument(); + ).resolves.toBeInTheDocument(); }); });