diff --git a/.changeset/rude-tomatoes-itch.md b/.changeset/rude-tomatoes-itch.md
new file mode 100644
index 0000000000..1071f65756
--- /dev/null
+++ b/.changeset/rude-tomatoes-itch.md
@@ -0,0 +1,5 @@
+---
+'@backstage/frontend-app-api': patch
+---
+
+Installed features are now deduplicated both by reference and ID when available. Features passed to `createApp` now override both discovered and loaded features.
diff --git a/packages/app-next/package.json b/packages/app-next/package.json
index 635a513c0f..71d888ee50 100644
--- a/packages/app-next/package.json
+++ b/packages/app-next/package.json
@@ -12,6 +12,7 @@
"@backstage/cli": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/core-app-api": "workspace:^",
+ "@backstage/core-compat-api": "workspace:^",
"@backstage/core-components": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/frontend-app-api": "workspace:^",
diff --git a/packages/app-next/src/App.tsx b/packages/app-next/src/App.tsx
index d64c4dbb15..4e42d9b268 100644
--- a/packages/app-next/src/App.tsx
+++ b/packages/app-next/src/App.tsx
@@ -27,6 +27,7 @@ import homePlugin, {
import {
coreExtensionData,
createExtension,
+ createApiExtension,
createExtensionOverrides,
createPageExtension,
} from '@backstage/frontend-plugin-api';
@@ -34,6 +35,16 @@ import { entityRouteRef } from '@backstage/plugin-catalog-react';
import techdocsPlugin from '@backstage/plugin-techdocs/alpha';
import { convertLegacyRouteRef } from '@backstage/core-plugin-api/alpha';
import { homePage } from './HomePage';
+import { collectLegacyRoutes } from '@backstage/core-compat-api';
+import { FlatRoutes } from '@backstage/core-app-api';
+import { Route } from 'react-router';
+import { CatalogImportPage } from '@backstage/plugin-catalog-import';
+import { createApiFactory, configApiRef } from '@backstage/core-plugin-api';
+import {
+ ScmAuth,
+ ScmIntegrationsApi,
+ scmIntegrationsApiRef,
+} from '@backstage/integration-react';
/*
@@ -83,6 +94,24 @@ const homePageExtension = createExtension({
},
});
+const scmAuthExtension = createApiExtension({
+ factory: ScmAuth.createDefaultApiFactory(),
+});
+
+const scmIntegrationApi = createApiExtension({
+ factory: createApiFactory({
+ api: scmIntegrationsApiRef,
+ deps: { configApi: configApiRef },
+ factory: ({ configApi }) => ScmIntegrationsApi.fromConfig(configApi),
+ }),
+});
+
+const collectedLegacyPlugins = collectLegacyRoutes(
+
+ } />
+ ,
+);
+
const app = createApp({
features: [
graphiqlPlugin,
@@ -91,8 +120,14 @@ const app = createApp({
techdocsPlugin,
userSettingsPlugin,
homePlugin,
+ ...collectedLegacyPlugins,
createExtensionOverrides({
- extensions: [entityPageExtension, homePageExtension],
+ extensions: [
+ entityPageExtension,
+ homePageExtension,
+ scmAuthExtension,
+ scmIntegrationApi,
+ ],
}),
],
/* Handled through config instead */
diff --git a/packages/core-compat-api/.eslintrc.js b/packages/core-compat-api/.eslintrc.js
new file mode 100644
index 0000000000..e2a53a6ad2
--- /dev/null
+++ b/packages/core-compat-api/.eslintrc.js
@@ -0,0 +1 @@
+module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
diff --git a/packages/core-compat-api/README.md b/packages/core-compat-api/README.md
new file mode 100644
index 0000000000..5faa299f03
--- /dev/null
+++ b/packages/core-compat-api/README.md
@@ -0,0 +1,12 @@
+# @backstage/core-compat-api
+
+_This package was created through the Backstage CLI_.
+
+## Installation
+
+Install the package via Yarn:
+
+```sh
+cd # if within a monorepo
+yarn add @backstage/core-compat-api
+```
diff --git a/packages/core-compat-api/api-report.md b/packages/core-compat-api/api-report.md
new file mode 100644
index 0000000000..5175c76ac8
--- /dev/null
+++ b/packages/core-compat-api/api-report.md
@@ -0,0 +1,16 @@
+## API Report File for "@backstage/core-compat-api"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+///
+
+import { BackstagePlugin } from '@backstage/frontend-plugin-api';
+
+// @public (undocumented)
+export function collectLegacyRoutes(
+ flatRoutesElement: JSX.Element,
+): BackstagePlugin[];
+
+// (No @packageDocumentation comment for this package)
+```
diff --git a/packages/core-compat-api/catalog-info.yaml b/packages/core-compat-api/catalog-info.yaml
new file mode 100644
index 0000000000..f20f0543a0
--- /dev/null
+++ b/packages/core-compat-api/catalog-info.yaml
@@ -0,0 +1,9 @@
+apiVersion: backstage.io/v1alpha1
+kind: Component
+metadata:
+ name: backstage-core-compat-api
+ title: '@backstage/core-compat-api'
+spec:
+ lifecycle: experimental
+ type: backstage-web-library
+ owner: maintainers
diff --git a/packages/core-compat-api/package.json b/packages/core-compat-api/package.json
new file mode 100644
index 0000000000..d27049c2a4
--- /dev/null
+++ b/packages/core-compat-api/package.json
@@ -0,0 +1,45 @@
+{
+ "name": "@backstage/core-compat-api",
+ "version": "0.0.0",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "license": "Apache-2.0",
+ "private": true,
+ "publishConfig": {
+ "access": "public",
+ "main": "dist/index.esm.js",
+ "types": "dist/index.d.ts"
+ },
+ "backstage": {
+ "role": "web-library"
+ },
+ "sideEffects": false,
+ "scripts": {
+ "start": "backstage-cli package start",
+ "build": "backstage-cli package build",
+ "lint": "backstage-cli package lint",
+ "test": "backstage-cli package test",
+ "clean": "backstage-cli package clean",
+ "prepack": "backstage-cli package prepack",
+ "postpack": "backstage-cli package postpack"
+ },
+ "devDependencies": {
+ "@backstage/cli": "workspace:^",
+ "@backstage/core-app-api": "workspace:^",
+ "@backstage/plugin-puppetdb": "workspace:^",
+ "@backstage/plugin-stackstorm": "workspace:^",
+ "@oriflame/backstage-plugin-score-card": "^0.7.0",
+ "@testing-library/jest-dom": "^6.0.0"
+ },
+ "files": [
+ "dist"
+ ],
+ "peerDependencies": {
+ "react": "^16.13.1 || ^17.0.0 || ^18.0.0",
+ "react-router-dom": "6.0.0-beta.0 || ^6.3.0"
+ },
+ "dependencies": {
+ "@backstage/core-plugin-api": "workspace:^",
+ "@backstage/frontend-plugin-api": "workspace:^"
+ }
+}
diff --git a/packages/core-compat-api/src/collectLegacyRoutes.test.tsx b/packages/core-compat-api/src/collectLegacyRoutes.test.tsx
new file mode 100644
index 0000000000..486c373991
--- /dev/null
+++ b/packages/core-compat-api/src/collectLegacyRoutes.test.tsx
@@ -0,0 +1,97 @@
+/*
+ * 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 { FlatRoutes } from '@backstage/core-app-api';
+import { PuppetDbPage } from '@backstage/plugin-puppetdb';
+import { StackstormPage } from '@backstage/plugin-stackstorm';
+import { ScoreBoardPage } from '@oriflame/backstage-plugin-score-card';
+import React from 'react';
+import { Route } from 'react-router-dom';
+
+import { collectLegacyRoutes } from './collectLegacyRoutes';
+
+describe('collectLegacyRoutes', () => {
+ it('should collect legacy routes', () => {
+ const collected = collectLegacyRoutes(
+
+ } />
+ } />
+ } />
+ ,
+ );
+
+ expect(
+ collected.map(p => ({
+ id: p.id,
+ extensions: p.extensions.map(e => ({
+ id: e.id,
+ attachTo: e.attachTo,
+ disabled: e.disabled,
+ defaultConfig: e.configSchema?.parse({}),
+ })),
+ })),
+ ).toEqual([
+ {
+ id: 'score-card',
+ extensions: [
+ {
+ id: 'plugin.score-card.page',
+ attachTo: { id: 'core.routes', input: 'routes' },
+ disabled: false,
+ defaultConfig: { path: 'score-board' },
+ },
+ {
+ id: 'apis.plugin.scoringdata.service',
+ attachTo: { id: 'core', input: 'apis' },
+ disabled: false,
+ },
+ ],
+ },
+ {
+ id: 'stackstorm',
+ extensions: [
+ {
+ id: 'plugin.stackstorm.page',
+ attachTo: { id: 'core.routes', input: 'routes' },
+ disabled: false,
+ defaultConfig: { path: 'stackstorm' },
+ },
+ {
+ id: 'apis.plugin.stackstorm.service',
+ attachTo: { id: 'core', input: 'apis' },
+ disabled: false,
+ },
+ ],
+ },
+ {
+ id: 'puppetDb',
+ extensions: [
+ {
+ id: 'plugin.puppetDb.page',
+ attachTo: { id: 'core.routes', input: 'routes' },
+ disabled: false,
+ defaultConfig: { path: 'puppetdb' },
+ },
+ {
+ id: 'apis.plugin.puppetdb.service',
+ attachTo: { id: 'core', input: 'apis' },
+ disabled: false,
+ },
+ ],
+ },
+ ]);
+ });
+});
diff --git a/packages/core-compat-api/src/collectLegacyRoutes.tsx b/packages/core-compat-api/src/collectLegacyRoutes.tsx
new file mode 100644
index 0000000000..2b149e6285
--- /dev/null
+++ b/packages/core-compat-api/src/collectLegacyRoutes.tsx
@@ -0,0 +1,137 @@
+/*
+ * 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 React, { ReactNode } from 'react';
+import {
+ Extension,
+ createApiExtension,
+ createPageExtension,
+ createPlugin,
+ BackstagePlugin,
+} from '@backstage/frontend-plugin-api';
+import { Route, Routes } from 'react-router-dom';
+import {
+ BackstagePlugin as LegacyBackstagePlugin,
+ RouteRef,
+ getComponentData,
+} from '@backstage/core-plugin-api';
+import { convertLegacyRouteRef } from '@backstage/core-plugin-api/alpha';
+
+/*
+
+# Legacy interoperability
+
+Use-cases (prioritized):
+ 1. Slowly migrate over an existing app to DI, piece by piece
+ 2. Use a legacy plugin in a new DI app
+ 3. Use DI in an existing legacy app
+
+Starting point: use-case #1
+
+Potential solutions:
+ 1. Codemods (we're not considering this for now)
+ 2. Legacy apps are migrated bottom-up, i.e. keep legacy root, replace pages with DI
+ 3. Legacy apps are migrated top-down i.e. switch out base to DI, legacy adapter allows for usage of existing app structure
+
+Chosen path: #3
+
+Existing tasks:
+ - Adopters can migrate their existing app gradually (~4)
+ - Example-app uses legacy base with DI adapters
+ - Create an API that lets you inject DI into existing apps - working assumption is that this is enough
+ - Adopters can use legacy plugins in DI through adapters (~8)
+ - App-next uses DI base with legacy adapters
+ - Create a legacy adapter that is able to take an existing extension tree
+
+*/
+
+/** @public */
+export function collectLegacyRoutes(
+ flatRoutesElement: JSX.Element,
+): BackstagePlugin[] {
+ const results = new Array();
+
+ React.Children.forEach(
+ flatRoutesElement.props.children,
+ (route: ReactNode) => {
+ if (!React.isValidElement(route)) {
+ return;
+ }
+
+ // TODO(freben): Handle feature flag and permissions framework wrapper elements
+ if (route.type !== Route) {
+ return;
+ }
+
+ const routeElement = route.props.element;
+
+ // TODO: to support deeper extension component, e.g. hidden within , use https://github.com/backstage/backstage/blob/518a34646b79ec2028cc0ed6bc67d4366c51c4d6/packages/core-app-api/src/routing/collectors.tsx#L69
+ const plugin = getComponentData(
+ routeElement,
+ 'core.plugin',
+ );
+ if (!plugin) {
+ return;
+ }
+
+ const routeRef = getComponentData(
+ routeElement,
+ 'core.mountPoint',
+ );
+
+ const pluginId = plugin.getId();
+ const path: string = route.props.path;
+
+ const detectedExtensions = new Array>();
+
+ detectedExtensions.push(
+ createPageExtension({
+ id: `plugin.${pluginId}.page`,
+ defaultPath: path[0] === '/' ? path.slice(1) : path,
+ routeRef: routeRef ? convertLegacyRouteRef(routeRef) : undefined,
+
+ loader: async () =>
+ route.props.children ? (
+
+
+
+
+
+ ) : (
+ routeElement
+ ),
+ }),
+ );
+
+ detectedExtensions.push(
+ ...Array.from(plugin.getApis()).map(factory =>
+ createApiExtension({
+ factory,
+ }),
+ ),
+ );
+
+ results.push(
+ createPlugin({
+ id: plugin.getId(),
+ extensions: detectedExtensions,
+ }),
+ );
+ },
+ );
+
+ return results;
+}
diff --git a/packages/core-compat-api/src/index.ts b/packages/core-compat-api/src/index.ts
new file mode 100644
index 0000000000..2181b2fa9a
--- /dev/null
+++ b/packages/core-compat-api/src/index.ts
@@ -0,0 +1,16 @@
+/*
+ * 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.
+ */
+export { collectLegacyRoutes } from './collectLegacyRoutes';
diff --git a/packages/core-compat-api/src/setupTests.ts b/packages/core-compat-api/src/setupTests.ts
new file mode 100644
index 0000000000..865308e634
--- /dev/null
+++ b/packages/core-compat-api/src/setupTests.ts
@@ -0,0 +1,16 @@
+/*
+ * 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 '@testing-library/jest-dom';
diff --git a/packages/frontend-app-api/src/wiring/createApp.test.tsx b/packages/frontend-app-api/src/wiring/createApp.test.tsx
index 0f2a6d38ce..de574bc6a9 100644
--- a/packages/frontend-app-api/src/wiring/createApp.test.tsx
+++ b/packages/frontend-app-api/src/wiring/createApp.test.tsx
@@ -23,7 +23,7 @@ import {
createThemeExtension,
} from '@backstage/frontend-plugin-api';
import { createApp, createInstances } from './createApp';
-import { screen } from '@testing-library/react';
+import { screen, waitFor } from '@testing-library/react';
import { MockConfigApi, renderWithEffects } from '@backstage/test-utils';
import React from 'react';
@@ -244,4 +244,42 @@ describe('createApp', () => {
}
`);
});
+
+ it('should deduplicate features keeping the last received one', async () => {
+ const duplicatedFeatureId = 'test';
+ const app = createApp({
+ configLoader: async () => new MockConfigApi({}),
+ features: [
+ createPlugin({
+ id: duplicatedFeatureId,
+ extensions: [
+ createPageExtension({
+ id: 'test.page.first',
+ defaultPath: '/',
+ loader: async () => First Page
,
+ }),
+ ],
+ }),
+ createPlugin({
+ id: duplicatedFeatureId,
+ extensions: [
+ createPageExtension({
+ id: 'test.page.last',
+ defaultPath: '/',
+ loader: async () => Last Page
,
+ }),
+ ],
+ }),
+ ],
+ });
+
+ await renderWithEffects(app.createRoot());
+
+ await waitFor(() =>
+ expect(screen.queryByText('First Page')).not.toBeInTheDocument(),
+ );
+ await waitFor(() =>
+ expect(screen.getByText('Last Page')).toBeInTheDocument(),
+ );
+ });
});
diff --git a/packages/frontend-app-api/src/wiring/createApp.tsx b/packages/frontend-app-api/src/wiring/createApp.tsx
index 87b2042d34..8b411ac38b 100644
--- a/packages/frontend-app-api/src/wiring/createApp.tsx
+++ b/packages/frontend-app-api/src/wiring/createApp.tsx
@@ -267,6 +267,29 @@ export function createInstances(options: {
return { coreInstance, instances };
}
+function deduplicateFeatures(
+ allFeatures: (BackstagePlugin | ExtensionOverrides)[],
+): (BackstagePlugin | ExtensionOverrides)[] {
+ // Start by removing duplicates by reference
+ const features = Array.from(new Set(allFeatures));
+
+ // Plugins are deduplicated by ID, last one wins
+ const seenIds = new Set();
+ return features
+ .reverse()
+ .filter(feature => {
+ if (feature.$$type !== '@backstage/BackstagePlugin') {
+ return true;
+ }
+ if (seenIds.has(feature.id)) {
+ return false;
+ }
+ seenIds.add(feature.id);
+ return true;
+ })
+ .reverse();
+}
+
/** @public */
export function createApp(options: {
features?: (BackstagePlugin | ExtensionOverrides)[];
@@ -287,13 +310,11 @@ export function createApp(options: {
const discoveredFeatures = getAvailableFeatures(config);
const loadedFeatures = (await options.featureLoader?.({ config })) ?? [];
- const allFeatures = Array.from(
- new Set([
- ...discoveredFeatures,
- ...(options.features ?? []),
- ...loadedFeatures,
- ]),
- );
+ const allFeatures = deduplicateFeatures([
+ ...discoveredFeatures,
+ ...loadedFeatures,
+ ...(options.features ?? []),
+ ]);
const { coreInstance } = createInstances({
features: allFeatures,
diff --git a/yarn.lock b/yarn.lock
index 0fe4bb7768..636d159dd3 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3952,6 +3952,24 @@ __metadata:
languageName: unknown
linkType: soft
+"@backstage/core-compat-api@workspace:^, @backstage/core-compat-api@workspace:packages/core-compat-api":
+ version: 0.0.0-use.local
+ resolution: "@backstage/core-compat-api@workspace:packages/core-compat-api"
+ dependencies:
+ "@backstage/cli": "workspace:^"
+ "@backstage/core-app-api": "workspace:^"
+ "@backstage/core-plugin-api": "workspace:^"
+ "@backstage/frontend-plugin-api": "workspace:^"
+ "@backstage/plugin-puppetdb": "workspace:^"
+ "@backstage/plugin-stackstorm": "workspace:^"
+ "@oriflame/backstage-plugin-score-card": ^0.7.0
+ "@testing-library/jest-dom": ^6.0.0
+ peerDependencies:
+ react: ^16.13.1 || ^17.0.0 || ^18.0.0
+ react-router-dom: 6.0.0-beta.0 || ^6.3.0
+ languageName: unknown
+ linkType: soft
+
"@backstage/core-components@^0.13.5, @backstage/core-components@workspace:^, @backstage/core-components@workspace:packages/core-components":
version: 0.0.0-use.local
resolution: "@backstage/core-components@workspace:packages/core-components"
@@ -25239,6 +25257,7 @@ __metadata:
"@backstage/cli": "workspace:^"
"@backstage/config": "workspace:^"
"@backstage/core-app-api": "workspace:^"
+ "@backstage/core-compat-api": "workspace:^"
"@backstage/core-components": "workspace:^"
"@backstage/core-plugin-api": "workspace:^"
"@backstage/frontend-app-api": "workspace:^"