From 925e0895b32ae94f2b331abb4ed559be5353be7e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 5 Mar 2026 15:40:42 +0100 Subject: [PATCH] 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 Made-with: Cursor --- .../prepare-specialized-app-signin-flow.md | 6 + packages/frontend-app-api/report.api.md | 82 +- .../src/wiring/FrontendApiRegistry.ts | 114 ++ .../src/wiring/createSpecializedApp.test.tsx | 1293 +++++++++++++---- .../src/wiring/createSpecializedApp.tsx | 470 +----- packages/frontend-app-api/src/wiring/index.ts | 2 + .../frontend-defaults/src/createApp.test.tsx | 121 +- packages/frontend-defaults/src/createApp.tsx | 92 +- 8 files changed, 1429 insertions(+), 751 deletions(-) create mode 100644 .changeset/prepare-specialized-app-signin-flow.md create mode 100644 packages/frontend-app-api/src/wiring/FrontendApiRegistry.ts diff --git a/.changeset/prepare-specialized-app-signin-flow.md b/.changeset/prepare-specialized-app-signin-flow.md new file mode 100644 index 0000000000..baae7c6d00 --- /dev/null +++ b/.changeset/prepare-specialized-app-signin-flow.md @@ -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. 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/wiring/FrontendApiRegistry.ts b/packages/frontend-app-api/src/wiring/FrontendApiRegistry.ts new file mode 100644 index 0000000000..1705aa9b25 --- /dev/null +++ b/packages/frontend-app-api/src/wiring/FrontendApiRegistry.ts @@ -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(); + + 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( + 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); + } + + private load(ref: ApiRef, 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; + } +} diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx index 55159c7e69..cd1356d4cb 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx @@ -28,17 +28,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 +67,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(
App Layout
)], + }), + ], + }); + it('should render the root app', () => { const app = createSpecializedApp({ features: [makeAppPlugin()], }); - render(app.tree.root.instance!.getData(coreExtensionData.reactElement)); + render(app.element); expect(screen.getByText('Test')).toBeInTheDocument(); }); @@ -68,7 +108,7 @@ describe('createSpecializedApp', () => { features: [makeAppPlugin('Test 1'), makeAppPlugin('Test 2')], }); - render(app.tree.root.instance!.getData(coreExtensionData.reactElement)); + render(app.element); expect(screen.getByText('Test 2')).toBeInTheDocument(); }); @@ -94,11 +134,41 @@ describe('createSpecializedApp', () => { ], }); - render(app.tree.root.instance!.getData(coreExtensionData.reactElement)); + render(app.element); expect(screen.getByText('Test foo')).toBeInTheDocument(); }); + it('should warn and ignore bootstrap-visible if predicates', () => { + const app = createSpecializedApp({ + features: [ + createFrontendPlugin({ + pluginId: 'test', + extensions: [ + createExtension({ + name: 'conditional-root', + attachTo: { id: 'root', input: 'app' }, + if: { featureFlags: { $contains: 'test-flag' } }, + output: [coreExtensionData.reactElement], + factory: () => [ + coreExtensionData.reactElement(
Ignored Predicate
), + ], + }), + ], + }), + ], + }); + + render(app.element); + + expect(screen.getByText('Ignored Predicate')).toBeInTheDocument(); + expect(app.errors).toEqual([ + expect.objectContaining({ + code: 'EXTENSION_BOOTSTRAP_PREDICATE_IGNORED', + }), + ]); + }); + it('should support APIs and feature flags', async () => { const flags = new Array<{ name: string; pluginId: string }>(); const app = createSpecializedApp({ @@ -147,7 +217,7 @@ describe('createSpecializedApp', () => { ], }); - render(app.tree.root.instance!.getData(coreExtensionData.reactElement)); + render(app.element); expect(screen.getByText('flags:test=a,test=b')).toBeInTheDocument(); @@ -155,96 +225,7 @@ describe('createSpecializedApp', () => { { name: 'a', pluginId: 'test' }, { name: 'b', pluginId: 'test', description: 'Feature B description' }, ]); - - expect(app.apis).toMatchInlineSnapshot(` - ApiResolver { - "apis": Map { - "core.featureflags" => { - "getRegisteredFlags": [Function], - "registerFlag": [Function], - }, - }, - "factories": ApiFactoryRegistry { - "factories": Map { - "core.featureflags" => { - "factory": { - "api": { - "$$type": "@backstage/ApiRef", - "T": null, - "id": "core.featureflags", - "pluginId": "app", - "toString": [Function], - "version": "v1", - }, - "deps": {}, - "factory": [Function], - }, - "priority": 10, - }, - "core.app-tree" => { - "factory": { - "api": { - "$$type": "@backstage/ApiRef", - "T": null, - "id": "core.app-tree", - "pluginId": "app", - "toString": [Function], - "version": "v1", - }, - "deps": {}, - "factory": [Function], - }, - "priority": 100, - }, - "core.config" => { - "factory": { - "api": { - "$$type": "@backstage/ApiRef", - "T": null, - "id": "core.config", - "pluginId": "app", - "toString": [Function], - "version": "v1", - }, - "deps": {}, - "factory": [Function], - }, - "priority": 100, - }, - "core.route-resolution" => { - "factory": { - "api": { - "$$type": "@backstage/ApiRef", - "T": null, - "id": "core.route-resolution", - "pluginId": "app", - "toString": [Function], - "version": "v1", - }, - "deps": {}, - "factory": [Function], - }, - "priority": 100, - }, - "core.identity" => { - "factory": { - "api": { - "$$type": "@backstage/ApiRef", - "T": null, - "id": "core.identity", - "pluginId": "app", - "toString": [Function], - "version": "v1", - }, - "deps": {}, - "factory": [Function], - }, - "priority": 100, - }, - }, - }, - } - `); + expect(app.sessionState).toBeDefined(); }); it('should initialize the APIs in the correct order to allow for overrides', () => { @@ -309,17 +290,31 @@ describe('createSpecializedApp', () => { ], }); - render(app.tree.root.instance!.getData(coreExtensionData.reactElement)); + render(app.element); expect(mockAnalyticsApi).toHaveBeenCalled(); }); it('should select the API factory from the owning plugin on conflict', () => { const testApiRef = createApiRef<{ value: string }>({ id: 'test.api' }); + const appRootPlugin = createFrontendPlugin({ + pluginId: 'app', + extensions: [ + createExtension({ + attachTo: { id: 'root', input: 'app' }, + output: [coreExtensionData.reactElement], + factory: ({ apis }) => [ + coreExtensionData.reactElement( +
Selected API: {apis.get(testApiRef)!.value}
, + ), + ], + }), + ], + }); const app = createSpecializedApp({ features: [ - makeAppPlugin(), + appRootPlugin, createFrontendPlugin({ pluginId: 'other-before', extensions: [ @@ -373,159 +368,30 @@ describe('createSpecializedApp', () => { }), ]); - expect(app.apis.get(testApiRef)).toEqual({ value: 'owner' }); - }); - - it('should select the API factory from an explicitly owned plugin on conflict', () => { - const testApiRef = createApiRef<{ value: string }>().with({ - id: 'shared.api', - pluginId: 'owner', - }); - - const app = createSpecializedApp({ - features: [ - makeAppPlugin(), - createFrontendPlugin({ - pluginId: 'other-before', - extensions: [ - ApiBlueprint.make({ - params: defineParams => - defineParams({ - api: testApiRef, - deps: {}, - factory: () => ({ value: 'other' }), - }), - }), - ], - }), - createFrontendPlugin({ - pluginId: 'owner', - extensions: [ - ApiBlueprint.make({ - params: defineParams => - defineParams({ - api: testApiRef, - deps: {}, - factory: () => ({ value: 'owner' }), - }), - }), - ], - }), - ], - }); - - expect(app.errors).toEqual([ - expect.objectContaining({ - code: 'API_FACTORY_CONFLICT', - message: expect.stringContaining("API 'shared.api'"), - }), - ]); - - expect(app.apis.get(testApiRef)).toEqual({ value: 'owner' }); - }); - - it('should reject unsupported opaque ApiRef versions', () => { - const testApiRef = { - $$type: '@backstage/ApiRef', - version: 'v0', - id: 'shared.api', - pluginId: 'owner', - T: null as unknown as { value: string }, - toString() { - return 'apiRef{shared.api}'; - }, - } as ApiRef<{ value: string }, 'shared.api'> & { - readonly $$type: '@backstage/ApiRef'; - readonly version: 'v0'; - readonly pluginId: 'owner'; - }; - - expect(() => - createSpecializedApp({ - features: [ - makeAppPlugin(), - createFrontendPlugin({ - pluginId: 'other-before', - extensions: [ - ApiBlueprint.make({ - params: defineParams => - defineParams({ - api: testApiRef, - deps: {}, - factory: () => ({ value: 'other' }), - }), - }), - ], - }), - createFrontendPlugin({ - pluginId: 'owner', - extensions: [ - ApiBlueprint.make({ - params: defineParams => - defineParams({ - api: testApiRef, - deps: {}, - factory: () => ({ value: 'owner' }), - }), - }), - ], - }), - ], - }), - ).toThrow("Invalid opaque type instance, got version 'v0', expected 'v1'"); - }); - - it('should not infer app ownership from core-prefixed API ids', () => { - const testApiRef = createApiRef<{ value: string }>({ id: 'core.shared' }); - - const app = createSpecializedApp({ - features: [ - makeAppPlugin(), - createFrontendPlugin({ - pluginId: 'other-before', - extensions: [ - ApiBlueprint.make({ - params: defineParams => - defineParams({ - api: testApiRef, - deps: {}, - factory: () => ({ value: 'other' }), - }), - }), - ], - }), - createFrontendModule({ - pluginId: 'app', - extensions: [ - ApiBlueprint.make({ - params: defineParams => - defineParams({ - api: testApiRef, - deps: {}, - factory: () => ({ value: 'app' }), - }), - }), - ], - }), - ], - }); - - expect(app.errors).toEqual([ - expect.objectContaining({ - code: 'API_FACTORY_CONFLICT', - message: expect.stringContaining("API 'core.shared'"), - }), - ]); - - expect(app.apis.get(testApiRef)).toEqual({ value: 'other' }); + render(app.element); + expect(screen.getByText('Selected API: owner')).toBeInTheDocument(); }); it('should allow API overrides within the same plugin', () => { const testApiRef = createApiRef<{ value: string }>({ id: 'test.api' }); + const appRootPlugin = createFrontendPlugin({ + pluginId: 'app', + extensions: [ + createExtension({ + attachTo: { id: 'root', input: 'app' }, + output: [coreExtensionData.reactElement], + factory: ({ apis }) => [ + coreExtensionData.reactElement( +
Selected API: {apis.get(testApiRef)!.value}
, + ), + ], + }), + ], + }); const app = createSpecializedApp({ features: [ - makeAppPlugin(), + appRootPlugin, createFrontendPlugin({ pluginId: 'test', extensions: [ @@ -556,17 +422,13 @@ describe('createSpecializedApp', () => { }); expect(app.errors).toBeUndefined(); - expect(app.apis.get(testApiRef)).toEqual({ value: 'module' }); + render(app.element); + expect(screen.getByText('Selected API: module')).toBeInTheDocument(); }); - it('should use provided apis', async () => { + it('should reuse provided apis', async () => { + const testApiRef = createApiRef<{ value: string }>({ id: 'test.api' }); const app = createSpecializedApp({ - advanced: { - apis: TestApiRegistry.from([ - configApiRef, - new ConfigReader({ anything: 'config' }), - ]), - }, features: [ createFrontendPlugin({ pluginId: 'test', @@ -577,8 +439,9 @@ describe('createSpecializedApp', () => { factory: ({ apis }) => [ coreExtensionData.reactElement(
- providedApis: - {apis.get(configApiRef)!.getString('anything')} + reusedApis: + {apis.get(configApiRef)!.getString('anything')}: + {apis.get(testApiRef)!.value}
, ), ], @@ -586,28 +449,17 @@ describe('createSpecializedApp', () => { ], }), ], + advanced: { + apis: TestApiRegistry.from( + [configApiRef, new ConfigReader({ anything: 'config' })], + [testApiRef, { value: 'from-apis' }], + ), + }, }); render(app.tree.root.instance!.getData(coreExtensionData.reactElement)); - expect(screen.getByText('providedApis:config')).toBeInTheDocument(); - - expect(app.apis).toMatchInlineSnapshot(` - TestApiRegistry { - "apis": Map { - "core.config" => ConfigReader { - "context": "mock-config", - "data": { - "anything": "config", - }, - "fallback": undefined, - "filteredKeys": undefined, - "notifiedFilteredKeys": Set {}, - "prefix": "", - }, - }, - } - `); + expect(screen.getByText('reusedApis:config:from-apis')).toBeInTheDocument(); }); it('should make the app structure available through the AppTreeApi', async () => { @@ -1043,4 +895,897 @@ describe('createSpecializedApp', () => { }); }); }); + + describe('prepareSpecializedApp', () => { + it('should accept session state through advanced options', () => { + const originalApp = createSpecializedApp({ + features: [makeAppPlugin('Original')], + }); + const preparedApp = prepareSpecializedApp({ + features: [makeAppPlugin('Prepared')], + advanced: { + sessionState: originalApp.sessionState, + }, + }); + + const app = preparedApp.finalize(); + + render(app.tree.root.instance!.getData(coreExtensionData.reactElement)); + + expect(screen.getByText('Prepared')).toBeInTheDocument(); + }); + + it('should reject finalize after selecting onFinalized', () => { + const preparedApp = prepareSpecializedApp({ + features: [makeAppPlugin()], + }); + + const unsubscribe = preparedApp.onFinalized(() => {}); + + expect(() => preparedApp.finalize()).toThrow( + 'prepareSpecializedApp only supports using either onFinalized() or finalize(), not both', + ); + + unsubscribe(); + }); + + it('should reject onFinalized after selecting finalize', () => { + const preparedApp = prepareSpecializedApp({ + features: [makeAppPlugin()], + }); + + preparedApp.finalize(); + + expect(() => preparedApp.onFinalized(() => {})).toThrow( + 'prepareSpecializedApp only supports using either onFinalized() or finalize(), not both', + ); + }); + + it('should synchronously finalize feature flag predicates without sign-in', async () => { + const featureFlagsApi = { + isActive: jest.fn((name: string) => name === 'test-flag'), + registerFlag: jest.fn(), + getRegisteredFlags: () => [], + save: jest.fn(), + } as unknown as typeof featureFlagsApiRef.T; + const noSignInAppPlugin = appPluginOriginal.withOverrides({ + extensions: [ + appPluginOriginal + .getExtension('sign-in-page:app') + .override({ disabled: true }), + ], + }); + const preparedApp = prepareSpecializedApp({ + features: [ + noSignInAppPlugin, + createFrontendPlugin({ + pluginId: 'test', + featureFlags: [{ name: 'test-flag' }], + extensions: [ + createExtension({ + name: 'bootstrap-element', + attachTo: { id: 'app/root', input: 'elements' }, + output: [coreExtensionData.reactElement], + factory: () => [ + coreExtensionData.reactElement(
Bootstrap Element
), + ], + }), + createExtension({ + name: 'deferred-element', + attachTo: { id: 'app/root', input: 'elements' }, + if: { featureFlags: { $contains: 'test-flag' } }, + output: [coreExtensionData.reactElement], + factory: () => [ + coreExtensionData.reactElement(
Deferred Element
), + ], + }), + ], + }), + createFrontendModule({ + pluginId: 'app', + extensions: [ + ApiBlueprint.make({ + params: defineParams => + defineParams({ + api: featureFlagsApiRef, + deps: {}, + factory: () => featureFlagsApi, + }), + }), + ], + }), + ], + }); + + renderPreparedBootstrap(preparedApp); + + expect(screen.getByText('Bootstrap Element')).toBeInTheDocument(); + expect(screen.queryByText('Deferred Element')).not.toBeInTheDocument(); + + const finalizedApp = preparedApp.finalize(); + render( + finalizedApp.tree.root.instance!.getData( + coreExtensionData.reactElement, + ), + ); + + expect(featureFlagsApi.isActive).toHaveBeenCalledWith('test-flag'); + await expect( + screen.findByText('Deferred Element'), + ).resolves.toBeInTheDocument(); + }); + + it('should defer conditional api roots without resetting visible api instances', () => { + const featureFlagsApi = { + isActive: jest.fn((name: string) => name === 'test-flag'), + registerFlag: jest.fn(), + getRegisteredFlags: () => [], + save: jest.fn(), + } as unknown as typeof featureFlagsApiRef.T; + const visibleApiExtension = ApiBlueprint.make({ + name: 'visible-api', + params: defineParams => + defineParams({ + api: createApiRef<{ value: string }>({ id: 'test.visible-api' }), + deps: {}, + factory: () => ({ value: 'visible' }), + }), + }); + const deferredApiExtension = ApiBlueprint.make({ + name: 'deferred-api', + params: defineParams => + defineParams({ + api: createApiRef<{ value: string }>({ id: 'test.deferred-api' }), + deps: {}, + factory: () => ({ value: 'deferred' }), + }), + }).override({ + if: { featureFlags: { $contains: 'test-flag' } }, + }); + const preparedApp = prepareSpecializedApp({ + features: [ + makeAppPlugin(), + createFrontendPlugin({ + pluginId: 'test', + featureFlags: [{ name: 'test-flag' }], + extensions: [ + visibleApiExtension, + deferredApiExtension, + ApiBlueprint.make({ + name: 'feature-flags', + params: defineParams => + defineParams({ + api: featureFlagsApiRef, + deps: {}, + factory: () => featureFlagsApi, + }), + }), + ], + }), + ], + }); + + const bootstrapTree = preparedApp.getBootstrapApp().tree; + const apiNodes = bootstrapTree.root.edges.attachments.get('apis') ?? []; + const visibleApiNode = apiNodes.find( + node => node.spec.id === 'api:test/visible-api', + ); + const deferredApiNode = apiNodes.find( + node => node.spec.id === 'api:test/deferred-api', + ); + + expect(visibleApiNode?.instance).toBeDefined(); + expect(deferredApiNode?.instance).toBeUndefined(); + + const visibleApiInstance = visibleApiNode?.instance; + preparedApp.finalize(); + + expect(visibleApiNode?.instance).toBe(visibleApiInstance); + expect(deferredApiNode?.instance).toBeDefined(); + }); + + it('should ignore deferred overrides of materialized bootstrap APIs', () => { + const apiRef = createApiRef<{ value: string }>({ + id: 'test.bootstrap-frozen-api', + }); + let bootstrapApiValue: string | undefined; + let finalApiValue: string | undefined; + const featureFlagsApi = { + isActive: jest.fn((name: string) => name === 'test-flag'), + registerFlag: jest.fn(), + getRegisteredFlags: () => [], + save: jest.fn(), + } as unknown as typeof featureFlagsApiRef.T; + const noSignInAppPlugin = appPluginOriginal.withOverrides({ + extensions: [ + appPluginOriginal + .getExtension('sign-in-page:app') + .override({ disabled: true }), + ], + }); + const preparedApp = prepareSpecializedApp({ + features: [ + noSignInAppPlugin, + createFrontendPlugin({ + pluginId: 'test', + featureFlags: [{ name: 'test-flag' }], + extensions: [ + ApiBlueprint.make({ + name: 'bootstrap-api', + params: defineParams => + defineParams({ + api: apiRef, + deps: {}, + factory: () => ({ value: 'bootstrap' }), + }), + }), + ApiBlueprint.make({ + name: 'deferred-api', + params: defineParams => + defineParams({ + api: apiRef, + deps: {}, + factory: () => ({ value: 'final' }), + }), + }).override({ + if: { featureFlags: { $contains: 'test-flag' } }, + }), + createExtension({ + name: 'bootstrap-reader', + attachTo: { id: 'app/root', input: 'elements' }, + output: [coreExtensionData.reactElement], + factory: ({ apis }) => { + bootstrapApiValue = apis.get(apiRef)?.value; + return [ + coreExtensionData.reactElement(
Bootstrap Reader
), + ]; + }, + }), + createExtension({ + name: 'final-reader', + attachTo: { id: 'app/root', input: 'elements' }, + if: { featureFlags: { $contains: 'test-flag' } }, + output: [coreExtensionData.reactElement], + factory: ({ apis }) => { + finalApiValue = apis.get(apiRef)?.value; + return [ + coreExtensionData.reactElement(
Final Reader
), + ]; + }, + }), + ], + }), + createFrontendModule({ + pluginId: 'app', + extensions: [ + ApiBlueprint.make({ + params: defineParams => + defineParams({ + api: featureFlagsApiRef, + deps: {}, + factory: () => featureFlagsApi, + }), + }), + ], + }), + ], + }); + + renderPreparedBootstrap(preparedApp); + expect(bootstrapApiValue).toBe('bootstrap'); + + const finalizedApp = preparedApp.finalize(); + + expect(featureFlagsApi.isActive).toHaveBeenCalledWith('test-flag'); + expect(finalApiValue).toBe('bootstrap'); + expect(finalizedApp.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'EXTENSION_BOOTSTRAP_API_OVERRIDE_IGNORED', + context: expect.objectContaining({ + apiRefId: apiRef.id, + }), + }), + ]), + ); + }); + + it('should allow deferred overrides of bootstrap APIs that were not materialized', () => { + const apiRef = createApiRef<{ value: string }>({ + id: 'test.bootstrap-overridable-api', + }); + let finalApiValue: string | undefined; + const featureFlagsApi = { + isActive: jest.fn((name: string) => name === 'test-flag'), + registerFlag: jest.fn(), + getRegisteredFlags: () => [], + save: jest.fn(), + } as unknown as typeof featureFlagsApiRef.T; + const noSignInAppPlugin = appPluginOriginal.withOverrides({ + extensions: [ + appPluginOriginal + .getExtension('sign-in-page:app') + .override({ disabled: true }), + ], + }); + const preparedApp = prepareSpecializedApp({ + features: [ + noSignInAppPlugin, + createFrontendPlugin({ + pluginId: 'test', + featureFlags: [{ name: 'test-flag' }], + extensions: [ + ApiBlueprint.make({ + name: 'bootstrap-api', + params: defineParams => + defineParams({ + api: apiRef, + deps: {}, + factory: () => ({ value: 'bootstrap' }), + }), + }), + ApiBlueprint.make({ + name: 'deferred-api', + params: defineParams => + defineParams({ + api: apiRef, + deps: {}, + factory: () => ({ value: 'final' }), + }), + }).override({ + if: { featureFlags: { $contains: 'test-flag' } }, + }), + createExtension({ + name: 'bootstrap-element', + attachTo: { id: 'app/root', input: 'elements' }, + output: [coreExtensionData.reactElement], + factory: () => [ + coreExtensionData.reactElement(
Bootstrap Element
), + ], + }), + createExtension({ + name: 'final-reader', + attachTo: { id: 'app/root', input: 'elements' }, + if: { featureFlags: { $contains: 'test-flag' } }, + output: [coreExtensionData.reactElement], + factory: ({ apis }) => { + finalApiValue = apis.get(apiRef)?.value; + return [ + coreExtensionData.reactElement(
Final Reader
), + ]; + }, + }), + ], + }), + createFrontendModule({ + pluginId: 'app', + extensions: [ + ApiBlueprint.make({ + params: defineParams => + defineParams({ + api: featureFlagsApiRef, + deps: {}, + factory: () => featureFlagsApi, + }), + }), + ], + }), + ], + }); + + renderPreparedBootstrap(preparedApp); + expect(screen.getByText('Bootstrap Element')).toBeInTheDocument(); + + const finalizedApp = preparedApp.finalize(); + + expect(featureFlagsApi.isActive).toHaveBeenCalledWith('test-flag'); + expect(finalApiValue).toBe('final'); + expect(finalizedApp.errors ?? []).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'EXTENSION_BOOTSTRAP_API_OVERRIDE_IGNORED', + context: expect.objectContaining({ + apiRefId: apiRef.id, + }), + }), + ]), + ); + }); + + it('should defer app root children until finalize', async () => { + const identityApi = { + getProfileInfo: async () => ({ displayName: 'Test User' }), + getBackstageIdentity: async () => ({ + type: 'user' as const, + userEntityRef: 'user:default/test-user', + ownershipEntityRefs: ['user:default/test-user'], + }), + getCredentials: async () => ({ token: 'token' }), + signOut: async () => {}, + }; + const appLayoutFactory = jest.fn(() => [ + coreExtensionData.reactElement(
App Layout
), + ]); + const phasedAppPlugin = appPluginOriginal.withOverrides({ + extensions: [ + appPluginOriginal.getExtension('app/layout').override({ + factory: appLayoutFactory, + }), + ], + }); + let onSignInSuccess: ((identity: IdentityApi) => void) | undefined; + + const preparedApp = prepareSpecializedApp({ + features: [ + phasedAppPlugin, + createFrontendModule({ + pluginId: 'app', + extensions: [ + phasedAppPlugin.getExtension('sign-in-page:app').override({ + factory: () => { + function SignInPage(props: { + onSignInSuccess(identity: IdentityApi): void; + }) { + onSignInSuccess = props.onSignInSuccess; + return
Custom Sign In
; + } + + return [signInPageComponentDataRef(SignInPage)]; + }, + }), + ], + }), + ], + }); + + renderPreparedBootstrap(preparedApp); + await expect( + screen.findByText('Custom Sign In'), + ).resolves.toBeInTheDocument(); + + const finalizedAppPromise = waitForFinalizedApp(preparedApp); + if (!onSignInSuccess) { + throw new Error('Expected sign-in success callback to be captured'); + } + const triggerSignInSuccess = onSignInSuccess; + act(() => { + triggerSignInSuccess(identityApi); + }); + + const finalizedApp = await finalizedAppPromise; + expect(appLayoutFactory).toHaveBeenCalledTimes(1); + render( + finalizedApp.tree.root.instance!.getData( + coreExtensionData.reactElement, + ), + ); + + expect(screen.getByText('App Layout')).toBeInTheDocument(); + expect(appLayoutFactory).toHaveBeenCalledTimes(1); + }); + + it('should expose a sign-in page element and finalize with the captured identity', async () => { + const identityApi = { + getProfileInfo: async () => ({ displayName: 'Test User' }), + getBackstageIdentity: async () => ({ + type: 'user' as const, + userEntityRef: 'user:default/test-user', + ownershipEntityRefs: ['user:default/test-user'], + }), + getCredentials: async () => ({ token: 'token' }), + signOut: async () => {}, + }; + const identityAppPlugin = appPluginOriginal.withOverrides({ + extensions: [ + appPluginOriginal.getExtension('app/layout').override({ + factory: () => { + function IdentityReader() { + const [identity, setIdentity] = useState(); + const appIdentityApi = useApi(identityApiRef); + + useEffect(() => { + void appIdentityApi.getBackstageIdentity().then(result => { + setIdentity(result.userEntityRef); + }); + }, [appIdentityApi]); + + return
Identity: {identity}
; + } + + return [coreExtensionData.reactElement()]; + }, + }), + ], + }); + let onSignInSuccess: ((identity: IdentityApi) => void) | undefined; + + const preparedApp = prepareSpecializedApp({ + features: [ + identityAppPlugin, + createFrontendModule({ + pluginId: 'app', + extensions: [ + identityAppPlugin.getExtension('sign-in-page:app').override({ + factory: () => { + function SignInPage(props: { + onSignInSuccess(identity: IdentityApi): void; + }) { + onSignInSuccess = props.onSignInSuccess; + return
Custom Sign In
; + } + + return [signInPageComponentDataRef(SignInPage)]; + }, + }), + ], + }), + ], + }); + + renderPreparedBootstrap(preparedApp); + await expect( + screen.findByText('Custom Sign In'), + ).resolves.toBeInTheDocument(); + + const finalizedAppPromise = waitForFinalizedApp(preparedApp); + if (!onSignInSuccess) { + throw new Error('Expected sign-in success callback to be captured'); + } + const triggerSignInSuccess = onSignInSuccess; + act(() => { + triggerSignInSuccess(identityApi); + }); + + const finalizedApp = await finalizedAppPromise; + render( + finalizedApp.tree.root.instance!.getData( + coreExtensionData.reactElement, + ), + ); + await expect( + screen.findByText('Identity: user:default/test-user'), + ).resolves.toBeInTheDocument(); + }); + + it('should reuse predicate context gathered during sign-in completion', async () => { + const identityApi = { + getProfileInfo: async () => ({ displayName: 'Test User' }), + getBackstageIdentity: async () => ({ + type: 'user' as const, + userEntityRef: 'user:default/test-user', + ownershipEntityRefs: ['user:default/test-user'], + }), + getCredentials: async () => ({ token: 'token' }), + signOut: async () => {}, + }; + const featureFlagsApi = { + isActive: jest.fn((name: string) => name === 'test-flag'), + registerFlag: jest.fn(), + getRegisteredFlags: () => [], + save: jest.fn(), + } as unknown as typeof featureFlagsApiRef.T; + const gatedAppPlugin = appPluginOriginal.withOverrides({ + extensions: [ + appPluginOriginal.getExtension('app/layout').override({ + if: { featureFlags: { $contains: 'test-flag' } }, + factory: () => [ + coreExtensionData.reactElement(
Flagged Layout
), + ], + }), + ], + }); + let onSignInSuccess: ((identity: IdentityApi) => void) | undefined; + + const preparedApp = prepareSpecializedApp({ + features: [ + gatedAppPlugin, + createFrontendModule({ + pluginId: 'app', + extensions: [ + ApiBlueprint.make({ + params: defineParams => + defineParams({ + api: featureFlagsApiRef, + deps: {}, + factory: () => featureFlagsApi, + }), + }), + gatedAppPlugin.getExtension('sign-in-page:app').override({ + factory: () => { + function SignInPage(props: { + onSignInSuccess(identity: IdentityApi): void; + }) { + onSignInSuccess = props.onSignInSuccess; + return
Custom Sign In
; + } + + return [signInPageComponentDataRef(SignInPage)]; + }, + }), + ], + }), + ], + }); + + renderPreparedBootstrap(preparedApp); + await expect( + screen.findByText('Custom Sign In'), + ).resolves.toBeInTheDocument(); + + const finalizedAppPromise = waitForFinalizedApp(preparedApp); + if (!onSignInSuccess) { + throw new Error('Expected sign-in success callback to be captured'); + } + const triggerSignInSuccess = onSignInSuccess; + act(() => { + triggerSignInSuccess(identityApi); + }); + + const finalizedApp = await finalizedAppPromise; + expect(featureFlagsApi.isActive).toHaveBeenCalledWith('test-flag'); + expect(featureFlagsApi.isActive).toHaveBeenCalledTimes(1); + render( + finalizedApp.tree.root.instance!.getData( + coreExtensionData.reactElement, + ), + ); + + expect(screen.getByText('Flagged Layout')).toBeInTheDocument(); + }); + + it('should defer conditional app root elements until finalize', async () => { + const identityApi = { + getProfileInfo: async () => ({ displayName: 'Test User' }), + getBackstageIdentity: async () => ({ + type: 'user' as const, + userEntityRef: 'user:default/test-user', + ownershipEntityRefs: ['user:default/test-user'], + }), + getCredentials: async () => ({ token: 'token' }), + signOut: async () => {}, + }; + const featureFlagsApi = { + isActive: jest.fn((name: string) => name === 'test-flag'), + registerFlag: jest.fn(), + getRegisteredFlags: () => [], + save: jest.fn(), + } as unknown as typeof featureFlagsApiRef.T; + + const preparedApp = prepareSpecializedApp({ + features: [ + appPluginOriginal, + createFrontendPlugin({ + pluginId: 'test', + featureFlags: [{ name: 'test-flag' }], + extensions: [ + createExtension({ + name: 'bootstrap-element', + attachTo: { id: 'app/root', input: 'elements' }, + output: [coreExtensionData.reactElement], + factory: () => [ + coreExtensionData.reactElement(
Bootstrap Element
), + ], + }), + createExtension({ + name: 'deferred-element', + attachTo: { id: 'app/root', input: 'elements' }, + if: { featureFlags: { $contains: 'test-flag' } }, + output: [coreExtensionData.reactElement], + factory: () => [ + coreExtensionData.reactElement(
Deferred Element
), + ], + }), + ], + }), + createFrontendModule({ + pluginId: 'app', + extensions: [ + ApiBlueprint.make({ + params: defineParams => + defineParams({ + api: featureFlagsApiRef, + deps: {}, + factory: () => featureFlagsApi, + }), + }), + appPluginOriginal.getExtension('sign-in-page:app').override({ + factory: () => { + function SignInPage(props: { + onSignInSuccess(identity: IdentityApi): void; + }) { + useEffect(() => { + props.onSignInSuccess(identityApi); + }, [props]); + return
Custom Sign In
; + } + + return [signInPageComponentDataRef(SignInPage)]; + }, + }), + ], + }), + ], + }); + + renderPreparedBootstrap(preparedApp); + await expect( + screen.findByText('Bootstrap Element'), + ).resolves.toBeInTheDocument(); + expect(screen.queryByText('Deferred Element')).not.toBeInTheDocument(); + + const finalizedApp = await waitForFinalizedApp(preparedApp); + render( + finalizedApp.tree.root.instance!.getData( + coreExtensionData.reactElement, + ), + ); + + await expect( + screen.findByText('Deferred Element'), + ).resolves.toBeInTheDocument(); + }); + + it('should warn when bootstrap extensions access APIs that appear during finalization', async () => { + const identityApi = { + getProfileInfo: async () => ({ displayName: 'Test User' }), + getBackstageIdentity: async () => ({ + type: 'user' as const, + userEntityRef: 'user:default/test-user', + ownershipEntityRefs: ['user:default/test-user'], + }), + getCredentials: async () => ({ token: 'token' }), + signOut: async () => {}, + }; + const delayedApiRef = createApiRef<{ value: string }>({ + id: 'test.delayed-api', + }); + const featureFlagsApi = { + isActive: jest.fn((name: string) => name === 'test-flag'), + registerFlag: jest.fn(), + getRegisteredFlags: () => [], + save: jest.fn(), + } as unknown as typeof featureFlagsApiRef.T; + + const preparedApp = prepareSpecializedApp({ + features: [ + appPluginOriginal, + createFrontendPlugin({ + pluginId: 'test', + featureFlags: [{ name: 'test-flag' }], + extensions: [ + createExtension({ + name: 'bootstrap-api-reader', + attachTo: { id: 'app/root', input: 'elements' }, + output: [coreExtensionData.reactElement], + factory: ({ apis }) => { + const delayedApi = apis.get(delayedApiRef); + return [ + coreExtensionData.reactElement( +
+ Bootstrap API:{' '} + {delayedApi ? delayedApi.value : 'missing'} +
, + ), + ]; + }, + }), + ApiBlueprint.make({ + name: 'delayed-api', + params: defineParams => + defineParams({ + api: delayedApiRef, + deps: {}, + factory: () => ({ value: 'ready' }), + }), + }).override({ + if: { featureFlags: { $contains: 'test-flag' } }, + }), + ], + }), + createFrontendModule({ + pluginId: 'app', + extensions: [ + ApiBlueprint.make({ + params: defineParams => + defineParams({ + api: featureFlagsApiRef, + deps: {}, + factory: () => featureFlagsApi, + }), + }), + appPluginOriginal.getExtension('sign-in-page:app').override({ + factory: () => { + function SignInPage(props: { + onSignInSuccess(identity: IdentityApi): void; + }) { + useEffect(() => { + props.onSignInSuccess(identityApi); + }, [props]); + return
Custom Sign In
; + } + + return [signInPageComponentDataRef(SignInPage)]; + }, + }), + ], + }), + ], + }); + + renderPreparedBootstrap(preparedApp); + await expect( + screen.findByText('Bootstrap API: missing'), + ).resolves.toBeInTheDocument(); + + const finalizedApp = await waitForFinalizedApp(preparedApp); + + expect(finalizedApp.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'EXTENSION_BOOTSTRAP_API_UNAVAILABLE', + context: expect.objectContaining({ + apiRefId: delayedApiRef.id, + }), + }), + ]), + ); + }); + + it('should gate finalize behind internal async sign-in finalization', async () => { + const identityApi = { + getProfileInfo: async () => ({ displayName: 'Test User' }), + getBackstageIdentity: async () => ({ + type: 'user' as const, + userEntityRef: 'user:default/test-user', + ownershipEntityRefs: ['user:default/test-user'], + }), + getCredentials: async () => ({ token: 'token' }), + signOut: async () => {}, + }; + let onSignInSuccess: ((identity: IdentityApi) => void) | undefined; + + const preparedApp = prepareSpecializedApp({ + features: [ + appPlugin, + createFrontendModule({ + pluginId: 'app', + extensions: [ + appPlugin.getExtension('sign-in-page:app').override({ + factory: () => { + function SignInPage(props: { + onSignInSuccess(identity: IdentityApi): void; + }) { + onSignInSuccess = props.onSignInSuccess; + return
Custom Sign In
; + } + + return [signInPageComponentDataRef(SignInPage)]; + }, + }), + ], + }), + ], + }); + + renderPreparedBootstrap(preparedApp); + await expect( + screen.findByText('Custom Sign In'), + ).resolves.toBeInTheDocument(); + + expect(() => preparedApp.finalize()).toThrow( + 'prepareSpecializedApp requires waiting for the bootstrap app to be ready before calling finalize()', + ); + + if (!onSignInSuccess) { + throw new Error('Expected sign-in success callback to be captured'); + } + const triggerSignInSuccess = onSignInSuccess; + act(() => { + triggerSignInSuccess(identityApi); + }); + expect(() => preparedApp.finalize()).toThrow( + 'prepareSpecializedApp requires waiting for the bootstrap app to be ready before calling finalize()', + ); + }); + }); }); diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx index 268ac6d24b..dc2f8e0562 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx @@ -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(); - 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; - private readonly appBasePath: string; - - constructor( - routeBindings: Map, - appBasePath: string, - ) { - this.routeBindings = routeBindings; - this.appBasePath = appBasePath; - } - - resolve( - anyRouteRef: - | RouteRef - | SubRouteRef - | ExternalRouteRef, - options?: { sourcePath?: string }, - ): RouteFunc | 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, - ) { - 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(); } diff --git a/packages/frontend-app-api/src/wiring/index.ts b/packages/frontend-app-api/src/wiring/index.ts index cae2117df5..56851ac88a 100644 --- a/packages/frontend-app-api/src/wiring/index.ts +++ b/packages/frontend-app-api/src/wiring/index.ts @@ -15,6 +15,8 @@ */ export { + prepareSpecializedApp, + type PreparedSpecializedApp, createSpecializedApp, type CreateSpecializedAppOptions, } from './createSpecializedApp'; diff --git a/packages/frontend-defaults/src/createApp.test.tsx b/packages/frontend-defaults/src/createApp.test.tsx index 1d1c5ebf53..5b5e6b0e29 100644 --- a/packages/frontend-defaults/src/createApp.test.tsx +++ b/packages/frontend-defaults/src/createApp.test.tsx @@ -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
Sign In API: {api.value}
; + }; + + 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 ( +
+ Flags:{' '} + {flagsApi + .getRegisteredFlags() + .map(flag => flag.name) + .join(', ')} +
+ ); + }; + + 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', () => { ] - signInPage [ - - ] ] diff --git a/packages/frontend-defaults/src/createApp.tsx b/packages/frontend-defaults/src/createApp.tsx index 220a7b743f..bd1311740b 100644 --- a/packages/frontend-defaults/src/createApp.tsx +++ b/packages/frontend-defaults/src/createApp.tsx @@ -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: () => , + }; } - 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(); + 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, +) { + const errorPage = maybeCreateErrorPage(app); + if (errorPage) { + return errorPage; + } + + return app.tree.root.instance!.getData(coreExtensionData.reactElement)!; +}