frontend-app-api: add prepareSpecializedApp two-phase app wiring

This adds a new prepare/finalize app wiring flow that renders sign-in first and finalizes the full app after identity capture, while keeping createSpecializedApp as a deprecated wrapper. It also updates frontend-defaults/createApp to use the same flow and includes test and API report updates.

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-03-05 15:40:42 +01:00
parent e9eb585978
commit 925e0895b3
8 changed files with 1429 additions and 751 deletions
@@ -0,0 +1,6 @@
"@backstage/frontend-app-api": patch
"@backstage/frontend-defaults": patch
---
Adds `prepareSpecializedApp` as a new two-phase app wiring API for rendering a sign-in page before full app finalization. The existing `createSpecializedApp` API is now deprecated and backed by `prepareSpecializedApp().finalize()`, while `createApp` has been updated to use the same prepare/finalize flow.
+73 -9
View File
@@ -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<ExtensionDataValue<any, any>>;
// @public
export type FinalizedSpecializedApp = {
element: JSX.Element;
sessionState: SpecializedAppSessionState;
tree: AppTree;
errors?: AppError[];
};
// @public
export type FrontendPluginInfoResolver = (ctx: {
packageJson(): Promise<JsonObject | undefined>;
@@ -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';
};
```
@@ -0,0 +1,114 @@
/*
* 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<string, AnyApiFactory>();
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);
}
}
get<T>(
api: ApiRef<T>,
): ApiFactory<T, T, { [name: string]: unknown }> | undefined {
const factory = this.factories.get(api.id);
if (!factory) {
return undefined;
}
return factory as ApiFactory<T, T, { [name: string]: unknown }>;
}
getAllApis() {
const refs = new Set<AnyApiRef>();
for (const factory of this.factories.values()) {
refs.add(factory.api);
}
return refs;
}
}
export class FrontendApiResolver implements ApiHolder {
private readonly apis = new Map<string, unknown>();
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<T>(ref: ApiRef<T>): T | undefined {
return this.load(ref);
}
private load<T>(ref: ApiRef<T>, loading: AnyApiRef[] = []): T | undefined {
const existing = this.apis.get(ref.id);
if (existing) {
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) {
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;
}
}
File diff suppressed because it is too large Load Diff
@@ -14,209 +14,28 @@
* limitations under the License.
*/
import { ConfigReader } from '@backstage/config';
import {
AnyApiFactory,
ApiBlueprint,
ApiHolder,
AppNode,
AppTree,
AppTreeApi,
appTreeApiRef,
AnyRouteRefParams,
ConfigApi,
configApiRef,
createApiFactory,
ExternalRouteRef,
featureFlagsApiRef,
ExtensionFactoryMiddleware,
FrontendFeature,
identityApiRef,
RouteFunc,
RouteRef,
RouteResolutionApi,
routeResolutionApiRef,
SubRouteRef,
} from '@backstage/frontend-plugin-api';
import { ExtensionFactoryMiddleware } from './types';
import { ApiFactoryRegistry, ApiResolver } from '@backstage/core-app-api';
import {
createExtensionDataContainer,
OpaqueApiRef,
OpaqueFrontendPlugin,
} from '@internal/frontend';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import {
resolveExtensionDefinition,
toInternalExtension,
} from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
import {
extractRouteInfoFromAppNode,
RouteInfo,
} from '../routing/extractRouteInfoFromAppNode';
import { CreateAppRouteBinder } from '../routing';
import { RouteResolver } from '../routing/RouteResolver';
import { resolveRouteBindings } from '../routing/resolveRouteBindings';
import { collectRouteIds } from '../routing/collectRouteIds';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { FrontendPluginInfoResolver } from './createPluginInfoAttacher';
import {
toInternalFrontendModule,
isInternalFrontendModule,
} from '../../../frontend-plugin-api/src/wiring/createFrontendModule';
import { getBasePath } from '../routing/getBasePath';
import { Root } from '../extensions/Root';
import { resolveAppTree } from '../tree/resolveAppTree';
import { resolveAppNodeSpecs } from '../tree/resolveAppNodeSpecs';
import { readAppExtensionsConfig } from '../tree/readAppExtensionsConfig';
import { instantiateAppNodeTree } from '../tree/instantiateAppNodeTree';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
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 { matchRoutes } from 'react-router-dom';
import {
createPluginInfoAttacher,
FrontendPluginInfoResolver,
} from './createPluginInfoAttacher';
import { createRouteAliasResolver } from '../routing/RouteAliasResolver';
import {
AppError,
createErrorCollector,
ErrorCollector,
} from './createErrorCollector';
createSessionStateFromApis,
CreateSpecializedAppInternalOptions,
FinalizedSpecializedApp,
prepareSpecializedApp,
} from './prepareSpecializedApp';
function deduplicateFeatures(
allFeatures: FrontendFeature[],
): FrontendFeature[] {
// 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<string>();
return features
.reverse()
.filter(feature => {
if (!OpaqueFrontendPlugin.isType(feature)) {
return true;
}
if (seenIds.has(feature.id)) {
return false;
}
seenIds.add(feature.id);
return true;
})
.reverse();
}
// Helps delay callers from reaching out to the API before the app tree has been materialized
class AppTreeApiProxy implements AppTreeApi {
#routeInfo?: RouteInfo;
private readonly tree: AppTree;
private readonly appBasePath: string;
constructor(tree: AppTree, appBasePath: string) {
this.tree = tree;
this.appBasePath = appBasePath;
}
private checkIfInitialized() {
if (!this.#routeInfo) {
throw new Error(
`You can't access the AppTreeApi during initialization of the app tree. Please move occurrences of this out of the initialization of the factory`,
);
}
}
getTree() {
this.checkIfInitialized();
return { tree: this.tree };
}
getNodesByRoutePath(routePath: string): { nodes: AppNode[] } {
this.checkIfInitialized();
let path = routePath;
if (path.startsWith(this.appBasePath)) {
path = path.slice(this.appBasePath.length);
}
const matchedRoutes = matchRoutes(this.#routeInfo!.routeObjects, path);
const matchedAppNodes =
matchedRoutes
?.filter(routeObj => !!routeObj.route.appNode)
.map(routeObj => routeObj.route.appNode!) || [];
return { nodes: matchedAppNodes };
}
initialize(routeInfo: RouteInfo) {
this.#routeInfo = routeInfo;
}
}
// Helps delay callers from reaching out to the API before the app tree has been materialized
class RouteResolutionApiProxy implements RouteResolutionApi {
#delegate: RouteResolutionApi | undefined;
#routeObjects: BackstageRouteObject[] | undefined;
private readonly routeBindings: Map<ExternalRouteRef, RouteRef | SubRouteRef>;
private readonly appBasePath: string;
constructor(
routeBindings: Map<ExternalRouteRef, RouteRef | SubRouteRef>,
appBasePath: string,
) {
this.routeBindings = routeBindings;
this.appBasePath = appBasePath;
}
resolve<TParams extends AnyRouteRefParams>(
anyRouteRef:
| RouteRef<TParams>
| SubRouteRef<TParams>
| ExternalRouteRef<TParams>,
options?: { sourcePath?: string },
): RouteFunc<TParams> | undefined {
if (!this.#delegate) {
throw new Error(
`You can't access the RouteResolver during initialization of the app tree. Please move occurrences of this out of the initialization of the factory`,
);
}
return this.#delegate.resolve(anyRouteRef, options);
}
initialize(
routeInfo: RouteInfo,
routeRefsById: Map<string, RouteRef | SubRouteRef>,
) {
this.#delegate = new RouteResolver(
routeInfo.routePaths,
routeInfo.routeParents,
routeInfo.routeObjects,
this.routeBindings,
this.appBasePath,
routeInfo.routeAliasResolver,
routeRefsById,
);
this.#routeObjects = routeInfo.routeObjects;
return routeInfo;
}
getRouteObjects() {
return this.#routeObjects;
}
}
export type { CreateSpecializedAppInternalOptions };
/**
* Options for {@link createSpecializedApp}.
*
* @deprecated Use `PrepareSpecializedAppOptions` with `prepareSpecializedApp` instead.
*
* @public
*/
export type CreateSpecializedAppOptions = {
@@ -245,12 +64,7 @@ export type CreateSpecializedAppOptions = {
*/
advanced?: {
/**
* A replacement API holder implementation to use.
*
* By default, a new API holder will be constructed automatically based on
* the other inputs. If you pass in a custom one here, none of that
* automation will take place - so you will have to take care to supply all
* those APIs yourself.
* APIs to expose to the app during startup.
*/
apis?: ApiHolder;
@@ -272,257 +86,29 @@ export type CreateSpecializedAppOptions = {
};
};
// Internal options type, not exported in the public API
export interface CreateSpecializedAppInternalOptions
extends CreateSpecializedAppOptions {
__internal?: {
apiFactoryOverrides?: AnyApiFactory[];
};
}
/**
* Creates an empty app without any default features. This is a low-level API is
* intended for use in tests or specialized setups. Typically you want to use
* `createApp` from `@backstage/frontend-defaults` instead.
*
* @deprecated Use `prepareSpecializedApp` instead.
*
* @public
*/
export function createSpecializedApp(options?: CreateSpecializedAppOptions): {
apis: ApiHolder;
tree: AppTree;
errors?: AppError[];
} {
const internalOptions = options as CreateSpecializedAppInternalOptions;
const config = options?.config ?? new ConfigReader({}, 'empty-config');
const features = deduplicateFeatures(options?.features ?? []).map(
createPluginInfoAttacher(config, options?.advanced?.pluginInfoResolver),
);
export function createSpecializedApp(
options?: CreateSpecializedAppOptions,
): FinalizedSpecializedApp {
const sessionState = options?.advanced?.apis
? createSessionStateFromApis(options.advanced.apis)
: undefined;
const collector = createErrorCollector();
const tree = resolveAppTree(
'root',
resolveAppNodeSpecs({
features,
builtinExtensions: [
resolveExtensionDefinition(Root, { namespace: 'root' }),
],
parameters: readAppExtensionsConfig(config),
forbidden: new Set(['root']),
collector,
}),
collector,
);
const factories = createApiFactories({ tree, collector });
const appBasePath = getBasePath(config);
const appTreeApi = new AppTreeApiProxy(tree, appBasePath);
const routeRefsById = collectRouteIds(features, collector);
const routeResolutionApi = new RouteResolutionApiProxy(
resolveRouteBindings(options?.bindRoutes, config, routeRefsById, collector),
appBasePath,
);
const appIdentityProxy = new AppIdentityProxy();
const apis =
options?.advanced?.apis ??
createApiHolder({
factories,
staticFactories: [
createApiFactory(appTreeApiRef, appTreeApi),
createApiFactory(configApiRef, config),
createApiFactory(routeResolutionApiRef, routeResolutionApi),
createApiFactory(identityApiRef, appIdentityProxy),
...(internalOptions?.__internal?.apiFactoryOverrides ?? []),
],
});
const featureFlagApi = apis.get(featureFlagsApiRef);
if (featureFlagApi) {
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,
}),
);
}
}
}
// Now instantiate the entire tree, which will skip anything that's already been instantiated
instantiateAppNodeTree(
tree.root,
apis,
collector,
mergeExtensionFactoryMiddleware(
options?.advanced?.extensionFactoryMiddleware,
),
);
const routeInfo = extractRouteInfoFromAppNode(
tree.root,
createRouteAliasResolver(routeRefsById),
);
routeResolutionApi.initialize(routeInfo, routeRefsById.routes);
appTreeApi.initialize(routeInfo);
return { apis, tree, errors: collector.collectErrors() };
}
function createApiFactories(options: {
tree: AppTree;
collector: ErrorCollector;
}): AnyApiFactory[] {
const emptyApiHolder = ApiRegistry.from([]);
const factoriesById = new Map<
string,
{ pluginId: string; factory: AnyApiFactory }
>();
for (const apiNode of options.tree.root.edges.attachments.get('apis') ?? []) {
if (!instantiateAppNodeTree(apiNode, emptyApiHolder, options.collector)) {
continue;
}
const apiFactory = apiNode.instance?.getData(ApiBlueprint.dataRefs.factory);
if (apiFactory) {
const apiRefId = apiFactory.api.id;
const ownerId = getApiOwnerId(apiFactory.api);
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 inferred from the explicit pluginId or
// legacy plugin-prefixed 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,
factory: apiFactory,
});
}
continue;
}
factoriesById.set(apiRefId, { pluginId, 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 Array.from(factoriesById.values(), entry => entry.factory);
}
// 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(apiRef: { id: string }): string {
if (OpaqueApiRef.isType(apiRef)) {
const { pluginId } = OpaqueApiRef.toInternal(apiRef);
if (pluginId) {
return pluginId;
}
}
const apiRefId = apiRef.id;
const [prefix, ...rest] = apiRefId.split('.');
if (!prefix) {
return apiRefId;
}
if (prefix === 'plugin' && rest[0]) {
return rest[0];
}
return prefix;
}
function createApiHolder(options: {
factories: AnyApiFactory[];
staticFactories: AnyApiFactory[];
}): ApiHolder {
const factoryRegistry = new ApiFactoryRegistry();
for (const factory of options.factories.slice().reverse()) {
factoryRegistry.register('default', factory);
}
for (const factory of options.staticFactories) {
factoryRegistry.register('static', factory);
}
ApiResolver.validateFactories(factoryRegistry, factoryRegistry.getAllApis());
return new ApiResolver(factoryRegistry);
}
function mergeExtensionFactoryMiddleware(
middlewares?: ExtensionFactoryMiddleware | ExtensionFactoryMiddleware[],
): ExtensionFactoryMiddleware | undefined {
if (!middlewares) {
return undefined;
}
if (!Array.isArray(middlewares)) {
return middlewares;
}
if (middlewares.length <= 1) {
return middlewares[0];
}
return middlewares.reduce((prev, next) => {
if (!prev || !next) {
return prev ?? next;
}
return (orig, ctx) => {
const internalExt = toInternalExtension(ctx.node.spec.extension);
if (internalExt.version !== 'v2') {
return orig();
}
return next(ctxOverrides => {
return createExtensionDataContainer(
prev(orig, {
node: ctx.node,
apis: ctx.apis,
config: ctxOverrides?.config ?? ctx.config,
}),
'extension factory middleware',
);
}, ctx);
};
});
return prepareSpecializedApp({
features: options?.features,
config: options?.config,
bindRoutes: options?.bindRoutes,
advanced: {
...options?.advanced,
sessionState,
},
}).finalize();
}
@@ -15,6 +15,8 @@
*/
export {
prepareSpecializedApp,
type PreparedSpecializedApp,
createSpecializedApp,
type CreateSpecializedAppOptions,
} from './createSpecializedApp';
+107 -14
View File
@@ -16,8 +16,11 @@
import {
AppTreeApi,
ApiBlueprint,
appTreeApiRef,
coreExtensionData,
createApiRef,
createExtensionDataRef,
createExtension,
PageBlueprint,
createFrontendPlugin,
@@ -30,9 +33,17 @@ import { ThemeBlueprint } from '@backstage/plugin-app-react';
import { screen, waitFor } from '@testing-library/react';
import { createApp } from './createApp';
import { mockApis, renderWithEffects } from '@backstage/test-utils';
import { featureFlagsApiRef, useApi } from '@backstage/core-plugin-api';
import {
featureFlagsApiRef,
IdentityApi,
useApi,
} from '@backstage/core-plugin-api';
import { default as appPluginOriginal } from '@backstage/plugin-app';
import { useState, useEffect } from 'react';
import { ComponentType, useState, useEffect } from 'react';
const signInPageComponentDataRef = createExtensionDataRef<
ComponentType<{ onSignInSuccess(identity: IdentityApi): void }>
>().with({ id: 'core.sign-in-page.component' });
describe('createApp', () => {
const appPlugin = appPluginOriginal.withOverrides({
@@ -84,6 +95,98 @@ describe('createApp', () => {
await expect(screen.findByText('Derp')).resolves.toBeInTheDocument();
});
it('should provide app APIs to sign-in pages before finalization', async () => {
const signInApiRef = createApiRef<{ value: string }>({
id: 'test.sign-in-api',
});
const app = createApp({
advanced: {
configLoader: async () => ({ config: mockApis.config() }),
},
features: [
appPluginOriginal,
createFrontendPlugin({
pluginId: 'test',
extensions: [
ApiBlueprint.make({
params: defineParams =>
defineParams({
api: signInApiRef,
deps: {},
factory: () => ({ value: 'ok' }),
}),
}),
],
}),
createFrontendModule({
pluginId: 'app',
extensions: [
appPluginOriginal.getExtension('sign-in-page:app').override({
factory: () => {
const SignInPage = () => {
const api = useApi(signInApiRef);
return <div>Sign In API: {api.value}</div>;
};
return [signInPageComponentDataRef(SignInPage)];
},
}),
],
}),
],
});
await renderWithEffects(app.createRoot());
await expect(
screen.findByText('Sign In API: ok'),
).resolves.toBeInTheDocument();
});
it('should provide feature flags to sign-in pages before finalization', async () => {
const app = createApp({
advanced: {
configLoader: async () => ({ config: mockApis.config() }),
},
features: [
appPluginOriginal,
createFrontendPlugin({
pluginId: 'test',
featureFlags: [{ name: 'test-flag' }],
extensions: [],
}),
createFrontendModule({
pluginId: 'app',
extensions: [
appPluginOriginal.getExtension('sign-in-page:app').override({
factory: () => {
const SignInPage = () => {
const flagsApi = useApi(featureFlagsApiRef);
return (
<div>
Flags:{' '}
{flagsApi
.getRegisteredFlags()
.map(flag => flag.name)
.join(', ')}
</div>
);
};
return [signInPageComponentDataRef(SignInPage)];
},
}),
],
}),
],
});
await renderWithEffects(app.createRoot());
await expect(
screen.findByText('Flags: test-flag'),
).resolves.toBeInTheDocument();
});
it('should deduplicate features keeping the last received one', async () => {
const duplicatedFeatureId = 'test';
const app = createApp({
@@ -283,9 +386,7 @@ describe('createApp', () => {
).resolves.toBeInTheDocument();
});
it('should warn about unknown extension config', async () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
it('should allow unknown extension config if the flag is set', async () => {
const app = createApp({
features: [
appPlugin,
@@ -302,6 +403,7 @@ describe('createApp', () => {
}),
],
advanced: {
allowUnknownExtensionConfig: true,
configLoader: async () => ({
config: mockApis.config({
data: {
@@ -317,12 +419,6 @@ describe('createApp', () => {
await renderWithEffects(app.createRoot());
await expect(screen.findByText('Derp')).resolves.toBeInTheDocument();
expect(warnSpy).toHaveBeenCalledWith('App startup encountered warnings:');
expect(warnSpy).toHaveBeenCalledWith(
'INVALID_EXTENSION_CONFIG_KEY: Extension unknown:lols/wut does not exist',
);
warnSpy.mockRestore();
});
it('should make the app structure available through the AppTreeApi', async () => {
let appTreeApi: AppTreeApi | undefined = undefined;
@@ -428,9 +524,6 @@ describe('createApp', () => {
<app-root-element:app/alert-display out=[core.reactElement] />
<app-root-element:app/dialog-display out=[core.reactElement] />
]
signInPage [
<sign-in-page:app />
]
</app/root>
]
</app>
+80 -12
View File
@@ -14,10 +14,11 @@
* limitations under the License.
*/
import { JSX, lazy, ReactNode, Suspense } from 'react';
import { JSX, lazy, ReactNode, Suspense, useEffect, useState } from 'react';
import {
ConfigApi,
coreExtensionData,
ExtensionFactoryMiddleware,
FrontendFeature,
FrontendFeatureLoader,
} from '@backstage/frontend-plugin-api';
@@ -29,8 +30,8 @@ import { overrideBaseUrlConfigs } from '../../core-app-api/src/app/overrideBaseU
import { ConfigReader } from '@backstage/config';
import {
CreateAppRouteBinder,
createSpecializedApp,
ExtensionFactoryMiddleware,
prepareSpecializedApp,
PreparedSpecializedApp,
FrontendPluginInfoResolver,
} from '@backstage/frontend-app-api';
import appPlugin from '@backstage/plugin-app';
@@ -58,6 +59,17 @@ export interface CreateAppOptions {
* Advanced, more rarely used options.
*/
advanced?: {
/**
* If set to true, the system will silently accept and move on if
* encountering config for extensions that do not exist. The default is to
* reject such config to help catch simple mistakes.
*
* This flag can be useful in some scenarios where you have a dynamic set of
* extensions enabled at different times, but also increases the risk of
* accidentally missing e.g. simple typos in your config.
*/
allowUnknownExtensionConfig?: boolean;
/**
* Sets a custom config loader, replacing the builtin one.
*
@@ -119,23 +131,22 @@ export function createApp(options?: CreateAppOptions): {
features: [...discoveredFeaturesAndLoaders, ...(options?.features ?? [])],
});
const app = createSpecializedApp({
const preparedApp = prepareSpecializedApp({
features: [appPlugin, ...loadedFeatures],
config,
bindRoutes: options?.bindRoutes,
advanced: options?.advanced,
});
const errorPage = maybeCreateErrorPage(app);
if (errorPage) {
return { default: () => errorPage };
if (preparedApp.getSignIn()) {
return {
default: () => <PreparedAppRoot preparedApp={preparedApp} />,
};
}
const rootEl = app.tree.root.instance!.getData(
coreExtensionData.reactElement,
);
return { default: () => rootEl };
return {
default: () => renderFinalizedApp(preparedApp.finalize()),
};
}
const LazyApp = lazy(appLoader);
@@ -150,3 +161,60 @@ export function createApp(options?: CreateAppOptions): {
},
};
}
function PreparedAppRoot(props: {
preparedApp: PreparedSpecializedApp;
}): JSX.Element {
const signIn = props.preparedApp.getSignIn();
const [finalizeError, setFinalizeError] = useState<Error>();
const [finalizedApp, setFinalizedApp] = useState(() => {
if (!signIn) {
return props.preparedApp.finalize();
}
return undefined;
});
useEffect(() => {
let cancelled = false;
if (signIn) {
void signIn.complete
.then(async () => {
if (cancelled) {
return;
}
setFinalizedApp(props.preparedApp.finalize());
})
.catch(error => {
if (cancelled) {
return;
}
setFinalizeError(error);
});
}
return () => {
cancelled = true;
};
}, [props.preparedApp, signIn]);
if (finalizeError) {
throw finalizeError;
}
if (!finalizedApp) {
return signIn!.element;
}
return renderFinalizedApp(finalizedApp);
}
function renderFinalizedApp(
app: ReturnType<PreparedSpecializedApp['finalize']>,
) {
const errorPage = maybeCreateErrorPage(app);
if (errorPage) {
return errorPage;
}
return app.tree.root.instance!.getData(coreExtensionData.reactElement)!;
}