Merge pull request #30813 from backstage/freben/advanced-options

Arrange most `create[Specialized]App` options into an `advanced` field
This commit is contained in:
Fredrik Adelöw
2025-08-08 15:09:05 +02:00
committed by GitHub
12 changed files with 311 additions and 148 deletions
+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();
}