frontend-plugin-api: support feature-level predicates

This lets plugin and module instances apply a shared condition to all of their extensions, while preserving extension-level conditions by combining them with logical AND during app spec resolution.

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-03-14 12:00:55 +01:00
parent 457904d372
commit f30eeba995
9 changed files with 201 additions and 66 deletions
@@ -1,6 +1,7 @@
---
'@backstage/frontend-app-api': patch
'@backstage/frontend-defaults': patch
'@backstage/frontend-plugin-api': patch
---
Adds `prepareSpecializedApp` as a new two-phase app wiring API for rendering a bootstrap tree before full app finalization. The bootstrap phase is exposed as a partial app tree through `getBootstrapApp()`, while `onFinalized()` provides a one-way handoff to the finalized app once bootstrap completes. The opaque reusable `sessionState` is returned from the finalized app and can be passed into a future `prepareSpecializedApp` call to skip sign-in and reuse the prepared session. Conditional `app/root.elements` and predicate-gated APIs are now deferred until finalization, while unsupported bootstrap-visible predicates are downgraded to warnings and bootstrap extensions now surface warnings if they accessed APIs that only became available after finalization. The existing `createSpecializedApp` API is now deprecated and backed by `prepareSpecializedApp().finalize()`, while `createApp` has been updated to use the same prepare/finalize flow.
Adds `prepareSpecializedApp` as a new two-phase app wiring API for rendering a bootstrap tree before full app finalization. The bootstrap phase is exposed as a partial app tree through `getBootstrapApp()`, while `onFinalized()` provides a one-way handoff to the finalized app once bootstrap completes. The opaque reusable `sessionState` is returned from the finalized app and can be passed into a future `prepareSpecializedApp` call to skip sign-in and reuse the prepared session. Conditional `app/root.elements` and predicate-gated APIs are now deferred until finalization, while unsupported bootstrap-visible predicates are downgraded to warnings and bootstrap extensions now surface warnings if they accessed APIs that only became available after finalization. The `if` option is also now supported on `createFrontendPlugin` and `createFrontendModule`, and is applied to each extension from that feature together with any extension-level predicate. The existing `createSpecializedApp` API is now deprecated and backed by `prepareSpecializedApp().finalize()`, while `createApp` has been updated to use the same prepare/finalize flow.
@@ -532,4 +532,88 @@ describe('resolveAppNodeSpecs', () => {
expect(specs).toHaveLength(1);
expect(specs[0].if).toEqual(ifPredicate);
});
it('should apply plugin if predicates to all plugin extensions', () => {
const dataRef = createExtensionDataRef<string>().with({ id: 'test.data' });
const pluginIf = { featureFlags: { $contains: 'plugin-flag' } };
const plugin = createFrontendPlugin({
pluginId: 'test-plugin',
if: pluginIf,
extensions: [
createExtension({
name: 'one',
attachTo: { id: 'app', input: 'root' },
output: [dataRef],
factory: () => [dataRef('one')],
}),
createExtension({
name: 'two',
attachTo: { id: 'app', input: 'root' },
output: [dataRef],
factory: () => [dataRef('two')],
}),
],
});
const specs = resolveAppNodeSpecs({
features: [plugin],
builtinExtensions: [],
parameters: [],
collector,
});
expect(specs).toHaveLength(2);
expect(specs[0].if).toEqual(pluginIf);
expect(specs[1].if).toEqual(pluginIf);
});
it('should merge plugin and module if predicates with extension predicates', () => {
const dataRef = createExtensionDataRef<string>().with({ id: 'test.data' });
const pluginIf = { featureFlags: { $contains: 'plugin-flag' } };
const moduleIf = { permissions: { $contains: 'module.permission' } };
const extensionIf = { featureFlags: { $contains: 'extension-flag' } };
const moduleExtensionIf = { featureFlags: { $contains: 'module-flag' } };
const plugin = createFrontendPlugin({
pluginId: 'test-plugin',
if: pluginIf,
extensions: [
createExtension({
name: 'plugin-extension',
attachTo: { id: 'app', input: 'root' },
if: extensionIf,
output: [dataRef],
factory: () => [dataRef('plugin')],
}),
createExtension({
name: 'module-extension',
attachTo: { id: 'app', input: 'root' },
output: [dataRef],
factory: () => [dataRef('base')],
}),
],
});
const module = createFrontendModule({
pluginId: 'test-plugin',
if: moduleIf,
extensions: [
plugin.getExtension('test-plugin/module-extension').override({
if: moduleExtensionIf,
factory: () => [dataRef('module')],
}),
],
});
const specs = resolveAppNodeSpecs({
features: [plugin, module],
builtinExtensions: [],
parameters: [],
collector,
});
expect(specs).toHaveLength(2);
expect(specs[0].id).toBe('test-plugin/plugin-extension');
expect(specs[0].if).toEqual({ $all: [pluginIf, extensionIf] });
expect(specs[1].id).toBe('test-plugin/module-extension');
expect(specs[1].if).toEqual({ $all: [moduleIf, moduleExtensionIf] });
});
});
@@ -20,6 +20,7 @@ import {
FrontendFeature,
FrontendPlugin,
} from '@backstage/frontend-plugin-api';
import { FilterPredicate } from '@backstage/filter-predicates';
import { ExtensionParameters } from './readAppExtensionsConfig';
import { AppNodeSpec } from '@backstage/frontend-plugin-api';
import { OpaqueFrontendPlugin } from '@internal/frontend';
@@ -40,6 +41,33 @@ function normalizePlugin(plugin: FrontendPlugin): FrontendPlugin {
return plugin;
}
function combinePredicates(
left: FilterPredicate | undefined,
right: FilterPredicate | undefined,
) {
if (!left) {
return right;
}
if (!right) {
return left;
}
return { $all: [left, right] };
}
function getExtensionPredicate(options: {
extension: Extension<any, any>;
internalExtension: ReturnType<typeof toInternalExtension>;
}) {
if (options.extension.version === 'v2') {
return options.extension.if;
}
if (options.internalExtension.version === 'v2') {
return options.internalExtension.if;
}
return undefined;
}
/** @internal */
export function resolveAppNodeSpecs(options: {
features?: FrontendFeature[];
@@ -79,26 +107,41 @@ export function resolveAppNodeSpecs(options: {
};
const pluginExtensions = plugins.flatMap(plugin => {
return OpaqueFrontendPlugin.toInternal(plugin)
.extensions.map(extension => ({
const internalPlugin = OpaqueFrontendPlugin.toInternal(plugin);
return internalPlugin.extensions
.map(extension => ({
...extension,
plugin,
if: combinePredicates(
internalPlugin.if,
extension.version === 'v2' ? extension.if : undefined,
),
}))
.filter(filterForbidden);
});
const moduleExtensions = modules.flatMap(mod =>
toInternalFrontendModule(mod)
.extensions.flatMap(extension => {
const moduleExtensions = modules.flatMap(mod => {
const internalModule = toInternalFrontendModule(mod);
return internalModule.extensions
.flatMap(extension => {
// Modules for plugins that are not installed are ignored
const plugin = plugins.find(p => p.pluginId === mod.pluginId);
if (!plugin) {
return [];
}
return [{ ...extension, plugin }];
return [
{
...extension,
plugin,
if: combinePredicates(
internalModule.if,
extension.version === 'v2' ? extension.if : undefined,
),
},
];
})
.filter(filterForbidden),
);
.filter(filterForbidden);
});
const appPlugin =
plugins.find(plugin => plugin.pluginId === 'app') ??
@@ -116,10 +159,7 @@ export function resolveAppNodeSpecs(options: {
source: plugin,
attachTo: internalExtension.attachTo,
disabled: internalExtension.disabled,
if:
internalExtension.version === 'v2'
? internalExtension.if
: undefined,
if: getExtensionPredicate({ extension, internalExtension }),
config: undefined as unknown,
},
};
@@ -133,10 +173,7 @@ export function resolveAppNodeSpecs(options: {
plugin: appPlugin,
attachTo: internalExtension.attachTo,
disabled: internalExtension.disabled,
if:
internalExtension.version === 'v2'
? internalExtension.if
: undefined,
if: getExtensionPredicate({ extension, internalExtension }),
config: undefined as unknown,
},
};
@@ -156,8 +193,10 @@ export function resolveAppNodeSpecs(options: {
configuredExtensions[index].extension = internalExtension;
configuredExtensions[index].params.attachTo = internalExtension.attachTo;
configuredExtensions[index].params.disabled = internalExtension.disabled;
configuredExtensions[index].params.if =
internalExtension.version === 'v2' ? internalExtension.if : undefined;
configuredExtensions[index].params.if = getExtensionPredicate({
extension,
internalExtension,
});
} else {
// Add the extension as a new one when not overriding an existing one
configuredExtensions.push({
@@ -167,10 +206,7 @@ export function resolveAppNodeSpecs(options: {
source: extension.plugin,
attachTo: internalExtension.attachTo,
disabled: internalExtension.disabled,
if:
internalExtension.version === 'v2'
? internalExtension.if
: undefined,
if: getExtensionPredicate({ extension, internalExtension }),
config: undefined,
},
});
@@ -17,6 +17,7 @@
import {
Extension,
FeatureFlagConfig,
FilterPredicate,
IconElement,
OverridableFrontendPlugin,
} from '@backstage/frontend-plugin-api';
@@ -31,6 +32,7 @@ export const OpaqueFrontendPlugin = OpaqueType.create<{
readonly icon?: IconElement;
readonly extensions: Extension<unknown>[];
readonly featureFlags: FeatureFlagConfig[];
readonly if?: FilterPredicate;
readonly infoOptions?: {
packageJson?: () => Promise<JsonObject>;
manifest?: () => Promise<JsonObject>;
+44 -43
View File
@@ -255,12 +255,12 @@ export interface AppNodeSpec {
// (undocumented)
readonly disabled: boolean;
// (undocumented)
readonly enabled?: FilterPredicate;
// (undocumented)
readonly extension: Extension<unknown, unknown>;
// (undocumented)
readonly id: string;
// (undocumented)
readonly if?: FilterPredicate;
// (undocumented)
readonly plugin: FrontendPlugin;
}
@@ -580,7 +580,7 @@ export type CreateExtensionBlueprintOptions<
attachTo: ExtensionDefinitionAttachTo<UParentInputs> &
VerifyExtensionAttachTo<UOutput, UParentInputs>;
disabled?: boolean;
enabled?: FilterPredicate;
if?: FilterPredicate;
inputs?: TInputs;
output: Array<UOutput>;
config?: {
@@ -667,7 +667,7 @@ export type CreateExtensionOptions<
attachTo: ExtensionDefinitionAttachTo<UParentInputs> &
VerifyExtensionAttachTo<UOutput, UParentInputs>;
disabled?: boolean;
enabled?: FilterPredicate;
if?: FilterPredicate;
inputs?: TInputs;
output: Array<UOutput>;
config?: {
@@ -756,6 +756,8 @@ export interface CreateFrontendModuleOptions<
// (undocumented)
featureFlags?: FeatureFlagConfig[];
// (undocumented)
if?: FilterPredicate;
// (undocumented)
pluginId: TPluginId;
}
@@ -770,45 +772,13 @@ export function createFrontendPlugin<
[name in string]: ExternalRouteRef;
} = {},
>(
options: CreateFrontendPluginOptions<
TId,
TRoutes,
TExternalRoutes,
TExtensions
>,
options: PluginOptions<TId, TRoutes, TExternalRoutes, TExtensions>,
): OverridableFrontendPlugin<
TRoutes,
TExternalRoutes,
MakeSortedExtensionsMap<TExtensions[number], TId>
>;
// @public
export interface CreateFrontendPluginOptions<
TId extends string,
TRoutes extends {
[name in string]: RouteRef | SubRouteRef;
},
TExternalRoutes extends {
[name in string]: ExternalRouteRef;
},
TExtensions extends readonly ExtensionDefinition[],
> {
// (undocumented)
extensions?: TExtensions;
// (undocumented)
externalRoutes?: TExternalRoutes;
// (undocumented)
featureFlags?: FeatureFlagConfig[];
icon?: IconElement;
// (undocumented)
info?: FrontendPluginInfoOptions;
// (undocumented)
pluginId: TId;
// (undocumented)
routes?: TRoutes;
title?: string;
}
// @public
export function createRouteRef<
TParams extends
@@ -1031,7 +1001,7 @@ export interface ExtensionBlueprint<
attachTo?: ExtensionDefinitionAttachTo<UParentInputs> &
VerifyExtensionAttachTo<NonNullable<T['output']>, UParentInputs>;
disabled?: boolean;
enabled?: FilterPredicate;
if?: FilterPredicate;
params: TParamsInput extends ExtensionBlueprintDefineParams
? TParamsInput
: T['params'] extends ExtensionBlueprintDefineParams
@@ -1067,7 +1037,7 @@ export interface ExtensionBlueprint<
UParentInputs
>;
disabled?: boolean;
enabled?: FilterPredicate;
if?: FilterPredicate;
inputs?: TExtraInputs & {
[KName in keyof T['inputs']]?: `Error: Input '${KName &
string}' is already defined in parent definition`;
@@ -1709,7 +1679,7 @@ export interface OverridableExtensionDefinition<
UParentInputs
>;
disabled?: boolean;
enabled?: FilterPredicate;
if?: FilterPredicate;
inputs?: TExtraInputs & {
[KName in keyof T['inputs']]?: `Error: Input '${KName &
string}' is already defined in parent definition`;
@@ -1815,6 +1785,7 @@ export interface OverridableFrontendPlugin<
// (undocumented)
withOverrides(options: {
extensions?: Array<ExtensionDefinition>;
if?: FilterPredicate;
title?: string;
icon?: IconElement;
info?: FrontendPluginInfoOptions;
@@ -1971,8 +1942,8 @@ export const pluginHeaderActionsApiRef: ApiRef_2<
readonly $$type: '@backstage/ApiRef';
};
// @public @deprecated (undocumented)
export type PluginOptions<
// @public (undocumented)
export interface PluginOptions<
TId extends string,
TRoutes extends {
[name in string]: RouteRef | SubRouteRef;
@@ -1981,7 +1952,24 @@ export type PluginOptions<
[name in string]: ExternalRouteRef;
},
TExtensions extends readonly ExtensionDefinition[],
> = CreateFrontendPluginOptions<TId, TRoutes, TExternalRoutes, TExtensions>;
> {
// (undocumented)
extensions?: TExtensions;
// (undocumented)
externalRoutes?: TExternalRoutes;
// (undocumented)
featureFlags?: FeatureFlagConfig[];
icon?: IconElement;
// (undocumented)
if?: FilterPredicate;
// (undocumented)
info?: FrontendPluginInfoOptions;
// (undocumented)
pluginId: TId;
// (undocumented)
routes?: TRoutes;
title?: string;
}
// @public
export type PluginWrapperApi = {
@@ -2064,6 +2052,19 @@ export const Progress: {
// @public (undocumented)
export type ProgressProps = {};
// @public
export type ResolvedExtensionInputs<
TInputs extends {
[name in string]: ExtensionInput;
},
> = {
[InputName in keyof TInputs]: false extends TInputs[InputName]['config']['singleton']
? Array<Expand<ResolvedExtensionInput<TInputs[InputName]>>>
: false extends TInputs[InputName]['config']['optional']
? Expand<ResolvedExtensionInput<TInputs[InputName]>>
: Expand<ResolvedExtensionInput<TInputs[InputName]> | undefined>;
};
// @public
export type RouteFunc<TParams extends AnyRouteRefParams> = (
...input: TParams extends undefined ? readonly [] : readonly [params: TParams]
@@ -46,6 +46,7 @@ describe('createFrontendModule', () => {
"disabled": false,
"factory": [Function],
"id": "route:test/test",
"if": undefined,
"inputs": {},
"output": [],
"toString": [Function],
@@ -53,6 +54,7 @@ describe('createFrontendModule', () => {
},
],
"featureFlags": [],
"if": undefined,
"pluginId": "test",
"toString": [Function],
"version": "v1",
@@ -21,6 +21,7 @@ import {
resolveExtensionDefinition,
} from './resolveExtensionDefinition';
import { FeatureFlagConfig } from './types';
import { FilterPredicate } from '@backstage/filter-predicates';
/** @public */
export interface CreateFrontendModuleOptions<
@@ -30,6 +31,7 @@ export interface CreateFrontendModuleOptions<
pluginId: TPluginId;
extensions?: TExtensions;
featureFlags?: FeatureFlagConfig[];
if?: FilterPredicate;
}
/** @public */
@@ -43,6 +45,7 @@ export interface InternalFrontendModule extends FrontendModule {
readonly version: 'v1';
readonly extensions: Extension<unknown>[];
readonly featureFlags: FeatureFlagConfig[];
readonly if?: FilterPredicate;
}
/**
@@ -126,6 +129,7 @@ export function createFrontendModule<
version: 'v1',
pluginId,
featureFlags: options.featureFlags ?? [],
if: options.if,
extensions,
toString() {
return `Module{pluginId=${pluginId}}`;
@@ -171,6 +171,7 @@ describe('createFrontendPlugin', () => {
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"if": undefined,
"inputs": {},
"kind": undefined,
"name": "1",
@@ -359,6 +360,7 @@ describe('createFrontendPlugin', () => {
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"if": undefined,
"inputs": {},
"kind": undefined,
"name": "1",
@@ -32,6 +32,7 @@ import { JsonObject } from '@backstage/types';
import { IconElement } from '../icons/types';
import { RouteRef, SubRouteRef, ExternalRouteRef } from '../routing';
import { ID_PATTERN } from './constants';
import { FilterPredicate } from '@backstage/filter-predicates';
/**
* Information about the plugin.
@@ -195,6 +196,7 @@ export interface CreateFrontendPluginOptions<
externalRoutes?: TExternalRoutes;
extensions?: TExtensions;
featureFlags?: FeatureFlagConfig[];
if?: FilterPredicate;
info?: FrontendPluginInfoOptions;
}
@@ -304,6 +306,7 @@ export function createFrontendPlugin<
routes: options.routes ?? ({} as TRoutes),
externalRoutes: options.externalRoutes ?? ({} as TExternalRoutes),
featureFlags: options.featureFlags ?? [],
if: options.if,
extensions: extensions,
infoOptions: options.info,