diff --git a/.changeset/dry-beers-shake.md b/.changeset/dry-beers-shake.md
new file mode 100644
index 0000000000..59ff5ead54
--- /dev/null
+++ b/.changeset/dry-beers-shake.md
@@ -0,0 +1,5 @@
+---
+'@backstage/frontend-defaults': minor
+---
+
+Initial release of this package, which provides a default app setup through the `createApp` function. This replaces the existing `createApp` method from `@backstage/frontend-app-api`.
diff --git a/.changeset/four-parents-buy.md b/.changeset/four-parents-buy.md
new file mode 100644
index 0000000000..fc925cf6d1
--- /dev/null
+++ b/.changeset/four-parents-buy.md
@@ -0,0 +1,5 @@
+---
+'@backstage/frontend-app-api': minor
+---
+
+**BREAKING**: The `createSpecializedApp` function now creates a bare-bones app without any of the default app structure or APIs. To re-introduce this functionality if you need to use `createSpecializedApp` you can install the `app` plugin from `@backstage/plugin-app`.
diff --git a/beps/0002-dynamic-frontend-plugins/README.md b/beps/0002-dynamic-frontend-plugins/README.md
index 34c6e2d435..db223c959d 100644
--- a/beps/0002-dynamic-frontend-plugins/README.md
+++ b/beps/0002-dynamic-frontend-plugins/README.md
@@ -301,7 +301,7 @@ An example of [integration with scalprum](https://github.com/backstage/backstage
**Dynamic Feature configuration**
-The dynamic remote loading can be added directly into the [`createApp`](https://github.com/backstage/backstage/blob/master/packages/frontend-app-api/src/wiring/createApp.tsx#L234) function.
+The dynamic remote loading can be added directly into the [`createApp`](https://backstage.io/docs/reference/frontend-defaults.createapp) function.
The current `feature` type can be expanded with a `DynamicFrontendFeature` type:
@@ -351,7 +351,7 @@ const scalprum = initialize({
});
```
-Because the [`appLoader`](https://github.com/backstage/backstage/blob/master/packages/frontend-app-api/src/wiring/createApp.tsx#L193) is already async, it is a perfect place to load the plugin registry and init the dynamic plugins.
+Because the [`appLoader`](https://backstage.io/docs/reference/frontend-defaults.createapp) is already async, it is a perfect place to load the plugin registry and init the dynamic plugins.
Initializing the dynamic feature is just a case of mapping the `DynamicFrontendFeature` to `FrontendFeature` via Scalprum:
diff --git a/docs/frontend-system/architecture/10-app.md b/docs/frontend-system/architecture/10-app.md
index 532971d124..d1c3820f09 100644
--- a/docs/frontend-system/architecture/10-app.md
+++ b/docs/frontend-system/architecture/10-app.md
@@ -16,7 +16,7 @@ Below is a simple example of how to create and render an app instance:
```ts
import ReactDOM from 'react-dom/client';
-import { createApp } from '@backstage/frontend-app-api';
+import { createApp } from '@backstage/frontend-defaults';
// Create your app instance
const app = createApp({
diff --git a/docs/frontend-system/architecture/25-extension-overrides.md b/docs/frontend-system/architecture/25-extension-overrides.md
index d47e2697a9..785843f6e0 100644
--- a/docs/frontend-system/architecture/25-extension-overrides.md
+++ b/docs/frontend-system/architecture/25-extension-overrides.md
@@ -295,7 +295,7 @@ export default createExtensionOverrides({
Assuming the above code resides in the `@internal/search-page` package, you can install it in your app like this:
```tsx title="packages/app/src/App.tsx"
-import { createApp } from '@backstage/frontend-app-api';
+import { createApp } from '@backstage/frontend-defaults';
import searchPageOverride from '@internal/search-page';
const app = createApp({
diff --git a/docs/frontend-system/architecture/36-routes.md b/docs/frontend-system/architecture/36-routes.md
index e684932414..2a9b372b1c 100644
--- a/docs/frontend-system/architecture/36-routes.md
+++ b/docs/frontend-system/architecture/36-routes.md
@@ -256,7 +256,7 @@ app:
We also have the ability to express this in code as an option to `createApp`, but you of course only need to use one of these two methods:
```tsx title="packages/app/src/App.tsx"
-import { createApp } from '@backstage/frontend-app-api';
+import { createApp } from '@backstage/frontend-defaults';
import catalog from '@backstage/plugin-catalog';
import scaffolder from '@backstage/plugin-scaffolder';
diff --git a/docs/frontend-system/building-apps/01-index.md b/docs/frontend-system/building-apps/01-index.md
index 16aacc1185..5b5298a2c5 100644
--- a/docs/frontend-system/building-apps/01-index.md
+++ b/docs/frontend-system/building-apps/01-index.md
@@ -30,13 +30,13 @@ The created-app is currently templated for legacy frontend system applications,
## The app instance
-The starting point of a frontend app is the `createApp` function, which accepts a single options object as its only parameter. It is imported from `@backstage/frontend-app-api`, which is where you will find most of the common APIs for building apps.
+The starting point of a frontend app is the `createApp` function, which accepts a single options object as its only parameter. It is imported from `@backstage/frontend-defaults`, which is where you will find most of the common APIs for building apps.
This is how to create a minimal app:
```tsx title="in src/index.ts"
import ReactDOM from 'react-dom/client';
-import { createApp } from '@backstage/frontend-app-api';
+import { createApp } from '@backstage/frontend-defaults';
import catalogPlugin from '@backstage/plugin-catalog/alpha';
// Create your app instance
@@ -87,7 +87,7 @@ Previously you would customize the application routes, components, apis, sidebar
A manual installation is required if your packages are not discovered automatically, either because you are not using `@backstage/cli` to build your application or because the features are defined in local modules in the app package. In order to manually install a feature, you must import it and pass it to the `createApp` function:
```tsx title="packages/app/src/App.tsx"
-import { createApp } from '@backstage/frontend-app-api';
+import { createApp } from '@backstage/frontend-defaults';
// This plugin was create as a local module in the app
import { somePlugin } from './plugins';
@@ -104,10 +104,10 @@ You can also pass overrides to the features array, for more details, please read
### Using an async features loader
-In case you need to perform asynchronous operations before passing features to the `createApp` function, define a [feature loader](https://backstage.io/docs/reference/frontend-app-api.createappfeatureloader/) object and pass it to the `features` option:
+In case you need to perform asynchronous operations before passing features to the `createApp` function, define a [feature loader](https://backstage.io/docs/reference/frontend-defaults.createappfeatureloader/) object and pass it to the `features` option:
```tsx title="packages/app/src/App.tsx"
-import { createApp } from '@backstage/frontend-app-api';
+import { createApp } from '@backstage/frontend-defaults';
const app = createApp({
features: {
@@ -129,7 +129,7 @@ export default app.createRoot();
In some cases we want to load our configuration from a backend server and to do so, you can pass an callback to the `configLoader` option when calling the `createApp` function, the callback should return a promise of an object with the config object:
```tsx title="packages/app/src/App.tsx"
-import { createApp } from '@backstage/frontend-app-api';
+import { createApp } from '@backstage/frontend-defaults';
import { getConfigFromServer } from './utils';
// Example lazy loading the app configuration
diff --git a/docs/frontend-system/building-apps/08-migrating.md b/docs/frontend-system/building-apps/08-migrating.md
index ddec37a4ea..96e7dd2bd5 100644
--- a/docs/frontend-system/building-apps/08-migrating.md
+++ b/docs/frontend-system/building-apps/08-migrating.md
@@ -18,7 +18,7 @@ The first step in migrating an app is to switch out the `createApp` function for
// highlight-remove-next-line
import { createApp } from '@backstage/app-defaults';
// highlight-add-next-line
-import { createApp } from '@backstage/frontend-app-api';
+import { createApp } from '@backstage/frontend-defaults';
```
This immediate switch will lead to a lot of breakages that we need to fix.
diff --git a/packages/core-compat-api/src/collectLegacyRoutes.test.tsx b/packages/core-compat-api/src/collectLegacyRoutes.test.tsx
index 34d2937425..4c2acd35a6 100644
--- a/packages/core-compat-api/src/collectLegacyRoutes.test.tsx
+++ b/packages/core-compat-api/src/collectLegacyRoutes.test.tsx
@@ -38,8 +38,8 @@ import {
createRouteRef,
useApp,
} from '@backstage/core-plugin-api';
-import { createSpecializedApp } from '@backstage/frontend-app-api';
-import { render, screen } from '@testing-library/react';
+import { screen } from '@testing-library/react';
+import { renderInTestApp } from '@backstage/frontend-test-utils';
describe('collectLegacyRoutes', () => {
it('should collect legacy routes', () => {
@@ -281,7 +281,7 @@ describe('collectLegacyRoutes', () => {
,
);
- render(createSpecializedApp({ features }).createRoot());
+ renderInTestApp(
, { features });
await expect(
screen.findByText('plugins: app, test'),
diff --git a/packages/frontend-app-api/api-report.md b/packages/frontend-app-api/api-report.md
index e8fa7572b7..e102d6bf20 100644
--- a/packages/frontend-app-api/api-report.md
+++ b/packages/frontend-app-api/api-report.md
@@ -4,33 +4,20 @@
```ts
import { ConfigApi } from '@backstage/core-plugin-api';
+import { createApp as createApp_2 } from '@backstage/frontend-defaults';
+import { CreateAppFeatureLoader as CreateAppFeatureLoader_2 } from '@backstage/frontend-defaults';
import { ExternalRouteRef } from '@backstage/frontend-plugin-api';
import { FrontendModule } from '@backstage/frontend-plugin-api';
import { FrontendPlugin } from '@backstage/frontend-plugin-api';
import { JSX as JSX_2 } from 'react';
-import { ReactNode } from 'react';
import { RouteRef } from '@backstage/frontend-plugin-api';
import { SubRouteRef } from '@backstage/frontend-plugin-api';
-// @public (undocumented)
-export function createApp(options?: {
- features?: (FrontendFeature | CreateAppFeatureLoader)[];
- configLoader?: () => Promise<{
- config: ConfigApi;
- }>;
- bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
- loadingComponent?: ReactNode;
-}): {
- createRoot(): JSX_2.Element;
-};
+// @public @deprecated (undocumented)
+export const createApp: typeof createApp_2;
-// @public
-export interface CreateAppFeatureLoader {
- getLoaderName(): string;
- load(options: { config: ConfigApi }): Promise<{
- features: FrontendFeature[];
- }>;
-}
+// @public @deprecated (undocumented)
+export type CreateAppFeatureLoader = CreateAppFeatureLoader_2;
// @public
export type CreateAppRouteBinder = <
diff --git a/packages/frontend-app-api/package.json b/packages/frontend-app-api/package.json
index 66f68bb9e9..52078a4ff7 100644
--- a/packages/frontend-app-api/package.json
+++ b/packages/frontend-app-api/package.json
@@ -34,21 +34,18 @@
"dependencies": {
"@backstage/config": "workspace:^",
"@backstage/core-app-api": "workspace:^",
- "@backstage/core-components": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/errors": "workspace:^",
+ "@backstage/frontend-defaults": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
- "@backstage/plugin-app": "workspace:^",
- "@backstage/theme": "workspace:^",
"@backstage/types": "workspace:^",
"@backstage/version-bridge": "workspace:^",
- "@material-ui/core": "^4.12.4",
- "@material-ui/icons": "^4.11.3",
"@types/react": "^16.13.1 || ^17.0.0 || ^18.0.0",
"lodash": "^4.17.21"
},
"devDependencies": {
"@backstage/cli": "workspace:^",
+ "@backstage/plugin-app": "workspace:^",
"@backstage/test-utils": "workspace:^",
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^15.0.0"
diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx
new file mode 100644
index 0000000000..e98014f273
--- /dev/null
+++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx
@@ -0,0 +1,283 @@
+/*
+ * 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 {
+ AppTreeApi,
+ appTreeApiRef,
+ coreExtensionData,
+ createExtension,
+ createFrontendPlugin,
+ ApiBlueprint,
+ createRouteRef,
+ createExternalRouteRef,
+ createExtensionInput,
+ useRouteRef,
+} from '@backstage/frontend-plugin-api';
+import { screen, render } from '@testing-library/react';
+import { createSpecializedApp } from './createSpecializedApp';
+import { MockConfigApi } from '@backstage/test-utils';
+import React from 'react';
+import {
+ configApiRef,
+ createApiFactory,
+ featureFlagsApiRef,
+} from '@backstage/core-plugin-api';
+import { MemoryRouter } from 'react-router-dom';
+import { ApiProvider } from '@backstage/core-app-api';
+
+describe('createSpecializedApp', () => {
+ it('should render the root app', () => {
+ const app = createSpecializedApp({
+ features: [
+ createFrontendPlugin({
+ id: 'test',
+ extensions: [
+ createExtension({
+ attachTo: { id: 'root', input: 'app' },
+ output: [coreExtensionData.reactElement],
+ factory: () => [coreExtensionData.reactElement(Test
)],
+ }),
+ ],
+ }),
+ ],
+ });
+
+ render(app.createRoot());
+
+ expect(screen.getByText('Test')).toBeInTheDocument();
+ });
+
+ it('should deduplicate features keeping the last received one', () => {
+ const app = createSpecializedApp({
+ features: [
+ createFrontendPlugin({
+ id: 'test',
+ extensions: [
+ createExtension({
+ attachTo: { id: 'root', input: 'app' },
+ output: [coreExtensionData.reactElement],
+ factory: () => [
+ coreExtensionData.reactElement(Test 1
),
+ ],
+ }),
+ ],
+ }),
+ createFrontendPlugin({
+ id: 'test',
+ extensions: [
+ createExtension({
+ attachTo: { id: 'root', input: 'app' },
+ output: [coreExtensionData.reactElement],
+ factory: () => [
+ coreExtensionData.reactElement(Test 2
),
+ ],
+ }),
+ ],
+ }),
+ ],
+ });
+
+ render(app.createRoot());
+
+ expect(screen.getByText('Test 2')).toBeInTheDocument();
+ });
+
+ it('should forward config', () => {
+ const app = createSpecializedApp({
+ config: new MockConfigApi({ test: 'foo' }),
+ features: [
+ createFrontendPlugin({
+ id: 'test',
+ extensions: [
+ createExtension({
+ attachTo: { id: 'root', input: 'app' },
+ output: [coreExtensionData.reactElement],
+ factory: ({ apis }) => [
+ coreExtensionData.reactElement(
+ Test {apis.get(configApiRef)!.getString('test')}
,
+ ),
+ ],
+ }),
+ ],
+ }),
+ ],
+ });
+
+ render(app.createRoot());
+
+ expect(screen.getByText('Test foo')).toBeInTheDocument();
+ });
+
+ it('should support APIs and feature flags', async () => {
+ const flags = new Array<{ name: string; pluginId: string }>();
+ const app = createSpecializedApp({
+ features: [
+ createFrontendPlugin({
+ id: 'test',
+ featureFlags: [{ name: 'a' }, { name: 'b' }],
+ extensions: [
+ createExtension({
+ attachTo: { id: 'root', input: 'app' },
+ output: [coreExtensionData.reactElement],
+ factory: ({ apis }) => [
+ coreExtensionData.reactElement(
+
+ flags:
+ {apis
+ .get(featureFlagsApiRef)!
+ .getRegisteredFlags()
+ .map(f => `${f.pluginId}=${f.name}`)
+ .join(',')}
+
,
+ ),
+ ],
+ }),
+ ApiBlueprint.make({
+ params: {
+ factory: createApiFactory(featureFlagsApiRef, {
+ registerFlag(flag) {
+ flags.push(flag);
+ },
+ getRegisteredFlags() {
+ return flags;
+ },
+ } as typeof featureFlagsApiRef.T),
+ },
+ }),
+ ],
+ }),
+ ],
+ });
+
+ render(app.createRoot());
+
+ expect(screen.getByText('flags:test=a,test=b')).toBeInTheDocument();
+ });
+
+ it('should make the app structure available through the AppTreeApi', async () => {
+ let appTreeApi: AppTreeApi | undefined = undefined;
+
+ createSpecializedApp({
+ features: [
+ createFrontendPlugin({
+ id: 'test',
+ extensions: [
+ createExtension({
+ attachTo: { id: 'root', input: 'app' },
+ output: [coreExtensionData.reactElement],
+ factory: ({ apis }) => {
+ appTreeApi = apis.get(appTreeApiRef);
+ return [coreExtensionData.reactElement()];
+ },
+ }),
+ ],
+ }),
+ ],
+ });
+
+ expect(String(appTreeApi!.getTree().tree.root)).toMatchInlineSnapshot(`
+ "
+ app [
+
+ ]
+ "
+ `);
+ });
+
+ it('should support route bindings', async () => {
+ const routeRef = createRouteRef();
+ const extRouteRef = createExternalRouteRef();
+
+ const pluginA = createFrontendPlugin({
+ id: 'a',
+ externalRoutes: {
+ ext: extRouteRef,
+ },
+ extensions: [
+ createExtension({
+ name: 'parent',
+ attachTo: { id: 'root', input: 'app' },
+ inputs: {
+ children: createExtensionInput([coreExtensionData.reactElement]),
+ },
+ output: [coreExtensionData.reactElement],
+ factory: ({ apis, inputs }) => {
+ return [
+ coreExtensionData.reactElement(
+
+
+ {inputs.children.map(i => (
+
+ {i.get(coreExtensionData.reactElement)}
+
+ ))}
+
+ ,
+ ),
+ ];
+ },
+ }),
+ createExtension({
+ name: 'child',
+ attachTo: { id: 'a/parent', input: 'children' },
+ output: [coreExtensionData.reactElement],
+ factory: () => {
+ const Component = () => {
+ const link = useRouteRef(extRouteRef);
+ return link: {link?.() ?? 'none'}
;
+ };
+ return [coreExtensionData.reactElement()];
+ },
+ }),
+ ],
+ });
+ const pluginB = createFrontendPlugin({
+ id: 'b',
+ routes: {
+ root: routeRef,
+ },
+ extensions: [
+ createExtension({
+ name: 'child',
+ attachTo: { id: 'a/parent', input: 'children' },
+ output: [
+ coreExtensionData.reactElement,
+ coreExtensionData.routePath,
+ coreExtensionData.routeRef,
+ ],
+ factory: () => {
+ return [
+ coreExtensionData.reactElement(),
+ coreExtensionData.routePath('/test'),
+ coreExtensionData.routeRef(routeRef),
+ ];
+ },
+ }),
+ ],
+ });
+
+ render(
+ createSpecializedApp({
+ features: [pluginA, pluginB],
+ bindRoutes({ bind }) {
+ bind(pluginA.externalRoutes, { ext: pluginB.routes.root });
+ },
+ }).createRoot(),
+ );
+
+ expect(screen.getByText('link: /test')).toBeInTheDocument();
+ });
+});
diff --git a/packages/frontend-app-api/src/wiring/createApp.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx
similarity index 74%
rename from packages/frontend-app-api/src/wiring/createApp.tsx
rename to packages/frontend-app-api/src/wiring/createSpecializedApp.tsx
index 591dee9a00..27f1d52822 100644
--- a/packages/frontend-app-api/src/wiring/createApp.tsx
+++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-import React, { JSX, ReactNode } from 'react';
+import React, { JSX } from 'react';
import { ConfigReader } from '@backstage/config';
import {
ApiBlueprint,
@@ -32,7 +32,6 @@ import {
createApiFactory,
routeResolutionApiRef,
} from '@backstage/frontend-plugin-api';
-
import {
AnyApiFactory,
ApiHolder,
@@ -41,15 +40,14 @@ import {
featureFlagsApiRef,
identityApiRef,
} from '@backstage/core-plugin-api';
-import { getAvailableFeatures } from './discovery';
import { ApiFactoryRegistry, ApiResolver } from '@backstage/core-app-api';
// TODO: Get rid of all of these
-// eslint-disable-next-line @backstage/no-relative-monorepo-imports
-import { defaultConfigLoaderSync } from '../../../core-app-api/src/app/defaultConfigLoader';
-// eslint-disable-next-line @backstage/no-relative-monorepo-imports
-import { overrideBaseUrlConfigs } from '../../../core-app-api/src/app/overrideBaseUrlConfigs';
+import {
+ createApp as _createApp,
+ CreateAppFeatureLoader as _CreateAppFeatureLoader,
+} from '@backstage/frontend-defaults';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { resolveExtensionDefinition } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
@@ -71,7 +69,6 @@ import {
} from '../../../frontend-plugin-api/src/wiring/createFrontendModule';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { toInternalExtensionOverrides } from '../../../frontend-plugin-api/src/wiring/createExtensionOverrides';
-import { stringifyError } from '@backstage/errors';
import { getBasePath } from '../routing/getBasePath';
import { Root } from '../extensions/Root';
import { resolveAppTree } from '../tree/resolveAppTree';
@@ -83,7 +80,6 @@ import { ApiRegistry } from '../../../core-app-api/src/apis/system/ApiRegistry';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { AppIdentityProxy } from '../../../core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy';
import { BackstageRouteObject } from '../routing/types';
-import appPlugin from '@backstage/plugin-app';
import { FrontendFeature } from './types';
function deduplicateFeatures(
@@ -109,93 +105,6 @@ function deduplicateFeatures(
.reverse();
}
-/**
- * A source of dynamically loaded frontend features.
- *
- * @public
- */
-export interface CreateAppFeatureLoader {
- /**
- * Returns name of this loader. suitable for showing to users.
- */
- getLoaderName(): string;
-
- /**
- * Loads a number of features dynamically.
- */
- load(options: { config: ConfigApi }): Promise<{
- features: FrontendFeature[];
- }>;
-}
-
-/** @public */
-export function createApp(options?: {
- features?: (FrontendFeature | CreateAppFeatureLoader)[];
- configLoader?: () => Promise<{ config: ConfigApi }>;
- bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
- /**
- * The component to render while loading the app (waiting for config, features, etc)
- *
- * Is the text "Loading..." by default.
- * If set to "null" then no loading fallback component is rendered. *
- */
- loadingComponent?: ReactNode;
-}): {
- createRoot(): JSX.Element;
-} {
- let suspenseFallback = options?.loadingComponent;
- if (suspenseFallback === undefined) {
- suspenseFallback = 'Loading...';
- }
-
- async function appLoader() {
- const config =
- (await options?.configLoader?.().then(c => c.config)) ??
- ConfigReader.fromConfigs(
- overrideBaseUrlConfigs(defaultConfigLoaderSync()),
- );
-
- const discoveredFeatures = getAvailableFeatures(config);
-
- const providedFeatures: FrontendFeature[] = [];
- for (const entry of options?.features ?? []) {
- if ('load' in entry) {
- try {
- const result = await entry.load({ config });
- providedFeatures.push(...result.features);
- } catch (e) {
- throw new Error(
- `Failed to read frontend features from loader '${entry.getLoaderName()}', ${stringifyError(
- e,
- )}`,
- );
- }
- } else {
- providedFeatures.push(entry);
- }
- }
-
- const app = createSpecializedApp({
- config,
- features: [...discoveredFeatures, ...providedFeatures],
- bindRoutes: options?.bindRoutes,
- }).createRoot();
-
- return { default: () => app };
- }
-
- return {
- createRoot() {
- const LazyApp = React.lazy(appLoader);
- return (
-
-
-
- );
- },
- };
-}
-
// Helps delay callers from reaching out to the API before the app tree has been materialized
class AppTreeApiProxy implements AppTreeApi {
#safeToUse: boolean = false;
@@ -267,8 +176,21 @@ class RouteResolutionApiProxy implements RouteResolutionApi {
}
/**
- * Synchronous version of {@link createApp}, expecting all features and
- * config to have been loaded already.
+ * @public
+ * @deprecated Import from `@backstage/frontend-defaults` instead.
+ */
+export const createApp = _createApp;
+
+/**
+ * @public
+ * @deprecated Import from `@backstage/frontend-defaults` instead.
+ */
+export type CreateAppFeatureLoader = _CreateAppFeatureLoader;
+
+/**
+ * Creates an empty app without any default features. This is a low-level API is
+ * intended for use in tests or specialized setups. Typically wou want to use
+ * `createApp` from `@backstage/frontend-defaults` instead.
*
* @public
*/
@@ -277,12 +199,8 @@ export function createSpecializedApp(options?: {
config?: ConfigApi;
bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
}): { createRoot(): JSX.Element } {
- const {
- features: featuresWithoutApp = [],
- config = new ConfigReader({}, 'empty-config'),
- } = options ?? {};
-
- const features = deduplicateFeatures([appPlugin, ...featuresWithoutApp]);
+ const config = options?.config ?? new ConfigReader({}, 'empty-config');
+ const features = deduplicateFeatures(options?.features ?? []);
const tree = resolveAppTree(
'root',
@@ -317,12 +235,6 @@ export function createSpecializedApp(options?: {
],
});
- // Now instantiate the entire tree, which will skip anything that's already been instantiated
- instantiateAppNodeTree(tree.root, apiHolder);
-
- routeResolutionApi.initialize();
- appTreeApi.initialize();
-
const featureFlagApi = apiHolder.get(featureFlagsApiRef);
if (featureFlagApi) {
for (const feature of features) {
@@ -350,6 +262,12 @@ export function createSpecializedApp(options?: {
}
}
+ // Now instantiate the entire tree, which will skip anything that's already been instantiated
+ instantiateAppNodeTree(tree.root, apiHolder);
+
+ routeResolutionApi.initialize();
+ appTreeApi.initialize();
+
const rootEl = tree.root.instance!.getData(coreExtensionData.reactElement);
const AppComponent = () => rootEl;
diff --git a/packages/frontend-app-api/src/wiring/index.ts b/packages/frontend-app-api/src/wiring/index.ts
index bcf623d740..d76055f63b 100644
--- a/packages/frontend-app-api/src/wiring/index.ts
+++ b/packages/frontend-app-api/src/wiring/index.ts
@@ -18,5 +18,5 @@ export {
createApp,
createSpecializedApp,
type CreateAppFeatureLoader,
-} from './createApp';
+} from './createSpecializedApp';
export * from './types';
diff --git a/packages/frontend-defaults/.eslintrc.js b/packages/frontend-defaults/.eslintrc.js
new file mode 100644
index 0000000000..e2a53a6ad2
--- /dev/null
+++ b/packages/frontend-defaults/.eslintrc.js
@@ -0,0 +1 @@
+module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
diff --git a/packages/frontend-defaults/README.md b/packages/frontend-defaults/README.md
new file mode 100644
index 0000000000..2cca17ee85
--- /dev/null
+++ b/packages/frontend-defaults/README.md
@@ -0,0 +1,9 @@
+# @backstage/frontend-defaults
+
+**The [new frontend system](https://backstage.io/docs/frontend-system/) that this package is part of is in alpha, and we do not yet recommend using it for production deployments**
+
+This package provides the high-level APIs used to create Backstage frontend applications with the default setup. For more information, see the [documentation on how to build Backstage frontend applications](https://backstage.io/docs/frontend-system/building-apps/index).
+
+## Documentation
+
+- [Backstage Documentation](https://backstage.io/docs)
diff --git a/packages/frontend-defaults/api-report.md b/packages/frontend-defaults/api-report.md
new file mode 100644
index 0000000000..f1ef153e43
--- /dev/null
+++ b/packages/frontend-defaults/api-report.md
@@ -0,0 +1,31 @@
+## API Report File for "@backstage/frontend-defaults"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+import { ConfigApi } from '@backstage/frontend-plugin-api';
+import { CreateAppRouteBinder } from '@backstage/frontend-app-api';
+import { FrontendFeature } from '@backstage/frontend-app-api';
+import { JSX as JSX_2 } from 'react';
+import { ReactNode } from 'react';
+
+// @public
+export function createApp(options?: {
+ features?: (FrontendFeature | CreateAppFeatureLoader)[];
+ configLoader?: () => Promise<{
+ config: ConfigApi;
+ }>;
+ bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
+ loadingComponent?: ReactNode;
+}): {
+ createRoot(): JSX_2.Element;
+};
+
+// @public
+export interface CreateAppFeatureLoader {
+ getLoaderName(): string;
+ load(options: { config: ConfigApi }): Promise<{
+ features: FrontendFeature[];
+ }>;
+}
+```
diff --git a/packages/frontend-defaults/catalog-info.yaml b/packages/frontend-defaults/catalog-info.yaml
new file mode 100644
index 0000000000..cd0ac9b79a
--- /dev/null
+++ b/packages/frontend-defaults/catalog-info.yaml
@@ -0,0 +1,9 @@
+apiVersion: backstage.io/v1alpha1
+kind: Component
+metadata:
+ name: backstage-frontend-defaults
+ title: '@backstage/frontend-defaults'
+spec:
+ lifecycle: experimental
+ type: backstage-web-library
+ owner: maintainers
diff --git a/packages/frontend-defaults/package.json b/packages/frontend-defaults/package.json
new file mode 100644
index 0000000000..c70bb42424
--- /dev/null
+++ b/packages/frontend-defaults/package.json
@@ -0,0 +1,51 @@
+{
+ "name": "@backstage/frontend-defaults",
+ "version": "0.0.0",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "license": "Apache-2.0",
+ "publishConfig": {
+ "access": "public",
+ "main": "dist/index.esm.js",
+ "types": "dist/index.d.ts"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/backstage/backstage",
+ "directory": "packages/frontend-defaults"
+ },
+ "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-plugin-api": "workspace:^",
+ "@backstage/test-utils": "workspace:^",
+ "@testing-library/jest-dom": "^6.0.0",
+ "@testing-library/react": "^15.0.0"
+ },
+ "files": [
+ "dist"
+ ],
+ "dependencies": {
+ "@backstage/config": "workspace:^",
+ "@backstage/errors": "workspace:^",
+ "@backstage/frontend-app-api": "workspace:^",
+ "@backstage/frontend-plugin-api": "workspace:^",
+ "@backstage/plugin-app": "workspace:^",
+ "@types/react": "^16.13.1 || ^17.0.0 || ^18.0.0"
+ },
+ "peerDependencies": {
+ "react": "^16.13.1 || ^17.0.0 || ^18.0.0"
+ }
+}
diff --git a/packages/frontend-app-api/src/wiring/createApp.test.tsx b/packages/frontend-defaults/src/createApp.test.tsx
similarity index 97%
rename from packages/frontend-app-api/src/wiring/createApp.test.tsx
rename to packages/frontend-defaults/src/createApp.test.tsx
index 1a92a0eb7d..1954554816 100644
--- a/packages/frontend-app-api/src/wiring/createApp.test.tsx
+++ b/packages/frontend-defaults/src/createApp.test.tsx
@@ -19,7 +19,6 @@ import {
appTreeApiRef,
coreExtensionData,
createExtension,
- createExtensionOverrides,
PageBlueprint,
createFrontendPlugin,
ThemeBlueprint,
@@ -176,7 +175,13 @@ describe('createApp', () => {
const app = createApp({
configLoader: async () => ({ config: new MockConfigApi({}) }),
features: [
- appPlugin,
+ appPlugin.withOverrides({
+ extensions: [
+ appPlugin
+ .getExtension('app/root')
+ .override({ disabled: true, factory: orig => orig() }),
+ ],
+ }),
createFrontendPlugin({
id: 'test',
featureFlags: [{ name: 'test-1' }],
@@ -203,18 +208,10 @@ describe('createApp', () => {
}),
],
}),
- createExtensionOverrides({
+ createFrontendPlugin({
+ id: 'other',
featureFlags: [{ name: 'test-2' }],
- extensions: [
- createExtension({
- namespace: 'app',
- name: 'root',
- attachTo: { id: 'app', input: 'root' },
- disabled: true,
- output: [],
- factory: () => [],
- }),
- ],
+ extensions: [],
}),
],
});
@@ -222,7 +219,7 @@ describe('createApp', () => {
await renderWithEffects(app.createRoot());
await expect(
- screen.findByText("Flags: test-1 from 'test', test-2 from ''"),
+ screen.findByText("Flags: test-1 from 'test', test-2 from 'other'"),
).resolves.toBeInTheDocument();
});
diff --git a/packages/frontend-defaults/src/createApp.tsx b/packages/frontend-defaults/src/createApp.tsx
new file mode 100644
index 0000000000..688e01534a
--- /dev/null
+++ b/packages/frontend-defaults/src/createApp.tsx
@@ -0,0 +1,122 @@
+/*
+ * Copyright 2024 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, { JSX, ReactNode } from 'react';
+import { ConfigApi } from '@backstage/frontend-plugin-api';
+import { stringifyError } from '@backstage/errors';
+// eslint-disable-next-line @backstage/no-relative-monorepo-imports
+import { defaultConfigLoaderSync } from '../../core-app-api/src/app/defaultConfigLoader';
+// eslint-disable-next-line @backstage/no-relative-monorepo-imports
+import { overrideBaseUrlConfigs } from '../../core-app-api/src/app/overrideBaseUrlConfigs';
+import { getAvailableFeatures } from './discovery';
+import { ConfigReader } from '@backstage/config';
+import appPlugin from '@backstage/plugin-app';
+import {
+ CreateAppRouteBinder,
+ FrontendFeature,
+ createSpecializedApp,
+} from '@backstage/frontend-app-api';
+
+/**
+ * A source of dynamically loaded frontend features.
+ *
+ * @public
+ */
+export interface CreateAppFeatureLoader {
+ /**
+ * Returns name of this loader. suitable for showing to users.
+ */
+ getLoaderName(): string;
+
+ /**
+ * Loads a number of features dynamically.
+ */
+ load(options: { config: ConfigApi }): Promise<{
+ features: FrontendFeature[];
+ }>;
+}
+
+/**
+ * Creates a new Backstage frontend app instance. See https://backstage.io/docs/frontend-system/building-apps/index
+ *
+ * @public
+ */
+export function createApp(options?: {
+ features?: (FrontendFeature | CreateAppFeatureLoader)[];
+ configLoader?: () => Promise<{ config: ConfigApi }>;
+ bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
+ /**
+ * The component to render while loading the app (waiting for config, features, etc)
+ *
+ * Is the text "Loading..." by default.
+ * If set to "null" then no loading fallback component is rendered. *
+ */
+ loadingComponent?: ReactNode;
+}): {
+ createRoot(): JSX.Element;
+} {
+ let suspenseFallback = options?.loadingComponent;
+ if (suspenseFallback === undefined) {
+ suspenseFallback = 'Loading...';
+ }
+
+ async function appLoader() {
+ const config =
+ (await options?.configLoader?.().then(c => c.config)) ??
+ ConfigReader.fromConfigs(
+ overrideBaseUrlConfigs(defaultConfigLoaderSync()),
+ );
+
+ const discoveredFeatures = getAvailableFeatures(config);
+
+ const providedFeatures: FrontendFeature[] = [];
+ for (const entry of options?.features ?? []) {
+ if ('load' in entry) {
+ try {
+ const result = await entry.load({ config });
+ providedFeatures.push(...result.features);
+ } catch (e) {
+ throw new Error(
+ `Failed to read frontend features from loader '${entry.getLoaderName()}', ${stringifyError(
+ e,
+ )}`,
+ );
+ }
+ } else {
+ providedFeatures.push(entry);
+ }
+ }
+
+ const app = createSpecializedApp({
+ config,
+ features: [appPlugin, ...discoveredFeatures, ...providedFeatures],
+ bindRoutes: options?.bindRoutes,
+ }).createRoot();
+
+ return { default: () => app };
+ }
+
+ return {
+ createRoot() {
+ const LazyApp = React.lazy(appLoader);
+ return (
+
+
+
+ );
+ },
+ };
+}
diff --git a/packages/frontend-app-api/src/wiring/discovery.test.ts b/packages/frontend-defaults/src/discovery.test.ts
similarity index 100%
rename from packages/frontend-app-api/src/wiring/discovery.test.ts
rename to packages/frontend-defaults/src/discovery.test.ts
diff --git a/packages/frontend-app-api/src/wiring/discovery.ts b/packages/frontend-defaults/src/discovery.ts
similarity index 99%
rename from packages/frontend-app-api/src/wiring/discovery.ts
rename to packages/frontend-defaults/src/discovery.ts
index 6f9d2e2d16..e04960dc48 100644
--- a/packages/frontend-app-api/src/wiring/discovery.ts
+++ b/packages/frontend-defaults/src/discovery.ts
@@ -53,7 +53,7 @@ function readPackageDetectionConfig(config: Config) {
}
/**
- * @public
+ * @internal
*/
export function getAvailableFeatures(config: Config): FrontendFeature[] {
const discovered = (
diff --git a/packages/frontend-defaults/src/index.ts b/packages/frontend-defaults/src/index.ts
new file mode 100644
index 0000000000..913cd3934b
--- /dev/null
+++ b/packages/frontend-defaults/src/index.ts
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2024 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.
+ */
+
+/**
+ * APIs for creating Backstage apps with a default setup.
+ *
+ * @packageDocumentation
+ */
+
+export { createApp, type CreateAppFeatureLoader } from './createApp';
diff --git a/packages/frontend-defaults/src/setupTests.ts b/packages/frontend-defaults/src/setupTests.ts
new file mode 100644
index 0000000000..91af6695ac
--- /dev/null
+++ b/packages/frontend-defaults/src/setupTests.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2024 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/scripts/verify-local-dependencies.js b/scripts/verify-local-dependencies.js
index d3d76396e5..045abcff57 100755
--- a/scripts/verify-local-dependencies.js
+++ b/scripts/verify-local-dependencies.js
@@ -60,6 +60,7 @@ const roleRules = [
targetRole: 'frontend-plugin',
except: [
// TODO(freben): Address these
+ '@backstage/frontend-defaults',
'@backstage/frontend-app-api',
'@backstage/frontend-test-utils',
'@backstage/plugin-api-docs',
@@ -75,6 +76,7 @@ const roleRules = [
'@backstage/app-defaults',
'@backstage/core-compat-api',
'@backstage/dev-utils',
+ '@backstage/frontend-defaults',
'@backstage/frontend-app-api',
'@backstage/frontend-test-utils',
'@backstage/test-utils',
diff --git a/yarn.lock b/yarn.lock
index 53620b2868..22062e72bf 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4426,17 +4426,14 @@ __metadata:
"@backstage/cli": "workspace:^"
"@backstage/config": "workspace:^"
"@backstage/core-app-api": "workspace:^"
- "@backstage/core-components": "workspace:^"
"@backstage/core-plugin-api": "workspace:^"
"@backstage/errors": "workspace:^"
+ "@backstage/frontend-defaults": "workspace:^"
"@backstage/frontend-plugin-api": "workspace:^"
"@backstage/plugin-app": "workspace:^"
"@backstage/test-utils": "workspace:^"
- "@backstage/theme": "workspace:^"
"@backstage/types": "workspace:^"
"@backstage/version-bridge": "workspace:^"
- "@material-ui/core": ^4.12.4
- "@material-ui/icons": ^4.11.3
"@testing-library/jest-dom": ^6.0.0
"@testing-library/react": ^15.0.0
"@types/react": ^16.13.1 || ^17.0.0 || ^18.0.0
@@ -4447,6 +4444,26 @@ __metadata:
languageName: unknown
linkType: soft
+"@backstage/frontend-defaults@workspace:^, @backstage/frontend-defaults@workspace:packages/frontend-defaults":
+ version: 0.0.0-use.local
+ resolution: "@backstage/frontend-defaults@workspace:packages/frontend-defaults"
+ dependencies:
+ "@backstage/cli": "workspace:^"
+ "@backstage/config": "workspace:^"
+ "@backstage/core-plugin-api": "workspace:^"
+ "@backstage/errors": "workspace:^"
+ "@backstage/frontend-app-api": "workspace:^"
+ "@backstage/frontend-plugin-api": "workspace:^"
+ "@backstage/plugin-app": "workspace:^"
+ "@backstage/test-utils": "workspace:^"
+ "@testing-library/jest-dom": ^6.0.0
+ "@testing-library/react": ^15.0.0
+ "@types/react": ^16.13.1 || ^17.0.0 || ^18.0.0
+ peerDependencies:
+ react: ^16.13.1 || ^17.0.0 || ^18.0.0
+ languageName: unknown
+ linkType: soft
+
"@backstage/frontend-plugin-api@npm:^0.7.0":
version: 0.7.0
resolution: "@backstage/frontend-plugin-api@npm:0.7.0"