diff --git a/.changeset/wet-ghosts-rhyme.md b/.changeset/wet-ghosts-rhyme.md new file mode 100644 index 0000000000..f1867bfb5e --- /dev/null +++ b/.changeset/wet-ghosts-rhyme.md @@ -0,0 +1,23 @@ +--- +'@backstage/frontend-defaults': minor +'@backstage/frontend-app-api': minor +--- + +**BREAKING**: Restructured some of option fields of `createApp` and `createSpecializedApp`. + +- For `createApp`, all option fields _except_ `features` and `bindRoutes` have been moved into a new `advanced` object field. +- For `createSpecializedApp`, all option fields _except_ `features`, `config`, and `bindRoutes` have been moved into a new `advanced` object field. + +This helps highlight that some options are meant to rarely be needed or used, and simplifies the usage of those options that are almost always required. + +As an example, if you used to supply a custom config loader, you would update your code as follows: + +```diff + createApp({ + features: [...], +- configLoader: new MyCustomLoader(), ++ advanced: { ++ configLoader: new MyCustomLoader(), ++ }, + }) +``` diff --git a/packages/app-next/src/App.tsx b/packages/app-next/src/App.tsx index 54b346002b..ac15c895df 100644 --- a/packages/app-next/src/App.tsx +++ b/packages/app-next/src/App.tsx @@ -135,7 +135,9 @@ const app = createApp({ customHomePageModule, ...collectedLegacyPlugins, ], - pluginInfoResolver, + advanced: { + pluginInfoResolver, + }, /* Handled through config instead */ // bindRoutes({ bind }) { // bind(pagesPlugin.externalRoutes, { pageX: pagesPlugin.routes.pageX }); diff --git a/packages/frontend-app-api/report.api.md b/packages/frontend-app-api/report.api.md index 38f9647b79..039d0bb7b1 100644 --- a/packages/frontend-app-api/report.api.md +++ b/packages/frontend-app-api/report.api.md @@ -38,14 +38,14 @@ export type CreateSpecializedAppOptions = { features?: FrontendFeature[]; config?: ConfigApi; bindRoutes?(context: { bind: CreateAppRouteBinder }): void; - apis?: ApiHolder; - extensionFactoryMiddleware?: - | ExtensionFactoryMiddleware - | ExtensionFactoryMiddleware[]; - flags?: { + advanced?: { + apis?: ApiHolder; allowUnknownExtensionConfig?: boolean; + extensionFactoryMiddleware?: + | ExtensionFactoryMiddleware + | ExtensionFactoryMiddleware[]; + pluginInfoResolver?: FrontendPluginInfoResolver; }; - pluginInfoResolver?: FrontendPluginInfoResolver; }; // @public diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx index 146d3fdcf8..5622af0448 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx @@ -313,10 +313,12 @@ describe('createSpecializedApp', () => { it('should use provided apis', async () => { const app = createSpecializedApp({ - apis: TestApiRegistry.from([ - configApiRef, - new ConfigReader({ anything: 'config' }), - ]), + advanced: { + apis: TestApiRegistry.from([ + configApiRef, + new ConfigReader({ anything: 'config' }), + ]), + }, features: [ createFrontendPlugin({ pluginId: 'test', @@ -639,28 +641,30 @@ describe('createSpecializedApp', () => { ], }), ], - extensionFactoryMiddleware: [ - function* middleware(originalFactory, { config }) { - const result = originalFactory({ - config: config && { text: `1-${config.text}` }, - }); - yield* result; - const el = result.get(textDataRef); - if (el) { - yield textDataRef(`${el}-1`); - } - }, - function* middleware(originalFactory, { config }) { - const result = originalFactory({ - config: config && { text: `2-${config.text}` }, - }); - yield* result; - const el = result.get(textDataRef); - if (el) { - yield textDataRef(`${el}-2`); - } - }, - ], + advanced: { + extensionFactoryMiddleware: [ + function* middleware(originalFactory, { config }) { + const result = originalFactory({ + config: config && { text: `1-${config.text}` }, + }); + yield* result; + const el = result.get(textDataRef); + if (el) { + yield textDataRef(`${el}-1`); + } + }, + function* middleware(originalFactory, { config }) { + const result = originalFactory({ + config: config && { text: `2-${config.text}` }, + }); + yield* result; + const el = result.get(textDataRef); + if (el) { + yield textDataRef(`${el}-2`); + } + }, + ], + }, }); const root = app.tree.root.instance!.getData( @@ -774,12 +778,14 @@ describe('createSpecializedApp', () => { const app = createSpecializedApp({ features: [plugin], - async pluginInfoResolver(ctx) { - const { info } = await ctx.defaultResolver({ - packageJson: await ctx.packageJson(), - manifest: await ctx.manifest(), - }); - return { info: { packageName: `decorated:${info.packageName}` } }; + advanced: { + pluginInfoResolver: async ctx => { + const { info } = await ctx.defaultResolver({ + packageJson: await ctx.packageJson(), + manifest: await ctx.manifest(), + }); + return { info: { packageName: `decorated:${info.packageName}` } }; + }, }, }); const info = await app.tree.nodes.get('test')?.spec.plugin?.info(); diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx index e5c565f117..4f15c9d3d4 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx @@ -200,20 +200,72 @@ class RouteResolutionApiProxy implements RouteResolutionApi { } /** - * Options for `createSpecializedApp`. + * Options for {@link createSpecializedApp}. * * @public */ export type CreateSpecializedAppOptions = { + /** + * The list of features to load. + */ features?: FrontendFeature[]; + + /** + * The config API implementation to use. For most normal apps, this should be + * specified. + * + * If none is given, a new _empty_ config will be used during startup. In + * later stages of the app lifecycle, the config API in the API holder will be + * used. + */ config?: ConfigApi; + + /** + * Allows for the binding of plugins' external route refs within the app. + */ bindRoutes?(context: { bind: CreateAppRouteBinder }): void; - apis?: ApiHolder; - extensionFactoryMiddleware?: - | ExtensionFactoryMiddleware - | ExtensionFactoryMiddleware[]; - flags?: { allowUnknownExtensionConfig?: boolean }; - pluginInfoResolver?: FrontendPluginInfoResolver; + + /** + * Advanced, more rarely used options. + */ + advanced?: { + /** + * A replacement API holder implementation to use. + * + * By default, a new API holder will be constructed automatically based on + * the other inputs. If you pass in a custom one here, none of that + * automation will take place - so you will have to take care to supply all + * those APIs yourself. + */ + apis?: ApiHolder; + + /** + * If set to true, the system will silently accept and move on if + * encountering config for extensions that do not exist. The default is to + * reject such config to help catch simple mistakes. + * + * This flag can be useful in some scenarios where you have a dynamic set of + * extensions enabled at different times, but also increases the risk of + * accidentally missing e.g. simple typos in your config. + */ + allowUnknownExtensionConfig?: boolean; + + /** + * Applies one or more middleware on every extension, as they are added to + * the application. + * + * This is an advanced use case for modifying extension data on the fly as + * it gets emitted by extensions being instantiated. + */ + extensionFactoryMiddleware?: + | ExtensionFactoryMiddleware + | ExtensionFactoryMiddleware[]; + + /** + * Allows for customizing how plugin info is retrieved. + */ + pluginInfoResolver?: FrontendPluginInfoResolver; + }; }; /** @@ -229,7 +281,7 @@ export function createSpecializedApp(options?: CreateSpecializedAppOptions): { } { const config = options?.config ?? new ConfigReader({}, 'empty-config'); const features = deduplicateFeatures(options?.features ?? []).map( - createPluginInfoAttacher(config, options?.pluginInfoResolver), + createPluginInfoAttacher(config, options?.advanced?.pluginInfoResolver), ); const tree = resolveAppTree( @@ -241,7 +293,8 @@ export function createSpecializedApp(options?: CreateSpecializedAppOptions): { ], parameters: readAppExtensionsConfig(config), forbidden: new Set(['root']), - allowUnknownExtensionConfig: options?.flags?.allowUnknownExtensionConfig, + allowUnknownExtensionConfig: + options?.advanced?.allowUnknownExtensionConfig, }), ); @@ -257,7 +310,7 @@ export function createSpecializedApp(options?: CreateSpecializedAppOptions): { const appIdentityProxy = new AppIdentityProxy(); const apis = - options?.apis ?? + options?.advanced?.apis ?? createApiHolder({ factories, staticFactories: [ @@ -294,7 +347,9 @@ export function createSpecializedApp(options?: CreateSpecializedAppOptions): { instantiateAppNodeTree( tree.root, apis, - mergeExtensionFactoryMiddleware(options?.extensionFactoryMiddleware), + mergeExtensionFactoryMiddleware( + options?.advanced?.extensionFactoryMiddleware, + ), ); const routeInfo = extractRouteInfoFromAppNode( diff --git a/packages/frontend-defaults/report.api.md b/packages/frontend-defaults/report.api.md index 171cb40fbe..8042345d35 100644 --- a/packages/frontend-defaults/report.api.md +++ b/packages/frontend-defaults/report.api.md @@ -20,25 +20,19 @@ export function createApp(options?: CreateAppOptions): { // @public export interface CreateAppOptions { - // (undocumented) - bindRoutes?(context: { bind: CreateAppRouteBinder }): void; - // (undocumented) - configLoader?: () => Promise<{ - config: ConfigApi; - }>; - // (undocumented) - extensionFactoryMiddleware?: - | ExtensionFactoryMiddleware - | ExtensionFactoryMiddleware[]; - // (undocumented) - features?: (FrontendFeature | FrontendFeatureLoader)[]; - // (undocumented) - flags?: { + advanced?: { allowUnknownExtensionConfig?: boolean; + configLoader?: () => Promise<{ + config: ConfigApi; + }>; + extensionFactoryMiddleware?: + | ExtensionFactoryMiddleware + | ExtensionFactoryMiddleware[]; + loadingComponent?: ReactNode; + pluginInfoResolver?: FrontendPluginInfoResolver; }; - loadingComponent?: ReactNode; - // (undocumented) - pluginInfoResolver?: FrontendPluginInfoResolver; + bindRoutes?(context: { bind: CreateAppRouteBinder }): void; + features?: (FrontendFeature | FrontendFeatureLoader)[]; } // @public @deprecated (undocumented) diff --git a/packages/frontend-defaults/src/createApp.test.tsx b/packages/frontend-defaults/src/createApp.test.tsx index 89ac0a5b39..9558ee05d5 100644 --- a/packages/frontend-defaults/src/createApp.test.tsx +++ b/packages/frontend-defaults/src/createApp.test.tsx @@ -45,18 +45,20 @@ describe('createApp', () => { it('should allow themes to be installed', async () => { const app = createApp({ - configLoader: async () => ({ - config: mockApis.config({ - data: { - app: { - extensions: [ - { 'theme:app/light': false }, - { 'theme:app/dark': false }, - ], + advanced: { + configLoader: async () => ({ + config: mockApis.config({ + data: { + app: { + extensions: [ + { 'theme:app/light': false }, + { 'theme:app/dark': false }, + ], + }, }, - }, + }), }), - }), + }, features: [ createFrontendPlugin({ pluginId: 'test', @@ -85,7 +87,9 @@ describe('createApp', () => { it('should deduplicate features keeping the last received one', async () => { const duplicatedFeatureId = 'test'; const app = createApp({ - configLoader: async () => ({ config: mockApis.config() }), + advanced: { + configLoader: async () => ({ config: mockApis.config() }), + }, features: [ createFrontendPlugin({ pluginId: duplicatedFeatureId, @@ -138,7 +142,6 @@ describe('createApp', () => { } const app = createApp({ - configLoader: async () => ({ config: mockApis.config() }), features: [ appPlugin, createFrontendPlugin({ @@ -153,12 +156,15 @@ describe('createApp', () => { ], }), ], - pluginInfoResolver: async () => { - return { - info: { - packageName: '@test/test', - }, - }; + advanced: { + configLoader: async () => ({ config: mockApis.config() }), + pluginInfoResolver: async () => { + return { + info: { + packageName: '@test/test', + }, + }; + }, }, }); @@ -187,9 +193,11 @@ describe('createApp', () => { }); const app = createApp({ - configLoader: async () => ({ - config: mockApis.config({ data: { key: 'config-value' } }), - }), + advanced: { + configLoader: async () => ({ + config: mockApis.config({ data: { key: 'config-value' } }), + }), + }, features: [appPlugin, loader], }); @@ -208,9 +216,11 @@ describe('createApp', () => { }); const app = createApp({ - configLoader: async () => ({ - config: mockApis.config(), - }), + advanced: { + configLoader: async () => ({ + config: mockApis.config(), + }), + }, features: [loader], }); @@ -221,7 +231,9 @@ describe('createApp', () => { it('should register feature flags', async () => { const app = createApp({ - configLoader: async () => ({ config: mockApis.config() }), + advanced: { + configLoader: async () => ({ config: mockApis.config() }), + }, features: [ appPlugin.withOverrides({ extensions: [ @@ -273,15 +285,6 @@ describe('createApp', () => { it('should allow unknown extension config if the flag is set', async () => { const app = createApp({ - configLoader: async () => ({ - config: mockApis.config({ - data: { - app: { - extensions: [{ 'unknown:lols/wut': false }], - }, - }, - }), - }), features: [ appPlugin, createFrontendPlugin({ @@ -296,7 +299,18 @@ describe('createApp', () => { ], }), ], - flags: { allowUnknownExtensionConfig: true }, + advanced: { + allowUnknownExtensionConfig: true, + configLoader: async () => ({ + config: mockApis.config({ + data: { + app: { + extensions: [{ 'unknown:lols/wut': false }], + }, + }, + }), + }), + }, }); await renderWithEffects(app.createRoot()); @@ -307,7 +321,9 @@ describe('createApp', () => { let appTreeApi: AppTreeApi | undefined = undefined; const app = createApp({ - configLoader: async () => ({ config: mockApis.config() }), + advanced: { + configLoader: async () => ({ config: mockApis.config() }), + }, features: [ appPlugin, createFrontendPlugin({ @@ -413,7 +429,9 @@ describe('createApp', () => { it('should use "Loading..." as the default suspense fallback', async () => { const app = createApp({ - configLoader: () => new Promise(() => {}), + advanced: { + configLoader: () => new Promise(() => {}), + }, }); await renderWithEffects(app.createRoot()); @@ -423,8 +441,10 @@ describe('createApp', () => { it('should use no suspense fallback if the "loadingComponent" is null', async () => { const app = createApp({ - configLoader: () => new Promise(() => {}), - loadingComponent: null, + advanced: { + configLoader: () => new Promise(() => {}), + loadingComponent: null, + }, }); await renderWithEffects(app.createRoot()); @@ -434,8 +454,10 @@ describe('createApp', () => { it('should use a custom "loadingComponent"', async () => { const app = createApp({ - configLoader: () => new Promise(() => {}), - loadingComponent: "Custom loading message", + advanced: { + configLoader: () => new Promise(() => {}), + loadingComponent: "Custom loading message", + }, }); await renderWithEffects(app.createRoot()); @@ -445,7 +467,9 @@ describe('createApp', () => { it('should allow overriding the app plugin', async () => { const app = createApp({ - configLoader: () => new Promise(() => {}), + advanced: { + configLoader: () => new Promise(() => {}), + }, features: [ appPlugin.withOverrides({ extensions: [ @@ -468,7 +492,6 @@ describe('createApp', () => { it('should use a custom extensionFactoryMiddleware', async () => { const app = createApp({ - configLoader: async () => ({ config: mockApis.config() }), features: [ appPlugin, createFrontendPlugin({ @@ -484,18 +507,21 @@ describe('createApp', () => { ], }), ], - *extensionFactoryMiddleware(originalFactory, context) { - const output = originalFactory(); - yield* output; - const element = output.get(coreExtensionData.reactElement); + advanced: { + configLoader: async () => ({ config: mockApis.config() }), + *extensionFactoryMiddleware(originalFactory, context) { + const output = originalFactory(); + yield* output; + const element = output.get(coreExtensionData.reactElement); - if (element) { - yield coreExtensionData.reactElement( -