rename flags to advanced

Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2025-08-07 12:08:06 +02:00
parent c26d91dd9d
commit 5e12252a96
12 changed files with 311 additions and 148 deletions
+23
View File
@@ -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(),
+ },
})
```
+3 -1
View File
@@ -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 });
+6 -6
View File
@@ -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
@@ -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();
@@ -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(
+11 -17
View File
@@ -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)
@@ -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: <span>"Custom loading message"</span>,
advanced: {
configLoader: () => new Promise(() => {}),
loadingComponent: <span>"Custom loading message"</span>,
},
});
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(
<div data-testid={`wrapped(${context.node.spec.id})`}>
{element}
</div>,
);
}
if (element) {
yield coreExtensionData.reactElement(
<div data-testid={`wrapped(${context.node.spec.id})`}>
{element}
</div>,
);
}
},
},
});
@@ -522,7 +548,9 @@ describe('createApp', () => {
});
const app = createApp({
configLoader: () => new Promise(() => {}),
advanced: {
configLoader: () => new Promise(() => {}),
},
features: [mod],
});
@@ -549,7 +577,9 @@ describe('createApp', () => {
});
const app = createApp({
configLoader: () => new Promise(() => {}),
advanced: {
configLoader: () => new Promise(() => {}),
},
features: [mod],
});
+60 -19
View File
@@ -42,21 +42,64 @@ import { resolveAsyncFeatures } from './resolution';
* @public
*/
export interface CreateAppOptions {
features?: (FrontendFeature | FrontendFeatureLoader)[];
configLoader?: () => Promise<{ config: ConfigApi }>;
bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
/**
* The component to render while loading the app (waiting for config, features, etc)
*
* Is the text "Loading..." by default.
* If set to "null" then no loading fallback component is rendered. *
* The list of features to load.
*/
loadingComponent?: ReactNode;
extensionFactoryMiddleware?:
| ExtensionFactoryMiddleware
| ExtensionFactoryMiddleware[];
pluginInfoResolver?: FrontendPluginInfoResolver;
flags?: { allowUnknownExtensionConfig?: boolean };
features?: (FrontendFeature | FrontendFeatureLoader)[];
/**
* Allows for the binding of plugins' external route refs within the app.
*/
bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
/**
* Advanced, more rarely used options.
*/
advanced?: {
/**
* 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;
/**
* Sets a custom config loader, replacing the builtin one.
*
* This can be used e.g. if you have the need to source config out of custom
* storages.
*/
configLoader?: () => Promise<{ config: ConfigApi }>;
/**
* 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[];
/**
* The component to render while loading the app (waiting for config,
* features, etc).
*
* This is the text "Loading..." by default. If set to "null" then no loading
* fallback component is rendered at all.
*/
loadingComponent?: ReactNode;
/**
* Allows for customizing how plugin info is retrieved.
*/
pluginInfoResolver?: FrontendPluginInfoResolver;
};
}
/**
@@ -67,14 +110,14 @@ export interface CreateAppOptions {
export function createApp(options?: CreateAppOptions): {
createRoot(): JSX.Element;
} {
let suspenseFallback = options?.loadingComponent;
let suspenseFallback = options?.advanced?.loadingComponent;
if (suspenseFallback === undefined) {
suspenseFallback = 'Loading...';
}
async function appLoader() {
const config =
(await options?.configLoader?.().then(c => c.config)) ??
(await options?.advanced?.configLoader?.().then(c => c.config)) ??
ConfigReader.fromConfigs(
overrideBaseUrlConfigs(defaultConfigLoaderSync()),
);
@@ -87,12 +130,10 @@ export function createApp(options?: CreateAppOptions): {
});
const app = createSpecializedApp({
config,
features: [appPlugin, ...loadedFeatures],
config,
bindRoutes: options?.bindRoutes,
extensionFactoryMiddleware: options?.extensionFactoryMiddleware,
pluginInfoResolver: options?.pluginInfoResolver,
flags: options?.flags,
advanced: options?.advanced,
});
const rootEl = app.tree.root.instance!.getData(
@@ -30,7 +30,9 @@ describe('createPublicSignInApp', () => {
it('should render a sign-in page', async () => {
const app = createPublicSignInApp({
configLoader: async () => ({ config: mockApis.config() }),
advanced: {
configLoader: async () => ({ config: mockApis.config() }),
},
features: [
createFrontendModule({
pluginId: 'app',
@@ -58,7 +60,9 @@ describe('createPublicSignInApp', () => {
.mockReturnValue();
const app = createPublicSignInApp({
configLoader: async () => ({ config: mockApis.config() }),
advanced: {
configLoader: async () => ({ config: mockApis.config() }),
},
features: [
createFrontendModule({
pluginId: 'app',
@@ -46,7 +46,9 @@ function createTestAppRoot({
}) {
return createApp({
features: [...features],
configLoader: async () => ({ config: mockApis.config({ data: config }) }),
advanced: {
configLoader: async () => ({ config: mockApis.config({ data: config }) }),
},
}).createRoot();
}
@@ -128,7 +128,9 @@ function createTestAppRoot({
}) {
return createApp({
features: [...features],
configLoader: async () => ({ config: mockApis.config({ data: config }) }),
advanced: {
configLoader: async () => ({ config: mockApis.config({ data: config }) }),
},
}).createRoot();
}
@@ -31,7 +31,9 @@ describe('appModulePublicSignIn', () => {
it('should render a sign-in page', async () => {
const app = createApp({
configLoader: async () => ({ config: mockApis.config() }),
advanced: {
configLoader: async () => ({ config: mockApis.config() }),
},
features: [
appModulePublicSignIn,
createFrontendModule({
@@ -60,7 +62,9 @@ describe('appModulePublicSignIn', () => {
.mockReturnValue();
const app = createApp({
configLoader: async () => ({ config: mockApis.config() }),
advanced: {
configLoader: async () => ({ config: mockApis.config() }),
},
features: [
appModulePublicSignIn,
createFrontendModule({