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
+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',