diff --git a/.changeset/core-compat-api-filter-predicates-dependency.md b/.changeset/core-compat-api-filter-predicates-dependency.md
new file mode 100644
index 0000000000..5b99e9c79c
--- /dev/null
+++ b/.changeset/core-compat-api-filter-predicates-dependency.md
@@ -0,0 +1,5 @@
+---
+'@backstage/core-compat-api': patch
+---
+
+Added a missing dependency on `@backstage/filter-predicates` to `@backstage/core-compat-api`. This fixes package metadata for consumers that use compatibility helpers relying on filter predicate support.
diff --git a/.changeset/core-components-progress-accessibility.md b/.changeset/core-components-progress-accessibility.md
new file mode 100644
index 0000000000..c4f53ccd2d
--- /dev/null
+++ b/.changeset/core-components-progress-accessibility.md
@@ -0,0 +1,5 @@
+---
+'@backstage/core-components': patch
+---
+
+Fixed the shared `Progress` component to provide an accessible name for its loading indicator by default.
diff --git a/.changeset/frontend-test-utils-filter-predicates-dependency.md b/.changeset/frontend-test-utils-filter-predicates-dependency.md
new file mode 100644
index 0000000000..c8664d9673
--- /dev/null
+++ b/.changeset/frontend-test-utils-filter-predicates-dependency.md
@@ -0,0 +1,5 @@
+---
+'@backstage/frontend-test-utils': patch
+---
+
+Added a missing dependency on `@backstage/filter-predicates` to `@backstage/frontend-test-utils`. This fixes package metadata for consumers using the frontend test app helpers with predicate-based behavior.
diff --git a/.changeset/permission-api-batching.md b/.changeset/permission-api-batching.md
new file mode 100644
index 0000000000..187eb2a3bc
--- /dev/null
+++ b/.changeset/permission-api-batching.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-permission-react': patch
+---
+
+Permission checks made in the same tick are now batched into a single call to the permission backend.
diff --git a/.changeset/plugin-app-bootstrap-phase.md b/.changeset/plugin-app-bootstrap-phase.md
new file mode 100644
index 0000000000..c3bab9e563
--- /dev/null
+++ b/.changeset/plugin-app-bootstrap-phase.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-app': patch
+---
+
+Updated the default app root to better support phased app preparation by allowing the app layout to be absent during bootstrap, routing bootstrap failures through the app root boundary, and avoiding installation of a guest identity in protected apps that do not provide a sign-in page.
diff --git a/.changeset/prepare-specialized-app-frontend-app-api.md b/.changeset/prepare-specialized-app-frontend-app-api.md
new file mode 100644
index 0000000000..e62d025189
--- /dev/null
+++ b/.changeset/prepare-specialized-app-frontend-app-api.md
@@ -0,0 +1,5 @@
+---
+'@backstage/frontend-app-api': patch
+---
+
+Added `prepareSpecializedApp` for two-phase app wiring so apps can render a bootstrap tree before full app finalization. The bootstrap phase now supports deferred `app/root.elements`, predicate-gated APIs, reusable `sessionState`, and warnings for bootstrap-visible predicates or bootstrap code that accessed APIs that only became available after finalization. Utility APIs that are materialized during bootstrap are also frozen for the lifetime of the app instance, causing deferred overrides of those APIs to be ignored and reported as app errors.
diff --git a/.changeset/prepare-specialized-app-frontend-defaults.md b/.changeset/prepare-specialized-app-frontend-defaults.md
new file mode 100644
index 0000000000..12e153fc75
--- /dev/null
+++ b/.changeset/prepare-specialized-app-frontend-defaults.md
@@ -0,0 +1,5 @@
+---
+'@backstage/frontend-defaults': patch
+---
+
+Updated `createApp` to use the phased `prepareSpecializedApp` flow, allowing apps to render a bootstrap tree before the full app is finalized.
diff --git a/.changeset/prepare-specialized-app-frontend-plugin-api.md b/.changeset/prepare-specialized-app-frontend-plugin-api.md
new file mode 100644
index 0000000000..9933690f39
--- /dev/null
+++ b/.changeset/prepare-specialized-app-frontend-plugin-api.md
@@ -0,0 +1,5 @@
+---
+'@backstage/frontend-plugin-api': patch
+---
+
+Added support for `if` predicates on `createFrontendPlugin` and `createFrontendModule`, applying shared conditions to every extension in the feature. Plugin and extension overrides can now also replace or remove existing `if` predicates.
diff --git a/docs/frontend-system/architecture/10-app.md b/docs/frontend-system/architecture/10-app.md
index 22457bcb30..3e7c760a42 100644
--- a/docs/frontend-system/architecture/10-app.md
+++ b/docs/frontend-system/architecture/10-app.md
@@ -48,6 +48,52 @@ Because feature discovery needs to interact with the compilation process, it is
For information on how to configure feature discovery and other installation options, see [Installing Plugins](../building-apps/05-installing-plugins.md).
+## Preparing an App in Phases
+
+Most apps should use `createApp` from `@backstage/frontend-defaults`, which takes care of all app preparation internally. For more advanced use cases there is also a lower-level `prepareSpecializedApp` API in `@backstage/frontend-app-api`.
+
+This API is useful when you need to render a bootstrap tree before the full app can be finalized, for example while waiting for sign-in or other session-dependent state. It gives you access to a bootstrap app tree immediately, lets you either subscribe to finalization with `onFinalized()` or finalize synchronously with `finalize()`, and lets you reuse a prepared session in a later app instance.
+
+```tsx
+import {
+ FinalizedSpecializedApp,
+ prepareSpecializedApp,
+} from '@backstage/frontend-app-api';
+
+const preparedApp = prepareSpecializedApp({
+ config,
+ features: [appPlugin, ...features],
+});
+
+const bootstrapApp = preparedApp.getBootstrapApp();
+
+const unsubscribe = preparedApp.onFinalized(
+ (finalizedApp: FinalizedSpecializedApp) => {
+ console.log(finalizedApp.sessionState);
+ },
+);
+```
+
+The `getBootstrapApp()` method exposes the partial app tree that is available during bootstrap. If you call `onFinalized()`, you are subscribing to the bootstrap-owned finalization flow. In the sign-in case, the sign-in page receives an `onSignInSuccess` callback, and once it provides an identity through that callback the full app is finalized and `onFinalized()` subscribers are notified.
+
+If you instead call `finalize()`, you are taking ownership of finalization yourself. This only works when the app can be finalized synchronously, for example when all predicate context is already available or when you passed a reusable session state to `prepareSpecializedApp()` up front:
+
+```tsx
+const preparedApp = prepareSpecializedApp({
+ config,
+ features: [appPlugin, ...features],
+ advanced: {
+ sessionState,
+ },
+});
+
+const app = preparedApp.finalize();
+```
+
+When using phased app preparation, `app/root.children` acts as the main session boundary. Conditional extensions behind that boundary are evaluated during finalization. Conditional `app/root.elements` and API branches are also deferred until finalization, while other bootstrap-visible predicates are ignored and reported as warnings.
+
+Utility APIs that are first materialized during bootstrap are frozen for the lifetime of that app instance. Finalization may still add new APIs and may override existing API refs that were not materialized during bootstrap, but any deferred override of an already materialized bootstrap API is ignored and reported as an app error.
+
## Plugin Info Resolution
When a plugin is installed in an app it may provide sources of information about the plugin that can be useful to end users and admins. This includes things like what version of a plugin is running, what team owns the plugin, and who to contact for support. You can read more about how the plugins provide this information in the [plugins `info` option section](./15-plugins.md#info).
diff --git a/docs/frontend-system/architecture/15-plugins.md b/docs/frontend-system/architecture/15-plugins.md
index c2f5f237c3..d124a0e61d 100644
--- a/docs/frontend-system/architecture/15-plugins.md
+++ b/docs/frontend-system/architecture/15-plugins.md
@@ -76,6 +76,20 @@ These are the routes that the plugin exposes to the app. The `routes` option dec
This is a list of feature flag declarations that your plugin provides to the app. This makes sure that the feature flags are correctly registered and can be toggled in the app. To read a feature flag you can use the feature flags [Utility API](../architecture/33-utility-apis.md), accessible via `featureFlagsApiRef`.
+### `if` option
+
+The `if` option lets you apply a shared condition to all extensions that are provided by a plugin instance. This is useful when you want to gate an entire plugin behind a feature flag or permission without repeating the same predicate on every individual extension.
+
+```tsx
+export default createFrontendPlugin({
+ pluginId: 'my-plugin',
+ if: { featureFlags: { $contains: 'my-plugin-enabled' } },
+ extensions: [...],
+});
+```
+
+This predicate is applied to every extension from that plugin instance. If any extension already has its own `if` predicate, the two are combined using logical `AND`.
+
### `info` option
This options is used to provide loaders for different sources of information about the plugin that may be useful to users and admins. The two available loaders are `packageJson` and `manifest`, and a plugin can use either or both as needed. The resulting information is available via the `info()` method on the plugin instance once it is installed in an app, but it is up to each app to decide how to derive the information from the provided sources.
diff --git a/docs/frontend-system/architecture/20-extensions.md b/docs/frontend-system/architecture/20-extensions.md
index 106ab5340a..3df33b9cad 100644
--- a/docs/frontend-system/architecture/20-extensions.md
+++ b/docs/frontend-system/architecture/20-extensions.md
@@ -41,6 +41,43 @@ Each extension in the app can be disabled, meaning it will not be instantiated a
The ordering of extensions is sometimes very important, as it may for example affect in which order they show up in the UI. When an extension is toggled from disabled to enabled through configuration it resets the ordering of the extension, pushing it to the end of the list. It is generally recommended to leave extensions as disabled by default if their order is important, allowing for the order in which their are enabled in the configuration to determine their order in the app.
+### Conditions
+
+Extensions can also be conditionally enabled by providing an `if` predicate. This is available both on `createExtension(...)` directly and when creating extensions from blueprints.
+
+The predicate uses the same `FilterPredicate` syntax as elsewhere in Backstage, but in the frontend system it is evaluated against app-level data such as `featureFlags` and `permissions`. For example, the following page is only installed when the `experimental-features` flag is active:
+
+```tsx
+const examplePage = PageBlueprint.make({
+ params: {
+ path: '/example',
+ loader: () => import('./ExamplePage').then(m => ),
+ },
+ if: { featureFlags: { $contains: 'experimental-features' } },
+});
+```
+
+You can also combine conditions using logical operators such as `$all`, `$any`, and `$not`:
+
+```tsx
+const guardedCard = CardBlueprint.make({
+ params: {
+ title: 'Guarded Card',
+ loader: () => import('./GuardedCard').then(m => ),
+ },
+ if: {
+ $all: [
+ { featureFlags: { $contains: 'experimental-features' } },
+ { permissions: { $contains: 'catalog.entity.create' } },
+ ],
+ },
+});
+```
+
+Conditions are evaluated when the app tree is prepared, not continuously while the app is running. If the underlying feature flags or permissions change, the app needs to be prepared again in order for the extension tree to change, which in practice typically means reloading the app.
+
+If a plugin or module also provides an `if` predicate, it is combined with the extension-level predicate using logical `AND`. See the [plugin `if` option](./15-plugins.md#if-option) and [frontend modules](./25-extension-overrides.md#creating-a-frontend-module) sections for more details.
+
### Configuration & configuration schema
Each extension can define a configuration schema that describes the configuration that it accepts. This schema is used to validate the configuration provided by integrators, but also to fill in default configuration values. The configuration itself is provided by integrators in order to customize the extension. It is not possible to provide a default configuration of an extension, this must instead be done through defaults in the configuration schema. This allows for a simpler configuration logic where multiple configurations of the same extension completely replace each other rather than being merged.
diff --git a/docs/frontend-system/architecture/23-extension-blueprints.md b/docs/frontend-system/architecture/23-extension-blueprints.md
index f6efccccbb..97eb01022d 100644
--- a/docs/frontend-system/architecture/23-extension-blueprints.md
+++ b/docs/frontend-system/architecture/23-extension-blueprints.md
@@ -9,7 +9,7 @@ The `createExtension` function and related APIs is considered a low-level buildi
## Creating an extension from a blueprint
-Every extension blueprint provides a `make` method that can be used to create new extensions. It is a simple way to create a new extension where the base blueprint provides all the necessary functionality. All you need to do is to provide the necessary blueprint parameters, but you also have the ability to provide additional options, for example a `name` for the extension.
+Every extension blueprint provides a `make` method that can be used to create new extensions. It is a simple way to create a new extension where the base blueprint provides all the necessary functionality. All you need to do is to provide the necessary blueprint parameters, but you also have the ability to provide additional options, for example a `name`, `attachTo`, `disabled`, or `if` predicate for the extension.
The following is a simple example of how one might use the blueprint `make` method to create a new extension:
diff --git a/docs/frontend-system/architecture/25-extension-overrides.md b/docs/frontend-system/architecture/25-extension-overrides.md
index 5e684476c8..7afbd2bacb 100644
--- a/docs/frontend-system/architecture/25-extension-overrides.md
+++ b/docs/frontend-system/architecture/25-extension-overrides.md
@@ -11,6 +11,8 @@ An important customization point in the frontend system is the ability to overri
In general, most features should have a good level of customization built into them, so that users do not have to leverage extension overrides to achieve common goals. A well written feature often has [configuration](../../conf/) settings, or uses extension inputs for extensibility where applicable. An example of this is the search plugin, which allows you to provide result renderers as inputs rather than replacing the result page wholesale just to tweak how results are shown. Adopters should take advantage of those when possible in order to reduce the need and size of extension overrides.
+Extension overrides can also replace or remove existing `if` predicates. This applies both to direct extension overrides through `.override(...)` and to plugin-level overrides through `plugin.withOverrides(...)`. Frontend modules can use the same extension override mechanism to adjust or clear the condition for an overridden extension.
+
## Overriding an extension
Every extension created with `createExtension` comes with an `override` method, including those created from an [extension blueprint](./23-extension-blueprints.md). The `override` method **creates a new extension**, it does not mutate the existing extension. This new extension in created in such a way that if it is installed adjacent to the existing extension, it will take precedence and override the existing extension. While the `override` method does create new extension instances, it is not intended to be used as a way to create multiple new extensions from a base template, for that use-case you will want to use an [extension blueprint](./23-extension-blueprints.md) instead.
@@ -343,3 +345,5 @@ export default app.createRoot();
```
You must define a `pluginId` when creating a frontend module, and the plugin must also be installed for the module to be loaded.
+
+Frontend modules also support an `if` option. Just like for plugins, that predicate is applied to every extension that comes from the module, and is combined with any extension-level `if` predicate using logical `AND`. This is useful when you want to enable or disable an entire override package based on a feature flag or permission.
diff --git a/docs/frontend-system/building-apps/01-index.md b/docs/frontend-system/building-apps/01-index.md
index bd9a0fbae5..cfef547afc 100644
--- a/docs/frontend-system/building-apps/01-index.md
+++ b/docs/frontend-system/building-apps/01-index.md
@@ -57,6 +57,8 @@ Note that `createRoot` returns the root element that is rendered by React. The a
Visit the [built-in extensions](#customize-or-override-built-in-extensions) section to see what is installed by default in a Backstage application.
+For advanced bootstrap flows that need access to the app tree before the full app is finalized, see [preparing an app in phases](../architecture/10-app.md#preparing-an-app-in-phases).
+
## Configure your app
### Bind external routes
diff --git a/docs/frontend-system/building-apps/03-built-in-extensions.md b/docs/frontend-system/building-apps/03-built-in-extensions.md
index e1d0b0282d..05e60b54b7 100644
--- a/docs/frontend-system/building-apps/03-built-in-extensions.md
+++ b/docs/frontend-system/building-apps/03-built-in-extensions.md
@@ -99,7 +99,7 @@ This is the extension that creates the app root element, so it renders root leve
| ---------- | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| router | A React component that should manager the app routes context. | It must be one [router](https://reactrouter.com/en/main/routers/picking-a-router#web-projects) component or a custom component compatible with the 'react-router' library. | true | [BrowserRouter](https://reactrouter.com/en/main/router-components/browser-router) | [createRouterExtension](https://backstage.io/docs/reference/frontend-plugin-api.createrouterextension) |
| signInPage | A React component that should render the app sign-in page. | Should call the `onSignInSuccess` prop when the user has been successfully authorized, otherwise the user will not be correctly redirected to the application home page. | true | The default `AppRoot` extension does not use a default component for this input, it bypasses the user authentication check and always renders all routes when a login page is not installed. | [createSignInPageExtension](https://backstage.io/docs/reference/frontend-plugin-api.createsigninpageextension/) |
-| children | A React component that renders the app sidebar and main content in a particular layout. | - | false | The [`App/Layout`](#app-layout) extension output. | No creator available, configure or override the [`App/Layout`](#app-layout) extension. |
+| children | A React component that renders the app sidebar and main content in a particular layout. | - | true | The [`App/Layout`](#app-layout) extension output. | No creator available, configure or override the [`App/Layout`](#app-layout) extension. |
| elements | React elements to be rendered outside of the app layout, such as shared popups. | - | false | See [default elements](#default-app-root-elements-extensions). | [createAppRootElementExtension](https://backstage.io/docs/reference/frontend-plugin-api.createapprootelementextension/) |
| wrappers | React components that should wrap the root element. | - | true | - | [createAppRootWrapperExtension](https://backstage.io/docs/reference/frontend-plugin-api.createapprootwrapperextension/) |
diff --git a/lighthouserc.js b/lighthouserc.js
index c7c1275cdb..5273844c79 100644
--- a/lighthouserc.js
+++ b/lighthouserc.js
@@ -54,7 +54,7 @@ module.exports = {
preset: 'desktop',
},
startServerCommand: 'yarn start',
- startServerReadyPattern: 'webpack compiled successfully',
+ startServerReadyPattern: 'compiled.*successfully',
startServerReadyTimeout: 600000,
numberOfRuns: 1,
puppeteerScript: './.lighthouseci/scripts/guest-auth.js',
diff --git a/packages/app/src/examples/pagesPlugin.tsx b/packages/app/src/examples/pagesPlugin.tsx
index 35426b589b..531d0084fb 100644
--- a/packages/app/src/examples/pagesPlugin.tsx
+++ b/packages/app/src/examples/pagesPlugin.tsx
@@ -23,6 +23,9 @@ import {
PageBlueprint,
FrontendPluginInfo,
useAppNode,
+ createExtensionBlueprint,
+ createExtensionInput,
+ coreExtensionData,
} from '@backstage/frontend-plugin-api';
import { useEffect, useState } from 'react';
import { Route, Routes } from 'react-router-dom';
@@ -65,7 +68,8 @@ const IndexPage = PageBlueprint.make({
const page1Link = useRouteRef(page1RouteRef);
return (
+ The following pages demonstrate conditional extension enablement
+ via the if predicate using permissions. They will
+ only appear when the user has the required permissions.
+
+
+ Permission Card Example
+ {' '}
+ — a page that is always visible, but individual cards on it are
+ toggled by permissions
+
+
+
+
Feature Flag Enablement Examples
+
+ The following pages demonstrate conditional extension enablement
+ via the if predicate. They will only appear in the
+ router tree when their conditions are satisfied. Toggle the
+ relevant feature flags in Settings,
+ then refresh the app to see the pages appear.
+
+
+
+ Feature Flag Example —
+ requires the experimental-features flag
+
+
+ All Flags Example —
+ requires bothexperimental-features and{' '}
+ advanced-features ($all)
+
+
+ Any Flag Example — requires{' '}
+ eitherexperimental-features or{' '}
+ beta-access ($any)
+
+
+
);
@@ -150,6 +202,270 @@ const ExternalPage = PageBlueprint.make({
},
});
+// Example: Page enabled only when a single feature flag is active.
+//
+// The `if` predicate is evaluated once at app startup (before the router
+// tree is built), so this page simply won't exist in the app until the flag is
+// toggled and the page is refreshed.
+//
+// To test: enable the 'experimental-features' flag in Settings, then refresh.
+const FeatureFlagPage = PageBlueprint.make({
+ name: 'featureFlagExample',
+ params: {
+ path: '/feature-flag-example',
+ loader: async () => {
+ const Component = () => {
+ const indexLink = useRouteRef(indexRouteRef);
+ return (
+
+
Feature Flag Enabled Page
+
+ This page is only present in the app when the{' '}
+ experimental-features feature flag is active.
+
+ );
+ };
+ return ;
+ },
+ },
+ if: { featureFlags: { $contains: 'experimental-features' } },
+});
+
+// Example: Page enabled only when ALL of several feature flags are active.
+//
+// The $all operator requires every nested predicate to be satisfied. This page
+// won't appear unless both 'experimental-features' and 'advanced-features' are
+// enabled at the same time.
+//
+// To test: enable BOTH flags in Settings, then refresh.
+const AllFlagsPage = PageBlueprint.make({
+ name: 'allFlagsExample',
+ params: {
+ path: '/all-flags-example',
+ loader: async () => {
+ const Component = () => {
+ const indexLink = useRouteRef(indexRouteRef);
+ return (
+
+
All Flags Required Page
+
+ This page requires both{' '}
+ experimental-features and{' '}
+ advanced-features to be active simultaneously.
+
+
+ It uses a $all predicate to AND the two conditions
+ together.
+
+ {indexLink && Go back}
+
+ );
+ };
+ return ;
+ },
+ },
+ if: {
+ $all: [
+ { featureFlags: { $contains: 'experimental-features' } },
+ { featureFlags: { $contains: 'advanced-features' } },
+ ],
+ },
+});
+
+// Example: Page enabled when ANY one of several feature flags is active.
+//
+// The $any operator is satisfied as soon as at least one nested predicate
+// matches. Enabling either 'experimental-features' or 'beta-access' will make
+// this page appear.
+//
+// To test: enable at least one of the two flags in Settings, then refresh.
+const AnyFlagPage = PageBlueprint.make({
+ name: 'anyFlagExample',
+ params: {
+ path: '/any-flag-example',
+ loader: async () => {
+ const Component = () => {
+ const indexLink = useRouteRef(indexRouteRef);
+ return (
+
+
Any Flag Sufficient Page
+
+ This page appears when either{' '}
+ experimental-features or beta-access is
+ active.
+
+
+ It uses a $any predicate to OR the two conditions
+ together.
+
+ {indexLink && Go back}
+
+ );
+ };
+ return ;
+ },
+ },
+ if: {
+ $any: [
+ { featureFlags: { $contains: 'experimental-features' } },
+ { featureFlags: { $contains: 'beta-access' } },
+ ],
+ },
+});
+
+// Blueprint for cards that attach to the PermissionCardPage below.
+//
+// Each card receives a title and description and renders a simple bordered card.
+// Individual card instances can be selectively enabled via the `if`
+// predicate, so only the cards the user is allowed to see will be instantiated.
+const PermissionExampleCardBlueprint = createExtensionBlueprint({
+ kind: 'permission-example-card',
+ attachTo: { id: 'page:pages/permissionCardExample', input: 'cards' },
+ output: [coreExtensionData.reactElement],
+ *factory(params: { title: string; description: string }) {
+ yield coreExtensionData.reactElement(
+
+
{params.title}
+
{params.description}
+
,
+ );
+ },
+});
+
+// Example: Page with cards that are individually toggled by permissions.
+//
+// The page itself is always present. What changes is which cards are
+// instantiated inside it — each card declares its own `enabled` predicate
+// and is only wired into the page if that predicate is satisfied at startup.
+//
+// To test: make sure you do NOT have the catalog.entity.create permission and
+// refresh the page — the "Restricted Card" below should disappear.
+const PermissionCardPage = PageBlueprint.makeWithOverrides({
+ name: 'permissionCardExample',
+ inputs: {
+ cards: createExtensionInput([coreExtensionData.reactElement]),
+ },
+ factory(originalFactory, { inputs }) {
+ return originalFactory({
+ path: '/permission-card-example',
+ loader: async () => {
+ const Component = () => {
+ const indexLink = useRouteRef(indexRouteRef);
+ const cards = inputs.cards.map(card =>
+ card.get(coreExtensionData.reactElement),
+ );
+ return (
+
+
Permission-Gated Card Example
+
+ This page is always visible. The cards below are individually
+ gated — each one declares its own{' '}
+ {'if: { permissions: { $contains: "..." } }'}{' '}
+ predicate. Cards whose predicate fails are never instantiated,
+ so they simply won't appear here.
+
+
+ {cards.length > 0 ? (
+ cards
+ ) : (
+
+ No cards are visible — you may lack the required
+ permissions.
+
+ )}
+
+ {indexLink && Go back}
+
+ );
+ };
+ return ;
+ },
+ });
+ },
+});
+
+// Always-visible card — no predicate, every user sees this.
+const PublicCard = PermissionExampleCardBlueprint.make({
+ name: 'public',
+ params: {
+ title: 'Public Card',
+ description: 'This card is visible to everyone regardless of permissions.',
+ },
+});
+
+// Permission-gated card — only instantiated when the user has
+// the catalog.entity.create permission.
+const RestrictedCard = PermissionExampleCardBlueprint.make({
+ name: 'restricted',
+ params: {
+ title: 'Restricted Card',
+ description:
+ 'This card is only visible to users who have the catalog.entity.create permission.',
+ },
+ if: { permissions: { $contains: 'catalog.entity.create' } },
+});
+
+// Feature flag-gated card — only instantiated when the user has
+// the experimental-card FF enabled.
+const FeatureFlagCard = PermissionExampleCardBlueprint.make({
+ name: 'feature-flag',
+ params: {
+ title: 'Feature Flagged Card',
+ description: 'Visible only with the experimental-card FF active.',
+ },
+ if: { featureFlags: { $contains: 'experimental-card' } },
+});
+
+// Example: Page enabled only when the user is allowed to create catalog entities.
+//
+// The `if` predicate is evaluated once at app startup (after sign-in),
+// so this page simply won't exist in the router tree if the user lacks the
+// required permission.
+const PermissionGatedPage = PageBlueprint.make({
+ name: 'permissionGatedExample',
+ params: {
+ path: '/permission-gated-example',
+ loader: async () => {
+ const Component = () => {
+ const indexLink = useRouteRef(indexRouteRef);
+ return (
+
+
Permission Gated Page
+
+ This page is only present when the user has the{' '}
+ catalog.entity.create permission.
+
+ {indexLink && Go back}
+
+ );
+ };
+ return ;
+ },
+ },
+ if: { permissions: { $contains: 'catalog.entity.create' } },
+});
+
export const pagesPlugin = createFrontendPlugin({
pluginId: 'pages',
// routes: {
@@ -170,5 +486,23 @@ export const pagesPlugin = createFrontendPlugin({
externalRoutes: {
pageX: externalPageXRouteRef,
},
- extensions: [IndexPage, Page1, ExternalPage],
+ featureFlags: [
+ { name: 'experimental-features' },
+ { name: 'advanced-features' },
+ { name: 'beta-access' },
+ { name: 'experimental-card' },
+ ],
+ extensions: [
+ IndexPage,
+ Page1,
+ ExternalPage,
+ FeatureFlagPage,
+ AllFlagsPage,
+ AnyFlagPage,
+ PermissionCardPage,
+ PublicCard,
+ RestrictedCard,
+ PermissionGatedPage,
+ FeatureFlagCard,
+ ],
});
diff --git a/packages/core-compat-api/package.json b/packages/core-compat-api/package.json
index 152b6d1141..6ee93be875 100644
--- a/packages/core-compat-api/package.json
+++ b/packages/core-compat-api/package.json
@@ -33,6 +33,7 @@
"dependencies": {
"@backstage/core-plugin-api": "workspace:^",
"@backstage/errors": "workspace:^",
+ "@backstage/filter-predicates": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
"@backstage/plugin-app-react": "workspace:^",
"@backstage/plugin-catalog-react": "workspace:^",
diff --git a/packages/core-compat-api/src/convertLegacyPlugin.test.tsx b/packages/core-compat-api/src/convertLegacyPlugin.test.tsx
index f8436e7434..9a18d86b1f 100644
--- a/packages/core-compat-api/src/convertLegacyPlugin.test.tsx
+++ b/packages/core-compat-api/src/convertLegacyPlugin.test.tsx
@@ -42,6 +42,7 @@ describe('convertLegacyPlugin', () => {
"getExtension": [Function],
"icon": undefined,
"id": "test",
+ "if": undefined,
"info": [Function],
"infoOptions": undefined,
"pluginId": "test",
diff --git a/packages/core-components/src/components/Progress/Progress.test.tsx b/packages/core-components/src/components/Progress/Progress.test.tsx
index 918c9ba518..f72454754e 100644
--- a/packages/core-components/src/components/Progress/Progress.test.tsx
+++ b/packages/core-components/src/components/Progress/Progress.test.tsx
@@ -15,6 +15,7 @@
*/
import { renderInTestApp } from '@backstage/test-utils';
+import { screen } from '@testing-library/react';
import { Progress } from './Progress';
@@ -23,4 +24,11 @@ describe('', () => {
const { queryByTestId } = await renderInTestApp();
expect(queryByTestId('progress')).toBeInTheDocument();
});
+
+ it('provides an accessible name for the progress bar', async () => {
+ await renderInTestApp();
+ expect(
+ await screen.findByRole('progressbar', { name: 'Loading' }),
+ ).toBeInTheDocument();
+ });
});
diff --git a/packages/core-components/src/components/Progress/Progress.tsx b/packages/core-components/src/components/Progress/Progress.tsx
index b7176a5825..f1dea0ed5c 100644
--- a/packages/core-components/src/components/Progress/Progress.tsx
+++ b/packages/core-components/src/components/Progress/Progress.tsx
@@ -22,6 +22,7 @@ import { useTheme } from '@material-ui/core/styles';
import { PropsWithChildren, useEffect, useState } from 'react';
export function Progress(props: PropsWithChildren) {
+ const { 'aria-label': ariaLabel, ...progressProps } = props;
const theme = useTheme();
const [isVisible, setIsVisible] = useState(false);
@@ -34,7 +35,11 @@ export function Progress(props: PropsWithChildren) {
}, [theme.transitions.duration.short]);
return isVisible ? (
-
+
) : (
);
diff --git a/packages/frontend-app-api/package.json b/packages/frontend-app-api/package.json
index 1951c18828..f5dea017a9 100644
--- a/packages/frontend-app-api/package.json
+++ b/packages/frontend-app-api/package.json
@@ -36,6 +36,7 @@
"@backstage/core-app-api": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/errors": "workspace:^",
+ "@backstage/filter-predicates": "workspace:^",
"@backstage/frontend-defaults": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
"@backstage/types": "workspace:^",
@@ -47,6 +48,7 @@
"@backstage/cli": "workspace:^",
"@backstage/frontend-test-utils": "workspace:^",
"@backstage/plugin-app": "workspace:^",
+ "@backstage/plugin-permission-common": "workspace:^",
"@backstage/test-utils": "workspace:^",
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^16.0.0",
diff --git a/packages/frontend-app-api/report.api.md b/packages/frontend-app-api/report.api.md
index 0a0b23203b..7795794051 100644
--- a/packages/frontend-app-api/report.api.md
+++ b/packages/frontend-app-api/report.api.md
@@ -10,6 +10,7 @@ import { ConfigApi } from '@backstage/frontend-plugin-api';
import { ExtensionDataContainer } from '@backstage/frontend-plugin-api';
import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ExtensionDataValue } from '@backstage/frontend-plugin-api';
+import { ExtensionFactoryMiddleware as ExtensionFactoryMiddleware_2 } from '@backstage/frontend-plugin-api';
import { ExternalRouteRef } from '@backstage/frontend-plugin-api';
import { FrontendFeature } from '@backstage/frontend-plugin-api';
import { FrontendPlugin } from '@backstage/frontend-plugin-api';
@@ -127,6 +128,26 @@ export type AppErrorTypes = {
existingPluginId: string;
};
};
+ EXTENSION_BOOTSTRAP_PREDICATE_IGNORED: {
+ context: {
+ node: AppNode;
+ };
+ };
+ EXTENSION_BOOTSTRAP_API_UNAVAILABLE: {
+ context: {
+ node: AppNode;
+ apiRefId: string;
+ };
+ };
+ EXTENSION_BOOTSTRAP_API_OVERRIDE_IGNORED: {
+ context: {
+ node: AppNode;
+ apiRefId: string;
+ bootstrapNode: AppNode;
+ pluginId: string;
+ bootstrapPluginId: string;
+ };
+ };
ROUTE_DUPLICATE: {
context: {
routeId: string;
@@ -144,6 +165,12 @@ export type AppErrorTypes = {
};
};
+// @public
+export type BootstrapSpecializedApp = {
+ element: JSX.Element;
+ tree: AppTree;
+};
+
// @public
export type CreateAppRouteBinder = <
TExternalRoutes extends {
@@ -157,14 +184,12 @@ export type CreateAppRouteBinder = <
>,
) => void;
-// @public
-export function createSpecializedApp(options?: CreateSpecializedAppOptions): {
- apis: ApiHolder;
- tree: AppTree;
- errors?: AppError[];
-};
+// @public @deprecated
+export function createSpecializedApp(
+ options?: CreateSpecializedAppOptions,
+): FinalizedSpecializedApp;
-// @public
+// @public @deprecated
export type CreateSpecializedAppOptions = {
features?: FrontendFeature[];
config?: ConfigApi;
@@ -172,8 +197,8 @@ export type CreateSpecializedAppOptions = {
advanced?: {
apis?: ApiHolder;
extensionFactoryMiddleware?:
- | ExtensionFactoryMiddleware
- | ExtensionFactoryMiddleware[];
+ | ExtensionFactoryMiddleware_2
+ | ExtensionFactoryMiddleware_2[];
pluginInfoResolver?: FrontendPluginInfoResolver;
};
};
@@ -190,6 +215,14 @@ export type ExtensionFactoryMiddleware = (
},
) => Iterable>;
+// @public
+export type FinalizedSpecializedApp = {
+ element: JSX.Element;
+ sessionState: SpecializedAppSessionState;
+ tree: AppTree;
+ errors?: AppError[];
+};
+
// @public
export type FrontendPluginInfoResolver = (ctx: {
packageJson(): Promise;
@@ -203,4 +236,35 @@ export type FrontendPluginInfoResolver = (ctx: {
}) => Promise<{
info: FrontendPluginInfo;
}>;
+
+// @public
+export type PreparedSpecializedApp = {
+ getBootstrapApp(): BootstrapSpecializedApp;
+ onFinalized(callback: (app: FinalizedSpecializedApp) => void): () => void;
+ finalize(): FinalizedSpecializedApp;
+};
+
+// @public
+export function prepareSpecializedApp(
+ options?: PrepareSpecializedAppOptions,
+): PreparedSpecializedApp;
+
+// @public
+export type PrepareSpecializedAppOptions = {
+ features?: FrontendFeature[];
+ config?: ConfigApi;
+ bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
+ advanced?: {
+ sessionState?: SpecializedAppSessionState;
+ extensionFactoryMiddleware?:
+ | ExtensionFactoryMiddleware_2
+ | ExtensionFactoryMiddleware_2[];
+ pluginInfoResolver?: FrontendPluginInfoResolver;
+ };
+};
+
+// @public
+export type SpecializedAppSessionState = {
+ $$type: '@backstage/SpecializedAppSessionState';
+};
```
diff --git a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts
index e52bbfa320..f5fc9f2e3a 100644
--- a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts
+++ b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts
@@ -1782,4 +1782,96 @@ describe('instantiateAppNodeTree', () => {
});
});
});
+
+ describe('if predicate', () => {
+ function makeNodeWithEnabled(
+ enabled: AppNodeSpec['if'],
+ disabled = false,
+ ): AppNode {
+ const ext = resolveExtensionDefinition(
+ createExtension({
+ attachTo: { id: 'ignored', input: 'ignored' },
+ output: [testDataRef],
+ factory: () => [testDataRef('value')],
+ }),
+ { namespace: 'test-ext' },
+ );
+ return {
+ spec: {
+ id: ext.id,
+ attachTo: ext.attachTo,
+ disabled,
+ if: enabled,
+ extension: ext as Extension,
+ plugin: createFrontendPlugin({ pluginId: 'app' }),
+ },
+ edges: { attachments: new Map() },
+ };
+ }
+
+ it('should skip a node when the predicate is not satisfied', () => {
+ const node = makeNodeWithEnabled({
+ featureFlags: { $contains: 'the-flag' },
+ });
+ const tree = resolveAppTree('test-ext', [node.spec], collector);
+ instantiateAppNodeTree(tree.root, testApis, collector, undefined, {
+ featureFlags: [],
+ });
+ expect(tree.root.instance).toBeUndefined();
+ });
+
+ it('should instantiate a node when the predicate is satisfied', () => {
+ const node = makeNodeWithEnabled({
+ featureFlags: { $contains: 'the-flag' },
+ });
+ const tree = resolveAppTree('test-ext', [node.spec], collector);
+ instantiateAppNodeTree(tree.root, testApis, collector, undefined, {
+ featureFlags: ['the-flag'],
+ });
+ expect(tree.root.instance).toBeDefined();
+ expect(tree.root.instance?.getData(testDataRef)).toBe('value');
+ });
+
+ it('should support $all operator across multiple flags', () => {
+ const node = makeNodeWithEnabled({
+ $all: [
+ { featureFlags: { $contains: 'flag-a' } },
+ { featureFlags: { $contains: 'flag-b' } },
+ ],
+ });
+ const tree = resolveAppTree('test-ext', [node.spec], collector);
+
+ // Only one flag active — should not instantiate
+ instantiateAppNodeTree(tree.root, testApis, collector, undefined, {
+ featureFlags: ['flag-a'],
+ });
+ expect(tree.root.instance).toBeUndefined();
+
+ // Both flags active — should instantiate
+ const tree2 = resolveAppTree('test-ext', [node.spec], collector);
+ instantiateAppNodeTree(tree2.root, testApis, collector, undefined, {
+ featureFlags: ['flag-a', 'flag-b'],
+ });
+ expect(tree2.root.instance).toBeDefined();
+ });
+
+ it('should instantiate nodes without an enabled field regardless of predicateContext', () => {
+ const node = makeNodeWithEnabled(undefined);
+ const tree = resolveAppTree('test-ext', [node.spec], collector);
+ instantiateAppNodeTree(tree.root, testApis, collector, undefined, {
+ featureFlags: [],
+ });
+ expect(tree.root.instance).toBeDefined();
+ });
+
+ it('should instantiate nodes with enabled predicate when predicateContext is not provided', () => {
+ const node = makeNodeWithEnabled({
+ featureFlags: { $contains: 'the-flag' },
+ });
+ const tree = resolveAppTree('test-ext', [node.spec], collector);
+ // No predicateContext passed — predicate evaluation is skipped
+ instantiateAppNodeTree(tree.root, testApis, collector);
+ expect(tree.root.instance).toBeDefined();
+ });
+ });
});
diff --git a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts
index 3de09acf41..8cef89f497 100644
--- a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts
+++ b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts
@@ -29,6 +29,7 @@ import { AppNode, AppNodeInstance } from '@backstage/frontend-plugin-api';
import { toInternalExtension } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
import { createExtensionDataContainer } from '@internal/frontend';
import { ErrorCollector } from '../wiring/createErrorCollector';
+import { evaluateFilterPredicate } from '@backstage/filter-predicates';
const INSTANTIATION_FAILED = new Error('Instantiation failed');
@@ -64,6 +65,19 @@ type Mutable = {
-readonly [P in keyof T]: T[P];
};
+type InstantiateAppNodeSubtreeOptions = {
+ rootNode: AppNode;
+ apis: ApiHolder;
+ collector: ErrorCollector;
+ extensionFactoryMiddleware?: ExtensionFactoryMiddleware;
+ stopAtAttachment?(ctx: { node: AppNode; input: string }): boolean;
+ skipChild?(ctx: { node: AppNode; input: string; child: AppNode }): boolean;
+ onMissingApi?(ctx: { node: AppNode; apiRefId: string }): void;
+ predicateContext?: Record;
+ reuseExistingInstances?: boolean;
+ writeNodeInstances?: boolean;
+};
+
function resolveV1InputDataMap(
dataMap: {
[name in string]: ExtensionDataRef;
@@ -337,12 +351,28 @@ export function createAppNodeInstance(options: {
apis: ApiHolder;
attachments: ReadonlyMap;
collector: ErrorCollector;
+ onMissingApi?(ctx: { node: AppNode; apiRefId: string }): void;
}): AppNodeInstance | undefined {
const { node, apis, attachments } = options;
const collector = options.collector.child({ node });
const { id, extension, config } = node.spec;
const extensionData = new Map();
const extensionDataRefs = new Set>();
+ const scopedApis: ApiHolder =
+ options.onMissingApi === undefined
+ ? apis
+ : {
+ get(apiRef) {
+ const api = apis.get(apiRef);
+ if (api === undefined) {
+ options.onMissingApi?.({
+ node,
+ apiRefId: apiRef.id,
+ });
+ }
+ return api;
+ },
+ };
let parsedConfig: { [x: string]: any };
try {
@@ -367,7 +397,7 @@ export function createAppNodeInstance(options: {
if (internalExtension.version === 'v1') {
const namedOutputs = internalExtension.factory({
node,
- apis,
+ apis: scopedApis,
config: parsedConfig,
inputs: resolveV1Inputs(internalExtension.inputs, attachments),
});
@@ -388,7 +418,7 @@ export function createAppNodeInstance(options: {
} else if (internalExtension.version === 'v2') {
const context = {
node,
- apis,
+ apis: scopedApis,
config: parsedConfig,
inputs: resolveV2Inputs(
internalExtension.inputs,
@@ -500,6 +530,87 @@ export function createAppNodeInstance(options: {
};
}
+/**
+ * Starting at the provided node, instantiate a subtree without necessarily
+ * mutating the original app tree.
+ *
+ * @internal
+ */
+export function instantiateAppNodeSubtree(
+ options: InstantiateAppNodeSubtreeOptions,
+): AppNode | undefined {
+ const instantiatedNodes = new WeakMap();
+
+ function createInstance(node: AppNode): AppNode | undefined {
+ if (instantiatedNodes.has(node)) {
+ return instantiatedNodes.get(node) ?? undefined;
+ }
+ if (options.reuseExistingInstances !== false && node.instance) {
+ instantiatedNodes.set(node, node);
+ return node;
+ }
+ if (node.spec.disabled) {
+ instantiatedNodes.set(node, null);
+ return undefined;
+ }
+ if (
+ options.predicateContext !== undefined &&
+ node.spec.if !== undefined &&
+ !evaluateFilterPredicate(node.spec.if, options.predicateContext)
+ ) {
+ instantiatedNodes.set(node, null);
+ return undefined;
+ }
+
+ const instantiatedAttachments = new Map();
+
+ for (const [input, children] of node.edges.attachments) {
+ if (options.stopAtAttachment?.({ node, input })) {
+ continue;
+ }
+ const instantiatedChildren = children.flatMap(child => {
+ if (options.skipChild?.({ node, input, child })) {
+ return [];
+ }
+ const childNode = createInstance(child);
+ return childNode ? [childNode] : [];
+ });
+ if (instantiatedChildren.length > 0) {
+ instantiatedAttachments.set(input, instantiatedChildren);
+ }
+ }
+
+ const instance = createAppNodeInstance({
+ extensionFactoryMiddleware: options.extensionFactoryMiddleware,
+ node,
+ apis: options.apis,
+ attachments: instantiatedAttachments,
+ collector: options.collector,
+ onMissingApi: options.onMissingApi,
+ });
+ if (!instance) {
+ instantiatedNodes.set(node, null);
+ return undefined;
+ }
+
+ if (options.writeNodeInstances === false) {
+ const detachedNode: AppNode = {
+ spec: node.spec,
+ edges: node.edges,
+ instance,
+ };
+ instantiatedNodes.set(node, detachedNode);
+ return detachedNode;
+ }
+
+ (node as Mutable).instance = instance;
+ instantiatedNodes.set(node, node);
+ return node;
+ }
+
+ return createInstance(options.rootNode);
+}
+
/**
* Starting at the provided node, instantiate all reachable nodes in the tree that have not been disabled.
* @internal
@@ -509,40 +620,45 @@ export function instantiateAppNodeTree(
apis: ApiHolder,
collector: ErrorCollector,
extensionFactoryMiddleware?: ExtensionFactoryMiddleware,
-): boolean {
- function createInstance(node: AppNode): AppNodeInstance | undefined {
- if (node.instance) {
- return node.instance;
- }
- if (node.spec.disabled) {
- return undefined;
- }
-
- const instantiatedAttachments = new Map();
-
- for (const [input, children] of node.edges.attachments) {
- const instantiatedChildren = children.flatMap(child => {
- const childInstance = createInstance(child);
- if (!childInstance) {
- return [];
- }
- return [child];
- });
- if (instantiatedChildren.length > 0) {
- instantiatedAttachments.set(input, instantiatedChildren);
+ optionsOrPredicateContext?:
+ | {
+ stopAtAttachment?(ctx: { node: AppNode; input: string }): boolean;
+ skipChild?(ctx: {
+ node: AppNode;
+ input: string;
+ child: AppNode;
+ }): boolean;
+ onMissingApi?(ctx: { node: AppNode; apiRefId: string }): void;
+ predicateContext?: Record;
}
- }
+ | Record,
+): boolean {
+ const options: {
+ stopAtAttachment?(ctx: { node: AppNode; input: string }): boolean;
+ skipChild?(ctx: { node: AppNode; input: string; child: AppNode }): boolean;
+ onMissingApi?(ctx: { node: AppNode; apiRefId: string }): void;
+ predicateContext?: Record;
+ } =
+ optionsOrPredicateContext &&
+ ('stopAtAttachment' in optionsOrPredicateContext ||
+ 'skipChild' in optionsOrPredicateContext ||
+ 'onMissingApi' in optionsOrPredicateContext ||
+ 'predicateContext' in optionsOrPredicateContext)
+ ? optionsOrPredicateContext
+ : {
+ predicateContext: optionsOrPredicateContext,
+ };
- (node as Mutable).instance = createAppNodeInstance({
- extensionFactoryMiddleware,
- node,
+ return (
+ instantiateAppNodeSubtree({
+ rootNode,
apis,
- attachments: instantiatedAttachments,
collector,
- });
-
- return node.instance;
- }
-
- return createInstance(rootNode) !== undefined;
+ extensionFactoryMiddleware,
+ stopAtAttachment: options.stopAtAttachment,
+ skipChild: options.skipChild,
+ onMissingApi: options.onMissingApi,
+ predicateContext: options.predicateContext,
+ }) !== undefined
+ );
}
diff --git a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts
index 3ccdf5258f..ec56394b39 100644
--- a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts
+++ b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts
@@ -15,6 +15,8 @@
*/
import {
+ createExtension,
+ createExtensionDataRef,
createFrontendModule,
createFrontendPlugin,
Extension,
@@ -506,4 +508,204 @@ describe('resolveAppNodeSpecs', () => {
},
]);
});
+
+ it('should carry if predicate through to AppNodeSpec', () => {
+ const dataRef = createExtensionDataRef().with({ id: 'test.data' });
+ const ifPredicate = { featureFlags: { $contains: 'my-flag' } };
+ const plugin = createFrontendPlugin({
+ pluginId: 'test-plugin',
+ extensions: [
+ createExtension({
+ attachTo: { id: 'app', input: 'root' },
+ if: ifPredicate,
+ output: [dataRef],
+ factory: () => [dataRef('value')],
+ }),
+ ],
+ });
+ const specs = resolveAppNodeSpecs({
+ features: [plugin],
+ builtinExtensions: [],
+ parameters: [],
+ collector,
+ });
+ expect(specs).toHaveLength(1);
+ expect(specs[0].if).toEqual(ifPredicate);
+ });
+
+ it('should apply plugin if predicates to all plugin extensions', () => {
+ const dataRef = createExtensionDataRef().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 allow plugin overrides to replace or remove plugin if predicates', () => {
+ const dataRef = createExtensionDataRef().with({ id: 'test.data' });
+ const pluginIf = { featureFlags: { $contains: 'plugin-flag' } };
+ const overrideIf = { permissions: { $contains: 'override.permission' } };
+ const plugin = createFrontendPlugin({
+ pluginId: 'test-plugin',
+ if: pluginIf,
+ extensions: [
+ createExtension({
+ name: 'one',
+ attachTo: { id: 'app', input: 'root' },
+ output: [dataRef],
+ factory: () => [dataRef('one')],
+ }),
+ ],
+ });
+
+ const overriddenSpecs = resolveAppNodeSpecs({
+ features: [plugin.withOverrides({ if: overrideIf })],
+ builtinExtensions: [],
+ parameters: [],
+ collector,
+ });
+ const clearedSpecs = resolveAppNodeSpecs({
+ features: [plugin.withOverrides({ if: undefined })],
+ builtinExtensions: [],
+ parameters: [],
+ collector,
+ });
+
+ expect(overriddenSpecs).toHaveLength(1);
+ expect(overriddenSpecs[0].if).toEqual(overrideIf);
+ expect(clearedSpecs).toHaveLength(1);
+ expect(clearedSpecs[0].if).toBeUndefined();
+ });
+
+ it('should merge plugin and module if predicates with extension predicates', () => {
+ const dataRef = createExtensionDataRef().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] });
+ });
+
+ it('should allow module extension overrides to replace or remove extension if predicates', () => {
+ const dataRef = createExtensionDataRef().with({ id: 'test.data' });
+ const extensionIf = { featureFlags: { $contains: 'extension-flag' } };
+ const overrideIf = { permissions: { $contains: 'override.permission' } };
+ const plugin = createFrontendPlugin({
+ pluginId: 'test-plugin',
+ extensions: [
+ createExtension({
+ name: 'extension',
+ attachTo: { id: 'app', input: 'root' },
+ if: extensionIf,
+ output: [dataRef],
+ factory: () => [dataRef('base')],
+ }),
+ ],
+ });
+
+ const overriddenSpecs = resolveAppNodeSpecs({
+ features: [
+ plugin,
+ createFrontendModule({
+ pluginId: 'test-plugin',
+ extensions: [
+ plugin.getExtension('test-plugin/extension').override({
+ if: overrideIf,
+ }),
+ ],
+ }),
+ ],
+ builtinExtensions: [],
+ parameters: [],
+ collector,
+ });
+ const clearedSpecs = resolveAppNodeSpecs({
+ features: [
+ plugin,
+ createFrontendModule({
+ pluginId: 'test-plugin',
+ extensions: [
+ plugin.getExtension('test-plugin/extension').override({
+ if: undefined,
+ }),
+ ],
+ }),
+ ],
+ builtinExtensions: [],
+ parameters: [],
+ collector,
+ });
+
+ expect(overriddenSpecs).toHaveLength(1);
+ expect(overriddenSpecs[0].if).toEqual(overrideIf);
+ expect(clearedSpecs).toHaveLength(1);
+ expect(clearedSpecs[0].if).toBeUndefined();
+ });
});
diff --git a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts
index a095acee0e..0e56fa4a98 100644
--- a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts
+++ b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts
@@ -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,29 @@ 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: {
+ internalExtension: ReturnType;
+}) {
+ if (options.internalExtension.version === 'v2') {
+ return options.internalExtension.if;
+ }
+ return undefined;
+}
+
/** @internal */
export function resolveAppNodeSpecs(options: {
features?: FrontendFeature[];
@@ -79,26 +103,50 @@ export function resolveAppNodeSpecs(options: {
};
const pluginExtensions = plugins.flatMap(plugin => {
- return OpaqueFrontendPlugin.toInternal(plugin)
- .extensions.map(extension => ({
- ...extension,
- plugin,
- }))
+ const internalPlugin = OpaqueFrontendPlugin.toInternal(plugin);
+ return internalPlugin.extensions
+ .map(extension => {
+ const internalExtension = toInternalExtension(extension);
+ return {
+ ...internalExtension,
+ plugin,
+ if: combinePredicates(
+ internalPlugin.if,
+ internalExtension.version === 'v2'
+ ? internalExtension.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 => {
+ const internalExtension = toInternalExtension(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 [
+ {
+ ...internalExtension,
+ plugin,
+ if: combinePredicates(
+ internalModule.if,
+ internalExtension.version === 'v2'
+ ? internalExtension.if
+ : undefined,
+ ),
+ },
+ ];
})
- .filter(filterForbidden),
- );
+ .filter(filterForbidden);
+ });
const appPlugin =
plugins.find(plugin => plugin.pluginId === 'app') ??
@@ -116,6 +164,7 @@ export function resolveAppNodeSpecs(options: {
source: plugin,
attachTo: internalExtension.attachTo,
disabled: internalExtension.disabled,
+ if: getExtensionPredicate({ internalExtension }),
config: undefined as unknown,
},
};
@@ -129,6 +178,7 @@ export function resolveAppNodeSpecs(options: {
plugin: appPlugin,
attachTo: internalExtension.attachTo,
disabled: internalExtension.disabled,
+ if: getExtensionPredicate({ internalExtension }),
config: undefined as unknown,
},
};
@@ -148,6 +198,9 @@ export function resolveAppNodeSpecs(options: {
configuredExtensions[index].extension = internalExtension;
configuredExtensions[index].params.attachTo = internalExtension.attachTo;
configuredExtensions[index].params.disabled = internalExtension.disabled;
+ configuredExtensions[index].params.if = getExtensionPredicate({
+ internalExtension,
+ });
} else {
// Add the extension as a new one when not overriding an existing one
configuredExtensions.push({
@@ -157,6 +210,7 @@ export function resolveAppNodeSpecs(options: {
source: extension.plugin,
attachTo: internalExtension.attachTo,
disabled: internalExtension.disabled,
+ if: getExtensionPredicate({ internalExtension }),
config: undefined,
},
});
@@ -235,6 +289,7 @@ export function resolveAppNodeSpecs(options: {
attachTo: param.params.attachTo,
extension: param.extension,
disabled: param.params.disabled,
+ if: param.params.if,
plugin: param.params.plugin,
source: param.params.source,
config: param.params.config,
diff --git a/packages/frontend-app-api/src/wiring/FrontendApiRegistry.test.ts b/packages/frontend-app-api/src/wiring/FrontendApiRegistry.test.ts
new file mode 100644
index 0000000000..65e6010182
--- /dev/null
+++ b/packages/frontend-app-api/src/wiring/FrontendApiRegistry.test.ts
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2026 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+ type AnyApiFactory,
+ createApiRef,
+} from '@backstage/frontend-plugin-api';
+import {
+ FrontendApiRegistry,
+ FrontendApiResolver,
+} from './FrontendApiRegistry';
+
+describe('FrontendApiResolver', () => {
+ it('should cache falsy API values', () => {
+ const falseApiRef = createApiRef({ id: 'test.false' });
+ const falseFactoryFn = jest.fn(() => false);
+ const registry = new FrontendApiRegistry();
+
+ registry.register({
+ api: falseApiRef,
+ deps: {},
+ factory: falseFactoryFn,
+ } as AnyApiFactory);
+
+ const resolver = new FrontendApiResolver({ primaryRegistry: registry });
+
+ expect(resolver.get(falseApiRef)).toBe(false);
+ expect(resolver.get(falseApiRef)).toBe(false);
+ expect(falseFactoryFn).toHaveBeenCalledTimes(1);
+ });
+
+ it('should resolve falsy dependencies', () => {
+ const falseApiRef = createApiRef({ id: 'test.false' });
+ const dependentApiRef = createApiRef({ id: 'test.dependent' });
+ const falseFactoryFn = jest.fn(() => false);
+ const dependentFactoryFn = jest.fn((deps: { falseDependency: boolean }) =>
+ deps.falseDependency === false ? 'resolved' : 'unexpected',
+ );
+ const registry = new FrontendApiRegistry();
+
+ registry.register({
+ api: falseApiRef,
+ deps: {},
+ factory: falseFactoryFn,
+ } as AnyApiFactory);
+ registry.register({
+ api: dependentApiRef,
+ deps: { falseDependency: falseApiRef },
+ factory: dependentFactoryFn,
+ } as AnyApiFactory);
+
+ const resolver = new FrontendApiResolver({ primaryRegistry: registry });
+
+ expect(resolver.get(dependentApiRef)).toBe('resolved');
+ expect(dependentFactoryFn).toHaveBeenCalledWith({
+ falseDependency: false,
+ });
+ });
+});
diff --git a/packages/frontend-app-api/src/wiring/FrontendApiRegistry.ts b/packages/frontend-app-api/src/wiring/FrontendApiRegistry.ts
new file mode 100644
index 0000000000..00d565170a
--- /dev/null
+++ b/packages/frontend-app-api/src/wiring/FrontendApiRegistry.ts
@@ -0,0 +1,139 @@
+/*
+ * Copyright 2026 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+ AnyApiFactory,
+ AnyApiRef,
+ ApiFactory,
+ ApiHolder,
+ ApiRef,
+} from '@backstage/frontend-plugin-api';
+
+export class FrontendApiRegistry {
+ private readonly factories = new Map();
+
+ register(factory: AnyApiFactory) {
+ if (this.factories.has(factory.api.id)) {
+ return false;
+ }
+
+ this.factories.set(factory.api.id, factory);
+ return true;
+ }
+
+ registerAll(factories: AnyApiFactory[]) {
+ for (const factory of factories) {
+ this.register(factory);
+ }
+ }
+
+ set(factory: AnyApiFactory) {
+ this.factories.set(factory.api.id, factory);
+ }
+
+ setAll(factories: Iterable) {
+ for (const factory of factories) {
+ this.set(factory);
+ }
+ }
+
+ get(
+ api: ApiRef,
+ ): ApiFactory | undefined {
+ const factory = this.factories.get(api.id);
+ if (!factory) {
+ return undefined;
+ }
+
+ return factory as ApiFactory;
+ }
+
+ getAllApis() {
+ const refs = new Set();
+ for (const factory of this.factories.values()) {
+ refs.add(factory.api);
+ }
+ return refs;
+ }
+}
+
+export class FrontendApiResolver implements ApiHolder {
+ private readonly apis = new Map();
+ private readonly primaryRegistry?: FrontendApiRegistry;
+ private readonly secondaryRegistry?: FrontendApiRegistry;
+ private readonly fallbackApis?: ApiHolder;
+
+ constructor(options: {
+ primaryRegistry?: FrontendApiRegistry;
+ secondaryRegistry?: FrontendApiRegistry;
+ fallbackApis?: ApiHolder;
+ }) {
+ this.primaryRegistry = options.primaryRegistry;
+ this.secondaryRegistry = options.secondaryRegistry;
+ this.fallbackApis = options.fallbackApis;
+ }
+
+ get(ref: ApiRef): T | undefined {
+ return this.load(ref);
+ }
+
+ isMaterialized(apiRefId: string) {
+ return this.apis.has(apiRefId);
+ }
+
+ invalidate(apiRefIds?: Iterable) {
+ if (apiRefIds === undefined) {
+ this.apis.clear();
+ return;
+ }
+
+ for (const apiRefId of apiRefIds) {
+ this.apis.delete(apiRefId);
+ }
+ }
+
+ private load(ref: ApiRef, loading: AnyApiRef[] = []): T | undefined {
+ const existing = this.apis.get(ref.id);
+ if (this.apis.has(ref.id)) {
+ return existing as T;
+ }
+
+ const factory =
+ this.primaryRegistry?.get(ref) ?? this.secondaryRegistry?.get(ref);
+ if (!factory) {
+ return this.fallbackApis?.get(ref);
+ }
+
+ if (loading.includes(factory.api)) {
+ throw new Error(`Circular dependency of api factory for ${factory.api}`);
+ }
+
+ const deps = {} as { [name: string]: unknown };
+ for (const [key, depRef] of Object.entries(factory.deps)) {
+ const dep = this.load(depRef, [...loading, factory.api]);
+ if (dep === undefined) {
+ throw new Error(
+ `No API factory available for dependency ${depRef} of dependent ${factory.api}`,
+ );
+ }
+ deps[key] = dep;
+ }
+
+ const api = factory.factory(deps);
+ this.apis.set(ref.id, api);
+ return api as T;
+ }
+}
diff --git a/packages/frontend-app-api/src/wiring/apiFactories.ts b/packages/frontend-app-api/src/wiring/apiFactories.ts
new file mode 100644
index 0000000000..6ccc75b9c1
--- /dev/null
+++ b/packages/frontend-app-api/src/wiring/apiFactories.ts
@@ -0,0 +1,304 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+ ApiBlueprint,
+ AnyApiFactory,
+ ApiHolder,
+ AppNode,
+ FrontendFeature,
+ featureFlagsApiRef,
+} from '@backstage/frontend-plugin-api';
+import { OpaqueFrontendPlugin } from '@internal/frontend';
+import { instantiateAppNodeSubtree } from '../tree/instantiateAppNodeTree';
+// eslint-disable-next-line @backstage/no-relative-monorepo-imports
+import {
+ isInternalFrontendModule,
+ toInternalFrontendModule,
+} from '../../../frontend-plugin-api/src/wiring/createFrontendModule';
+import { ErrorCollector } from './createErrorCollector';
+import {
+ FrontendApiRegistry,
+ FrontendApiResolver,
+} from './FrontendApiRegistry';
+import { type ExtensionPredicateContext } from './predicates';
+
+export type ApiFactoryEntry = {
+ node: AppNode;
+ pluginId: string;
+ factory: AnyApiFactory;
+};
+
+/**
+ * Registers feature flag declarations on an already prepared API holder.
+ *
+ * This is primarily used when bootstrap reuses APIs from a provided session
+ * state rather than building a fresh registry from bootstrap-visible factories.
+ */
+export function registerFeatureFlagDeclarationsInHolder(
+ apis: ApiHolder,
+ features: FrontendFeature[],
+) {
+ const featureFlagApi = apis.get(featureFlagsApiRef);
+ if (featureFlagApi) {
+ registerFeatureFlagDeclarations(featureFlagApi, features);
+ }
+}
+
+/**
+ * Decorates the feature flags API factory so plugin and module declarations are
+ * registered whenever that API is instantiated.
+ */
+export function wrapFeatureFlagApiFactory(
+ factory: AnyApiFactory,
+ features: FrontendFeature[],
+) {
+ if (factory.api.id !== featureFlagsApiRef.id) {
+ return factory;
+ }
+
+ return {
+ ...factory,
+ factory(deps) {
+ const featureFlagApi = factory.factory(
+ deps,
+ ) as typeof featureFlagsApiRef.T;
+ registerFeatureFlagDeclarations(featureFlagApi, features);
+ return featureFlagApi;
+ },
+ } as AnyApiFactory;
+}
+
+/**
+ * Reconciles deferred API factories into the finalized API registry.
+ *
+ * It preserves bootstrap-frozen APIs, allows safe deferred additions, and
+ * reports cases where bootstrap-visible extensions relied on APIs that only
+ * became available during finalization.
+ */
+export function syncFinalApiFactories(options: {
+ deferredApiNodes: Iterable;
+ appApiRegistry: FrontendApiRegistry;
+ apiResolver: FrontendApiResolver;
+ collector: ErrorCollector;
+ features: FrontendFeature[];
+ bootstrapApiFactoryEntries: ReadonlyMap;
+ bootstrapMissingApiAccesses: Map;
+ predicateContext: ExtensionPredicateContext;
+}) {
+ const finalApiEntries = collectApiFactoryEntries({
+ apiNodes: options.deferredApiNodes,
+ collector: options.collector,
+ predicateContext: options.predicateContext,
+ entries: new Map(options.bootstrapApiFactoryEntries),
+ });
+ // Only newly introduced or still-safe overrides are registered here. Any
+ // bootstrap-materialized API remains frozen for the lifetime of the app.
+ const changedEntries = Array.from(finalApiEntries.values()).filter(entry => {
+ const bootstrapEntry = options.bootstrapApiFactoryEntries.get(
+ entry.factory.api.id,
+ );
+ if (!bootstrapEntry) {
+ return true;
+ }
+ if (bootstrapEntry.factory === entry.factory) {
+ return false;
+ }
+ if (options.apiResolver.isMaterialized(entry.factory.api.id)) {
+ options.collector.report({
+ code: 'EXTENSION_BOOTSTRAP_API_OVERRIDE_IGNORED',
+ message:
+ `Extension '${entry.node.spec.id}' tried to override API ` +
+ `'${entry.factory.api.id}' after it had already been materialized during bootstrap. ` +
+ 'The bootstrap implementation was kept and the deferred override was ignored.',
+ context: {
+ node: entry.node,
+ apiRefId: entry.factory.api.id,
+ bootstrapNode: bootstrapEntry.node,
+ pluginId: entry.pluginId,
+ bootstrapPluginId: bootstrapEntry.pluginId,
+ },
+ });
+ return false;
+ }
+ return true;
+ });
+ const changedFactories = changedEntries.map(entry =>
+ wrapFeatureFlagApiFactory(entry.factory, options.features),
+ );
+ options.appApiRegistry.setAll(changedFactories);
+ options.apiResolver.invalidate(
+ changedFactories.map(factory => factory.api.id),
+ );
+ for (const bootstrapAccess of options.bootstrapMissingApiAccesses.values()) {
+ if (
+ options.bootstrapApiFactoryEntries.has(bootstrapAccess.apiRefId) ||
+ !finalApiEntries.has(bootstrapAccess.apiRefId)
+ ) {
+ continue;
+ }
+
+ options.collector.report({
+ code: 'EXTENSION_BOOTSTRAP_API_UNAVAILABLE',
+ message:
+ `Extension '${bootstrapAccess.node.spec.id}' tried to access API ` +
+ `'${bootstrapAccess.apiRefId}' during bootstrap before it was available. ` +
+ 'That API became available during finalization, so bootstrap-visible extensions must not depend on deferred APIs.',
+ context: {
+ node: bootstrapAccess.node,
+ apiRefId: bootstrapAccess.apiRefId,
+ },
+ });
+ }
+}
+
+const EMPTY_API_HOLDER: ApiHolder = {
+ get() {
+ return undefined;
+ },
+};
+
+function registerFeatureFlagDeclarations(
+ featureFlagApi: typeof featureFlagsApiRef.T,
+ features: FrontendFeature[],
+) {
+ for (const feature of features) {
+ if (OpaqueFrontendPlugin.isType(feature)) {
+ OpaqueFrontendPlugin.toInternal(feature).featureFlags.forEach(flag =>
+ featureFlagApi.registerFlag({
+ name: flag.name,
+ description: flag.description,
+ pluginId: feature.id,
+ }),
+ );
+ }
+ if (isInternalFrontendModule(feature)) {
+ toInternalFrontendModule(feature).featureFlags.forEach(flag =>
+ featureFlagApi.registerFlag({
+ name: flag.name,
+ description: flag.description,
+ pluginId: feature.pluginId,
+ }),
+ );
+ }
+ }
+}
+
+/**
+ * Instantiates API extension subtrees in isolation and extracts the factories
+ * they provide without mutating the live app tree.
+ *
+ * The collected entries are later used both for bootstrap registration and for
+ * the finalization-time reconciliation of deferred API roots.
+ */
+export function collectApiFactoryEntries(options: {
+ apiNodes: Iterable;
+ collector: ErrorCollector;
+ predicateContext?: ExtensionPredicateContext;
+ entries?: Map;
+}): Map {
+ const factoriesById = options.entries ?? new Map();
+ for (const apiNode of options.apiNodes) {
+ // API extensions are instantiated in isolation so we can inspect the
+ // produced factories without mutating the live app tree.
+ const detachedApiNode = instantiateAppNodeSubtree({
+ rootNode: apiNode,
+ apis: EMPTY_API_HOLDER,
+ collector: options.collector,
+ predicateContext: options.predicateContext,
+ writeNodeInstances: false,
+ reuseExistingInstances: false,
+ });
+ if (!detachedApiNode) {
+ continue;
+ }
+ const apiFactory = detachedApiNode.instance?.getData(
+ ApiBlueprint.dataRefs.factory,
+ );
+ if (apiFactory) {
+ const apiRefId = apiFactory.api.id;
+ const ownerId = getApiOwnerId(apiRefId);
+ const pluginId = apiNode.spec.plugin.pluginId ?? 'app';
+ const existingFactory = factoriesById.get(apiRefId);
+
+ // This allows modules to override factories provided by the plugin, but
+ // it rejects API overrides from other plugins. In the event of a
+ // conflict, the owning plugin is attempted to be inferred from the API
+ // reference ID.
+ if (existingFactory && existingFactory.pluginId !== pluginId) {
+ const shouldReplace =
+ ownerId === pluginId && existingFactory.pluginId !== ownerId;
+ const acceptedPluginId = shouldReplace
+ ? pluginId
+ : existingFactory.pluginId;
+ const rejectedPluginId = shouldReplace
+ ? existingFactory.pluginId
+ : pluginId;
+
+ options.collector.report({
+ code: 'API_FACTORY_CONFLICT',
+ message: `API '${apiRefId}' is already provided by plugin '${acceptedPluginId}', cannot also be provided by '${rejectedPluginId}'.`,
+ context: {
+ node: apiNode,
+ apiRefId,
+ pluginId: rejectedPluginId,
+ existingPluginId: acceptedPluginId,
+ },
+ });
+ if (shouldReplace) {
+ factoriesById.set(apiRefId, {
+ pluginId,
+ node: apiNode,
+ factory: apiFactory,
+ });
+ }
+ continue;
+ }
+
+ factoriesById.set(apiRefId, {
+ pluginId,
+ node: apiNode,
+ factory: apiFactory,
+ });
+ } else {
+ options.collector.report({
+ code: 'API_EXTENSION_INVALID',
+ message: `API extension '${apiNode.spec.id}' did not output an API factory`,
+ context: {
+ node: apiNode,
+ },
+ });
+ }
+ }
+
+ return factoriesById;
+}
+
+// TODO(Rugvip): It would be good if this was more explicit, but I think that
+// might need to wait for some future update for API factories.
+function getApiOwnerId(apiRefId: string): string {
+ const [prefix, ...rest] = apiRefId.split('.');
+ if (!prefix) {
+ return apiRefId;
+ }
+ if (prefix === 'core') {
+ return 'app';
+ }
+ if (prefix === 'plugin' && rest[0]) {
+ return rest[0];
+ }
+ return prefix;
+}
diff --git a/packages/frontend-app-api/src/wiring/createErrorCollector.ts b/packages/frontend-app-api/src/wiring/createErrorCollector.ts
index 5f081bc092..7077e0e9c4 100644
--- a/packages/frontend-app-api/src/wiring/createErrorCollector.ts
+++ b/packages/frontend-app-api/src/wiring/createErrorCollector.ts
@@ -82,6 +82,21 @@ export type AppErrorTypes = {
existingPluginId: string;
};
};
+ EXTENSION_BOOTSTRAP_PREDICATE_IGNORED: {
+ context: { node: AppNode };
+ };
+ EXTENSION_BOOTSTRAP_API_UNAVAILABLE: {
+ context: { node: AppNode; apiRefId: string };
+ };
+ EXTENSION_BOOTSTRAP_API_OVERRIDE_IGNORED: {
+ context: {
+ node: AppNode;
+ apiRefId: string;
+ bootstrapNode: AppNode;
+ pluginId: string;
+ bootstrapPluginId: string;
+ };
+ };
// routing
ROUTE_DUPLICATE: {
context: { routeId: string };
diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx
index 55159c7e69..3e841a1283 100644
--- a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx
+++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx
@@ -16,7 +16,6 @@
import {
AppTreeApi,
- type ApiRef,
appTreeApiRef,
coreExtensionData,
createExtension,
@@ -28,17 +27,32 @@ import {
createExternalRouteRef,
createExtensionInput,
useRouteRef,
+ useApi,
analyticsApiRef,
- configApiRef,
createExtensionDataRef,
- featureFlagsApiRef,
} from '@backstage/frontend-plugin-api';
-import { screen, render } from '@testing-library/react';
+import { act, render, screen } from '@testing-library/react';
import { createSpecializedApp } from './createSpecializedApp';
+import {
+ FinalizedSpecializedApp,
+ prepareSpecializedApp,
+ PreparedSpecializedApp,
+} from './prepareSpecializedApp';
import { mockApis, TestApiRegistry } from '@backstage/test-utils';
+import {
+ configApiRef,
+ featureFlagsApiRef,
+ IdentityApi,
+ identityApiRef,
+} from '@backstage/core-plugin-api';
import { MemoryRouter } from 'react-router-dom';
import { ApiProvider, ConfigReader } from '@backstage/core-app-api';
-import { Fragment } from 'react';
+import { ComponentType, Fragment, useEffect, useState } from 'react';
+import appPluginOriginal from '@backstage/plugin-app';
+
+const signInPageComponentDataRef = createExtensionDataRef<
+ ComponentType<{ onSignInSuccess(identity: IdentityApi): void }>
+>().with({ id: 'core.sign-in-page.component' });
function makeAppPlugin(label: string = 'Test') {
return createFrontendPlugin({
@@ -52,13 +66,38 @@ function makeAppPlugin(label: string = 'Test') {
],
});
}
+
+function renderPreparedBootstrap(preparedApp: PreparedSpecializedApp) {
+ const bootstrapApp = preparedApp.getBootstrapApp();
+ const bootstrapElement = bootstrapApp.element;
+ if (!bootstrapElement) {
+ throw new Error('Expected bootstrap tree to expose a root element');
+ }
+
+ render(bootstrapElement);
+}
+
+async function waitForFinalizedApp(preparedApp: PreparedSpecializedApp) {
+ return new Promise(resolve => {
+ preparedApp.onFinalized(resolve);
+ });
+}
+
describe('createSpecializedApp', () => {
+ const appPlugin = appPluginOriginal.withOverrides({
+ extensions: [
+ appPluginOriginal.getExtension('app/layout').override({
+ factory: () => [coreExtensionData.reactElement(