From 925e0895b32ae94f2b331abb4ed559be5353be7e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 5 Mar 2026 15:40:42 +0100 Subject: [PATCH 01/68] 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)!; +} From fc08a9b4dded94c9a46d873456419d0b859b5659 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 8 Mar 2026 20:52:00 +0100 Subject: [PATCH 02/68] plugin-app: keep identity proxy in prepared finalize flow This fixes the prepare/finalize sign-in flow so finalized apps continue to use AppIdentityProxy, including protected-mode cookie auth behavior. It also avoids setting guest identity in protected mode when no sign-in page is configured, which lets pre-captured identities remain intact. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- plugins/app/src/extensions/AppRoot.tsx | 46 ++++++++++++++------------ 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/plugins/app/src/extensions/AppRoot.tsx b/plugins/app/src/extensions/AppRoot.tsx index 9ae564e06d..07fd3aa4a0 100644 --- a/plugins/app/src/extensions/AppRoot.tsx +++ b/plugins/app/src/extensions/AppRoot.tsx @@ -253,28 +253,30 @@ export function AppRouter(props: AppRouterProps) { // If the app hasn't configured a sign-in page, we just continue as guest. if (!SignInPageComponent) { - appIdentityProxy.setTarget( - { - getUserId: () => 'guest', - getIdToken: async () => undefined, - getProfile: () => ({ - email: 'guest@example.com', - displayName: 'Guest', - }), - getProfileInfo: async () => ({ - email: 'guest@example.com', - displayName: 'Guest', - }), - getBackstageIdentity: async () => ({ - type: 'user', - userEntityRef: 'user:default/guest', - ownershipEntityRefs: ['user:default/guest'], - }), - getCredentials: async () => ({}), - signOut: async () => {}, - }, - { signOutTargetUrl: basePath || '/' }, - ); + if (!isProtectedApp()) { + appIdentityProxy.setTarget( + { + getUserId: () => 'guest', + getIdToken: async () => undefined, + getProfile: () => ({ + email: 'guest@example.com', + displayName: 'Guest', + }), + getProfileInfo: async () => ({ + email: 'guest@example.com', + displayName: 'Guest', + }), + getBackstageIdentity: async () => ({ + type: 'user', + userEntityRef: 'user:default/guest', + ownershipEntityRefs: ['user:default/guest'], + }), + getCredentials: async () => ({}), + signOut: async () => {}, + }, + { signOutTargetUrl: basePath || '/' }, + ); + } return ( From 904841dd8443f7f42f98a2a29e877774cadc4d15 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 11 Mar 2026 21:20:10 +0100 Subject: [PATCH 03/68] frontend-app-api: halfway implementation of app preparation split Signed-off-by: Patrik Oldsberg --- .changeset/prepare-specialized-app-signin-flow.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/prepare-specialized-app-signin-flow.md b/.changeset/prepare-specialized-app-signin-flow.md index baae7c6d00..03f37b7107 100644 --- a/.changeset/prepare-specialized-app-signin-flow.md +++ b/.changeset/prepare-specialized-app-signin-flow.md @@ -1,6 +1,6 @@ -"@backstage/frontend-app-api": patch -"@backstage/frontend-defaults": patch - +--- +'@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. From 805e7cc688f727f576f93c295179964eaaaed801 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 11 Mar 2026 22:23:08 +0100 Subject: [PATCH 04/68] frontend-app-api: remove reintroduced allowUnknownExtensionConfig Keep createApp and createSpecializedApp aligned with the breaking change that removed this no-op option, and drop the stale test coverage for it. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../frontend-defaults/src/createApp.test.tsx | 34 ------------------- packages/frontend-defaults/src/createApp.tsx | 11 ------ 2 files changed, 45 deletions(-) diff --git a/packages/frontend-defaults/src/createApp.test.tsx b/packages/frontend-defaults/src/createApp.test.tsx index 5b5e6b0e29..af8517624a 100644 --- a/packages/frontend-defaults/src/createApp.test.tsx +++ b/packages/frontend-defaults/src/createApp.test.tsx @@ -386,40 +386,6 @@ describe('createApp', () => { ).resolves.toBeInTheDocument(); }); - it('should allow unknown extension config if the flag is set', async () => { - const app = createApp({ - features: [ - appPlugin, - createFrontendPlugin({ - pluginId: 'test', - extensions: [ - PageBlueprint.make({ - params: { - path: '/', - loader: async () =>
Derp
, - }, - }), - ], - }), - ], - advanced: { - allowUnknownExtensionConfig: true, - configLoader: async () => ({ - config: mockApis.config({ - data: { - app: { - extensions: [{ 'unknown:lols/wut': false }], - }, - }, - }), - }), - }, - }); - - await renderWithEffects(app.createRoot()); - - await expect(screen.findByText('Derp')).resolves.toBeInTheDocument(); - }); it('should make the app structure available through the AppTreeApi', async () => { let appTreeApi: AppTreeApi | undefined = undefined; diff --git a/packages/frontend-defaults/src/createApp.tsx b/packages/frontend-defaults/src/createApp.tsx index bd1311740b..c4cf43fb2e 100644 --- a/packages/frontend-defaults/src/createApp.tsx +++ b/packages/frontend-defaults/src/createApp.tsx @@ -59,17 +59,6 @@ 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. * From d8d8b6a7f3a605daa752671b27ba984a7297000c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 11 Mar 2026 22:32:57 +0100 Subject: [PATCH 05/68] frontend-app-api: update ExtensionFactoryMiddleware imports Align the specialized app wiring and generated API reports with the current ExtensionFactoryMiddleware type location. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- packages/frontend-defaults/report.api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/frontend-defaults/report.api.md b/packages/frontend-defaults/report.api.md index 72bd0d785e..cfe4f55b2d 100644 --- a/packages/frontend-defaults/report.api.md +++ b/packages/frontend-defaults/report.api.md @@ -8,7 +8,7 @@ import { AppErrorTypes } from '@backstage/frontend-app-api'; import { Config } from '@backstage/config'; import { ConfigApi } from '@backstage/frontend-plugin-api'; import { CreateAppRouteBinder } from '@backstage/frontend-app-api'; -import { ExtensionFactoryMiddleware } from '@backstage/frontend-app-api'; +import { ExtensionFactoryMiddleware } from '@backstage/frontend-plugin-api'; import { FrontendFeature } from '@backstage/frontend-plugin-api'; import { FrontendFeatureLoader } from '@backstage/frontend-plugin-api'; import { FrontendPluginInfoResolver } from '@backstage/frontend-app-api'; From 00381fa7b2cb52d443d848904a617aed8d10687c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 12 Mar 2026 13:54:18 +0100 Subject: [PATCH 06/68] frontend-app-api: add session boundary app initialization Instantiate the app shell up to app/root.children during sign-in, then rebuild the full tree after sign-in without cloning the app tree. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../frontend-app-api/src/tree/instantiateAppNodeTree.ts | 6 ++++++ plugins/app/src/extensions/AppRoot.tsx | 5 ++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts index 3de09acf41..85a7baa11c 100644 --- a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts +++ b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts @@ -509,6 +509,9 @@ export function instantiateAppNodeTree( apis: ApiHolder, collector: ErrorCollector, extensionFactoryMiddleware?: ExtensionFactoryMiddleware, + options?: { + stopAtAttachment?(ctx: { node: AppNode; input: string }): boolean; + }, ): boolean { function createInstance(node: AppNode): AppNodeInstance | undefined { if (node.instance) { @@ -521,6 +524,9 @@ export function instantiateAppNodeTree( const instantiatedAttachments = new Map(); for (const [input, children] of node.edges.attachments) { + if (options?.stopAtAttachment?.({ node, input })) { + continue; + } const instantiatedChildren = children.flatMap(child => { const childInstance = createInstance(child); if (!childInstance) { diff --git a/plugins/app/src/extensions/AppRoot.tsx b/plugins/app/src/extensions/AppRoot.tsx index 07fd3aa4a0..a970ae4463 100644 --- a/plugins/app/src/extensions/AppRoot.tsx +++ b/plugins/app/src/extensions/AppRoot.tsx @@ -73,6 +73,7 @@ export const AppRoot = createExtension({ }), children: createExtensionInput([coreExtensionData.reactElement], { singleton: true, + optional: true, }), elements: createExtensionInput([coreExtensionData.reactElement]), wrappers: createExtensionInput( @@ -105,9 +106,7 @@ export const AppRoot = createExtension({ }); } - let content: ReactNode = inputs.children.get( - coreExtensionData.reactElement, - ); + let content = inputs.children?.get(coreExtensionData.reactElement); for (const wrapper of inputs.wrappers) { const Component = wrapper.get(AppRootWrapperBlueprint.dataRefs.component); From 25b7ddd6644484ec6ed9ea17d99b59d00dce08c1 Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Sun, 8 Mar 2026 18:08:09 -0400 Subject: [PATCH 07/68] feat: allow dynamically enabling and disabling extensions Signed-off-by: aramissennyeydd --- .../ff-perm-enabled-frontend-app-api.md | 7 + .../ff-perm-enabled-frontend-defaults.md | 5 + .../ff-perm-enabled-frontend-plugin-api.md | 7 + packages/app/src/examples/pagesPlugin.tsx | 208 +++++++++++++++++- packages/frontend-app-api/package.json | 2 + .../src/tree/instantiateAppNodeTree.test.ts | 92 ++++++++ .../src/tree/instantiateAppNodeTree.ts | 29 ++- .../src/tree/resolveAppNodeSpecs.test.ts | 26 +++ .../src/tree/resolveAppNodeSpecs.ts | 17 ++ packages/frontend-defaults/report.api.md | 1 + packages/frontend-defaults/src/createApp.tsx | 32 ++- packages/frontend-internal/package.json | 1 + .../src/wiring/InternalExtensionDefinition.ts | 2 + packages/frontend-plugin-api/package.json | 1 + packages/frontend-plugin-api/report.api.md | 8 + .../src/apis/definitions/AppTreeApi.ts | 2 + .../src/wiring/createExtension.ts | 5 + .../src/wiring/createExtensionBlueprint.ts | 6 + .../src/wiring/resolveExtensionDefinition.ts | 2 + .../src/apis/IdentityPermissionApi.ts | 15 +- .../src/apis/PermissionApi.ts | 3 + .../report.api.md | 4 +- yarn.lock | 4 + 23 files changed, 458 insertions(+), 21 deletions(-) create mode 100644 .changeset/ff-perm-enabled-frontend-app-api.md create mode 100644 .changeset/ff-perm-enabled-frontend-defaults.md create mode 100644 .changeset/ff-perm-enabled-frontend-plugin-api.md diff --git a/.changeset/ff-perm-enabled-frontend-app-api.md b/.changeset/ff-perm-enabled-frontend-app-api.md new file mode 100644 index 0000000000..dc3bba03ba --- /dev/null +++ b/.changeset/ff-perm-enabled-frontend-app-api.md @@ -0,0 +1,7 @@ +--- +'@backstage/frontend-app-api': patch +--- + +Extensions with an `enabled` predicate are now evaluated before app tree instantiation, so pages that do not meet their conditions are excluded from the router tree. + +`prepareSpecializedApp().finalize()` is now async. Extensions whose `enabled` predicate references `permissions` are checked against the current user's allowed permissions (via a single batched call to `permissionApiRef`) before the app tree is instantiated. diff --git a/.changeset/ff-perm-enabled-frontend-defaults.md b/.changeset/ff-perm-enabled-frontend-defaults.md new file mode 100644 index 0000000000..c811cad8eb --- /dev/null +++ b/.changeset/ff-perm-enabled-frontend-defaults.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-defaults': patch +--- + +Updated `createApp` to await the now-async `finalize()` call from `prepareSpecializedApp`, enabling permission-gated extensions to be resolved before the app tree is rendered. diff --git a/.changeset/ff-perm-enabled-frontend-plugin-api.md b/.changeset/ff-perm-enabled-frontend-plugin-api.md new file mode 100644 index 0000000000..67fd93eb19 --- /dev/null +++ b/.changeset/ff-perm-enabled-frontend-plugin-api.md @@ -0,0 +1,7 @@ +--- +'@backstage/frontend-plugin-api': patch +--- + +Added `enabled` option to `createExtension`, `createExtensionBlueprint`, and `AppNodeSpec`, accepting a `FilterPredicate` from `@backstage/filter-predicates` to conditionally enable extensions based on feature flags or other runtime conditions. + +Added `permissions` option to `createFrontendPlugin` and `createFrontendModule`, allowing plugins and modules to declare which permissions they use. These permissions are checked at app startup so that extensions can conditionally enable themselves using a `permissions` predicate, e.g. `enabled: { permissions: { $contains: 'catalog.entity.create' } }`. diff --git a/packages/app/src/examples/pagesPlugin.tsx b/packages/app/src/examples/pagesPlugin.tsx index 35426b589b..374935e843 100644 --- a/packages/app/src/examples/pagesPlugin.tsx +++ b/packages/app/src/examples/pagesPlugin.tsx @@ -65,7 +65,8 @@ const IndexPage = PageBlueprint.make({ const page1Link = useRouteRef(page1RouteRef); return (
- op +

Example Pages Plugin

+

Navigation

{page1Link && (
Page 1 @@ -83,6 +84,47 @@ const IndexPage = PageBlueprint.make({
Settings
+ +

Permission Enablement Examples

+

+ The following pages demonstrate conditional extension enablement + via the enabled predicate using permissions. They + will only appear when the user has the required permissions. +

+
    +
  • + + Permission Gated Example + {' '} + — requires catalog.entity.create +
  • +
+ +

Feature Flag Enablement Examples

+

+ The following pages demonstrate conditional extension enablement + via the enabled predicate. They will only appear in + the router tree when their conditions are satisfied. Toggle the + relevant feature flags in Settings, + then refresh the app to see the pages appear. +

+
    +
  • + Feature Flag Example — + requires the experimental-features flag +
  • +
  • + All Flags Example — + requires both experimental-features and{' '} + advanced-features ($all) +
  • +
  • + Any Flag Example — requires{' '} + either experimental-features or{' '} + beta-access ($any) +
  • +
+
); @@ -150,6 +192,155 @@ const ExternalPage = PageBlueprint.make({ }, }); +// Example: Page enabled only when a single feature flag is active. +// +// The `enabled` predicate is evaluated once at app startup (before the router +// tree is built), so this page simply won't exist in the app until the flag is +// toggled and the page is refreshed. +// +// To test: enable the 'experimental-features' flag in Settings, then refresh. +const FeatureFlagPage = PageBlueprint.make({ + name: 'featureFlagExample', + params: { + path: '/feature-flag-example', + loader: async () => { + const Component = () => { + const indexLink = useRouteRef(indexRouteRef); + return ( +
+

Feature Flag Enabled Page

+

+ This page is only present in the app when the{' '} + experimental-features feature flag is active. +

+

+ It uses a simple{' '} + + {'{ featureFlags: { $contains: "experimental-features" } }'} + {' '} + predicate. +

+ {indexLink && Go back} +
+ ); + }; + return ; + }, + }, + enabled: { featureFlags: { $contains: 'experimental-features' } }, +}); + +// Example: Page enabled only when ALL of several feature flags are active. +// +// The $all operator requires every nested predicate to be satisfied. This page +// won't appear unless both 'experimental-features' and 'advanced-features' are +// enabled at the same time. +// +// To test: enable BOTH flags in Settings, then refresh. +const AllFlagsPage = PageBlueprint.make({ + name: 'allFlagsExample', + params: { + path: '/all-flags-example', + loader: async () => { + const Component = () => { + const indexLink = useRouteRef(indexRouteRef); + return ( +
+

All Flags Required Page

+

+ This page requires both{' '} + experimental-features and{' '} + advanced-features to be active simultaneously. +

+

+ It uses a $all predicate to AND the two conditions + together. +

+ {indexLink && Go back} +
+ ); + }; + return ; + }, + }, + enabled: { + $all: [ + { featureFlags: { $contains: 'experimental-features' } }, + { featureFlags: { $contains: 'advanced-features' } }, + ], + }, +}); + +// Example: Page enabled when ANY one of several feature flags is active. +// +// The $any operator is satisfied as soon as at least one nested predicate +// matches. Enabling either 'experimental-features' or 'beta-access' will make +// this page appear. +// +// To test: enable at least one of the two flags in Settings, then refresh. +const AnyFlagPage = PageBlueprint.make({ + name: 'anyFlagExample', + params: { + path: '/any-flag-example', + loader: async () => { + const Component = () => { + const indexLink = useRouteRef(indexRouteRef); + return ( +
+

Any Flag Sufficient Page

+

+ This page appears when either{' '} + experimental-features or beta-access is + active. +

+

+ It uses a $any predicate to OR the two conditions + together. +

+ {indexLink && Go back} +
+ ); + }; + return ; + }, + }, + enabled: { + $any: [ + { featureFlags: { $contains: 'experimental-features' } }, + { featureFlags: { $contains: 'beta-access' } }, + ], + }, +}); + +// Example: Page enabled only when the user is allowed to create catalog entities. +// +// The `enabled` predicate is evaluated once at app startup (after sign-in), +// so this page simply won't exist in the router tree if the user lacks the +// required permission. +const PermissionGatedPage = PageBlueprint.make({ + name: 'permissionGatedExample', + params: { + path: '/permission-gated-example', + loader: async () => { + const Component = () => { + const indexLink = useRouteRef(indexRouteRef); + return ( +
+

Permission Gated Page

+

+ This page is only present when the user has the{' '} + catalog.entity.create permission. +

+ {indexLink && Go back} +
+ ); + }; + return ; + }, + }, + enabled: { permissions: { $contains: 'catalog.entity.create' } }, +}); + export const pagesPlugin = createFrontendPlugin({ pluginId: 'pages', // routes: { @@ -170,5 +361,18 @@ export const pagesPlugin = createFrontendPlugin({ externalRoutes: { pageX: externalPageXRouteRef, }, - extensions: [IndexPage, Page1, ExternalPage], + featureFlags: [ + { name: 'experimental-features' }, + { name: 'advanced-features' }, + { name: 'beta-access' }, + ], + extensions: [ + IndexPage, + Page1, + ExternalPage, + FeatureFlagPage, + AllFlagsPage, + AnyFlagPage, + PermissionGatedPage, + ], }); diff --git a/packages/frontend-app-api/package.json b/packages/frontend-app-api/package.json index 1951c18828..b188f94348 100644 --- a/packages/frontend-app-api/package.json +++ b/packages/frontend-app-api/package.json @@ -36,8 +36,10 @@ "@backstage/core-app-api": "workspace:^", "@backstage/core-plugin-api": "workspace:^", "@backstage/errors": "workspace:^", + "@backstage/filter-predicates": "workspace:^", "@backstage/frontend-defaults": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", + "@backstage/plugin-permission-common": "workspace:^", "@backstage/types": "workspace:^", "@backstage/version-bridge": "workspace:^", "lodash": "^4.17.21", diff --git a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts index e52bbfa320..ac22c34696 100644 --- a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts +++ b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts @@ -1782,4 +1782,96 @@ describe('instantiateAppNodeTree', () => { }); }); }); + + describe('enabled predicate', () => { + function makeNodeWithEnabled( + enabled: AppNodeSpec['enabled'], + disabled = false, + ): AppNode { + const ext = resolveExtensionDefinition( + createExtension({ + attachTo: { id: 'ignored', input: 'ignored' }, + output: [testDataRef], + factory: () => [testDataRef('value')], + }), + { namespace: 'test-ext' }, + ); + return { + spec: { + id: ext.id, + attachTo: ext.attachTo, + disabled, + enabled, + extension: ext as Extension, + plugin: createFrontendPlugin({ pluginId: 'app' }), + }, + edges: { attachments: new Map() }, + }; + } + + it('should skip a node when the predicate is not satisfied', () => { + const node = makeNodeWithEnabled({ + featureFlags: { $contains: 'the-flag' }, + }); + const tree = resolveAppTree('test-ext', [node.spec], collector); + instantiateAppNodeTree(tree.root, testApis, collector, undefined, { + featureFlags: [], + }); + expect(tree.root.instance).toBeUndefined(); + }); + + it('should instantiate a node when the predicate is satisfied', () => { + const node = makeNodeWithEnabled({ + featureFlags: { $contains: 'the-flag' }, + }); + const tree = resolveAppTree('test-ext', [node.spec], collector); + instantiateAppNodeTree(tree.root, testApis, collector, undefined, { + featureFlags: ['the-flag'], + }); + expect(tree.root.instance).toBeDefined(); + expect(tree.root.instance?.getData(testDataRef)).toBe('value'); + }); + + it('should support $all operator across multiple flags', () => { + const node = makeNodeWithEnabled({ + $all: [ + { featureFlags: { $contains: 'flag-a' } }, + { featureFlags: { $contains: 'flag-b' } }, + ], + }); + const tree = resolveAppTree('test-ext', [node.spec], collector); + + // Only one flag active — should not instantiate + instantiateAppNodeTree(tree.root, testApis, collector, undefined, { + featureFlags: ['flag-a'], + }); + expect(tree.root.instance).toBeUndefined(); + + // Both flags active — should instantiate + const tree2 = resolveAppTree('test-ext', [node.spec], collector); + instantiateAppNodeTree(tree2.root, testApis, collector, undefined, { + featureFlags: ['flag-a', 'flag-b'], + }); + expect(tree2.root.instance).toBeDefined(); + }); + + it('should instantiate nodes without an enabled field regardless of predicateContext', () => { + const node = makeNodeWithEnabled(undefined); + const tree = resolveAppTree('test-ext', [node.spec], collector); + instantiateAppNodeTree(tree.root, testApis, collector, undefined, { + featureFlags: [], + }); + expect(tree.root.instance).toBeDefined(); + }); + + it('should instantiate nodes with enabled predicate when predicateContext is not provided', () => { + const node = makeNodeWithEnabled({ + featureFlags: { $contains: 'the-flag' }, + }); + const tree = resolveAppTree('test-ext', [node.spec], collector); + // No predicateContext passed — predicate evaluation is skipped + instantiateAppNodeTree(tree.root, testApis, collector); + expect(tree.root.instance).toBeDefined(); + }); + }); }); diff --git a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts index 85a7baa11c..d60a910487 100644 --- a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts +++ b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts @@ -29,6 +29,7 @@ import { AppNode, AppNodeInstance } from '@backstage/frontend-plugin-api'; import { toInternalExtension } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition'; import { createExtensionDataContainer } from '@internal/frontend'; import { ErrorCollector } from '../wiring/createErrorCollector'; +import { evaluateFilterPredicate } from '@backstage/filter-predicates'; const INSTANTIATION_FAILED = new Error('Instantiation failed'); @@ -509,10 +510,25 @@ export function instantiateAppNodeTree( apis: ApiHolder, collector: ErrorCollector, extensionFactoryMiddleware?: ExtensionFactoryMiddleware, - options?: { - stopAtAttachment?(ctx: { node: AppNode; input: string }): boolean; - }, + optionsOrPredicateContext?: + | { + stopAtAttachment?(ctx: { node: AppNode; input: string }): boolean; + predicateContext?: Record; + } + | Record, ): boolean { + const options: { + stopAtAttachment?(ctx: { node: AppNode; input: string }): boolean; + predicateContext?: Record; + } = + optionsOrPredicateContext && + ('stopAtAttachment' in optionsOrPredicateContext || + 'predicateContext' in optionsOrPredicateContext) + ? optionsOrPredicateContext + : { + predicateContext: optionsOrPredicateContext, + }; + function createInstance(node: AppNode): AppNodeInstance | undefined { if (node.instance) { return node.instance; @@ -520,6 +536,13 @@ export function instantiateAppNodeTree( if (node.spec.disabled) { return undefined; } + if ( + options?.predicateContext !== undefined && + node.spec.enabled !== undefined && + !evaluateFilterPredicate(node.spec.enabled, options.predicateContext) + ) { + return undefined; + } const instantiatedAttachments = new Map(); diff --git a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts index 3ccdf5258f..279ae13af1 100644 --- a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts +++ b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts @@ -15,6 +15,8 @@ */ import { + createExtension, + createExtensionDataRef, createFrontendModule, createFrontendPlugin, Extension, @@ -506,4 +508,28 @@ describe('resolveAppNodeSpecs', () => { }, ]); }); + + it('should carry enabled predicate through to AppNodeSpec', () => { + const dataRef = createExtensionDataRef().with({ id: 'test.data' }); + const enabledPredicate = { featureFlags: { $contains: 'my-flag' } }; + const plugin = createFrontendPlugin({ + pluginId: 'test-plugin', + extensions: [ + createExtension({ + attachTo: { id: 'app', input: 'root' }, + enabled: enabledPredicate, + output: [dataRef], + factory: () => [dataRef('value')], + }), + ], + }); + const specs = resolveAppNodeSpecs({ + features: [plugin], + builtinExtensions: [], + parameters: [], + collector, + }); + expect(specs).toHaveLength(1); + expect(specs[0].enabled).toEqual(enabledPredicate); + }); }); diff --git a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts index a095acee0e..005e6a05b9 100644 --- a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts +++ b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts @@ -116,6 +116,10 @@ export function resolveAppNodeSpecs(options: { source: plugin, attachTo: internalExtension.attachTo, disabled: internalExtension.disabled, + enabled: + internalExtension.version === 'v2' + ? internalExtension.enabled + : undefined, config: undefined as unknown, }, }; @@ -129,6 +133,10 @@ export function resolveAppNodeSpecs(options: { plugin: appPlugin, attachTo: internalExtension.attachTo, disabled: internalExtension.disabled, + enabled: + internalExtension.version === 'v2' + ? internalExtension.enabled + : undefined, config: undefined as unknown, }, }; @@ -148,6 +156,10 @@ export function resolveAppNodeSpecs(options: { configuredExtensions[index].extension = internalExtension; configuredExtensions[index].params.attachTo = internalExtension.attachTo; configuredExtensions[index].params.disabled = internalExtension.disabled; + configuredExtensions[index].params.enabled = + internalExtension.version === 'v2' + ? internalExtension.enabled + : undefined; } else { // Add the extension as a new one when not overriding an existing one configuredExtensions.push({ @@ -157,6 +169,10 @@ export function resolveAppNodeSpecs(options: { source: extension.plugin, attachTo: internalExtension.attachTo, disabled: internalExtension.disabled, + enabled: + internalExtension.version === 'v2' + ? internalExtension.enabled + : undefined, config: undefined, }, }); @@ -235,6 +251,7 @@ export function resolveAppNodeSpecs(options: { attachTo: param.params.attachTo, extension: param.extension, disabled: param.params.disabled, + enabled: param.params.enabled, plugin: param.params.plugin, source: param.params.source, config: param.params.config, diff --git a/packages/frontend-defaults/report.api.md b/packages/frontend-defaults/report.api.md index cfe4f55b2d..bc620c95a3 100644 --- a/packages/frontend-defaults/report.api.md +++ b/packages/frontend-defaults/report.api.md @@ -23,6 +23,7 @@ export function createApp(options?: CreateAppOptions): { // @public export interface CreateAppOptions { advanced?: { + allowUnknownExtensionConfig?: boolean; configLoader?: () => Promise<{ config: ConfigApi; }>; diff --git a/packages/frontend-defaults/src/createApp.tsx b/packages/frontend-defaults/src/createApp.tsx index c4cf43fb2e..2c1aa0519c 100644 --- a/packages/frontend-defaults/src/createApp.tsx +++ b/packages/frontend-defaults/src/createApp.tsx @@ -156,22 +156,33 @@ function PreparedAppRoot(props: { }): JSX.Element { const signIn = props.preparedApp.getSignIn(); const [finalizeError, setFinalizeError] = useState(); - const [finalizedApp, setFinalizedApp] = useState(() => { - if (!signIn) { - return props.preparedApp.finalize(); - } - return undefined; - }); + const [finalizedApp, setFinalizedApp] = useState< + ReturnType | undefined + >(undefined); useEffect(() => { let cancelled = false; + const runFinalize = async () => { + try { + const predicateContext = await props.preparedApp.buildPredicateContext(); + if (cancelled) { + return; + } + setFinalizedApp(props.preparedApp.finalize(predicateContext)); + } catch (error) { + if (cancelled) { + return; + } + setFinalizeError(error as Error); + } + }; if (signIn) { void signIn.complete - .then(async () => { + .then(() => { if (cancelled) { return; } - setFinalizedApp(props.preparedApp.finalize()); + void runFinalize(); }) .catch(error => { if (cancelled) { @@ -179,8 +190,9 @@ function PreparedAppRoot(props: { } setFinalizeError(error); }); + } else { + void runFinalize(); } - return () => { cancelled = true; }; @@ -191,7 +203,7 @@ function PreparedAppRoot(props: { } if (!finalizedApp) { - return signIn!.element; + return signIn?.element ?? <>; } return renderFinalizedApp(finalizedApp); diff --git a/packages/frontend-internal/package.json b/packages/frontend-internal/package.json index 52cdb33641..abfac8a51a 100644 --- a/packages/frontend-internal/package.json +++ b/packages/frontend-internal/package.json @@ -23,6 +23,7 @@ "test": "backstage-cli package test" }, "dependencies": { + "@backstage/filter-predicates": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", "@backstage/types": "workspace:^", "@backstage/version-bridge": "workspace:^" diff --git a/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts b/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts index d6a4eb2eab..a3f1ae8ff3 100644 --- a/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts +++ b/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts @@ -28,6 +28,7 @@ import { // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { ResolvedExtensionInputs } from '../../../frontend-plugin-api/src/wiring/createExtension'; import { OpaqueType } from '@internal/opaque'; +import { FilterPredicate } from '@backstage/filter-predicates'; export const OpaqueExtensionDefinition = OpaqueType.create<{ public: OverridableExtensionDefinition; @@ -70,6 +71,7 @@ export const OpaqueExtensionDefinition = OpaqueType.create<{ readonly name?: string; readonly attachTo: ExtensionDefinitionAttachTo; readonly disabled: boolean; + readonly enabled?: FilterPredicate; readonly configSchema?: PortableSchema; readonly inputs: { [inputName in string]: ExtensionInput }; readonly output: Array; diff --git a/packages/frontend-plugin-api/package.json b/packages/frontend-plugin-api/package.json index 568d542a38..66973d4406 100644 --- a/packages/frontend-plugin-api/package.json +++ b/packages/frontend-plugin-api/package.json @@ -45,6 +45,7 @@ }, "dependencies": { "@backstage/errors": "workspace:^", + "@backstage/filter-predicates": "workspace:^", "@backstage/types": "workspace:^", "@backstage/version-bridge": "workspace:^", "zod": "^3.25.76", diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index 74823c0639..0af67779e7 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -14,6 +14,7 @@ import { ExtensionBlueprint as ExtensionBlueprint_2 } from '@backstage/frontend- import { ExtensionBlueprintParams as ExtensionBlueprintParams_2 } from '@backstage/frontend-plugin-api'; import { ExtensionDataRef as ExtensionDataRef_2 } from '@backstage/frontend-plugin-api'; import { ExtensionInput as ExtensionInput_2 } from '@backstage/frontend-plugin-api'; +import { FilterPredicate } from '@backstage/filter-predicates'; import { JsonObject } from '@backstage/types'; import { JsonValue } from '@backstage/types'; import { JSX as JSX_2 } from 'react'; @@ -254,6 +255,8 @@ export interface AppNodeSpec { // (undocumented) readonly disabled: boolean; // (undocumented) + readonly enabled?: FilterPredicate; + // (undocumented) readonly extension: Extension; // (undocumented) readonly id: string; @@ -577,6 +580,7 @@ export type CreateExtensionBlueprintOptions< attachTo: ExtensionDefinitionAttachTo & VerifyExtensionAttachTo; disabled?: boolean; + enabled?: FilterPredicate; inputs?: TInputs; output: Array; config?: { @@ -663,6 +667,7 @@ export type CreateExtensionOptions< attachTo: ExtensionDefinitionAttachTo & VerifyExtensionAttachTo; disabled?: boolean; + enabled?: FilterPredicate; inputs?: TInputs; output: Array; config?: { @@ -1026,6 +1031,7 @@ export interface ExtensionBlueprint< attachTo?: ExtensionDefinitionAttachTo & VerifyExtensionAttachTo, UParentInputs>; disabled?: boolean; + enabled?: FilterPredicate; params: TParamsInput extends ExtensionBlueprintDefineParams ? TParamsInput : T['params'] extends ExtensionBlueprintDefineParams @@ -1061,6 +1067,7 @@ export interface ExtensionBlueprint< UParentInputs >; disabled?: boolean; + enabled?: FilterPredicate; inputs?: TExtraInputs & { [KName in keyof T['inputs']]?: `Error: Input '${KName & string}' is already defined in parent definition`; @@ -1702,6 +1709,7 @@ export interface OverridableExtensionDefinition< UParentInputs >; disabled?: boolean; + enabled?: FilterPredicate; inputs?: TExtraInputs & { [KName in keyof T['inputs']]?: `Error: Input '${KName & string}' is already defined in parent definition`; diff --git a/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts b/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts index 16369680f2..c6c367e0b5 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts @@ -17,6 +17,7 @@ import { createApiRef } from '../system'; import { FrontendPlugin, Extension, ExtensionDataRef } from '../../wiring'; import { ExtensionAttachTo } from '../../wiring/resolveExtensionDefinition'; +import { FilterPredicate } from '@backstage/filter-predicates'; /** * The specification for this {@link AppNode} in the {@link AppTree}. @@ -32,6 +33,7 @@ export interface AppNodeSpec { readonly attachTo: ExtensionAttachTo; readonly extension: Extension; readonly disabled: boolean; + readonly enabled?: FilterPredicate; readonly config?: unknown; readonly plugin: FrontendPlugin; } diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts index 0a1e9c91f8..10ca58d0da 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts @@ -36,6 +36,7 @@ import { } from './createExtensionBlueprint'; import { FrontendPlugin } from './createFrontendPlugin'; import { FrontendModule } from './createFrontendModule'; +import { FilterPredicate } from '@backstage/filter-predicates'; /** * This symbol is used to pass parameter overrides from the extension override to the blueprint factory @@ -174,6 +175,7 @@ export type CreateExtensionOptions< attachTo: ExtensionDefinitionAttachTo & VerifyExtensionAttachTo; disabled?: boolean; + enabled?: FilterPredicate; inputs?: TInputs; output: Array; config?: { @@ -255,6 +257,7 @@ export interface OverridableExtensionDefinition< UParentInputs >; disabled?: boolean; + enabled?: FilterPredicate; inputs?: TExtraInputs & { [KName in keyof T['inputs']]?: `Error: Input '${KName & string}' is already defined in parent definition`; @@ -474,6 +477,7 @@ export function createExtension< name: options.name, attachTo: options.attachTo, disabled: options.disabled ?? false, + enabled: options.enabled, inputs: bindInputs(options.inputs, options.kind, options.name), output: options.output, configSchema, @@ -551,6 +555,7 @@ export function createExtension< attachTo: (overrideOptions.attachTo ?? options.attachTo) as ExtensionDefinitionAttachTo, disabled: overrideOptions.disabled ?? options.disabled, + enabled: overrideOptions.enabled ?? options.enabled, inputs: bindInputs( { ...(options.inputs ?? {}), diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts index a7537d27e9..538a47003b 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts @@ -36,6 +36,7 @@ import { } from './resolveInputOverrides'; import { ExtensionDataContainer } from './types'; import { PageBlueprint } from '../blueprints/PageBlueprint'; +import { FilterPredicate } from '@backstage/filter-predicates'; /** * A function used to define a parameter mapping function in order to facilitate @@ -114,6 +115,7 @@ export type CreateExtensionBlueprintOptions< attachTo: ExtensionDefinitionAttachTo & VerifyExtensionAttachTo; disabled?: boolean; + enabled?: FilterPredicate; inputs?: TInputs; output: Array; config?: { @@ -221,6 +223,7 @@ export interface ExtensionBlueprint< attachTo?: ExtensionDefinitionAttachTo & VerifyExtensionAttachTo, UParentInputs>; disabled?: boolean; + enabled?: FilterPredicate; params: TParamsInput extends ExtensionBlueprintDefineParams ? TParamsInput : T['params'] extends ExtensionBlueprintDefineParams @@ -261,6 +264,7 @@ export interface ExtensionBlueprint< UParentInputs >; disabled?: boolean; + enabled?: FilterPredicate; inputs?: TExtraInputs & { [KName in keyof T['inputs']]?: `Error: Input '${KName & string}' is already defined in parent definition`; @@ -510,6 +514,7 @@ export function createExtensionBlueprint< attachTo: (args.attachTo ?? options.attachTo) as ExtensionDefinitionAttachTo, disabled: args.disabled ?? options.disabled, + enabled: args.enabled ?? options.enabled, inputs: options.inputs, output: options.output as ExtensionDataRef[], config: options.config, @@ -527,6 +532,7 @@ export function createExtensionBlueprint< attachTo: (args.attachTo ?? options.attachTo) as ExtensionDefinitionAttachTo, disabled: args.disabled ?? options.disabled, + enabled: args.enabled ?? options.enabled, inputs: { ...args.inputs, ...options.inputs }, output: (args.output ?? options.output) as ExtensionDataRef[], config: diff --git a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts index 70a0bd7837..9476c48256 100644 --- a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts +++ b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts @@ -28,6 +28,7 @@ import { OpaqueExtensionDefinition, OpaqueExtensionInput, } from '@internal/frontend'; +import { FilterPredicate } from '@backstage/filter-predicates'; /** @public */ export type ExtensionAttachTo = { id: string; input: string }; @@ -74,6 +75,7 @@ export type InternalExtension = Extension< } | { readonly version: 'v2'; + readonly enabled?: FilterPredicate; readonly inputs: { [inputName in string]: ExtensionInput }; readonly output: Array; factory(options: { diff --git a/plugins/permission-react/src/apis/IdentityPermissionApi.ts b/plugins/permission-react/src/apis/IdentityPermissionApi.ts index dc1fc3b5a3..7a3ac6482e 100644 --- a/plugins/permission-react/src/apis/IdentityPermissionApi.ts +++ b/plugins/permission-react/src/apis/IdentityPermissionApi.ts @@ -52,11 +52,18 @@ export class IdentityPermissionApi implements PermissionApi { async authorize( request: AuthorizePermissionRequest, - ): Promise { - const response = await this.permissionClient.authorize( - [request], + ): Promise; + async authorize( + requests: AuthorizePermissionRequest[], + ): Promise; + async authorize( + request: AuthorizePermissionRequest | AuthorizePermissionRequest[], + ): Promise { + const requests = Array.isArray(request) ? request : [request]; + const responses = await this.permissionClient.authorize( + requests, await this.identityApi.getCredentials(), ); - return response[0]; + return Array.isArray(request) ? responses : responses[0]; } } diff --git a/plugins/permission-react/src/apis/PermissionApi.ts b/plugins/permission-react/src/apis/PermissionApi.ts index d3a37421ef..5f538a068d 100644 --- a/plugins/permission-react/src/apis/PermissionApi.ts +++ b/plugins/permission-react/src/apis/PermissionApi.ts @@ -30,6 +30,9 @@ export type PermissionApi = { authorize( request: EvaluatePermissionRequest, ): Promise; + authorize( + requests: EvaluatePermissionRequest[], + ): Promise; }; /** diff --git a/plugins/scaffolder-backend-module-gitlab/report.api.md b/plugins/scaffolder-backend-module-gitlab/report.api.md index cbe31b02f3..aec6cb61b5 100644 --- a/plugins/scaffolder-backend-module-gitlab/report.api.md +++ b/plugins/scaffolder-backend-module-gitlab/report.api.md @@ -154,7 +154,7 @@ export const createGitlabRepoPushAction: (options: { sourcePath?: string | undefined; targetPath?: string | undefined; token?: string | undefined; - commitAction?: 'auto' | 'update' | 'delete' | 'create' | undefined; + commitAction?: 'auto' | 'update' | 'create' | 'delete' | undefined; }, { projectid: string; @@ -271,7 +271,7 @@ export const createPublishGitlabMergeRequestAction: (options: { sourcePath?: string | undefined; targetPath?: string | undefined; token?: string | undefined; - commitAction?: 'auto' | 'update' | 'delete' | 'create' | 'skip' | undefined; + commitAction?: 'auto' | 'update' | 'create' | 'delete' | 'skip' | undefined; projectid?: string | undefined; removeSourceBranch?: boolean | undefined; assignee?: string | undefined; diff --git a/yarn.lock b/yarn.lock index 63aff90ba1..80b34e1d42 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3628,10 +3628,12 @@ __metadata: "@backstage/core-app-api": "workspace:^" "@backstage/core-plugin-api": "workspace:^" "@backstage/errors": "workspace:^" + "@backstage/filter-predicates": "workspace:^" "@backstage/frontend-defaults": "workspace:^" "@backstage/frontend-plugin-api": "workspace:^" "@backstage/frontend-test-utils": "workspace:^" "@backstage/plugin-app": "workspace:^" + "@backstage/plugin-permission-common": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/types": "workspace:^" "@backstage/version-bridge": "workspace:^" @@ -3752,6 +3754,7 @@ __metadata: "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" "@backstage/errors": "workspace:^" + "@backstage/filter-predicates": "workspace:^" "@backstage/frontend-app-api": "workspace:^" "@backstage/frontend-test-utils": "workspace:^" "@backstage/test-utils": "workspace:^" @@ -9941,6 +9944,7 @@ __metadata: resolution: "@internal/frontend@workspace:packages/frontend-internal" dependencies: "@backstage/cli": "workspace:^" + "@backstage/filter-predicates": "workspace:^" "@backstage/frontend-app-api": "workspace:^" "@backstage/frontend-plugin-api": "workspace:^" "@backstage/frontend-test-utils": "workspace:^" From 9770aed23b8fad3c0e3868eb6bf34fd1a5c39414 Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Sun, 8 Mar 2026 18:14:08 -0400 Subject: [PATCH 08/68] if no signin page setup, don't try to resolve context Signed-off-by: aramissennyeydd --- packages/frontend-defaults/src/createApp.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/frontend-defaults/src/createApp.tsx b/packages/frontend-defaults/src/createApp.tsx index 2c1aa0519c..91ec883a27 100644 --- a/packages/frontend-defaults/src/createApp.tsx +++ b/packages/frontend-defaults/src/createApp.tsx @@ -191,7 +191,7 @@ function PreparedAppRoot(props: { setFinalizeError(error); }); } else { - void runFinalize(); + setFinalizedApp(props.preparedApp.finalize()); } return () => { cancelled = true; From ce97558e11e94306b6d900083d009e34bfc4ce3b Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Wed, 11 Mar 2026 09:24:26 -0400 Subject: [PATCH 09/68] rename to if and add examples of dynamic cards Signed-off-by: aramissennyeydd --- packages/app/src/examples/pagesPlugin.tsx | 137 ++++++++++++++++-- packages/frontend-app-api/package.json | 2 +- .../src/tree/instantiateAppNodeTree.test.ts | 6 +- .../src/tree/instantiateAppNodeTree.ts | 4 +- .../src/tree/resolveAppNodeSpecs.test.ts | 8 +- .../src/tree/resolveAppNodeSpecs.ts | 20 ++- packages/frontend-defaults/report.api.md | 3 +- .../src/wiring/InternalExtensionDefinition.ts | 2 +- .../src/apis/definitions/AppTreeApi.ts | 2 +- .../src/wiring/createExtension.ts | 8 +- .../src/wiring/createExtensionBlueprint.ts | 10 +- .../src/wiring/resolveExtensionDefinition.ts | 2 +- 12 files changed, 159 insertions(+), 45 deletions(-) diff --git a/packages/app/src/examples/pagesPlugin.tsx b/packages/app/src/examples/pagesPlugin.tsx index 374935e843..d9d8bbb4c3 100644 --- a/packages/app/src/examples/pagesPlugin.tsx +++ b/packages/app/src/examples/pagesPlugin.tsx @@ -23,6 +23,9 @@ import { PageBlueprint, FrontendPluginInfo, useAppNode, + createExtensionBlueprint, + createExtensionInput, + coreExtensionData, } from '@backstage/frontend-plugin-api'; import { useEffect, useState } from 'react'; import { Route, Routes } from 'react-router-dom'; @@ -88,8 +91,8 @@ const IndexPage = PageBlueprint.make({

Permission Enablement Examples

The following pages demonstrate conditional extension enablement - via the enabled predicate using permissions. They - will only appear when the user has the required permissions. + via the if predicate using permissions. They will + only appear when the user has the required permissions.

  • @@ -98,13 +101,20 @@ const IndexPage = PageBlueprint.make({ {' '} — requires catalog.entity.create
  • +
  • + + Permission Card Example + {' '} + — a page that is always visible, but individual cards on it are + toggled by permissions +

Feature Flag Enablement Examples

The following pages demonstrate conditional extension enablement - via the enabled predicate. They will only appear in - the router tree when their conditions are satisfied. Toggle the + via the if predicate. They will only appear in the + router tree when their conditions are satisfied. Toggle the relevant feature flags in Settings, then refresh the app to see the pages appear.

@@ -194,7 +204,7 @@ const ExternalPage = PageBlueprint.make({ // Example: Page enabled only when a single feature flag is active. // -// The `enabled` predicate is evaluated once at app startup (before the router +// The `if` predicate is evaluated once at app startup (before the router // tree is built), so this page simply won't exist in the app until the flag is // toggled and the page is refreshed. // @@ -227,7 +237,7 @@ const FeatureFlagPage = PageBlueprint.make({ return ; }, }, - enabled: { featureFlags: { $contains: 'experimental-features' } }, + if: { featureFlags: { $contains: 'experimental-features' } }, }); // Example: Page enabled only when ALL of several feature flags are active. @@ -263,7 +273,7 @@ const AllFlagsPage = PageBlueprint.make({ return ; }, }, - enabled: { + if: { $all: [ { featureFlags: { $contains: 'experimental-features' } }, { featureFlags: { $contains: 'advanced-features' } }, @@ -304,7 +314,7 @@ const AnyFlagPage = PageBlueprint.make({ return ; }, }, - enabled: { + if: { $any: [ { featureFlags: { $contains: 'experimental-features' } }, { featureFlags: { $contains: 'beta-access' } }, @@ -312,9 +322,113 @@ const AnyFlagPage = PageBlueprint.make({ }, }); +// Blueprint for cards that attach to the PermissionCardPage below. +// +// Each card receives a title and description and renders a simple bordered card. +// Individual card instances can be selectively enabled via the `if` +// predicate, so only the cards the user is allowed to see will be instantiated. +const PermissionExampleCardBlueprint = createExtensionBlueprint({ + kind: 'permission-example-card', + attachTo: { id: 'page:pages/permissionCardExample', input: 'cards' }, + output: [coreExtensionData.reactElement], + *factory(params: { title: string; description: string }) { + yield coreExtensionData.reactElement( +
+

{params.title}

+

{params.description}

+
, + ); + }, +}); + +// Example: Page with cards that are individually toggled by permissions. +// +// The page itself is always present. What changes is which cards are +// instantiated inside it — each card declares its own `enabled` predicate +// and is only wired into the page if that predicate is satisfied at startup. +// +// To test: make sure you do NOT have the catalog.entity.create permission and +// refresh the page — the "Restricted Card" below should disappear. +const PermissionCardPage = PageBlueprint.makeWithOverrides({ + name: 'permissionCardExample', + inputs: { + cards: createExtensionInput([coreExtensionData.reactElement]), + }, + factory(originalFactory, { inputs }) { + return originalFactory({ + path: '/permission-card-example', + loader: async () => { + const Component = () => { + const indexLink = useRouteRef(indexRouteRef); + const cards = inputs.cards.map(card => + card.get(coreExtensionData.reactElement), + ); + return ( +
+

Permission-Gated Card Example

+

+ This page is always visible. The cards below are individually + gated — each one declares its own{' '} + {'if: { permissions: { $contains: "..." } }'}{' '} + predicate. Cards whose predicate fails are never instantiated, + so they simply won't appear here. +

+
+ {cards.length > 0 ? ( + cards + ) : ( +

+ No cards are visible — you may lack the required + permissions. +

+ )} +
+ {indexLink && Go back} +
+ ); + }; + return ; + }, + }); + }, +}); + +// Always-visible card — no predicate, every user sees this. +const PublicCard = PermissionExampleCardBlueprint.make({ + name: 'public', + params: { + title: 'Public Card', + description: 'This card is visible to everyone regardless of permissions.', + }, +}); + +// Permission-gated card — only instantiated when the user has +// the catalog.entity.create permission. +const RestrictedCard = PermissionExampleCardBlueprint.make({ + name: 'restricted', + params: { + title: 'Restricted Card', + description: + 'This card is only visible to users who have the catalog.entity.create permission.', + }, + if: { permissions: { $contains: 'catalog.entity.create' } }, +}); + // Example: Page enabled only when the user is allowed to create catalog entities. // -// The `enabled` predicate is evaluated once at app startup (after sign-in), +// The `if` predicate is evaluated once at app startup (after sign-in), // so this page simply won't exist in the router tree if the user lacks the // required permission. const PermissionGatedPage = PageBlueprint.make({ @@ -338,7 +452,7 @@ const PermissionGatedPage = PageBlueprint.make({ return ; }, }, - enabled: { permissions: { $contains: 'catalog.entity.create' } }, + if: { permissions: { $contains: 'catalog.entity.create' } }, }); export const pagesPlugin = createFrontendPlugin({ @@ -373,6 +487,9 @@ export const pagesPlugin = createFrontendPlugin({ FeatureFlagPage, AllFlagsPage, AnyFlagPage, + PermissionCardPage, + PublicCard, + RestrictedCard, PermissionGatedPage, ], }); diff --git a/packages/frontend-app-api/package.json b/packages/frontend-app-api/package.json index b188f94348..f5dea017a9 100644 --- a/packages/frontend-app-api/package.json +++ b/packages/frontend-app-api/package.json @@ -39,7 +39,6 @@ "@backstage/filter-predicates": "workspace:^", "@backstage/frontend-defaults": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", - "@backstage/plugin-permission-common": "workspace:^", "@backstage/types": "workspace:^", "@backstage/version-bridge": "workspace:^", "lodash": "^4.17.21", @@ -49,6 +48,7 @@ "@backstage/cli": "workspace:^", "@backstage/frontend-test-utils": "workspace:^", "@backstage/plugin-app": "workspace:^", + "@backstage/plugin-permission-common": "workspace:^", "@backstage/test-utils": "workspace:^", "@testing-library/jest-dom": "^6.0.0", "@testing-library/react": "^16.0.0", diff --git a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts index ac22c34696..f5fc9f2e3a 100644 --- a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts +++ b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts @@ -1783,9 +1783,9 @@ describe('instantiateAppNodeTree', () => { }); }); - describe('enabled predicate', () => { + describe('if predicate', () => { function makeNodeWithEnabled( - enabled: AppNodeSpec['enabled'], + enabled: AppNodeSpec['if'], disabled = false, ): AppNode { const ext = resolveExtensionDefinition( @@ -1801,7 +1801,7 @@ describe('instantiateAppNodeTree', () => { id: ext.id, attachTo: ext.attachTo, disabled, - enabled, + if: enabled, extension: ext as Extension, plugin: createFrontendPlugin({ pluginId: 'app' }), }, diff --git a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts index d60a910487..5c9062dd06 100644 --- a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts +++ b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts @@ -538,8 +538,8 @@ export function instantiateAppNodeTree( } if ( options?.predicateContext !== undefined && - node.spec.enabled !== undefined && - !evaluateFilterPredicate(node.spec.enabled, options.predicateContext) + node.spec.if !== undefined && + !evaluateFilterPredicate(node.spec.if, options.predicateContext) ) { return undefined; } diff --git a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts index 279ae13af1..1dedac8e43 100644 --- a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts +++ b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts @@ -509,15 +509,15 @@ describe('resolveAppNodeSpecs', () => { ]); }); - it('should carry enabled predicate through to AppNodeSpec', () => { + it('should carry if predicate through to AppNodeSpec', () => { const dataRef = createExtensionDataRef().with({ id: 'test.data' }); - const enabledPredicate = { featureFlags: { $contains: 'my-flag' } }; + const ifPredicate = { featureFlags: { $contains: 'my-flag' } }; const plugin = createFrontendPlugin({ pluginId: 'test-plugin', extensions: [ createExtension({ attachTo: { id: 'app', input: 'root' }, - enabled: enabledPredicate, + if: ifPredicate, output: [dataRef], factory: () => [dataRef('value')], }), @@ -530,6 +530,6 @@ describe('resolveAppNodeSpecs', () => { collector, }); expect(specs).toHaveLength(1); - expect(specs[0].enabled).toEqual(enabledPredicate); + expect(specs[0].if).toEqual(ifPredicate); }); }); diff --git a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts index 005e6a05b9..874df7b8bd 100644 --- a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts +++ b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts @@ -116,9 +116,9 @@ export function resolveAppNodeSpecs(options: { source: plugin, attachTo: internalExtension.attachTo, disabled: internalExtension.disabled, - enabled: + if: internalExtension.version === 'v2' - ? internalExtension.enabled + ? internalExtension.if : undefined, config: undefined as unknown, }, @@ -133,9 +133,9 @@ export function resolveAppNodeSpecs(options: { plugin: appPlugin, attachTo: internalExtension.attachTo, disabled: internalExtension.disabled, - enabled: + if: internalExtension.version === 'v2' - ? internalExtension.enabled + ? internalExtension.if : undefined, config: undefined as unknown, }, @@ -156,10 +156,8 @@ export function resolveAppNodeSpecs(options: { configuredExtensions[index].extension = internalExtension; configuredExtensions[index].params.attachTo = internalExtension.attachTo; configuredExtensions[index].params.disabled = internalExtension.disabled; - configuredExtensions[index].params.enabled = - internalExtension.version === 'v2' - ? internalExtension.enabled - : undefined; + configuredExtensions[index].params.if = + internalExtension.version === 'v2' ? internalExtension.if : undefined; } else { // Add the extension as a new one when not overriding an existing one configuredExtensions.push({ @@ -169,9 +167,9 @@ export function resolveAppNodeSpecs(options: { source: extension.plugin, attachTo: internalExtension.attachTo, disabled: internalExtension.disabled, - enabled: + if: internalExtension.version === 'v2' - ? internalExtension.enabled + ? internalExtension.if : undefined, config: undefined, }, @@ -251,7 +249,7 @@ export function resolveAppNodeSpecs(options: { attachTo: param.params.attachTo, extension: param.extension, disabled: param.params.disabled, - enabled: param.params.enabled, + if: param.params.if, plugin: param.params.plugin, source: param.params.source, config: param.params.config, diff --git a/packages/frontend-defaults/report.api.md b/packages/frontend-defaults/report.api.md index bc620c95a3..72bd0d785e 100644 --- a/packages/frontend-defaults/report.api.md +++ b/packages/frontend-defaults/report.api.md @@ -8,7 +8,7 @@ import { AppErrorTypes } from '@backstage/frontend-app-api'; import { Config } from '@backstage/config'; import { ConfigApi } from '@backstage/frontend-plugin-api'; import { CreateAppRouteBinder } from '@backstage/frontend-app-api'; -import { ExtensionFactoryMiddleware } from '@backstage/frontend-plugin-api'; +import { ExtensionFactoryMiddleware } from '@backstage/frontend-app-api'; import { FrontendFeature } from '@backstage/frontend-plugin-api'; import { FrontendFeatureLoader } from '@backstage/frontend-plugin-api'; import { FrontendPluginInfoResolver } from '@backstage/frontend-app-api'; @@ -23,7 +23,6 @@ export function createApp(options?: CreateAppOptions): { // @public export interface CreateAppOptions { advanced?: { - allowUnknownExtensionConfig?: boolean; configLoader?: () => Promise<{ config: ConfigApi; }>; diff --git a/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts b/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts index a3f1ae8ff3..dcadccb305 100644 --- a/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts +++ b/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts @@ -71,7 +71,7 @@ export const OpaqueExtensionDefinition = OpaqueType.create<{ readonly name?: string; readonly attachTo: ExtensionDefinitionAttachTo; readonly disabled: boolean; - readonly enabled?: FilterPredicate; + readonly if?: FilterPredicate; readonly configSchema?: PortableSchema; readonly inputs: { [inputName in string]: ExtensionInput }; readonly output: Array; diff --git a/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts b/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts index c6c367e0b5..901216b554 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts @@ -33,7 +33,7 @@ export interface AppNodeSpec { readonly attachTo: ExtensionAttachTo; readonly extension: Extension; readonly disabled: boolean; - readonly enabled?: FilterPredicate; + readonly if?: FilterPredicate; readonly config?: unknown; readonly plugin: FrontendPlugin; } diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts index 10ca58d0da..6f3e87acfd 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts @@ -175,7 +175,7 @@ export type CreateExtensionOptions< attachTo: ExtensionDefinitionAttachTo & VerifyExtensionAttachTo; disabled?: boolean; - enabled?: FilterPredicate; + if?: FilterPredicate; inputs?: TInputs; output: Array; config?: { @@ -257,7 +257,7 @@ export interface OverridableExtensionDefinition< UParentInputs >; disabled?: boolean; - enabled?: FilterPredicate; + if?: FilterPredicate; inputs?: TExtraInputs & { [KName in keyof T['inputs']]?: `Error: Input '${KName & string}' is already defined in parent definition`; @@ -477,7 +477,7 @@ export function createExtension< name: options.name, attachTo: options.attachTo, disabled: options.disabled ?? false, - enabled: options.enabled, + if: options.if, inputs: bindInputs(options.inputs, options.kind, options.name), output: options.output, configSchema, @@ -555,7 +555,7 @@ export function createExtension< attachTo: (overrideOptions.attachTo ?? options.attachTo) as ExtensionDefinitionAttachTo, disabled: overrideOptions.disabled ?? options.disabled, - enabled: overrideOptions.enabled ?? options.enabled, + if: overrideOptions.if ?? options.if, inputs: bindInputs( { ...(options.inputs ?? {}), diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts index 538a47003b..2d2288b120 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts @@ -115,7 +115,7 @@ export type CreateExtensionBlueprintOptions< attachTo: ExtensionDefinitionAttachTo & VerifyExtensionAttachTo; disabled?: boolean; - enabled?: FilterPredicate; + if?: FilterPredicate; inputs?: TInputs; output: Array; config?: { @@ -223,7 +223,7 @@ export interface ExtensionBlueprint< attachTo?: ExtensionDefinitionAttachTo & VerifyExtensionAttachTo, UParentInputs>; disabled?: boolean; - enabled?: FilterPredicate; + if?: FilterPredicate; params: TParamsInput extends ExtensionBlueprintDefineParams ? TParamsInput : T['params'] extends ExtensionBlueprintDefineParams @@ -264,7 +264,7 @@ export interface ExtensionBlueprint< UParentInputs >; disabled?: boolean; - enabled?: FilterPredicate; + if?: FilterPredicate; inputs?: TExtraInputs & { [KName in keyof T['inputs']]?: `Error: Input '${KName & string}' is already defined in parent definition`; @@ -514,7 +514,7 @@ export function createExtensionBlueprint< attachTo: (args.attachTo ?? options.attachTo) as ExtensionDefinitionAttachTo, disabled: args.disabled ?? options.disabled, - enabled: args.enabled ?? options.enabled, + if: args.if ?? options.if, inputs: options.inputs, output: options.output as ExtensionDataRef[], config: options.config, @@ -532,7 +532,7 @@ export function createExtensionBlueprint< attachTo: (args.attachTo ?? options.attachTo) as ExtensionDefinitionAttachTo, disabled: args.disabled ?? options.disabled, - enabled: args.enabled ?? options.enabled, + if: args.if ?? options.if, inputs: { ...args.inputs, ...options.inputs }, output: (args.output ?? options.output) as ExtensionDataRef[], config: diff --git a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts index 9476c48256..a14c4e7863 100644 --- a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts +++ b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts @@ -75,7 +75,7 @@ export type InternalExtension = Extension< } | { readonly version: 'v2'; - readonly enabled?: FilterPredicate; + readonly if?: FilterPredicate; readonly inputs: { [inputName in string]: ExtensionInput }; readonly output: Array; factory(options: { From 10f8fa1df86db689a1073ecae7401e0dc88055c2 Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Wed, 11 Mar 2026 09:42:56 -0400 Subject: [PATCH 10/68] migrate to dataloader for batch permissions fetching Signed-off-by: aramissennyeydd --- packages/app/src/examples/pagesPlugin.tsx | 13 ++++++++ plugins/permission-react/package.json | 1 + .../src/apis/IdentityPermissionApi.ts | 30 +++++++++++-------- yarn.lock | 1 + 4 files changed, 33 insertions(+), 12 deletions(-) diff --git a/packages/app/src/examples/pagesPlugin.tsx b/packages/app/src/examples/pagesPlugin.tsx index d9d8bbb4c3..531d0084fb 100644 --- a/packages/app/src/examples/pagesPlugin.tsx +++ b/packages/app/src/examples/pagesPlugin.tsx @@ -426,6 +426,17 @@ const RestrictedCard = PermissionExampleCardBlueprint.make({ if: { permissions: { $contains: 'catalog.entity.create' } }, }); +// Feature flag-gated card — only instantiated when the user has +// the experimental-card FF enabled. +const FeatureFlagCard = PermissionExampleCardBlueprint.make({ + name: 'feature-flag', + params: { + title: 'Feature Flagged Card', + description: 'Visible only with the experimental-card FF active.', + }, + if: { featureFlags: { $contains: 'experimental-card' } }, +}); + // Example: Page enabled only when the user is allowed to create catalog entities. // // The `if` predicate is evaluated once at app startup (after sign-in), @@ -479,6 +490,7 @@ export const pagesPlugin = createFrontendPlugin({ { name: 'experimental-features' }, { name: 'advanced-features' }, { name: 'beta-access' }, + { name: 'experimental-card' }, ], extensions: [ IndexPage, @@ -491,5 +503,6 @@ export const pagesPlugin = createFrontendPlugin({ PublicCard, RestrictedCard, PermissionGatedPage, + FeatureFlagCard, ], }); diff --git a/plugins/permission-react/package.json b/plugins/permission-react/package.json index eb98150e64..e8fd21305a 100644 --- a/plugins/permission-react/package.json +++ b/plugins/permission-react/package.json @@ -45,6 +45,7 @@ "@backstage/config": "workspace:^", "@backstage/core-plugin-api": "workspace:^", "@backstage/plugin-permission-common": "workspace:^", + "dataloader": "^2.0.0", "swr": "^2.0.0" }, "devDependencies": { diff --git a/plugins/permission-react/src/apis/IdentityPermissionApi.ts b/plugins/permission-react/src/apis/IdentityPermissionApi.ts index 7a3ac6482e..6e848bd778 100644 --- a/plugins/permission-react/src/apis/IdentityPermissionApi.ts +++ b/plugins/permission-react/src/apis/IdentityPermissionApi.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import DataLoader from 'dataloader'; import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api'; import { PermissionApi } from './PermissionApi'; import { @@ -24,20 +25,27 @@ import { import { Config } from '@backstage/config'; /** - * The default implementation of the PermissionApi, which simply calls the authorize method of the given - * {@link @backstage/plugin-permission-common#PermissionClient}. + * The default implementation of the PermissionApi, which batches calls to + * {@link @backstage/plugin-permission-common#PermissionClient} that are made + * within the same microtask into a single HTTP request. * @public */ export class IdentityPermissionApi implements PermissionApi { - private readonly permissionClient: PermissionClient; - private readonly identityApi: IdentityApi; + private readonly loader: DataLoader< + AuthorizePermissionRequest, + AuthorizePermissionResponse + >; private constructor( permissionClient: PermissionClient, identityApi: IdentityApi, ) { - this.permissionClient = permissionClient; - this.identityApi = identityApi; + this.loader = new DataLoader( + async (requests: readonly AuthorizePermissionRequest[]) => { + const credentials = await identityApi.getCredentials(); + return permissionClient.authorize([...requests], credentials); + }, + ); } static create(options: { @@ -59,11 +67,9 @@ export class IdentityPermissionApi implements PermissionApi { async authorize( request: AuthorizePermissionRequest | AuthorizePermissionRequest[], ): Promise { - const requests = Array.isArray(request) ? request : [request]; - const responses = await this.permissionClient.authorize( - requests, - await this.identityApi.getCredentials(), - ); - return Array.isArray(request) ? responses : responses[0]; + if (Array.isArray(request)) { + return Promise.all(request.map(r => this.loader.load(r))); + } + return this.loader.load(request); } } diff --git a/yarn.lock b/yarn.lock index 80b34e1d42..191891148d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6454,6 +6454,7 @@ __metadata: "@testing-library/jest-dom": "npm:^6.0.0" "@testing-library/react": "npm:^16.0.0" "@types/react": "npm:^18.0.0" + dataloader: "npm:^2.0.0" react: "npm:^18.0.2" react-dom: "npm:^18.0.2" react-router-dom: "npm:^6.30.2" From 0ed572576e47d6b52dd7d4f0c99f6a602e8fd98d Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Wed, 11 Mar 2026 10:04:49 -0400 Subject: [PATCH 11/68] delete changesets Signed-off-by: aramissennyeydd --- .changeset/ff-perm-enabled-frontend-app-api.md | 7 ------- .changeset/ff-perm-enabled-frontend-defaults.md | 5 ----- .changeset/ff-perm-enabled-frontend-plugin-api.md | 7 ------- 3 files changed, 19 deletions(-) delete mode 100644 .changeset/ff-perm-enabled-frontend-app-api.md delete mode 100644 .changeset/ff-perm-enabled-frontend-defaults.md delete mode 100644 .changeset/ff-perm-enabled-frontend-plugin-api.md diff --git a/.changeset/ff-perm-enabled-frontend-app-api.md b/.changeset/ff-perm-enabled-frontend-app-api.md deleted file mode 100644 index dc3bba03ba..0000000000 --- a/.changeset/ff-perm-enabled-frontend-app-api.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@backstage/frontend-app-api': patch ---- - -Extensions with an `enabled` predicate are now evaluated before app tree instantiation, so pages that do not meet their conditions are excluded from the router tree. - -`prepareSpecializedApp().finalize()` is now async. Extensions whose `enabled` predicate references `permissions` are checked against the current user's allowed permissions (via a single batched call to `permissionApiRef`) before the app tree is instantiated. diff --git a/.changeset/ff-perm-enabled-frontend-defaults.md b/.changeset/ff-perm-enabled-frontend-defaults.md deleted file mode 100644 index c811cad8eb..0000000000 --- a/.changeset/ff-perm-enabled-frontend-defaults.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/frontend-defaults': patch ---- - -Updated `createApp` to await the now-async `finalize()` call from `prepareSpecializedApp`, enabling permission-gated extensions to be resolved before the app tree is rendered. diff --git a/.changeset/ff-perm-enabled-frontend-plugin-api.md b/.changeset/ff-perm-enabled-frontend-plugin-api.md deleted file mode 100644 index 67fd93eb19..0000000000 --- a/.changeset/ff-perm-enabled-frontend-plugin-api.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@backstage/frontend-plugin-api': patch ---- - -Added `enabled` option to `createExtension`, `createExtensionBlueprint`, and `AppNodeSpec`, accepting a `FilterPredicate` from `@backstage/filter-predicates` to conditionally enable extensions based on feature flags or other runtime conditions. - -Added `permissions` option to `createFrontendPlugin` and `createFrontendModule`, allowing plugins and modules to declare which permissions they use. These permissions are checked at app startup so that extensions can conditionally enable themselves using a `permissions` predicate, e.g. `enabled: { permissions: { $contains: 'catalog.entity.create' } }`. From 7c92a250ec0cd3cd0371380c63a7bca9ab1187b7 Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Wed, 11 Mar 2026 10:06:54 -0400 Subject: [PATCH 12/68] fix lint warning Signed-off-by: aramissennyeydd --- packages/core-compat-api/package.json | 1 + yarn.lock | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/core-compat-api/package.json b/packages/core-compat-api/package.json index 152b6d1141..6ee93be875 100644 --- a/packages/core-compat-api/package.json +++ b/packages/core-compat-api/package.json @@ -33,6 +33,7 @@ "dependencies": { "@backstage/core-plugin-api": "workspace:^", "@backstage/errors": "workspace:^", + "@backstage/filter-predicates": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", "@backstage/plugin-app-react": "workspace:^", "@backstage/plugin-catalog-react": "workspace:^", diff --git a/yarn.lock b/yarn.lock index 191891148d..7ec35b7e9b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3359,6 +3359,7 @@ __metadata: "@backstage/core-app-api": "workspace:^" "@backstage/core-plugin-api": "workspace:^" "@backstage/errors": "workspace:^" + "@backstage/filter-predicates": "workspace:^" "@backstage/frontend-app-api": "workspace:^" "@backstage/frontend-plugin-api": "workspace:^" "@backstage/frontend-test-utils": "workspace:^" From 4958e320b6f335071f0bec5e4d1a1433f7bb2caf Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Wed, 11 Mar 2026 10:08:28 -0400 Subject: [PATCH 13/68] fix other lint warning Signed-off-by: aramissennyeydd --- plugins/app/package.json | 1 + yarn.lock | 1 + 2 files changed, 2 insertions(+) diff --git a/plugins/app/package.json b/plugins/app/package.json index 170846c584..a7d03eb61f 100644 --- a/plugins/app/package.json +++ b/plugins/app/package.json @@ -53,6 +53,7 @@ "dependencies": { "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", + "@backstage/filter-predicates": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", "@backstage/integration-react": "workspace:^", "@backstage/plugin-app-react": "workspace:^", diff --git a/yarn.lock b/yarn.lock index 7ec35b7e9b..d9f53d1439 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4100,6 +4100,7 @@ __metadata: "@backstage/core-components": "workspace:^" "@backstage/core-plugin-api": "workspace:^" "@backstage/dev-utils": "workspace:^" + "@backstage/filter-predicates": "workspace:^" "@backstage/frontend-defaults": "workspace:^" "@backstage/frontend-plugin-api": "workspace:^" "@backstage/frontend-test-utils": "workspace:^" From 6e198233c2482da2bed91b16e1c0cbc3c59d6488 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 12 Mar 2026 14:24:30 +0100 Subject: [PATCH 14/68] frontend-defaults: prettier Signed-off-by: Patrik Oldsberg --- packages/frontend-defaults/src/createApp.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/frontend-defaults/src/createApp.tsx b/packages/frontend-defaults/src/createApp.tsx index 91ec883a27..8a47088f62 100644 --- a/packages/frontend-defaults/src/createApp.tsx +++ b/packages/frontend-defaults/src/createApp.tsx @@ -164,7 +164,8 @@ function PreparedAppRoot(props: { let cancelled = false; const runFinalize = async () => { try { - const predicateContext = await props.preparedApp.buildPredicateContext(); + const predicateContext = + await props.preparedApp.buildPredicateContext(); if (cancelled) { return; } From 2325e4bf48fb4621029ab5afd5a827ce138923ea Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 12 Mar 2026 14:38:12 +0100 Subject: [PATCH 15/68] frontend-app-api: integrate predicate context into app phases Compute and cache extension predicate context as part of app phase transitions so sign-in and non-sign-in flows finalize against the same gating state. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../src/wiring/createSpecializedApp.test.tsx | 80 +++++++++++++++++++ .../frontend-defaults/src/createApp.test.tsx | 50 ++++++++++++ packages/frontend-defaults/src/createApp.tsx | 27 ++----- 3 files changed, 138 insertions(+), 19 deletions(-) diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx index cd1356d4cb..1695897a14 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx @@ -1731,6 +1731,86 @@ describe('createSpecializedApp', () => { ); }); + 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
), + ], + }), + ], + }); + + 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; + }) { + useEffect(() => { + props.onSignInSuccess(identityApi); + }, [props]); + return
Custom Sign In
; + } + + return [signInPageComponentDataRef(SignInPage)]; + }, + }), + ], + }), + ], + }); + + const signIn = preparedApp.getSignIn(); + render(signIn!.element); + await expect( + screen.findByText('Custom Sign In'), + ).resolves.toBeInTheDocument(); + + await signIn!.complete; + expect(featureFlagsApi.isActive).toHaveBeenCalledWith('test-flag'); + + const finalizedApp = preparedApp.finalize(); + render( + finalizedApp.tree.root.instance!.getData( + coreExtensionData.reactElement, + ), + ); + + expect(screen.getByText('Flagged Layout')).toBeInTheDocument(); + }); + it('should gate finalize behind internal async sign-in finalization', async () => { const identityApi = { getProfileInfo: async () => ({ displayName: 'Test User' }), diff --git a/packages/frontend-defaults/src/createApp.test.tsx b/packages/frontend-defaults/src/createApp.test.tsx index af8517624a..540fb5e41c 100644 --- a/packages/frontend-defaults/src/createApp.test.tsx +++ b/packages/frontend-defaults/src/createApp.test.tsx @@ -386,6 +386,56 @@ describe('createApp', () => { ).resolves.toBeInTheDocument(); }); + it('should evaluate extension if predicates before rendering apps 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 app = createApp({ + advanced: { + configLoader: async () => ({ config: mockApis.config() }), + }, + features: [ + appPlugin, + createFrontendModule({ + pluginId: 'app', + extensions: [ + ApiBlueprint.make({ + params: defineParams => + defineParams({ + api: featureFlagsApiRef, + deps: {}, + factory: () => featureFlagsApi, + }), + }), + ], + }), + createFrontendPlugin({ + pluginId: 'test', + featureFlags: [{ name: 'test-flag' }], + extensions: [ + PageBlueprint.make({ + if: { featureFlags: { $contains: 'test-flag' } }, + params: { + path: '/', + loader: async () =>
Flagged Page
, + }, + }), + ], + }), + ], + }); + + await renderWithEffects(app.createRoot()); + + await expect( + screen.findByText('Flagged Page'), + ).resolves.toBeInTheDocument(); + expect(featureFlagsApi.isActive).toHaveBeenCalledWith('test-flag'); + }); + it('should make the app structure available through the AppTreeApi', async () => { let appTreeApi: AppTreeApi | undefined = undefined; diff --git a/packages/frontend-defaults/src/createApp.tsx b/packages/frontend-defaults/src/createApp.tsx index 8a47088f62..50f8db64f7 100644 --- a/packages/frontend-defaults/src/createApp.tsx +++ b/packages/frontend-defaults/src/createApp.tsx @@ -133,6 +133,8 @@ export function createApp(options?: CreateAppOptions): { }; } + await preparedApp.buildPredicateContext(); + return { default: () => renderFinalizedApp(preparedApp.finalize()), }; @@ -164,12 +166,15 @@ function PreparedAppRoot(props: { let cancelled = false; const runFinalize = async () => { try { - const predicateContext = + if (signIn) { + await signIn.complete; + } else { await props.preparedApp.buildPredicateContext(); + } if (cancelled) { return; } - setFinalizedApp(props.preparedApp.finalize(predicateContext)); + setFinalizedApp(props.preparedApp.finalize()); } catch (error) { if (cancelled) { return; @@ -177,23 +182,7 @@ function PreparedAppRoot(props: { setFinalizeError(error as Error); } }; - if (signIn) { - void signIn.complete - .then(() => { - if (cancelled) { - return; - } - void runFinalize(); - }) - .catch(error => { - if (cancelled) { - return; - } - setFinalizeError(error); - }); - } else { - setFinalizedApp(props.preparedApp.finalize()); - } + void runFinalize(); return () => { cancelled = true; }; From b14e301bb6dcbf13aa4d680b7d5fe4da01e18f4f Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 12 Mar 2026 15:49:34 +0100 Subject: [PATCH 16/68] frontend-app-api: clean up session boundary initialization Make the bootstrap-visible app slice explicit so APIs and predicate validation follow the same session boundary rules before the full tree is finalized. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../src/wiring/createSpecializedApp.test.tsx | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx index 1695897a14..788fc6bbc2 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx @@ -1800,6 +1800,7 @@ describe('createSpecializedApp', () => { await signIn!.complete; expect(featureFlagsApi.isActive).toHaveBeenCalledWith('test-flag'); + expect(featureFlagsApi.isActive).toHaveBeenCalledTimes(1); const finalizedApp = preparedApp.finalize(); render( @@ -1811,6 +1812,26 @@ describe('createSpecializedApp', () => { expect(screen.getByText('Flagged Layout')).toBeInTheDocument(); }); + it('should reject bootstrap-visible extensions that use if predicates', () => { + expect(() => + prepareSpecializedApp({ + features: [ + appPluginOriginal, + createFrontendModule({ + pluginId: 'app', + extensions: [ + appPluginOriginal.getExtension('sign-in-page:app').override({ + if: { featureFlags: { $contains: 'test-flag' } }, + }), + ], + }), + ], + }), + ).toThrow( + "Extension 'sign-in-page:app' uses 'if' before the session boundary at 'app/root.children'. Move it behind the session boundary or remove the predicate.", + ); + }); + it('should gate finalize behind internal async sign-in finalization', async () => { const identityApi = { getProfileInfo: async () => ({ displayName: 'Test User' }), From 94e91d9de3c2a9aebb67deddea787ebb8093ea26 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 12 Mar 2026 18:14:14 +0100 Subject: [PATCH 17/68] frontend-app-api: make session state opaque Replace exposed prepared app APIs and predicate plumbing with a reusable opaque session state so apps can skip sign-in without leaking internals. Align the specialized app flow around getSignIn().ready and finalize(sessionState) for explicit session bootstrap and reuse. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../prepare-specialized-app-signin-flow.md | 2 +- .../src/wiring/createSpecializedApp.test.tsx | 101 ------------------ packages/frontend-defaults/src/createApp.tsx | 17 ++- 3 files changed, 8 insertions(+), 112 deletions(-) diff --git a/.changeset/prepare-specialized-app-signin-flow.md b/.changeset/prepare-specialized-app-signin-flow.md index 03f37b7107..31d5bb1b8d 100644 --- a/.changeset/prepare-specialized-app-signin-flow.md +++ b/.changeset/prepare-specialized-app-signin-flow.md @@ -3,4 +3,4 @@ '@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. +Adds `prepareSpecializedApp` as a new two-phase app wiring API for rendering a sign-in page before full app finalization. Session preparation now resolves to an opaque reusable `sessionState`, which is returned from `getSignIn().ready` and from `finalize()`, and can be passed into a future `prepareSpecializedApp` call to skip sign-in and reuse the prepared session. 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/src/wiring/createSpecializedApp.test.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx index 788fc6bbc2..cd1356d4cb 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx @@ -1731,107 +1731,6 @@ describe('createSpecializedApp', () => { ); }); - 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
), - ], - }), - ], - }); - - 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; - }) { - useEffect(() => { - props.onSignInSuccess(identityApi); - }, [props]); - return
Custom Sign In
; - } - - return [signInPageComponentDataRef(SignInPage)]; - }, - }), - ], - }), - ], - }); - - const signIn = preparedApp.getSignIn(); - render(signIn!.element); - await expect( - screen.findByText('Custom Sign In'), - ).resolves.toBeInTheDocument(); - - await signIn!.complete; - expect(featureFlagsApi.isActive).toHaveBeenCalledWith('test-flag'); - expect(featureFlagsApi.isActive).toHaveBeenCalledTimes(1); - - const finalizedApp = preparedApp.finalize(); - render( - finalizedApp.tree.root.instance!.getData( - coreExtensionData.reactElement, - ), - ); - - expect(screen.getByText('Flagged Layout')).toBeInTheDocument(); - }); - - it('should reject bootstrap-visible extensions that use if predicates', () => { - expect(() => - prepareSpecializedApp({ - features: [ - appPluginOriginal, - createFrontendModule({ - pluginId: 'app', - extensions: [ - appPluginOriginal.getExtension('sign-in-page:app').override({ - if: { featureFlags: { $contains: 'test-flag' } }, - }), - ], - }), - ], - }), - ).toThrow( - "Extension 'sign-in-page:app' uses 'if' before the session boundary at 'app/root.children'. Move it behind the session boundary or remove the predicate.", - ); - }); - it('should gate finalize behind internal async sign-in finalization', async () => { const identityApi = { getProfileInfo: async () => ({ displayName: 'Test User' }), diff --git a/packages/frontend-defaults/src/createApp.tsx b/packages/frontend-defaults/src/createApp.tsx index 50f8db64f7..75d33282e7 100644 --- a/packages/frontend-defaults/src/createApp.tsx +++ b/packages/frontend-defaults/src/createApp.tsx @@ -126,17 +126,18 @@ export function createApp(options?: CreateAppOptions): { bindRoutes: options?.bindRoutes, advanced: options?.advanced, }); + const signIn = preparedApp.getSignIn(); - if (preparedApp.getSignIn()) { + if (signIn.element) { return { default: () => , }; } - await preparedApp.buildPredicateContext(); + const { sessionState } = await signIn.ready; return { - default: () => renderFinalizedApp(preparedApp.finalize()), + default: () => renderFinalizedApp(preparedApp.finalize(sessionState)), }; } @@ -166,15 +167,11 @@ function PreparedAppRoot(props: { let cancelled = false; const runFinalize = async () => { try { - if (signIn) { - await signIn.complete; - } else { - await props.preparedApp.buildPredicateContext(); - } + const { sessionState } = await signIn.ready; if (cancelled) { return; } - setFinalizedApp(props.preparedApp.finalize()); + setFinalizedApp(props.preparedApp.finalize(sessionState)); } catch (error) { if (cancelled) { return; @@ -193,7 +190,7 @@ function PreparedAppRoot(props: { } if (!finalizedApp) { - return signIn?.element ?? <>; + return signIn.element ?? <>; } return renderFinalizedApp(finalizedApp); From f4c03772a46042ff32da0f8241e1b2c889104bd8 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 12 Mar 2026 21:59:25 +0100 Subject: [PATCH 18/68] frontend-app-api: internalize prepared session state Move prepared app session ownership into frontend-app-api so bootstrap only signals readiness while callers use tryFinalize or finalize to read the finalized app state. This reduces createApp boilerplate and keeps the specialized app lifecycle centered in the lower-level API. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../prepare-specialized-app-signin-flow.md | 2 +- packages/frontend-app-api/src/wiring/index.ts | 3 + packages/frontend-defaults/src/createApp.tsx | 65 ++++++------------- 3 files changed, 24 insertions(+), 46 deletions(-) diff --git a/.changeset/prepare-specialized-app-signin-flow.md b/.changeset/prepare-specialized-app-signin-flow.md index 31d5bb1b8d..746df0d4fe 100644 --- a/.changeset/prepare-specialized-app-signin-flow.md +++ b/.changeset/prepare-specialized-app-signin-flow.md @@ -3,4 +3,4 @@ '@backstage/frontend-defaults': patch --- -Adds `prepareSpecializedApp` as a new two-phase app wiring API for rendering a sign-in page before full app finalization. Session preparation now resolves to an opaque reusable `sessionState`, which is returned from `getSignIn().ready` and from `finalize()`, and can be passed into a future `prepareSpecializedApp` call to skip sign-in and reuse the prepared session. The existing `createSpecializedApp` API is now deprecated and backed by `prepareSpecializedApp().finalize()`, while `createApp` has been updated to use the same prepare/finalize flow. +Adds `prepareSpecializedApp` as a new two-phase app wiring API for rendering a sign-in page before full app finalization. The sign-in step is now exposed as a bootstrap component whose `onReady` callback signals that the prepared app can now be finalized, while the opaque reusable `sessionState` is stored internally on the prepared app and returned from `tryFinalize()` and `finalize()`. That session state can also be passed into a future `prepareSpecializedApp` call to skip sign-in and reuse the prepared session. 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/src/wiring/index.ts b/packages/frontend-app-api/src/wiring/index.ts index 56851ac88a..d4db40bfee 100644 --- a/packages/frontend-app-api/src/wiring/index.ts +++ b/packages/frontend-app-api/src/wiring/index.ts @@ -15,8 +15,11 @@ */ export { + type FinalizedSpecializedApp, prepareSpecializedApp, type PreparedSpecializedApp, + type PreparedSpecializedAppSignInProps, + type SpecializedAppSessionState, createSpecializedApp, type CreateSpecializedAppOptions, } from './createSpecializedApp'; diff --git a/packages/frontend-defaults/src/createApp.tsx b/packages/frontend-defaults/src/createApp.tsx index 75d33282e7..38eadf6509 100644 --- a/packages/frontend-defaults/src/createApp.tsx +++ b/packages/frontend-defaults/src/createApp.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { JSX, lazy, ReactNode, Suspense, useEffect, useState } from 'react'; +import { JSX, lazy, ReactNode, Suspense, useReducer, useState } from 'react'; import { ConfigApi, coreExtensionData, @@ -30,6 +30,7 @@ import { overrideBaseUrlConfigs } from '../../core-app-api/src/app/overrideBaseU import { ConfigReader } from '@backstage/config'; import { CreateAppRouteBinder, + FinalizedSpecializedApp, prepareSpecializedApp, PreparedSpecializedApp, FrontendPluginInfoResolver, @@ -126,18 +127,9 @@ export function createApp(options?: CreateAppOptions): { bindRoutes: options?.bindRoutes, advanced: options?.advanced, }); - const signIn = preparedApp.getSignIn(); - - if (signIn.element) { - return { - default: () => , - }; - } - - const { sessionState } = await signIn.ready; return { - default: () => renderFinalizedApp(preparedApp.finalize(sessionState)), + default: () => , }; } @@ -158,51 +150,34 @@ function PreparedAppRoot(props: { preparedApp: PreparedSpecializedApp; }): JSX.Element { const signIn = props.preparedApp.getSignIn(); + const SignIn = signIn.Component; const [finalizeError, setFinalizeError] = useState(); - const [finalizedApp, setFinalizedApp] = useState< - ReturnType | undefined - >(undefined); - - useEffect(() => { - let cancelled = false; - const runFinalize = async () => { - try { - const { sessionState } = await signIn.ready; - if (cancelled) { - return; - } - setFinalizedApp(props.preparedApp.finalize(sessionState)); - } catch (error) { - if (cancelled) { - return; - } - setFinalizeError(error as Error); - } - }; - void runFinalize(); - return () => { - cancelled = true; - }; - }, [props.preparedApp, signIn]); + const [, triggerRerender] = useReducer((count: number) => count + 1, 0); if (finalizeError) { throw finalizeError; } + const finalizedApp: FinalizedSpecializedApp | undefined = + props.preparedApp.tryFinalize(); + if (!finalizedApp) { - return signIn.element ?? <>; + return ( + { + triggerRerender(); + }} + onError={setFinalizeError} + /> + ); } - return renderFinalizedApp(finalizedApp); -} - -function renderFinalizedApp( - app: ReturnType, -) { - const errorPage = maybeCreateErrorPage(app); + const errorPage = maybeCreateErrorPage(finalizedApp); if (errorPage) { return errorPage; } - return app.tree.root.instance!.getData(coreExtensionData.reactElement)!; + return finalizedApp.tree.root.instance!.getData( + coreExtensionData.reactElement, + )!; } From 72dd26a3a69760fb2acc805fbe78d90878033b31 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 12 Mar 2026 22:13:45 +0100 Subject: [PATCH 19/68] frontend-app-api: route sign-in errors through app boundary Rethrow sign-in bootstrap failures from inside the prepared sign-in tree so the app root extension boundary handles them instead of createApp keeping its own error state. This keeps bootstrap error handling aligned with the rest of the extension tree. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../frontend-defaults/src/createApp.test.tsx | 80 +++++++++++++++++++ packages/frontend-defaults/src/createApp.tsx | 8 +- plugins/app/src/extensions/AppRoot.tsx | 31 +++---- 3 files changed, 98 insertions(+), 21 deletions(-) diff --git a/packages/frontend-defaults/src/createApp.test.tsx b/packages/frontend-defaults/src/createApp.test.tsx index 540fb5e41c..9b3733b5af 100644 --- a/packages/frontend-defaults/src/createApp.test.tsx +++ b/packages/frontend-defaults/src/createApp.test.tsx @@ -187,6 +187,86 @@ describe('createApp', () => { ).resolves.toBeInTheDocument(); }); + it('should surface sign-in bootstrap errors through the app root boundary', 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(() => { + throw new Error('sign-in bootstrap failed'); + }), + registerFlag: jest.fn(), + getRegisteredFlags: () => [], + save: jest.fn(), + } as unknown as typeof featureFlagsApiRef.T; + + const app = createApp({ + advanced: { + configLoader: async () => ({ config: mockApis.config() }), + }, + features: [ + appPluginOriginal, + 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)]; + }, + }), + ], + }), + createFrontendPlugin({ + pluginId: 'test', + featureFlags: [{ name: 'test-flag' }], + extensions: [ + PageBlueprint.make({ + if: { featureFlags: { $contains: 'test-flag' } }, + params: { + path: '/', + loader: async () =>
Flagged Page
, + }, + }), + ], + }), + ], + }); + + await renderWithEffects(app.createRoot()); + + await expect( + screen.findByText(/Error in app/), + ).resolves.toBeInTheDocument(); + await expect( + screen.findByText('sign-in bootstrap failed'), + ).resolves.toBeInTheDocument(); + }); + it('should deduplicate features keeping the last received one', async () => { const duplicatedFeatureId = 'test'; const app = createApp({ diff --git a/packages/frontend-defaults/src/createApp.tsx b/packages/frontend-defaults/src/createApp.tsx index 38eadf6509..310cd48f17 100644 --- a/packages/frontend-defaults/src/createApp.tsx +++ b/packages/frontend-defaults/src/createApp.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { JSX, lazy, ReactNode, Suspense, useReducer, useState } from 'react'; +import { JSX, lazy, ReactNode, Suspense, useReducer } from 'react'; import { ConfigApi, coreExtensionData, @@ -151,13 +151,8 @@ function PreparedAppRoot(props: { }): JSX.Element { const signIn = props.preparedApp.getSignIn(); const SignIn = signIn.Component; - const [finalizeError, setFinalizeError] = useState(); const [, triggerRerender] = useReducer((count: number) => count + 1, 0); - if (finalizeError) { - throw finalizeError; - } - const finalizedApp: FinalizedSpecializedApp | undefined = props.preparedApp.tryFinalize(); @@ -167,7 +162,6 @@ function PreparedAppRoot(props: { onReady={() => { triggerRerender(); }} - onError={setFinalizeError} /> ); } diff --git a/plugins/app/src/extensions/AppRoot.tsx b/plugins/app/src/extensions/AppRoot.tsx index a970ae4463..af6b134fc1 100644 --- a/plugins/app/src/extensions/AppRoot.tsx +++ b/plugins/app/src/extensions/AppRoot.tsx @@ -22,6 +22,7 @@ import { JSX, } from 'react'; import { + ExtensionBoundary, coreExtensionData, discoveryApiRef, fetchApiRef, @@ -84,7 +85,7 @@ export const AppRoot = createExtension({ ), }, output: [coreExtensionData.reactElement], - factory({ inputs, apis }) { + factory({ inputs, apis, node }) { if (isProtectedApp()) { const identityApi = apis.get(identityApiRef); if (!identityApi) { @@ -123,19 +124,21 @@ export const AppRoot = createExtension({ return [ coreExtensionData.reactElement( - - el.get(coreExtensionData.reactElement), - )} - > - {content} - , + + + el.get(coreExtensionData.reactElement), + )} + > + {content} + + , ), ]; }, From 89f83827c959ff771d21a94bcd299f8cf36852d8 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 12 Mar 2026 22:39:25 +0100 Subject: [PATCH 20/68] frontend-app-api: expose bootstrap app state Expose the prepared bootstrap tree together with subscription-based phase updates so createApp can render from app state directly instead of forcing rerenders through a sign-in callback. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../prepare-specialized-app-signin-flow.md | 2 +- packages/frontend-app-api/src/wiring/index.ts | 2 +- packages/frontend-defaults/src/createApp.tsx | 22 ++++++++----------- 3 files changed, 11 insertions(+), 15 deletions(-) diff --git a/.changeset/prepare-specialized-app-signin-flow.md b/.changeset/prepare-specialized-app-signin-flow.md index 746df0d4fe..be7ddca934 100644 --- a/.changeset/prepare-specialized-app-signin-flow.md +++ b/.changeset/prepare-specialized-app-signin-flow.md @@ -3,4 +3,4 @@ '@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 sign-in step is now exposed as a bootstrap component whose `onReady` callback signals that the prepared app can now be finalized, while the opaque reusable `sessionState` is stored internally on the prepared app and returned from `tryFinalize()` and `finalize()`. That session state can also be passed into a future `prepareSpecializedApp` call to skip sign-in and reuse the prepared session. The existing `createSpecializedApp` API is now deprecated and backed by `prepareSpecializedApp().finalize()`, while `createApp` has been updated to use the same prepare/finalize flow. +Adds `prepareSpecializedApp` as a new two-phase app wiring API for rendering a bootstrap tree before full app finalization. The bootstrap phase is now exposed as a partial app tree that consumers can render while subscribing to phase transitions, while the opaque reusable `sessionState` is stored internally on the prepared app and returned from `getFinalizedApp()` and `finalize()`. That session state can also be passed into a future `prepareSpecializedApp` call to skip sign-in and reuse the prepared session. 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/src/wiring/index.ts b/packages/frontend-app-api/src/wiring/index.ts index d4db40bfee..8f55d1f3cf 100644 --- a/packages/frontend-app-api/src/wiring/index.ts +++ b/packages/frontend-app-api/src/wiring/index.ts @@ -15,10 +15,10 @@ */ export { + type BootstrapSpecializedApp, type FinalizedSpecializedApp, prepareSpecializedApp, type PreparedSpecializedApp, - type PreparedSpecializedAppSignInProps, type SpecializedAppSessionState, createSpecializedApp, type CreateSpecializedAppOptions, diff --git a/packages/frontend-defaults/src/createApp.tsx b/packages/frontend-defaults/src/createApp.tsx index 310cd48f17..41c70adcf5 100644 --- a/packages/frontend-defaults/src/createApp.tsx +++ b/packages/frontend-defaults/src/createApp.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { JSX, lazy, ReactNode, Suspense, useReducer } from 'react'; +import { JSX, lazy, ReactNode, Suspense, useSyncExternalStore } from 'react'; import { ConfigApi, coreExtensionData, @@ -149,21 +149,17 @@ export function createApp(options?: CreateAppOptions): { function PreparedAppRoot(props: { preparedApp: PreparedSpecializedApp; }): JSX.Element { - const signIn = props.preparedApp.getSignIn(); - const SignIn = signIn.Component; - const [, triggerRerender] = useReducer((count: number) => count + 1, 0); - + const bootstrapApp = props.preparedApp.getBootstrapApp(); const finalizedApp: FinalizedSpecializedApp | undefined = - props.preparedApp.tryFinalize(); + useSyncExternalStore( + props.preparedApp.subscribe, + props.preparedApp.getFinalizedApp, + ); if (!finalizedApp) { - return ( - { - triggerRerender(); - }} - /> - ); + return bootstrapApp.tree.root.instance!.getData( + coreExtensionData.reactElement, + )!; } const errorPage = maybeCreateErrorPage(finalizedApp); From 01d3c2ec0f7607b64248f89817edae70d51d2df7 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 14 Mar 2026 08:39:42 +0100 Subject: [PATCH 21/68] frontend-app-api: hand off finalized apps through callback Replace the prepared app promise/snapshot finalization API with an onFinalized callback so bootstrap consumers can switch to the finalized app through a simple one-way handoff. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .changeset/prepare-specialized-app-signin-flow.md | 2 +- packages/frontend-defaults/src/createApp.tsx | 15 +++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.changeset/prepare-specialized-app-signin-flow.md b/.changeset/prepare-specialized-app-signin-flow.md index be7ddca934..1ee2dba443 100644 --- a/.changeset/prepare-specialized-app-signin-flow.md +++ b/.changeset/prepare-specialized-app-signin-flow.md @@ -3,4 +3,4 @@ '@backstage/frontend-defaults': patch --- -Adds `prepareSpecializedApp` as a new two-phase app wiring API for rendering a bootstrap tree before full app finalization. The bootstrap phase is now exposed as a partial app tree that consumers can render while subscribing to phase transitions, while the opaque reusable `sessionState` is stored internally on the prepared app and returned from `getFinalizedApp()` and `finalize()`. That session state can also be passed into a future `prepareSpecializedApp` call to skip sign-in and reuse the prepared session. The existing `createSpecializedApp` API is now deprecated and backed by `prepareSpecializedApp().finalize()`, while `createApp` has been updated to use the same prepare/finalize flow. +Adds `prepareSpecializedApp` as a new two-phase app wiring API for rendering a bootstrap tree before full app finalization. The bootstrap phase is exposed as a partial app tree through `getBootstrapApp()`, while `onFinalized()` provides a one-way handoff to the finalized app once bootstrap completes. The opaque reusable `sessionState` is returned from the finalized app and can be passed into a future `prepareSpecializedApp` call to skip sign-in and reuse the prepared session. 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-defaults/src/createApp.tsx b/packages/frontend-defaults/src/createApp.tsx index 41c70adcf5..061137071b 100644 --- a/packages/frontend-defaults/src/createApp.tsx +++ b/packages/frontend-defaults/src/createApp.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { JSX, lazy, ReactNode, Suspense, useSyncExternalStore } from 'react'; +import { JSX, lazy, ReactNode, Suspense, useEffect, useState } from 'react'; import { ConfigApi, coreExtensionData, @@ -150,11 +150,14 @@ function PreparedAppRoot(props: { preparedApp: PreparedSpecializedApp; }): JSX.Element { const bootstrapApp = props.preparedApp.getBootstrapApp(); - const finalizedApp: FinalizedSpecializedApp | undefined = - useSyncExternalStore( - props.preparedApp.subscribe, - props.preparedApp.getFinalizedApp, - ); + const [finalizedApp, setFinalizedApp] = useState< + FinalizedSpecializedApp | undefined + >(); + + useEffect( + () => props.preparedApp.onFinalized(setFinalizedApp), + [props.preparedApp], + ); if (!finalizedApp) { return bootstrapApp.tree.root.instance!.getData( From 457904d372443aacc0c7d11afd8834e8f5ea2fd7 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 14 Mar 2026 11:38:36 +0100 Subject: [PATCH 22/68] frontend-app-api: defer conditional bootstrap APIs and root elements This keeps bootstrap rendering stable while still allowing root elements and API subtrees to activate once session predicates are available, and warns when bootstrap-visible extensions depend on APIs that only appear during finalization. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../prepare-specialized-app-signin-flow.md | 2 +- .../src/tree/instantiateAppNodeTree.ts | 34 +++++++++++++++++-- .../src/wiring/FrontendApiRegistry.ts | 21 ++++++++++++ .../src/wiring/createErrorCollector.ts | 6 ++++ .../src/maybeCreateErrorPage.tsx | 2 ++ 5 files changed, 62 insertions(+), 3 deletions(-) diff --git a/.changeset/prepare-specialized-app-signin-flow.md b/.changeset/prepare-specialized-app-signin-flow.md index 1ee2dba443..b8a7e65a06 100644 --- a/.changeset/prepare-specialized-app-signin-flow.md +++ b/.changeset/prepare-specialized-app-signin-flow.md @@ -3,4 +3,4 @@ '@backstage/frontend-defaults': patch --- -Adds `prepareSpecializedApp` as a new two-phase app wiring API for rendering a bootstrap tree before full app finalization. The bootstrap phase is exposed as a partial app tree through `getBootstrapApp()`, while `onFinalized()` provides a one-way handoff to the finalized app once bootstrap completes. The opaque reusable `sessionState` is returned from the finalized app and can be passed into a future `prepareSpecializedApp` call to skip sign-in and reuse the prepared session. The existing `createSpecializedApp` API is now deprecated and backed by `prepareSpecializedApp().finalize()`, while `createApp` has been updated to use the same prepare/finalize flow. +Adds `prepareSpecializedApp` as a new two-phase app wiring API for rendering a bootstrap tree before full app finalization. The bootstrap phase is exposed as a partial app tree through `getBootstrapApp()`, while `onFinalized()` provides a one-way handoff to the finalized app once bootstrap completes. The opaque reusable `sessionState` is returned from the finalized app and can be passed into a future `prepareSpecializedApp` call to skip sign-in and reuse the prepared session. Conditional `app/root.elements` and predicate-gated APIs are now deferred until finalization, while unsupported bootstrap-visible predicates are downgraded to warnings and bootstrap extensions now surface warnings if they accessed APIs that only became available after 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/src/tree/instantiateAppNodeTree.ts b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts index 5c9062dd06..23df618350 100644 --- a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts +++ b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts @@ -338,12 +338,28 @@ export function createAppNodeInstance(options: { apis: ApiHolder; attachments: ReadonlyMap; collector: ErrorCollector; + onMissingApi?(ctx: { node: AppNode; apiRefId: string }): void; }): AppNodeInstance | undefined { const { node, apis, attachments } = options; const collector = options.collector.child({ node }); const { id, extension, config } = node.spec; const extensionData = new Map(); const extensionDataRefs = new Set>(); + const scopedApis: ApiHolder = + options.onMissingApi === undefined + ? apis + : { + get(apiRef) { + const api = apis.get(apiRef); + if (api === undefined) { + options.onMissingApi?.({ + node, + apiRefId: apiRef.id, + }); + } + return api; + }, + }; let parsedConfig: { [x: string]: any }; try { @@ -368,7 +384,7 @@ export function createAppNodeInstance(options: { if (internalExtension.version === 'v1') { const namedOutputs = internalExtension.factory({ node, - apis, + apis: scopedApis, config: parsedConfig, inputs: resolveV1Inputs(internalExtension.inputs, attachments), }); @@ -389,7 +405,7 @@ export function createAppNodeInstance(options: { } else if (internalExtension.version === 'v2') { const context = { node, - apis, + apis: scopedApis, config: parsedConfig, inputs: resolveV2Inputs( internalExtension.inputs, @@ -513,16 +529,26 @@ export function instantiateAppNodeTree( optionsOrPredicateContext?: | { stopAtAttachment?(ctx: { node: AppNode; input: string }): boolean; + skipChild?(ctx: { + node: AppNode; + input: string; + child: AppNode; + }): boolean; + onMissingApi?(ctx: { node: AppNode; apiRefId: string }): void; predicateContext?: Record; } | Record, ): boolean { const options: { stopAtAttachment?(ctx: { node: AppNode; input: string }): boolean; + skipChild?(ctx: { node: AppNode; input: string; child: AppNode }): boolean; + onMissingApi?(ctx: { node: AppNode; apiRefId: string }): void; predicateContext?: Record; } = optionsOrPredicateContext && ('stopAtAttachment' in optionsOrPredicateContext || + 'skipChild' in optionsOrPredicateContext || + 'onMissingApi' in optionsOrPredicateContext || 'predicateContext' in optionsOrPredicateContext) ? optionsOrPredicateContext : { @@ -551,6 +577,9 @@ export function instantiateAppNodeTree( continue; } const instantiatedChildren = children.flatMap(child => { + if (options?.skipChild?.({ node, input, child })) { + return []; + } const childInstance = createInstance(child); if (!childInstance) { return []; @@ -568,6 +597,7 @@ export function instantiateAppNodeTree( apis, attachments: instantiatedAttachments, collector, + onMissingApi: options?.onMissingApi, }); return node.instance; diff --git a/packages/frontend-app-api/src/wiring/FrontendApiRegistry.ts b/packages/frontend-app-api/src/wiring/FrontendApiRegistry.ts index 1705aa9b25..8e56fe1ce8 100644 --- a/packages/frontend-app-api/src/wiring/FrontendApiRegistry.ts +++ b/packages/frontend-app-api/src/wiring/FrontendApiRegistry.ts @@ -40,6 +40,16 @@ export class FrontendApiRegistry { } } + set(factory: AnyApiFactory) { + this.factories.set(factory.api.id, factory); + } + + setAll(factories: Iterable) { + for (const factory of factories) { + this.set(factory); + } + } + get( api: ApiRef, ): ApiFactory | undefined { @@ -80,6 +90,17 @@ export class FrontendApiResolver implements ApiHolder { return this.load(ref); } + invalidate(apiRefIds?: Iterable) { + if (apiRefIds === undefined) { + this.apis.clear(); + return; + } + + for (const apiRefId of apiRefIds) { + this.apis.delete(apiRefId); + } + } + private load(ref: ApiRef, loading: AnyApiRef[] = []): T | undefined { const existing = this.apis.get(ref.id); if (existing) { diff --git a/packages/frontend-app-api/src/wiring/createErrorCollector.ts b/packages/frontend-app-api/src/wiring/createErrorCollector.ts index 5f081bc092..1867507948 100644 --- a/packages/frontend-app-api/src/wiring/createErrorCollector.ts +++ b/packages/frontend-app-api/src/wiring/createErrorCollector.ts @@ -82,6 +82,12 @@ export type AppErrorTypes = { existingPluginId: string; }; }; + EXTENSION_BOOTSTRAP_PREDICATE_IGNORED: { + context: { node: AppNode }; + }; + EXTENSION_BOOTSTRAP_API_UNAVAILABLE: { + context: { node: AppNode; apiRefId: string }; + }; // routing ROUTE_DUPLICATE: { context: { routeId: string }; diff --git a/packages/frontend-defaults/src/maybeCreateErrorPage.tsx b/packages/frontend-defaults/src/maybeCreateErrorPage.tsx index 152c37fdd3..8c058b838f 100644 --- a/packages/frontend-defaults/src/maybeCreateErrorPage.tsx +++ b/packages/frontend-defaults/src/maybeCreateErrorPage.tsx @@ -24,6 +24,8 @@ const DEFAULT_WARNING_CODES: Array = [ 'EXTENSION_INPUT_DATA_IGNORED', 'EXTENSION_INPUT_INTERNAL_IGNORED', 'EXTENSION_OUTPUT_IGNORED', + 'EXTENSION_BOOTSTRAP_PREDICATE_IGNORED', + 'EXTENSION_BOOTSTRAP_API_UNAVAILABLE', ]; function AppErrorItem(props: { error: AppError }): JSX.Element { From f30eeba995ef26afb6c448dda9ec49c056cfab7d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 14 Mar 2026 12:00:55 +0100 Subject: [PATCH 23/68] frontend-plugin-api: support feature-level predicates This lets plugin and module instances apply a shared condition to all of their extensions, while preserving extension-level conditions by combining them with logical AND during app spec resolution. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../prepare-specialized-app-signin-flow.md | 3 +- .../src/tree/resolveAppNodeSpecs.test.ts | 84 ++++++++++++++++++ .../src/tree/resolveAppNodeSpecs.ts | 80 ++++++++++++----- .../src/wiring/InternalFrontendPlugin.ts | 2 + packages/frontend-plugin-api/report.api.md | 87 ++++++++++--------- .../src/wiring/createFrontendModule.test.ts | 2 + .../src/wiring/createFrontendModule.ts | 4 + .../src/wiring/createFrontendPlugin.test.ts | 2 + .../src/wiring/createFrontendPlugin.ts | 3 + 9 files changed, 201 insertions(+), 66 deletions(-) diff --git a/.changeset/prepare-specialized-app-signin-flow.md b/.changeset/prepare-specialized-app-signin-flow.md index b8a7e65a06..59efcd8c34 100644 --- a/.changeset/prepare-specialized-app-signin-flow.md +++ b/.changeset/prepare-specialized-app-signin-flow.md @@ -1,6 +1,7 @@ --- '@backstage/frontend-app-api': patch '@backstage/frontend-defaults': patch +'@backstage/frontend-plugin-api': patch --- -Adds `prepareSpecializedApp` as a new two-phase app wiring API for rendering a bootstrap tree before full app finalization. The bootstrap phase is exposed as a partial app tree through `getBootstrapApp()`, while `onFinalized()` provides a one-way handoff to the finalized app once bootstrap completes. The opaque reusable `sessionState` is returned from the finalized app and can be passed into a future `prepareSpecializedApp` call to skip sign-in and reuse the prepared session. Conditional `app/root.elements` and predicate-gated APIs are now deferred until finalization, while unsupported bootstrap-visible predicates are downgraded to warnings and bootstrap extensions now surface warnings if they accessed APIs that only became available after 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. +Adds `prepareSpecializedApp` as a new two-phase app wiring API for rendering a bootstrap tree before full app finalization. The bootstrap phase is exposed as a partial app tree through `getBootstrapApp()`, while `onFinalized()` provides a one-way handoff to the finalized app once bootstrap completes. The opaque reusable `sessionState` is returned from the finalized app and can be passed into a future `prepareSpecializedApp` call to skip sign-in and reuse the prepared session. Conditional `app/root.elements` and predicate-gated APIs are now deferred until finalization, while unsupported bootstrap-visible predicates are downgraded to warnings and bootstrap extensions now surface warnings if they accessed APIs that only became available after finalization. The `if` option is also now supported on `createFrontendPlugin` and `createFrontendModule`, and is applied to each extension from that feature together with any extension-level predicate. 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/src/tree/resolveAppNodeSpecs.test.ts b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts index 1dedac8e43..8116999cbf 100644 --- a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts +++ b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts @@ -532,4 +532,88 @@ describe('resolveAppNodeSpecs', () => { expect(specs).toHaveLength(1); expect(specs[0].if).toEqual(ifPredicate); }); + + it('should apply plugin if predicates to all plugin extensions', () => { + const dataRef = createExtensionDataRef().with({ id: 'test.data' }); + const pluginIf = { featureFlags: { $contains: 'plugin-flag' } }; + const plugin = createFrontendPlugin({ + pluginId: 'test-plugin', + if: pluginIf, + extensions: [ + createExtension({ + name: 'one', + attachTo: { id: 'app', input: 'root' }, + output: [dataRef], + factory: () => [dataRef('one')], + }), + createExtension({ + name: 'two', + attachTo: { id: 'app', input: 'root' }, + output: [dataRef], + factory: () => [dataRef('two')], + }), + ], + }); + + const specs = resolveAppNodeSpecs({ + features: [plugin], + builtinExtensions: [], + parameters: [], + collector, + }); + + expect(specs).toHaveLength(2); + expect(specs[0].if).toEqual(pluginIf); + expect(specs[1].if).toEqual(pluginIf); + }); + + it('should merge plugin and module if predicates with extension predicates', () => { + const dataRef = createExtensionDataRef().with({ id: 'test.data' }); + const pluginIf = { featureFlags: { $contains: 'plugin-flag' } }; + const moduleIf = { permissions: { $contains: 'module.permission' } }; + const extensionIf = { featureFlags: { $contains: 'extension-flag' } }; + const moduleExtensionIf = { featureFlags: { $contains: 'module-flag' } }; + const plugin = createFrontendPlugin({ + pluginId: 'test-plugin', + if: pluginIf, + extensions: [ + createExtension({ + name: 'plugin-extension', + attachTo: { id: 'app', input: 'root' }, + if: extensionIf, + output: [dataRef], + factory: () => [dataRef('plugin')], + }), + createExtension({ + name: 'module-extension', + attachTo: { id: 'app', input: 'root' }, + output: [dataRef], + factory: () => [dataRef('base')], + }), + ], + }); + const module = createFrontendModule({ + pluginId: 'test-plugin', + if: moduleIf, + extensions: [ + plugin.getExtension('test-plugin/module-extension').override({ + if: moduleExtensionIf, + factory: () => [dataRef('module')], + }), + ], + }); + + const specs = resolveAppNodeSpecs({ + features: [plugin, module], + builtinExtensions: [], + parameters: [], + collector, + }); + + expect(specs).toHaveLength(2); + expect(specs[0].id).toBe('test-plugin/plugin-extension'); + expect(specs[0].if).toEqual({ $all: [pluginIf, extensionIf] }); + expect(specs[1].id).toBe('test-plugin/module-extension'); + expect(specs[1].if).toEqual({ $all: [moduleIf, moduleExtensionIf] }); + }); }); diff --git a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts index 874df7b8bd..318479d6b5 100644 --- a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts +++ b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts @@ -20,6 +20,7 @@ import { FrontendFeature, FrontendPlugin, } from '@backstage/frontend-plugin-api'; +import { FilterPredicate } from '@backstage/filter-predicates'; import { ExtensionParameters } from './readAppExtensionsConfig'; import { AppNodeSpec } from '@backstage/frontend-plugin-api'; import { OpaqueFrontendPlugin } from '@internal/frontend'; @@ -40,6 +41,33 @@ function normalizePlugin(plugin: FrontendPlugin): FrontendPlugin { return plugin; } +function combinePredicates( + left: FilterPredicate | undefined, + right: FilterPredicate | undefined, +) { + if (!left) { + return right; + } + if (!right) { + return left; + } + + return { $all: [left, right] }; +} + +function getExtensionPredicate(options: { + extension: Extension; + internalExtension: ReturnType; +}) { + if (options.extension.version === 'v2') { + return options.extension.if; + } + if (options.internalExtension.version === 'v2') { + return options.internalExtension.if; + } + return undefined; +} + /** @internal */ export function resolveAppNodeSpecs(options: { features?: FrontendFeature[]; @@ -79,26 +107,41 @@ export function resolveAppNodeSpecs(options: { }; const pluginExtensions = plugins.flatMap(plugin => { - return OpaqueFrontendPlugin.toInternal(plugin) - .extensions.map(extension => ({ + const internalPlugin = OpaqueFrontendPlugin.toInternal(plugin); + return internalPlugin.extensions + .map(extension => ({ ...extension, plugin, + if: combinePredicates( + internalPlugin.if, + extension.version === 'v2' ? extension.if : undefined, + ), })) .filter(filterForbidden); }); - const moduleExtensions = modules.flatMap(mod => - toInternalFrontendModule(mod) - .extensions.flatMap(extension => { + const moduleExtensions = modules.flatMap(mod => { + const internalModule = toInternalFrontendModule(mod); + return internalModule.extensions + .flatMap(extension => { // Modules for plugins that are not installed are ignored const plugin = plugins.find(p => p.pluginId === mod.pluginId); if (!plugin) { return []; } - return [{ ...extension, plugin }]; + return [ + { + ...extension, + plugin, + if: combinePredicates( + internalModule.if, + extension.version === 'v2' ? extension.if : undefined, + ), + }, + ]; }) - .filter(filterForbidden), - ); + .filter(filterForbidden); + }); const appPlugin = plugins.find(plugin => plugin.pluginId === 'app') ?? @@ -116,10 +159,7 @@ export function resolveAppNodeSpecs(options: { source: plugin, attachTo: internalExtension.attachTo, disabled: internalExtension.disabled, - if: - internalExtension.version === 'v2' - ? internalExtension.if - : undefined, + if: getExtensionPredicate({ extension, internalExtension }), config: undefined as unknown, }, }; @@ -133,10 +173,7 @@ export function resolveAppNodeSpecs(options: { plugin: appPlugin, attachTo: internalExtension.attachTo, disabled: internalExtension.disabled, - if: - internalExtension.version === 'v2' - ? internalExtension.if - : undefined, + if: getExtensionPredicate({ extension, internalExtension }), config: undefined as unknown, }, }; @@ -156,8 +193,10 @@ export function resolveAppNodeSpecs(options: { configuredExtensions[index].extension = internalExtension; configuredExtensions[index].params.attachTo = internalExtension.attachTo; configuredExtensions[index].params.disabled = internalExtension.disabled; - configuredExtensions[index].params.if = - internalExtension.version === 'v2' ? internalExtension.if : undefined; + configuredExtensions[index].params.if = getExtensionPredicate({ + extension, + internalExtension, + }); } else { // Add the extension as a new one when not overriding an existing one configuredExtensions.push({ @@ -167,10 +206,7 @@ export function resolveAppNodeSpecs(options: { source: extension.plugin, attachTo: internalExtension.attachTo, disabled: internalExtension.disabled, - if: - internalExtension.version === 'v2' - ? internalExtension.if - : undefined, + if: getExtensionPredicate({ extension, internalExtension }), config: undefined, }, }); diff --git a/packages/frontend-internal/src/wiring/InternalFrontendPlugin.ts b/packages/frontend-internal/src/wiring/InternalFrontendPlugin.ts index 4564be2bb1..8d62e08627 100644 --- a/packages/frontend-internal/src/wiring/InternalFrontendPlugin.ts +++ b/packages/frontend-internal/src/wiring/InternalFrontendPlugin.ts @@ -17,6 +17,7 @@ import { Extension, FeatureFlagConfig, + FilterPredicate, IconElement, OverridableFrontendPlugin, } from '@backstage/frontend-plugin-api'; @@ -31,6 +32,7 @@ export const OpaqueFrontendPlugin = OpaqueType.create<{ readonly icon?: IconElement; readonly extensions: Extension[]; readonly featureFlags: FeatureFlagConfig[]; + readonly if?: FilterPredicate; readonly infoOptions?: { packageJson?: () => Promise; manifest?: () => Promise; diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index 0af67779e7..c265dd41f9 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -255,12 +255,12 @@ export interface AppNodeSpec { // (undocumented) readonly disabled: boolean; // (undocumented) - readonly enabled?: FilterPredicate; - // (undocumented) readonly extension: Extension; // (undocumented) readonly id: string; // (undocumented) + readonly if?: FilterPredicate; + // (undocumented) readonly plugin: FrontendPlugin; } @@ -580,7 +580,7 @@ export type CreateExtensionBlueprintOptions< attachTo: ExtensionDefinitionAttachTo & VerifyExtensionAttachTo; disabled?: boolean; - enabled?: FilterPredicate; + if?: FilterPredicate; inputs?: TInputs; output: Array; config?: { @@ -667,7 +667,7 @@ export type CreateExtensionOptions< attachTo: ExtensionDefinitionAttachTo & VerifyExtensionAttachTo; disabled?: boolean; - enabled?: FilterPredicate; + if?: FilterPredicate; inputs?: TInputs; output: Array; config?: { @@ -756,6 +756,8 @@ export interface CreateFrontendModuleOptions< // (undocumented) featureFlags?: FeatureFlagConfig[]; // (undocumented) + if?: FilterPredicate; + // (undocumented) pluginId: TPluginId; } @@ -770,45 +772,13 @@ export function createFrontendPlugin< [name in string]: ExternalRouteRef; } = {}, >( - options: CreateFrontendPluginOptions< - TId, - TRoutes, - TExternalRoutes, - TExtensions - >, + options: PluginOptions, ): OverridableFrontendPlugin< TRoutes, TExternalRoutes, MakeSortedExtensionsMap >; -// @public -export interface CreateFrontendPluginOptions< - TId extends string, - TRoutes extends { - [name in string]: RouteRef | SubRouteRef; - }, - TExternalRoutes extends { - [name in string]: ExternalRouteRef; - }, - TExtensions extends readonly ExtensionDefinition[], -> { - // (undocumented) - extensions?: TExtensions; - // (undocumented) - externalRoutes?: TExternalRoutes; - // (undocumented) - featureFlags?: FeatureFlagConfig[]; - icon?: IconElement; - // (undocumented) - info?: FrontendPluginInfoOptions; - // (undocumented) - pluginId: TId; - // (undocumented) - routes?: TRoutes; - title?: string; -} - // @public export function createRouteRef< TParams extends @@ -1031,7 +1001,7 @@ export interface ExtensionBlueprint< attachTo?: ExtensionDefinitionAttachTo & VerifyExtensionAttachTo, UParentInputs>; disabled?: boolean; - enabled?: FilterPredicate; + if?: FilterPredicate; params: TParamsInput extends ExtensionBlueprintDefineParams ? TParamsInput : T['params'] extends ExtensionBlueprintDefineParams @@ -1067,7 +1037,7 @@ export interface ExtensionBlueprint< UParentInputs >; disabled?: boolean; - enabled?: FilterPredicate; + if?: FilterPredicate; inputs?: TExtraInputs & { [KName in keyof T['inputs']]?: `Error: Input '${KName & string}' is already defined in parent definition`; @@ -1709,7 +1679,7 @@ export interface OverridableExtensionDefinition< UParentInputs >; disabled?: boolean; - enabled?: FilterPredicate; + if?: FilterPredicate; inputs?: TExtraInputs & { [KName in keyof T['inputs']]?: `Error: Input '${KName & string}' is already defined in parent definition`; @@ -1815,6 +1785,7 @@ export interface OverridableFrontendPlugin< // (undocumented) withOverrides(options: { extensions?: Array; + if?: FilterPredicate; title?: string; icon?: IconElement; info?: FrontendPluginInfoOptions; @@ -1971,8 +1942,8 @@ export const pluginHeaderActionsApiRef: ApiRef_2< readonly $$type: '@backstage/ApiRef'; }; -// @public @deprecated (undocumented) -export type PluginOptions< +// @public (undocumented) +export interface PluginOptions< TId extends string, TRoutes extends { [name in string]: RouteRef | SubRouteRef; @@ -1981,7 +1952,24 @@ export type PluginOptions< [name in string]: ExternalRouteRef; }, TExtensions extends readonly ExtensionDefinition[], -> = CreateFrontendPluginOptions; +> { + // (undocumented) + extensions?: TExtensions; + // (undocumented) + externalRoutes?: TExternalRoutes; + // (undocumented) + featureFlags?: FeatureFlagConfig[]; + icon?: IconElement; + // (undocumented) + if?: FilterPredicate; + // (undocumented) + info?: FrontendPluginInfoOptions; + // (undocumented) + pluginId: TId; + // (undocumented) + routes?: TRoutes; + title?: string; +} // @public export type PluginWrapperApi = { @@ -2064,6 +2052,19 @@ export const Progress: { // @public (undocumented) export type ProgressProps = {}; +// @public +export type ResolvedExtensionInputs< + TInputs extends { + [name in string]: ExtensionInput; + }, +> = { + [InputName in keyof TInputs]: false extends TInputs[InputName]['config']['singleton'] + ? Array>> + : false extends TInputs[InputName]['config']['optional'] + ? Expand> + : Expand | undefined>; +}; + // @public export type RouteFunc = ( ...input: TParams extends undefined ? readonly [] : readonly [params: TParams] diff --git a/packages/frontend-plugin-api/src/wiring/createFrontendModule.test.ts b/packages/frontend-plugin-api/src/wiring/createFrontendModule.test.ts index d895f6de0f..6f7d8c12c8 100644 --- a/packages/frontend-plugin-api/src/wiring/createFrontendModule.test.ts +++ b/packages/frontend-plugin-api/src/wiring/createFrontendModule.test.ts @@ -46,6 +46,7 @@ describe('createFrontendModule', () => { "disabled": false, "factory": [Function], "id": "route:test/test", + "if": undefined, "inputs": {}, "output": [], "toString": [Function], @@ -53,6 +54,7 @@ describe('createFrontendModule', () => { }, ], "featureFlags": [], + "if": undefined, "pluginId": "test", "toString": [Function], "version": "v1", diff --git a/packages/frontend-plugin-api/src/wiring/createFrontendModule.ts b/packages/frontend-plugin-api/src/wiring/createFrontendModule.ts index cea0760e68..1d77ce2167 100644 --- a/packages/frontend-plugin-api/src/wiring/createFrontendModule.ts +++ b/packages/frontend-plugin-api/src/wiring/createFrontendModule.ts @@ -21,6 +21,7 @@ import { resolveExtensionDefinition, } from './resolveExtensionDefinition'; import { FeatureFlagConfig } from './types'; +import { FilterPredicate } from '@backstage/filter-predicates'; /** @public */ export interface CreateFrontendModuleOptions< @@ -30,6 +31,7 @@ export interface CreateFrontendModuleOptions< pluginId: TPluginId; extensions?: TExtensions; featureFlags?: FeatureFlagConfig[]; + if?: FilterPredicate; } /** @public */ @@ -43,6 +45,7 @@ export interface InternalFrontendModule extends FrontendModule { readonly version: 'v1'; readonly extensions: Extension[]; readonly featureFlags: FeatureFlagConfig[]; + readonly if?: FilterPredicate; } /** @@ -126,6 +129,7 @@ export function createFrontendModule< version: 'v1', pluginId, featureFlags: options.featureFlags ?? [], + if: options.if, extensions, toString() { return `Module{pluginId=${pluginId}}`; diff --git a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.test.ts b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.test.ts index 1e1f7e1a84..a6034db1e2 100644 --- a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.test.ts +++ b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.test.ts @@ -171,6 +171,7 @@ describe('createFrontendPlugin', () => { "configSchema": undefined, "disabled": false, "factory": [Function], + "if": undefined, "inputs": {}, "kind": undefined, "name": "1", @@ -359,6 +360,7 @@ describe('createFrontendPlugin', () => { "configSchema": undefined, "disabled": false, "factory": [Function], + "if": undefined, "inputs": {}, "kind": undefined, "name": "1", diff --git a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts index f4e9a18990..18e4218dfc 100644 --- a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts +++ b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts @@ -32,6 +32,7 @@ import { JsonObject } from '@backstage/types'; import { IconElement } from '../icons/types'; import { RouteRef, SubRouteRef, ExternalRouteRef } from '../routing'; import { ID_PATTERN } from './constants'; +import { FilterPredicate } from '@backstage/filter-predicates'; /** * Information about the plugin. @@ -195,6 +196,7 @@ export interface CreateFrontendPluginOptions< externalRoutes?: TExternalRoutes; extensions?: TExtensions; featureFlags?: FeatureFlagConfig[]; + if?: FilterPredicate; info?: FrontendPluginInfoOptions; } @@ -304,6 +306,7 @@ export function createFrontendPlugin< routes: options.routes ?? ({} as TRoutes), externalRoutes: options.externalRoutes ?? ({} as TExternalRoutes), featureFlags: options.featureFlags ?? [], + if: options.if, extensions: extensions, infoOptions: options.info, From 5fec07d08312bb98f4cac918b53a773cf6e1b09c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 14 Mar 2026 12:25:39 +0100 Subject: [PATCH 24/68] docs: cover phased frontend app setup This documents the new phased app preparation flow and conditional feature behavior in the frontend-system docs, and adds the missing changeset coverage for related published package changes. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .changeset/permission-api-batching.md | 5 +++ .changeset/plugin-app-bootstrap-phase.md | 5 +++ docs/frontend-system/architecture/10-app.md | 30 +++++++++++++++ .../architecture/15-plugins.md | 14 +++++++ .../architecture/20-extensions.md | 37 +++++++++++++++++++ .../architecture/23-extension-blueprints.md | 2 +- .../architecture/25-extension-overrides.md | 2 + .../frontend-system/building-apps/01-index.md | 2 + .../building-apps/03-built-in-extensions.md | 2 +- 9 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 .changeset/permission-api-batching.md create mode 100644 .changeset/plugin-app-bootstrap-phase.md diff --git a/.changeset/permission-api-batching.md b/.changeset/permission-api-batching.md new file mode 100644 index 0000000000..857422665d --- /dev/null +++ b/.changeset/permission-api-batching.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-permission-react': patch +--- + +The `PermissionApi.authorize()` method now accepts arrays of permission requests, and permission checks made in the same tick are batched into a single call to the permission backend. diff --git a/.changeset/plugin-app-bootstrap-phase.md b/.changeset/plugin-app-bootstrap-phase.md new file mode 100644 index 0000000000..c3bab9e563 --- /dev/null +++ b/.changeset/plugin-app-bootstrap-phase.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-app': patch +--- + +Updated the default app root to better support phased app preparation by allowing the app layout to be absent during bootstrap, routing bootstrap failures through the app root boundary, and avoiding installation of a guest identity in protected apps that do not provide a sign-in page. diff --git a/docs/frontend-system/architecture/10-app.md b/docs/frontend-system/architecture/10-app.md index 22457bcb30..bfc667f664 100644 --- a/docs/frontend-system/architecture/10-app.md +++ b/docs/frontend-system/architecture/10-app.md @@ -40,6 +40,36 @@ Each node in this tree is an extension with a parent node and children. The colo A common type of data that is shared between extensions is React elements and components. These can in turn be rendered by each other in their own React components, which ends up forming a parallel tree of React components that is similar in shape to that of the app extension tree. At the top of the app extension tree is a built-in root extension that among other things outputs a React element. This element also ends up being the root of the parallel React tree, and is rendered by the React element returned by `app.createRoot()`. +## Preparing an App in Phases + +Most apps should use `createApp` from `@backstage/frontend-defaults`, which takes care of all app preparation internally. For more advanced use cases there is also a lower-level `prepareSpecializedApp` API in `@backstage/frontend-app-api`. + +This API is useful when you need to render a bootstrap tree before the full app can be finalized, for example while waiting for sign-in or other session-dependent state. It gives you access to a bootstrap app tree immediately, notifies you when the finalized app is ready, and lets you reuse a prepared session in a later app instance. + +```tsx +import { + FinalizedSpecializedApp, + prepareSpecializedApp, +} from '@backstage/frontend-app-api'; + +const preparedApp = prepareSpecializedApp({ + config, + features: [appPlugin, ...features], +}); + +const bootstrapApp = preparedApp.getBootstrapApp(); + +const unsubscribe = preparedApp.onFinalized( + (finalizedApp: FinalizedSpecializedApp) => { + console.log(finalizedApp.sessionState); + }, +); +``` + +The `getBootstrapApp()` method exposes the partial app tree that is available during bootstrap. The `onFinalized()` method notifies you once the full app tree has been finalized, and `finalize(sessionState?)` can be used when you already have a reusable session state or when you want to bypass the asynchronous bootstrap flow in tests. + +When using phased app preparation, `app/root.children` acts as the main session boundary. Conditional extensions behind that boundary are evaluated during finalization. Conditional `app/root.elements` and API branches are also deferred until finalization, while other bootstrap-visible predicates are ignored and reported as warnings. + ## Feature Discovery App feature discovery lets you automatically discover and install features provided by dependencies in your app. In practice, it means that you don't need to manually `import` features in code, but they are instead installed as soon as you add them as a dependency in your `package.json`. diff --git a/docs/frontend-system/architecture/15-plugins.md b/docs/frontend-system/architecture/15-plugins.md index c2f5f237c3..d124a0e61d 100644 --- a/docs/frontend-system/architecture/15-plugins.md +++ b/docs/frontend-system/architecture/15-plugins.md @@ -76,6 +76,20 @@ These are the routes that the plugin exposes to the app. The `routes` option dec This is a list of feature flag declarations that your plugin provides to the app. This makes sure that the feature flags are correctly registered and can be toggled in the app. To read a feature flag you can use the feature flags [Utility API](../architecture/33-utility-apis.md), accessible via `featureFlagsApiRef`. +### `if` option + +The `if` option lets you apply a shared condition to all extensions that are provided by a plugin instance. This is useful when you want to gate an entire plugin behind a feature flag or permission without repeating the same predicate on every individual extension. + +```tsx +export default createFrontendPlugin({ + pluginId: 'my-plugin', + if: { featureFlags: { $contains: 'my-plugin-enabled' } }, + extensions: [...], +}); +``` + +This predicate is applied to every extension from that plugin instance. If any extension already has its own `if` predicate, the two are combined using logical `AND`. + ### `info` option This options is used to provide loaders for different sources of information about the plugin that may be useful to users and admins. The two available loaders are `packageJson` and `manifest`, and a plugin can use either or both as needed. The resulting information is available via the `info()` method on the plugin instance once it is installed in an app, but it is up to each app to decide how to derive the information from the provided sources. diff --git a/docs/frontend-system/architecture/20-extensions.md b/docs/frontend-system/architecture/20-extensions.md index 106ab5340a..3df33b9cad 100644 --- a/docs/frontend-system/architecture/20-extensions.md +++ b/docs/frontend-system/architecture/20-extensions.md @@ -41,6 +41,43 @@ Each extension in the app can be disabled, meaning it will not be instantiated a The ordering of extensions is sometimes very important, as it may for example affect in which order they show up in the UI. When an extension is toggled from disabled to enabled through configuration it resets the ordering of the extension, pushing it to the end of the list. It is generally recommended to leave extensions as disabled by default if their order is important, allowing for the order in which their are enabled in the configuration to determine their order in the app. +### Conditions + +Extensions can also be conditionally enabled by providing an `if` predicate. This is available both on `createExtension(...)` directly and when creating extensions from blueprints. + +The predicate uses the same `FilterPredicate` syntax as elsewhere in Backstage, but in the frontend system it is evaluated against app-level data such as `featureFlags` and `permissions`. For example, the following page is only installed when the `experimental-features` flag is active: + +```tsx +const examplePage = PageBlueprint.make({ + params: { + path: '/example', + loader: () => import('./ExamplePage').then(m => ), + }, + if: { featureFlags: { $contains: 'experimental-features' } }, +}); +``` + +You can also combine conditions using logical operators such as `$all`, `$any`, and `$not`: + +```tsx +const guardedCard = CardBlueprint.make({ + params: { + title: 'Guarded Card', + loader: () => import('./GuardedCard').then(m => ), + }, + if: { + $all: [ + { featureFlags: { $contains: 'experimental-features' } }, + { permissions: { $contains: 'catalog.entity.create' } }, + ], + }, +}); +``` + +Conditions are evaluated when the app tree is prepared, not continuously while the app is running. If the underlying feature flags or permissions change, the app needs to be prepared again in order for the extension tree to change, which in practice typically means reloading the app. + +If a plugin or module also provides an `if` predicate, it is combined with the extension-level predicate using logical `AND`. See the [plugin `if` option](./15-plugins.md#if-option) and [frontend modules](./25-extension-overrides.md#creating-a-frontend-module) sections for more details. + ### Configuration & configuration schema Each extension can define a configuration schema that describes the configuration that it accepts. This schema is used to validate the configuration provided by integrators, but also to fill in default configuration values. The configuration itself is provided by integrators in order to customize the extension. It is not possible to provide a default configuration of an extension, this must instead be done through defaults in the configuration schema. This allows for a simpler configuration logic where multiple configurations of the same extension completely replace each other rather than being merged. diff --git a/docs/frontend-system/architecture/23-extension-blueprints.md b/docs/frontend-system/architecture/23-extension-blueprints.md index f6efccccbb..97eb01022d 100644 --- a/docs/frontend-system/architecture/23-extension-blueprints.md +++ b/docs/frontend-system/architecture/23-extension-blueprints.md @@ -9,7 +9,7 @@ The `createExtension` function and related APIs is considered a low-level buildi ## Creating an extension from a blueprint -Every extension blueprint provides a `make` method that can be used to create new extensions. It is a simple way to create a new extension where the base blueprint provides all the necessary functionality. All you need to do is to provide the necessary blueprint parameters, but you also have the ability to provide additional options, for example a `name` for the extension. +Every extension blueprint provides a `make` method that can be used to create new extensions. It is a simple way to create a new extension where the base blueprint provides all the necessary functionality. All you need to do is to provide the necessary blueprint parameters, but you also have the ability to provide additional options, for example a `name`, `attachTo`, `disabled`, or `if` predicate for the extension. The following is a simple example of how one might use the blueprint `make` method to create a new extension: diff --git a/docs/frontend-system/architecture/25-extension-overrides.md b/docs/frontend-system/architecture/25-extension-overrides.md index 5e684476c8..b3a7759867 100644 --- a/docs/frontend-system/architecture/25-extension-overrides.md +++ b/docs/frontend-system/architecture/25-extension-overrides.md @@ -343,3 +343,5 @@ export default app.createRoot(); ``` You must define a `pluginId` when creating a frontend module, and the plugin must also be installed for the module to be loaded. + +Frontend modules also support an `if` option. Just like for plugins, that predicate is applied to every extension that comes from the module, and is combined with any extension-level `if` predicate using logical `AND`. This is useful when you want to enable or disable an entire override package based on a feature flag or permission. diff --git a/docs/frontend-system/building-apps/01-index.md b/docs/frontend-system/building-apps/01-index.md index bd9a0fbae5..cfef547afc 100644 --- a/docs/frontend-system/building-apps/01-index.md +++ b/docs/frontend-system/building-apps/01-index.md @@ -57,6 +57,8 @@ Note that `createRoot` returns the root element that is rendered by React. The a Visit the [built-in extensions](#customize-or-override-built-in-extensions) section to see what is installed by default in a Backstage application. +For advanced bootstrap flows that need access to the app tree before the full app is finalized, see [preparing an app in phases](../architecture/10-app.md#preparing-an-app-in-phases). + ## Configure your app ### Bind external routes diff --git a/docs/frontend-system/building-apps/03-built-in-extensions.md b/docs/frontend-system/building-apps/03-built-in-extensions.md index e1d0b0282d..05e60b54b7 100644 --- a/docs/frontend-system/building-apps/03-built-in-extensions.md +++ b/docs/frontend-system/building-apps/03-built-in-extensions.md @@ -99,7 +99,7 @@ This is the extension that creates the app root element, so it renders root leve | ---------- | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | router | A React component that should manager the app routes context. | It must be one [router](https://reactrouter.com/en/main/routers/picking-a-router#web-projects) component or a custom component compatible with the 'react-router' library. | true | [BrowserRouter](https://reactrouter.com/en/main/router-components/browser-router) | [createRouterExtension](https://backstage.io/docs/reference/frontend-plugin-api.createrouterextension) | | signInPage | A React component that should render the app sign-in page. | Should call the `onSignInSuccess` prop when the user has been successfully authorized, otherwise the user will not be correctly redirected to the application home page. | true | The default `AppRoot` extension does not use a default component for this input, it bypasses the user authentication check and always renders all routes when a login page is not installed. | [createSignInPageExtension](https://backstage.io/docs/reference/frontend-plugin-api.createsigninpageextension/) | -| children | A React component that renders the app sidebar and main content in a particular layout. | - | false | The [`App/Layout`](#app-layout) extension output. | No creator available, configure or override the [`App/Layout`](#app-layout) extension. | +| children | A React component that renders the app sidebar and main content in a particular layout. | - | true | The [`App/Layout`](#app-layout) extension output. | No creator available, configure or override the [`App/Layout`](#app-layout) extension. | | elements | React elements to be rendered outside of the app layout, such as shared popups. | - | false | See [default elements](#default-app-root-elements-extensions). | [createAppRootElementExtension](https://backstage.io/docs/reference/frontend-plugin-api.createapprootelementextension/) | | wrappers | React components that should wrap the root element. | - | true | - | [createAppRootWrapperExtension](https://backstage.io/docs/reference/frontend-plugin-api.createapprootwrapperextension/) | From 47f7cd25058f4187a7d8e12f2a275efc2a9af72c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 14 Mar 2026 12:28:57 +0100 Subject: [PATCH 25/68] permission-react: keep batching internal Revert the public multi-request authorize overload and rely on internal batching instead. Frontend app predicate checks now make separate authorize calls that still coalesce within the same tick. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .changeset/permission-api-batching.md | 2 +- .../permission-react/src/apis/IdentityPermissionApi.ts | 10 ++-------- plugins/permission-react/src/apis/PermissionApi.ts | 3 --- 3 files changed, 3 insertions(+), 12 deletions(-) diff --git a/.changeset/permission-api-batching.md b/.changeset/permission-api-batching.md index 857422665d..187eb2a3bc 100644 --- a/.changeset/permission-api-batching.md +++ b/.changeset/permission-api-batching.md @@ -2,4 +2,4 @@ '@backstage/plugin-permission-react': patch --- -The `PermissionApi.authorize()` method now accepts arrays of permission requests, and permission checks made in the same tick are batched into a single call to the permission backend. +Permission checks made in the same tick are now batched into a single call to the permission backend. diff --git a/plugins/permission-react/src/apis/IdentityPermissionApi.ts b/plugins/permission-react/src/apis/IdentityPermissionApi.ts index 6e848bd778..4a8edefd84 100644 --- a/plugins/permission-react/src/apis/IdentityPermissionApi.ts +++ b/plugins/permission-react/src/apis/IdentityPermissionApi.ts @@ -62,14 +62,8 @@ export class IdentityPermissionApi implements PermissionApi { request: AuthorizePermissionRequest, ): Promise; async authorize( - requests: AuthorizePermissionRequest[], - ): Promise; - async authorize( - request: AuthorizePermissionRequest | AuthorizePermissionRequest[], - ): Promise { - if (Array.isArray(request)) { - return Promise.all(request.map(r => this.loader.load(r))); - } + request: AuthorizePermissionRequest, + ): Promise { return this.loader.load(request); } } diff --git a/plugins/permission-react/src/apis/PermissionApi.ts b/plugins/permission-react/src/apis/PermissionApi.ts index 5f538a068d..d3a37421ef 100644 --- a/plugins/permission-react/src/apis/PermissionApi.ts +++ b/plugins/permission-react/src/apis/PermissionApi.ts @@ -30,9 +30,6 @@ export type PermissionApi = { authorize( request: EvaluatePermissionRequest, ): Promise; - authorize( - requests: EvaluatePermissionRequest[], - ): Promise; }; /** From d65d4812f64f07eab054b718ee73989cc924926d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 14 Mar 2026 12:33:19 +0100 Subject: [PATCH 26/68] frontend-plugin-api: support predicate overrides Allow plugin and extension overrides to replace or remove existing if predicates. This makes conditional plugin and extension behavior overrideable through both plugin overrides and module-installed extension overrides. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../prepare-specialized-app-signin-flow.md | 2 +- .../architecture/25-extension-overrides.md | 2 + .../src/tree/resolveAppNodeSpecs.test.ts | 92 +++++++++++++++++++ .../src/wiring/createExtension.ts | 7 +- .../src/wiring/createFrontendPlugin.ts | 10 ++ 5 files changed, 111 insertions(+), 2 deletions(-) diff --git a/.changeset/prepare-specialized-app-signin-flow.md b/.changeset/prepare-specialized-app-signin-flow.md index 59efcd8c34..422c35ae8b 100644 --- a/.changeset/prepare-specialized-app-signin-flow.md +++ b/.changeset/prepare-specialized-app-signin-flow.md @@ -4,4 +4,4 @@ '@backstage/frontend-plugin-api': patch --- -Adds `prepareSpecializedApp` as a new two-phase app wiring API for rendering a bootstrap tree before full app finalization. The bootstrap phase is exposed as a partial app tree through `getBootstrapApp()`, while `onFinalized()` provides a one-way handoff to the finalized app once bootstrap completes. The opaque reusable `sessionState` is returned from the finalized app and can be passed into a future `prepareSpecializedApp` call to skip sign-in and reuse the prepared session. Conditional `app/root.elements` and predicate-gated APIs are now deferred until finalization, while unsupported bootstrap-visible predicates are downgraded to warnings and bootstrap extensions now surface warnings if they accessed APIs that only became available after finalization. The `if` option is also now supported on `createFrontendPlugin` and `createFrontendModule`, and is applied to each extension from that feature together with any extension-level predicate. The existing `createSpecializedApp` API is now deprecated and backed by `prepareSpecializedApp().finalize()`, while `createApp` has been updated to use the same prepare/finalize flow. +Adds `prepareSpecializedApp` as a new two-phase app wiring API for rendering a bootstrap tree before full app finalization. The bootstrap phase is exposed as a partial app tree through `getBootstrapApp()`, while `onFinalized()` provides a one-way handoff to the finalized app once bootstrap completes. The opaque reusable `sessionState` is returned from the finalized app and can be passed into a future `prepareSpecializedApp` call to skip sign-in and reuse the prepared session. Conditional `app/root.elements` and predicate-gated APIs are now deferred until finalization, while unsupported bootstrap-visible predicates are downgraded to warnings and bootstrap extensions now surface warnings if they accessed APIs that only became available after finalization. The `if` option is also now supported on `createFrontendPlugin` and `createFrontendModule`, and is applied to each extension from that feature together with any extension-level predicate. Plugin and extension overrides can now also replace or remove existing `if` predicates. 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/docs/frontend-system/architecture/25-extension-overrides.md b/docs/frontend-system/architecture/25-extension-overrides.md index b3a7759867..7afbd2bacb 100644 --- a/docs/frontend-system/architecture/25-extension-overrides.md +++ b/docs/frontend-system/architecture/25-extension-overrides.md @@ -11,6 +11,8 @@ An important customization point in the frontend system is the ability to overri In general, most features should have a good level of customization built into them, so that users do not have to leverage extension overrides to achieve common goals. A well written feature often has [configuration](../../conf/) settings, or uses extension inputs for extensibility where applicable. An example of this is the search plugin, which allows you to provide result renderers as inputs rather than replacing the result page wholesale just to tweak how results are shown. Adopters should take advantage of those when possible in order to reduce the need and size of extension overrides. +Extension overrides can also replace or remove existing `if` predicates. This applies both to direct extension overrides through `.override(...)` and to plugin-level overrides through `plugin.withOverrides(...)`. Frontend modules can use the same extension override mechanism to adjust or clear the condition for an overridden extension. + ## Overriding an extension Every extension created with `createExtension` comes with an `override` method, including those created from an [extension blueprint](./23-extension-blueprints.md). The `override` method **creates a new extension**, it does not mutate the existing extension. This new extension in created in such a way that if it is installed adjacent to the existing extension, it will take precedence and override the existing extension. While the `override` method does create new extension instances, it is not intended to be used as a way to create multiple new extensions from a base template, for that use-case you will want to use an [extension blueprint](./23-extension-blueprints.md) instead. diff --git a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts index 8116999cbf..ec56394b39 100644 --- a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts +++ b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts @@ -567,6 +567,42 @@ describe('resolveAppNodeSpecs', () => { expect(specs[1].if).toEqual(pluginIf); }); + it('should allow plugin overrides to replace or remove plugin if predicates', () => { + const dataRef = createExtensionDataRef().with({ id: 'test.data' }); + const pluginIf = { featureFlags: { $contains: 'plugin-flag' } }; + const overrideIf = { permissions: { $contains: 'override.permission' } }; + const plugin = createFrontendPlugin({ + pluginId: 'test-plugin', + if: pluginIf, + extensions: [ + createExtension({ + name: 'one', + attachTo: { id: 'app', input: 'root' }, + output: [dataRef], + factory: () => [dataRef('one')], + }), + ], + }); + + const overriddenSpecs = resolveAppNodeSpecs({ + features: [plugin.withOverrides({ if: overrideIf })], + builtinExtensions: [], + parameters: [], + collector, + }); + const clearedSpecs = resolveAppNodeSpecs({ + features: [plugin.withOverrides({ if: undefined })], + builtinExtensions: [], + parameters: [], + collector, + }); + + expect(overriddenSpecs).toHaveLength(1); + expect(overriddenSpecs[0].if).toEqual(overrideIf); + expect(clearedSpecs).toHaveLength(1); + expect(clearedSpecs[0].if).toBeUndefined(); + }); + it('should merge plugin and module if predicates with extension predicates', () => { const dataRef = createExtensionDataRef().with({ id: 'test.data' }); const pluginIf = { featureFlags: { $contains: 'plugin-flag' } }; @@ -616,4 +652,60 @@ describe('resolveAppNodeSpecs', () => { expect(specs[1].id).toBe('test-plugin/module-extension'); expect(specs[1].if).toEqual({ $all: [moduleIf, moduleExtensionIf] }); }); + + it('should allow module extension overrides to replace or remove extension if predicates', () => { + const dataRef = createExtensionDataRef().with({ id: 'test.data' }); + const extensionIf = { featureFlags: { $contains: 'extension-flag' } }; + const overrideIf = { permissions: { $contains: 'override.permission' } }; + const plugin = createFrontendPlugin({ + pluginId: 'test-plugin', + extensions: [ + createExtension({ + name: 'extension', + attachTo: { id: 'app', input: 'root' }, + if: extensionIf, + output: [dataRef], + factory: () => [dataRef('base')], + }), + ], + }); + + const overriddenSpecs = resolveAppNodeSpecs({ + features: [ + plugin, + createFrontendModule({ + pluginId: 'test-plugin', + extensions: [ + plugin.getExtension('test-plugin/extension').override({ + if: overrideIf, + }), + ], + }), + ], + builtinExtensions: [], + parameters: [], + collector, + }); + const clearedSpecs = resolveAppNodeSpecs({ + features: [ + plugin, + createFrontendModule({ + pluginId: 'test-plugin', + extensions: [ + plugin.getExtension('test-plugin/extension').override({ + if: undefined, + }), + ], + }), + ], + builtinExtensions: [], + parameters: [], + collector, + }); + + expect(overriddenSpecs).toHaveLength(1); + expect(overriddenSpecs[0].if).toEqual(overrideIf); + expect(clearedSpecs).toHaveLength(1); + expect(clearedSpecs[0].if).toBeUndefined(); + }); }); diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts index 6f3e87acfd..8632b3caeb 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts @@ -549,13 +549,18 @@ export function createExtension< ); } + let ifPredicate = options.if; + if ('if' in overrideOptions) { + ifPredicate = overrideOptions.if; + } + return createExtension({ kind: options.kind, name: options.name, attachTo: (overrideOptions.attachTo ?? options.attachTo) as ExtensionDefinitionAttachTo, disabled: overrideOptions.disabled ?? options.disabled, - if: overrideOptions.if ?? options.if, + if: ifPredicate, inputs: bindInputs( { ...(options.inputs ?? {}), diff --git a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts index 18e4218dfc..9e9c58c4b6 100644 --- a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts +++ b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts @@ -116,6 +116,11 @@ export interface OverridableFrontendPlugin< withOverrides(options: { extensions?: Array; + /** + * Overrides the shared condition that applies to all extensions in the plugin. + */ + if?: FilterPredicate; + /** * Overrides the display title of the plugin. */ @@ -329,6 +334,10 @@ export function createFrontendPlugin< return `Plugin{id=${pluginId}}`; }, withOverrides(overrides) { + let ifPredicate = options.if; + if ('if' in overrides) { + ifPredicate = overrides.if; + } const overrideExtensions = overrides.extensions ?? []; const overriddenExtensionIds = new Set( overrideExtensions.map( @@ -344,6 +353,7 @@ export function createFrontendPlugin< return createFrontendPlugin({ ...options, pluginId, + if: ifPredicate, title: overrides.title ?? options.title, icon: overrides.icon ?? options.icon, extensions: [...nonOverriddenExtensions, ...overrideExtensions], From b6fc7f861f232aeccacf820485a3786ce6b98551 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 14 Mar 2026 15:43:01 +0100 Subject: [PATCH 27/68] frontend-app-api: fix phased predicate type checks Use internal extension shapes when reading predicate metadata and type the finalized app test helper explicitly. This fixes the typecheck breakage introduced by the phased predicate and override changes. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../src/tree/resolveAppNodeSpecs.ts | 40 ++++++++++--------- .../src/wiring/InternalFrontendPlugin.ts | 2 +- 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts index 318479d6b5..0e56fa4a98 100644 --- a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts +++ b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts @@ -56,12 +56,8 @@ function combinePredicates( } function getExtensionPredicate(options: { - extension: Extension; internalExtension: ReturnType; }) { - if (options.extension.version === 'v2') { - return options.extension.if; - } if (options.internalExtension.version === 'v2') { return options.internalExtension.if; } @@ -109,20 +105,27 @@ export function resolveAppNodeSpecs(options: { const pluginExtensions = plugins.flatMap(plugin => { const internalPlugin = OpaqueFrontendPlugin.toInternal(plugin); return internalPlugin.extensions - .map(extension => ({ - ...extension, - plugin, - if: combinePredicates( - internalPlugin.if, - extension.version === 'v2' ? extension.if : undefined, - ), - })) + .map(extension => { + const internalExtension = toInternalExtension(extension); + return { + ...internalExtension, + plugin, + if: combinePredicates( + internalPlugin.if, + internalExtension.version === 'v2' + ? internalExtension.if + : undefined, + ), + }; + }) .filter(filterForbidden); }); const moduleExtensions = modules.flatMap(mod => { const internalModule = toInternalFrontendModule(mod); return internalModule.extensions .flatMap(extension => { + const internalExtension = toInternalExtension(extension); + // Modules for plugins that are not installed are ignored const plugin = plugins.find(p => p.pluginId === mod.pluginId); if (!plugin) { @@ -131,11 +134,13 @@ export function resolveAppNodeSpecs(options: { return [ { - ...extension, + ...internalExtension, plugin, if: combinePredicates( internalModule.if, - extension.version === 'v2' ? extension.if : undefined, + internalExtension.version === 'v2' + ? internalExtension.if + : undefined, ), }, ]; @@ -159,7 +164,7 @@ export function resolveAppNodeSpecs(options: { source: plugin, attachTo: internalExtension.attachTo, disabled: internalExtension.disabled, - if: getExtensionPredicate({ extension, internalExtension }), + if: getExtensionPredicate({ internalExtension }), config: undefined as unknown, }, }; @@ -173,7 +178,7 @@ export function resolveAppNodeSpecs(options: { plugin: appPlugin, attachTo: internalExtension.attachTo, disabled: internalExtension.disabled, - if: getExtensionPredicate({ extension, internalExtension }), + if: getExtensionPredicate({ internalExtension }), config: undefined as unknown, }, }; @@ -194,7 +199,6 @@ export function resolveAppNodeSpecs(options: { configuredExtensions[index].params.attachTo = internalExtension.attachTo; configuredExtensions[index].params.disabled = internalExtension.disabled; configuredExtensions[index].params.if = getExtensionPredicate({ - extension, internalExtension, }); } else { @@ -206,7 +210,7 @@ export function resolveAppNodeSpecs(options: { source: extension.plugin, attachTo: internalExtension.attachTo, disabled: internalExtension.disabled, - if: getExtensionPredicate({ extension, internalExtension }), + if: getExtensionPredicate({ internalExtension }), config: undefined, }, }); diff --git a/packages/frontend-internal/src/wiring/InternalFrontendPlugin.ts b/packages/frontend-internal/src/wiring/InternalFrontendPlugin.ts index 8d62e08627..31af631a59 100644 --- a/packages/frontend-internal/src/wiring/InternalFrontendPlugin.ts +++ b/packages/frontend-internal/src/wiring/InternalFrontendPlugin.ts @@ -17,10 +17,10 @@ import { Extension, FeatureFlagConfig, - FilterPredicate, IconElement, OverridableFrontendPlugin, } from '@backstage/frontend-plugin-api'; +import { FilterPredicate } from '@backstage/filter-predicates'; import { JsonObject } from '@backstage/types'; import { OpaqueType } from '@internal/opaque'; From 00982b947d4c95ae17d9f7b781f4815ed3c98631 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 14 Mar 2026 15:59:06 +0100 Subject: [PATCH 28/68] repo: refresh phased app API reports Update generated API reports to match the phased app and predicate changes, and drop the stray core-compat-api dependency addition that would otherwise require its own changeset. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- packages/core-compat-api/package.json | 1 - packages/frontend-defaults/report.api.md | 2 +- plugins/app/report.api.md | 2 +- yarn.lock | 1 - 4 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/core-compat-api/package.json b/packages/core-compat-api/package.json index 6ee93be875..152b6d1141 100644 --- a/packages/core-compat-api/package.json +++ b/packages/core-compat-api/package.json @@ -33,7 +33,6 @@ "dependencies": { "@backstage/core-plugin-api": "workspace:^", "@backstage/errors": "workspace:^", - "@backstage/filter-predicates": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", "@backstage/plugin-app-react": "workspace:^", "@backstage/plugin-catalog-react": "workspace:^", diff --git a/packages/frontend-defaults/report.api.md b/packages/frontend-defaults/report.api.md index 72bd0d785e..cfe4f55b2d 100644 --- a/packages/frontend-defaults/report.api.md +++ b/packages/frontend-defaults/report.api.md @@ -8,7 +8,7 @@ import { AppErrorTypes } from '@backstage/frontend-app-api'; import { Config } from '@backstage/config'; import { ConfigApi } from '@backstage/frontend-plugin-api'; import { CreateAppRouteBinder } from '@backstage/frontend-app-api'; -import { ExtensionFactoryMiddleware } from '@backstage/frontend-app-api'; +import { ExtensionFactoryMiddleware } from '@backstage/frontend-plugin-api'; import { FrontendFeature } from '@backstage/frontend-plugin-api'; import { FrontendFeatureLoader } from '@backstage/frontend-plugin-api'; import { FrontendPluginInfoResolver } from '@backstage/frontend-app-api'; diff --git a/plugins/app/report.api.md b/plugins/app/report.api.md index 8347e5f447..211604bc10 100644 --- a/plugins/app/report.api.md +++ b/plugins/app/report.api.md @@ -147,7 +147,7 @@ const appPlugin: OverridableFrontendPlugin< ConfigurableExtensionDataRef, { singleton: true; - optional: false; + optional: true; internal: false; } >; diff --git a/yarn.lock b/yarn.lock index d9f53d1439..5c1a660969 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3359,7 +3359,6 @@ __metadata: "@backstage/core-app-api": "workspace:^" "@backstage/core-plugin-api": "workspace:^" "@backstage/errors": "workspace:^" - "@backstage/filter-predicates": "workspace:^" "@backstage/frontend-app-api": "workspace:^" "@backstage/frontend-plugin-api": "workspace:^" "@backstage/frontend-test-utils": "workspace:^" From e4d8a9ce52f8b5a7c984e196062a468aae99d322 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 14 Mar 2026 16:33:06 +0100 Subject: [PATCH 29/68] repo: refresh blueprint snapshots for predicates Update inline snapshots that now include the new extension predicate shape so the changed-package test suite matches the intentional blueprint output. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- packages/core-compat-api/src/convertLegacyPlugin.test.tsx | 1 + .../src/blueprints/AnalyticsImplementationBlueprint.test.ts | 1 + .../frontend-plugin-api/src/blueprints/ApiBlueprint.test.ts | 2 ++ .../src/blueprints/AppRootElementBlueprint.test.tsx | 1 + .../src/blueprints/NavItemBlueprint.test.tsx | 1 + .../frontend-plugin-api/src/blueprints/PageBlueprint.test.tsx | 1 + .../app-react/src/blueprints/AppRootWrapperBlueprint.test.tsx | 1 + plugins/app-react/src/blueprints/NavContentBlueprint.test.tsx | 1 + plugins/app-react/src/blueprints/RouterBlueprint.test.tsx | 1 + plugins/app-react/src/blueprints/SignInPageBlueprint.test.tsx | 1 + plugins/app-react/src/blueprints/ThemeBlueprint.test.ts | 1 + plugins/app-react/src/blueprints/TranslationBlueprint.test.ts | 1 + .../src/alpha/blueprints/CatalogFilterBlueprint.test.tsx | 1 + .../src/alpha/blueprints/EntityCardBlueprint.test.tsx | 1 + .../src/alpha/blueprints/EntityContentBlueprint.test.tsx | 1 + .../alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx | 1 + .../src/alpha/blueprints/SearchFilterBlueprint.test.tsx | 1 + .../alpha/blueprints/SearchFilterResultTypeBlueprint.test.tsx | 1 + .../src/alpha/blueprints/SearchResultListItemBlueprint.test.tsx | 1 + 19 files changed, 20 insertions(+) diff --git a/packages/core-compat-api/src/convertLegacyPlugin.test.tsx b/packages/core-compat-api/src/convertLegacyPlugin.test.tsx index f8436e7434..9a18d86b1f 100644 --- a/packages/core-compat-api/src/convertLegacyPlugin.test.tsx +++ b/packages/core-compat-api/src/convertLegacyPlugin.test.tsx @@ -42,6 +42,7 @@ describe('convertLegacyPlugin', () => { "getExtension": [Function], "icon": undefined, "id": "test", + "if": undefined, "info": [Function], "infoOptions": undefined, "pluginId": "test", diff --git a/packages/frontend-plugin-api/src/blueprints/AnalyticsImplementationBlueprint.test.ts b/packages/frontend-plugin-api/src/blueprints/AnalyticsImplementationBlueprint.test.ts index 06ad9b877e..6d43e0352f 100644 --- a/packages/frontend-plugin-api/src/blueprints/AnalyticsImplementationBlueprint.test.ts +++ b/packages/frontend-plugin-api/src/blueprints/AnalyticsImplementationBlueprint.test.ts @@ -39,6 +39,7 @@ describe('AnalyticsBlueprint', () => { "configSchema": undefined, "disabled": false, "factory": [Function], + "if": undefined, "inputs": {}, "kind": "analytics", "name": "test", diff --git a/packages/frontend-plugin-api/src/blueprints/ApiBlueprint.test.ts b/packages/frontend-plugin-api/src/blueprints/ApiBlueprint.test.ts index 8e9c35afc8..b39abb175b 100644 --- a/packages/frontend-plugin-api/src/blueprints/ApiBlueprint.test.ts +++ b/packages/frontend-plugin-api/src/blueprints/ApiBlueprint.test.ts @@ -43,6 +43,7 @@ describe('ApiBlueprint', () => { "configSchema": undefined, "disabled": false, "factory": [Function], + "if": undefined, "inputs": {}, "kind": "api", "name": "test", @@ -196,6 +197,7 @@ describe('ApiBlueprint', () => { }, "disabled": false, "factory": [Function], + "if": undefined, "inputs": { "test": { "$$type": "@backstage/ExtensionInput", diff --git a/packages/frontend-plugin-api/src/blueprints/AppRootElementBlueprint.test.tsx b/packages/frontend-plugin-api/src/blueprints/AppRootElementBlueprint.test.tsx index 57984761ac..0e477dd2f1 100644 --- a/packages/frontend-plugin-api/src/blueprints/AppRootElementBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/blueprints/AppRootElementBlueprint.test.tsx @@ -42,6 +42,7 @@ describe('AppRootElementBlueprint', () => { "configSchema": undefined, "disabled": false, "factory": [Function], + "if": undefined, "inputs": {}, "kind": "app-root-element", "name": undefined, diff --git a/packages/frontend-plugin-api/src/blueprints/NavItemBlueprint.test.tsx b/packages/frontend-plugin-api/src/blueprints/NavItemBlueprint.test.tsx index 8656cc7a33..b3be3fb8a8 100644 --- a/packages/frontend-plugin-api/src/blueprints/NavItemBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/blueprints/NavItemBlueprint.test.tsx @@ -53,6 +53,7 @@ describe('NavItemBlueprint', () => { }, "disabled": false, "factory": [Function], + "if": undefined, "inputs": {}, "kind": "nav-item", "name": undefined, diff --git a/packages/frontend-plugin-api/src/blueprints/PageBlueprint.test.tsx b/packages/frontend-plugin-api/src/blueprints/PageBlueprint.test.tsx index 8fc9c56bfe..925c370af6 100644 --- a/packages/frontend-plugin-api/src/blueprints/PageBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/blueprints/PageBlueprint.test.tsx @@ -65,6 +65,7 @@ describe('PageBlueprint', () => { }, "disabled": false, "factory": [Function], + "if": undefined, "inputs": { "pages": { "$$type": "@backstage/ExtensionInput", diff --git a/plugins/app-react/src/blueprints/AppRootWrapperBlueprint.test.tsx b/plugins/app-react/src/blueprints/AppRootWrapperBlueprint.test.tsx index a61f583ca8..956e4bbaee 100644 --- a/plugins/app-react/src/blueprints/AppRootWrapperBlueprint.test.tsx +++ b/plugins/app-react/src/blueprints/AppRootWrapperBlueprint.test.tsx @@ -43,6 +43,7 @@ describe('AppRootWrapperBlueprint', () => { "configSchema": undefined, "disabled": false, "factory": [Function], + "if": undefined, "inputs": {}, "kind": "app-root-wrapper", "name": undefined, diff --git a/plugins/app-react/src/blueprints/NavContentBlueprint.test.tsx b/plugins/app-react/src/blueprints/NavContentBlueprint.test.tsx index cb782409b1..2c74300d9d 100644 --- a/plugins/app-react/src/blueprints/NavContentBlueprint.test.tsx +++ b/plugins/app-react/src/blueprints/NavContentBlueprint.test.tsx @@ -86,6 +86,7 @@ describe('NavContentBlueprint', () => { "configSchema": undefined, "disabled": false, "factory": [Function], + "if": undefined, "inputs": {}, "kind": "nav-content", "name": undefined, diff --git a/plugins/app-react/src/blueprints/RouterBlueprint.test.tsx b/plugins/app-react/src/blueprints/RouterBlueprint.test.tsx index 0371794fff..899cb34903 100644 --- a/plugins/app-react/src/blueprints/RouterBlueprint.test.tsx +++ b/plugins/app-react/src/blueprints/RouterBlueprint.test.tsx @@ -42,6 +42,7 @@ describe('RouterBlueprint', () => { "configSchema": undefined, "disabled": false, "factory": [Function], + "if": undefined, "inputs": {}, "kind": "app-router-component", "name": undefined, diff --git a/plugins/app-react/src/blueprints/SignInPageBlueprint.test.tsx b/plugins/app-react/src/blueprints/SignInPageBlueprint.test.tsx index aa1f49c075..d7db57efad 100644 --- a/plugins/app-react/src/blueprints/SignInPageBlueprint.test.tsx +++ b/plugins/app-react/src/blueprints/SignInPageBlueprint.test.tsx @@ -38,6 +38,7 @@ describe('SignInPageBlueprint', () => { "configSchema": undefined, "disabled": false, "factory": [Function], + "if": undefined, "inputs": {}, "kind": "sign-in-page", "name": undefined, diff --git a/plugins/app-react/src/blueprints/ThemeBlueprint.test.ts b/plugins/app-react/src/blueprints/ThemeBlueprint.test.ts index 5ee66ece67..5ab14e7617 100644 --- a/plugins/app-react/src/blueprints/ThemeBlueprint.test.ts +++ b/plugins/app-react/src/blueprints/ThemeBlueprint.test.ts @@ -39,6 +39,7 @@ describe('ThemeBlueprint', () => { "configSchema": undefined, "disabled": false, "factory": [Function], + "if": undefined, "inputs": {}, "kind": "theme", "name": "light", diff --git a/plugins/app-react/src/blueprints/TranslationBlueprint.test.ts b/plugins/app-react/src/blueprints/TranslationBlueprint.test.ts index bf291526ca..47206e91b1 100644 --- a/plugins/app-react/src/blueprints/TranslationBlueprint.test.ts +++ b/plugins/app-react/src/blueprints/TranslationBlueprint.test.ts @@ -54,6 +54,7 @@ describe('TranslationBlueprint', () => { "configSchema": undefined, "disabled": false, "factory": [Function], + "if": undefined, "inputs": {}, "kind": "translation", "name": "blob", diff --git a/plugins/catalog-react/src/alpha/blueprints/CatalogFilterBlueprint.test.tsx b/plugins/catalog-react/src/alpha/blueprints/CatalogFilterBlueprint.test.tsx index 95a1f4c468..fc2bbb993d 100644 --- a/plugins/catalog-react/src/alpha/blueprints/CatalogFilterBlueprint.test.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/CatalogFilterBlueprint.test.tsx @@ -43,6 +43,7 @@ describe('CatalogFilterBlueprint', () => { "configSchema": undefined, "disabled": false, "factory": [Function], + "if": undefined, "inputs": {}, "kind": "catalog-filter", "name": undefined, diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx index 0ae880a462..491f3a2dba 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx @@ -200,6 +200,7 @@ describe('EntityCardBlueprint', () => { }, "disabled": false, "factory": [Function], + "if": undefined, "inputs": {}, "kind": "entity-card", "name": "test", diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx index 661f501b3f..5bc1aba2c2 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx @@ -215,6 +215,7 @@ describe('EntityContentBlueprint', () => { }, "disabled": false, "factory": [Function], + "if": undefined, "inputs": {}, "kind": "entity-content", "name": "test", diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx index 6201f32f2d..40f8915917 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx @@ -204,6 +204,7 @@ describe('EntityContextMenuItemBlueprint', () => { }, "disabled": false, "factory": [Function], + "if": undefined, "inputs": {}, "kind": "entity-context-menu-item", "name": "test", diff --git a/plugins/search-react/src/alpha/blueprints/SearchFilterBlueprint.test.tsx b/plugins/search-react/src/alpha/blueprints/SearchFilterBlueprint.test.tsx index adfceeec47..2d48454003 100644 --- a/plugins/search-react/src/alpha/blueprints/SearchFilterBlueprint.test.tsx +++ b/plugins/search-react/src/alpha/blueprints/SearchFilterBlueprint.test.tsx @@ -45,6 +45,7 @@ describe('SearchFilterBlueprint', () => { "configSchema": undefined, "disabled": false, "factory": [Function], + "if": undefined, "inputs": {}, "kind": "search-filter", "name": "test", diff --git a/plugins/search-react/src/alpha/blueprints/SearchFilterResultTypeBlueprint.test.tsx b/plugins/search-react/src/alpha/blueprints/SearchFilterResultTypeBlueprint.test.tsx index d9f8e97080..d324956068 100644 --- a/plugins/search-react/src/alpha/blueprints/SearchFilterResultTypeBlueprint.test.tsx +++ b/plugins/search-react/src/alpha/blueprints/SearchFilterResultTypeBlueprint.test.tsx @@ -47,6 +47,7 @@ describe('SearchFilterResultTypeBlueprint', () => { "configSchema": undefined, "disabled": false, "factory": [Function], + "if": undefined, "inputs": {}, "kind": "search-filter-result-type", "name": "test", diff --git a/plugins/search-react/src/alpha/blueprints/SearchResultListItemBlueprint.test.tsx b/plugins/search-react/src/alpha/blueprints/SearchResultListItemBlueprint.test.tsx index 112c0a8470..3ef5e2d0e9 100644 --- a/plugins/search-react/src/alpha/blueprints/SearchResultListItemBlueprint.test.tsx +++ b/plugins/search-react/src/alpha/blueprints/SearchResultListItemBlueprint.test.tsx @@ -60,6 +60,7 @@ describe('SearchResultListItemBlueprint', () => { }, "disabled": false, "factory": [Function], + "if": undefined, "inputs": {}, "kind": "search-result-list-item", "name": "test", From 3c6436cba0b606584051bf8d7274f4054802bb7d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 14 Mar 2026 16:45:45 +0100 Subject: [PATCH 30/68] core-compat-api: restore filter predicate dependency Keep the redeclared filter-predicates dependency required by core-compat-api's inline frontend imports so the changed-package lint check passes. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- packages/core-compat-api/package.json | 1 + yarn.lock | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/core-compat-api/package.json b/packages/core-compat-api/package.json index 152b6d1141..6ee93be875 100644 --- a/packages/core-compat-api/package.json +++ b/packages/core-compat-api/package.json @@ -33,6 +33,7 @@ "dependencies": { "@backstage/core-plugin-api": "workspace:^", "@backstage/errors": "workspace:^", + "@backstage/filter-predicates": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", "@backstage/plugin-app-react": "workspace:^", "@backstage/plugin-catalog-react": "workspace:^", diff --git a/yarn.lock b/yarn.lock index 5c1a660969..d9f53d1439 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3359,6 +3359,7 @@ __metadata: "@backstage/core-app-api": "workspace:^" "@backstage/core-plugin-api": "workspace:^" "@backstage/errors": "workspace:^" + "@backstage/filter-predicates": "workspace:^" "@backstage/frontend-app-api": "workspace:^" "@backstage/frontend-plugin-api": "workspace:^" "@backstage/frontend-test-utils": "workspace:^" From 5b160f97dbfbfada614032a40b18dc0a68ddaaf3 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 14 Mar 2026 21:30:32 +0100 Subject: [PATCH 31/68] changesets: split phased app follow-up notes Split the combined phased app changeset into package-specific entries so each published package describes only its own adopter-facing change. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .changeset/prepare-specialized-app-frontend-app-api.md | 5 +++++ .changeset/prepare-specialized-app-frontend-defaults.md | 5 +++++ .changeset/prepare-specialized-app-frontend-plugin-api.md | 5 +++++ .changeset/prepare-specialized-app-signin-flow.md | 7 ------- 4 files changed, 15 insertions(+), 7 deletions(-) create mode 100644 .changeset/prepare-specialized-app-frontend-app-api.md create mode 100644 .changeset/prepare-specialized-app-frontend-defaults.md create mode 100644 .changeset/prepare-specialized-app-frontend-plugin-api.md delete mode 100644 .changeset/prepare-specialized-app-signin-flow.md diff --git a/.changeset/prepare-specialized-app-frontend-app-api.md b/.changeset/prepare-specialized-app-frontend-app-api.md new file mode 100644 index 0000000000..fd60cf00d7 --- /dev/null +++ b/.changeset/prepare-specialized-app-frontend-app-api.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-app-api': patch +--- + +Added `prepareSpecializedApp` for two-phase app wiring so apps can render a bootstrap tree before full app finalization. The bootstrap phase now supports deferred `app/root.elements`, predicate-gated APIs, reusable `sessionState`, and warnings for bootstrap-visible predicates or bootstrap code that accessed APIs that only became available after finalization. diff --git a/.changeset/prepare-specialized-app-frontend-defaults.md b/.changeset/prepare-specialized-app-frontend-defaults.md new file mode 100644 index 0000000000..12e153fc75 --- /dev/null +++ b/.changeset/prepare-specialized-app-frontend-defaults.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-defaults': patch +--- + +Updated `createApp` to use the phased `prepareSpecializedApp` flow, allowing apps to render a bootstrap tree before the full app is finalized. diff --git a/.changeset/prepare-specialized-app-frontend-plugin-api.md b/.changeset/prepare-specialized-app-frontend-plugin-api.md new file mode 100644 index 0000000000..9933690f39 --- /dev/null +++ b/.changeset/prepare-specialized-app-frontend-plugin-api.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-plugin-api': patch +--- + +Added support for `if` predicates on `createFrontendPlugin` and `createFrontendModule`, applying shared conditions to every extension in the feature. Plugin and extension overrides can now also replace or remove existing `if` predicates. diff --git a/.changeset/prepare-specialized-app-signin-flow.md b/.changeset/prepare-specialized-app-signin-flow.md deleted file mode 100644 index 422c35ae8b..0000000000 --- a/.changeset/prepare-specialized-app-signin-flow.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@backstage/frontend-app-api': patch -'@backstage/frontend-defaults': patch -'@backstage/frontend-plugin-api': patch ---- - -Adds `prepareSpecializedApp` as a new two-phase app wiring API for rendering a bootstrap tree before full app finalization. The bootstrap phase is exposed as a partial app tree through `getBootstrapApp()`, while `onFinalized()` provides a one-way handoff to the finalized app once bootstrap completes. The opaque reusable `sessionState` is returned from the finalized app and can be passed into a future `prepareSpecializedApp` call to skip sign-in and reuse the prepared session. Conditional `app/root.elements` and predicate-gated APIs are now deferred until finalization, while unsupported bootstrap-visible predicates are downgraded to warnings and bootstrap extensions now surface warnings if they accessed APIs that only became available after finalization. The `if` option is also now supported on `createFrontendPlugin` and `createFrontendModule`, and is applied to each extension from that feature together with any extension-level predicate. Plugin and extension overrides can now also replace or remove existing `if` predicates. The existing `createSpecializedApp` API is now deprecated and backed by `prepareSpecializedApp().finalize()`, while `createApp` has been updated to use the same prepare/finalize flow. From 37ff85614238bf4e695ab642f5f69c197c5ccd1f Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 15 Mar 2026 01:08:30 +0100 Subject: [PATCH 32/68] frontend-defaults: cover conditional page usage patterns Add createApp coverage for the new page-level and page-child predicate patterns so the example app is backed by implementation-adjacent tests. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../frontend-defaults/src/createApp.test.tsx | 459 ++++++++++++++++++ 1 file changed, 459 insertions(+) diff --git a/packages/frontend-defaults/src/createApp.test.tsx b/packages/frontend-defaults/src/createApp.test.tsx index 9b3733b5af..c4a4861e1b 100644 --- a/packages/frontend-defaults/src/createApp.test.tsx +++ b/packages/frontend-defaults/src/createApp.test.tsx @@ -22,6 +22,8 @@ import { createApiRef, createExtensionDataRef, createExtension, + createExtensionBlueprint, + createExtensionInput, PageBlueprint, createFrontendPlugin, createFrontendFeatureLoader, @@ -40,6 +42,8 @@ import { } from '@backstage/core-plugin-api'; import { default as appPluginOriginal } from '@backstage/plugin-app'; import { ComponentType, useState, useEffect } from 'react'; +import { permissionApiRef } from '@backstage/plugin-permission-react'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; const signInPageComponentDataRef = createExtensionDataRef< ComponentType<{ onSignInSuccess(identity: IdentityApi): void }> @@ -54,6 +58,25 @@ describe('createApp', () => { ], }); + function createFeatureFlagsApi(activeFlags: string[]) { + return { + isActive: jest.fn((name: string) => activeFlags.includes(name)), + registerFlag: jest.fn(), + getRegisteredFlags: () => [], + save: jest.fn(), + } as unknown as typeof featureFlagsApiRef.T; + } + + function createPermissionApi(allowedPermissions: string[]) { + return { + authorize: jest.fn(async request => ({ + result: allowedPermissions.includes(request.permission.name) + ? AuthorizeResult.ALLOW + : AuthorizeResult.DENY, + })), + } as typeof permissionApiRef.T; + } + it('should allow themes to be installed', async () => { const app = createApp({ advanced: { @@ -516,6 +539,442 @@ describe('createApp', () => { expect(featureFlagsApi.isActive).toHaveBeenCalledWith('test-flag'); }); + it('should support $all feature flag predicates on pages', async () => { + const partialFlagsApi = createFeatureFlagsApi(['experimental-features']); + const partialFlagsApp = createApp({ + advanced: { + configLoader: async () => ({ config: mockApis.config() }), + }, + features: [ + appPlugin, + createFrontendModule({ + pluginId: 'app', + extensions: [ + ApiBlueprint.make({ + params: defineParams => + defineParams({ + api: featureFlagsApiRef, + deps: {}, + factory: () => partialFlagsApi, + }), + }), + ], + }), + createFrontendPlugin({ + pluginId: 'test', + featureFlags: [ + { name: 'experimental-features' }, + { name: 'advanced-features' }, + ], + extensions: [ + PageBlueprint.make({ + if: { + $all: [ + { featureFlags: { $contains: 'experimental-features' } }, + { featureFlags: { $contains: 'advanced-features' } }, + ], + }, + params: { + path: '/', + loader: async () =>
All Flags Page
, + }, + }), + ], + }), + ], + }); + + const partialRender = await renderWithEffects(partialFlagsApp.createRoot()); + await waitFor(() => + expect(screen.queryByText('All Flags Page')).not.toBeInTheDocument(), + ); + partialRender.unmount(); + + const allFlagsApi = createFeatureFlagsApi([ + 'experimental-features', + 'advanced-features', + ]); + const allFlagsApp = createApp({ + advanced: { + configLoader: async () => ({ config: mockApis.config() }), + }, + features: [ + appPlugin, + createFrontendModule({ + pluginId: 'app', + extensions: [ + ApiBlueprint.make({ + params: defineParams => + defineParams({ + api: featureFlagsApiRef, + deps: {}, + factory: () => allFlagsApi, + }), + }), + ], + }), + createFrontendPlugin({ + pluginId: 'test', + featureFlags: [ + { name: 'experimental-features' }, + { name: 'advanced-features' }, + ], + extensions: [ + PageBlueprint.make({ + if: { + $all: [ + { featureFlags: { $contains: 'experimental-features' } }, + { featureFlags: { $contains: 'advanced-features' } }, + ], + }, + params: { + path: '/', + loader: async () =>
All Flags Page
, + }, + }), + ], + }), + ], + }); + + await renderWithEffects(allFlagsApp.createRoot()); + await expect( + screen.findByText('All Flags Page'), + ).resolves.toBeInTheDocument(); + expect(allFlagsApi.isActive).toHaveBeenCalledWith('experimental-features'); + expect(allFlagsApi.isActive).toHaveBeenCalledWith('advanced-features'); + }); + + it('should support $any feature flag predicates on pages', async () => { + const noFlagsApi = createFeatureFlagsApi([]); + const noFlagsApp = createApp({ + advanced: { + configLoader: async () => ({ config: mockApis.config() }), + }, + features: [ + appPlugin, + createFrontendModule({ + pluginId: 'app', + extensions: [ + ApiBlueprint.make({ + params: defineParams => + defineParams({ + api: featureFlagsApiRef, + deps: {}, + factory: () => noFlagsApi, + }), + }), + ], + }), + createFrontendPlugin({ + pluginId: 'test', + featureFlags: [ + { name: 'experimental-features' }, + { name: 'beta-access' }, + ], + extensions: [ + PageBlueprint.make({ + if: { + $any: [ + { featureFlags: { $contains: 'experimental-features' } }, + { featureFlags: { $contains: 'beta-access' } }, + ], + }, + params: { + path: '/', + loader: async () =>
Any Flag Page
, + }, + }), + ], + }), + ], + }); + + const noFlagsRender = await renderWithEffects(noFlagsApp.createRoot()); + await waitFor(() => + expect(screen.queryByText('Any Flag Page')).not.toBeInTheDocument(), + ); + noFlagsRender.unmount(); + + const oneFlagApi = createFeatureFlagsApi(['beta-access']); + const oneFlagApp = createApp({ + advanced: { + configLoader: async () => ({ config: mockApis.config() }), + }, + features: [ + appPlugin, + createFrontendModule({ + pluginId: 'app', + extensions: [ + ApiBlueprint.make({ + params: defineParams => + defineParams({ + api: featureFlagsApiRef, + deps: {}, + factory: () => oneFlagApi, + }), + }), + ], + }), + createFrontendPlugin({ + pluginId: 'test', + featureFlags: [ + { name: 'experimental-features' }, + { name: 'beta-access' }, + ], + extensions: [ + PageBlueprint.make({ + if: { + $any: [ + { featureFlags: { $contains: 'experimental-features' } }, + { featureFlags: { $contains: 'beta-access' } }, + ], + }, + params: { + path: '/', + loader: async () =>
Any Flag Page
, + }, + }), + ], + }), + ], + }); + + await renderWithEffects(oneFlagApp.createRoot()); + await expect( + screen.findByText('Any Flag Page'), + ).resolves.toBeInTheDocument(); + expect(oneFlagApi.isActive).toHaveBeenCalledWith('experimental-features'); + expect(oneFlagApi.isActive).toHaveBeenCalledWith('beta-access'); + }); + + it('should support permission predicates on pages', async () => { + const deniedPermissionApi = createPermissionApi([]); + const deniedApp = createApp({ + advanced: { + configLoader: async () => ({ config: mockApis.config() }), + }, + features: [ + appPlugin, + createFrontendModule({ + pluginId: 'app', + extensions: [ + ApiBlueprint.make({ + params: defineParams => + defineParams({ + api: permissionApiRef, + deps: {}, + factory: () => deniedPermissionApi, + }), + }), + ], + }), + createFrontendPlugin({ + pluginId: 'test', + extensions: [ + PageBlueprint.make({ + if: { permissions: { $contains: 'catalog.entity.create' } }, + params: { + path: '/', + loader: async () =>
Permission Page
, + }, + }), + ], + }), + ], + }); + + const deniedRender = await renderWithEffects(deniedApp.createRoot()); + await waitFor(() => + expect(screen.queryByText('Permission Page')).not.toBeInTheDocument(), + ); + deniedRender.unmount(); + + const allowedPermissionApi = createPermissionApi(['catalog.entity.create']); + const allowedApp = createApp({ + advanced: { + configLoader: async () => ({ config: mockApis.config() }), + }, + features: [ + appPlugin, + createFrontendModule({ + pluginId: 'app', + extensions: [ + ApiBlueprint.make({ + params: defineParams => + defineParams({ + api: permissionApiRef, + deps: {}, + factory: () => allowedPermissionApi, + }), + }), + ], + }), + createFrontendPlugin({ + pluginId: 'test', + extensions: [ + PageBlueprint.make({ + if: { permissions: { $contains: 'catalog.entity.create' } }, + params: { + path: '/', + loader: async () =>
Permission Page
, + }, + }), + ], + }), + ], + }); + + await renderWithEffects(allowedApp.createRoot()); + await expect( + screen.findByText('Permission Page'), + ).resolves.toBeInTheDocument(); + expect(allowedPermissionApi.authorize).toHaveBeenCalledWith({ + permission: { name: 'catalog.entity.create', type: 'basic', attributes: {} }, + }); + }); + + it('should support conditional child extensions attached to pages', async () => { + const CardBlueprint = createExtensionBlueprint({ + kind: 'card', + attachTo: { id: 'page:test/card-page', input: 'cards' }, + output: [coreExtensionData.reactElement], + *factory(params: { title: string }) { + yield coreExtensionData.reactElement(
{params.title}
); + }, + }); + + const page = PageBlueprint.makeWithOverrides({ + name: 'card-page', + inputs: { + cards: createExtensionInput([coreExtensionData.reactElement], { + optional: false, + singleton: false, + }), + }, + factory(originalFactory, { inputs }) { + return originalFactory({ + path: '/', + loader: async () => ( +
+ {inputs.cards.map(card => card.get(coreExtensionData.reactElement))} +
+ ), + }); + }, + }); + + const publicCard = CardBlueprint.make({ + name: 'public', + params: { title: 'Public Card' }, + }); + const permissionCard = CardBlueprint.make({ + name: 'permission', + params: { title: 'Permission Card' }, + if: { permissions: { $contains: 'catalog.entity.create' } }, + }); + const featureFlagCard = CardBlueprint.make({ + name: 'feature-flag', + params: { title: 'Feature Flag Card' }, + if: { featureFlags: { $contains: 'experimental-card' } }, + }); + + const hiddenCardsApp = createApp({ + advanced: { + configLoader: async () => ({ config: mockApis.config() }), + }, + features: [ + appPlugin, + createFrontendModule({ + pluginId: 'app', + extensions: [ + ApiBlueprint.make({ + name: 'permission-api', + params: defineParams => + defineParams({ + api: permissionApiRef, + deps: {}, + factory: () => createPermissionApi([]), + }), + }), + ApiBlueprint.make({ + name: 'feature-flags-api', + params: defineParams => + defineParams({ + api: featureFlagsApiRef, + deps: {}, + factory: () => createFeatureFlagsApi([]), + }), + }), + ], + }), + createFrontendPlugin({ + pluginId: 'test', + featureFlags: [{ name: 'experimental-card' }], + extensions: [page, publicCard, permissionCard, featureFlagCard], + }), + ], + }); + + const hiddenCardsRender = await renderWithEffects(hiddenCardsApp.createRoot()); + await expect( + screen.findByText('Public Card'), + ).resolves.toBeInTheDocument(); + await waitFor(() => + expect(screen.queryByText('Permission Card')).not.toBeInTheDocument(), + ); + await waitFor(() => + expect(screen.queryByText('Feature Flag Card')).not.toBeInTheDocument(), + ); + hiddenCardsRender.unmount(); + + const visibleCardsApp = createApp({ + advanced: { + configLoader: async () => ({ config: mockApis.config() }), + }, + features: [ + appPlugin, + createFrontendModule({ + pluginId: 'app', + extensions: [ + ApiBlueprint.make({ + name: 'permission-api', + params: defineParams => + defineParams({ + api: permissionApiRef, + deps: {}, + factory: () => + createPermissionApi(['catalog.entity.create']), + }), + }), + ApiBlueprint.make({ + name: 'feature-flags-api', + params: defineParams => + defineParams({ + api: featureFlagsApiRef, + deps: {}, + factory: () => createFeatureFlagsApi(['experimental-card']), + }), + }), + ], + }), + createFrontendPlugin({ + pluginId: 'test', + featureFlags: [{ name: 'experimental-card' }], + extensions: [page, publicCard, permissionCard, featureFlagCard], + }), + ], + }); + + await renderWithEffects(visibleCardsApp.createRoot()); + await expect( + screen.findByText('Permission Card'), + ).resolves.toBeInTheDocument(); + await expect( + screen.findByText('Feature Flag Card'), + ).resolves.toBeInTheDocument(); + }); + it('should make the app structure available through the AppTreeApi', async () => { let appTreeApi: AppTreeApi | undefined = undefined; From e66bbfc85c13f690650eeced6aeb5b2d90184fa5 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 15 Mar 2026 01:17:06 +0100 Subject: [PATCH 33/68] frontend-test-utils: prepare apps before finalizing Move the test app helpers over to prepareSpecializedApp().finalize() so they stop depending on the deprecated createSpecializedApp entrypoint. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- packages/frontend-test-utils/src/app/renderInTestApp.tsx | 6 +++--- packages/frontend-test-utils/src/app/renderTestApp.tsx | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/frontend-test-utils/src/app/renderInTestApp.tsx b/packages/frontend-test-utils/src/app/renderInTestApp.tsx index eeb0e02353..4e62d55c4d 100644 --- a/packages/frontend-test-utils/src/app/renderInTestApp.tsx +++ b/packages/frontend-test-utils/src/app/renderInTestApp.tsx @@ -16,7 +16,7 @@ import { Fragment } from 'react'; import { Link, MemoryRouter } from 'react-router-dom'; -import { createSpecializedApp } from '@backstage/frontend-app-api'; +import { prepareSpecializedApp } from '@backstage/frontend-app-api'; import { RenderResult, render } from '@testing-library/react'; import { ConfigReader } from '@backstage/config'; import { JsonObject } from '@backstage/types'; @@ -233,7 +233,7 @@ export function renderInTestApp( features.push(...options.features); } - const app = createSpecializedApp({ + const app = prepareSpecializedApp({ features, config: ConfigReader.fromConfigs([ { @@ -251,7 +251,7 @@ export function renderInTestApp( return createApiFactory(apiRef, implementation); }), }, - } as CreateSpecializedAppInternalOptions); + } as CreateSpecializedAppInternalOptions).finalize(); return render( app.tree.root.instance!.getData(coreExtensionData.reactElement), diff --git a/packages/frontend-test-utils/src/app/renderTestApp.tsx b/packages/frontend-test-utils/src/app/renderTestApp.tsx index f470be7dbb..7708fa4220 100644 --- a/packages/frontend-test-utils/src/app/renderTestApp.tsx +++ b/packages/frontend-test-utils/src/app/renderTestApp.tsx @@ -15,7 +15,7 @@ */ import { Fragment } from 'react'; -import { createSpecializedApp } from '@backstage/frontend-app-api'; +import { prepareSpecializedApp } from '@backstage/frontend-app-api'; import { coreExtensionData, createApiFactory, @@ -175,7 +175,7 @@ export function renderTestApp( features.push(...options.features); } - const app = createSpecializedApp({ + const app = prepareSpecializedApp({ features, config: ConfigReader.fromConfigs([ { @@ -193,7 +193,7 @@ export function renderTestApp( return createApiFactory(apiRef, implementation); }), }, - } as CreateSpecializedAppInternalOptions); + } as CreateSpecializedAppInternalOptions).finalize(); return render( app.tree.root.instance!.getData(coreExtensionData.reactElement), From 03d2484caef82b1fec65741d0fdc68cfa60a70af Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 15 Mar 2026 01:20:03 +0100 Subject: [PATCH 34/68] frontend-app-api: expose root elements on specialized apps Add an element shorthand to bootstrap and finalized app results so callers can render the root element without reaching through the tree instance. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- packages/frontend-defaults/src/createApp.tsx | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/frontend-defaults/src/createApp.tsx b/packages/frontend-defaults/src/createApp.tsx index 061137071b..728ec29e42 100644 --- a/packages/frontend-defaults/src/createApp.tsx +++ b/packages/frontend-defaults/src/createApp.tsx @@ -17,7 +17,6 @@ import { JSX, lazy, ReactNode, Suspense, useEffect, useState } from 'react'; import { ConfigApi, - coreExtensionData, ExtensionFactoryMiddleware, FrontendFeature, FrontendFeatureLoader, @@ -160,9 +159,7 @@ function PreparedAppRoot(props: { ); if (!finalizedApp) { - return bootstrapApp.tree.root.instance!.getData( - coreExtensionData.reactElement, - )!; + return bootstrapApp.element; } const errorPage = maybeCreateErrorPage(finalizedApp); @@ -170,7 +167,5 @@ function PreparedAppRoot(props: { return errorPage; } - return finalizedApp.tree.root.instance!.getData( - coreExtensionData.reactElement, - )!; + return finalizedApp.element; } From 6234db86e904337265656fb3980de22ef2a6e813 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 15 Mar 2026 02:00:38 +0100 Subject: [PATCH 35/68] style: apply prettier to phased app follow-up files Align the recent phased app follow-up changes with the repository formatting rules to avoid carrying a stray formatting-only diff. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../frontend-defaults/src/createApp.test.tsx | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/frontend-defaults/src/createApp.test.tsx b/packages/frontend-defaults/src/createApp.test.tsx index c4a4861e1b..be09c8b8df 100644 --- a/packages/frontend-defaults/src/createApp.test.tsx +++ b/packages/frontend-defaults/src/createApp.test.tsx @@ -830,7 +830,11 @@ describe('createApp', () => { screen.findByText('Permission Page'), ).resolves.toBeInTheDocument(); expect(allowedPermissionApi.authorize).toHaveBeenCalledWith({ - permission: { name: 'catalog.entity.create', type: 'basic', attributes: {} }, + permission: { + name: 'catalog.entity.create', + type: 'basic', + attributes: {}, + }, }); }); @@ -857,7 +861,9 @@ describe('createApp', () => { path: '/', loader: async () => (
- {inputs.cards.map(card => card.get(coreExtensionData.reactElement))} + {inputs.cards.map(card => + card.get(coreExtensionData.reactElement), + )}
), }); @@ -916,10 +922,10 @@ describe('createApp', () => { ], }); - const hiddenCardsRender = await renderWithEffects(hiddenCardsApp.createRoot()); - await expect( - screen.findByText('Public Card'), - ).resolves.toBeInTheDocument(); + const hiddenCardsRender = await renderWithEffects( + hiddenCardsApp.createRoot(), + ); + await expect(screen.findByText('Public Card')).resolves.toBeInTheDocument(); await waitFor(() => expect(screen.queryByText('Permission Card')).not.toBeInTheDocument(), ); @@ -943,8 +949,7 @@ describe('createApp', () => { defineParams({ api: permissionApiRef, deps: {}, - factory: () => - createPermissionApi(['catalog.entity.create']), + factory: () => createPermissionApi(['catalog.entity.create']), }), }), ApiBlueprint.make({ From 837de816d5dae5e4bd9b70c2d14ba2f8f2b2d114 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 15 Mar 2026 12:47:21 +0100 Subject: [PATCH 36/68] frontend-app-api: export prepare app options type Expose PrepareSpecializedAppOptions from the wiring entrypoint and refresh the API report so the new type is available to callers. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- packages/frontend-app-api/src/wiring/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/frontend-app-api/src/wiring/index.ts b/packages/frontend-app-api/src/wiring/index.ts index 8f55d1f3cf..e3c52227b9 100644 --- a/packages/frontend-app-api/src/wiring/index.ts +++ b/packages/frontend-app-api/src/wiring/index.ts @@ -18,6 +18,7 @@ export { type BootstrapSpecializedApp, type FinalizedSpecializedApp, prepareSpecializedApp, + type PrepareSpecializedAppOptions, type PreparedSpecializedApp, type SpecializedAppSessionState, createSpecializedApp, From ec794b9e8bf0b035768c306b7c533690fbe36b81 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 15 Mar 2026 13:04:02 +0100 Subject: [PATCH 37/68] frontend-app-api: fix changed-package lint regressions Declare the new frontend test dependencies, avoid the finalize options shadowing lint error, and add explicit assertions in frontend-test-utils so the changed-package lint jobs pass again. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- packages/frontend-defaults/package.json | 2 ++ packages/frontend-test-utils/package.json | 1 + .../src/apis/TestApiProvider.test.tsx | 12 ++++++++++++ yarn.lock | 3 +++ 4 files changed, 18 insertions(+) diff --git a/packages/frontend-defaults/package.json b/packages/frontend-defaults/package.json index 0ff81a490f..622763a6c9 100644 --- a/packages/frontend-defaults/package.json +++ b/packages/frontend-defaults/package.json @@ -43,6 +43,8 @@ "@backstage/cli": "workspace:^", "@backstage/core-plugin-api": "workspace:^", "@backstage/plugin-app-react": "workspace:^", + "@backstage/plugin-permission-common": "workspace:^", + "@backstage/plugin-permission-react": "workspace:^", "@backstage/test-utils": "workspace:^", "@testing-library/jest-dom": "^6.0.0", "@testing-library/react": "^16.0.0", diff --git a/packages/frontend-test-utils/package.json b/packages/frontend-test-utils/package.json index 13de1ef239..59c5545a81 100644 --- a/packages/frontend-test-utils/package.json +++ b/packages/frontend-test-utils/package.json @@ -34,6 +34,7 @@ "@backstage/config": "workspace:^", "@backstage/core-app-api": "workspace:^", "@backstage/core-plugin-api": "workspace:^", + "@backstage/filter-predicates": "workspace:^", "@backstage/frontend-app-api": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", "@backstage/plugin-app": "workspace:^", diff --git a/packages/frontend-test-utils/src/apis/TestApiProvider.test.tsx b/packages/frontend-test-utils/src/apis/TestApiProvider.test.tsx index 1bb5673751..5d27e3a228 100644 --- a/packages/frontend-test-utils/src/apis/TestApiProvider.test.tsx +++ b/packages/frontend-test-utils/src/apis/TestApiProvider.test.tsx @@ -38,6 +38,8 @@ describe('TestApiProvider', () => {
, ); + + expect(document.body).toBeInTheDocument(); }); it('should allow partial API implementations', () => { @@ -46,6 +48,8 @@ describe('TestApiProvider', () => {
, ); + + expect(document.body).toBeInTheDocument(); }); it('should reject mismatched types in tuple syntax', () => { @@ -55,6 +59,8 @@ describe('TestApiProvider', () => {
, ); + + expect(document.body).toBeInTheDocument(); }); it('should accept MockWithApiFactory entries', () => { @@ -63,6 +69,8 @@ describe('TestApiProvider', () => {
, ); + + expect(document.body).toBeInTheDocument(); }); it('should accept a mix of tuples and MockWithApiFactory entries', () => { @@ -71,6 +79,8 @@ describe('TestApiProvider', () => {
, ); + + expect(document.body).toBeInTheDocument(); }); it('should allow empty APIs', () => { @@ -79,6 +89,8 @@ describe('TestApiProvider', () => {
, ); + + expect(document.body).toBeInTheDocument(); }); it('should provide APIs at runtime', async () => { diff --git a/yarn.lock b/yarn.lock index d9f53d1439..072fb23cee 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3670,6 +3670,8 @@ __metadata: "@backstage/frontend-plugin-api": "workspace:^" "@backstage/plugin-app": "workspace:^" "@backstage/plugin-app-react": "workspace:^" + "@backstage/plugin-permission-common": "workspace:^" + "@backstage/plugin-permission-react": "workspace:^" "@backstage/test-utils": "workspace:^" "@react-hookz/web": "npm:^24.0.0" "@testing-library/jest-dom": "npm:^6.0.0" @@ -3789,6 +3791,7 @@ __metadata: "@backstage/config": "workspace:^" "@backstage/core-app-api": "workspace:^" "@backstage/core-plugin-api": "workspace:^" + "@backstage/filter-predicates": "workspace:^" "@backstage/frontend-app-api": "workspace:^" "@backstage/frontend-plugin-api": "workspace:^" "@backstage/plugin-app": "workspace:^" From ea7d951677205b556f2983a3ad2af0922fd557aa Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 15 Mar 2026 13:38:44 +0100 Subject: [PATCH 38/68] repo: accept rspack startup output in accessibility CI Use a generic Lighthouse server ready pattern so the accessibility job recognizes the example app startup log after the rspack switch. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- lighthouserc.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lighthouserc.js b/lighthouserc.js index c7c1275cdb..b8c503c5d2 100644 --- a/lighthouserc.js +++ b/lighthouserc.js @@ -54,7 +54,7 @@ module.exports = { preset: 'desktop', }, startServerCommand: 'yarn start', - startServerReadyPattern: 'webpack compiled successfully', + startServerReadyPattern: 'compiled successfully', startServerReadyTimeout: 600000, numberOfRuns: 1, puppeteerScript: './.lighthouseci/scripts/guest-auth.js', From 80202546d5021751d8e517080919e47779589992 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 15 Mar 2026 15:14:17 +0100 Subject: [PATCH 39/68] plugin-app-react: update analytics blueprint snapshot Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../src/blueprints/AnalyticsImplementationBlueprint.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/app-react/src/blueprints/AnalyticsImplementationBlueprint.test.ts b/plugins/app-react/src/blueprints/AnalyticsImplementationBlueprint.test.ts index 06ad9b877e..6d43e0352f 100644 --- a/plugins/app-react/src/blueprints/AnalyticsImplementationBlueprint.test.ts +++ b/plugins/app-react/src/blueprints/AnalyticsImplementationBlueprint.test.ts @@ -39,6 +39,7 @@ describe('AnalyticsBlueprint', () => { "configSchema": undefined, "disabled": false, "factory": [Function], + "if": undefined, "inputs": {}, "kind": "analytics", "name": "test", From 4195e7ccb0ea2e1d3ca1ff94e8af6f3d8532a8e7 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 15 Mar 2026 15:36:52 +0100 Subject: [PATCH 40/68] repo: match colorized rspack startup output in accessibility CI Signed-off-by: Patrik Oldsberg Made-with: Cursor --- lighthouserc.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lighthouserc.js b/lighthouserc.js index b8c503c5d2..5273844c79 100644 --- a/lighthouserc.js +++ b/lighthouserc.js @@ -54,7 +54,7 @@ module.exports = { preset: 'desktop', }, startServerCommand: 'yarn start', - startServerReadyPattern: 'compiled successfully', + startServerReadyPattern: 'compiled.*successfully', startServerReadyTimeout: 600000, numberOfRuns: 1, puppeteerScript: './.lighthouseci/scripts/guest-auth.js', From f80cf50cda1704027a85f4239dc0bc039215f92c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 15 Mar 2026 15:51:21 +0100 Subject: [PATCH 41/68] core-components: name the shared progress indicator Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../src/components/Progress/Progress.test.tsx | 8 ++++++++ .../core-components/src/components/Progress/Progress.tsx | 7 ++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/core-components/src/components/Progress/Progress.test.tsx b/packages/core-components/src/components/Progress/Progress.test.tsx index 918c9ba518..f72454754e 100644 --- a/packages/core-components/src/components/Progress/Progress.test.tsx +++ b/packages/core-components/src/components/Progress/Progress.test.tsx @@ -15,6 +15,7 @@ */ import { renderInTestApp } from '@backstage/test-utils'; +import { screen } from '@testing-library/react'; import { Progress } from './Progress'; @@ -23,4 +24,11 @@ describe('', () => { const { queryByTestId } = await renderInTestApp(); expect(queryByTestId('progress')).toBeInTheDocument(); }); + + it('provides an accessible name for the progress bar', async () => { + await renderInTestApp(); + expect( + await screen.findByRole('progressbar', { name: 'Loading' }), + ).toBeInTheDocument(); + }); }); diff --git a/packages/core-components/src/components/Progress/Progress.tsx b/packages/core-components/src/components/Progress/Progress.tsx index b7176a5825..f1dea0ed5c 100644 --- a/packages/core-components/src/components/Progress/Progress.tsx +++ b/packages/core-components/src/components/Progress/Progress.tsx @@ -22,6 +22,7 @@ import { useTheme } from '@material-ui/core/styles'; import { PropsWithChildren, useEffect, useState } from 'react'; export function Progress(props: PropsWithChildren) { + const { 'aria-label': ariaLabel, ...progressProps } = props; const theme = useTheme(); const [isVisible, setIsVisible] = useState(false); @@ -34,7 +35,11 @@ export function Progress(props: PropsWithChildren) { }, [theme.transitions.duration.short]); return isVisible ? ( - + ) : ( ); From 8e092338c6ce2dc6e45830aab3c5eae8bd007928 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 15 Mar 2026 17:22:36 +0100 Subject: [PATCH 42/68] changesets: add package metadata follow-up notes Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .changeset/core-compat-api-filter-predicates-dependency.md | 5 +++++ .changeset/core-components-progress-accessibility.md | 7 +++++++ .../frontend-test-utils-filter-predicates-dependency.md | 5 +++++ 3 files changed, 17 insertions(+) create mode 100644 .changeset/core-compat-api-filter-predicates-dependency.md create mode 100644 .changeset/core-components-progress-accessibility.md create mode 100644 .changeset/frontend-test-utils-filter-predicates-dependency.md diff --git a/.changeset/core-compat-api-filter-predicates-dependency.md b/.changeset/core-compat-api-filter-predicates-dependency.md new file mode 100644 index 0000000000..5b99e9c79c --- /dev/null +++ b/.changeset/core-compat-api-filter-predicates-dependency.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-compat-api': patch +--- + +Added a missing dependency on `@backstage/filter-predicates` to `@backstage/core-compat-api`. This fixes package metadata for consumers that use compatibility helpers relying on filter predicate support. diff --git a/.changeset/core-components-progress-accessibility.md b/.changeset/core-components-progress-accessibility.md new file mode 100644 index 0000000000..17614eb247 --- /dev/null +++ b/.changeset/core-components-progress-accessibility.md @@ -0,0 +1,7 @@ +--- +'@backstage/core-components': patch +--- + +Fixed the shared `Progress` component to provide an accessible name for its loading indicator by default. This improves screen reader support and accessibility checks across pages that render the default loading bar. + +**Affected components:** Progress diff --git a/.changeset/frontend-test-utils-filter-predicates-dependency.md b/.changeset/frontend-test-utils-filter-predicates-dependency.md new file mode 100644 index 0000000000..c8664d9673 --- /dev/null +++ b/.changeset/frontend-test-utils-filter-predicates-dependency.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-test-utils': patch +--- + +Added a missing dependency on `@backstage/filter-predicates` to `@backstage/frontend-test-utils`. This fixes package metadata for consumers using the frontend test app helpers with predicate-based behavior. From bfb6f50d4ad203f28124fec56cf9977f3f00fffb Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 15 Mar 2026 17:35:09 +0100 Subject: [PATCH 43/68] changesets: shorten progress accessibility note Keep the core-components changeset aligned with the shorter single-sentence style used for simple package notes. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .changeset/core-components-progress-accessibility.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.changeset/core-components-progress-accessibility.md b/.changeset/core-components-progress-accessibility.md index 17614eb247..c4f53ccd2d 100644 --- a/.changeset/core-components-progress-accessibility.md +++ b/.changeset/core-components-progress-accessibility.md @@ -2,6 +2,4 @@ '@backstage/core-components': patch --- -Fixed the shared `Progress` component to provide an accessible name for its loading indicator by default. This improves screen reader support and accessibility checks across pages that render the default loading bar. - -**Affected components:** Progress +Fixed the shared `Progress` component to provide an accessible name for its loading indicator by default. From 268f8f8d9fdf06df0bcdcd40b4db675f2cd32ed6 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 15 Mar 2026 19:34:02 +0100 Subject: [PATCH 44/68] frontend-app-api: fix sync specialized app predicates Avoid finalizing prepared apps with an empty predicate context when sign-in is disabled, so deferred feature-flagged extensions resolve consistently in both bootstrap and finalized trees. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../src/wiring/createSpecializedApp.test.tsx | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx index cd1356d4cb..080de78177 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx @@ -1292,6 +1292,80 @@ describe('createSpecializedApp', () => { ); }); + 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 app root children until finalize', async () => { const identityApi = { getProfileInfo: async () => ({ displayName: 'Test User' }), From f919c714fce9a823c435ed76cd0c29f98c0c94ca Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 15 Mar 2026 19:38:35 +0100 Subject: [PATCH 45/68] frontend-app-api: stop resetting the tree for api discovery Collect bootstrap and deferred API factories without mutating app node instances, so conditional API roots stay deferred and visible API instances can survive finalization unchanged. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../src/wiring/createSpecializedApp.test.tsx | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx index 080de78177..1aff292256 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx @@ -1366,6 +1366,75 @@ describe('createSpecializedApp', () => { ).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 defer app root children until finalize', async () => { const identityApi = { getProfileInfo: async () => ({ displayName: 'Test User' }), From d7a44138d2d36dac120f64c1c3b661beeb84786f Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 15 Mar 2026 21:46:53 +0100 Subject: [PATCH 46/68] frontend-app-api: freeze materialized bootstrap APIs Keep deferred API overrides only for bootstrap APIs that were never materialized, while reporting and ignoring overrides of implementations that were already used during bootstrap. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- ...repare-specialized-app-frontend-app-api.md | 2 +- docs/frontend-system/architecture/10-app.md | 16 +- .../src/wiring/FrontendApiRegistry.ts | 4 + .../src/wiring/createErrorCollector.ts | 9 + .../src/wiring/createSpecializedApp.test.tsx | 208 ++++++++++++++++++ 5 files changed, 231 insertions(+), 8 deletions(-) diff --git a/.changeset/prepare-specialized-app-frontend-app-api.md b/.changeset/prepare-specialized-app-frontend-app-api.md index fd60cf00d7..e62d025189 100644 --- a/.changeset/prepare-specialized-app-frontend-app-api.md +++ b/.changeset/prepare-specialized-app-frontend-app-api.md @@ -2,4 +2,4 @@ '@backstage/frontend-app-api': patch --- -Added `prepareSpecializedApp` for two-phase app wiring so apps can render a bootstrap tree before full app finalization. The bootstrap phase now supports deferred `app/root.elements`, predicate-gated APIs, reusable `sessionState`, and warnings for bootstrap-visible predicates or bootstrap code that accessed APIs that only became available after finalization. +Added `prepareSpecializedApp` for two-phase app wiring so apps can render a bootstrap tree before full app finalization. The bootstrap phase now supports deferred `app/root.elements`, predicate-gated APIs, reusable `sessionState`, and warnings for bootstrap-visible predicates or bootstrap code that accessed APIs that only became available after finalization. Utility APIs that are materialized during bootstrap are also frozen for the lifetime of the app instance, causing deferred overrides of those APIs to be ignored and reported as app errors. diff --git a/docs/frontend-system/architecture/10-app.md b/docs/frontend-system/architecture/10-app.md index bfc667f664..fde39fc8b0 100644 --- a/docs/frontend-system/architecture/10-app.md +++ b/docs/frontend-system/architecture/10-app.md @@ -40,6 +40,14 @@ Each node in this tree is an extension with a parent node and children. The colo A common type of data that is shared between extensions is React elements and components. These can in turn be rendered by each other in their own React components, which ends up forming a parallel tree of React components that is similar in shape to that of the app extension tree. At the top of the app extension tree is a built-in root extension that among other things outputs a React element. This element also ends up being the root of the parallel React tree, and is rendered by the React element returned by `app.createRoot()`. +## Feature Discovery + +App feature discovery lets you automatically discover and install features provided by dependencies in your app. In practice, it means that you don't need to manually `import` features in code, but they are instead installed as soon as you add them as a dependency in your `package.json`. + +Because feature discovery needs to interact with the compilation process, it is only available when using the `@backstage/cli` to build your app. It is hooked into the WebPack compilation process by scanning your app package for compatible dependencies, which are then made part of the app compilation bundle. + +For information on how to configure feature discovery and other installation options, see [Installing Plugins](../building-apps/05-installing-plugins.md). + ## Preparing an App in Phases Most apps should use `createApp` from `@backstage/frontend-defaults`, which takes care of all app preparation internally. For more advanced use cases there is also a lower-level `prepareSpecializedApp` API in `@backstage/frontend-app-api`. @@ -70,13 +78,7 @@ The `getBootstrapApp()` method exposes the partial app tree that is available du When using phased app preparation, `app/root.children` acts as the main session boundary. Conditional extensions behind that boundary are evaluated during finalization. Conditional `app/root.elements` and API branches are also deferred until finalization, while other bootstrap-visible predicates are ignored and reported as warnings. -## Feature Discovery - -App feature discovery lets you automatically discover and install features provided by dependencies in your app. In practice, it means that you don't need to manually `import` features in code, but they are instead installed as soon as you add them as a dependency in your `package.json`. - -Because feature discovery needs to interact with the compilation process, it is only available when using the `@backstage/cli` to build your app. It is hooked into the WebPack compilation process by scanning your app package for compatible dependencies, which are then made part of the app compilation bundle. - -For information on how to configure feature discovery and other installation options, see [Installing Plugins](../building-apps/05-installing-plugins.md). +Utility APIs that are first materialized during bootstrap are frozen for the lifetime of that app instance. Finalization may still add new APIs and may override existing API refs that were not materialized during bootstrap, but any deferred override of an already materialized bootstrap API is ignored and reported as an app error. ## Plugin Info Resolution diff --git a/packages/frontend-app-api/src/wiring/FrontendApiRegistry.ts b/packages/frontend-app-api/src/wiring/FrontendApiRegistry.ts index 8e56fe1ce8..448fe535e0 100644 --- a/packages/frontend-app-api/src/wiring/FrontendApiRegistry.ts +++ b/packages/frontend-app-api/src/wiring/FrontendApiRegistry.ts @@ -90,6 +90,10 @@ export class FrontendApiResolver implements ApiHolder { return this.load(ref); } + isMaterialized(apiRefId: string) { + return this.apis.has(apiRefId); + } + invalidate(apiRefIds?: Iterable) { if (apiRefIds === undefined) { this.apis.clear(); diff --git a/packages/frontend-app-api/src/wiring/createErrorCollector.ts b/packages/frontend-app-api/src/wiring/createErrorCollector.ts index 1867507948..7077e0e9c4 100644 --- a/packages/frontend-app-api/src/wiring/createErrorCollector.ts +++ b/packages/frontend-app-api/src/wiring/createErrorCollector.ts @@ -88,6 +88,15 @@ export type AppErrorTypes = { EXTENSION_BOOTSTRAP_API_UNAVAILABLE: { context: { node: AppNode; apiRefId: string }; }; + EXTENSION_BOOTSTRAP_API_OVERRIDE_IGNORED: { + context: { + node: AppNode; + apiRefId: string; + bootstrapNode: AppNode; + pluginId: string; + bootstrapPluginId: string; + }; + }; // routing ROUTE_DUPLICATE: { context: { routeId: string }; diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx index 1aff292256..76196f45cd 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx @@ -1435,6 +1435,214 @@ describe('createSpecializedApp', () => { 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' }), From ab3f15f3b3b7197265a8f283cad53cbf182eb7b8 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 15 Mar 2026 23:07:19 +0100 Subject: [PATCH 47/68] frontend-app-api: share subtree instantiation logic Move detached API discovery over to the same subtree instantiation traversal that powers the app tree so predicate handling and attachment traversal stay aligned in one place. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../src/tree/instantiateAppNodeTree.ts | 151 ++++++++++++------ 1 file changed, 104 insertions(+), 47 deletions(-) diff --git a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts index 23df618350..8cef89f497 100644 --- a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts +++ b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts @@ -65,6 +65,19 @@ type Mutable = { -readonly [P in keyof T]: T[P]; }; +type InstantiateAppNodeSubtreeOptions = { + rootNode: AppNode; + apis: ApiHolder; + collector: ErrorCollector; + extensionFactoryMiddleware?: ExtensionFactoryMiddleware; + stopAtAttachment?(ctx: { node: AppNode; input: string }): boolean; + skipChild?(ctx: { node: AppNode; input: string; child: AppNode }): boolean; + onMissingApi?(ctx: { node: AppNode; apiRefId: string }): void; + predicateContext?: Record; + reuseExistingInstances?: boolean; + writeNodeInstances?: boolean; +}; + function resolveV1InputDataMap( dataMap: { [name in string]: ExtensionDataRef; @@ -517,6 +530,87 @@ export function createAppNodeInstance(options: { }; } +/** + * Starting at the provided node, instantiate a subtree without necessarily + * mutating the original app tree. + * + * @internal + */ +export function instantiateAppNodeSubtree( + options: InstantiateAppNodeSubtreeOptions, +): AppNode | undefined { + const instantiatedNodes = new WeakMap(); + + function createInstance(node: AppNode): AppNode | undefined { + if (instantiatedNodes.has(node)) { + return instantiatedNodes.get(node) ?? undefined; + } + if (options.reuseExistingInstances !== false && node.instance) { + instantiatedNodes.set(node, node); + return node; + } + if (node.spec.disabled) { + instantiatedNodes.set(node, null); + return undefined; + } + if ( + options.predicateContext !== undefined && + node.spec.if !== undefined && + !evaluateFilterPredicate(node.spec.if, options.predicateContext) + ) { + instantiatedNodes.set(node, null); + return undefined; + } + + const instantiatedAttachments = new Map(); + + for (const [input, children] of node.edges.attachments) { + if (options.stopAtAttachment?.({ node, input })) { + continue; + } + const instantiatedChildren = children.flatMap(child => { + if (options.skipChild?.({ node, input, child })) { + return []; + } + const childNode = createInstance(child); + return childNode ? [childNode] : []; + }); + if (instantiatedChildren.length > 0) { + instantiatedAttachments.set(input, instantiatedChildren); + } + } + + const instance = createAppNodeInstance({ + extensionFactoryMiddleware: options.extensionFactoryMiddleware, + node, + apis: options.apis, + attachments: instantiatedAttachments, + collector: options.collector, + onMissingApi: options.onMissingApi, + }); + if (!instance) { + instantiatedNodes.set(node, null); + return undefined; + } + + if (options.writeNodeInstances === false) { + const detachedNode: AppNode = { + spec: node.spec, + edges: node.edges, + instance, + }; + instantiatedNodes.set(node, detachedNode); + return detachedNode; + } + + (node as Mutable).instance = instance; + instantiatedNodes.set(node, node); + return node; + } + + return createInstance(options.rootNode); +} + /** * Starting at the provided node, instantiate all reachable nodes in the tree that have not been disabled. * @internal @@ -555,53 +649,16 @@ export function instantiateAppNodeTree( predicateContext: optionsOrPredicateContext, }; - function createInstance(node: AppNode): AppNodeInstance | undefined { - if (node.instance) { - return node.instance; - } - if (node.spec.disabled) { - return undefined; - } - if ( - options?.predicateContext !== undefined && - node.spec.if !== undefined && - !evaluateFilterPredicate(node.spec.if, options.predicateContext) - ) { - return undefined; - } - - const instantiatedAttachments = new Map(); - - for (const [input, children] of node.edges.attachments) { - if (options?.stopAtAttachment?.({ node, input })) { - continue; - } - const instantiatedChildren = children.flatMap(child => { - if (options?.skipChild?.({ node, input, child })) { - return []; - } - const childInstance = createInstance(child); - if (!childInstance) { - return []; - } - return [child]; - }); - if (instantiatedChildren.length > 0) { - instantiatedAttachments.set(input, instantiatedChildren); - } - } - - (node as Mutable).instance = createAppNodeInstance({ - extensionFactoryMiddleware, - node, + return ( + instantiateAppNodeSubtree({ + rootNode, apis, - attachments: instantiatedAttachments, collector, - onMissingApi: options?.onMissingApi, - }); - - return node.instance; - } - - return createInstance(rootNode) !== undefined; + extensionFactoryMiddleware, + stopAtAttachment: options.stopAtAttachment, + skipChild: options.skipChild, + onMissingApi: options.onMissingApi, + predicateContext: options.predicateContext, + }) !== undefined + ); } From 756e84eb9aa7781c35337430637d16128e0e13ce Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 15 Mar 2026 23:20:36 +0100 Subject: [PATCH 48/68] frontend-app-api: capture sign-in through identity proxy Let prepared apps observe sign-in completion through the bootstrap identity proxy instead of overriding the sign-in page component, and surface finalization failures back through the default app root. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../src/wiring/createSpecializedApp.test.tsx | 351 ------------------ .../frontend-defaults/src/createApp.test.tsx | 17 +- packages/frontend-defaults/src/createApp.tsx | 12 +- 3 files changed, 24 insertions(+), 356 deletions(-) diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx index 76196f45cd..cd1356d4cb 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx @@ -1292,357 +1292,6 @@ describe('createSpecializedApp', () => { ); }); - 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' }), diff --git a/packages/frontend-defaults/src/createApp.test.tsx b/packages/frontend-defaults/src/createApp.test.tsx index be09c8b8df..4cc9e367f8 100644 --- a/packages/frontend-defaults/src/createApp.test.tsx +++ b/packages/frontend-defaults/src/createApp.test.tsx @@ -32,7 +32,7 @@ import { FrontendPluginInfo, } from '@backstage/frontend-plugin-api'; import { ThemeBlueprint } from '@backstage/plugin-app-react'; -import { screen, waitFor } from '@testing-library/react'; +import { act, screen, waitFor } from '@testing-library/react'; import { createApp } from './createApp'; import { mockApis, renderWithEffects } from '@backstage/test-utils'; import { @@ -229,6 +229,7 @@ describe('createApp', () => { getRegisteredFlags: () => [], save: jest.fn(), } as unknown as typeof featureFlagsApiRef.T; + let onSignInSuccess: ((identity: IdentityApi) => void) | undefined; const app = createApp({ advanced: { @@ -252,9 +253,7 @@ describe('createApp', () => { function SignInPage(props: { onSignInSuccess(identity: IdentityApi): void; }) { - useEffect(() => { - props.onSignInSuccess(identityApi); - }, [props]); + onSignInSuccess = props.onSignInSuccess; return
Custom Sign In
; } @@ -281,6 +280,16 @@ describe('createApp', () => { }); await renderWithEffects(app.createRoot()); + await expect( + screen.findByText('Custom Sign In'), + ).resolves.toBeInTheDocument(); + if (!onSignInSuccess) { + throw new Error('Expected sign-in success callback to be captured'); + } + const triggerSignInSuccess = onSignInSuccess; + act(() => { + triggerSignInSuccess(identityApi); + }); await expect( screen.findByText(/Error in app/), diff --git a/packages/frontend-defaults/src/createApp.tsx b/packages/frontend-defaults/src/createApp.tsx index 728ec29e42..2dd75031c2 100644 --- a/packages/frontend-defaults/src/createApp.tsx +++ b/packages/frontend-defaults/src/createApp.tsx @@ -152,12 +152,22 @@ function PreparedAppRoot(props: { const [finalizedApp, setFinalizedApp] = useState< FinalizedSpecializedApp | undefined >(); + const [bootstrapError, setBootstrapError] = useState(); useEffect( - () => props.preparedApp.onFinalized(setFinalizedApp), + () => props.preparedApp.onFinalized(setFinalizedApp, setBootstrapError), [props.preparedApp], ); + if (bootstrapError) { + return ( + <> +
{bootstrapError.message}
+

Error in app

+ + ); + } + if (!finalizedApp) { return bootstrapApp.element; } From 01f5660fe75058e3cc625f4f12dcce724c9bf3c5 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Mar 2026 00:18:08 +0100 Subject: [PATCH 49/68] frontend-app-api: split prepare and create app wiring Move the phased specialized app lifecycle into prepareSpecializedApp.tsx and leave createSpecializedApp.tsx as the deprecated wrapper so the public entry points and their related types live alongside their own implementations. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- packages/frontend-app-api/src/wiring/index.ts | 2 + .../src/wiring/prepareSpecializedApp.tsx | 1738 +++++++++++++++++ 2 files changed, 1740 insertions(+) create mode 100644 packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx diff --git a/packages/frontend-app-api/src/wiring/index.ts b/packages/frontend-app-api/src/wiring/index.ts index e3c52227b9..d23e60ff0c 100644 --- a/packages/frontend-app-api/src/wiring/index.ts +++ b/packages/frontend-app-api/src/wiring/index.ts @@ -21,6 +21,8 @@ export { type PrepareSpecializedAppOptions, type PreparedSpecializedApp, type SpecializedAppSessionState, +} from './prepareSpecializedApp'; +export { createSpecializedApp, type CreateSpecializedAppOptions, } from './createSpecializedApp'; diff --git a/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx b/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx new file mode 100644 index 0000000000..c742e294cc --- /dev/null +++ b/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx @@ -0,0 +1,1738 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigReader } from '@backstage/config'; +import { + ApiBlueprint, + AnyApiFactory, + ApiHolder, + AppTree, + AppTreeApi, + appTreeApiRef, + ConfigApi, + configApiRef, + coreExtensionData, + RouteRef, + ExternalRouteRef, + SubRouteRef, + AnyRouteRefParams, + RouteFunc, + RouteResolutionApi, + createApiFactory, + createApiRef, + routeResolutionApiRef, + AppNode, + AppNodeInstance, + ExtensionDataRef, + ExtensionFactoryMiddleware, + FrontendFeature, + featureFlagsApiRef, + IdentityApi, + identityApiRef, + createExtensionDataRef, +} from '@backstage/frontend-plugin-api'; +import type { + EvaluatePermissionRequest, + EvaluatePermissionResponse, +} from '@backstage/plugin-permission-common'; +import { FilterPredicate } from '@backstage/filter-predicates'; +import { + createExtensionDataContainer, + OpaqueFrontendPlugin, +} from '@internal/frontend'; +import { OpaqueType } from '@internal/opaque'; +import { ComponentType, ReactNode, useLayoutEffect, useState } from 'react'; + +// 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 { + 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 { + instantiateAppNodeSubtree, + instantiateAppNodeTree, +} from '../tree/instantiateAppNodeTree'; +// 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'; +import { + FrontendApiRegistry, + FrontendApiResolver, +} from './FrontendApiRegistry'; + +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(); +} + +type SignInPageProps = { + onSignInSuccess(identityApi: IdentityApi): void; + children?: ReactNode; +}; + +/** + * Result of bootstrapping a prepared specialized app. + * + * @public + */ +export type BootstrapSpecializedApp = { + element: JSX.Element; + tree: AppTree; +}; + +/** + * Result of finalizing a prepared specialized app. + * + * @public + */ +export type FinalizedSpecializedApp = { + element: JSX.Element; + sessionState: SpecializedAppSessionState; + tree: AppTree; + errors?: AppError[]; +}; + +type SignInRuntime = { + error?: unknown; + readyIdentityApi?: IdentityApi; + requiresSignIn: boolean; +}; + +type FinalizationState = { + started: boolean; + promise: Promise; + resolve(app: FinalizedSpecializedApp): void; + reject(error: unknown): void; +}; + +type ExtensionPredicateContext = { + featureFlags: string[]; + permissions: string[]; +}; + +type InternalSpecializedAppSessionState = { + apis: ApiHolder; + identityApi?: IdentityApi; + predicateContext: ExtensionPredicateContext; +}; + +const EMPTY_PREDICATE_CONTEXT: ExtensionPredicateContext = { + featureFlags: [], + permissions: [], +}; + +/** + * Opaque reusable session state for specialized apps. + * + * @public + */ +export type SpecializedAppSessionState = { + $$type: '@backstage/SpecializedAppSessionState'; +}; + +const OpaqueSpecializedAppSessionState = OpaqueType.create<{ + public: SpecializedAppSessionState; + versions: InternalSpecializedAppSessionState & { + version: 'v1'; + }; +}>({ + type: '@backstage/SpecializedAppSessionState', + versions: ['v1'], +}); + +const signInPageComponentDataRef = createExtensionDataRef< + ComponentType +>().with({ id: 'core.sign-in-page.component' }); + +// 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(); + const routeInfo = this.#routeInfo; + if (!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`, + ); + } + + let path = routePath; + if (path.startsWith(this.appBasePath)) { + path = path.slice(this.appBasePath.length); + } + + const matchedRoutes = matchRoutes(routeInfo.routeObjects, path); + + const matchedAppNodes = + matchedRoutes?.flatMap(routeObj => { + const appNode = routeObj.route.appNode; + return appNode ? [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; + } +} + +class PreparedAppIdentityProxy extends AppIdentityProxy { + #onTargetSet?: + | (( + identityApi: Parameters[0], + ) => Promise | void) + | undefined; + #onTargetError?: ((error: unknown) => void) | undefined; + + setTargetHandlers(options: { + onTargetSet?( + identityApi: Parameters[0], + ): Promise | void; + onTargetError?(error: unknown): void; + }) { + this.#onTargetSet = options.onTargetSet; + this.#onTargetError = options.onTargetError; + } + + clearTargetHandlers() { + this.#onTargetSet = undefined; + this.#onTargetError = undefined; + } + + override setTarget( + identityApi: Parameters[0], + targetOptions: Parameters[1], + ) { + super.setTarget(identityApi, targetOptions); + + const onTargetSet = this.#onTargetSet; + if (!onTargetSet) { + return; + } + + const onTargetError = this.#onTargetError; + this.clearTargetHandlers(); + + try { + void Promise.resolve(onTargetSet(identityApi)).catch(error => { + onTargetError?.(error); + }); + } catch (error) { + onTargetError?.(error); + } + } +} + +/** + * Options for {@link prepareSpecializedApp}. + * + * @public + */ +export type PrepareSpecializedAppOptions = { + /** + * The list of features to load. + */ + features?: FrontendFeature[]; + + /** + * The config API implementation to use. For most normal apps, this should be + * specified. + * + * If none is given, a new _empty_ config will be used during startup. In + * later stages of the app lifecycle, the config API in the API holder will be + * used. + */ + config?: ConfigApi; + + /** + * Allows for the binding of plugins' external route refs within the app. + */ + bindRoutes?(context: { bind: CreateAppRouteBinder }): void; + + /** + * Advanced, more rarely used options. + */ + advanced?: { + /** + * A reusable specialized app session state to use. + * + * This can be obtained from either the app passed to + * {@link PreparedSpecializedApp.onFinalized} or from + * {@link PreparedSpecializedApp.finalize}, and reused in a future app + * instance to skip sign-in and session preparation. + */ + sessionState?: SpecializedAppSessionState; + + /** + * Applies one or more middleware on every extension, as they are added to + * the application. + * + * This is an advanced use case for modifying extension data on the fly as + * it gets emitted by extensions being instantiated. + */ + extensionFactoryMiddleware?: + | ExtensionFactoryMiddleware + | ExtensionFactoryMiddleware[]; + + /** + * Allows for customizing how plugin info is retrieved. + */ + pluginInfoResolver?: FrontendPluginInfoResolver; + }; +}; + +/** + * Result of {@link prepareSpecializedApp}. + * + * @public + */ +export type PreparedSpecializedApp = { + getBootstrapApp(): BootstrapSpecializedApp; + onFinalized( + callback: (app: FinalizedSpecializedApp) => void, + onError?: (error: Error) => void, + ): () => void; + finalize(options?: { + sessionState?: SpecializedAppSessionState; + }): FinalizedSpecializedApp; +}; + +// Minimal local permission API interface to avoid a dependency on @backstage/plugin-permission-react +type MinimalPermissionApi = { + authorize( + request: EvaluatePermissionRequest, + ): Promise; +}; + +const localPermissionApiRef = createApiRef({ + id: 'plugin.permission.api', +}); + +// Internal options type, not exported in the public API +export interface CreateSpecializedAppInternalOptions + extends PrepareSpecializedAppOptions { + __internal?: { + apiFactoryOverrides?: AnyApiFactory[]; + }; +} + +export function createSessionStateFromApis( + apis: ApiHolder, +): SpecializedAppSessionState { + return OpaqueSpecializedAppSessionState.createInstance('v1', { + apis, + identityApi: apis.get(identityApiRef), + predicateContext: EMPTY_PREDICATE_CONTEXT, + }); +} + +/** + * Prepares an app without instantiating the full extension tree. + * + * @remarks + * + * This is useful for split sign-in flows where the sign-in page should be + * rendered first, and the full app finalized once an identity has been + * captured. + * + * @public + */ +export function prepareSpecializedApp( + options?: PrepareSpecializedAppOptions, +): PreparedSpecializedApp { + const internalOptions = options as CreateSpecializedAppInternalOptions; + const config = options?.config ?? new ConfigReader({}, 'empty-config'); + const features = deduplicateFeatures(options?.features ?? []).map( + createPluginInfoAttacher(config, options?.advanced?.pluginInfoResolver), + ); + + const collector = createErrorCollector(); + + const tree = resolveAppTree( + 'root', + resolveAppNodeSpecs({ + features, + builtinExtensions: [ + resolveExtensionDefinition(Root, { namespace: 'root' }), + ], + parameters: readAppExtensionsConfig(config), + forbidden: new Set(['root']), + collector, + }), + collector, + ); + + const appBasePath = getBasePath(config); + const routeRefsById = collectRouteIds(features, collector); + const routeBindings = resolveRouteBindings( + options?.bindRoutes, + config, + routeRefsById, + collector, + ); + + const mergedExtensionFactoryMiddleware = mergeExtensionFactoryMiddleware( + options?.advanced?.extensionFactoryMiddleware, + ); + const providedSessionState = options?.advanced?.sessionState; + const providedSessionData = providedSessionState + ? OpaqueSpecializedAppSessionState.toInternal(providedSessionState) + : undefined; + const providedApis = providedSessionData?.apis; + const bootstrapClassification = classifyBootstrapTree({ + tree, + collector, + }); + const predicateReferences = collectPredicateReferences(tree); + const appApiRegistry = new FrontendApiRegistry(); + const internalStaticFactories = + internalOptions?.__internal?.apiFactoryOverrides ?? []; + const phaseStaticFactories = [...internalStaticFactories]; + const bootstrapApiFactoryEntries = new Map(); + const bootstrapMissingApiAccesses = new Map< + string, + { node: AppNode; apiRefId: string } + >(); + + if (providedApis) { + registerFeatureFlagDeclarationsInHolder(providedApis, features); + } else { + collectApiFactoryEntries({ + apiNodes: (tree.root.edges.attachments.get('apis') ?? []).filter( + apiNode => !bootstrapClassification.deferredApiRoots.has(apiNode), + ), + collector, + entries: bootstrapApiFactoryEntries, + }); + const apiFactories = Array.from( + bootstrapApiFactoryEntries.values(), + entry => wrapFeatureFlagApiFactory(entry.factory, features), + ); + appApiRegistry.registerAll(apiFactories); + } + const phase = createPhaseApis({ + tree, + config, + appApiRegistry, + fallbackApis: providedApis, + includeConfigApi: !providedApis, + appBasePath, + routeBindings, + staticFactories: phaseStaticFactories, + }); + const state: { + signInRuntime?: SignInRuntime; + cachedSessionState?: SpecializedAppSessionState; + sessionStatePromise?: Promise; + finalized?: FinalizedSpecializedApp; + bootstrapApp?: BootstrapSpecializedApp; + bootstrapError?: Error; + finalizationState?: FinalizationState; + bootstrapErrorReporter?: (error: Error) => void; + pendingBootstrapError?: Error; + } = { + cachedSessionState: providedSessionState, + }; + + function updateIdentityApiTarget(identityApi?: IdentityApi) { + if (!identityApi) { + return; + } + + setIdentityApiTarget({ + identityApiProxy: phase.identityApiProxy, + identityApi, + signOutTargetUrl: appBasePath || '/', + }); + } + + function getActiveFeatureFlags() { + const featureFlagsApi = phase.apis.get(featureFlagsApiRef); + if (!featureFlagsApi) { + return []; + } + + return predicateReferences.featureFlags.filter(name => + featureFlagsApi.isActive(name), + ); + } + + function getImmediatePredicateContext(): + | ExtensionPredicateContext + | undefined { + if (predicateReferences.permissions.length > 0) { + const permissionApi = phase.apis.get(localPermissionApiRef); + if (permissionApi) { + return undefined; + } + } + + return { + featureFlags: getActiveFeatureFlags(), + permissions: [], + }; + } + + async function createPredicateContext() { + const immediatePredicateContext = getImmediatePredicateContext(); + if (immediatePredicateContext) { + return immediatePredicateContext; + } + + let allowedPermissions: string[] = []; + const permissionApi = phase.apis.get(localPermissionApiRef); + if (permissionApi) { + const permNames = predicateReferences.permissions; + const responses = await Promise.all( + permNames.map(name => + permissionApi.authorize({ + permission: { name, type: 'basic', attributes: {} }, + }), + ), + ); + allowedPermissions = permNames.filter( + (_, i) => responses[i].result === 'ALLOW', + ); + } + + return { + featureFlags: getActiveFeatureFlags(), + permissions: allowedPermissions, + }; + } + + function createSessionState(predicateContext: ExtensionPredicateContext) { + const identityApi = + state.signInRuntime?.readyIdentityApi ?? providedSessionData?.identityApi; + updateIdentityApiTarget(identityApi); + const sessionState = OpaqueSpecializedAppSessionState.createInstance('v1', { + apis: phase.apis, + identityApi, + predicateContext, + }); + state.cachedSessionState = sessionState; + return sessionState; + } + + function getImmediateSessionState() { + if (state.cachedSessionState) { + return state.cachedSessionState; + } + if (state.signInRuntime?.requiresSignIn) { + return undefined; + } + + const predicateContext = getImmediatePredicateContext(); + if (!predicateContext) { + return undefined; + } + + return createSessionState(predicateContext); + } + + function getSessionState() { + const immediateSessionState = getImmediateSessionState(); + if (immediateSessionState) { + return Promise.resolve(immediateSessionState); + } + if (state.sessionStatePromise) { + return state.sessionStatePromise; + } + if (state.signInRuntime?.error) { + return Promise.reject(state.signInRuntime.error); + } + if ( + state.signInRuntime?.requiresSignIn && + !state.signInRuntime.readyIdentityApi + ) { + return Promise.reject( + new Error( + 'prepareSpecializedApp requires waiting for the bootstrap app to be ready before calling finalize()', + ), + ); + } + + state.sessionStatePromise = createPredicateContext() + .then(predicateContext => { + if (state.cachedSessionState) { + return state.cachedSessionState; + } + return createSessionState(predicateContext); + }) + .catch(error => { + state.sessionStatePromise = undefined; + throw error; + }); + + return state.sessionStatePromise; + } + + function startSignInFinalize( + runtime: SignInRuntime, + identityApi: IdentityApi, + ): Promise { + runtime.readyIdentityApi = identityApi; + return getSessionState().catch(error => { + runtime.error = error; + throw error; + }); + } + + function finalizeFromSessionState( + finalizedSessionState: SpecializedAppSessionState, + ): FinalizedSpecializedApp { + if (state.finalized) { + return state.finalized; + } + + state.cachedSessionState = finalizedSessionState; + const sessionStateData = OpaqueSpecializedAppSessionState.toInternal( + finalizedSessionState, + ); + updateIdentityApiTarget(sessionStateData.identityApi); + if (!providedApis) { + syncFinalApiFactories({ + deferredApiNodes: bootstrapClassification.deferredApiRoots, + appApiRegistry, + apiResolver: phase.apis, + collector, + features, + bootstrapApiFactoryEntries, + bootstrapMissingApiAccesses, + predicateContext: sessionStateData.predicateContext, + }); + } + + prepareFinalizedTree({ + tree, + }); + clearFinalizationBoundaryInstances(tree); + instantiateAndInitializePhaseTree({ + tree, + apis: phase.apis, + collector, + extensionFactoryMiddleware: mergedExtensionFactoryMiddleware, + routeResolutionApi: phase.routeResolutionApi, + appTreeApi: phase.appTreeApi, + routeRefsById, + predicateContext: sessionStateData.predicateContext, + }); + + const element = tree.root.instance?.getData(coreExtensionData.reactElement); + if (!element) { + throw new Error('Expected finalized app tree to expose a root element'); + } + + const finalizedApp: FinalizedSpecializedApp = { + element, + sessionState: finalizedSessionState, + tree, + errors: collector.collectErrors(), + }; + state.finalized = finalizedApp; + return finalizedApp; + } + + function reportBootstrapFailure(error: unknown) { + const bootstrapFailure = asError(error); + state.bootstrapError = bootstrapFailure; + if (state.bootstrapErrorReporter) { + state.bootstrapErrorReporter(bootstrapFailure); + return; + } + + state.pendingBootstrapError = bootstrapFailure; + } + + function getFinalizationState(): FinalizationState { + if (state.finalizationState) { + return state.finalizationState; + } + + let resolve: ((app: FinalizedSpecializedApp) => void) | undefined; + let reject: ((error: unknown) => void) | undefined; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + if (!resolve || !reject) { + throw new Error('Failed to create finalization state'); + } + + state.finalizationState = { + started: false, + promise, + resolve, + reject, + }; + return state.finalizationState; + } + + function beginFinalization( + loader: Promise, + ): Promise { + if (state.finalized) { + return Promise.resolve(state.finalized); + } + const finalization = getFinalizationState(); + if (finalization.started) { + return finalization.promise; + } + finalization.started = true; + + void loader + .then(sessionState => { + const finalizedApp = finalizeFromSessionState(sessionState); + finalization.resolve(finalizedApp); + }) + .catch(error => { + state.finalizationState = undefined; + + if (state.signInRuntime?.requiresSignIn) { + finalization.reject(error); + return; + } + + reportBootstrapFailure(error); + finalization.reject(state.bootstrapError); + }); + + return finalization.promise; + } + + function getBootstrapApp() { + if (state.bootstrapApp) { + return state.bootstrapApp; + } + + const runtime: SignInRuntime = { + requiresSignIn: false, + }; + if (!providedSessionState) { + phase.identityApiProxy.setTargetHandlers({ + onTargetSet(identityApi) { + return beginFinalization( + startSignInFinalize(runtime, identityApi), + ).then(() => {}); + }, + onTargetError(error) { + reportBootstrapFailure(error); + }, + }); + } + + const result = createBootstrapApp({ + tree, + apis: phase.apis, + collector, + routeRefsById, + routeResolutionApi: phase.routeResolutionApi, + appTreeApi: phase.appTreeApi, + extensionFactoryMiddleware: mergedExtensionFactoryMiddleware, + disableSignIn: Boolean(providedSessionState), + registerBootstrapErrorReporter(reporter) { + state.bootstrapErrorReporter = reporter; + if (state.pendingBootstrapError) { + reporter(state.pendingBootstrapError); + state.pendingBootstrapError = undefined; + } + + return () => { + if (state.bootstrapErrorReporter === reporter) { + state.bootstrapErrorReporter = undefined; + } + }; + }, + skipBootstrapChild({ child }) { + return bootstrapClassification.deferredRoots.has(child); + }, + onMissingApi({ node, apiRefId }) { + bootstrapMissingApiAccesses.set(`${node.spec.id}:${apiRefId}`, { + node, + apiRefId, + }); + }, + }); + if (!result.requiresSignIn) { + phase.identityApiProxy.clearTargetHandlers(); + } + + runtime.requiresSignIn = result.requiresSignIn; + state.signInRuntime = runtime; + state.bootstrapApp = result.bootstrapApp; + + return state.bootstrapApp; + } + + return { + getBootstrapApp, + onFinalized(callback, onError) { + getBootstrapApp(); + + let subscribed = true; + + if (state.bootstrapError) { + const bootstrapError = state.bootstrapError; + Promise.resolve().then(() => { + if (subscribed) { + onError?.(bootstrapError); + } + }); + return () => { + subscribed = false; + }; + } + + if (state.finalized) { + const finalizedApp = state.finalized; + Promise.resolve().then(() => { + if (subscribed) { + callback(finalizedApp); + } + }); + return () => { + subscribed = false; + }; + } + + const finalizedAppPromise = state.signInRuntime?.requiresSignIn + ? getFinalizationState().promise + : beginFinalization(getSessionState()); + void finalizedAppPromise + .then(finalizedApp => { + if (subscribed) { + callback(finalizedApp); + } + }) + .catch(error => { + if (subscribed) { + onError?.(asError(error)); + } + }); + + return () => { + subscribed = false; + }; + }, + finalize(finalizeOptions?: { sessionState?: SpecializedAppSessionState }) { + if (state.finalized) { + return state.finalized; + } + + if (state.bootstrapError) { + throw state.bootstrapError; + } + if (state.signInRuntime?.error && !state.signInRuntime.requiresSignIn) { + throw state.signInRuntime.error; + } + + if (!finalizeOptions?.sessionState && !state.cachedSessionState) { + getBootstrapApp(); + } + + const finalizedSessionState = + finalizeOptions?.sessionState ?? + state.cachedSessionState ?? + (state.signInRuntime?.requiresSignIn + ? undefined + : getImmediateSessionState()); + if (!finalizedSessionState) { + if (state.signInRuntime?.requiresSignIn) { + throw new Error( + 'prepareSpecializedApp requires waiting for the bootstrap app to be ready before calling finalize()', + ); + } + throw new Error( + 'prepareSpecializedApp requires waiting for asynchronous finalization before calling finalize()', + ); + } + + state.finalized = finalizeFromSessionState(finalizedSessionState); + state.finalizationState?.resolve(state.finalized); + return state.finalized; + }, + }; +} + +function registerFeatureFlagDeclarations( + featureFlagApi: typeof featureFlagsApiRef.T, + features: FrontendFeature[], +) { + for (const feature of features) { + if (OpaqueFrontendPlugin.isType(feature)) { + OpaqueFrontendPlugin.toInternal(feature).featureFlags.forEach(flag => + featureFlagApi.registerFlag({ + name: flag.name, + description: flag.description, + pluginId: feature.id, + }), + ); + } + if (isInternalFrontendModule(feature)) { + toInternalFrontendModule(feature).featureFlags.forEach(flag => + featureFlagApi.registerFlag({ + name: flag.name, + description: flag.description, + pluginId: feature.pluginId, + }), + ); + } + } +} + +function registerFeatureFlagDeclarationsInHolder( + apis: ApiHolder, + features: FrontendFeature[], +) { + const featureFlagApi = apis.get(featureFlagsApiRef); + if (featureFlagApi) { + registerFeatureFlagDeclarations(featureFlagApi, features); + } +} + +function wrapFeatureFlagApiFactory( + factory: AnyApiFactory, + features: FrontendFeature[], +) { + if (factory.api.id !== featureFlagsApiRef.id) { + return factory; + } + + return { + ...factory, + factory(deps) { + const featureFlagApi = factory.factory( + deps, + ) as typeof featureFlagsApiRef.T; + registerFeatureFlagDeclarations(featureFlagApi, features); + return featureFlagApi; + }, + } as AnyApiFactory; +} + +type ApiFactoryEntry = { + node: AppNode; + pluginId: string; + factory: AnyApiFactory; +}; + +type BootstrapClassification = { + deferredApiRoots: Set; + deferredElementRoots: Set; + deferredRoots: Set; +}; + +function createBootstrapApp(options: { + tree: AppTree; + apis: ApiHolder; + collector: ErrorCollector; + routeRefsById: ReturnType; + routeResolutionApi: RouteResolutionApiProxy; + appTreeApi: AppTreeApiProxy; + extensionFactoryMiddleware?: ExtensionFactoryMiddleware; + disableSignIn?: boolean; + registerBootstrapErrorReporter(reporter: (error: Error) => void): () => void; + skipBootstrapChild?(ctx: { + node: AppNode; + input: string; + child: AppNode; + }): boolean; + onMissingApi?(ctx: { node: AppNode; apiRefId: string }): void; +}): { + bootstrapApp: BootstrapSpecializedApp; + requiresSignIn: boolean; +} { + const signInPageNode = getAppRootNode(options.tree)?.edges.attachments.get( + 'signInPage', + )?.[0]; + + instantiateAndInitializePhaseTree({ + tree: options.tree, + apis: options.apis, + collector: options.collector, + extensionFactoryMiddleware: options.extensionFactoryMiddleware, + routeResolutionApi: options.routeResolutionApi, + appTreeApi: options.appTreeApi, + routeRefsById: options.routeRefsById, + stopAtSessionBoundary: true, + skipChild: options.skipBootstrapChild, + onMissingApi: options.onMissingApi, + }); + prepareBootstrapErrorBoundary({ + tree: options.tree, + registerBootstrapErrorReporter: options.registerBootstrapErrorReporter, + }); + + const element = options.tree.root.instance?.getData( + coreExtensionData.reactElement, + ); + if (!element) { + throw new Error('Expected bootstrap tree to expose a root element'); + } + + return { + bootstrapApp: { + element, + tree: options.tree, + }, + requiresSignIn: + !options.disableSignIn && + Boolean(signInPageNode?.instance?.getData(signInPageComponentDataRef)), + }; +} + +function createPhaseApis(options: { + tree: AppTree; + config: ConfigApi; + appApiRegistry: FrontendApiRegistry; + fallbackApis?: ApiHolder; + includeConfigApi: boolean; + appBasePath: string; + routeBindings: Map; + staticFactories: AnyApiFactory[]; +}) { + const appTreeApi = new AppTreeApiProxy(options.tree, options.appBasePath); + const routeResolutionApi = new RouteResolutionApiProxy( + options.routeBindings, + options.appBasePath, + ); + const identityProxy = new PreparedAppIdentityProxy(); + const phaseApiRegistry = new FrontendApiRegistry(); + phaseApiRegistry.registerAll([ + createApiFactory(appTreeApiRef, appTreeApi), + ...(options.includeConfigApi + ? [createApiFactory(configApiRef, options.config)] + : []), + createApiFactory(routeResolutionApiRef, routeResolutionApi), + createApiFactory(identityApiRef, identityProxy), + ...options.staticFactories, + ]); + + const apis = new FrontendApiResolver({ + primaryRegistry: phaseApiRegistry, + secondaryRegistry: options.appApiRegistry, + fallbackApis: options.fallbackApis, + }); + + return { + apis, + routeResolutionApi, + appTreeApi, + identityApiProxy: identityProxy, + }; +} + +function instantiateAndInitializePhaseTree(options: { + tree: AppTree; + apis: ApiHolder; + collector: ErrorCollector; + extensionFactoryMiddleware?: ExtensionFactoryMiddleware; + routeResolutionApi: RouteResolutionApiProxy; + appTreeApi: AppTreeApiProxy; + routeRefsById: ReturnType; + stopAtSessionBoundary?: boolean; + skipChild?(ctx: { node: AppNode; input: string; child: AppNode }): boolean; + onMissingApi?(ctx: { node: AppNode; apiRefId: string }): void; + predicateContext?: ExtensionPredicateContext; +}) { + instantiateAppNodeTree( + options.tree.root, + options.apis, + options.collector, + options.extensionFactoryMiddleware, + { + ...(options.stopAtSessionBoundary + ? { + stopAtAttachment: ({ + node, + input, + }: { + node: AppNode; + input: string; + }) => isSessionBoundaryAttachment(node, input), + } + : {}), + skipChild: options.skipChild, + onMissingApi: options.onMissingApi, + predicateContext: options.predicateContext, + }, + ); + + const routeInfo = extractRouteInfoFromAppNode( + options.tree.root, + createRouteAliasResolver(options.routeRefsById), + ); + + options.routeResolutionApi.initialize( + routeInfo, + options.routeRefsById.routes, + ); + options.appTreeApi.initialize(routeInfo); +} + +function prepareFinalizedTree(options: { tree: AppTree }) { + for (const appRootNode of getFinalizationBoundaryNodes(options.tree)) { + const attachments = appRootNode.edges.attachments as Map; + attachments.delete('signInPage'); + } +} + +function prepareBootstrapErrorBoundary(options: { + tree: AppTree; + registerBootstrapErrorReporter(reporter: (error: Error) => void): () => void; +}) { + const rootNode = options.tree.root; + const rootInstance = rootNode.instance; + if (!rootInstance) { + return; + } + + const rootElement = rootInstance.getData(coreExtensionData.reactElement); + if (!rootElement) { + return; + } + + function PreparedBootstrapRoot() { + const [bootstrapError, setBootstrapError] = useState(); + + useLayoutEffect(() => { + return options.registerBootstrapErrorReporter(setBootstrapError); + }, []); + + if (bootstrapError) { + throw bootstrapError; + } + + return rootElement; + } + + setNodeInstance( + rootNode, + createReactElementOverrideInstance(rootInstance, ), + ); +} + +function clearFinalizationBoundaryInstances(tree: AppTree) { + clearNodeInstance(tree.root); + + const visited = new Set(); + function visit(node: AppNode) { + if (visited.has(node)) { + return; + } + visited.add(node); + clearNodeInstance(node); + + for (const [input, children] of node.edges.attachments) { + if (node.spec.id === 'app/root' && input === 'elements') { + continue; + } + + for (const child of children) { + visit(child); + } + } + } + + for (const appRootNode of getFinalizationBoundaryNodes(tree)) { + visit(appRootNode); + } +} + +function getAppRootNode(tree: AppTree) { + return tree.nodes.get('app/root'); +} + +function getFinalizationBoundaryNodes(tree: AppTree): AppNode[] { + const nodes = new Set(); + const appRootNode = getAppRootNode(tree); + if (appRootNode) { + nodes.add(appRootNode); + } + const attachedAppRootNode = tree.root.edges.attachments.get('app')?.[0]; + if (attachedAppRootNode) { + nodes.add(attachedAppRootNode); + } + return Array.from(nodes); +} + +function isSessionBoundaryAttachment(node: AppNode, input: string) { + return node.spec.id === 'app/root' && input === 'children'; +} + +function createReactElementOverrideInstance( + instance: AppNodeInstance, + value: ReactNode, +): AppNodeInstance { + return { + getDataRefs() { + const refs = Array.from(instance.getDataRefs()); + if (!refs.some(ref => ref.id === coreExtensionData.reactElement.id)) { + refs.push(coreExtensionData.reactElement); + } + return refs[Symbol.iterator](); + }, + getData(dataRef: ExtensionDataRef) { + if (dataRef.id === coreExtensionData.reactElement.id) { + return value as TValue; + } + return instance.getData(dataRef); + }, + }; +} + +function setNodeInstance(node: AppNode, instance?: AppNodeInstance) { + (node as AppNode & { instance?: AppNodeInstance }).instance = instance; +} + +function clearNodeInstance(node: AppNode) { + setNodeInstance(node, undefined); +} + +function asError(error: unknown): Error { + if (error instanceof Error) { + return error; + } + + return new Error(String(error)); +} + +function setIdentityApiTarget(options: { + identityApiProxy: AppIdentityProxy; + identityApi: IdentityApi; + signOutTargetUrl: string; +}) { + options.identityApiProxy.setTarget(options.identityApi, { + signOutTargetUrl: options.signOutTargetUrl, + }); +} + +const EMPTY_API_HOLDER: ApiHolder = { + get() { + return undefined; + }, +}; + +function collectApiFactoryEntries(options: { + apiNodes: Iterable; + collector: ErrorCollector; + predicateContext?: ExtensionPredicateContext; + entries?: Map; +}): Map { + const factoriesById = options.entries ?? new Map(); + for (const apiNode of options.apiNodes) { + const detachedApiNode = instantiateAppNodeSubtree({ + rootNode: apiNode, + apis: EMPTY_API_HOLDER, + collector: options.collector, + predicateContext: options.predicateContext, + writeNodeInstances: false, + reuseExistingInstances: false, + }); + if (!detachedApiNode) { + continue; + } + const apiFactory = detachedApiNode.instance?.getData( + ApiBlueprint.dataRefs.factory, + ); + if (apiFactory) { + const apiRefId = apiFactory.api.id; + const ownerId = getApiOwnerId(apiRefId); + const pluginId = apiNode.spec.plugin.pluginId ?? 'app'; + const existingFactory = factoriesById.get(apiRefId); + + // This allows modules to override factories provided by the plugin, but + // it rejects API overrides from other plugins. In the event of a + // conflict, the owning plugin is attempted to be inferred from the API + // reference ID. + if (existingFactory && existingFactory.pluginId !== pluginId) { + const shouldReplace = + ownerId === pluginId && existingFactory.pluginId !== ownerId; + const acceptedPluginId = shouldReplace + ? pluginId + : existingFactory.pluginId; + const rejectedPluginId = shouldReplace + ? existingFactory.pluginId + : pluginId; + + options.collector.report({ + code: 'API_FACTORY_CONFLICT', + message: `API '${apiRefId}' is already provided by plugin '${acceptedPluginId}', cannot also be provided by '${rejectedPluginId}'.`, + context: { + node: apiNode, + apiRefId, + pluginId: rejectedPluginId, + existingPluginId: acceptedPluginId, + }, + }); + if (shouldReplace) { + factoriesById.set(apiRefId, { + pluginId, + node: apiNode, + factory: apiFactory, + }); + } + continue; + } + + factoriesById.set(apiRefId, { + pluginId, + node: apiNode, + factory: apiFactory, + }); + } else { + options.collector.report({ + code: 'API_EXTENSION_INVALID', + message: `API extension '${apiNode.spec.id}' did not output an API factory`, + context: { + node: apiNode, + }, + }); + } + } + + return factoriesById; +} + +function syncFinalApiFactories(options: { + deferredApiNodes: Iterable; + appApiRegistry: FrontendApiRegistry; + apiResolver: FrontendApiResolver; + collector: ErrorCollector; + features: FrontendFeature[]; + bootstrapApiFactoryEntries: ReadonlyMap; + bootstrapMissingApiAccesses: Map; + predicateContext: ExtensionPredicateContext; +}) { + const finalApiEntries = collectApiFactoryEntries({ + apiNodes: options.deferredApiNodes, + collector: options.collector, + predicateContext: options.predicateContext, + entries: new Map(options.bootstrapApiFactoryEntries), + }); + const changedEntries = Array.from(finalApiEntries.values()).filter(entry => { + const bootstrapEntry = options.bootstrapApiFactoryEntries.get( + entry.factory.api.id, + ); + if (!bootstrapEntry) { + return true; + } + if (bootstrapEntry.factory === entry.factory) { + return false; + } + if (options.apiResolver.isMaterialized(entry.factory.api.id)) { + options.collector.report({ + code: 'EXTENSION_BOOTSTRAP_API_OVERRIDE_IGNORED', + message: + `Extension '${entry.node.spec.id}' tried to override API ` + + `'${entry.factory.api.id}' after it had already been materialized during bootstrap. ` + + 'The bootstrap implementation was kept and the deferred override was ignored.', + context: { + node: entry.node, + apiRefId: entry.factory.api.id, + bootstrapNode: bootstrapEntry.node, + pluginId: entry.pluginId, + bootstrapPluginId: bootstrapEntry.pluginId, + }, + }); + return false; + } + return true; + }); + const changedFactories = changedEntries.map(entry => + wrapFeatureFlagApiFactory(entry.factory, options.features), + ); + options.appApiRegistry.setAll(changedFactories); + options.apiResolver.invalidate( + changedFactories.map(factory => factory.api.id), + ); + for (const bootstrapAccess of options.bootstrapMissingApiAccesses.values()) { + if ( + options.bootstrapApiFactoryEntries.has(bootstrapAccess.apiRefId) || + !finalApiEntries.has(bootstrapAccess.apiRefId) + ) { + continue; + } + + options.collector.report({ + code: 'EXTENSION_BOOTSTRAP_API_UNAVAILABLE', + message: + `Extension '${bootstrapAccess.node.spec.id}' tried to access API ` + + `'${bootstrapAccess.apiRefId}' during bootstrap before it was available. ` + + 'That API became available during finalization, so bootstrap-visible extensions must not depend on deferred APIs.', + context: { + node: bootstrapAccess.node, + apiRefId: bootstrapAccess.apiRefId, + }, + }); + } +} + +function collectBootstrapVisibleNodes( + tree: AppTree, + options?: { deferredRoots?: Set }, +) { + const visibleNodes = new Set(); + + function visit(node: AppNode) { + if (visibleNodes.has(node)) { + return; + } + visibleNodes.add(node); + + for (const [input, children] of node.edges.attachments) { + if (isSessionBoundaryAttachment(node, input)) { + continue; + } + + for (const child of children) { + if (options?.deferredRoots?.has(child)) { + continue; + } + visit(child); + } + } + } + + visit(tree.root); + + return visibleNodes; +} + +function classifyBootstrapTree(options: { + tree: AppTree; + collector: ErrorCollector; +}): BootstrapClassification { + const apiNodes = options.tree.root.edges.attachments.get('apis') ?? []; + const deferredApiRoots = new Set( + apiNodes.filter(apiNode => subtreeContainsPredicate(apiNode)), + ); + const appRootElementNodes = + getAppRootNode(options.tree)?.edges.attachments.get('elements') ?? []; + const deferredElementRoots = new Set( + appRootElementNodes.filter(elementNode => + subtreeContainsPredicate(elementNode), + ), + ); + const deferredRoots = new Set([ + ...deferredApiRoots, + ...deferredElementRoots, + ]); + const bootstrapNodes = collectBootstrapVisibleNodes(options.tree, { + deferredRoots, + }); + + for (const node of bootstrapNodes) { + if (node.spec.if === undefined) { + continue; + } + + options.collector.report({ + code: 'EXTENSION_BOOTSTRAP_PREDICATE_IGNORED', + message: + `Extension '${node.spec.id}' uses 'if' during bootstrap, so the predicate was ignored. ` + + "Move it behind 'app/root.children', onto a deferred 'app/root.elements' subtree, or into an API subtree.", + context: { + node, + }, + }); + (node.spec as typeof node.spec & { if?: FilterPredicate }).if = undefined; + } + + return { + deferredApiRoots, + deferredElementRoots, + deferredRoots, + }; +} + +function collectPredicateReferences(tree: AppTree): ExtensionPredicateContext { + const featureFlags = new Set(); + const permissions = new Set(); + + for (const node of tree.nodes.values()) { + if (node.spec.if === undefined) { + continue; + } + + for (const name of extractFeatureFlagNames(node.spec.if)) { + featureFlags.add(name); + } + for (const name of extractPermissionNames(node.spec.if)) { + permissions.add(name); + } + } + + return { + featureFlags: Array.from(featureFlags), + permissions: Array.from(permissions), + }; +} + +function subtreeContainsPredicate(root: AppNode) { + const visited = new Set(); + + function visit(node: AppNode): boolean { + if (visited.has(node)) { + return false; + } + visited.add(node); + + if (node.spec.if !== undefined) { + return true; + } + + for (const children of node.edges.attachments.values()) { + for (const child of children) { + if (visit(child)) { + return true; + } + } + } + + return false; + } + + return visit(root); +} + +// TODO(Rugvip): It would be good if this was more explicit, but I think that +// might need to wait for some future update for API factories. +function getApiOwnerId(apiRefId: string): string { + const [prefix, ...rest] = apiRefId.split('.'); + if (!prefix) { + return apiRefId; + } + if (prefix === 'core') { + return 'app'; + } + if (prefix === 'plugin' && rest[0]) { + return rest[0]; + } + return prefix; +} + +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); + }; + }); +} + +/** + * Recursively walks a FilterPredicate and returns all string values referenced + * by `featureFlags: { $contains: '...' }` expressions. This lets us call + * `isActive()` only for the flags that are actually used in predicates rather + * than fetching the full registered-flag list. + */ +function extractFeatureFlagNames(predicate: FilterPredicate): string[] { + return extractPredicateKeyNames(predicate, 'featureFlags'); +} + +/** + * Recursively walks a FilterPredicate and returns all string values referenced + * by `permissions: { $contains: '...' }` expressions. This lets us issue a + * single batched authorize call for only the permissions actually referenced. + */ +function extractPermissionNames(predicate: FilterPredicate): string[] { + return extractPredicateKeyNames(predicate, 'permissions'); +} + +function extractPredicateKeyNames( + predicate: FilterPredicate, + key: string, +): string[] { + if (typeof predicate !== 'object' || predicate === null) { + return []; + } + const obj = predicate as Record; + if (Array.isArray(obj.$all)) { + return (obj.$all as FilterPredicate[]).flatMap(p => + extractPredicateKeyNames(p, key), + ); + } + if (Array.isArray(obj.$any)) { + return (obj.$any as FilterPredicate[]).flatMap(p => + extractPredicateKeyNames(p, key), + ); + } + if (obj.$not !== undefined) { + return extractPredicateKeyNames(obj.$not as FilterPredicate, key); + } + const value = obj[key]; + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + const contains = (value as Record).$contains; + if (typeof contains === 'string') { + return [contains]; + } + } + return []; +} From ca24aa2da94911d10974bb2306bd987b6e31e6e9 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Mar 2026 00:23:54 +0100 Subject: [PATCH 50/68] frontend-app-api: extract prepared app predicates Move predicate reference collection and predicate context loading out of prepareSpecializedApp so the session and finalization flow can focus on lifecycle state. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../frontend-app-api/src/wiring/predicates.ts | 185 ++++++++++++++++++ .../src/wiring/prepareSpecializedApp.tsx | 171 ++-------------- 2 files changed, 199 insertions(+), 157 deletions(-) create mode 100644 packages/frontend-app-api/src/wiring/predicates.ts diff --git a/packages/frontend-app-api/src/wiring/predicates.ts b/packages/frontend-app-api/src/wiring/predicates.ts new file mode 100644 index 0000000000..819036fc17 --- /dev/null +++ b/packages/frontend-app-api/src/wiring/predicates.ts @@ -0,0 +1,185 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ApiHolder, + createApiRef, + featureFlagsApiRef, +} from '@backstage/frontend-plugin-api'; +import { FilterPredicate } from '@backstage/filter-predicates'; +import type { + EvaluatePermissionRequest, + EvaluatePermissionResponse, +} from '@backstage/plugin-permission-common'; + +export type ExtensionPredicateContext = { + featureFlags: string[]; + permissions: string[]; +}; + +export const EMPTY_PREDICATE_CONTEXT: ExtensionPredicateContext = { + featureFlags: [], + permissions: [], +}; + +// Minimal local permission API interface to avoid a dependency on @backstage/plugin-permission-react +type MinimalPermissionApi = { + authorize( + request: EvaluatePermissionRequest, + ): Promise; +}; + +export const localPermissionApiRef = createApiRef({ + id: 'plugin.permission.api', +}); + +export function createPredicateContextLoader(options: { + apis: ApiHolder; + predicateReferences: ExtensionPredicateContext; +}) { + function getActiveFeatureFlags() { + const featureFlagsApi = options.apis.get(featureFlagsApiRef); + if (!featureFlagsApi) { + return []; + } + + return options.predicateReferences.featureFlags.filter(name => + featureFlagsApi.isActive(name), + ); + } + + function getImmediate(): ExtensionPredicateContext | undefined { + if (options.predicateReferences.permissions.length > 0) { + const permissionApi = options.apis.get(localPermissionApiRef); + if (permissionApi) { + return undefined; + } + } + + return { + featureFlags: getActiveFeatureFlags(), + permissions: [], + }; + } + + async function load() { + const immediatePredicateContext = getImmediate(); + if (immediatePredicateContext) { + return immediatePredicateContext; + } + + let allowedPermissions: string[] = []; + const permissionApi = options.apis.get(localPermissionApiRef); + if (permissionApi) { + const permissionNames = options.predicateReferences.permissions; + const responses = await Promise.all( + permissionNames.map(name => + permissionApi.authorize({ + permission: { name, type: 'basic', attributes: {} }, + }), + ), + ); + allowedPermissions = permissionNames.filter( + (_, i) => responses[i].result === 'ALLOW', + ); + } + + return { + featureFlags: getActiveFeatureFlags(), + permissions: allowedPermissions, + }; + } + + return { + getImmediate, + load, + }; +} + +export function collectPredicateReferences( + nodes: Iterable<{ spec: { if?: FilterPredicate } }>, +): ExtensionPredicateContext { + const featureFlags = new Set(); + const permissions = new Set(); + + for (const node of nodes) { + if (node.spec.if === undefined) { + continue; + } + + for (const name of extractFeatureFlagNames(node.spec.if)) { + featureFlags.add(name); + } + for (const name of extractPermissionNames(node.spec.if)) { + permissions.add(name); + } + } + + return { + featureFlags: Array.from(featureFlags), + permissions: Array.from(permissions), + }; +} + +/** + * Recursively walks a FilterPredicate and returns all string values referenced + * by `featureFlags: { $contains: '...' }` expressions. This lets us call + * `isActive()` only for the flags that are actually used in predicates rather + * than fetching the full registered-flag list. + */ +function extractFeatureFlagNames(predicate: FilterPredicate): string[] { + return extractPredicateKeyNames(predicate, 'featureFlags'); +} + +/** + * Recursively walks a FilterPredicate and returns all string values referenced + * by `permissions: { $contains: '...' }` expressions. This lets us issue a + * single batched authorize call for only the permissions actually referenced. + */ +function extractPermissionNames(predicate: FilterPredicate): string[] { + return extractPredicateKeyNames(predicate, 'permissions'); +} + +function extractPredicateKeyNames( + predicate: FilterPredicate, + key: string, +): string[] { + if (typeof predicate !== 'object' || predicate === null) { + return []; + } + const obj = predicate as Record; + if (Array.isArray(obj.$all)) { + return (obj.$all as FilterPredicate[]).flatMap(p => + extractPredicateKeyNames(p, key), + ); + } + if (Array.isArray(obj.$any)) { + return (obj.$any as FilterPredicate[]).flatMap(p => + extractPredicateKeyNames(p, key), + ); + } + if (obj.$not !== undefined) { + return extractPredicateKeyNames(obj.$not as FilterPredicate, key); + } + const value = obj[key]; + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + const contains = (value as Record).$contains; + if (typeof contains === 'string') { + return [contains]; + } + } + return []; +} diff --git a/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx b/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx index c742e294cc..b916545160 100644 --- a/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx +++ b/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx @@ -32,7 +32,6 @@ import { RouteFunc, RouteResolutionApi, createApiFactory, - createApiRef, routeResolutionApiRef, AppNode, AppNodeInstance, @@ -44,10 +43,6 @@ import { identityApiRef, createExtensionDataRef, } from '@backstage/frontend-plugin-api'; -import type { - EvaluatePermissionRequest, - EvaluatePermissionResponse, -} from '@backstage/plugin-permission-common'; import { FilterPredicate } from '@backstage/filter-predicates'; import { createExtensionDataContainer, @@ -99,6 +94,12 @@ import { createErrorCollector, ErrorCollector, } from './createErrorCollector'; +import { + collectPredicateReferences, + createPredicateContextLoader, + EMPTY_PREDICATE_CONTEXT, + type ExtensionPredicateContext, +} from './predicates'; import { FrontendApiRegistry, FrontendApiResolver, @@ -167,22 +168,12 @@ type FinalizationState = { reject(error: unknown): void; }; -type ExtensionPredicateContext = { - featureFlags: string[]; - permissions: string[]; -}; - type InternalSpecializedAppSessionState = { apis: ApiHolder; identityApi?: IdentityApi; predicateContext: ExtensionPredicateContext; }; -const EMPTY_PREDICATE_CONTEXT: ExtensionPredicateContext = { - featureFlags: [], - permissions: [], -}; - /** * Opaque reusable session state for specialized apps. * @@ -437,17 +428,6 @@ export type PreparedSpecializedApp = { }): FinalizedSpecializedApp; }; -// Minimal local permission API interface to avoid a dependency on @backstage/plugin-permission-react -type MinimalPermissionApi = { - authorize( - request: EvaluatePermissionRequest, - ): Promise; -}; - -const localPermissionApiRef = createApiRef({ - id: 'plugin.permission.api', -}); - // Internal options type, not exported in the public API export interface CreateSpecializedAppInternalOptions extends PrepareSpecializedAppOptions { @@ -523,7 +503,7 @@ export function prepareSpecializedApp( tree, collector, }); - const predicateReferences = collectPredicateReferences(tree); + const predicateReferences = collectPredicateReferences(tree.nodes.values()); const appApiRegistry = new FrontendApiRegistry(); const internalStaticFactories = internalOptions?.__internal?.apiFactoryOverrides ?? []; @@ -560,6 +540,10 @@ export function prepareSpecializedApp( routeBindings, staticFactories: phaseStaticFactories, }); + const predicateContextLoader = createPredicateContextLoader({ + apis: phase.apis, + predicateReferences, + }); const state: { signInRuntime?: SignInRuntime; cachedSessionState?: SpecializedAppSessionState; @@ -586,61 +570,6 @@ export function prepareSpecializedApp( }); } - function getActiveFeatureFlags() { - const featureFlagsApi = phase.apis.get(featureFlagsApiRef); - if (!featureFlagsApi) { - return []; - } - - return predicateReferences.featureFlags.filter(name => - featureFlagsApi.isActive(name), - ); - } - - function getImmediatePredicateContext(): - | ExtensionPredicateContext - | undefined { - if (predicateReferences.permissions.length > 0) { - const permissionApi = phase.apis.get(localPermissionApiRef); - if (permissionApi) { - return undefined; - } - } - - return { - featureFlags: getActiveFeatureFlags(), - permissions: [], - }; - } - - async function createPredicateContext() { - const immediatePredicateContext = getImmediatePredicateContext(); - if (immediatePredicateContext) { - return immediatePredicateContext; - } - - let allowedPermissions: string[] = []; - const permissionApi = phase.apis.get(localPermissionApiRef); - if (permissionApi) { - const permNames = predicateReferences.permissions; - const responses = await Promise.all( - permNames.map(name => - permissionApi.authorize({ - permission: { name, type: 'basic', attributes: {} }, - }), - ), - ); - allowedPermissions = permNames.filter( - (_, i) => responses[i].result === 'ALLOW', - ); - } - - return { - featureFlags: getActiveFeatureFlags(), - permissions: allowedPermissions, - }; - } - function createSessionState(predicateContext: ExtensionPredicateContext) { const identityApi = state.signInRuntime?.readyIdentityApi ?? providedSessionData?.identityApi; @@ -662,7 +591,7 @@ export function prepareSpecializedApp( return undefined; } - const predicateContext = getImmediatePredicateContext(); + const predicateContext = predicateContextLoader.getImmediate(); if (!predicateContext) { return undefined; } @@ -692,7 +621,8 @@ export function prepareSpecializedApp( ); } - state.sessionStatePromise = createPredicateContext() + state.sessionStatePromise = predicateContextLoader + .load() .then(predicateContext => { if (state.cachedSessionState) { return state.cachedSessionState; @@ -1586,29 +1516,6 @@ function classifyBootstrapTree(options: { }; } -function collectPredicateReferences(tree: AppTree): ExtensionPredicateContext { - const featureFlags = new Set(); - const permissions = new Set(); - - for (const node of tree.nodes.values()) { - if (node.spec.if === undefined) { - continue; - } - - for (const name of extractFeatureFlagNames(node.spec.if)) { - featureFlags.add(name); - } - for (const name of extractPermissionNames(node.spec.if)) { - permissions.add(name); - } - } - - return { - featureFlags: Array.from(featureFlags), - permissions: Array.from(permissions), - }; -} - function subtreeContainsPredicate(root: AppNode) { const visited = new Set(); @@ -1686,53 +1593,3 @@ function mergeExtensionFactoryMiddleware( }; }); } - -/** - * Recursively walks a FilterPredicate and returns all string values referenced - * by `featureFlags: { $contains: '...' }` expressions. This lets us call - * `isActive()` only for the flags that are actually used in predicates rather - * than fetching the full registered-flag list. - */ -function extractFeatureFlagNames(predicate: FilterPredicate): string[] { - return extractPredicateKeyNames(predicate, 'featureFlags'); -} - -/** - * Recursively walks a FilterPredicate and returns all string values referenced - * by `permissions: { $contains: '...' }` expressions. This lets us issue a - * single batched authorize call for only the permissions actually referenced. - */ -function extractPermissionNames(predicate: FilterPredicate): string[] { - return extractPredicateKeyNames(predicate, 'permissions'); -} - -function extractPredicateKeyNames( - predicate: FilterPredicate, - key: string, -): string[] { - if (typeof predicate !== 'object' || predicate === null) { - return []; - } - const obj = predicate as Record; - if (Array.isArray(obj.$all)) { - return (obj.$all as FilterPredicate[]).flatMap(p => - extractPredicateKeyNames(p, key), - ); - } - if (Array.isArray(obj.$any)) { - return (obj.$any as FilterPredicate[]).flatMap(p => - extractPredicateKeyNames(p, key), - ); - } - if (obj.$not !== undefined) { - return extractPredicateKeyNames(obj.$not as FilterPredicate, key); - } - const value = obj[key]; - if (typeof value === 'object' && value !== null && !Array.isArray(value)) { - const contains = (value as Record).$contains; - if (typeof contains === 'string') { - return [contains]; - } - } - return []; -} From d30aba815a903c2131492b038c9dd36aecff86c0 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Mar 2026 00:27:59 +0100 Subject: [PATCH 51/68] frontend-app-api: extract prepared app phase apis Move the phase-specific API proxies and tree initialization helpers out of prepareSpecializedApp so the remaining file can stay focused on bootstrap and finalization flow. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../frontend-app-api/src/wiring/phaseApis.tsx | 312 ++++++++++++++++++ .../src/wiring/prepareSpecializedApp.tsx | 290 +--------------- 2 files changed, 320 insertions(+), 282 deletions(-) create mode 100644 packages/frontend-app-api/src/wiring/phaseApis.tsx diff --git a/packages/frontend-app-api/src/wiring/phaseApis.tsx b/packages/frontend-app-api/src/wiring/phaseApis.tsx new file mode 100644 index 0000000000..08eb20c17a --- /dev/null +++ b/packages/frontend-app-api/src/wiring/phaseApis.tsx @@ -0,0 +1,312 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + AnyApiFactory, + ApiHolder, + AppTree, + AppTreeApi, + appTreeApiRef, + ConfigApi, + configApiRef, + createApiFactory, + ExternalRouteRef, + identityApiRef, + RouteFunc, + RouteRef, + RouteResolutionApi, + routeResolutionApiRef, + SubRouteRef, + type AnyRouteRefParams, + type AppNode, + type ExtensionFactoryMiddleware, + type IdentityApi, +} from '@backstage/frontend-plugin-api'; +import { matchRoutes } from 'react-router-dom'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { AppIdentityProxy } from '../../../core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy'; +import { createRouteAliasResolver } from '../routing/RouteAliasResolver'; +import { RouteResolver } from '../routing/RouteResolver'; +import { collectRouteIds } from '../routing/collectRouteIds'; +import { + extractRouteInfoFromAppNode, + type RouteInfo, +} from '../routing/extractRouteInfoFromAppNode'; +import { type BackstageRouteObject } from '../routing/types'; +import { instantiateAppNodeTree } from '../tree/instantiateAppNodeTree'; +import { + FrontendApiRegistry, + FrontendApiResolver, +} from './FrontendApiRegistry'; +import { type ExtensionPredicateContext } from './predicates'; +import { type ErrorCollector } from './createErrorCollector'; + +// Helps delay callers from reaching out to the API before the app tree has been materialized +export 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(); + const routeInfo = this.#routeInfo; + if (!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`, + ); + } + + let path = routePath; + if (path.startsWith(this.appBasePath)) { + path = path.slice(this.appBasePath.length); + } + + const matchedRoutes = matchRoutes(routeInfo.routeObjects, path); + + const matchedAppNodes = + matchedRoutes?.flatMap(routeObj => { + const appNode = routeObj.route.appNode; + return appNode ? [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 +export 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 class PreparedAppIdentityProxy extends AppIdentityProxy { + #onTargetSet?: + | (( + identityApi: Parameters[0], + ) => Promise | void) + | undefined; + #onTargetError?: ((error: unknown) => void) | undefined; + + setTargetHandlers(options: { + onTargetSet?( + identityApi: Parameters[0], + ): Promise | void; + onTargetError?(error: unknown): void; + }) { + this.#onTargetSet = options.onTargetSet; + this.#onTargetError = options.onTargetError; + } + + clearTargetHandlers() { + this.#onTargetSet = undefined; + this.#onTargetError = undefined; + } + + override setTarget( + identityApi: Parameters[0], + targetOptions: Parameters[1], + ) { + super.setTarget(identityApi, targetOptions); + + const onTargetSet = this.#onTargetSet; + if (!onTargetSet) { + return; + } + + const onTargetError = this.#onTargetError; + this.clearTargetHandlers(); + + try { + void Promise.resolve(onTargetSet(identityApi)).catch(error => { + onTargetError?.(error); + }); + } catch (error) { + onTargetError?.(error); + } + } +} + +export function createPhaseApis(options: { + tree: AppTree; + config: ConfigApi; + appApiRegistry: FrontendApiRegistry; + fallbackApis?: ApiHolder; + includeConfigApi: boolean; + appBasePath: string; + routeBindings: Map; + staticFactories: AnyApiFactory[]; +}) { + const appTreeApi = new AppTreeApiProxy(options.tree, options.appBasePath); + const routeResolutionApi = new RouteResolutionApiProxy( + options.routeBindings, + options.appBasePath, + ); + const identityProxy = new PreparedAppIdentityProxy(); + const phaseApiRegistry = new FrontendApiRegistry(); + phaseApiRegistry.registerAll([ + createApiFactory(appTreeApiRef, appTreeApi), + ...(options.includeConfigApi + ? [createApiFactory(configApiRef, options.config)] + : []), + createApiFactory(routeResolutionApiRef, routeResolutionApi), + createApiFactory(identityApiRef, identityProxy), + ...options.staticFactories, + ]); + + const apis = new FrontendApiResolver({ + primaryRegistry: phaseApiRegistry, + secondaryRegistry: options.appApiRegistry, + fallbackApis: options.fallbackApis, + }); + + return { + apis, + routeResolutionApi, + appTreeApi, + identityApiProxy: identityProxy, + }; +} + +export function instantiateAndInitializePhaseTree(options: { + tree: AppTree; + apis: ApiHolder; + collector: ErrorCollector; + extensionFactoryMiddleware?: ExtensionFactoryMiddleware; + routeResolutionApi: RouteResolutionApiProxy; + appTreeApi: AppTreeApiProxy; + routeRefsById: ReturnType; + stopAtSessionBoundary?: boolean; + skipChild?(ctx: { node: AppNode; input: string; child: AppNode }): boolean; + onMissingApi?(ctx: { node: AppNode; apiRefId: string }): void; + predicateContext?: ExtensionPredicateContext; + stopAtAttachment?(ctx: { node: AppNode; input: string }): boolean; +}) { + let stopAtAttachment = options.stopAtAttachment; + if (options.stopAtSessionBoundary) { + stopAtAttachment = ({ node, input }) => + isSessionBoundaryAttachment(node, input); + } + + instantiateAppNodeTree( + options.tree.root, + options.apis, + options.collector, + options.extensionFactoryMiddleware, + { + ...(stopAtAttachment ? { stopAtAttachment } : {}), + skipChild: options.skipChild, + onMissingApi: options.onMissingApi, + predicateContext: options.predicateContext, + }, + ); + + const routeInfo = extractRouteInfoFromAppNode( + options.tree.root, + createRouteAliasResolver(options.routeRefsById), + ); + + options.routeResolutionApi.initialize( + routeInfo, + options.routeRefsById.routes, + ); + options.appTreeApi.initialize(routeInfo); +} + +export function setIdentityApiTarget(options: { + identityApiProxy: AppIdentityProxy; + identityApi: IdentityApi; + signOutTargetUrl: string; +}) { + options.identityApiProxy.setTarget(options.identityApi, { + signOutTargetUrl: options.signOutTargetUrl, + }); +} + +function isSessionBoundaryAttachment(node: AppNode, input: string) { + return node.spec.id === 'app/root' && input === 'children'; +} diff --git a/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx b/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx index b916545160..6f4655309e 100644 --- a/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx +++ b/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx @@ -20,19 +20,8 @@ import { AnyApiFactory, ApiHolder, AppTree, - AppTreeApi, - appTreeApiRef, ConfigApi, - configApiRef, coreExtensionData, - RouteRef, - ExternalRouteRef, - SubRouteRef, - AnyRouteRefParams, - RouteFunc, - RouteResolutionApi, - createApiFactory, - routeResolutionApiRef, AppNode, AppNodeInstance, ExtensionDataRef, @@ -57,13 +46,7 @@ import { 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 @@ -76,24 +59,23 @@ import { Root } from '../extensions/Root'; import { resolveAppTree } from '../tree/resolveAppTree'; import { resolveAppNodeSpecs } from '../tree/resolveAppNodeSpecs'; import { readAppExtensionsConfig } from '../tree/readAppExtensionsConfig'; -import { - instantiateAppNodeSubtree, - instantiateAppNodeTree, -} from '../tree/instantiateAppNodeTree'; -// 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 { instantiateAppNodeSubtree } from '../tree/instantiateAppNodeTree'; import { createPluginInfoAttacher, FrontendPluginInfoResolver, } from './createPluginInfoAttacher'; -import { createRouteAliasResolver } from '../routing/RouteAliasResolver'; import { AppError, createErrorCollector, ErrorCollector, } from './createErrorCollector'; +import { + AppTreeApiProxy, + createPhaseApis, + instantiateAndInitializePhaseTree, + RouteResolutionApiProxy, + setIdentityApiTarget, +} from './phaseApis'; import { collectPredicateReferences, createPredicateContextLoader, @@ -197,163 +179,6 @@ const signInPageComponentDataRef = createExtensionDataRef< ComponentType >().with({ id: 'core.sign-in-page.component' }); -// 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(); - const routeInfo = this.#routeInfo; - if (!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`, - ); - } - - let path = routePath; - if (path.startsWith(this.appBasePath)) { - path = path.slice(this.appBasePath.length); - } - - const matchedRoutes = matchRoutes(routeInfo.routeObjects, path); - - const matchedAppNodes = - matchedRoutes?.flatMap(routeObj => { - const appNode = routeObj.route.appNode; - return appNode ? [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; - } -} - -class PreparedAppIdentityProxy extends AppIdentityProxy { - #onTargetSet?: - | (( - identityApi: Parameters[0], - ) => Promise | void) - | undefined; - #onTargetError?: ((error: unknown) => void) | undefined; - - setTargetHandlers(options: { - onTargetSet?( - identityApi: Parameters[0], - ): Promise | void; - onTargetError?(error: unknown): void; - }) { - this.#onTargetSet = options.onTargetSet; - this.#onTargetError = options.onTargetError; - } - - clearTargetHandlers() { - this.#onTargetSet = undefined; - this.#onTargetError = undefined; - } - - override setTarget( - identityApi: Parameters[0], - targetOptions: Parameters[1], - ) { - super.setTarget(identityApi, targetOptions); - - const onTargetSet = this.#onTargetSet; - if (!onTargetSet) { - return; - } - - const onTargetError = this.#onTargetError; - this.clearTargetHandlers(); - - try { - void Promise.resolve(onTargetSet(identityApi)).catch(error => { - onTargetError?.(error); - }); - } catch (error) { - onTargetError?.(error); - } - } -} - /** * Options for {@link prepareSpecializedApp}. * @@ -1051,95 +876,6 @@ function createBootstrapApp(options: { }; } -function createPhaseApis(options: { - tree: AppTree; - config: ConfigApi; - appApiRegistry: FrontendApiRegistry; - fallbackApis?: ApiHolder; - includeConfigApi: boolean; - appBasePath: string; - routeBindings: Map; - staticFactories: AnyApiFactory[]; -}) { - const appTreeApi = new AppTreeApiProxy(options.tree, options.appBasePath); - const routeResolutionApi = new RouteResolutionApiProxy( - options.routeBindings, - options.appBasePath, - ); - const identityProxy = new PreparedAppIdentityProxy(); - const phaseApiRegistry = new FrontendApiRegistry(); - phaseApiRegistry.registerAll([ - createApiFactory(appTreeApiRef, appTreeApi), - ...(options.includeConfigApi - ? [createApiFactory(configApiRef, options.config)] - : []), - createApiFactory(routeResolutionApiRef, routeResolutionApi), - createApiFactory(identityApiRef, identityProxy), - ...options.staticFactories, - ]); - - const apis = new FrontendApiResolver({ - primaryRegistry: phaseApiRegistry, - secondaryRegistry: options.appApiRegistry, - fallbackApis: options.fallbackApis, - }); - - return { - apis, - routeResolutionApi, - appTreeApi, - identityApiProxy: identityProxy, - }; -} - -function instantiateAndInitializePhaseTree(options: { - tree: AppTree; - apis: ApiHolder; - collector: ErrorCollector; - extensionFactoryMiddleware?: ExtensionFactoryMiddleware; - routeResolutionApi: RouteResolutionApiProxy; - appTreeApi: AppTreeApiProxy; - routeRefsById: ReturnType; - stopAtSessionBoundary?: boolean; - skipChild?(ctx: { node: AppNode; input: string; child: AppNode }): boolean; - onMissingApi?(ctx: { node: AppNode; apiRefId: string }): void; - predicateContext?: ExtensionPredicateContext; -}) { - instantiateAppNodeTree( - options.tree.root, - options.apis, - options.collector, - options.extensionFactoryMiddleware, - { - ...(options.stopAtSessionBoundary - ? { - stopAtAttachment: ({ - node, - input, - }: { - node: AppNode; - input: string; - }) => isSessionBoundaryAttachment(node, input), - } - : {}), - skipChild: options.skipChild, - onMissingApi: options.onMissingApi, - predicateContext: options.predicateContext, - }, - ); - - const routeInfo = extractRouteInfoFromAppNode( - options.tree.root, - createRouteAliasResolver(options.routeRefsById), - ); - - options.routeResolutionApi.initialize( - routeInfo, - options.routeRefsById.routes, - ); - options.appTreeApi.initialize(routeInfo); -} - function prepareFinalizedTree(options: { tree: AppTree }) { for (const appRootNode of getFinalizationBoundaryNodes(options.tree)) { const attachments = appRootNode.edges.attachments as Map; @@ -1267,16 +1003,6 @@ function asError(error: unknown): Error { return new Error(String(error)); } -function setIdentityApiTarget(options: { - identityApiProxy: AppIdentityProxy; - identityApi: IdentityApi; - signOutTargetUrl: string; -}) { - options.identityApiProxy.setTarget(options.identityApi, { - signOutTargetUrl: options.signOutTargetUrl, - }); -} - const EMPTY_API_HOLDER: ApiHolder = { get() { return undefined; From 12f809015f0209d203707594d5dae8dc911f266f Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Mar 2026 00:42:50 +0100 Subject: [PATCH 52/68] frontend-app-api: restore local prepare app state Replace the consolidated runtime state object in prepareSpecializedApp with local variables so the control flow stays easier to follow while preserving the extracted helper modules. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../src/wiring/prepareSpecializedApp.tsx | 149 +++++++++--------- 1 file changed, 71 insertions(+), 78 deletions(-) diff --git a/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx b/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx index 6f4655309e..a1a3ff02e2 100644 --- a/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx +++ b/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx @@ -369,19 +369,15 @@ export function prepareSpecializedApp( apis: phase.apis, predicateReferences, }); - const state: { - signInRuntime?: SignInRuntime; - cachedSessionState?: SpecializedAppSessionState; - sessionStatePromise?: Promise; - finalized?: FinalizedSpecializedApp; - bootstrapApp?: BootstrapSpecializedApp; - bootstrapError?: Error; - finalizationState?: FinalizationState; - bootstrapErrorReporter?: (error: Error) => void; - pendingBootstrapError?: Error; - } = { - cachedSessionState: providedSessionState, - }; + let signInRuntime: SignInRuntime | undefined; + let cachedSessionState = providedSessionState; + let sessionStatePromise: Promise | undefined; + let finalized: FinalizedSpecializedApp | undefined; + let bootstrapApp: BootstrapSpecializedApp | undefined; + let bootstrapError: Error | undefined; + let finalizationState: FinalizationState | undefined; + let bootstrapErrorReporter: ((error: Error) => void) | undefined; + let pendingBootstrapError: Error | undefined; function updateIdentityApiTarget(identityApi?: IdentityApi) { if (!identityApi) { @@ -397,22 +393,22 @@ export function prepareSpecializedApp( function createSessionState(predicateContext: ExtensionPredicateContext) { const identityApi = - state.signInRuntime?.readyIdentityApi ?? providedSessionData?.identityApi; + signInRuntime?.readyIdentityApi ?? providedSessionData?.identityApi; updateIdentityApiTarget(identityApi); const sessionState = OpaqueSpecializedAppSessionState.createInstance('v1', { apis: phase.apis, identityApi, predicateContext, }); - state.cachedSessionState = sessionState; + cachedSessionState = sessionState; return sessionState; } function getImmediateSessionState() { - if (state.cachedSessionState) { - return state.cachedSessionState; + if (cachedSessionState) { + return cachedSessionState; } - if (state.signInRuntime?.requiresSignIn) { + if (signInRuntime?.requiresSignIn) { return undefined; } @@ -429,16 +425,13 @@ export function prepareSpecializedApp( if (immediateSessionState) { return Promise.resolve(immediateSessionState); } - if (state.sessionStatePromise) { - return state.sessionStatePromise; + if (sessionStatePromise) { + return sessionStatePromise; } - if (state.signInRuntime?.error) { - return Promise.reject(state.signInRuntime.error); + if (signInRuntime?.error) { + return Promise.reject(signInRuntime.error); } - if ( - state.signInRuntime?.requiresSignIn && - !state.signInRuntime.readyIdentityApi - ) { + if (signInRuntime?.requiresSignIn && !signInRuntime.readyIdentityApi) { return Promise.reject( new Error( 'prepareSpecializedApp requires waiting for the bootstrap app to be ready before calling finalize()', @@ -446,20 +439,20 @@ export function prepareSpecializedApp( ); } - state.sessionStatePromise = predicateContextLoader + sessionStatePromise = predicateContextLoader .load() .then(predicateContext => { - if (state.cachedSessionState) { - return state.cachedSessionState; + if (cachedSessionState) { + return cachedSessionState; } return createSessionState(predicateContext); }) .catch(error => { - state.sessionStatePromise = undefined; + sessionStatePromise = undefined; throw error; }); - return state.sessionStatePromise; + return sessionStatePromise; } function startSignInFinalize( @@ -476,11 +469,11 @@ export function prepareSpecializedApp( function finalizeFromSessionState( finalizedSessionState: SpecializedAppSessionState, ): FinalizedSpecializedApp { - if (state.finalized) { - return state.finalized; + if (finalized) { + return finalized; } - state.cachedSessionState = finalizedSessionState; + cachedSessionState = finalizedSessionState; const sessionStateData = OpaqueSpecializedAppSessionState.toInternal( finalizedSessionState, ); @@ -524,24 +517,24 @@ export function prepareSpecializedApp( tree, errors: collector.collectErrors(), }; - state.finalized = finalizedApp; + finalized = finalizedApp; return finalizedApp; } function reportBootstrapFailure(error: unknown) { const bootstrapFailure = asError(error); - state.bootstrapError = bootstrapFailure; - if (state.bootstrapErrorReporter) { - state.bootstrapErrorReporter(bootstrapFailure); + bootstrapError = bootstrapFailure; + if (bootstrapErrorReporter) { + bootstrapErrorReporter(bootstrapFailure); return; } - state.pendingBootstrapError = bootstrapFailure; + pendingBootstrapError = bootstrapFailure; } function getFinalizationState(): FinalizationState { - if (state.finalizationState) { - return state.finalizationState; + if (finalizationState) { + return finalizationState; } let resolve: ((app: FinalizedSpecializedApp) => void) | undefined; @@ -554,20 +547,20 @@ export function prepareSpecializedApp( throw new Error('Failed to create finalization state'); } - state.finalizationState = { + finalizationState = { started: false, promise, resolve, reject, }; - return state.finalizationState; + return finalizationState; } function beginFinalization( loader: Promise, ): Promise { - if (state.finalized) { - return Promise.resolve(state.finalized); + if (finalized) { + return Promise.resolve(finalized); } const finalization = getFinalizationState(); if (finalization.started) { @@ -581,23 +574,23 @@ export function prepareSpecializedApp( finalization.resolve(finalizedApp); }) .catch(error => { - state.finalizationState = undefined; + finalizationState = undefined; - if (state.signInRuntime?.requiresSignIn) { + if (signInRuntime?.requiresSignIn) { finalization.reject(error); return; } reportBootstrapFailure(error); - finalization.reject(state.bootstrapError); + finalization.reject(bootstrapError); }); return finalization.promise; } function getBootstrapApp() { - if (state.bootstrapApp) { - return state.bootstrapApp; + if (bootstrapApp) { + return bootstrapApp; } const runtime: SignInRuntime = { @@ -626,15 +619,15 @@ export function prepareSpecializedApp( extensionFactoryMiddleware: mergedExtensionFactoryMiddleware, disableSignIn: Boolean(providedSessionState), registerBootstrapErrorReporter(reporter) { - state.bootstrapErrorReporter = reporter; - if (state.pendingBootstrapError) { - reporter(state.pendingBootstrapError); - state.pendingBootstrapError = undefined; + bootstrapErrorReporter = reporter; + if (pendingBootstrapError) { + reporter(pendingBootstrapError); + pendingBootstrapError = undefined; } return () => { - if (state.bootstrapErrorReporter === reporter) { - state.bootstrapErrorReporter = undefined; + if (bootstrapErrorReporter === reporter) { + bootstrapErrorReporter = undefined; } }; }, @@ -653,10 +646,10 @@ export function prepareSpecializedApp( } runtime.requiresSignIn = result.requiresSignIn; - state.signInRuntime = runtime; - state.bootstrapApp = result.bootstrapApp; + signInRuntime = runtime; + bootstrapApp = result.bootstrapApp; - return state.bootstrapApp; + return bootstrapApp; } return { @@ -666,11 +659,11 @@ export function prepareSpecializedApp( let subscribed = true; - if (state.bootstrapError) { - const bootstrapError = state.bootstrapError; + if (bootstrapError) { + const currentBootstrapError = bootstrapError; Promise.resolve().then(() => { if (subscribed) { - onError?.(bootstrapError); + onError?.(currentBootstrapError); } }); return () => { @@ -678,8 +671,8 @@ export function prepareSpecializedApp( }; } - if (state.finalized) { - const finalizedApp = state.finalized; + if (finalized) { + const finalizedApp = finalized; Promise.resolve().then(() => { if (subscribed) { callback(finalizedApp); @@ -690,7 +683,7 @@ export function prepareSpecializedApp( }; } - const finalizedAppPromise = state.signInRuntime?.requiresSignIn + const finalizedAppPromise = signInRuntime?.requiresSignIn ? getFinalizationState().promise : beginFinalization(getSessionState()); void finalizedAppPromise @@ -710,29 +703,29 @@ export function prepareSpecializedApp( }; }, finalize(finalizeOptions?: { sessionState?: SpecializedAppSessionState }) { - if (state.finalized) { - return state.finalized; + if (finalized) { + return finalized; } - if (state.bootstrapError) { - throw state.bootstrapError; + if (bootstrapError) { + throw bootstrapError; } - if (state.signInRuntime?.error && !state.signInRuntime.requiresSignIn) { - throw state.signInRuntime.error; + if (signInRuntime?.error && !signInRuntime.requiresSignIn) { + throw signInRuntime.error; } - if (!finalizeOptions?.sessionState && !state.cachedSessionState) { + if (!finalizeOptions?.sessionState && !cachedSessionState) { getBootstrapApp(); } const finalizedSessionState = finalizeOptions?.sessionState ?? - state.cachedSessionState ?? - (state.signInRuntime?.requiresSignIn + cachedSessionState ?? + (signInRuntime?.requiresSignIn ? undefined : getImmediateSessionState()); if (!finalizedSessionState) { - if (state.signInRuntime?.requiresSignIn) { + if (signInRuntime?.requiresSignIn) { throw new Error( 'prepareSpecializedApp requires waiting for the bootstrap app to be ready before calling finalize()', ); @@ -742,9 +735,9 @@ export function prepareSpecializedApp( ); } - state.finalized = finalizeFromSessionState(finalizedSessionState); - state.finalizationState?.resolve(state.finalized); - return state.finalized; + finalized = finalizeFromSessionState(finalizedSessionState); + finalizationState?.resolve(finalized); + return finalized; }, }; } From 89fd91eaf8b8a52a17a3b565b8fc57c5a3bba558 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Mar 2026 01:32:29 +0100 Subject: [PATCH 53/68] frontend-app-api: store bootstrap errors for app root Route bootstrap finalization failures through an external store that re-enters React at app/root.children, and simplify prepared app finalization to use a success-only callback. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../frontend-app-api/src/wiring/phaseApis.tsx | 15 +- .../src/wiring/prepareSpecializedApp.tsx | 140 ++++++++---------- .../frontend-defaults/src/createApp.test.tsx | 3 - packages/frontend-defaults/src/createApp.tsx | 12 +- 4 files changed, 65 insertions(+), 105 deletions(-) diff --git a/packages/frontend-app-api/src/wiring/phaseApis.tsx b/packages/frontend-app-api/src/wiring/phaseApis.tsx index 08eb20c17a..b6d38d6e4b 100644 --- a/packages/frontend-app-api/src/wiring/phaseApis.tsx +++ b/packages/frontend-app-api/src/wiring/phaseApis.tsx @@ -260,25 +260,20 @@ export function instantiateAndInitializePhaseTree(options: { routeResolutionApi: RouteResolutionApiProxy; appTreeApi: AppTreeApiProxy; routeRefsById: ReturnType; - stopAtSessionBoundary?: boolean; skipChild?(ctx: { node: AppNode; input: string; child: AppNode }): boolean; onMissingApi?(ctx: { node: AppNode; apiRefId: string }): void; predicateContext?: ExtensionPredicateContext; stopAtAttachment?(ctx: { node: AppNode; input: string }): boolean; }) { - let stopAtAttachment = options.stopAtAttachment; - if (options.stopAtSessionBoundary) { - stopAtAttachment = ({ node, input }) => - isSessionBoundaryAttachment(node, input); - } - instantiateAppNodeTree( options.tree.root, options.apis, options.collector, options.extensionFactoryMiddleware, { - ...(stopAtAttachment ? { stopAtAttachment } : {}), + ...(options.stopAtAttachment + ? { stopAtAttachment: options.stopAtAttachment } + : {}), skipChild: options.skipChild, onMissingApi: options.onMissingApi, predicateContext: options.predicateContext, @@ -306,7 +301,3 @@ export function setIdentityApiTarget(options: { signOutTargetUrl: options.signOutTargetUrl, }); } - -function isSessionBoundaryAttachment(node: AppNode, input: string) { - return node.spec.id === 'app/root' && input === 'children'; -} diff --git a/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx b/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx index a1a3ff02e2..69731d00ab 100644 --- a/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx +++ b/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx @@ -38,7 +38,7 @@ import { OpaqueFrontendPlugin, } from '@internal/frontend'; import { OpaqueType } from '@internal/opaque'; -import { ComponentType, ReactNode, useLayoutEffect, useState } from 'react'; +import { ComponentType, ReactNode, useSyncExternalStore } from 'react'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { @@ -150,6 +150,12 @@ type FinalizationState = { reject(error: unknown): void; }; +type BootstrapErrorStore = { + getSnapshot(): Error | undefined; + subscribe(listener: () => void): () => void; + report(error: Error): void; +}; + type InternalSpecializedAppSessionState = { apis: ApiHolder; identityApi?: IdentityApi; @@ -244,10 +250,7 @@ export type PrepareSpecializedAppOptions = { */ export type PreparedSpecializedApp = { getBootstrapApp(): BootstrapSpecializedApp; - onFinalized( - callback: (app: FinalizedSpecializedApp) => void, - onError?: (error: Error) => void, - ): () => void; + onFinalized(callback: (app: FinalizedSpecializedApp) => void): () => void; finalize(options?: { sessionState?: SpecializedAppSessionState; }): FinalizedSpecializedApp; @@ -369,6 +372,7 @@ export function prepareSpecializedApp( apis: phase.apis, predicateReferences, }); + const bootstrapErrorStore = createBootstrapErrorStore(); let signInRuntime: SignInRuntime | undefined; let cachedSessionState = providedSessionState; let sessionStatePromise: Promise | undefined; @@ -376,8 +380,6 @@ export function prepareSpecializedApp( let bootstrapApp: BootstrapSpecializedApp | undefined; let bootstrapError: Error | undefined; let finalizationState: FinalizationState | undefined; - let bootstrapErrorReporter: ((error: Error) => void) | undefined; - let pendingBootstrapError: Error | undefined; function updateIdentityApiTarget(identityApi?: IdentityApi) { if (!identityApi) { @@ -524,12 +526,7 @@ export function prepareSpecializedApp( function reportBootstrapFailure(error: unknown) { const bootstrapFailure = asError(error); bootstrapError = bootstrapFailure; - if (bootstrapErrorReporter) { - bootstrapErrorReporter(bootstrapFailure); - return; - } - - pendingBootstrapError = bootstrapFailure; + bootstrapErrorStore.report(bootstrapFailure); } function getFinalizationState(): FinalizationState { @@ -576,13 +573,8 @@ export function prepareSpecializedApp( .catch(error => { finalizationState = undefined; - if (signInRuntime?.requiresSignIn) { - finalization.reject(error); - return; - } - reportBootstrapFailure(error); - finalization.reject(bootstrapError); + finalization.reject(bootstrapError ?? asError(error)); }); return finalization.promise; @@ -618,19 +610,7 @@ export function prepareSpecializedApp( appTreeApi: phase.appTreeApi, extensionFactoryMiddleware: mergedExtensionFactoryMiddleware, disableSignIn: Boolean(providedSessionState), - registerBootstrapErrorReporter(reporter) { - bootstrapErrorReporter = reporter; - if (pendingBootstrapError) { - reporter(pendingBootstrapError); - pendingBootstrapError = undefined; - } - - return () => { - if (bootstrapErrorReporter === reporter) { - bootstrapErrorReporter = undefined; - } - }; - }, + bootstrapErrorStore, skipBootstrapChild({ child }) { return bootstrapClassification.deferredRoots.has(child); }, @@ -654,18 +634,12 @@ export function prepareSpecializedApp( return { getBootstrapApp, - onFinalized(callback, onError) { + onFinalized(callback) { getBootstrapApp(); let subscribed = true; if (bootstrapError) { - const currentBootstrapError = bootstrapError; - Promise.resolve().then(() => { - if (subscribed) { - onError?.(currentBootstrapError); - } - }); return () => { subscribed = false; }; @@ -692,11 +666,7 @@ export function prepareSpecializedApp( callback(finalizedApp); } }) - .catch(error => { - if (subscribed) { - onError?.(asError(error)); - } - }); + .catch(() => {}); return () => { subscribed = false; @@ -819,7 +789,7 @@ function createBootstrapApp(options: { appTreeApi: AppTreeApiProxy; extensionFactoryMiddleware?: ExtensionFactoryMiddleware; disableSignIn?: boolean; - registerBootstrapErrorReporter(reporter: (error: Error) => void): () => void; + bootstrapErrorStore: BootstrapErrorStore; skipBootstrapChild?(ctx: { node: AppNode; input: string; @@ -830,6 +800,11 @@ function createBootstrapApp(options: { bootstrapApp: BootstrapSpecializedApp; requiresSignIn: boolean; } { + prepareBootstrapErrorThrower({ + tree: options.tree, + store: options.bootstrapErrorStore, + }); + const signInPageNode = getAppRootNode(options.tree)?.edges.attachments.get( 'signInPage', )?.[0]; @@ -842,14 +817,9 @@ function createBootstrapApp(options: { routeResolutionApi: options.routeResolutionApi, appTreeApi: options.appTreeApi, routeRefsById: options.routeRefsById, - stopAtSessionBoundary: true, skipChild: options.skipBootstrapChild, onMissingApi: options.onMissingApi, }); - prepareBootstrapErrorBoundary({ - tree: options.tree, - registerBootstrapErrorReporter: options.registerBootstrapErrorReporter, - }); const element = options.tree.root.instance?.getData( coreExtensionData.reactElement, @@ -876,38 +846,34 @@ function prepareFinalizedTree(options: { tree: AppTree }) { } } -function prepareBootstrapErrorBoundary(options: { +function prepareBootstrapErrorThrower(options: { tree: AppTree; - registerBootstrapErrorReporter(reporter: (error: Error) => void): () => void; + store: BootstrapErrorStore; }) { - const rootNode = options.tree.root; - const rootInstance = rootNode.instance; - if (!rootInstance) { + const bootstrapChildNode = getAppRootNode( + options.tree, + )?.edges.attachments.get('children')?.[0]; + if (!bootstrapChildNode) { return; } - const rootElement = rootInstance.getData(coreExtensionData.reactElement); - if (!rootElement) { - return; - } - - function PreparedBootstrapRoot() { - const [bootstrapError, setBootstrapError] = useState(); - - useLayoutEffect(() => { - return options.registerBootstrapErrorReporter(setBootstrapError); - }, []); + function BootstrapErrorThrower() { + const bootstrapError = useSyncExternalStore( + options.store.subscribe, + options.store.getSnapshot, + options.store.getSnapshot, + ); if (bootstrapError) { throw bootstrapError; } - return rootElement; + return null; } setNodeInstance( - rootNode, - createReactElementOverrideInstance(rootInstance, ), + bootstrapChildNode, + createReactElementInstance(), ); } @@ -959,23 +925,39 @@ function isSessionBoundaryAttachment(node: AppNode, input: string) { return node.spec.id === 'app/root' && input === 'children'; } -function createReactElementOverrideInstance( - instance: AppNodeInstance, - value: ReactNode, -): AppNodeInstance { +function createReactElementInstance(value: ReactNode): AppNodeInstance { return { getDataRefs() { - const refs = Array.from(instance.getDataRefs()); - if (!refs.some(ref => ref.id === coreExtensionData.reactElement.id)) { - refs.push(coreExtensionData.reactElement); - } - return refs[Symbol.iterator](); + return [coreExtensionData.reactElement].values(); }, getData(dataRef: ExtensionDataRef) { if (dataRef.id === coreExtensionData.reactElement.id) { return value as TValue; } - return instance.getData(dataRef); + return undefined; + }, + }; +} + +function createBootstrapErrorStore(): BootstrapErrorStore { + let snapshot: Error | undefined; + const listeners = new Set<() => void>(); + + return { + getSnapshot() { + return snapshot; + }, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + report(error) { + snapshot = error; + for (const listener of listeners) { + listener(); + } }, }; } diff --git a/packages/frontend-defaults/src/createApp.test.tsx b/packages/frontend-defaults/src/createApp.test.tsx index 4cc9e367f8..512475c711 100644 --- a/packages/frontend-defaults/src/createApp.test.tsx +++ b/packages/frontend-defaults/src/createApp.test.tsx @@ -291,9 +291,6 @@ describe('createApp', () => { triggerSignInSuccess(identityApi); }); - await expect( - screen.findByText(/Error in app/), - ).resolves.toBeInTheDocument(); await expect( screen.findByText('sign-in bootstrap failed'), ).resolves.toBeInTheDocument(); diff --git a/packages/frontend-defaults/src/createApp.tsx b/packages/frontend-defaults/src/createApp.tsx index 2dd75031c2..728ec29e42 100644 --- a/packages/frontend-defaults/src/createApp.tsx +++ b/packages/frontend-defaults/src/createApp.tsx @@ -152,22 +152,12 @@ function PreparedAppRoot(props: { const [finalizedApp, setFinalizedApp] = useState< FinalizedSpecializedApp | undefined >(); - const [bootstrapError, setBootstrapError] = useState(); useEffect( - () => props.preparedApp.onFinalized(setFinalizedApp, setBootstrapError), + () => props.preparedApp.onFinalized(setFinalizedApp), [props.preparedApp], ); - if (bootstrapError) { - return ( - <> -
{bootstrapError.message}
-

Error in app

- - ); - } - if (!finalizedApp) { return bootstrapApp.element; } From 217de172dcf7a7ae34eb056af830fa561bd7c0b8 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Mar 2026 10:14:38 +0100 Subject: [PATCH 54/68] frontend-app-api: finalize bootstrap failures through app root Resolve async finalization failures by building a finalized app that throws from app/root.children, and simplify the identity proxy target callback to a synchronous fire-and-forget hook. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../frontend-app-api/src/wiring/phaseApis.tsx | 20 +-- .../src/wiring/prepareSpecializedApp.tsx | 154 ++++++++---------- 2 files changed, 71 insertions(+), 103 deletions(-) diff --git a/packages/frontend-app-api/src/wiring/phaseApis.tsx b/packages/frontend-app-api/src/wiring/phaseApis.tsx index b6d38d6e4b..781d4c5c1c 100644 --- a/packages/frontend-app-api/src/wiring/phaseApis.tsx +++ b/packages/frontend-app-api/src/wiring/phaseApis.tsx @@ -166,25 +166,19 @@ export class RouteResolutionApiProxy implements RouteResolutionApi { export class PreparedAppIdentityProxy extends AppIdentityProxy { #onTargetSet?: - | (( - identityApi: Parameters[0], - ) => Promise | void) + | ((identityApi: Parameters[0]) => void) | undefined; - #onTargetError?: ((error: unknown) => void) | undefined; setTargetHandlers(options: { onTargetSet?( identityApi: Parameters[0], - ): Promise | void; - onTargetError?(error: unknown): void; + ): void; }) { this.#onTargetSet = options.onTargetSet; - this.#onTargetError = options.onTargetError; } clearTargetHandlers() { this.#onTargetSet = undefined; - this.#onTargetError = undefined; } override setTarget( @@ -198,16 +192,8 @@ export class PreparedAppIdentityProxy extends AppIdentityProxy { return; } - const onTargetError = this.#onTargetError; this.clearTargetHandlers(); - - try { - void Promise.resolve(onTargetSet(identityApi)).catch(error => { - onTargetError?.(error); - }); - } catch (error) { - onTargetError?.(error); - } + onTargetSet(identityApi); } } diff --git a/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx b/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx index 69731d00ab..1a10d3e184 100644 --- a/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx +++ b/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx @@ -38,7 +38,7 @@ import { OpaqueFrontendPlugin, } from '@internal/frontend'; import { OpaqueType } from '@internal/opaque'; -import { ComponentType, ReactNode, useSyncExternalStore } from 'react'; +import { ComponentType, ReactNode } from 'react'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { @@ -150,12 +150,6 @@ type FinalizationState = { reject(error: unknown): void; }; -type BootstrapErrorStore = { - getSnapshot(): Error | undefined; - subscribe(listener: () => void): () => void; - report(error: Error): void; -}; - type InternalSpecializedAppSessionState = { apis: ApiHolder; identityApi?: IdentityApi; @@ -372,7 +366,6 @@ export function prepareSpecializedApp( apis: phase.apis, predicateReferences, }); - const bootstrapErrorStore = createBootstrapErrorStore(); let signInRuntime: SignInRuntime | undefined; let cachedSessionState = providedSessionState; let sessionStatePromise: Promise | undefined; @@ -523,10 +516,49 @@ export function prepareSpecializedApp( return finalizedApp; } - function reportBootstrapFailure(error: unknown) { - const bootstrapFailure = asError(error); - bootstrapError = bootstrapFailure; - bootstrapErrorStore.report(bootstrapFailure); + function finalizeFromBootstrapError(error: Error): FinalizedSpecializedApp { + if (finalized) { + return finalized; + } + + bootstrapError = error; + const finalizedSessionState = + cachedSessionState ?? + OpaqueSpecializedAppSessionState.createInstance('v1', { + apis: phase.apis, + identityApi: + signInRuntime?.readyIdentityApi ?? providedSessionData?.identityApi, + predicateContext: EMPTY_PREDICATE_CONTEXT, + }); + cachedSessionState = finalizedSessionState; + + prepareFinalizedTree({ + tree, + }); + clearFinalizationBoundaryInstances(tree); + attachThrowingFinalizationChild(tree, error); + instantiateAndInitializePhaseTree({ + tree, + apis: phase.apis, + collector, + extensionFactoryMiddleware: mergedExtensionFactoryMiddleware, + routeResolutionApi: phase.routeResolutionApi, + appTreeApi: phase.appTreeApi, + routeRefsById, + }); + + const element = tree.root.instance?.getData(coreExtensionData.reactElement); + if (!element) { + throw new Error('Expected finalized app tree to expose a root element'); + } + + const finalizedApp: FinalizedSpecializedApp = { + element, + sessionState: finalizedSessionState, + tree, + }; + finalized = finalizedApp; + return finalizedApp; } function getFinalizationState(): FinalizationState { @@ -571,10 +603,13 @@ export function prepareSpecializedApp( finalization.resolve(finalizedApp); }) .catch(error => { - finalizationState = undefined; - - reportBootstrapFailure(error); - finalization.reject(bootstrapError ?? asError(error)); + try { + const finalizedApp = finalizeFromBootstrapError(asError(error)); + finalization.resolve(finalizedApp); + } catch (finalizationError) { + finalizationState = undefined; + finalization.reject(finalizationError); + } }); return finalization.promise; @@ -591,12 +626,7 @@ export function prepareSpecializedApp( if (!providedSessionState) { phase.identityApiProxy.setTargetHandlers({ onTargetSet(identityApi) { - return beginFinalization( - startSignInFinalize(runtime, identityApi), - ).then(() => {}); - }, - onTargetError(error) { - reportBootstrapFailure(error); + beginFinalization(startSignInFinalize(runtime, identityApi)); }, }); } @@ -610,7 +640,6 @@ export function prepareSpecializedApp( appTreeApi: phase.appTreeApi, extensionFactoryMiddleware: mergedExtensionFactoryMiddleware, disableSignIn: Boolean(providedSessionState), - bootstrapErrorStore, skipBootstrapChild({ child }) { return bootstrapClassification.deferredRoots.has(child); }, @@ -639,12 +668,6 @@ export function prepareSpecializedApp( let subscribed = true; - if (bootstrapError) { - return () => { - subscribed = false; - }; - } - if (finalized) { const finalizedApp = finalized; Promise.resolve().then(() => { @@ -789,7 +812,6 @@ function createBootstrapApp(options: { appTreeApi: AppTreeApiProxy; extensionFactoryMiddleware?: ExtensionFactoryMiddleware; disableSignIn?: boolean; - bootstrapErrorStore: BootstrapErrorStore; skipBootstrapChild?(ctx: { node: AppNode; input: string; @@ -800,11 +822,6 @@ function createBootstrapApp(options: { bootstrapApp: BootstrapSpecializedApp; requiresSignIn: boolean; } { - prepareBootstrapErrorThrower({ - tree: options.tree, - store: options.bootstrapErrorStore, - }); - const signInPageNode = getAppRootNode(options.tree)?.edges.attachments.get( 'signInPage', )?.[0]; @@ -817,6 +834,8 @@ function createBootstrapApp(options: { routeResolutionApi: options.routeResolutionApi, appTreeApi: options.appTreeApi, routeRefsById: options.routeRefsById, + stopAtAttachment: ({ node, input }) => + isSessionBoundaryAttachment(node, input), skipChild: options.skipBootstrapChild, onMissingApi: options.onMissingApi, }); @@ -846,37 +865,6 @@ function prepareFinalizedTree(options: { tree: AppTree }) { } } -function prepareBootstrapErrorThrower(options: { - tree: AppTree; - store: BootstrapErrorStore; -}) { - const bootstrapChildNode = getAppRootNode( - options.tree, - )?.edges.attachments.get('children')?.[0]; - if (!bootstrapChildNode) { - return; - } - - function BootstrapErrorThrower() { - const bootstrapError = useSyncExternalStore( - options.store.subscribe, - options.store.getSnapshot, - options.store.getSnapshot, - ); - - if (bootstrapError) { - throw bootstrapError; - } - - return null; - } - - setNodeInstance( - bootstrapChildNode, - createReactElementInstance(), - ); -} - function clearFinalizationBoundaryInstances(tree: AppTree) { clearNodeInstance(tree.root); @@ -939,27 +927,21 @@ function createReactElementInstance(value: ReactNode): AppNodeInstance { }; } -function createBootstrapErrorStore(): BootstrapErrorStore { - let snapshot: Error | undefined; - const listeners = new Set<() => void>(); +function attachThrowingFinalizationChild(tree: AppTree, error: Error) { + const bootstrapChildNode = + getAppRootNode(tree)?.edges.attachments.get('children')?.[0]; + if (!bootstrapChildNode) { + throw error; + } - return { - getSnapshot() { - return snapshot; - }, - subscribe(listener) { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; - }, - report(error) { - snapshot = error; - for (const listener of listeners) { - listener(); - } - }, - }; + function ThrowBootstrapError(): never { + throw error; + } + + setNodeInstance( + bootstrapChildNode, + createReactElementInstance(), + ); } function setNodeInstance(node: AppNode, instance?: AppNodeInstance) { From 0ce78c110ca234d2ea82b1105d58b5a56f9cfc21 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Mar 2026 10:29:37 +0100 Subject: [PATCH 55/68] frontend-app-api: simplify prepare app finalization flow Keep the sign-in finalization path local to the identity callback and use safer error normalization while preserving the finalized bootstrap error flow. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../src/wiring/prepareSpecializedApp.tsx | 71 +++++++------------ 1 file changed, 25 insertions(+), 46 deletions(-) diff --git a/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx b/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx index 1a10d3e184..59cbaf3e48 100644 --- a/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx +++ b/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx @@ -15,6 +15,7 @@ */ import { ConfigReader } from '@backstage/config'; +import { isError } from '@backstage/errors'; import { ApiBlueprint, AnyApiFactory, @@ -450,17 +451,6 @@ export function prepareSpecializedApp( return sessionStatePromise; } - function startSignInFinalize( - runtime: SignInRuntime, - identityApi: IdentityApi, - ): Promise { - runtime.readyIdentityApi = identityApi; - return getSessionState().catch(error => { - runtime.error = error; - throw error; - }); - } - function finalizeFromSessionState( finalizedSessionState: SpecializedAppSessionState, ): FinalizedSpecializedApp { @@ -597,14 +587,16 @@ export function prepareSpecializedApp( } finalization.started = true; - void loader + loader .then(sessionState => { const finalizedApp = finalizeFromSessionState(sessionState); finalization.resolve(finalizedApp); }) .catch(error => { try { - const finalizedApp = finalizeFromBootstrapError(asError(error)); + const finalizedApp = finalizeFromBootstrapError( + isError(error) ? error : new Error(String(error)), + ); finalization.resolve(finalizedApp); } catch (finalizationError) { finalizationState = undefined; @@ -626,7 +618,13 @@ export function prepareSpecializedApp( if (!providedSessionState) { phase.identityApiProxy.setTargetHandlers({ onTargetSet(identityApi) { - beginFinalization(startSignInFinalize(runtime, identityApi)); + runtime.readyIdentityApi = identityApi; + beginFinalization( + getSessionState().catch(error => { + runtime.error = error; + throw error; + }), + ); }, }); } @@ -683,7 +681,7 @@ export function prepareSpecializedApp( const finalizedAppPromise = signInRuntime?.requiresSignIn ? getFinalizationState().promise : beginFinalization(getSessionState()); - void finalizedAppPromise + finalizedAppPromise .then(finalizedApp => { if (subscribed) { callback(finalizedApp); @@ -913,20 +911,6 @@ function isSessionBoundaryAttachment(node: AppNode, input: string) { return node.spec.id === 'app/root' && input === 'children'; } -function createReactElementInstance(value: ReactNode): AppNodeInstance { - return { - getDataRefs() { - return [coreExtensionData.reactElement].values(); - }, - getData(dataRef: ExtensionDataRef) { - if (dataRef.id === coreExtensionData.reactElement.id) { - return value as TValue; - } - return undefined; - }, - }; -} - function attachThrowingFinalizationChild(tree: AppTree, error: Error) { const bootstrapChildNode = getAppRootNode(tree)?.edges.attachments.get('children')?.[0]; @@ -938,26 +922,21 @@ function attachThrowingFinalizationChild(tree: AppTree, error: Error) { throw error; } - setNodeInstance( - bootstrapChildNode, - createReactElementInstance(), - ); -} - -function setNodeInstance(node: AppNode, instance?: AppNodeInstance) { - (node as AppNode & { instance?: AppNodeInstance }).instance = instance; + (bootstrapChildNode as AppNode & { instance?: AppNodeInstance }).instance = { + getDataRefs() { + return [coreExtensionData.reactElement].values(); + }, + getData(dataRef: ExtensionDataRef) { + if (dataRef.id === coreExtensionData.reactElement.id) { + return () as TValue; + } + return undefined; + }, + }; } function clearNodeInstance(node: AppNode) { - setNodeInstance(node, undefined); -} - -function asError(error: unknown): Error { - if (error instanceof Error) { - return error; - } - - return new Error(String(error)); + (node as AppNode & { instance?: AppNodeInstance }).instance = undefined; } const EMPTY_API_HOLDER: ApiHolder = { From a5017f79510941153d3d9087d972da0156888b9c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Mar 2026 10:34:54 +0100 Subject: [PATCH 56/68] frontend-app-api: extract prepare app finalizers Move the session-state and bootstrap-error finalization flows into same-file helpers with explicit inputs so the local state updates remain visible at the call sites. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../src/wiring/prepareSpecializedApp.tsx | 306 ++++++++++++------ 1 file changed, 201 insertions(+), 105 deletions(-) diff --git a/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx b/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx index 59cbaf3e48..0e0027e200 100644 --- a/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx +++ b/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx @@ -451,106 +451,6 @@ export function prepareSpecializedApp( return sessionStatePromise; } - function finalizeFromSessionState( - finalizedSessionState: SpecializedAppSessionState, - ): FinalizedSpecializedApp { - if (finalized) { - return finalized; - } - - cachedSessionState = finalizedSessionState; - const sessionStateData = OpaqueSpecializedAppSessionState.toInternal( - finalizedSessionState, - ); - updateIdentityApiTarget(sessionStateData.identityApi); - if (!providedApis) { - syncFinalApiFactories({ - deferredApiNodes: bootstrapClassification.deferredApiRoots, - appApiRegistry, - apiResolver: phase.apis, - collector, - features, - bootstrapApiFactoryEntries, - bootstrapMissingApiAccesses, - predicateContext: sessionStateData.predicateContext, - }); - } - - prepareFinalizedTree({ - tree, - }); - clearFinalizationBoundaryInstances(tree); - instantiateAndInitializePhaseTree({ - tree, - apis: phase.apis, - collector, - extensionFactoryMiddleware: mergedExtensionFactoryMiddleware, - routeResolutionApi: phase.routeResolutionApi, - appTreeApi: phase.appTreeApi, - routeRefsById, - predicateContext: sessionStateData.predicateContext, - }); - - const element = tree.root.instance?.getData(coreExtensionData.reactElement); - if (!element) { - throw new Error('Expected finalized app tree to expose a root element'); - } - - const finalizedApp: FinalizedSpecializedApp = { - element, - sessionState: finalizedSessionState, - tree, - errors: collector.collectErrors(), - }; - finalized = finalizedApp; - return finalizedApp; - } - - function finalizeFromBootstrapError(error: Error): FinalizedSpecializedApp { - if (finalized) { - return finalized; - } - - bootstrapError = error; - const finalizedSessionState = - cachedSessionState ?? - OpaqueSpecializedAppSessionState.createInstance('v1', { - apis: phase.apis, - identityApi: - signInRuntime?.readyIdentityApi ?? providedSessionData?.identityApi, - predicateContext: EMPTY_PREDICATE_CONTEXT, - }); - cachedSessionState = finalizedSessionState; - - prepareFinalizedTree({ - tree, - }); - clearFinalizationBoundaryInstances(tree); - attachThrowingFinalizationChild(tree, error); - instantiateAndInitializePhaseTree({ - tree, - apis: phase.apis, - collector, - extensionFactoryMiddleware: mergedExtensionFactoryMiddleware, - routeResolutionApi: phase.routeResolutionApi, - appTreeApi: phase.appTreeApi, - routeRefsById, - }); - - const element = tree.root.instance?.getData(coreExtensionData.reactElement); - if (!element) { - throw new Error('Expected finalized app tree to expose a root element'); - } - - const finalizedApp: FinalizedSpecializedApp = { - element, - sessionState: finalizedSessionState, - tree, - }; - finalized = finalizedApp; - return finalizedApp; - } - function getFinalizationState(): FinalizationState { if (finalizationState) { return finalizationState; @@ -589,14 +489,48 @@ export function prepareSpecializedApp( loader .then(sessionState => { - const finalizedApp = finalizeFromSessionState(sessionState); + const result = finalizeFromSessionState({ + finalized, + finalizedSessionState: sessionState, + tree, + collector, + phase, + extensionFactoryMiddleware: mergedExtensionFactoryMiddleware, + routeRefsById, + appBasePath, + providedApis, + features, + appApiRegistry, + bootstrapClassification, + bootstrapApiFactoryEntries, + bootstrapMissingApiAccesses, + }); + cachedSessionState = result.cachedSessionState; + finalized = result.finalizedApp; + const finalizedApp = result.finalizedApp; finalization.resolve(finalizedApp); }) .catch(error => { try { - const finalizedApp = finalizeFromBootstrapError( - isError(error) ? error : new Error(String(error)), - ); + const bootstrapFailure = isError(error) + ? error + : new Error(String(error)); + bootstrapError = bootstrapFailure; + const result = finalizeFromBootstrapError({ + finalized, + error: bootstrapFailure, + cachedSessionState, + tree, + collector, + phase, + extensionFactoryMiddleware: mergedExtensionFactoryMiddleware, + routeRefsById, + signInRuntime, + providedSessionData, + }); + cachedSessionState = result.cachedSessionState; + finalized = result.finalizedApp; + const finalizedApp = result.finalizedApp; finalization.resolve(finalizedApp); } catch (finalizationError) { finalizationState = undefined; @@ -726,7 +660,24 @@ export function prepareSpecializedApp( ); } - finalized = finalizeFromSessionState(finalizedSessionState); + const result = finalizeFromSessionState({ + finalized, + finalizedSessionState, + tree, + collector, + phase, + extensionFactoryMiddleware: mergedExtensionFactoryMiddleware, + routeRefsById, + appBasePath, + providedApis, + features, + appApiRegistry, + bootstrapClassification, + bootstrapApiFactoryEntries, + bootstrapMissingApiAccesses, + }); + cachedSessionState = result.cachedSessionState; + finalized = result.finalizedApp; finalizationState?.resolve(finalized); return finalized; }, @@ -801,6 +752,151 @@ type BootstrapClassification = { deferredRoots: Set; }; +type FinalizationResult = { + finalizedApp: FinalizedSpecializedApp; + cachedSessionState: SpecializedAppSessionState; +}; + +function finalizeFromSessionState(options: { + finalized?: FinalizedSpecializedApp; + finalizedSessionState: SpecializedAppSessionState; + tree: AppTree; + collector: ErrorCollector; + phase: ReturnType; + extensionFactoryMiddleware?: ExtensionFactoryMiddleware; + routeRefsById: ReturnType; + appBasePath: string; + providedApis?: ApiHolder; + features: FrontendFeature[]; + appApiRegistry: FrontendApiRegistry; + bootstrapClassification: BootstrapClassification; + bootstrapApiFactoryEntries: Map; + bootstrapMissingApiAccesses: Map; +}): FinalizationResult { + if (options.finalized) { + return { + finalizedApp: options.finalized, + cachedSessionState: options.finalized.sessionState, + }; + } + + const sessionStateData = OpaqueSpecializedAppSessionState.toInternal( + options.finalizedSessionState, + ); + if (sessionStateData.identityApi) { + setIdentityApiTarget({ + identityApiProxy: options.phase.identityApiProxy, + identityApi: sessionStateData.identityApi, + signOutTargetUrl: options.appBasePath || '/', + }); + } + if (!options.providedApis) { + syncFinalApiFactories({ + deferredApiNodes: options.bootstrapClassification.deferredApiRoots, + appApiRegistry: options.appApiRegistry, + apiResolver: options.phase.apis, + collector: options.collector, + features: options.features, + bootstrapApiFactoryEntries: options.bootstrapApiFactoryEntries, + bootstrapMissingApiAccesses: options.bootstrapMissingApiAccesses, + predicateContext: sessionStateData.predicateContext, + }); + } + + prepareFinalizedTree({ + tree: options.tree, + }); + clearFinalizationBoundaryInstances(options.tree); + instantiateAndInitializePhaseTree({ + tree: options.tree, + apis: options.phase.apis, + collector: options.collector, + extensionFactoryMiddleware: options.extensionFactoryMiddleware, + routeResolutionApi: options.phase.routeResolutionApi, + appTreeApi: options.phase.appTreeApi, + routeRefsById: options.routeRefsById, + predicateContext: sessionStateData.predicateContext, + }); + + const element = options.tree.root.instance?.getData( + coreExtensionData.reactElement, + ); + if (!element) { + throw new Error('Expected finalized app tree to expose a root element'); + } + + return { + finalizedApp: { + element, + sessionState: options.finalizedSessionState, + tree: options.tree, + errors: options.collector.collectErrors(), + }, + cachedSessionState: options.finalizedSessionState, + }; +} + +function finalizeFromBootstrapError(options: { + finalized?: FinalizedSpecializedApp; + error: Error; + cachedSessionState?: SpecializedAppSessionState; + tree: AppTree; + collector: ErrorCollector; + phase: ReturnType; + extensionFactoryMiddleware?: ExtensionFactoryMiddleware; + routeRefsById: ReturnType; + signInRuntime?: SignInRuntime; + providedSessionData?: InternalSpecializedAppSessionState; +}): FinalizationResult { + if (options.finalized) { + return { + finalizedApp: options.finalized, + cachedSessionState: options.finalized.sessionState, + }; + } + + const cachedSessionState = + options.cachedSessionState ?? + OpaqueSpecializedAppSessionState.createInstance('v1', { + apis: options.phase.apis, + identityApi: + options.signInRuntime?.readyIdentityApi ?? + options.providedSessionData?.identityApi, + predicateContext: EMPTY_PREDICATE_CONTEXT, + }); + + prepareFinalizedTree({ + tree: options.tree, + }); + clearFinalizationBoundaryInstances(options.tree); + attachThrowingFinalizationChild(options.tree, options.error); + instantiateAndInitializePhaseTree({ + tree: options.tree, + apis: options.phase.apis, + collector: options.collector, + extensionFactoryMiddleware: options.extensionFactoryMiddleware, + routeResolutionApi: options.phase.routeResolutionApi, + appTreeApi: options.phase.appTreeApi, + routeRefsById: options.routeRefsById, + }); + + const element = options.tree.root.instance?.getData( + coreExtensionData.reactElement, + ); + if (!element) { + throw new Error('Expected finalized app tree to expose a root element'); + } + + return { + finalizedApp: { + element, + sessionState: cachedSessionState, + tree: options.tree, + }, + cachedSessionState, + }; +} + function createBootstrapApp(options: { tree: AppTree; apis: ApiHolder; From 4a1a3496f248bb6bbfd4bdfa958d8a33b452f3ea Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Mar 2026 10:45:23 +0100 Subject: [PATCH 57/68] frontend-app-api: separate prepared app finalization modes Make prepared apps choose either onFinalized or finalize so the async and direct finalization flows can no longer be mixed on the same instance. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../src/wiring/createSpecializedApp.test.tsx | 26 +++++++++++++++++++ .../src/wiring/prepareSpecializedApp.tsx | 15 +++++++++++ 2 files changed, 41 insertions(+) diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx index cd1356d4cb..3d5cedbf30 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx @@ -941,6 +941,32 @@ describe('createSpecializedApp', () => { ); }); + 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'), diff --git a/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx b/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx index 0e0027e200..50a116f80a 100644 --- a/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx +++ b/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx @@ -151,6 +151,8 @@ type FinalizationState = { reject(error: unknown): void; }; +type FinalizationMode = 'onFinalized' | 'finalize'; + type InternalSpecializedAppSessionState = { apis: ApiHolder; identityApi?: IdentityApi; @@ -374,6 +376,7 @@ export function prepareSpecializedApp( let bootstrapApp: BootstrapSpecializedApp | undefined; let bootstrapError: Error | undefined; let finalizationState: FinalizationState | undefined; + let finalizationMode: FinalizationMode | undefined; function updateIdentityApiTarget(identityApi?: IdentityApi) { if (!identityApi) { @@ -541,6 +544,16 @@ export function prepareSpecializedApp( return finalization.promise; } + function selectFinalizationMode(mode: FinalizationMode) { + if (finalizationMode && finalizationMode !== mode) { + throw new Error( + `prepareSpecializedApp only supports using either onFinalized() or finalize(), not both`, + ); + } + + finalizationMode = mode; + } + function getBootstrapApp() { if (bootstrapApp) { return bootstrapApp; @@ -596,6 +609,7 @@ export function prepareSpecializedApp( return { getBootstrapApp, onFinalized(callback) { + selectFinalizationMode('onFinalized'); getBootstrapApp(); let subscribed = true; @@ -628,6 +642,7 @@ export function prepareSpecializedApp( }; }, finalize(finalizeOptions?: { sessionState?: SpecializedAppSessionState }) { + selectFinalizationMode('finalize'); if (finalized) { return finalized; } From 65805c81179883f8004da6d703418c8d5a931cb9 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Mar 2026 11:05:04 +0100 Subject: [PATCH 58/68] frontend-app-api: split prepared app session loading Separate direct and callback-driven finalization by keeping async session loading inside onFinalized and simplifying the shared finalization helpers. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../src/wiring/prepareSpecializedApp.tsx | 139 ++++++------------ 1 file changed, 44 insertions(+), 95 deletions(-) diff --git a/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx b/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx index 50a116f80a..fd8a10bb58 100644 --- a/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx +++ b/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx @@ -139,7 +139,6 @@ export type FinalizedSpecializedApp = { }; type SignInRuntime = { - error?: unknown; readyIdentityApi?: IdentityApi; requiresSignIn: boolean; }; @@ -370,11 +369,8 @@ export function prepareSpecializedApp( predicateReferences, }); let signInRuntime: SignInRuntime | undefined; - let cachedSessionState = providedSessionState; - let sessionStatePromise: Promise | undefined; let finalized: FinalizedSpecializedApp | undefined; let bootstrapApp: BootstrapSpecializedApp | undefined; - let bootstrapError: Error | undefined; let finalizationState: FinalizationState | undefined; let finalizationMode: FinalizationMode | undefined; @@ -399,13 +395,12 @@ export function prepareSpecializedApp( identityApi, predicateContext, }); - cachedSessionState = sessionState; return sessionState; } function getImmediateSessionState() { - if (cachedSessionState) { - return cachedSessionState; + if (providedSessionState) { + return providedSessionState; } if (signInRuntime?.requiresSignIn) { return undefined; @@ -419,16 +414,9 @@ export function prepareSpecializedApp( return createSessionState(predicateContext); } - function getSessionState() { - const immediateSessionState = getImmediateSessionState(); - if (immediateSessionState) { - return Promise.resolve(immediateSessionState); - } - if (sessionStatePromise) { - return sessionStatePromise; - } - if (signInRuntime?.error) { - return Promise.reject(signInRuntime.error); + function loadSessionState() { + if (providedSessionState) { + return Promise.resolve(providedSessionState); } if (signInRuntime?.requiresSignIn && !signInRuntime.readyIdentityApi) { return Promise.reject( @@ -438,20 +426,14 @@ export function prepareSpecializedApp( ); } - sessionStatePromise = predicateContextLoader - .load() - .then(predicateContext => { - if (cachedSessionState) { - return cachedSessionState; - } - return createSessionState(predicateContext); - }) - .catch(error => { - sessionStatePromise = undefined; - throw error; - }); + if (!signInRuntime?.requiresSignIn) { + const immediateSessionState = getImmediateSessionState(); + if (immediateSessionState) { + return Promise.resolve(immediateSessionState); + } + } - return sessionStatePromise; + return predicateContextLoader.load().then(createSessionState); } function getFinalizationState(): FinalizationState { @@ -479,7 +461,7 @@ export function prepareSpecializedApp( } function beginFinalization( - loader: Promise, + loader: () => Promise, ): Promise { if (finalized) { return Promise.resolve(finalized); @@ -490,9 +472,11 @@ export function prepareSpecializedApp( } finalization.started = true; - loader + let finalizedSessionState: SpecializedAppSessionState | undefined; + loader() .then(sessionState => { - const result = finalizeFromSessionState({ + finalizedSessionState = sessionState; + const finalizedApp = finalizeFromSessionState({ finalized, finalizedSessionState: sessionState, tree, @@ -508,9 +492,7 @@ export function prepareSpecializedApp( bootstrapApiFactoryEntries, bootstrapMissingApiAccesses, }); - cachedSessionState = result.cachedSessionState; - finalized = result.finalizedApp; - const finalizedApp = result.finalizedApp; + finalized = finalizedApp; finalization.resolve(finalizedApp); }) .catch(error => { @@ -518,11 +500,10 @@ export function prepareSpecializedApp( const bootstrapFailure = isError(error) ? error : new Error(String(error)); - bootstrapError = bootstrapFailure; - const result = finalizeFromBootstrapError({ + const finalizedApp = finalizeFromBootstrapError({ finalized, error: bootstrapFailure, - cachedSessionState, + finalizedSessionState, tree, collector, phase, @@ -531,9 +512,7 @@ export function prepareSpecializedApp( signInRuntime, providedSessionData, }); - cachedSessionState = result.cachedSessionState; - finalized = result.finalizedApp; - const finalizedApp = result.finalizedApp; + finalized = finalizedApp; finalization.resolve(finalizedApp); } catch (finalizationError) { finalizationState = undefined; @@ -566,12 +545,9 @@ export function prepareSpecializedApp( phase.identityApiProxy.setTargetHandlers({ onTargetSet(identityApi) { runtime.readyIdentityApi = identityApi; - beginFinalization( - getSessionState().catch(error => { - runtime.error = error; - throw error; - }), - ); + if (finalizationMode === 'onFinalized') { + beginFinalization(loadSessionState); + } }, }); } @@ -626,9 +602,10 @@ export function prepareSpecializedApp( }; } - const finalizedAppPromise = signInRuntime?.requiresSignIn - ? getFinalizationState().promise - : beginFinalization(getSessionState()); + const finalizedAppPromise = + signInRuntime?.requiresSignIn && !signInRuntime.readyIdentityApi + ? getFinalizationState().promise + : beginFinalization(loadSessionState); finalizedAppPromise .then(finalizedApp => { if (subscribed) { @@ -646,21 +623,12 @@ export function prepareSpecializedApp( if (finalized) { return finalized; } - - if (bootstrapError) { - throw bootstrapError; - } - if (signInRuntime?.error && !signInRuntime.requiresSignIn) { - throw signInRuntime.error; - } - - if (!finalizeOptions?.sessionState && !cachedSessionState) { + if (!finalizeOptions?.sessionState) { getBootstrapApp(); } const finalizedSessionState = finalizeOptions?.sessionState ?? - cachedSessionState ?? (signInRuntime?.requiresSignIn ? undefined : getImmediateSessionState()); @@ -691,9 +659,7 @@ export function prepareSpecializedApp( bootstrapApiFactoryEntries, bootstrapMissingApiAccesses, }); - cachedSessionState = result.cachedSessionState; - finalized = result.finalizedApp; - finalizationState?.resolve(finalized); + finalized = result; return finalized; }, }; @@ -767,11 +733,6 @@ type BootstrapClassification = { deferredRoots: Set; }; -type FinalizationResult = { - finalizedApp: FinalizedSpecializedApp; - cachedSessionState: SpecializedAppSessionState; -}; - function finalizeFromSessionState(options: { finalized?: FinalizedSpecializedApp; finalizedSessionState: SpecializedAppSessionState; @@ -787,12 +748,9 @@ function finalizeFromSessionState(options: { bootstrapClassification: BootstrapClassification; bootstrapApiFactoryEntries: Map; bootstrapMissingApiAccesses: Map; -}): FinalizationResult { +}): FinalizedSpecializedApp { if (options.finalized) { - return { - finalizedApp: options.finalized, - cachedSessionState: options.finalized.sessionState, - }; + return options.finalized; } const sessionStateData = OpaqueSpecializedAppSessionState.toInternal( @@ -841,20 +799,17 @@ function finalizeFromSessionState(options: { } return { - finalizedApp: { - element, - sessionState: options.finalizedSessionState, - tree: options.tree, - errors: options.collector.collectErrors(), - }, - cachedSessionState: options.finalizedSessionState, + element, + sessionState: options.finalizedSessionState, + tree: options.tree, + errors: options.collector.collectErrors(), }; } function finalizeFromBootstrapError(options: { finalized?: FinalizedSpecializedApp; error: Error; - cachedSessionState?: SpecializedAppSessionState; + finalizedSessionState?: SpecializedAppSessionState; tree: AppTree; collector: ErrorCollector; phase: ReturnType; @@ -862,16 +817,13 @@ function finalizeFromBootstrapError(options: { routeRefsById: ReturnType; signInRuntime?: SignInRuntime; providedSessionData?: InternalSpecializedAppSessionState; -}): FinalizationResult { +}): FinalizedSpecializedApp { if (options.finalized) { - return { - finalizedApp: options.finalized, - cachedSessionState: options.finalized.sessionState, - }; + return options.finalized; } - const cachedSessionState = - options.cachedSessionState ?? + const finalizedSessionState = + options.finalizedSessionState ?? OpaqueSpecializedAppSessionState.createInstance('v1', { apis: options.phase.apis, identityApi: @@ -903,12 +855,9 @@ function finalizeFromBootstrapError(options: { } return { - finalizedApp: { - element, - sessionState: cachedSessionState, - tree: options.tree, - }, - cachedSessionState, + element, + sessionState: finalizedSessionState, + tree: options.tree, }; } From e04955b861e743dc5c18faad9856ccd8ae9fb2a6 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Mar 2026 11:51:20 +0100 Subject: [PATCH 59/68] frontend-app-api: split prepared app wiring helpers Extract the prepared app tree and API factory lifecycle helpers so prepareSpecializedApp reads as orchestration, while keeping the finalization flow documented and behaviorally unchanged. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../src/wiring/apiFactories.ts | 304 +++++++ .../src/wiring/prepareSpecializedApp.tsx | 835 +++++------------- .../src/wiring/treeLifecycle.tsx | 318 +++++++ 3 files changed, 845 insertions(+), 612 deletions(-) create mode 100644 packages/frontend-app-api/src/wiring/apiFactories.ts create mode 100644 packages/frontend-app-api/src/wiring/treeLifecycle.tsx diff --git a/packages/frontend-app-api/src/wiring/apiFactories.ts b/packages/frontend-app-api/src/wiring/apiFactories.ts new file mode 100644 index 0000000000..6ccc75b9c1 --- /dev/null +++ b/packages/frontend-app-api/src/wiring/apiFactories.ts @@ -0,0 +1,304 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ApiBlueprint, + AnyApiFactory, + ApiHolder, + AppNode, + FrontendFeature, + featureFlagsApiRef, +} from '@backstage/frontend-plugin-api'; +import { OpaqueFrontendPlugin } from '@internal/frontend'; +import { instantiateAppNodeSubtree } from '../tree/instantiateAppNodeTree'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { + isInternalFrontendModule, + toInternalFrontendModule, +} from '../../../frontend-plugin-api/src/wiring/createFrontendModule'; +import { ErrorCollector } from './createErrorCollector'; +import { + FrontendApiRegistry, + FrontendApiResolver, +} from './FrontendApiRegistry'; +import { type ExtensionPredicateContext } from './predicates'; + +export type ApiFactoryEntry = { + node: AppNode; + pluginId: string; + factory: AnyApiFactory; +}; + +/** + * Registers feature flag declarations on an already prepared API holder. + * + * This is primarily used when bootstrap reuses APIs from a provided session + * state rather than building a fresh registry from bootstrap-visible factories. + */ +export function registerFeatureFlagDeclarationsInHolder( + apis: ApiHolder, + features: FrontendFeature[], +) { + const featureFlagApi = apis.get(featureFlagsApiRef); + if (featureFlagApi) { + registerFeatureFlagDeclarations(featureFlagApi, features); + } +} + +/** + * Decorates the feature flags API factory so plugin and module declarations are + * registered whenever that API is instantiated. + */ +export function wrapFeatureFlagApiFactory( + factory: AnyApiFactory, + features: FrontendFeature[], +) { + if (factory.api.id !== featureFlagsApiRef.id) { + return factory; + } + + return { + ...factory, + factory(deps) { + const featureFlagApi = factory.factory( + deps, + ) as typeof featureFlagsApiRef.T; + registerFeatureFlagDeclarations(featureFlagApi, features); + return featureFlagApi; + }, + } as AnyApiFactory; +} + +/** + * Reconciles deferred API factories into the finalized API registry. + * + * It preserves bootstrap-frozen APIs, allows safe deferred additions, and + * reports cases where bootstrap-visible extensions relied on APIs that only + * became available during finalization. + */ +export function syncFinalApiFactories(options: { + deferredApiNodes: Iterable; + appApiRegistry: FrontendApiRegistry; + apiResolver: FrontendApiResolver; + collector: ErrorCollector; + features: FrontendFeature[]; + bootstrapApiFactoryEntries: ReadonlyMap; + bootstrapMissingApiAccesses: Map; + predicateContext: ExtensionPredicateContext; +}) { + const finalApiEntries = collectApiFactoryEntries({ + apiNodes: options.deferredApiNodes, + collector: options.collector, + predicateContext: options.predicateContext, + entries: new Map(options.bootstrapApiFactoryEntries), + }); + // Only newly introduced or still-safe overrides are registered here. Any + // bootstrap-materialized API remains frozen for the lifetime of the app. + const changedEntries = Array.from(finalApiEntries.values()).filter(entry => { + const bootstrapEntry = options.bootstrapApiFactoryEntries.get( + entry.factory.api.id, + ); + if (!bootstrapEntry) { + return true; + } + if (bootstrapEntry.factory === entry.factory) { + return false; + } + if (options.apiResolver.isMaterialized(entry.factory.api.id)) { + options.collector.report({ + code: 'EXTENSION_BOOTSTRAP_API_OVERRIDE_IGNORED', + message: + `Extension '${entry.node.spec.id}' tried to override API ` + + `'${entry.factory.api.id}' after it had already been materialized during bootstrap. ` + + 'The bootstrap implementation was kept and the deferred override was ignored.', + context: { + node: entry.node, + apiRefId: entry.factory.api.id, + bootstrapNode: bootstrapEntry.node, + pluginId: entry.pluginId, + bootstrapPluginId: bootstrapEntry.pluginId, + }, + }); + return false; + } + return true; + }); + const changedFactories = changedEntries.map(entry => + wrapFeatureFlagApiFactory(entry.factory, options.features), + ); + options.appApiRegistry.setAll(changedFactories); + options.apiResolver.invalidate( + changedFactories.map(factory => factory.api.id), + ); + for (const bootstrapAccess of options.bootstrapMissingApiAccesses.values()) { + if ( + options.bootstrapApiFactoryEntries.has(bootstrapAccess.apiRefId) || + !finalApiEntries.has(bootstrapAccess.apiRefId) + ) { + continue; + } + + options.collector.report({ + code: 'EXTENSION_BOOTSTRAP_API_UNAVAILABLE', + message: + `Extension '${bootstrapAccess.node.spec.id}' tried to access API ` + + `'${bootstrapAccess.apiRefId}' during bootstrap before it was available. ` + + 'That API became available during finalization, so bootstrap-visible extensions must not depend on deferred APIs.', + context: { + node: bootstrapAccess.node, + apiRefId: bootstrapAccess.apiRefId, + }, + }); + } +} + +const EMPTY_API_HOLDER: ApiHolder = { + get() { + return undefined; + }, +}; + +function registerFeatureFlagDeclarations( + featureFlagApi: typeof featureFlagsApiRef.T, + features: FrontendFeature[], +) { + for (const feature of features) { + if (OpaqueFrontendPlugin.isType(feature)) { + OpaqueFrontendPlugin.toInternal(feature).featureFlags.forEach(flag => + featureFlagApi.registerFlag({ + name: flag.name, + description: flag.description, + pluginId: feature.id, + }), + ); + } + if (isInternalFrontendModule(feature)) { + toInternalFrontendModule(feature).featureFlags.forEach(flag => + featureFlagApi.registerFlag({ + name: flag.name, + description: flag.description, + pluginId: feature.pluginId, + }), + ); + } + } +} + +/** + * Instantiates API extension subtrees in isolation and extracts the factories + * they provide without mutating the live app tree. + * + * The collected entries are later used both for bootstrap registration and for + * the finalization-time reconciliation of deferred API roots. + */ +export function collectApiFactoryEntries(options: { + apiNodes: Iterable; + collector: ErrorCollector; + predicateContext?: ExtensionPredicateContext; + entries?: Map; +}): Map { + const factoriesById = options.entries ?? new Map(); + for (const apiNode of options.apiNodes) { + // API extensions are instantiated in isolation so we can inspect the + // produced factories without mutating the live app tree. + const detachedApiNode = instantiateAppNodeSubtree({ + rootNode: apiNode, + apis: EMPTY_API_HOLDER, + collector: options.collector, + predicateContext: options.predicateContext, + writeNodeInstances: false, + reuseExistingInstances: false, + }); + if (!detachedApiNode) { + continue; + } + const apiFactory = detachedApiNode.instance?.getData( + ApiBlueprint.dataRefs.factory, + ); + if (apiFactory) { + const apiRefId = apiFactory.api.id; + const ownerId = getApiOwnerId(apiRefId); + const pluginId = apiNode.spec.plugin.pluginId ?? 'app'; + const existingFactory = factoriesById.get(apiRefId); + + // This allows modules to override factories provided by the plugin, but + // it rejects API overrides from other plugins. In the event of a + // conflict, the owning plugin is attempted to be inferred from the API + // reference ID. + if (existingFactory && existingFactory.pluginId !== pluginId) { + const shouldReplace = + ownerId === pluginId && existingFactory.pluginId !== ownerId; + const acceptedPluginId = shouldReplace + ? pluginId + : existingFactory.pluginId; + const rejectedPluginId = shouldReplace + ? existingFactory.pluginId + : pluginId; + + options.collector.report({ + code: 'API_FACTORY_CONFLICT', + message: `API '${apiRefId}' is already provided by plugin '${acceptedPluginId}', cannot also be provided by '${rejectedPluginId}'.`, + context: { + node: apiNode, + apiRefId, + pluginId: rejectedPluginId, + existingPluginId: acceptedPluginId, + }, + }); + if (shouldReplace) { + factoriesById.set(apiRefId, { + pluginId, + node: apiNode, + factory: apiFactory, + }); + } + continue; + } + + factoriesById.set(apiRefId, { + pluginId, + node: apiNode, + factory: apiFactory, + }); + } else { + options.collector.report({ + code: 'API_EXTENSION_INVALID', + message: `API extension '${apiNode.spec.id}' did not output an API factory`, + context: { + node: apiNode, + }, + }); + } + } + + return factoriesById; +} + +// TODO(Rugvip): It would be good if this was more explicit, but I think that +// might need to wait for some future update for API factories. +function getApiOwnerId(apiRefId: string): string { + const [prefix, ...rest] = apiRefId.split('.'); + if (!prefix) { + return apiRefId; + } + if (prefix === 'core') { + return 'app'; + } + if (prefix === 'plugin' && rest[0]) { + return rest[0]; + } + return prefix; +} diff --git a/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx b/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx index fd8a10bb58..546ca4200c 100644 --- a/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx +++ b/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx @@ -17,23 +17,18 @@ import { ConfigReader } from '@backstage/config'; import { isError } from '@backstage/errors'; import { - ApiBlueprint, AnyApiFactory, ApiHolder, AppTree, ConfigApi, coreExtensionData, AppNode, - AppNodeInstance, - ExtensionDataRef, ExtensionFactoryMiddleware, FrontendFeature, - featureFlagsApiRef, IdentityApi, identityApiRef, createExtensionDataRef, } from '@backstage/frontend-plugin-api'; -import { FilterPredicate } from '@backstage/filter-predicates'; import { createExtensionDataContainer, OpaqueFrontendPlugin, @@ -50,17 +45,11 @@ import { import { CreateAppRouteBinder } from '../routing'; import { resolveRouteBindings } from '../routing/resolveRouteBindings'; import { collectRouteIds } from '../routing/collectRouteIds'; -// eslint-disable-next-line @backstage/no-relative-monorepo-imports -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 { instantiateAppNodeSubtree } from '../tree/instantiateAppNodeTree'; import { createPluginInfoAttacher, FrontendPluginInfoResolver, @@ -71,10 +60,8 @@ import { ErrorCollector, } from './createErrorCollector'; import { - AppTreeApiProxy, createPhaseApis, instantiateAndInitializePhaseTree, - RouteResolutionApiProxy, setIdentityApiTarget, } from './phaseApis'; import { @@ -83,10 +70,22 @@ import { EMPTY_PREDICATE_CONTEXT, type ExtensionPredicateContext, } from './predicates'; +import { FrontendApiRegistry } from './FrontendApiRegistry'; import { - FrontendApiRegistry, - FrontendApiResolver, -} from './FrontendApiRegistry'; + ApiFactoryEntry, + collectApiFactoryEntries, + registerFeatureFlagDeclarationsInHolder, + syncFinalApiFactories, + wrapFeatureFlagApiFactory, +} from './apiFactories'; +import { + attachThrowingFinalizationChild, + BootstrapClassification, + classifyBootstrapTree, + clearFinalizationBoundaryInstances, + createBootstrapApp, + prepareFinalizedTree, +} from './treeLifecycle'; function deduplicateFeatures( allFeatures: FrontendFeature[], @@ -323,6 +322,8 @@ export function prepareSpecializedApp( ? OpaqueSpecializedAppSessionState.toInternal(providedSessionState) : undefined; const providedApis = providedSessionData?.apis; + // Bootstrap only renders the parts of the tree that are known to be safe + // before predicate context and sign-in have been resolved. const bootstrapClassification = classifyBootstrapTree({ tree, collector, @@ -339,8 +340,12 @@ export function prepareSpecializedApp( >(); if (providedApis) { + // Reused session state already carries a fully prepared API holder, so the + // bootstrap path only needs to register feature flag declarations on top. registerFeatureFlagDeclarationsInHolder(providedApis, features); } else { + // Bootstrap materializes only the immediately visible API factories. Any + // predicate-gated API roots are revisited during finalization. collectApiFactoryEntries({ apiNodes: (tree.root.edges.attachments.get('apis') ?? []).filter( apiNode => !bootstrapClassification.deferredApiRoots.has(apiNode), @@ -371,8 +376,6 @@ export function prepareSpecializedApp( let signInRuntime: SignInRuntime | undefined; let finalized: FinalizedSpecializedApp | undefined; let bootstrapApp: BootstrapSpecializedApp | undefined; - let finalizationState: FinalizationState | undefined; - let finalizationMode: FinalizationMode | undefined; function updateIdentityApiTarget(identityApi?: IdentityApi) { if (!identityApi) { @@ -389,6 +392,8 @@ export function prepareSpecializedApp( function createSessionState(predicateContext: ExtensionPredicateContext) { const identityApi = signInRuntime?.readyIdentityApi ?? providedSessionData?.identityApi; + // As soon as a real identity is available we swap the phase proxy over so + // the finalized tree observes the same API instance. updateIdentityApiTarget(identityApi); const sessionState = OpaqueSpecializedAppSessionState.createInstance('v1', { apis: phase.apis, @@ -398,10 +403,12 @@ export function prepareSpecializedApp( return sessionState; } - function getImmediateSessionState() { + function getSynchronousSessionState() { if (providedSessionState) { return providedSessionState; } + // The direct finalize() path is intentionally synchronous. If sign-in is + // still pending we refuse to guess and force the caller to wait. if (signInRuntime?.requiresSignIn) { return undefined; } @@ -414,7 +421,7 @@ export function prepareSpecializedApp( return createSessionState(predicateContext); } - function loadSessionState() { + function loadAsyncSessionState() { if (providedSessionState) { return Promise.resolve(providedSessionState); } @@ -426,8 +433,10 @@ export function prepareSpecializedApp( ); } + // For apps without sign-in we can sometimes finalize immediately from the + // already available predicate context, skipping the async loader. if (!signInRuntime?.requiresSignIn) { - const immediateSessionState = getImmediateSessionState(); + const immediateSessionState = getSynchronousSessionState(); if (immediateSessionState) { return Promise.resolve(immediateSessionState); } @@ -436,102 +445,55 @@ export function prepareSpecializedApp( return predicateContextLoader.load().then(createSessionState); } - function getFinalizationState(): FinalizationState { - if (finalizationState) { - return finalizationState; - } - - let resolve: ((app: FinalizedSpecializedApp) => void) | undefined; - let reject: ((error: unknown) => void) | undefined; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; + function finalizeWithSessionState( + finalizedSessionState: SpecializedAppSessionState, + ) { + return finalizeFromSessionState({ + finalized, + finalizedSessionState, + tree, + collector, + phase, + extensionFactoryMiddleware: mergedExtensionFactoryMiddleware, + routeRefsById, + appBasePath, + providedApis, + features, + appApiRegistry, + bootstrapClassification, + bootstrapApiFactoryEntries, + bootstrapMissingApiAccesses, }); - if (!resolve || !reject) { - throw new Error('Failed to create finalization state'); - } - - finalizationState = { - started: false, - promise, - resolve, - reject, - }; - return finalizationState; } - function beginFinalization( - loader: () => Promise, - ): Promise { - if (finalized) { - return Promise.resolve(finalized); - } - const finalization = getFinalizationState(); - if (finalization.started) { - return finalization.promise; - } - finalization.started = true; - - let finalizedSessionState: SpecializedAppSessionState | undefined; - loader() - .then(sessionState => { - finalizedSessionState = sessionState; - const finalizedApp = finalizeFromSessionState({ - finalized, - finalizedSessionState: sessionState, - tree, - collector, - phase, - extensionFactoryMiddleware: mergedExtensionFactoryMiddleware, - routeRefsById, - appBasePath, - providedApis, - features, - appApiRegistry, - bootstrapClassification, - bootstrapApiFactoryEntries, - bootstrapMissingApiAccesses, - }); - finalized = finalizedApp; - finalization.resolve(finalizedApp); - }) - .catch(error => { - try { - const bootstrapFailure = isError(error) - ? error - : new Error(String(error)); - const finalizedApp = finalizeFromBootstrapError({ - finalized, - error: bootstrapFailure, - finalizedSessionState, - tree, - collector, - phase, - extensionFactoryMiddleware: mergedExtensionFactoryMiddleware, - routeRefsById, - signInRuntime, - providedSessionData, - }); - finalized = finalizedApp; - finalization.resolve(finalizedApp); - } catch (finalizationError) { - finalizationState = undefined; - finalization.reject(finalizationError); - } - }); - - return finalization.promise; + function finalizeWithBootstrapError( + error: Error, + finalizedSessionState?: SpecializedAppSessionState, + ) { + return finalizeFromBootstrapError({ + finalized, + error, + finalizedSessionState, + tree, + collector, + phase, + extensionFactoryMiddleware: mergedExtensionFactoryMiddleware, + routeRefsById, + signInRuntime, + providedSessionData, + }); } - function selectFinalizationMode(mode: FinalizationMode) { - if (finalizationMode && finalizationMode !== mode) { - throw new Error( - `prepareSpecializedApp only supports using either onFinalized() or finalize(), not both`, - ); - } - - finalizationMode = mode; - } + const finalization = createFinalizationController({ + getFinalized() { + return finalized; + }, + setFinalized(finalizedApp) { + finalized = finalizedApp; + }, + finalizeFromSessionState: finalizeWithSessionState, + finalizeFromBootstrapError: finalizeWithBootstrapError, + }); function getBootstrapApp() { if (bootstrapApp) { @@ -545,8 +507,10 @@ export function prepareSpecializedApp( phase.identityApiProxy.setTargetHandlers({ onTargetSet(identityApi) { runtime.readyIdentityApi = identityApi; - if (finalizationMode === 'onFinalized') { - beginFinalization(loadSessionState); + // Sign-in completion only auto-starts finalization for onFinalized(). + // The direct finalize() path stays explicit and synchronous. + if (finalization.getMode() === 'onFinalized') { + finalization.start(loadAsyncSessionState); } }, }); @@ -570,6 +534,11 @@ export function prepareSpecializedApp( apiRefId, }); }, + hasSignInPage(signInPageNode) { + return Boolean( + signInPageNode?.instance?.getData(signInPageComponentDataRef), + ); + }, }); if (!result.requiresSignIn) { phase.identityApiProxy.clearTargetHandlers(); @@ -585,7 +554,9 @@ export function prepareSpecializedApp( return { getBootstrapApp, onFinalized(callback) { - selectFinalizationMode('onFinalized'); + finalization.selectMode('onFinalized'); + // Subscribing to finalization also ensures the bootstrap tree exists, + // because sign-in may need to capture identity before finalization starts. getBootstrapApp(); let subscribed = true; @@ -602,10 +573,12 @@ export function prepareSpecializedApp( }; } + // If sign-in is still in progress we wait for the shared promise created + // by the sign-in callback. Otherwise we can start finalization right away. const finalizedAppPromise = signInRuntime?.requiresSignIn && !signInRuntime.readyIdentityApi - ? getFinalizationState().promise - : beginFinalization(loadSessionState); + ? finalization.getPromise() + : finalization.start(loadAsyncSessionState); finalizedAppPromise .then(finalizedApp => { if (subscribed) { @@ -619,19 +592,24 @@ export function prepareSpecializedApp( }; }, finalize(finalizeOptions?: { sessionState?: SpecializedAppSessionState }) { - selectFinalizationMode('finalize'); + finalization.selectMode('finalize'); if (finalized) { return finalized; } if (!finalizeOptions?.sessionState) { + // finalize() still depends on bootstrap classification and sign-in + // discovery, so we make sure the bootstrap tree has been prepared first. getBootstrapApp(); } + // Direct finalization never waits for async session preparation. Callers + // must either supply sessionState or invoke finalize() only when the + // predicate context is already available synchronously. const finalizedSessionState = finalizeOptions?.sessionState ?? (signInRuntime?.requiresSignIn ? undefined - : getImmediateSessionState()); + : getSynchronousSessionState()); if (!finalizedSessionState) { if (signInRuntime?.requiresSignIn) { throw new Error( @@ -643,96 +621,19 @@ export function prepareSpecializedApp( ); } - const result = finalizeFromSessionState({ - finalized, - finalizedSessionState, - tree, - collector, - phase, - extensionFactoryMiddleware: mergedExtensionFactoryMiddleware, - routeRefsById, - appBasePath, - providedApis, - features, - appApiRegistry, - bootstrapClassification, - bootstrapApiFactoryEntries, - bootstrapMissingApiAccesses, - }); - finalized = result; + finalized = finalizeWithSessionState(finalizedSessionState); return finalized; }, }; } -function registerFeatureFlagDeclarations( - featureFlagApi: typeof featureFlagsApiRef.T, - features: FrontendFeature[], -) { - for (const feature of features) { - if (OpaqueFrontendPlugin.isType(feature)) { - OpaqueFrontendPlugin.toInternal(feature).featureFlags.forEach(flag => - featureFlagApi.registerFlag({ - name: flag.name, - description: flag.description, - pluginId: feature.id, - }), - ); - } - if (isInternalFrontendModule(feature)) { - toInternalFrontendModule(feature).featureFlags.forEach(flag => - featureFlagApi.registerFlag({ - name: flag.name, - description: flag.description, - pluginId: feature.pluginId, - }), - ); - } - } -} - -function registerFeatureFlagDeclarationsInHolder( - apis: ApiHolder, - features: FrontendFeature[], -) { - const featureFlagApi = apis.get(featureFlagsApiRef); - if (featureFlagApi) { - registerFeatureFlagDeclarations(featureFlagApi, features); - } -} - -function wrapFeatureFlagApiFactory( - factory: AnyApiFactory, - features: FrontendFeature[], -) { - if (factory.api.id !== featureFlagsApiRef.id) { - return factory; - } - - return { - ...factory, - factory(deps) { - const featureFlagApi = factory.factory( - deps, - ) as typeof featureFlagsApiRef.T; - registerFeatureFlagDeclarations(featureFlagApi, features); - return featureFlagApi; - }, - } as AnyApiFactory; -} - -type ApiFactoryEntry = { - node: AppNode; - pluginId: string; - factory: AnyApiFactory; -}; - -type BootstrapClassification = { - deferredApiRoots: Set; - deferredElementRoots: Set; - deferredRoots: Set; -}; - +/** + * Materializes the fully finalized app tree from a prepared session state. + * + * This is responsible for switching the identity proxy to the resolved target, + * synchronizing any deferred API factories, and re-instantiating the parts of + * the tree that are only valid once predicate context is available. + */ function finalizeFromSessionState(options: { finalized?: FinalizedSpecializedApp; finalizedSessionState: SpecializedAppSessionState; @@ -757,6 +658,8 @@ function finalizeFromSessionState(options: { options.finalizedSessionState, ); if (sessionStateData.identityApi) { + // Finalization retargets the identity proxy before any additional nodes are + // instantiated so the full tree observes the captured identity immediately. setIdentityApiTarget({ identityApiProxy: options.phase.identityApiProxy, identityApi: sessionStateData.identityApi, @@ -764,6 +667,8 @@ function finalizeFromSessionState(options: { }); } if (!options.providedApis) { + // Deferred API roots are synchronized at finalization time, but bootstrap- + // materialized APIs stay frozen if they were already observed earlier. syncFinalApiFactories({ deferredApiNodes: options.bootstrapClassification.deferredApiRoots, appApiRegistry: options.appApiRegistry, @@ -779,6 +684,8 @@ function finalizeFromSessionState(options: { prepareFinalizedTree({ tree: options.tree, }); + // Finalization re-instantiates the boundary subtree so predicate-gated app + // content can be re-evaluated without disturbing preserved bootstrap nodes. clearFinalizationBoundaryInstances(options.tree); instantiateAndInitializePhaseTree({ tree: options.tree, @@ -806,6 +713,13 @@ function finalizeFromSessionState(options: { }; } +/** + * Builds a finalized app that rethrows a bootstrap-time failure through the + * normal app root boundary. + * + * This keeps the error handling path aligned with normal finalization while + * preserving any session state that was already resolved before the failure. + */ function finalizeFromBootstrapError(options: { finalized?: FinalizedSpecializedApp; error: Error; @@ -822,6 +736,8 @@ function finalizeFromBootstrapError(options: { return options.finalized; } + // If finalization fails after session state was already prepared, keep using + // it so the error app reflects the same identity and API view. const finalizedSessionState = options.finalizedSessionState ?? OpaqueSpecializedAppSessionState.createInstance('v1', { @@ -836,6 +752,8 @@ function finalizeFromBootstrapError(options: { tree: options.tree, }); clearFinalizationBoundaryInstances(options.tree); + // The final app reports bootstrap failures through app/root.children so the + // normal app root boundary renders the error state for us. attachThrowingFinalizationChild(options.tree, options.error); instantiateAndInitializePhaseTree({ tree: options.tree, @@ -861,426 +779,119 @@ function finalizeFromBootstrapError(options: { }; } -function createBootstrapApp(options: { - tree: AppTree; - apis: ApiHolder; - collector: ErrorCollector; - routeRefsById: ReturnType; - routeResolutionApi: RouteResolutionApiProxy; - appTreeApi: AppTreeApiProxy; - extensionFactoryMiddleware?: ExtensionFactoryMiddleware; - disableSignIn?: boolean; - skipBootstrapChild?(ctx: { - node: AppNode; - input: string; - child: AppNode; - }): boolean; - onMissingApi?(ctx: { node: AppNode; apiRefId: string }): void; -}): { - bootstrapApp: BootstrapSpecializedApp; - requiresSignIn: boolean; -} { - const signInPageNode = getAppRootNode(options.tree)?.edges.attachments.get( - 'signInPage', - )?.[0]; - - instantiateAndInitializePhaseTree({ - tree: options.tree, - apis: options.apis, - collector: options.collector, - extensionFactoryMiddleware: options.extensionFactoryMiddleware, - routeResolutionApi: options.routeResolutionApi, - appTreeApi: options.appTreeApi, - routeRefsById: options.routeRefsById, - stopAtAttachment: ({ node, input }) => - isSessionBoundaryAttachment(node, input), - skipChild: options.skipBootstrapChild, - onMissingApi: options.onMissingApi, - }); - - const element = options.tree.root.instance?.getData( - coreExtensionData.reactElement, - ); - if (!element) { - throw new Error('Expected bootstrap tree to expose a root element'); - } - - return { - bootstrapApp: { - element, - tree: options.tree, - }, - requiresSignIn: - !options.disableSignIn && - Boolean(signInPageNode?.instance?.getData(signInPageComponentDataRef)), - }; -} - -function prepareFinalizedTree(options: { tree: AppTree }) { - for (const appRootNode of getFinalizationBoundaryNodes(options.tree)) { - const attachments = appRootNode.edges.attachments as Map; - attachments.delete('signInPage'); - } -} - -function clearFinalizationBoundaryInstances(tree: AppTree) { - clearNodeInstance(tree.root); - - const visited = new Set(); - function visit(node: AppNode) { - if (visited.has(node)) { - return; - } - visited.add(node); - clearNodeInstance(node); - - for (const [input, children] of node.edges.attachments) { - if (node.spec.id === 'app/root' && input === 'elements') { - continue; - } - - for (const child of children) { - visit(child); - } - } - } - - for (const appRootNode of getFinalizationBoundaryNodes(tree)) { - visit(appRootNode); - } -} - -function getAppRootNode(tree: AppTree) { - return tree.nodes.get('app/root'); -} - -function getFinalizationBoundaryNodes(tree: AppTree): AppNode[] { - const nodes = new Set(); - const appRootNode = getAppRootNode(tree); - if (appRootNode) { - nodes.add(appRootNode); - } - const attachedAppRootNode = tree.root.edges.attachments.get('app')?.[0]; - if (attachedAppRootNode) { - nodes.add(attachedAppRootNode); - } - return Array.from(nodes); -} - -function isSessionBoundaryAttachment(node: AppNode, input: string) { - return node.spec.id === 'app/root' && input === 'children'; -} - -function attachThrowingFinalizationChild(tree: AppTree, error: Error) { - const bootstrapChildNode = - getAppRootNode(tree)?.edges.attachments.get('children')?.[0]; - if (!bootstrapChildNode) { - throw error; - } - - function ThrowBootstrapError(): never { - throw error; - } - - (bootstrapChildNode as AppNode & { instance?: AppNodeInstance }).instance = { - getDataRefs() { - return [coreExtensionData.reactElement].values(); - }, - getData(dataRef: ExtensionDataRef) { - if (dataRef.id === coreExtensionData.reactElement.id) { - return () as TValue; - } - return undefined; - }, - }; -} - -function clearNodeInstance(node: AppNode) { - (node as AppNode & { instance?: AppNodeInstance }).instance = undefined; -} - -const EMPTY_API_HOLDER: ApiHolder = { - get() { - return undefined; - }, -}; - -function collectApiFactoryEntries(options: { - apiNodes: Iterable; - collector: ErrorCollector; - predicateContext?: ExtensionPredicateContext; - entries?: Map; -}): Map { - const factoriesById = options.entries ?? new Map(); - for (const apiNode of options.apiNodes) { - const detachedApiNode = instantiateAppNodeSubtree({ - rootNode: apiNode, - apis: EMPTY_API_HOLDER, - collector: options.collector, - predicateContext: options.predicateContext, - writeNodeInstances: false, - reuseExistingInstances: false, - }); - if (!detachedApiNode) { - continue; - } - const apiFactory = detachedApiNode.instance?.getData( - ApiBlueprint.dataRefs.factory, - ); - if (apiFactory) { - const apiRefId = apiFactory.api.id; - const ownerId = getApiOwnerId(apiRefId); - const pluginId = apiNode.spec.plugin.pluginId ?? 'app'; - const existingFactory = factoriesById.get(apiRefId); - - // This allows modules to override factories provided by the plugin, but - // it rejects API overrides from other plugins. In the event of a - // conflict, the owning plugin is attempted to be inferred from the API - // reference ID. - if (existingFactory && existingFactory.pluginId !== pluginId) { - const shouldReplace = - ownerId === pluginId && existingFactory.pluginId !== ownerId; - const acceptedPluginId = shouldReplace - ? pluginId - : existingFactory.pluginId; - const rejectedPluginId = shouldReplace - ? existingFactory.pluginId - : pluginId; - - options.collector.report({ - code: 'API_FACTORY_CONFLICT', - message: `API '${apiRefId}' is already provided by plugin '${acceptedPluginId}', cannot also be provided by '${rejectedPluginId}'.`, - context: { - node: apiNode, - apiRefId, - pluginId: rejectedPluginId, - existingPluginId: acceptedPluginId, - }, - }); - if (shouldReplace) { - factoriesById.set(apiRefId, { - pluginId, - node: apiNode, - factory: apiFactory, - }); - } - continue; - } - - factoriesById.set(apiRefId, { - pluginId, - node: apiNode, - factory: apiFactory, - }); - } else { - options.collector.report({ - code: 'API_EXTENSION_INVALID', - message: `API extension '${apiNode.spec.id}' did not output an API factory`, - context: { - node: apiNode, - }, - }); - } - } - - return factoriesById; -} - -function syncFinalApiFactories(options: { - deferredApiNodes: Iterable; - appApiRegistry: FrontendApiRegistry; - apiResolver: FrontendApiResolver; - collector: ErrorCollector; - features: FrontendFeature[]; - bootstrapApiFactoryEntries: ReadonlyMap; - bootstrapMissingApiAccesses: Map; - predicateContext: ExtensionPredicateContext; +/** + * Owns the callback-driven finalization lifecycle for a prepared app. + * + * The controller enforces the selected finalization mode, memoizes the shared + * async finalization promise for `onFinalized()` subscribers, and funnels both + * successful and failing async finalization through the same resolution path. + */ +function createFinalizationController(options: { + getFinalized(): FinalizedSpecializedApp | undefined; + setFinalized(finalizedApp: FinalizedSpecializedApp): void; + finalizeFromSessionState( + finalizedSessionState: SpecializedAppSessionState, + ): FinalizedSpecializedApp; + finalizeFromBootstrapError( + error: Error, + finalizedSessionState?: SpecializedAppSessionState, + ): FinalizedSpecializedApp; }) { - const finalApiEntries = collectApiFactoryEntries({ - apiNodes: options.deferredApiNodes, - collector: options.collector, - predicateContext: options.predicateContext, - entries: new Map(options.bootstrapApiFactoryEntries), - }); - const changedEntries = Array.from(finalApiEntries.values()).filter(entry => { - const bootstrapEntry = options.bootstrapApiFactoryEntries.get( - entry.factory.api.id, - ); - if (!bootstrapEntry) { - return true; - } - if (bootstrapEntry.factory === entry.factory) { - return false; - } - if (options.apiResolver.isMaterialized(entry.factory.api.id)) { - options.collector.report({ - code: 'EXTENSION_BOOTSTRAP_API_OVERRIDE_IGNORED', - message: - `Extension '${entry.node.spec.id}' tried to override API ` + - `'${entry.factory.api.id}' after it had already been materialized during bootstrap. ` + - 'The bootstrap implementation was kept and the deferred override was ignored.', - context: { - node: entry.node, - apiRefId: entry.factory.api.id, - bootstrapNode: bootstrapEntry.node, - pluginId: entry.pluginId, - bootstrapPluginId: bootstrapEntry.pluginId, - }, - }); - return false; - } - return true; - }); - const changedFactories = changedEntries.map(entry => - wrapFeatureFlagApiFactory(entry.factory, options.features), - ); - options.appApiRegistry.setAll(changedFactories); - options.apiResolver.invalidate( - changedFactories.map(factory => factory.api.id), - ); - for (const bootstrapAccess of options.bootstrapMissingApiAccesses.values()) { - if ( - options.bootstrapApiFactoryEntries.has(bootstrapAccess.apiRefId) || - !finalApiEntries.has(bootstrapAccess.apiRefId) - ) { - continue; + let finalizationState: FinalizationState | undefined; + let finalizationMode: FinalizationMode | undefined; + + function getState(): FinalizationState { + if (finalizationState) { + return finalizationState; } - options.collector.report({ - code: 'EXTENSION_BOOTSTRAP_API_UNAVAILABLE', - message: - `Extension '${bootstrapAccess.node.spec.id}' tried to access API ` + - `'${bootstrapAccess.apiRefId}' during bootstrap before it was available. ` + - 'That API became available during finalization, so bootstrap-visible extensions must not depend on deferred APIs.', - context: { - node: bootstrapAccess.node, - apiRefId: bootstrapAccess.apiRefId, - }, + // onFinalized() subscribers all fan into the same promise so that the full + // finalization flow only ever runs once. + let resolve: ((app: FinalizedSpecializedApp) => void) | undefined; + let reject: ((error: unknown) => void) | undefined; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; }); - } -} - -function collectBootstrapVisibleNodes( - tree: AppTree, - options?: { deferredRoots?: Set }, -) { - const visibleNodes = new Set(); - - function visit(node: AppNode) { - if (visibleNodes.has(node)) { - return; - } - visibleNodes.add(node); - - for (const [input, children] of node.edges.attachments) { - if (isSessionBoundaryAttachment(node, input)) { - continue; - } - - for (const child of children) { - if (options?.deferredRoots?.has(child)) { - continue; - } - visit(child); - } - } - } - - visit(tree.root); - - return visibleNodes; -} - -function classifyBootstrapTree(options: { - tree: AppTree; - collector: ErrorCollector; -}): BootstrapClassification { - const apiNodes = options.tree.root.edges.attachments.get('apis') ?? []; - const deferredApiRoots = new Set( - apiNodes.filter(apiNode => subtreeContainsPredicate(apiNode)), - ); - const appRootElementNodes = - getAppRootNode(options.tree)?.edges.attachments.get('elements') ?? []; - const deferredElementRoots = new Set( - appRootElementNodes.filter(elementNode => - subtreeContainsPredicate(elementNode), - ), - ); - const deferredRoots = new Set([ - ...deferredApiRoots, - ...deferredElementRoots, - ]); - const bootstrapNodes = collectBootstrapVisibleNodes(options.tree, { - deferredRoots, - }); - - for (const node of bootstrapNodes) { - if (node.spec.if === undefined) { - continue; + if (!resolve || !reject) { + throw new Error('Failed to create finalization state'); } - options.collector.report({ - code: 'EXTENSION_BOOTSTRAP_PREDICATE_IGNORED', - message: - `Extension '${node.spec.id}' uses 'if' during bootstrap, so the predicate was ignored. ` + - "Move it behind 'app/root.children', onto a deferred 'app/root.elements' subtree, or into an API subtree.", - context: { - node, - }, - }); - (node.spec as typeof node.spec & { if?: FilterPredicate }).if = undefined; + finalizationState = { + started: false, + promise, + resolve, + reject, + }; + return finalizationState; } return { - deferredApiRoots, - deferredElementRoots, - deferredRoots, + getMode() { + return finalizationMode; + }, + getPromise() { + return getState().promise; + }, + selectMode(mode: FinalizationMode) { + if (finalizationMode && finalizationMode !== mode) { + throw new Error( + `prepareSpecializedApp only supports using either onFinalized() or finalize(), not both`, + ); + } + + // A prepared app now has one owner: either the callback-driven path or + // the direct finalize() path, never both. + finalizationMode = mode; + }, + start(loader: () => Promise) { + const finalized = options.getFinalized(); + if (finalized) { + return Promise.resolve(finalized); + } + + const state = getState(); + if (state.started) { + return state.promise; + } + state.started = true; + + // If loading finishes but final tree materialization fails, we still + // want to preserve the resolved session state when building the error app. + let finalizedSessionState: SpecializedAppSessionState | undefined; + loader() + .then(sessionState => { + finalizedSessionState = sessionState; + const finalizedApp = options.finalizeFromSessionState(sessionState); + options.setFinalized(finalizedApp); + state.resolve(finalizedApp); + }) + .catch(error => { + try { + const bootstrapFailure = isError(error) + ? error + : new Error(String(error)); + const finalizedApp = options.finalizeFromBootstrapError( + bootstrapFailure, + finalizedSessionState, + ); + options.setFinalized(finalizedApp); + state.resolve(finalizedApp); + } catch (finalizationError) { + finalizationState = undefined; + state.reject(finalizationError); + } + }); + + return state.promise; + }, }; } -function subtreeContainsPredicate(root: AppNode) { - const visited = new Set(); - - function visit(node: AppNode): boolean { - if (visited.has(node)) { - return false; - } - visited.add(node); - - if (node.spec.if !== undefined) { - return true; - } - - for (const children of node.edges.attachments.values()) { - for (const child of children) { - if (visit(child)) { - return true; - } - } - } - - return false; - } - - return visit(root); -} - -// TODO(Rugvip): It would be good if this was more explicit, but I think that -// might need to wait for some future update for API factories. -function getApiOwnerId(apiRefId: string): string { - const [prefix, ...rest] = apiRefId.split('.'); - if (!prefix) { - return apiRefId; - } - if (prefix === 'core') { - return 'app'; - } - if (prefix === 'plugin' && rest[0]) { - return rest[0]; - } - return prefix; -} - +/** + * Combines one or more extension factory middlewares into a single middleware + * invocation chain that preserves Backstage's extension data container shape. + */ function mergeExtensionFactoryMiddleware( middlewares?: ExtensionFactoryMiddleware | ExtensionFactoryMiddleware[], ): ExtensionFactoryMiddleware | undefined { diff --git a/packages/frontend-app-api/src/wiring/treeLifecycle.tsx b/packages/frontend-app-api/src/wiring/treeLifecycle.tsx new file mode 100644 index 0000000000..e78d258949 --- /dev/null +++ b/packages/frontend-app-api/src/wiring/treeLifecycle.tsx @@ -0,0 +1,318 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ApiHolder, + AppNode, + AppNodeInstance, + AppTree, + coreExtensionData, + ExtensionDataRef, + ExtensionFactoryMiddleware, +} from '@backstage/frontend-plugin-api'; +import { FilterPredicate } from '@backstage/filter-predicates'; +import { collectRouteIds } from '../routing/collectRouteIds'; +import { ErrorCollector } from './createErrorCollector'; +import { + AppTreeApiProxy, + instantiateAndInitializePhaseTree, + RouteResolutionApiProxy, +} from './phaseApis'; + +export type BootstrapClassification = { + deferredApiRoots: Set; + deferredElementRoots: Set; + deferredRoots: Set; +}; + +/** + * Instantiates the bootstrap-visible portion of the app tree and returns the + * element that should be rendered while the prepared app is still incomplete. + * + * The bootstrap tree deliberately stops at the session boundary so sign-in and + * other deferred content can be handled separately during finalization. + */ +export function createBootstrapApp(options: { + tree: AppTree; + apis: ApiHolder; + collector: ErrorCollector; + routeRefsById: ReturnType; + routeResolutionApi: RouteResolutionApiProxy; + appTreeApi: AppTreeApiProxy; + extensionFactoryMiddleware?: ExtensionFactoryMiddleware; + disableSignIn?: boolean; + skipBootstrapChild?(ctx: { + node: AppNode; + input: string; + child: AppNode; + }): boolean; + onMissingApi?(ctx: { node: AppNode; apiRefId: string }): void; + hasSignInPage(node?: AppNode): boolean; +}): { + bootstrapApp: { element: JSX.Element; tree: AppTree }; + requiresSignIn: boolean; +} { + const signInPageNode = getAppRootNode(options.tree)?.edges.attachments.get( + 'signInPage', + )?.[0]; + + instantiateAndInitializePhaseTree({ + tree: options.tree, + apis: options.apis, + collector: options.collector, + extensionFactoryMiddleware: options.extensionFactoryMiddleware, + routeResolutionApi: options.routeResolutionApi, + appTreeApi: options.appTreeApi, + routeRefsById: options.routeRefsById, + stopAtAttachment: ({ node, input }) => + isSessionBoundaryAttachment(node, input), + skipChild: options.skipBootstrapChild, + onMissingApi: options.onMissingApi, + }); + + const element = options.tree.root.instance?.getData( + coreExtensionData.reactElement, + ); + if (!element) { + throw new Error('Expected bootstrap tree to expose a root element'); + } + + return { + bootstrapApp: { + element, + tree: options.tree, + }, + requiresSignIn: + !options.disableSignIn && options.hasSignInPage(signInPageNode), + }; +} + +/** + * Splits the app tree into bootstrap-visible and deferred regions. + * + * Predicate-gated roots are deferred to finalization, while any predicate that + * still leaks into the bootstrap-visible region is reported and ignored. + */ +export function classifyBootstrapTree(options: { + tree: AppTree; + collector: ErrorCollector; +}): BootstrapClassification { + const apiNodes = options.tree.root.edges.attachments.get('apis') ?? []; + const deferredApiRoots = new Set( + apiNodes.filter(apiNode => subtreeContainsPredicate(apiNode)), + ); + const appRootElementNodes = + getAppRootNode(options.tree)?.edges.attachments.get('elements') ?? []; + const deferredElementRoots = new Set( + appRootElementNodes.filter(elementNode => + subtreeContainsPredicate(elementNode), + ), + ); + const deferredRoots = new Set([ + ...deferredApiRoots, + ...deferredElementRoots, + ]); + const bootstrapNodes = collectBootstrapVisibleNodes(options.tree, { + deferredRoots, + }); + + for (const node of bootstrapNodes) { + if (node.spec.if === undefined) { + continue; + } + + options.collector.report({ + code: 'EXTENSION_BOOTSTRAP_PREDICATE_IGNORED', + message: + `Extension '${node.spec.id}' uses 'if' during bootstrap, so the predicate was ignored. ` + + "Move it behind 'app/root.children', onto a deferred 'app/root.elements' subtree, or into an API subtree.", + context: { + node, + }, + }); + (node.spec as typeof node.spec & { if?: FilterPredicate }).if = undefined; + } + + return { + deferredApiRoots, + deferredElementRoots, + deferredRoots, + }; +} + +/** + * Prepares the app tree for finalization by removing the bootstrap-only + * sign-in attachment from the app root boundary. + */ +export function prepareFinalizedTree(options: { tree: AppTree }) { + for (const appRootNode of getFinalizationBoundaryNodes(options.tree)) { + const attachments = appRootNode.edges.attachments as Map; + attachments.delete('signInPage'); + } +} + +/** + * Clears instances inside the finalization boundary so those nodes can be + * re-instantiated with finalized predicate context and API availability. + */ +export function clearFinalizationBoundaryInstances(tree: AppTree) { + clearNodeInstance(tree.root); + + const visited = new Set(); + function visit(node: AppNode) { + if (visited.has(node)) { + return; + } + visited.add(node); + clearNodeInstance(node); + + for (const [input, children] of node.edges.attachments) { + // app/root.elements is allowed to keep its bootstrap instances so we only + // re-run the parts of the boundary that actually change at finalization. + if (node.spec.id === 'app/root' && input === 'elements') { + continue; + } + + for (const child of children) { + visit(child); + } + } + } + + for (const appRootNode of getFinalizationBoundaryNodes(tree)) { + visit(appRootNode); + } +} + +/** + * Identifies the attachment that separates bootstrap rendering from the + * children that are deferred until finalization. + */ +export function isSessionBoundaryAttachment(node: AppNode, input: string) { + return node.spec.id === 'app/root' && input === 'children'; +} + +/** + * Injects a synthetic finalization child that throws the captured bootstrap + * error when rendered. + * + * This lets the finalized tree reuse the normal app root error boundary rather + * than introducing a separate error rendering path. + */ +export function attachThrowingFinalizationChild(tree: AppTree, error: Error) { + const bootstrapChildNode = + getAppRootNode(tree)?.edges.attachments.get('children')?.[0]; + if (!bootstrapChildNode) { + throw error; + } + + function ThrowBootstrapError(): never { + throw error; + } + + // This synthetic child gives the finalized tree a stable place to rethrow + // bootstrap failures through the normal extension boundary stack. + (bootstrapChildNode as AppNode & { instance?: AppNodeInstance }).instance = { + getDataRefs() { + return [coreExtensionData.reactElement].values(); + }, + getData(dataRef: ExtensionDataRef) { + if (dataRef.id === coreExtensionData.reactElement.id) { + return () as TValue; + } + return undefined; + }, + }; +} + +function getAppRootNode(tree: AppTree) { + return tree.nodes.get('app/root'); +} + +function getFinalizationBoundaryNodes(tree: AppTree): AppNode[] { + const nodes = new Set(); + const appRootNode = getAppRootNode(tree); + if (appRootNode) { + nodes.add(appRootNode); + } + const attachedAppRootNode = tree.root.edges.attachments.get('app')?.[0]; + if (attachedAppRootNode) { + nodes.add(attachedAppRootNode); + } + return Array.from(nodes); +} + +function clearNodeInstance(node: AppNode) { + (node as AppNode & { instance?: AppNodeInstance }).instance = undefined; +} + +function collectBootstrapVisibleNodes( + tree: AppTree, + options?: { deferredRoots?: Set }, +) { + const visibleNodes = new Set(); + + function visit(node: AppNode) { + if (visibleNodes.has(node)) { + return; + } + visibleNodes.add(node); + + for (const [input, children] of node.edges.attachments) { + if (isSessionBoundaryAttachment(node, input)) { + continue; + } + + for (const child of children) { + if (options?.deferredRoots?.has(child)) { + continue; + } + visit(child); + } + } + } + + visit(tree.root); + + return visibleNodes; +} + +function subtreeContainsPredicate(root: AppNode) { + const visited = new Set(); + + function visit(node: AppNode): boolean { + if (visited.has(node)) { + return false; + } + visited.add(node); + + if (node.spec.if !== undefined) { + return true; + } + + for (const children of node.edges.attachments.values()) { + for (const child of children) { + if (visit(child)) { + return true; + } + } + } + + return false; + } + + return visit(root); +} From da11157729f8241af800bb78c34d9dade183336b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Mar 2026 14:43:15 +0100 Subject: [PATCH 60/68] frontend-app-api: move session reuse to prepare time Remove the late-bound finalize session override so prepared apps accept reusable session state in one place. This keeps bootstrap and finalization semantics aligned and simplifies the prepared app API. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../src/wiring/createSpecializedApp.test.tsx | 26 ------------------- .../src/wiring/prepareSpecializedApp.tsx | 24 ++++++++--------- 2 files changed, 11 insertions(+), 39 deletions(-) diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx index 3d5cedbf30..cd1356d4cb 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx @@ -941,32 +941,6 @@ describe('createSpecializedApp', () => { ); }); - 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'), diff --git a/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx b/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx index 546ca4200c..13b5c8e8f0 100644 --- a/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx +++ b/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx @@ -246,9 +246,7 @@ export type PrepareSpecializedAppOptions = { export type PreparedSpecializedApp = { getBootstrapApp(): BootstrapSpecializedApp; onFinalized(callback: (app: FinalizedSpecializedApp) => void): () => void; - finalize(options?: { - sessionState?: SpecializedAppSessionState; - }): FinalizedSpecializedApp; + finalize(): FinalizedSpecializedApp; }; // Internal options type, not exported in the public API @@ -591,25 +589,25 @@ export function prepareSpecializedApp( subscribed = false; }; }, - finalize(finalizeOptions?: { sessionState?: SpecializedAppSessionState }) { + finalize() { finalization.selectMode('finalize'); if (finalized) { return finalized; } - if (!finalizeOptions?.sessionState) { + if (!providedSessionState) { // finalize() still depends on bootstrap classification and sign-in - // discovery, so we make sure the bootstrap tree has been prepared first. + // discovery unless a reusable session was supplied up front, so we make + // sure the bootstrap tree has been prepared first. getBootstrapApp(); } // Direct finalization never waits for async session preparation. Callers - // must either supply sessionState or invoke finalize() only when the - // predicate context is already available synchronously. - const finalizedSessionState = - finalizeOptions?.sessionState ?? - (signInRuntime?.requiresSignIn - ? undefined - : getSynchronousSessionState()); + // must either provide sessionState during prepareSpecializedApp() or + // invoke finalize() only when the predicate context is already available + // synchronously. + const finalizedSessionState = signInRuntime?.requiresSignIn + ? undefined + : getSynchronousSessionState(); if (!finalizedSessionState) { if (signInRuntime?.requiresSignIn) { throw new Error( From d7ed46b83cc953a6160c9984d5c7cf8c0ab9af2e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Mar 2026 17:02:01 +0100 Subject: [PATCH 61/68] frontend-app-api: follow up phased app review feedback Fix utility API resolution for falsy values and clarify how phased app finalization is owned between onFinalized and finalize. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- docs/frontend-system/architecture/10-app.md | 18 ++++- .../src/wiring/FrontendApiRegistry.test.ts | 72 +++++++++++++++++++ .../src/wiring/FrontendApiRegistry.ts | 4 +- 3 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 packages/frontend-app-api/src/wiring/FrontendApiRegistry.test.ts diff --git a/docs/frontend-system/architecture/10-app.md b/docs/frontend-system/architecture/10-app.md index fde39fc8b0..3e7c760a42 100644 --- a/docs/frontend-system/architecture/10-app.md +++ b/docs/frontend-system/architecture/10-app.md @@ -52,7 +52,7 @@ For information on how to configure feature discovery and other installation opt Most apps should use `createApp` from `@backstage/frontend-defaults`, which takes care of all app preparation internally. For more advanced use cases there is also a lower-level `prepareSpecializedApp` API in `@backstage/frontend-app-api`. -This API is useful when you need to render a bootstrap tree before the full app can be finalized, for example while waiting for sign-in or other session-dependent state. It gives you access to a bootstrap app tree immediately, notifies you when the finalized app is ready, and lets you reuse a prepared session in a later app instance. +This API is useful when you need to render a bootstrap tree before the full app can be finalized, for example while waiting for sign-in or other session-dependent state. It gives you access to a bootstrap app tree immediately, lets you either subscribe to finalization with `onFinalized()` or finalize synchronously with `finalize()`, and lets you reuse a prepared session in a later app instance. ```tsx import { @@ -74,7 +74,21 @@ const unsubscribe = preparedApp.onFinalized( ); ``` -The `getBootstrapApp()` method exposes the partial app tree that is available during bootstrap. The `onFinalized()` method notifies you once the full app tree has been finalized, and `finalize(sessionState?)` can be used when you already have a reusable session state or when you want to bypass the asynchronous bootstrap flow in tests. +The `getBootstrapApp()` method exposes the partial app tree that is available during bootstrap. If you call `onFinalized()`, you are subscribing to the bootstrap-owned finalization flow. In the sign-in case, the sign-in page receives an `onSignInSuccess` callback, and once it provides an identity through that callback the full app is finalized and `onFinalized()` subscribers are notified. + +If you instead call `finalize()`, you are taking ownership of finalization yourself. This only works when the app can be finalized synchronously, for example when all predicate context is already available or when you passed a reusable session state to `prepareSpecializedApp()` up front: + +```tsx +const preparedApp = prepareSpecializedApp({ + config, + features: [appPlugin, ...features], + advanced: { + sessionState, + }, +}); + +const app = preparedApp.finalize(); +``` When using phased app preparation, `app/root.children` acts as the main session boundary. Conditional extensions behind that boundary are evaluated during finalization. Conditional `app/root.elements` and API branches are also deferred until finalization, while other bootstrap-visible predicates are ignored and reported as warnings. diff --git a/packages/frontend-app-api/src/wiring/FrontendApiRegistry.test.ts b/packages/frontend-app-api/src/wiring/FrontendApiRegistry.test.ts new file mode 100644 index 0000000000..65e6010182 --- /dev/null +++ b/packages/frontend-app-api/src/wiring/FrontendApiRegistry.test.ts @@ -0,0 +1,72 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + type AnyApiFactory, + createApiRef, +} from '@backstage/frontend-plugin-api'; +import { + FrontendApiRegistry, + FrontendApiResolver, +} from './FrontendApiRegistry'; + +describe('FrontendApiResolver', () => { + it('should cache falsy API values', () => { + const falseApiRef = createApiRef({ id: 'test.false' }); + const falseFactoryFn = jest.fn(() => false); + const registry = new FrontendApiRegistry(); + + registry.register({ + api: falseApiRef, + deps: {}, + factory: falseFactoryFn, + } as AnyApiFactory); + + const resolver = new FrontendApiResolver({ primaryRegistry: registry }); + + expect(resolver.get(falseApiRef)).toBe(false); + expect(resolver.get(falseApiRef)).toBe(false); + expect(falseFactoryFn).toHaveBeenCalledTimes(1); + }); + + it('should resolve falsy dependencies', () => { + const falseApiRef = createApiRef({ id: 'test.false' }); + const dependentApiRef = createApiRef({ id: 'test.dependent' }); + const falseFactoryFn = jest.fn(() => false); + const dependentFactoryFn = jest.fn((deps: { falseDependency: boolean }) => + deps.falseDependency === false ? 'resolved' : 'unexpected', + ); + const registry = new FrontendApiRegistry(); + + registry.register({ + api: falseApiRef, + deps: {}, + factory: falseFactoryFn, + } as AnyApiFactory); + registry.register({ + api: dependentApiRef, + deps: { falseDependency: falseApiRef }, + factory: dependentFactoryFn, + } as AnyApiFactory); + + const resolver = new FrontendApiResolver({ primaryRegistry: registry }); + + expect(resolver.get(dependentApiRef)).toBe('resolved'); + expect(dependentFactoryFn).toHaveBeenCalledWith({ + falseDependency: false, + }); + }); +}); diff --git a/packages/frontend-app-api/src/wiring/FrontendApiRegistry.ts b/packages/frontend-app-api/src/wiring/FrontendApiRegistry.ts index 448fe535e0..00d565170a 100644 --- a/packages/frontend-app-api/src/wiring/FrontendApiRegistry.ts +++ b/packages/frontend-app-api/src/wiring/FrontendApiRegistry.ts @@ -107,7 +107,7 @@ export class FrontendApiResolver implements ApiHolder { private load(ref: ApiRef, loading: AnyApiRef[] = []): T | undefined { const existing = this.apis.get(ref.id); - if (existing) { + if (this.apis.has(ref.id)) { return existing as T; } @@ -124,7 +124,7 @@ export class FrontendApiResolver implements ApiHolder { 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) { + if (dep === undefined) { throw new Error( `No API factory available for dependency ${depRef} of dependent ${factory.api}`, ); From c6d96e742652bf8560243785c2a58e0a29c91274 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Mar 2026 19:21:36 +0100 Subject: [PATCH 62/68] frontend-app-api: address remaining review feedback Move the deprecated createSpecializedApp API holder option back under advanced and disable DataLoader caching in IdentityPermissionApi so batching stays limited to same-tick requests. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../src/apis/IdentityPermissionApi.test.ts | 97 +++++++++++++++++++ .../src/apis/IdentityPermissionApi.ts | 3 + 2 files changed, 100 insertions(+) create mode 100644 plugins/permission-react/src/apis/IdentityPermissionApi.test.ts diff --git a/plugins/permission-react/src/apis/IdentityPermissionApi.test.ts b/plugins/permission-react/src/apis/IdentityPermissionApi.test.ts new file mode 100644 index 0000000000..42b40e38da --- /dev/null +++ b/plugins/permission-react/src/apis/IdentityPermissionApi.test.ts @@ -0,0 +1,97 @@ +/* + * 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 { ConfigReader } from '@backstage/config'; +import { mockApis } from '@backstage/test-utils'; +import { + createPermission, + PermissionClient, +} from '@backstage/plugin-permission-common'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; +import { IdentityPermissionApi } from './IdentityPermissionApi'; + +describe('IdentityPermissionApi', () => { + const permission = createPermission({ + name: 'test.permission', + attributes: {}, + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should batch requests that arrive on the same tick', async () => { + const authorizeSpy = jest + .spyOn(PermissionClient.prototype, 'authorize') + .mockResolvedValue([ + { result: AuthorizeResult.ALLOW }, + { result: AuthorizeResult.DENY }, + ]); + const api = IdentityPermissionApi.create({ + config: new ConfigReader({}), + discovery: mockApis.discovery(), + identity: mockApis.identity(), + }); + + const firstRequest = { permission }; + const secondRequest = { permission }; + const [firstResponse, secondResponse] = await Promise.all([ + api.authorize(firstRequest), + api.authorize(secondRequest), + ]); + + expect(firstResponse.result).toBe(AuthorizeResult.ALLOW); + expect(secondResponse.result).toBe(AuthorizeResult.DENY); + expect(authorizeSpy).toHaveBeenCalledTimes(1); + expect(authorizeSpy).toHaveBeenCalledWith( + [firstRequest, secondRequest], + expect.anything(), + ); + }); + + it('should not cache requests across ticks', async () => { + const authorizeSpy = jest + .spyOn(PermissionClient.prototype, 'authorize') + .mockResolvedValue([{ result: AuthorizeResult.ALLOW }]); + const identityApi = mockApis.identity(); + const credentialsSpy = jest + .spyOn(identityApi, 'getCredentials') + .mockResolvedValueOnce({ token: 'first-token' }) + .mockResolvedValueOnce({ token: 'second-token' }); + const api = IdentityPermissionApi.create({ + config: new ConfigReader({}), + discovery: mockApis.discovery(), + identity: identityApi, + }); + + const request = { permission }; + await api.authorize(request); + await api.authorize(request); + + expect(authorizeSpy).toHaveBeenCalledTimes(2); + expect(credentialsSpy).toHaveBeenCalledTimes(2); + expect(authorizeSpy).toHaveBeenNthCalledWith( + 1, + [request], + expect.objectContaining({ token: 'first-token' }), + ); + expect(authorizeSpy).toHaveBeenNthCalledWith( + 2, + [request], + expect.objectContaining({ token: 'second-token' }), + ); + }); +}); diff --git a/plugins/permission-react/src/apis/IdentityPermissionApi.ts b/plugins/permission-react/src/apis/IdentityPermissionApi.ts index 4a8edefd84..cee1355fd4 100644 --- a/plugins/permission-react/src/apis/IdentityPermissionApi.ts +++ b/plugins/permission-react/src/apis/IdentityPermissionApi.ts @@ -45,6 +45,9 @@ export class IdentityPermissionApi implements PermissionApi { const credentials = await identityApi.getCredentials(); return permissionClient.authorize([...requests], credentials); }, + { + cache: false, + }, ); } From 0d6596f4e7db0d3d5fcd672d8969db6b9a28cdf1 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 17 Mar 2026 11:41:27 +0100 Subject: [PATCH 63/68] frontend-app-api: apply review follow-up cleanup Tighten a few small follow-up details from review by improving stack traces in permission batching, simplifying synthetic child refs, and making predicate traversal narrowing more direct. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../frontend-app-api/src/wiring/predicates.ts | 20 ++++++++----------- .../src/wiring/treeLifecycle.tsx | 2 +- .../src/apis/IdentityPermissionApi.ts | 2 +- 3 files changed, 10 insertions(+), 14 deletions(-) diff --git a/packages/frontend-app-api/src/wiring/predicates.ts b/packages/frontend-app-api/src/wiring/predicates.ts index 819036fc17..4ae9c8f135 100644 --- a/packages/frontend-app-api/src/wiring/predicates.ts +++ b/packages/frontend-app-api/src/wiring/predicates.ts @@ -160,21 +160,17 @@ function extractPredicateKeyNames( if (typeof predicate !== 'object' || predicate === null) { return []; } - const obj = predicate as Record; - if (Array.isArray(obj.$all)) { - return (obj.$all as FilterPredicate[]).flatMap(p => - extractPredicateKeyNames(p, key), - ); + const typedPredicate = predicate as FilterPredicate & Record; + if ('$all' in typedPredicate && Array.isArray(typedPredicate.$all)) { + return typedPredicate.$all.flatMap(p => extractPredicateKeyNames(p, key)); } - if (Array.isArray(obj.$any)) { - return (obj.$any as FilterPredicate[]).flatMap(p => - extractPredicateKeyNames(p, key), - ); + if ('$any' in typedPredicate && Array.isArray(typedPredicate.$any)) { + return typedPredicate.$any.flatMap(p => extractPredicateKeyNames(p, key)); } - if (obj.$not !== undefined) { - return extractPredicateKeyNames(obj.$not as FilterPredicate, key); + if ('$not' in typedPredicate && typedPredicate.$not !== undefined) { + return extractPredicateKeyNames(typedPredicate.$not, key); } - const value = obj[key]; + const value = typedPredicate[key]; if (typeof value === 'object' && value !== null && !Array.isArray(value)) { const contains = (value as Record).$contains; if (typeof contains === 'string') { diff --git a/packages/frontend-app-api/src/wiring/treeLifecycle.tsx b/packages/frontend-app-api/src/wiring/treeLifecycle.tsx index e78d258949..671464359a 100644 --- a/packages/frontend-app-api/src/wiring/treeLifecycle.tsx +++ b/packages/frontend-app-api/src/wiring/treeLifecycle.tsx @@ -227,7 +227,7 @@ export function attachThrowingFinalizationChild(tree: AppTree, error: Error) { // bootstrap failures through the normal extension boundary stack. (bootstrapChildNode as AppNode & { instance?: AppNodeInstance }).instance = { getDataRefs() { - return [coreExtensionData.reactElement].values(); + return [coreExtensionData.reactElement]; }, getData(dataRef: ExtensionDataRef) { if (dataRef.id === coreExtensionData.reactElement.id) { diff --git a/plugins/permission-react/src/apis/IdentityPermissionApi.ts b/plugins/permission-react/src/apis/IdentityPermissionApi.ts index cee1355fd4..f7c3c95107 100644 --- a/plugins/permission-react/src/apis/IdentityPermissionApi.ts +++ b/plugins/permission-react/src/apis/IdentityPermissionApi.ts @@ -67,6 +67,6 @@ export class IdentityPermissionApi implements PermissionApi { async authorize( request: AuthorizePermissionRequest, ): Promise { - return this.loader.load(request); + return await this.loader.load(request); } } From 46b530378d1f3fdfa4f3d04a57c16b6eb8af3ae8 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 17 Mar 2026 12:18:04 +0100 Subject: [PATCH 64/68] frontend-app-api: revert predicate traversal cleanup Restore the more defensive predicate traversal implementation after the narrowing cleanup turned out not to be worth the type fallout. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../frontend-app-api/src/wiring/predicates.ts | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/frontend-app-api/src/wiring/predicates.ts b/packages/frontend-app-api/src/wiring/predicates.ts index 4ae9c8f135..819036fc17 100644 --- a/packages/frontend-app-api/src/wiring/predicates.ts +++ b/packages/frontend-app-api/src/wiring/predicates.ts @@ -160,17 +160,21 @@ function extractPredicateKeyNames( if (typeof predicate !== 'object' || predicate === null) { return []; } - const typedPredicate = predicate as FilterPredicate & Record; - if ('$all' in typedPredicate && Array.isArray(typedPredicate.$all)) { - return typedPredicate.$all.flatMap(p => extractPredicateKeyNames(p, key)); + const obj = predicate as Record; + if (Array.isArray(obj.$all)) { + return (obj.$all as FilterPredicate[]).flatMap(p => + extractPredicateKeyNames(p, key), + ); } - if ('$any' in typedPredicate && Array.isArray(typedPredicate.$any)) { - return typedPredicate.$any.flatMap(p => extractPredicateKeyNames(p, key)); + if (Array.isArray(obj.$any)) { + return (obj.$any as FilterPredicate[]).flatMap(p => + extractPredicateKeyNames(p, key), + ); } - if ('$not' in typedPredicate && typedPredicate.$not !== undefined) { - return extractPredicateKeyNames(typedPredicate.$not, key); + if (obj.$not !== undefined) { + return extractPredicateKeyNames(obj.$not as FilterPredicate, key); } - const value = typedPredicate[key]; + const value = obj[key]; if (typeof value === 'object' && value !== null && !Array.isArray(value)) { const contains = (value as Record).$contains; if (typeof contains === 'string') { From 7ffb61b703df77c42f01a47006cd5b4c8c2f09f1 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 17 Mar 2026 13:19:10 +0100 Subject: [PATCH 65/68] frontend-app-api: fix rebased test type import Remove a stale createSpecializedApp test import that started failing repository type checking after the rebase. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../frontend-app-api/src/wiring/createSpecializedApp.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx index cd1356d4cb..3e841a1283 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx @@ -16,7 +16,6 @@ import { AppTreeApi, - type ApiRef, appTreeApiRef, coreExtensionData, createExtension, From 11328fc31b10f30ae1f4c3c3d3fec9643a4eddd8 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 17 Mar 2026 13:37:17 +0100 Subject: [PATCH 66/68] frontend-plugin-api: fix alpha API report exports Re-export the PluginWrapperBlueprint support types from the alpha entrypoint and regenerate the affected API reports so verify passes after the rebase. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../frontend-plugin-api/report-alpha.api.md | 599 +++++++++++++++++- packages/frontend-plugin-api/report.api.md | 185 +++--- packages/frontend-plugin-api/src/alpha.ts | 37 ++ plugins/app/report.api.md | 2 +- 4 files changed, 723 insertions(+), 100 deletions(-) diff --git a/packages/frontend-plugin-api/report-alpha.api.md b/packages/frontend-plugin-api/report-alpha.api.md index 921ad1039c..56856fde08 100644 --- a/packages/frontend-plugin-api/report-alpha.api.md +++ b/packages/frontend-plugin-api/report-alpha.api.md @@ -3,13 +3,572 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import { ApiRef } from '@backstage/frontend-plugin-api'; +import { ApiRef as ApiRef_2 } from '@backstage/core-plugin-api'; import { ComponentType } from 'react'; -import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; -import { ExtensionBlueprint } from '@backstage/frontend-plugin-api'; -import { ExtensionBlueprintParams } from '@backstage/frontend-plugin-api'; -import { ExtensionDataRef } from '@backstage/frontend-plugin-api'; +import { Expand } from '@backstage/types'; +import { FilterPredicate } from '@backstage/filter-predicates'; +import { JsonObject } from '@backstage/types'; +import { JSX as JSX_2 } from 'react'; import { ReactNode } from 'react'; +import type { z } from 'zod'; + +// @public +export type AnyRouteRefParams = + | { + [param in string]: string; + } + | undefined; + +// @public +export type ApiHolder = { + get(api: ApiRef): T | undefined; +}; + +// @public +export type ApiRef = { + readonly $$type?: '@backstage/ApiRef'; + readonly id: TId; + readonly T: T; +}; + +// @public +export interface AppNode { + readonly edges: AppNodeEdges; + readonly instance?: AppNodeInstance; + readonly spec: AppNodeSpec; +} + +// @public +export interface AppNodeEdges { + // (undocumented) + readonly attachedTo?: { + node: AppNode; + input: string; + }; + // (undocumented) + readonly attachments: ReadonlyMap; +} + +// @public +export interface AppNodeInstance { + getData(ref: ExtensionDataRef): T | undefined; + getDataRefs(): Iterable>; +} + +// @public +export interface AppNodeSpec { + // (undocumented) + readonly attachTo: ExtensionAttachTo; + // (undocumented) + readonly config?: unknown; + // (undocumented) + readonly disabled: boolean; + // (undocumented) + readonly extension: Extension; + // (undocumented) + readonly id: string; + // (undocumented) + readonly if?: FilterPredicate; + // (undocumented) + readonly plugin: FrontendPlugin; +} + +// @public +export interface AppTree { + readonly nodes: ReadonlyMap; + readonly orphans: Iterable; + readonly root: AppNode; +} + +// @public (undocumented) +export interface ConfigurableExtensionDataRef< + TData, + TId extends string, + TConfig extends { + optional?: true; + } = {}, +> extends ExtensionDataRef { + // (undocumented) + (t: TData): ExtensionDataValue; + // (undocumented) + optional(): ConfigurableExtensionDataRef< + TData, + TId, + TConfig & { + optional: true; + } + >; +} + +// @public +export function createExtensionBlueprintParams( + params: T, +): ExtensionBlueprintParams; + +// @public (undocumented) +export interface Extension { + // (undocumented) + $$type: '@backstage/Extension'; + // (undocumented) + readonly attachTo: ExtensionAttachTo; + // (undocumented) + readonly configSchema?: PortableSchema; + // (undocumented) + readonly disabled: boolean; + // (undocumented) + readonly id: string; +} + +// @public (undocumented) +export type ExtensionAttachTo = { + id: string; + input: string; +}; + +// @public (undocumented) +export interface ExtensionBlueprint< + T extends ExtensionBlueprintParameters = ExtensionBlueprintParameters, +> { + // (undocumented) + dataRefs: T['dataRefs']; + // (undocumented) + make< + TName extends string | undefined, + TParamsInput extends AnyParamsInput>, + UParentInputs extends ExtensionDataRef, + >(args: { + name?: TName; + attachTo?: ExtensionDefinitionAttachTo & + VerifyExtensionAttachTo, UParentInputs>; + disabled?: boolean; + if?: FilterPredicate; + params: TParamsInput extends ExtensionBlueprintDefineParams + ? TParamsInput + : T['params'] extends ExtensionBlueprintDefineParams + ? 'Error: This blueprint uses advanced parameter types and requires you to pass parameters as using the following callback syntax: `.make({ params: defineParams => defineParams() })`' + : T['params']; + }): OverridableExtensionDefinition<{ + kind: T['kind']; + name: string | undefined extends TName ? undefined : TName; + config: T['config']; + configInput: T['configInput']; + output: T['output']; + inputs: T['inputs']; + params: T['params']; + }>; + makeWithOverrides< + TName extends string | undefined, + TExtensionConfigSchema extends { + [key in string]: (zImpl: typeof z) => z.ZodType; + }, + UFactoryOutput extends ExtensionDataValue, + UNewOutput extends ExtensionDataRef, + UParentInputs extends ExtensionDataRef, + TExtraInputs extends { + [inputName in string]: ExtensionInput; + } = {}, + >(args: { + name?: TName; + attachTo?: ExtensionDefinitionAttachTo & + VerifyExtensionAttachTo< + ExtensionDataRef extends UNewOutput + ? NonNullable + : UNewOutput, + UParentInputs + >; + disabled?: boolean; + if?: FilterPredicate; + inputs?: TExtraInputs & { + [KName in keyof T['inputs']]?: `Error: Input '${KName & + string}' is already defined in parent definition`; + }; + output?: Array; + config?: { + schema: TExtensionConfigSchema & { + [KName in keyof T['config']]?: `Error: Config key '${KName & + string}' is already defined in parent schema`; + }; + }; + factory( + originalFactory: < + TParamsInput extends AnyParamsInput>, + >( + params: TParamsInput extends ExtensionBlueprintDefineParams + ? TParamsInput + : T['params'] extends ExtensionBlueprintDefineParams + ? 'Error: This blueprint uses advanced parameter types and requires you to pass parameters as using the following callback syntax: `originalFactory(defineParams => defineParams())`' + : T['params'], + context?: { + config?: T['config']; + inputs?: ResolvedInputValueOverrides>; + }, + ) => ExtensionDataContainer>, + context: { + node: AppNode; + apis: ApiHolder; + config: T['config'] & { + [key in keyof TExtensionConfigSchema]: z.infer< + ReturnType + >; + }; + inputs: Expand>; + }, + ): Iterable & + VerifyExtensionFactoryOutput< + ExtensionDataRef extends UNewOutput + ? NonNullable + : UNewOutput, + UFactoryOutput + >; + }): OverridableExtensionDefinition<{ + config: Expand< + (string extends keyof TExtensionConfigSchema + ? {} + : { + [key in keyof TExtensionConfigSchema]: z.infer< + ReturnType + >; + }) & + T['config'] + >; + configInput: Expand< + (string extends keyof TExtensionConfigSchema + ? {} + : z.input< + z.ZodObject<{ + [key in keyof TExtensionConfigSchema]: ReturnType< + TExtensionConfigSchema[key] + >; + }> + >) & + T['configInput'] + >; + output: ExtensionDataRef extends UNewOutput ? T['output'] : UNewOutput; + inputs: Expand; + kind: T['kind']; + name: string | undefined extends TName ? undefined : TName; + params: T['params']; + }>; +} + +// @public +export type ExtensionBlueprintDefineParams< + TParams extends object = object, + TInput = any, +> = (params: TInput) => ExtensionBlueprintParams; + +// @public (undocumented) +export type ExtensionBlueprintParameters = { + kind: string; + params?: object | ExtensionBlueprintDefineParams; + configInput?: { + [K in string]: any; + }; + config?: { + [K in string]: any; + }; + output?: ExtensionDataRef; + inputs?: { + [KName in string]: ExtensionInput; + }; + dataRefs?: { + [name in string]: ExtensionDataRef; + }; +}; + +// @public +export type ExtensionBlueprintParams = { + $$type: '@backstage/BlueprintParams'; + T: T; +}; + +// @public (undocumented) +export type ExtensionDataContainer = + Iterable< + UExtensionData extends ExtensionDataRef< + infer IData, + infer IId, + infer IConfig + > + ? IConfig['optional'] extends true + ? never + : ExtensionDataValue + : never + > & { + get( + ref: ExtensionDataRef, + ): UExtensionData extends ExtensionDataRef + ? IConfig['optional'] extends true + ? IData | undefined + : IData + : never; + }; + +// @public (undocumented) +export type ExtensionDataRef< + TData = unknown, + TId extends string = string, + TConfig extends { + optional?: true; + } = { + optional?: true; + }, +> = { + readonly $$type: '@backstage/ExtensionDataRef'; + readonly id: TId; + readonly T: TData; + readonly config: TConfig; +}; + +// @public (undocumented) +export type ExtensionDataValue = { + readonly $$type: '@backstage/ExtensionDataValue'; + readonly id: TId; + readonly value: TData; +}; + +// @public (undocumented) +export interface ExtensionDefinition< + TParams extends ExtensionDefinitionParameters = ExtensionDefinitionParameters, +> { + // (undocumented) + $$type: '@backstage/ExtensionDefinition'; + // (undocumented) + readonly T: TParams; +} + +// @public +export type ExtensionDefinitionAttachTo< + UParentInputs extends ExtensionDataRef = ExtensionDataRef, +> = + | { + id: string; + input: string; + relative?: never; + } + | { + relative: { + kind?: string; + name?: string; + }; + input: string; + id?: never; + } + | ExtensionInput; + +// @public (undocumented) +export type ExtensionDefinitionParameters = { + kind?: string; + name?: string; + configInput?: { + [K in string]: any; + }; + config?: { + [K in string]: any; + }; + output?: ExtensionDataRef; + inputs?: { + [KName in string]: ExtensionInput; + }; + params?: object | ExtensionBlueprintDefineParams; +}; + +// @public (undocumented) +export interface ExtensionInput< + UExtensionData extends ExtensionDataRef< + unknown, + string, + { + optional?: true; + } + > = ExtensionDataRef, + TConfig extends { + singleton: boolean; + optional: boolean; + internal?: boolean; + } = { + singleton: boolean; + optional: boolean; + internal?: boolean; + }, +> { + // (undocumented) + readonly $$type: '@backstage/ExtensionInput'; + // (undocumented) + readonly config: TConfig; + // (undocumented) + readonly extensionData: Array; + // (undocumented) + readonly replaces?: Array<{ + id: string; + input: string; + }>; +} + +// @public +export interface ExternalRouteRef< + TParams extends AnyRouteRefParams = AnyRouteRefParams, +> { + // (undocumented) + readonly $$type: '@backstage/ExternalRouteRef'; + // (undocumented) + readonly T: TParams; +} + +// @public (undocumented) +export interface FrontendPlugin< + TRoutes extends { + [name in string]: RouteRef | SubRouteRef; + } = { + [name in string]: RouteRef | SubRouteRef; + }, + TExternalRoutes extends { + [name in string]: ExternalRouteRef; + } = { + [name in string]: ExternalRouteRef; + }, +> { + // (undocumented) + readonly $$type: '@backstage/FrontendPlugin'; + // (undocumented) + readonly externalRoutes: TExternalRoutes; + readonly icon?: IconElement; + // @deprecated + readonly id: string; + info(): Promise; + readonly pluginId: string; + // (undocumented) + readonly routes: TRoutes; + readonly title?: string; +} + +// @public +export interface FrontendPluginInfo { + description?: string; + links?: Array<{ + title: string; + url: string; + }>; + ownerEntityRefs?: string[]; + packageName?: string; + version?: string; +} + +// @public +export type IconElement = JSX_2.Element | null; + +// @public (undocumented) +export interface OverridableExtensionDefinition< + T extends ExtensionDefinitionParameters = ExtensionDefinitionParameters, +> extends ExtensionDefinition { + readonly inputs: { + [K in keyof T['inputs']]: ExtensionInput< + T['inputs'][K] extends ExtensionInput ? IData : never + >; + }; + // (undocumented) + override< + TExtensionConfigSchema extends { + [key in string]: (zImpl: typeof z) => z.ZodType; + }, + UFactoryOutput extends ExtensionDataValue, + UNewOutput extends ExtensionDataRef, + TExtraInputs extends { + [inputName in string]: ExtensionInput; + }, + TParamsInput extends AnyParamsInput_2>, + UParentInputs extends ExtensionDataRef, + >( + args: Expand< + { + attachTo?: ExtensionDefinitionAttachTo & + VerifyExtensionAttachTo< + ExtensionDataRef extends UNewOutput + ? NonNullable + : UNewOutput, + UParentInputs + >; + disabled?: boolean; + if?: FilterPredicate; + inputs?: TExtraInputs & { + [KName in keyof T['inputs']]?: `Error: Input '${KName & + string}' is already defined in parent definition`; + }; + output?: Array; + config?: { + schema: TExtensionConfigSchema & { + [KName in keyof T['config']]?: `Error: Config key '${KName & + string}' is already defined in parent schema`; + }; + }; + factory?( + originalFactory: < + TFactoryParamsReturn extends AnyParamsInput_2< + NonNullable + >, + >( + context?: Expand< + { + config?: T['config']; + inputs?: ResolvedInputValueOverrides>; + } & ([T['params']] extends [never] + ? {} + : { + params?: TFactoryParamsReturn extends ExtensionBlueprintDefineParams + ? TFactoryParamsReturn + : T['params'] extends ExtensionBlueprintDefineParams + ? 'Error: This blueprint uses advanced parameter types and requires you to pass parameters as using the following callback syntax: `originalFactory(defineParams => defineParams())`' + : Partial; + }) + >, + ) => ExtensionDataContainer>, + context: { + node: AppNode; + apis: ApiHolder; + config: T['config'] & { + [key in keyof TExtensionConfigSchema]: z.infer< + ReturnType + >; + }; + inputs: Expand>; + }, + ): Iterable; + } & ([T['params']] extends [never] + ? {} + : { + params?: TParamsInput extends ExtensionBlueprintDefineParams + ? TParamsInput + : T['params'] extends ExtensionBlueprintDefineParams + ? 'Error: This blueprint uses advanced parameter types and requires you to pass parameters as using the following callback syntax: `originalFactory(defineParams => defineParams())`' + : Partial; + }) + > & + VerifyExtensionFactoryOutput< + ExtensionDataRef extends UNewOutput + ? NonNullable + : UNewOutput, + UFactoryOutput + >, + ): OverridableExtensionDefinition<{ + kind: T['kind']; + name: T['name']; + output: ExtensionDataRef extends UNewOutput ? T['output'] : UNewOutput; + inputs: T['inputs'] & TExtraInputs; + config: T['config'] & { + [key in keyof TExtensionConfigSchema]: z.infer< + ReturnType + >; + }; + configInput: T['configInput'] & + z.input< + z.ZodObject<{ + [key in keyof TExtensionConfigSchema]: ReturnType< + TExtensionConfigSchema[key] + >; + }> + >; + }>; +} // @public export type PluginWrapperApi = { @@ -24,7 +583,7 @@ export type PluginWrapperApi = { }; // @public -export const pluginWrapperApiRef: ApiRef< +export const pluginWrapperApiRef: ApiRef_2< PluginWrapperApi, 'core.plugin-wrapper' > & { @@ -65,5 +624,33 @@ export type PluginWrapperDefinition = { }>; }; +// @public (undocumented) +export type PortableSchema = { + parse: (input: TInput) => TOutput; + schema: JsonObject; +}; + +// @public +export interface RouteRef< + TParams extends AnyRouteRefParams = AnyRouteRefParams, +> { + // (undocumented) + readonly $$type: '@backstage/RouteRef'; + // (undocumented) + readonly T: TParams; +} + +// @public +export interface SubRouteRef< + TParams extends AnyRouteRefParams = AnyRouteRefParams, +> { + // (undocumented) + readonly $$type: '@backstage/SubRouteRef'; + // (undocumented) + readonly path: string; + // (undocumented) + readonly T: TParams; +} + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index c265dd41f9..56e5b90725 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -3,17 +3,13 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import { AnyRouteRefParams as AnyRouteRefParams_2 } from '@backstage/frontend-plugin-api'; -import { ApiRef as ApiRef_2 } from '@backstage/frontend-plugin-api'; +import { ApiRef as ApiRef_2 } from '@backstage/core-plugin-api'; import { ComponentType } from 'react'; import type { Config } from '@backstage/config'; -import { ConfigurableExtensionDataRef as ConfigurableExtensionDataRef_2 } from '@backstage/frontend-plugin-api'; import { Expand } from '@backstage/types'; import { ExpandRecursive } from '@backstage/types'; import { ExtensionBlueprint as ExtensionBlueprint_2 } from '@backstage/frontend-plugin-api'; -import { ExtensionBlueprintParams as ExtensionBlueprintParams_2 } from '@backstage/frontend-plugin-api'; import { ExtensionDataRef as ExtensionDataRef_2 } from '@backstage/frontend-plugin-api'; -import { ExtensionInput as ExtensionInput_2 } from '@backstage/frontend-plugin-api'; import { FilterPredicate } from '@backstage/filter-predicates'; import { JsonObject } from '@backstage/types'; import { JsonValue } from '@backstage/types'; @@ -22,7 +18,6 @@ import { JSX as JSX_3 } from 'react/jsx-runtime'; import { Observable } from '@backstage/types'; import { PropsWithChildren } from 'react'; import { ReactNode } from 'react'; -import { SwappableComponentRef as SwappableComponentRef_2 } from '@backstage/frontend-plugin-api'; import type { z } from 'zod'; // @public @@ -87,12 +82,12 @@ export type AnalyticsImplementation = { }; // @public @deprecated -export const AnalyticsImplementationBlueprint: ExtensionBlueprint_2<{ +export const AnalyticsImplementationBlueprint: ExtensionBlueprint<{ kind: 'analytics'; params: ( params: AnalyticsImplementationFactory, - ) => ExtensionBlueprintParams_2>; - output: ExtensionDataRef_2< + ) => ExtensionBlueprintParams>; + output: ExtensionDataRef< AnalyticsImplementationFactory<{}>, 'core.analytics.factory', {} @@ -101,7 +96,7 @@ export const AnalyticsImplementationBlueprint: ExtensionBlueprint_2<{ config: {}; configInput: {}; dataRefs: { - factory: ConfigurableExtensionDataRef_2< + factory: ConfigurableExtensionDataRef< AnalyticsImplementationFactory<{}>, 'core.analytics.factory', {} @@ -151,7 +146,7 @@ export type AnyRouteRefParams = | undefined; // @public -export const ApiBlueprint: ExtensionBlueprint_2<{ +export const ApiBlueprint: ExtensionBlueprint<{ kind: 'api'; params: < TApi, @@ -159,13 +154,13 @@ export const ApiBlueprint: ExtensionBlueprint_2<{ TDeps extends { [name in string]: unknown }, >( params: ApiFactory, - ) => ExtensionBlueprintParams_2; - output: ExtensionDataRef_2; + ) => ExtensionBlueprintParams; + output: ExtensionDataRef; inputs: {}; config: {}; configInput: {}; dataRefs: { - factory: ConfigurableExtensionDataRef_2< + factory: ConfigurableExtensionDataRef< AnyApiFactory, 'core.api.factory', {} @@ -410,16 +405,16 @@ export interface ConfigurableExtensionDataRef< // @public (undocumented) export const coreExtensionData: { - title: ConfigurableExtensionDataRef_2; - icon: ConfigurableExtensionDataRef_2; - reactElement: ConfigurableExtensionDataRef_2< + title: ConfigurableExtensionDataRef; + icon: ConfigurableExtensionDataRef; + reactElement: ConfigurableExtensionDataRef< JSX_2.Element, 'core.reactElement', {} >; - routePath: ConfigurableExtensionDataRef_2; - routeRef: ConfigurableExtensionDataRef_2< - RouteRef, + routePath: ConfigurableExtensionDataRef; + routeRef: ConfigurableExtensionDataRef< + RouteRef, 'core.routing.ref', {} >; @@ -772,13 +767,47 @@ export function createFrontendPlugin< [name in string]: ExternalRouteRef; } = {}, >( - options: PluginOptions, + options: CreateFrontendPluginOptions< + TId, + TRoutes, + TExternalRoutes, + TExtensions + >, ): OverridableFrontendPlugin< TRoutes, TExternalRoutes, MakeSortedExtensionsMap >; +// @public +export interface CreateFrontendPluginOptions< + TId extends string, + TRoutes extends { + [name in string]: RouteRef | SubRouteRef; + }, + TExternalRoutes extends { + [name in string]: ExternalRouteRef; + }, + TExtensions extends readonly ExtensionDefinition[], +> { + // (undocumented) + extensions?: TExtensions; + // (undocumented) + externalRoutes?: TExternalRoutes; + // (undocumented) + featureFlags?: FeatureFlagConfig[]; + icon?: IconElement; + // (undocumented) + if?: FilterPredicate; + // (undocumented) + info?: FrontendPluginInfoOptions; + // (undocumented) + pluginId: TId; + // (undocumented) + routes?: TRoutes; + title?: string; +} + // @public export function createRouteRef< TParams extends @@ -955,7 +984,7 @@ export const errorApiRef: ApiRef_2 & { // @public (undocumented) export const ErrorDisplay: { (props: ErrorDisplayProps): JSX.Element | null; - ref: SwappableComponentRef_2; + ref: SwappableComponentRef; }; // @public (undocumented) @@ -994,7 +1023,7 @@ export interface ExtensionBlueprint< // (undocumented) make< TName extends string | undefined, - TParamsInput extends AnyParamsInput_2>, + TParamsInput extends AnyParamsInput>, UParentInputs extends ExtensionDataRef, >(args: { name?: TName; @@ -1051,7 +1080,7 @@ export interface ExtensionBlueprint< }; factory( originalFactory: < - TParamsInput extends AnyParamsInput_2>, + TParamsInput extends AnyParamsInput>, >( params: TParamsInput extends ExtensionBlueprintDefineParams ? TParamsInput @@ -1529,14 +1558,14 @@ export const microsoftAuthApiRef: ApiRef_2< }; // @public @deprecated -export const NavItemBlueprint: ExtensionBlueprint_2<{ +export const NavItemBlueprint: ExtensionBlueprint<{ kind: 'nav-item'; params: { title: string; icon: IconComponent; routeRef: RouteRef; }; - output: ExtensionDataRef_2< + output: ExtensionDataRef< { title: string; icon: IconComponent; @@ -1549,7 +1578,7 @@ export const NavItemBlueprint: ExtensionBlueprint_2<{ config: {}; configInput: {}; dataRefs: { - target: ConfigurableExtensionDataRef_2< + target: ConfigurableExtensionDataRef< { title: string; icon: IconComponent; @@ -1564,7 +1593,7 @@ export const NavItemBlueprint: ExtensionBlueprint_2<{ // @public (undocumented) export const NotFoundErrorPage: { (props: NotFoundErrorPageProps): JSX.Element | null; - ref: SwappableComponentRef_2; + ref: SwappableComponentRef; }; // @public (undocumented) @@ -1666,7 +1695,7 @@ export interface OverridableExtensionDefinition< TExtraInputs extends { [inputName in string]: ExtensionInput; }, - TParamsInput extends AnyParamsInput>, + TParamsInput extends AnyParamsInput_2>, UParentInputs extends ExtensionDataRef, >( args: Expand< @@ -1693,7 +1722,7 @@ export interface OverridableExtensionDefinition< }; factory?( originalFactory: < - TFactoryParamsReturn extends AnyParamsInput< + TFactoryParamsReturn extends AnyParamsInput_2< NonNullable >, >( @@ -1793,7 +1822,7 @@ export interface OverridableFrontendPlugin< } // @public -export const PageBlueprint: ExtensionBlueprint_2<{ +export const PageBlueprint: ExtensionBlueprint<{ kind: 'page'; params: { path: string; @@ -1804,23 +1833,23 @@ export const PageBlueprint: ExtensionBlueprint_2<{ noHeader?: boolean; }; output: - | ExtensionDataRef_2 - | ExtensionDataRef_2< - RouteRef, + | ExtensionDataRef + | ExtensionDataRef< + RouteRef, 'core.routing.ref', { optional: true; } > - | ExtensionDataRef_2 - | ExtensionDataRef_2< + | ExtensionDataRef + | ExtensionDataRef< string, 'core.title', { optional: true; } > - | ExtensionDataRef_2< + | ExtensionDataRef< IconElement, 'core.icon', { @@ -1828,24 +1857,24 @@ export const PageBlueprint: ExtensionBlueprint_2<{ } >; inputs: { - pages: ExtensionInput_2< - | ConfigurableExtensionDataRef_2 - | ConfigurableExtensionDataRef_2 - | ConfigurableExtensionDataRef_2< - RouteRef, + pages: ExtensionInput< + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef< + RouteRef, 'core.routing.ref', { optional: true; } > - | ConfigurableExtensionDataRef_2< + | ConfigurableExtensionDataRef< string, 'core.title', { optional: true; } > - | ConfigurableExtensionDataRef_2< + | ConfigurableExtensionDataRef< IconElement, 'core.icon', { @@ -1873,7 +1902,7 @@ export const PageBlueprint: ExtensionBlueprint_2<{ // @public export const PageLayout: { (props: PageLayoutProps): JSX.Element | null; - ref: SwappableComponentRef_2; + ref: SwappableComponentRef; }; // @public @@ -1915,14 +1944,14 @@ export type PendingOAuthRequest = { }; // @public -export const PluginHeaderActionBlueprint: ExtensionBlueprint_2<{ +export const PluginHeaderActionBlueprint: ExtensionBlueprint<{ kind: 'plugin-header-action'; params: (params: { loader: () => Promise; - }) => ExtensionBlueprintParams_2<{ + }) => ExtensionBlueprintParams<{ loader: () => Promise; }>; - output: ExtensionDataRef_2; + output: ExtensionDataRef; inputs: {}; config: {}; configInput: {}; @@ -1942,8 +1971,8 @@ export const pluginHeaderActionsApiRef: ApiRef_2< readonly $$type: '@backstage/ApiRef'; }; -// @public (undocumented) -export interface PluginOptions< +// @public @deprecated (undocumented) +export type PluginOptions< TId extends string, TRoutes extends { [name in string]: RouteRef | SubRouteRef; @@ -1952,24 +1981,7 @@ export interface PluginOptions< [name in string]: ExternalRouteRef; }, TExtensions extends readonly ExtensionDefinition[], -> { - // (undocumented) - extensions?: TExtensions; - // (undocumented) - externalRoutes?: TExternalRoutes; - // (undocumented) - featureFlags?: FeatureFlagConfig[]; - icon?: IconElement; - // (undocumented) - if?: FilterPredicate; - // (undocumented) - info?: FrontendPluginInfoOptions; - // (undocumented) - pluginId: TId; - // (undocumented) - routes?: TRoutes; - title?: string; -} +> = CreateFrontendPluginOptions; // @public export type PluginWrapperApi = { @@ -1992,14 +2004,14 @@ export const pluginWrapperApiRef: ApiRef_2< }; // @public -export const PluginWrapperBlueprint: ExtensionBlueprint_2<{ +export const PluginWrapperBlueprint: ExtensionBlueprint<{ kind: 'plugin-wrapper'; params: (params: { loader: () => Promise>; - }) => ExtensionBlueprintParams_2<{ + }) => ExtensionBlueprintParams<{ loader: () => Promise; }>; - output: ExtensionDataRef_2< + output: ExtensionDataRef< () => Promise, 'core.plugin-wrapper.loader', {} @@ -2008,7 +2020,7 @@ export const PluginWrapperBlueprint: ExtensionBlueprint_2<{ config: {}; configInput: {}; dataRefs: { - wrapper: ConfigurableExtensionDataRef_2< + wrapper: ConfigurableExtensionDataRef< () => Promise, 'core.plugin-wrapper.loader', {} @@ -2046,25 +2058,12 @@ export type ProfileInfoApi = { // @public (undocumented) export const Progress: { (props: ProgressProps): JSX.Element | null; - ref: SwappableComponentRef_2; + ref: SwappableComponentRef; }; // @public (undocumented) export type ProgressProps = {}; -// @public -export type ResolvedExtensionInputs< - TInputs extends { - [name in string]: ExtensionInput; - }, -> = { - [InputName in keyof TInputs]: false extends TInputs[InputName]['config']['singleton'] - ? Array>> - : false extends TInputs[InputName]['config']['optional'] - ? Expand> - : Expand | undefined>; -}; - // @public export type RouteFunc = ( ...input: TParams extends undefined ? readonly [] : readonly [params: TParams] @@ -2156,7 +2155,7 @@ export type StorageValueSnapshot = }; // @public -export const SubPageBlueprint: ExtensionBlueprint_2<{ +export const SubPageBlueprint: ExtensionBlueprint<{ kind: 'sub-page'; params: { path: string; @@ -2166,17 +2165,17 @@ export const SubPageBlueprint: ExtensionBlueprint_2<{ routeRef?: RouteRef; }; output: - | ExtensionDataRef_2 - | ExtensionDataRef_2< - RouteRef, + | ExtensionDataRef + | ExtensionDataRef< + RouteRef, 'core.routing.ref', { optional: true; } > - | ExtensionDataRef_2 - | ExtensionDataRef_2 - | ExtensionDataRef_2< + | ExtensionDataRef + | ExtensionDataRef + | ExtensionDataRef< IconElement, 'core.icon', { diff --git a/packages/frontend-plugin-api/src/alpha.ts b/packages/frontend-plugin-api/src/alpha.ts index dfd7f1e2bf..e822c13f88 100644 --- a/packages/frontend-plugin-api/src/alpha.ts +++ b/packages/frontend-plugin-api/src/alpha.ts @@ -20,6 +20,43 @@ export { PluginWrapperBlueprint, type PluginWrapperDefinition, } from './blueprints/PluginWrapperBlueprint'; +export type { + ConfigurableExtensionDataRef, + Extension, + ExtensionAttachTo, + ExtensionDefinition, + ExtensionDefinitionParameters, + ExtensionBlueprintDefineParams, + ExtensionBlueprint, + ExtensionBlueprintParameters, + ExtensionBlueprintParams, + ExtensionDataContainer, + ExtensionDataRef, + ExtensionDataValue, + ExtensionDefinitionAttachTo, + ExtensionInput, + FrontendPlugin, + OverridableExtensionDefinition, +} from './wiring'; +export type { + ApiHolder, + ApiRef, + AppNode, + AppNodeEdges, + AppNodeInstance, + AppNodeSpec, + AppTree, +} from './apis'; +export type { PortableSchema } from './schema'; +export type { + AnyRouteRefParams, + RouteRef, + SubRouteRef, + ExternalRouteRef, +} from './routing'; +export type { IconElement } from './icons'; +export type { FrontendPluginInfo } from './wiring'; +export { createExtensionBlueprintParams } from './wiring'; export { type PluginWrapperApi, pluginWrapperApiRef, diff --git a/plugins/app/report.api.md b/plugins/app/report.api.md index 211604bc10..4da77d055e 100644 --- a/plugins/app/report.api.md +++ b/plugins/app/report.api.md @@ -478,7 +478,7 @@ const appPlugin: OverridableFrontendPlugin< icons: ExtensionInput< ConfigurableExtensionDataRef< { - [x: string]: IconElement | IconComponent; + [x: string]: IconComponent | IconElement; }, 'core.icons', {} From 3860435f6cfe3097dc34b00e650d5c784ac0c5ea Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 17 Mar 2026 13:49:09 +0100 Subject: [PATCH 67/68] core-components: refresh alpha API report Update the generated core-components alpha API report after the rebase so verify sees the current translation key ordering. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- packages/core-components/report-alpha.api.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/core-components/report-alpha.api.md b/packages/core-components/report-alpha.api.md index f373dd56ff..408862a961 100644 --- a/packages/core-components/report-alpha.api.md +++ b/packages/core-components/report-alpha.api.md @@ -15,22 +15,19 @@ export const coreComponentsTranslationRef: TranslationRef< readonly 'table.body.emptyDataSourceMessage': 'No records to display'; readonly 'table.header.actions': 'Actions'; readonly 'table.toolbar.search': 'Filter'; - readonly 'table.pagination.labelDisplayedRows': '{from}-{to} of {count}'; readonly 'table.pagination.firstTooltip': 'First Page'; + readonly 'table.pagination.labelDisplayedRows': '{from}-{to} of {count}'; readonly 'table.pagination.labelRowsSelect': 'rows'; readonly 'table.pagination.lastTooltip': 'Last Page'; readonly 'table.pagination.nextTooltip': 'Next Page'; readonly 'table.pagination.previousTooltip': 'Previous Page'; - readonly 'emptyState.missingAnnotation.title': 'Missing Annotation'; - readonly 'emptyState.missingAnnotation.actionTitle': 'Add the annotation to your component YAML as shown in the highlighted example below:'; - readonly 'emptyState.missingAnnotation.readMore': 'Read more'; readonly 'signIn.title': 'Sign In'; readonly 'signIn.loginFailed': 'Login failed'; readonly 'signIn.customProvider.title': 'Custom User'; + readonly 'signIn.customProvider.continue': 'Continue'; readonly 'signIn.customProvider.subtitle': 'Enter your own User ID and credentials.\n This selection will not be stored.'; readonly 'signIn.customProvider.userId': 'User ID'; readonly 'signIn.customProvider.tokenInvalid': 'Token is not a valid OpenID Connect JWT Token'; - readonly 'signIn.customProvider.continue': 'Continue'; readonly 'signIn.customProvider.idToken': 'ID Token (optional)'; readonly 'signIn.guestProvider.title': 'Guest'; readonly 'signIn.guestProvider.enter': 'Enter'; @@ -47,6 +44,9 @@ export const coreComponentsTranslationRef: TranslationRef< readonly 'errorPage.goBack': 'Go back'; readonly 'errorPage.showMoreDetails': 'Show more details'; readonly 'errorPage.showLessDetails': 'Show less details'; + readonly 'emptyState.missingAnnotation.title': 'Missing Annotation'; + readonly 'emptyState.missingAnnotation.actionTitle': 'Add the annotation to your component YAML as shown in the highlighted example below:'; + readonly 'emptyState.missingAnnotation.readMore': 'Read more'; readonly 'supportConfig.default.title': 'Support Not Configured'; readonly 'supportConfig.default.linkTitle': 'Add `app.support` config key'; readonly 'errorBoundary.title': 'Please contact {{slackChannel}} for help.'; @@ -63,8 +63,8 @@ export const coreComponentsTranslationRef: TranslationRef< readonly 'autoLogout.stillTherePrompt.buttonText': "Yes! Don't log me out"; readonly 'dependencyGraph.fullscreenTooltip': 'Toggle fullscreen'; readonly 'proxiedSignInPage.title': 'You do not appear to be signed in. Please try reloading the browser page.'; - readonly 'logViewer.searchField.placeholder': 'Search'; readonly 'logViewer.downloadBtn.tooltip': 'Download logs'; + readonly 'logViewer.searchField.placeholder': 'Search'; } >; From 094cee6a40566a6ca50246dfd56af427803360de Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 17 Mar 2026 14:03:17 +0100 Subject: [PATCH 68/68] repo: refresh remaining API reports Check in the remaining generated API report updates after the rebase so verify sees the current exported surfaces and generated ordering. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- packages/core-components/report-alpha.api.md | 12 +- .../frontend-plugin-api/report-alpha.api.md | 14 ++- packages/frontend-plugin-api/report.api.md | 113 +++++++++--------- plugins/app-react/report.api.md | 4 +- 4 files changed, 76 insertions(+), 67 deletions(-) diff --git a/packages/core-components/report-alpha.api.md b/packages/core-components/report-alpha.api.md index 408862a961..f373dd56ff 100644 --- a/packages/core-components/report-alpha.api.md +++ b/packages/core-components/report-alpha.api.md @@ -15,19 +15,22 @@ export const coreComponentsTranslationRef: TranslationRef< readonly 'table.body.emptyDataSourceMessage': 'No records to display'; readonly 'table.header.actions': 'Actions'; readonly 'table.toolbar.search': 'Filter'; - readonly 'table.pagination.firstTooltip': 'First Page'; readonly 'table.pagination.labelDisplayedRows': '{from}-{to} of {count}'; + readonly 'table.pagination.firstTooltip': 'First Page'; readonly 'table.pagination.labelRowsSelect': 'rows'; readonly 'table.pagination.lastTooltip': 'Last Page'; readonly 'table.pagination.nextTooltip': 'Next Page'; readonly 'table.pagination.previousTooltip': 'Previous Page'; + readonly 'emptyState.missingAnnotation.title': 'Missing Annotation'; + readonly 'emptyState.missingAnnotation.actionTitle': 'Add the annotation to your component YAML as shown in the highlighted example below:'; + readonly 'emptyState.missingAnnotation.readMore': 'Read more'; readonly 'signIn.title': 'Sign In'; readonly 'signIn.loginFailed': 'Login failed'; readonly 'signIn.customProvider.title': 'Custom User'; - readonly 'signIn.customProvider.continue': 'Continue'; readonly 'signIn.customProvider.subtitle': 'Enter your own User ID and credentials.\n This selection will not be stored.'; readonly 'signIn.customProvider.userId': 'User ID'; readonly 'signIn.customProvider.tokenInvalid': 'Token is not a valid OpenID Connect JWT Token'; + readonly 'signIn.customProvider.continue': 'Continue'; readonly 'signIn.customProvider.idToken': 'ID Token (optional)'; readonly 'signIn.guestProvider.title': 'Guest'; readonly 'signIn.guestProvider.enter': 'Enter'; @@ -44,9 +47,6 @@ export const coreComponentsTranslationRef: TranslationRef< readonly 'errorPage.goBack': 'Go back'; readonly 'errorPage.showMoreDetails': 'Show more details'; readonly 'errorPage.showLessDetails': 'Show less details'; - readonly 'emptyState.missingAnnotation.title': 'Missing Annotation'; - readonly 'emptyState.missingAnnotation.actionTitle': 'Add the annotation to your component YAML as shown in the highlighted example below:'; - readonly 'emptyState.missingAnnotation.readMore': 'Read more'; readonly 'supportConfig.default.title': 'Support Not Configured'; readonly 'supportConfig.default.linkTitle': 'Add `app.support` config key'; readonly 'errorBoundary.title': 'Please contact {{slackChannel}} for help.'; @@ -63,8 +63,8 @@ export const coreComponentsTranslationRef: TranslationRef< readonly 'autoLogout.stillTherePrompt.buttonText': "Yes! Don't log me out"; readonly 'dependencyGraph.fullscreenTooltip': 'Toggle fullscreen'; readonly 'proxiedSignInPage.title': 'You do not appear to be signed in. Please try reloading the browser page.'; - readonly 'logViewer.downloadBtn.tooltip': 'Download logs'; readonly 'logViewer.searchField.placeholder': 'Search'; + readonly 'logViewer.downloadBtn.tooltip': 'Download logs'; } >; diff --git a/packages/frontend-plugin-api/report-alpha.api.md b/packages/frontend-plugin-api/report-alpha.api.md index 56856fde08..db4570ee2f 100644 --- a/packages/frontend-plugin-api/report-alpha.api.md +++ b/packages/frontend-plugin-api/report-alpha.api.md @@ -3,9 +3,13 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import { ApiRef as ApiRef_2 } from '@backstage/core-plugin-api'; +import { ApiRef as ApiRef_2 } from '@backstage/frontend-plugin-api'; import { ComponentType } from 'react'; +import { ConfigurableExtensionDataRef as ConfigurableExtensionDataRef_2 } from '@backstage/frontend-plugin-api'; import { Expand } from '@backstage/types'; +import { ExtensionBlueprint as ExtensionBlueprint_2 } from '@backstage/frontend-plugin-api'; +import { ExtensionBlueprintParams as ExtensionBlueprintParams_2 } from '@backstage/frontend-plugin-api'; +import { ExtensionDataRef as ExtensionDataRef_2 } from '@backstage/frontend-plugin-api'; import { FilterPredicate } from '@backstage/filter-predicates'; import { JsonObject } from '@backstage/types'; import { JSX as JSX_2 } from 'react'; @@ -591,14 +595,14 @@ export const pluginWrapperApiRef: ApiRef_2< }; // @public -export const PluginWrapperBlueprint: ExtensionBlueprint<{ +export const PluginWrapperBlueprint: ExtensionBlueprint_2<{ kind: 'plugin-wrapper'; params: (params: { loader: () => Promise>; - }) => ExtensionBlueprintParams<{ + }) => ExtensionBlueprintParams_2<{ loader: () => Promise; }>; - output: ExtensionDataRef< + output: ExtensionDataRef_2< () => Promise, 'core.plugin-wrapper.loader', {} @@ -607,7 +611,7 @@ export const PluginWrapperBlueprint: ExtensionBlueprint<{ config: {}; configInput: {}; dataRefs: { - wrapper: ConfigurableExtensionDataRef< + wrapper: ConfigurableExtensionDataRef_2< () => Promise, 'core.plugin-wrapper.loader', {} diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index 56e5b90725..06456730f8 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -3,13 +3,17 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import { ApiRef as ApiRef_2 } from '@backstage/core-plugin-api'; +import { AnyRouteRefParams as AnyRouteRefParams_2 } from '@backstage/frontend-plugin-api'; +import { ApiRef as ApiRef_2 } from '@backstage/frontend-plugin-api'; import { ComponentType } from 'react'; import type { Config } from '@backstage/config'; +import { ConfigurableExtensionDataRef as ConfigurableExtensionDataRef_2 } from '@backstage/frontend-plugin-api'; import { Expand } from '@backstage/types'; import { ExpandRecursive } from '@backstage/types'; import { ExtensionBlueprint as ExtensionBlueprint_2 } from '@backstage/frontend-plugin-api'; +import { ExtensionBlueprintParams as ExtensionBlueprintParams_2 } from '@backstage/frontend-plugin-api'; import { ExtensionDataRef as ExtensionDataRef_2 } from '@backstage/frontend-plugin-api'; +import { ExtensionInput as ExtensionInput_2 } from '@backstage/frontend-plugin-api'; import { FilterPredicate } from '@backstage/filter-predicates'; import { JsonObject } from '@backstage/types'; import { JsonValue } from '@backstage/types'; @@ -18,6 +22,7 @@ import { JSX as JSX_3 } from 'react/jsx-runtime'; import { Observable } from '@backstage/types'; import { PropsWithChildren } from 'react'; import { ReactNode } from 'react'; +import { SwappableComponentRef as SwappableComponentRef_2 } from '@backstage/frontend-plugin-api'; import type { z } from 'zod'; // @public @@ -82,12 +87,12 @@ export type AnalyticsImplementation = { }; // @public @deprecated -export const AnalyticsImplementationBlueprint: ExtensionBlueprint<{ +export const AnalyticsImplementationBlueprint: ExtensionBlueprint_2<{ kind: 'analytics'; params: ( params: AnalyticsImplementationFactory, - ) => ExtensionBlueprintParams>; - output: ExtensionDataRef< + ) => ExtensionBlueprintParams_2>; + output: ExtensionDataRef_2< AnalyticsImplementationFactory<{}>, 'core.analytics.factory', {} @@ -96,7 +101,7 @@ export const AnalyticsImplementationBlueprint: ExtensionBlueprint<{ config: {}; configInput: {}; dataRefs: { - factory: ConfigurableExtensionDataRef< + factory: ConfigurableExtensionDataRef_2< AnalyticsImplementationFactory<{}>, 'core.analytics.factory', {} @@ -146,7 +151,7 @@ export type AnyRouteRefParams = | undefined; // @public -export const ApiBlueprint: ExtensionBlueprint<{ +export const ApiBlueprint: ExtensionBlueprint_2<{ kind: 'api'; params: < TApi, @@ -154,13 +159,13 @@ export const ApiBlueprint: ExtensionBlueprint<{ TDeps extends { [name in string]: unknown }, >( params: ApiFactory, - ) => ExtensionBlueprintParams; - output: ExtensionDataRef; + ) => ExtensionBlueprintParams_2; + output: ExtensionDataRef_2; inputs: {}; config: {}; configInput: {}; dataRefs: { - factory: ConfigurableExtensionDataRef< + factory: ConfigurableExtensionDataRef_2< AnyApiFactory, 'core.api.factory', {} @@ -405,16 +410,16 @@ export interface ConfigurableExtensionDataRef< // @public (undocumented) export const coreExtensionData: { - title: ConfigurableExtensionDataRef; - icon: ConfigurableExtensionDataRef; - reactElement: ConfigurableExtensionDataRef< + title: ConfigurableExtensionDataRef_2; + icon: ConfigurableExtensionDataRef_2; + reactElement: ConfigurableExtensionDataRef_2< JSX_2.Element, 'core.reactElement', {} >; - routePath: ConfigurableExtensionDataRef; - routeRef: ConfigurableExtensionDataRef< - RouteRef, + routePath: ConfigurableExtensionDataRef_2; + routeRef: ConfigurableExtensionDataRef_2< + RouteRef, 'core.routing.ref', {} >; @@ -984,7 +989,7 @@ export const errorApiRef: ApiRef_2 & { // @public (undocumented) export const ErrorDisplay: { (props: ErrorDisplayProps): JSX.Element | null; - ref: SwappableComponentRef; + ref: SwappableComponentRef_2; }; // @public (undocumented) @@ -1023,7 +1028,7 @@ export interface ExtensionBlueprint< // (undocumented) make< TName extends string | undefined, - TParamsInput extends AnyParamsInput>, + TParamsInput extends AnyParamsInput_2>, UParentInputs extends ExtensionDataRef, >(args: { name?: TName; @@ -1080,7 +1085,7 @@ export interface ExtensionBlueprint< }; factory( originalFactory: < - TParamsInput extends AnyParamsInput>, + TParamsInput extends AnyParamsInput_2>, >( params: TParamsInput extends ExtensionBlueprintDefineParams ? TParamsInput @@ -1558,14 +1563,14 @@ export const microsoftAuthApiRef: ApiRef_2< }; // @public @deprecated -export const NavItemBlueprint: ExtensionBlueprint<{ +export const NavItemBlueprint: ExtensionBlueprint_2<{ kind: 'nav-item'; params: { title: string; icon: IconComponent; routeRef: RouteRef; }; - output: ExtensionDataRef< + output: ExtensionDataRef_2< { title: string; icon: IconComponent; @@ -1578,7 +1583,7 @@ export const NavItemBlueprint: ExtensionBlueprint<{ config: {}; configInput: {}; dataRefs: { - target: ConfigurableExtensionDataRef< + target: ConfigurableExtensionDataRef_2< { title: string; icon: IconComponent; @@ -1593,7 +1598,7 @@ export const NavItemBlueprint: ExtensionBlueprint<{ // @public (undocumented) export const NotFoundErrorPage: { (props: NotFoundErrorPageProps): JSX.Element | null; - ref: SwappableComponentRef; + ref: SwappableComponentRef_2; }; // @public (undocumented) @@ -1695,7 +1700,7 @@ export interface OverridableExtensionDefinition< TExtraInputs extends { [inputName in string]: ExtensionInput; }, - TParamsInput extends AnyParamsInput_2>, + TParamsInput extends AnyParamsInput>, UParentInputs extends ExtensionDataRef, >( args: Expand< @@ -1722,7 +1727,7 @@ export interface OverridableExtensionDefinition< }; factory?( originalFactory: < - TFactoryParamsReturn extends AnyParamsInput_2< + TFactoryParamsReturn extends AnyParamsInput< NonNullable >, >( @@ -1822,7 +1827,7 @@ export interface OverridableFrontendPlugin< } // @public -export const PageBlueprint: ExtensionBlueprint<{ +export const PageBlueprint: ExtensionBlueprint_2<{ kind: 'page'; params: { path: string; @@ -1833,23 +1838,23 @@ export const PageBlueprint: ExtensionBlueprint<{ noHeader?: boolean; }; output: - | ExtensionDataRef - | ExtensionDataRef< - RouteRef, + | ExtensionDataRef_2 + | ExtensionDataRef_2< + RouteRef, 'core.routing.ref', { optional: true; } > - | ExtensionDataRef - | ExtensionDataRef< + | ExtensionDataRef_2 + | ExtensionDataRef_2< string, 'core.title', { optional: true; } > - | ExtensionDataRef< + | ExtensionDataRef_2< IconElement, 'core.icon', { @@ -1857,24 +1862,24 @@ export const PageBlueprint: ExtensionBlueprint<{ } >; inputs: { - pages: ExtensionInput< - | ConfigurableExtensionDataRef - | ConfigurableExtensionDataRef - | ConfigurableExtensionDataRef< - RouteRef, + pages: ExtensionInput_2< + | ConfigurableExtensionDataRef_2 + | ConfigurableExtensionDataRef_2 + | ConfigurableExtensionDataRef_2< + RouteRef, 'core.routing.ref', { optional: true; } > - | ConfigurableExtensionDataRef< + | ConfigurableExtensionDataRef_2< string, 'core.title', { optional: true; } > - | ConfigurableExtensionDataRef< + | ConfigurableExtensionDataRef_2< IconElement, 'core.icon', { @@ -1902,7 +1907,7 @@ export const PageBlueprint: ExtensionBlueprint<{ // @public export const PageLayout: { (props: PageLayoutProps): JSX.Element | null; - ref: SwappableComponentRef; + ref: SwappableComponentRef_2; }; // @public @@ -1944,14 +1949,14 @@ export type PendingOAuthRequest = { }; // @public -export const PluginHeaderActionBlueprint: ExtensionBlueprint<{ +export const PluginHeaderActionBlueprint: ExtensionBlueprint_2<{ kind: 'plugin-header-action'; params: (params: { loader: () => Promise; - }) => ExtensionBlueprintParams<{ + }) => ExtensionBlueprintParams_2<{ loader: () => Promise; }>; - output: ExtensionDataRef; + output: ExtensionDataRef_2; inputs: {}; config: {}; configInput: {}; @@ -2004,14 +2009,14 @@ export const pluginWrapperApiRef: ApiRef_2< }; // @public -export const PluginWrapperBlueprint: ExtensionBlueprint<{ +export const PluginWrapperBlueprint: ExtensionBlueprint_2<{ kind: 'plugin-wrapper'; params: (params: { loader: () => Promise>; - }) => ExtensionBlueprintParams<{ + }) => ExtensionBlueprintParams_2<{ loader: () => Promise; }>; - output: ExtensionDataRef< + output: ExtensionDataRef_2< () => Promise, 'core.plugin-wrapper.loader', {} @@ -2020,7 +2025,7 @@ export const PluginWrapperBlueprint: ExtensionBlueprint<{ config: {}; configInput: {}; dataRefs: { - wrapper: ConfigurableExtensionDataRef< + wrapper: ConfigurableExtensionDataRef_2< () => Promise, 'core.plugin-wrapper.loader', {} @@ -2058,7 +2063,7 @@ export type ProfileInfoApi = { // @public (undocumented) export const Progress: { (props: ProgressProps): JSX.Element | null; - ref: SwappableComponentRef; + ref: SwappableComponentRef_2; }; // @public (undocumented) @@ -2155,7 +2160,7 @@ export type StorageValueSnapshot = }; // @public -export const SubPageBlueprint: ExtensionBlueprint<{ +export const SubPageBlueprint: ExtensionBlueprint_2<{ kind: 'sub-page'; params: { path: string; @@ -2165,17 +2170,17 @@ export const SubPageBlueprint: ExtensionBlueprint<{ routeRef?: RouteRef; }; output: - | ExtensionDataRef - | ExtensionDataRef< - RouteRef, + | ExtensionDataRef_2 + | ExtensionDataRef_2< + RouteRef, 'core.routing.ref', { optional: true; } > - | ExtensionDataRef - | ExtensionDataRef - | ExtensionDataRef< + | ExtensionDataRef_2 + | ExtensionDataRef_2 + | ExtensionDataRef_2< IconElement, 'core.icon', { diff --git a/plugins/app-react/report.api.md b/plugins/app-react/report.api.md index 4e3fff6e42..a4e63e254b 100644 --- a/plugins/app-react/report.api.md +++ b/plugins/app-react/report.api.md @@ -86,7 +86,7 @@ export const IconBundleBlueprint: ExtensionBlueprint<{ }; output: ExtensionDataRef< { - [x: string]: IconElement | IconComponent; + [x: string]: IconComponent | IconElement; }, 'core.icons', {} @@ -97,7 +97,7 @@ export const IconBundleBlueprint: ExtensionBlueprint<{ dataRefs: { icons: ConfigurableExtensionDataRef< { - [x: string]: IconElement | IconComponent; + [x: string]: IconComponent | IconElement; }, 'core.icons', {}