From 42960f1db799d9cdb289a08edb185dbd43d0ca80 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Mon, 16 Mar 2026 20:09:15 +0100 Subject: [PATCH 01/91] feat: add actions CLI module for distributed actions registry Adds @backstage/cli-module-actions with commands for listing and executing actions from the distributed actions registry. Exports auth helpers from cli-module-auth for cross-module reuse. Relaxes the actions registry auth check to allow direct user invocations from the CLI. Signed-off-by: benjdlambert --- .changeset/add-actions-cli-module.md | 5 + .changeset/allow-user-action-invocation.md | 5 + .changeset/auth-module-exports.md | 5 + .changeset/cli-defaults-actions.md | 5 + .../DefaultActionsRegistryService.ts | 10 +- .../actionsRegistryServiceFactory.test.ts | 10 +- packages/cli-defaults/package.json | 1 + packages/cli-defaults/src/index.ts | 2 + packages/cli-module-actions/.eslintrc.js | 1 + packages/cli-module-actions/catalog-info.yaml | 10 ++ packages/cli-module-actions/package.json | 42 +++++ packages/cli-module-actions/report.api.md | 13 ++ .../src/commands/execute.ts | 76 ++++++++ .../cli-module-actions/src/commands/list.ts | 62 +++++++ .../src/commands/sourcesAdd.ts | 54 ++++++ .../src/commands/sourcesList.ts | 39 +++++ .../src/commands/sourcesRemove.ts | 53 ++++++ packages/cli-module-actions/src/index.ts | 49 ++++++ .../src/lib/ActionsClient.test.ts | 129 ++++++++++++++ .../src/lib/ActionsClient.ts | 98 +++++++++++ .../cli-module-actions/src/lib/resolveAuth.ts | 48 ++++++ .../src/lib/schemaToFlags.test.ts | 163 ++++++++++++++++++ .../src/lib/schemaToFlags.ts | 77 +++++++++ packages/cli-module-auth/report.api.md | 58 +++++++ packages/cli-module-auth/src/index.ts | 14 ++ packages/cli-module-auth/src/lib/auth.ts | 2 + packages/cli-module-auth/src/lib/http.ts | 4 +- .../cli-module-auth/src/lib/secretStore.ts | 4 +- .../cli-module-auth/src/lib/storage.test.ts | 65 +++++++ packages/cli-module-auth/src/lib/storage.ts | 42 ++++- yarn.lock | 13 ++ 31 files changed, 1141 insertions(+), 18 deletions(-) create mode 100644 .changeset/add-actions-cli-module.md create mode 100644 .changeset/allow-user-action-invocation.md create mode 100644 .changeset/auth-module-exports.md create mode 100644 .changeset/cli-defaults-actions.md create mode 100644 packages/cli-module-actions/.eslintrc.js create mode 100644 packages/cli-module-actions/catalog-info.yaml create mode 100644 packages/cli-module-actions/package.json create mode 100644 packages/cli-module-actions/report.api.md create mode 100644 packages/cli-module-actions/src/commands/execute.ts create mode 100644 packages/cli-module-actions/src/commands/list.ts create mode 100644 packages/cli-module-actions/src/commands/sourcesAdd.ts create mode 100644 packages/cli-module-actions/src/commands/sourcesList.ts create mode 100644 packages/cli-module-actions/src/commands/sourcesRemove.ts create mode 100644 packages/cli-module-actions/src/index.ts create mode 100644 packages/cli-module-actions/src/lib/ActionsClient.test.ts create mode 100644 packages/cli-module-actions/src/lib/ActionsClient.ts create mode 100644 packages/cli-module-actions/src/lib/resolveAuth.ts create mode 100644 packages/cli-module-actions/src/lib/schemaToFlags.test.ts create mode 100644 packages/cli-module-actions/src/lib/schemaToFlags.ts diff --git a/.changeset/add-actions-cli-module.md b/.changeset/add-actions-cli-module.md new file mode 100644 index 0000000000..538509547f --- /dev/null +++ b/.changeset/add-actions-cli-module.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli-module-actions': patch +--- + +Added `actions` CLI module for listing and executing actions from the distributed actions registry. Includes `actions list`, `actions execute`, and `actions sources` commands for managing plugin sources. diff --git a/.changeset/allow-user-action-invocation.md b/.changeset/allow-user-action-invocation.md new file mode 100644 index 0000000000..299c38b41b --- /dev/null +++ b/.changeset/allow-user-action-invocation.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-defaults': patch +--- + +The actions registry invoke endpoint now accepts direct user credentials in addition to service principals, enabling CLI and other direct user clients to invoke actions. diff --git a/.changeset/auth-module-exports.md b/.changeset/auth-module-exports.md new file mode 100644 index 0000000000..0b6dc46ac3 --- /dev/null +++ b/.changeset/auth-module-exports.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli-module-auth': patch +--- + +Export auth helper utilities for use by other CLI modules. Added per-instance config storage with `getInstanceConfig` and `updateInstanceConfig`. diff --git a/.changeset/cli-defaults-actions.md b/.changeset/cli-defaults-actions.md new file mode 100644 index 0000000000..1c46f96ead --- /dev/null +++ b/.changeset/cli-defaults-actions.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli-defaults': patch +--- + +Added `@backstage/cli-module-actions` to the default set of CLI modules. diff --git a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts index ffe74f81d9..2dfcf4dbef 100644 --- a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts +++ b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts @@ -97,15 +97,9 @@ export class DefaultActionsRegistryService implements ActionsRegistryService { '/.backstage/actions/v1/actions/:actionId/invoke', async (req, res) => { const credentials = await this.httpAuth.credentials(req); - if (this.auth.isPrincipal(credentials, 'user')) { - if (!credentials.principal.actor) { - throw new NotAllowedError( - `Actions must be invoked by a service, not a user`, - ); - } - } else if (this.auth.isPrincipal(credentials, 'none')) { + if (this.auth.isPrincipal(credentials, 'none')) { throw new NotAllowedError( - `Actions must be invoked by a service, not an anonymous request`, + `Actions must be invoked by an authenticated principal, not an anonymous request`, ); } diff --git a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts index 1e7c27960e..6c25d4853b 100644 --- a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts +++ b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts @@ -439,7 +439,7 @@ describe('actionsRegistryServiceFactory', () => { }); }); - it('should throw an error if the action is invoked by a user', async () => { + it('should allow actions to be invoked by a user', async () => { const testServices = [ actionsRegistryServiceFactory, httpRouterServiceFactory, @@ -460,12 +460,8 @@ describe('actionsRegistryServiceFactory', () => { name: 'test', }); - expect(status).toBe(403); - expect(body).toMatchObject({ - error: { - message: 'Actions must be invoked by a service, not a user', - }, - }); + expect(status).toBe(200); + expect(body).toMatchObject({ output: { ok: true } }); }); it('should validate the output of the action if provided', async () => { diff --git a/packages/cli-defaults/package.json b/packages/cli-defaults/package.json index 8a065fd4d7..7b1016d0d6 100644 --- a/packages/cli-defaults/package.json +++ b/packages/cli-defaults/package.json @@ -30,6 +30,7 @@ "postpack": "backstage-cli package postpack" }, "dependencies": { + "@backstage/cli-module-actions": "workspace:^", "@backstage/cli-module-auth": "workspace:^", "@backstage/cli-module-build": "workspace:^", "@backstage/cli-module-config": "workspace:^", diff --git a/packages/cli-defaults/src/index.ts b/packages/cli-defaults/src/index.ts index dd7aed0c70..f167c3c424 100644 --- a/packages/cli-defaults/src/index.ts +++ b/packages/cli-defaults/src/index.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import actions from '@backstage/cli-module-actions'; import auth from '@backstage/cli-module-auth'; import build from '@backstage/cli-module-build'; import config from '@backstage/cli-module-config'; @@ -31,6 +32,7 @@ import translations from '@backstage/cli-module-translations'; * @public */ export default [ + actions, auth, build, config, diff --git a/packages/cli-module-actions/.eslintrc.js b/packages/cli-module-actions/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/packages/cli-module-actions/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/packages/cli-module-actions/catalog-info.yaml b/packages/cli-module-actions/catalog-info.yaml new file mode 100644 index 0000000000..fd282ca6a3 --- /dev/null +++ b/packages/cli-module-actions/catalog-info.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-cli-module-actions + title: '@backstage/cli-module-actions' + description: CLI module for executing distributed actions +spec: + lifecycle: experimental + type: backstage-cli-module + owner: tooling-maintainers diff --git a/packages/cli-module-actions/package.json b/packages/cli-module-actions/package.json new file mode 100644 index 0000000000..ca3c423999 --- /dev/null +++ b/packages/cli-module-actions/package.json @@ -0,0 +1,42 @@ +{ + "name": "@backstage/cli-module-actions", + "version": "0.0.0", + "description": "CLI module for executing distributed actions", + "backstage": { + "role": "cli-module" + }, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/cli-module-actions" + }, + "license": "Apache-2.0", + "main": "src/index.ts", + "types": "src/index.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "backstage-cli package build", + "clean": "backstage-cli package clean", + "lint": "backstage-cli package lint", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "test": "backstage-cli package test" + }, + "dependencies": { + "@backstage/cli-module-auth": "workspace:^", + "@backstage/cli-node": "workspace:^", + "cleye": "^2.3.0" + }, + "devDependencies": { + "@backstage/backend-test-utils": "workspace:^", + "@backstage/cli": "workspace:^" + } +} diff --git a/packages/cli-module-actions/report.api.md b/packages/cli-module-actions/report.api.md new file mode 100644 index 0000000000..180186482d --- /dev/null +++ b/packages/cli-module-actions/report.api.md @@ -0,0 +1,13 @@ +## API Report File for "@backstage/cli-module-actions" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { CliModule } from '@backstage/cli-node'; + +// @public (undocumented) +const _default: CliModule; +export default _default; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/cli-module-actions/src/commands/execute.ts b/packages/cli-module-actions/src/commands/execute.ts new file mode 100644 index 0000000000..fccb1e1595 --- /dev/null +++ b/packages/cli-module-actions/src/commands/execute.ts @@ -0,0 +1,76 @@ +/* + * Copyright 2025 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 { cli } from 'cleye'; +import type { CliCommandContext } from '@backstage/cli-node'; +import { ActionsClient } from '../lib/ActionsClient'; +import { schemaToFlags } from '../lib/schemaToFlags'; +import { resolveAuth } from '../lib/resolveAuth'; + +export default async ({ args, info }: CliCommandContext) => { + const instanceIdx = args.indexOf('--instance'); + const instanceFlag = instanceIdx !== -1 ? args[instanceIdx + 1] : undefined; + + const actionId = args.find( + (a, i) => !a.startsWith('-') && i !== instanceIdx + 1, + ); + if (!actionId) { + process.stderr.write('Usage: actions execute [flags]\n'); + process.exit(1); + } + + const { accessToken, instance } = await resolveAuth(instanceFlag); + + const client = new ActionsClient(instance.baseUrl, accessToken); + const actions = await client.listForPlugin(actionId); + const action = actions.find(a => a.id === actionId); + + if (!action) { + throw new Error( + `Action "${actionId}" not found. Run "actions list" to see available actions.`, + ); + } + + const schemaFlags = schemaToFlags(action.schema.input as any); + + const flagArgs = args.filter(a => a !== actionId); + + const { flags } = cli( + { + help: info, + flags: { + instance: { + type: String, + description: 'Name of the instance to use', + }, + ...schemaFlags, + }, + }, + undefined, + flagArgs, + ); + + const allFlags = flags as Record; + const input: Record = {}; + for (const [key, value] of Object.entries(allFlags)) { + if (key !== 'instance' && value !== undefined) { + input[key] = value; + } + } + + const output = await client.execute(actionId, input); + process.stdout.write(`${JSON.stringify(output, null, 2)}\n`); +}; diff --git a/packages/cli-module-actions/src/commands/list.ts b/packages/cli-module-actions/src/commands/list.ts new file mode 100644 index 0000000000..601fa6d2c7 --- /dev/null +++ b/packages/cli-module-actions/src/commands/list.ts @@ -0,0 +1,62 @@ +/* + * Copyright 2025 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 { cli } from 'cleye'; +import type { CliCommandContext } from '@backstage/cli-node'; +import { ActionsClient } from '../lib/ActionsClient'; +import { resolveAuth } from '../lib/resolveAuth'; + +export default async ({ args, info }: CliCommandContext) => { + const { + flags: { instance: instanceFlag }, + } = cli( + { + help: info, + flags: { + instance: { + type: String, + description: 'Name of the instance to use', + }, + }, + }, + undefined, + args, + ); + + const { accessToken, pluginSources, instance } = await resolveAuth( + instanceFlag, + ); + + if (!pluginSources.length) { + process.stderr.write( + 'No plugin sources configured. Run "actions sources add " to add one.\n', + ); + return; + } + + const client = new ActionsClient(instance.baseUrl, accessToken); + const actions = await client.list(pluginSources); + + if (!actions.length) { + process.stderr.write('No actions found.\n'); + return; + } + + for (const action of actions) { + const desc = action.description ? ` - ${action.description}` : ''; + process.stdout.write(`${action.id}${desc}\n`); + } +}; diff --git a/packages/cli-module-actions/src/commands/sourcesAdd.ts b/packages/cli-module-actions/src/commands/sourcesAdd.ts new file mode 100644 index 0000000000..2a8135b63f --- /dev/null +++ b/packages/cli-module-actions/src/commands/sourcesAdd.ts @@ -0,0 +1,54 @@ +/* + * Copyright 2025 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 { cli } from 'cleye'; +import type { CliCommandContext } from '@backstage/cli-node'; +import { + getSelectedInstance, + getInstanceConfig, + updateInstanceConfig, +} from '@backstage/cli-module-auth'; + +export default async ({ args, info }: CliCommandContext) => { + const parsed = cli( + { + help: info, + parameters: [''], + }, + undefined, + args, + ); + + const pluginId = parsed._[0]; + + const instance = await getSelectedInstance(); + const existing = + (await getInstanceConfig(instance.name, 'pluginSources')) ?? []; + + if (existing.includes(pluginId)) { + process.stderr.write( + `Plugin source "${pluginId}" is already configured.\n`, + ); + return; + } + + await updateInstanceConfig(instance.name, 'pluginSources', [ + ...existing, + pluginId, + ]); + + process.stdout.write(`Added plugin source "${pluginId}".\n`); +}; diff --git a/packages/cli-module-actions/src/commands/sourcesList.ts b/packages/cli-module-actions/src/commands/sourcesList.ts new file mode 100644 index 0000000000..68b368efd5 --- /dev/null +++ b/packages/cli-module-actions/src/commands/sourcesList.ts @@ -0,0 +1,39 @@ +/* + * Copyright 2025 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 { cli } from 'cleye'; +import type { CliCommandContext } from '@backstage/cli-node'; +import { + getSelectedInstance, + getInstanceConfig, +} from '@backstage/cli-module-auth'; + +export default async ({ args, info }: CliCommandContext) => { + cli({ help: info }, undefined, args); + + const instance = await getSelectedInstance(); + const sources = + (await getInstanceConfig(instance.name, 'pluginSources')) ?? []; + + if (!sources.length) { + process.stderr.write('No plugin sources configured.\n'); + return; + } + + for (const source of sources) { + process.stdout.write(`${source}\n`); + } +}; diff --git a/packages/cli-module-actions/src/commands/sourcesRemove.ts b/packages/cli-module-actions/src/commands/sourcesRemove.ts new file mode 100644 index 0000000000..731abe790d --- /dev/null +++ b/packages/cli-module-actions/src/commands/sourcesRemove.ts @@ -0,0 +1,53 @@ +/* + * Copyright 2025 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 { cli } from 'cleye'; +import type { CliCommandContext } from '@backstage/cli-node'; +import { + getSelectedInstance, + getInstanceConfig, + updateInstanceConfig, +} from '@backstage/cli-module-auth'; + +export default async ({ args, info }: CliCommandContext) => { + const parsed = cli( + { + help: info, + parameters: [''], + }, + undefined, + args, + ); + + const pluginId = parsed._[0]; + + const instance = await getSelectedInstance(); + const existing = + (await getInstanceConfig(instance.name, 'pluginSources')) ?? []; + + if (!existing.includes(pluginId)) { + process.stderr.write(`Plugin source "${pluginId}" is not configured.\n`); + return; + } + + await updateInstanceConfig( + instance.name, + 'pluginSources', + existing.filter(s => s !== pluginId), + ); + + process.stdout.write(`Removed plugin source "${pluginId}".\n`); +}; diff --git a/packages/cli-module-actions/src/index.ts b/packages/cli-module-actions/src/index.ts new file mode 100644 index 0000000000..57b2d741e3 --- /dev/null +++ b/packages/cli-module-actions/src/index.ts @@ -0,0 +1,49 @@ +/* + * Copyright 2025 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 { createCliModule } from '@backstage/cli-node'; +import packageJson from '../package.json'; + +export default createCliModule({ + packageJson, + init: async reg => { + reg.addCommand({ + path: ['actions', 'list'], + description: 'List available actions from configured plugin sources', + execute: { loader: () => import('./commands/list') }, + }); + reg.addCommand({ + path: ['actions', 'execute'], + description: 'Execute an action', + execute: { loader: () => import('./commands/execute') }, + }); + reg.addCommand({ + path: ['actions', 'sources', 'add'], + description: 'Add a plugin source for action discovery', + execute: { loader: () => import('./commands/sourcesAdd') }, + }); + reg.addCommand({ + path: ['actions', 'sources', 'list'], + description: 'List configured plugin sources', + execute: { loader: () => import('./commands/sourcesList') }, + }); + reg.addCommand({ + path: ['actions', 'sources', 'remove'], + description: 'Remove a plugin source', + execute: { loader: () => import('./commands/sourcesRemove') }, + }); + }, +}); diff --git a/packages/cli-module-actions/src/lib/ActionsClient.test.ts b/packages/cli-module-actions/src/lib/ActionsClient.test.ts new file mode 100644 index 0000000000..61526873c9 --- /dev/null +++ b/packages/cli-module-actions/src/lib/ActionsClient.test.ts @@ -0,0 +1,129 @@ +/* + * Copyright 2025 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 { ActionsClient } from './ActionsClient'; +import { httpJson } from '@backstage/cli-module-auth'; + +jest.mock('@backstage/cli-module-auth', () => ({ + httpJson: jest.fn(), +})); + +const mockHttpJson = httpJson as jest.MockedFunction; + +describe('ActionsClient', () => { + const baseUrl = 'https://backstage.example.com'; + const accessToken = 'test-token'; + let client: ActionsClient; + + beforeEach(() => { + jest.clearAllMocks(); + client = new ActionsClient(baseUrl, accessToken); + }); + + describe('list', () => { + it('returns empty array when no plugin sources provided', async () => { + const result = await client.list([]); + expect(result).toEqual([]); + expect(mockHttpJson).not.toHaveBeenCalled(); + }); + + it('fetches actions from each plugin source', async () => { + const catalogActions = [ + { + id: 'catalog:refresh', + name: 'refresh', + schema: { input: {}, output: {} }, + }, + ]; + const scaffolderActions = [ + { + id: 'scaffolder:run', + name: 'run', + schema: { input: {}, output: {} }, + }, + ]; + + mockHttpJson + .mockResolvedValueOnce({ actions: catalogActions }) + .mockResolvedValueOnce({ actions: scaffolderActions }); + + const result = await client.list(['catalog', 'scaffolder']); + + expect(mockHttpJson).toHaveBeenCalledTimes(2); + expect(mockHttpJson).toHaveBeenCalledWith( + 'https://backstage.example.com/api/catalog/.backstage/actions/v1/actions', + expect.objectContaining({ + headers: { Authorization: 'Bearer test-token' }, + }), + ); + expect(mockHttpJson).toHaveBeenCalledWith( + 'https://backstage.example.com/api/scaffolder/.backstage/actions/v1/actions', + expect.objectContaining({ + headers: { Authorization: 'Bearer test-token' }, + }), + ); + expect(result).toEqual([...catalogActions, ...scaffolderActions]); + }); + + it('propagates errors from httpJson', async () => { + mockHttpJson.mockRejectedValue(new Error('Network error')); + + await expect(client.list(['catalog'])).rejects.toThrow('Network error'); + }); + }); + + describe('execute', () => { + it('posts to the correct invoke endpoint', async () => { + mockHttpJson.mockResolvedValue({ output: { result: 'ok' } }); + + const output = await client.execute('catalog:refresh', { + entityRef: 'component:default/foo', + }); + + expect(mockHttpJson).toHaveBeenCalledWith( + 'https://backstage.example.com/api/catalog/.backstage/actions/v1/actions/catalog%3Arefresh/invoke', + expect.objectContaining({ + method: 'POST', + headers: { Authorization: 'Bearer test-token' }, + body: { entityRef: 'component:default/foo' }, + }), + ); + expect(output).toEqual({ result: 'ok' }); + }); + + it('sends empty object when no input provided', async () => { + mockHttpJson.mockResolvedValue({ output: null }); + + await client.execute('catalog:refresh'); + + expect(mockHttpJson).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ body: {} }), + ); + }); + + it('extracts pluginId from actionId to build correct URL', async () => { + mockHttpJson.mockResolvedValue({ output: {} }); + + await client.execute('my-plugin:some-action'); + + expect(mockHttpJson).toHaveBeenCalledWith( + expect.stringContaining('/api/my-plugin/'), + expect.any(Object), + ); + }); + }); +}); diff --git a/packages/cli-module-actions/src/lib/ActionsClient.ts b/packages/cli-module-actions/src/lib/ActionsClient.ts new file mode 100644 index 0000000000..aa04902b2f --- /dev/null +++ b/packages/cli-module-actions/src/lib/ActionsClient.ts @@ -0,0 +1,98 @@ +/* + * Copyright 2025 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 { httpJson } from '@backstage/cli-module-auth'; + +export type ActionDef = { + id: string; + name: string; + description?: string; + schema: { + input: object; + output: object; + }; +}; + +type ListActionsResponse = { + actions: ActionDef[]; +}; + +type InvokeResponse = { + output: unknown; +}; + +function extractPluginId(actionId: string): string { + const colonIndex = actionId.indexOf(':'); + if (colonIndex === -1) { + throw new Error( + `Invalid action ID "${actionId}". Expected format "pluginId:actionName".`, + ); + } + return actionId.substring(0, colonIndex); +} + +function pluginActionsUrl(baseUrl: string, pluginId: string): string { + return new URL( + `/api/${encodeURIComponent(pluginId)}/.backstage/actions/v1/actions`, + baseUrl, + ).toString(); +} + +export class ActionsClient { + constructor( + private readonly baseUrl: string, + private readonly accessToken: string, + ) {} + + async list(pluginSources: string[]): Promise { + const results: ActionDef[] = []; + + for (const pluginId of pluginSources) { + const url = pluginActionsUrl(this.baseUrl, pluginId); + + const response = await httpJson(url, { + headers: { Authorization: `Bearer ${this.accessToken}` }, + signal: AbortSignal.timeout(30_000), + }); + + results.push(...response.actions); + } + + return results; + } + + async listForPlugin(actionId: string): Promise { + const pluginId = extractPluginId(actionId); + return this.list([pluginId]); + } + + async execute(actionId: string, input?: unknown): Promise { + const pluginId = extractPluginId(actionId); + const url = `${pluginActionsUrl( + this.baseUrl, + pluginId, + )}/${encodeURIComponent(actionId)}/invoke`; + + const response = await httpJson(url, { + method: 'POST', + headers: { Authorization: `Bearer ${this.accessToken}` }, + body: input ?? {}, + signal: AbortSignal.timeout(30_000), + }); + + return response.output; + } +} diff --git a/packages/cli-module-actions/src/lib/resolveAuth.ts b/packages/cli-module-actions/src/lib/resolveAuth.ts new file mode 100644 index 0000000000..11fb646545 --- /dev/null +++ b/packages/cli-module-actions/src/lib/resolveAuth.ts @@ -0,0 +1,48 @@ +/* + * Copyright 2025 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 { + getSelectedInstance, + getInstanceConfig, + accessTokenNeedsRefresh, + refreshAccessToken, + getSecretStore, + type StoredInstance, +} from '@backstage/cli-module-auth'; + +export async function resolveAuth(instanceFlag?: string): Promise<{ + instance: StoredInstance; + accessToken: string; + pluginSources: string[]; +}> { + let instance = await getSelectedInstance(instanceFlag); + + if (accessTokenNeedsRefresh(instance)) { + instance = await refreshAccessToken(instance.name); + } + + const secretStore = await getSecretStore(); + const service = `backstage-cli:auth-instance:${instance.name}`; + const accessToken = await secretStore.get(service, 'accessToken'); + if (!accessToken) { + throw new Error('No access token found. Run "auth login" to authenticate.'); + } + + const pluginSources = + (await getInstanceConfig(instance.name, 'pluginSources')) ?? []; + + return { instance, accessToken, pluginSources }; +} diff --git a/packages/cli-module-actions/src/lib/schemaToFlags.test.ts b/packages/cli-module-actions/src/lib/schemaToFlags.test.ts new file mode 100644 index 0000000000..fa9f446aae --- /dev/null +++ b/packages/cli-module-actions/src/lib/schemaToFlags.test.ts @@ -0,0 +1,163 @@ +/* + * Copyright 2025 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 { schemaToFlags } from './schemaToFlags'; + +describe('schemaToFlags', () => { + it('returns empty object when schema has no properties', () => { + expect(schemaToFlags({})).toEqual({}); + expect(schemaToFlags({ properties: {} })).toEqual({}); + }); + + it('converts string properties to String flags', () => { + const flags = schemaToFlags({ + properties: { + myProp: { type: 'string', description: 'A string prop' }, + }, + }); + + expect(flags).toEqual({ + myProp: { type: String, description: 'A string prop' }, + }); + }); + + it('converts number and integer properties to Number flags', () => { + const flags = schemaToFlags({ + properties: { + count: { type: 'integer' }, + amount: { type: 'number', description: 'An amount' }, + }, + }); + + expect(flags.count).toEqual({ type: Number, description: undefined }); + expect(flags.amount).toEqual({ type: Number, description: 'An amount' }); + }); + + it('converts boolean properties to Boolean flags', () => { + const flags = schemaToFlags({ + properties: { + verbose: { type: 'boolean', description: 'Enable verbose output' }, + }, + }); + + expect(flags.verbose).toEqual({ + type: Boolean, + description: 'Enable verbose output', + }); + }); + + it('skips non-primitive properties like object and array', () => { + const flags = schemaToFlags({ + properties: { + name: { type: 'string' }, + metadata: { type: 'object' }, + tags: { type: 'array' }, + }, + }); + + expect(Object.keys(flags)).toEqual(['name']); + }); + + it('skips properties with no type or composite types', () => { + const flags = schemaToFlags({ + properties: { + noType: {}, + name: { type: 'string' }, + }, + }); + + expect(Object.keys(flags)).toEqual(['name']); + }); + + it('uses first type when type is an array', () => { + const flags = schemaToFlags({ + properties: { + value: { type: ['string', 'null'] }, + }, + }); + + expect(flags.value).toEqual({ type: String, description: undefined }); + }); + + it('appends enum values to description', () => { + const flags = schemaToFlags({ + properties: { + color: { + type: 'string', + description: 'Pick a color', + enum: ['red', 'green', 'blue'], + }, + bare: { type: 'string', enum: ['a', 'b'] }, + }, + }); + + expect(flags.color.description).toBe('Pick a color [red, green, blue]'); + expect(flags.bare.description).toBe('[a, b]'); + }); + + it('marks required fields in description', () => { + const flags = schemaToFlags({ + properties: { + name: { type: 'string', description: 'The name' }, + optional: { type: 'string', description: 'Optional field' }, + bare: { type: 'string' }, + }, + required: ['name', 'bare'], + }); + + expect(flags.name.description).toBe('The name (required)'); + expect(flags.optional.description).toBe('Optional field'); + expect(flags.bare.description).toBe('(required)'); + }); + + it('applies default values from schema', () => { + const flags = schemaToFlags({ + properties: { + count: { type: 'number', default: 10 }, + name: { type: 'string' }, + }, + }); + + expect(flags.count.default).toBe(10); + expect(flags.name.default).toBeUndefined(); + }); + + it('combines enum and required in description', () => { + const flags = schemaToFlags({ + properties: { + env: { + type: 'string', + description: 'Target env', + enum: ['dev', 'prod'], + }, + }, + required: ['env'], + }); + + expect(flags.env.description).toBe('Target env [dev, prod] (required)'); + }); + + it('preserves camelCase property names as flag keys', () => { + const flags = schemaToFlags({ + properties: { + targetEntityRef: { type: 'string' }, + maxResults: { type: 'integer' }, + }, + }); + + expect(Object.keys(flags)).toEqual(['targetEntityRef', 'maxResults']); + }); +}); diff --git a/packages/cli-module-actions/src/lib/schemaToFlags.ts b/packages/cli-module-actions/src/lib/schemaToFlags.ts new file mode 100644 index 0000000000..026613edaf --- /dev/null +++ b/packages/cli-module-actions/src/lib/schemaToFlags.ts @@ -0,0 +1,77 @@ +/* + * Copyright 2025 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. + */ + +type JsonSchemaProperty = { + type?: string | string[]; + description?: string; + enum?: unknown[]; + default?: unknown; +}; + +type JsonSchemaObject = { + properties?: Record; + required?: string[]; +}; + +type CleyeFlag = { + type: StringConstructor | NumberConstructor | BooleanConstructor; + description?: string; + default?: unknown; +}; + +export function schemaToFlags( + schema: JsonSchemaObject, +): Record { + const flags: Record = {}; + const required = new Set(schema.required ?? []); + + if (!schema.properties) { + return flags; + } + + for (const [key, prop] of Object.entries(schema.properties)) { + const rawType = Array.isArray(prop.type) ? prop.type[0] : prop.type; + + let flagType: StringConstructor | NumberConstructor | BooleanConstructor; + if (rawType === 'string') { + flagType = String; + } else if (rawType === 'number' || rawType === 'integer') { + flagType = Number; + } else if (rawType === 'boolean') { + flagType = Boolean; + } else { + continue; + } + + let desc = prop.description ?? ''; + if (prop.enum?.length) { + const values = prop.enum.map(v => String(v)).join(', '); + desc = desc ? `${desc} [${values}]` : `[${values}]`; + } + if (required.has(key)) { + desc = desc ? `${desc} (required)` : '(required)'; + } + + const flag: CleyeFlag = { type: flagType, description: desc || undefined }; + if (prop.default !== undefined) { + flag.default = prop.default; + } + + flags[key] = flag; + } + + return flags; +} diff --git a/packages/cli-module-auth/report.api.md b/packages/cli-module-auth/report.api.md index 510bcaabe7..a3b98e2f02 100644 --- a/packages/cli-module-auth/report.api.md +++ b/packages/cli-module-auth/report.api.md @@ -5,9 +5,67 @@ ```ts import { CliModule } from '@backstage/cli-node'; +// @public (undocumented) +export function accessTokenNeedsRefresh(instance: StoredInstance): boolean; + // @public (undocumented) const _default: CliModule; export default _default; +// @public (undocumented) +export function getInstanceConfig( + instanceName: string, + key: string, +): Promise; + +// @public (undocumented) +export function getSecretStore(): Promise; + +// @public (undocumented) +export function getSelectedInstance( + instanceName?: string, +): Promise; + +// @public (undocumented) +export type HttpInit = { + headers?: Record; + method?: string; + body?: any; + signal?: AbortSignal; +}; + +// @public (undocumented) +export function httpJson(url: string, init?: HttpInit): Promise; + +// @public (undocumented) +export function refreshAccessToken( + instanceName: string, +): Promise; + +// @public (undocumented) +export type SecretStore = { + get(service: string, account: string): Promise; + set(service: string, account: string, secret: string): Promise; + delete(service: string, account: string): Promise; +}; + +// @public (undocumented) +export type StoredInstance = { + name: string; + baseUrl: string; + clientId: string; + issuedAt: number; + accessTokenExpiresAt: number; + selected?: boolean; + config?: Record; +}; + +// @public (undocumented) +export function updateInstanceConfig( + instanceName: string, + key: string, + value: unknown, +): Promise; + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/cli-module-auth/src/index.ts b/packages/cli-module-auth/src/index.ts index fef74ec8a5..38567e2174 100644 --- a/packages/cli-module-auth/src/index.ts +++ b/packages/cli-module-auth/src/index.ts @@ -52,3 +52,17 @@ export default createCliModule({ }); }, }); + +/** @public */ +export { + getSelectedInstance, + getInstanceConfig, + updateInstanceConfig, + type StoredInstance, +} from './lib/storage'; +/** @public */ +export { accessTokenNeedsRefresh, refreshAccessToken } from './lib/auth'; +/** @public */ +export { getSecretStore, type SecretStore } from './lib/secretStore'; +/** @public */ +export { httpJson, type HttpInit } from './lib/http'; diff --git a/packages/cli-module-auth/src/lib/auth.ts b/packages/cli-module-auth/src/lib/auth.ts index 3ea7baef5c..0344d4636e 100644 --- a/packages/cli-module-auth/src/lib/auth.ts +++ b/packages/cli-module-auth/src/lib/auth.ts @@ -31,10 +31,12 @@ const TokenResponseSchema = z.object({ refresh_token: z.string().min(1).optional(), }); +/** @public */ export function accessTokenNeedsRefresh(instance: StoredInstance): boolean { return instance.accessTokenExpiresAt <= Date.now() + 2 * 60_000; // 2 minutes before expiration } +/** @public */ export async function refreshAccessToken( instanceName: string, ): Promise { diff --git a/packages/cli-module-auth/src/lib/http.ts b/packages/cli-module-auth/src/lib/http.ts index 307d7d2334..789c627606 100644 --- a/packages/cli-module-auth/src/lib/http.ts +++ b/packages/cli-module-auth/src/lib/http.ts @@ -17,13 +17,15 @@ import fetch from 'cross-fetch'; import { ResponseError } from '@backstage/errors'; -type HttpInit = { +/** @public */ +export type HttpInit = { headers?: Record; method?: string; body?: any; signal?: AbortSignal; }; +/** @public */ export async function httpJson(url: string, init?: HttpInit): Promise { const res = await fetch(url, { ...init, diff --git a/packages/cli-module-auth/src/lib/secretStore.ts b/packages/cli-module-auth/src/lib/secretStore.ts index 55fac878b7..a0f3c81d51 100644 --- a/packages/cli-module-auth/src/lib/secretStore.ts +++ b/packages/cli-module-auth/src/lib/secretStore.ts @@ -18,7 +18,8 @@ import fs from 'fs-extra'; import os from 'node:os'; import path from 'node:path'; -type SecretStore = { +/** @public */ +export type SecretStore = { get(service: string, account: string): Promise; set(service: string, account: string, secret: string): Promise; delete(service: string, account: string): Promise; @@ -89,6 +90,7 @@ class FileSecretStore implements SecretStore { let singleton: SecretStore | undefined; +/** @public */ export async function getSecretStore(): Promise { if (!singleton) { const keytar = await loadKeytar(); diff --git a/packages/cli-module-auth/src/lib/storage.test.ts b/packages/cli-module-auth/src/lib/storage.test.ts index 07ffba9510..9264c6bb89 100644 --- a/packages/cli-module-auth/src/lib/storage.test.ts +++ b/packages/cli-module-auth/src/lib/storage.test.ts @@ -22,6 +22,8 @@ import { getAllInstances, getSelectedInstance, getInstanceByName, + getInstanceConfig, + updateInstanceConfig, upsertInstance, removeInstance, setSelectedInstance, @@ -357,6 +359,69 @@ describe('storage', () => { }); }); + describe('getInstanceConfig', () => { + it('should return undefined when no config set', async () => { + await upsertInstance(mockInstance1); + + const result = await getInstanceConfig('production', 'someKey'); + expect(result).toBeUndefined(); + }); + + it('should return config value for a key', async () => { + await upsertInstance(mockInstance1); + await updateInstanceConfig('production', 'myKey', 'myValue'); + + const result = await getInstanceConfig('production', 'myKey'); + expect(result).toBe('myValue'); + }); + + it('should throw NotFoundError for unknown instance', async () => { + await expect(getInstanceConfig('nonexistent', 'key')).rejects.toThrow( + NotFoundError, + ); + }); + }); + + describe('updateInstanceConfig', () => { + it('should set a config value', async () => { + await upsertInstance(mockInstance1); + await updateInstanceConfig('production', 'key1', 'value1'); + + const result = await getInstanceConfig('production', 'key1'); + expect(result).toBe('value1'); + }); + + it('should preserve existing config keys', async () => { + await upsertInstance(mockInstance1); + await updateInstanceConfig('production', 'key1', 'value1'); + await updateInstanceConfig('production', 'key2', 'value2'); + + const result1 = await getInstanceConfig('production', 'key1'); + const result2 = await getInstanceConfig('production', 'key2'); + expect(result1).toBe('value1'); + expect(result2).toBe('value2'); + }); + + it('should throw NotFoundError for unknown instance', async () => { + await expect( + updateInstanceConfig('nonexistent', 'key', 'value'), + ).rejects.toThrow(NotFoundError); + }); + + it('should remove instance along with its config', async () => { + await upsertInstance(mockInstance1); + await updateInstanceConfig('production', 'key1', 'value1'); + await removeInstance('production'); + + const { instances } = await getAllInstances(); + expect(instances.find(i => i.name === 'production')).toBeUndefined(); + + await upsertInstance(mockInstance1); + const result = await getInstanceConfig('production', 'key1'); + expect(result).toBeUndefined(); + }); + }); + describe('file path resolution', () => { it('should use XDG_CONFIG_HOME when set', async () => { const customConfigHome = mockDir.resolve('custom-config'); diff --git a/packages/cli-module-auth/src/lib/storage.ts b/packages/cli-module-auth/src/lib/storage.ts index c2eb153112..9d2551a4f9 100644 --- a/packages/cli-module-auth/src/lib/storage.ts +++ b/packages/cli-module-auth/src/lib/storage.ts @@ -36,9 +36,19 @@ const storedInstanceSchema = z.object({ issuedAt: z.number().int().nonnegative(), accessTokenExpiresAt: z.number().int().nonnegative(), selected: z.boolean().optional(), + config: z.record(z.string(), z.unknown()).optional(), }); -export type StoredInstance = z.infer; +/** @public */ +export type StoredInstance = { + name: string; + baseUrl: string; + clientId: string; + issuedAt: number; + accessTokenExpiresAt: number; + selected?: boolean; + config?: Record; +}; const authYamlSchema = z.object({ instances: z.array(storedInstanceSchema).default([]), @@ -98,6 +108,7 @@ export async function getAllInstances(): Promise<{ }; } +/** @public */ export async function getSelectedInstance( instanceName?: string, ): Promise { @@ -160,6 +171,35 @@ export async function setSelectedInstance(name: string): Promise { }); } +/** @public */ +export async function getInstanceConfig( + instanceName: string, + key: string, +): Promise { + const instance = await getInstanceByName(instanceName); + return instance.config?.[key] as T | undefined; +} + +/** @public */ +export async function updateInstanceConfig( + instanceName: string, + key: string, + value: unknown, +): Promise { + return withMetadataLock(async () => { + const data = await readAll(); + const idx = data.instances.findIndex(i => i.name === instanceName); + if (idx === -1) { + throw new NotFoundError(`Instance '${instanceName}' not found`); + } + data.instances[idx] = { + ...data.instances[idx], + config: { ...data.instances[idx].config, [key]: value }, + }; + await writeAll(data); + }); +} + export async function withMetadataLock(fn: () => Promise): Promise { const file = getMetadataFilePath(); await fs.ensureDir(path.dirname(file)); diff --git a/yarn.lock b/yarn.lock index b533dd3afc..daff483d5a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2806,6 +2806,7 @@ __metadata: resolution: "@backstage/cli-defaults@workspace:packages/cli-defaults" dependencies: "@backstage/cli": "workspace:^" + "@backstage/cli-module-actions": "workspace:^" "@backstage/cli-module-auth": "workspace:^" "@backstage/cli-module-build": "workspace:^" "@backstage/cli-module-config": "workspace:^" @@ -2820,6 +2821,18 @@ __metadata: languageName: unknown linkType: soft +"@backstage/cli-module-actions@workspace:^, @backstage/cli-module-actions@workspace:packages/cli-module-actions": + version: 0.0.0-use.local + resolution: "@backstage/cli-module-actions@workspace:packages/cli-module-actions" + dependencies: + "@backstage/backend-test-utils": "workspace:^" + "@backstage/cli": "workspace:^" + "@backstage/cli-module-auth": "workspace:^" + "@backstage/cli-node": "workspace:^" + cleye: "npm:^2.3.0" + languageName: unknown + linkType: soft + "@backstage/cli-module-auth@workspace:^, @backstage/cli-module-auth@workspace:packages/cli-module-auth": version: 0.0.0-use.local resolution: "@backstage/cli-module-auth@workspace:packages/cli-module-auth" From cc459f73a8b4096ed509b3f8913d4a329fa054b9 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Mar 2026 11:09:17 +0100 Subject: [PATCH 02/91] frontend-plugin-api: convert ApiRef to an opaque type Convert the ApiRef type in the new frontend system to an opaque type with a $$type discriminator, matching the pattern used by route refs and extension data refs. Add a builder-pattern creation overload (createApiRef().with({ id })) alongside the existing direct-config form. Create OpaqueApiRef in frontend-internal for internal type validation. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .changeset/opaque-api-ref-type.md | 7 ++ .../src/apis/system/ApiRef.test.ts | 1 + .../src/wiring/createSpecializedApp.test.tsx | 45 +++++---- .../src/apis/OpaqueApiRef.ts | 28 ++++++ packages/frontend-internal/src/apis/index.ts | 17 ++++ packages/frontend-internal/src/index.ts | 1 + packages/frontend-plugin-api/report.api.md | 10 +- .../src/apis/system/ApiRef.test.ts | 17 +++- .../src/apis/system/ApiRef.ts | 99 +++++++++++++------ .../src/apis/system/types.ts | 5 +- 10 files changed, 174 insertions(+), 56 deletions(-) create mode 100644 .changeset/opaque-api-ref-type.md create mode 100644 packages/frontend-internal/src/apis/OpaqueApiRef.ts create mode 100644 packages/frontend-internal/src/apis/index.ts diff --git a/.changeset/opaque-api-ref-type.md b/.changeset/opaque-api-ref-type.md new file mode 100644 index 0000000000..b1d7caf063 --- /dev/null +++ b/.changeset/opaque-api-ref-type.md @@ -0,0 +1,7 @@ +--- +'@backstage/frontend-plugin-api': minor +--- + +**BREAKING**: The `ApiRef` type is now an opaque type with a `$$type` discriminator field and `readonly` properties. This means that `ApiRef` instances can no longer be created as plain object literals. Use `createApiRef` to create API references. + +Added a new builder pattern for creating API references: `createApiRef().with({ id: 'plugin.my.api' })`. The existing `createApiRef({ id: 'plugin.my.api' })` pattern continues to work. diff --git a/packages/core-plugin-api/src/apis/system/ApiRef.test.ts b/packages/core-plugin-api/src/apis/system/ApiRef.test.ts index dab872236f..994cde44c6 100644 --- a/packages/core-plugin-api/src/apis/system/ApiRef.test.ts +++ b/packages/core-plugin-api/src/apis/system/ApiRef.test.ts @@ -19,6 +19,7 @@ import { createApiRef } from './ApiRef'; describe('ApiRef', () => { it('should be created', () => { const ref = createApiRef({ id: 'abc' }); + expect(ref.$$type).toBe('@backstage/ApiRef'); expect(ref.id).toBe('abc'); expect(String(ref)).toBe('apiRef{abc}'); expect(() => ref.T).toThrow('tried to read ApiRef.T of apiRef{abc}'); diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx index 51c31aca25..e07e83677b 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx @@ -166,10 +166,11 @@ describe('createSpecializedApp', () => { "factories": Map { "core.featureflags" => { "factory": { - "api": ApiRefImpl { - "config": { - "id": "core.featureflags", - }, + "api": { + "$$type": "@backstage/ApiRef", + "id": "core.featureflags", + "toString": [Function], + "version": "v1", }, "deps": {}, "factory": [Function], @@ -178,10 +179,11 @@ describe('createSpecializedApp', () => { }, "core.app-tree" => { "factory": { - "api": ApiRefImpl { - "config": { - "id": "core.app-tree", - }, + "api": { + "$$type": "@backstage/ApiRef", + "id": "core.app-tree", + "toString": [Function], + "version": "v1", }, "deps": {}, "factory": [Function], @@ -190,10 +192,11 @@ describe('createSpecializedApp', () => { }, "core.config" => { "factory": { - "api": ApiRefImpl { - "config": { - "id": "core.config", - }, + "api": { + "$$type": "@backstage/ApiRef", + "id": "core.config", + "toString": [Function], + "version": "v1", }, "deps": {}, "factory": [Function], @@ -202,10 +205,11 @@ describe('createSpecializedApp', () => { }, "core.route-resolution" => { "factory": { - "api": ApiRefImpl { - "config": { - "id": "core.route-resolution", - }, + "api": { + "$$type": "@backstage/ApiRef", + "id": "core.route-resolution", + "toString": [Function], + "version": "v1", }, "deps": {}, "factory": [Function], @@ -214,10 +218,11 @@ describe('createSpecializedApp', () => { }, "core.identity" => { "factory": { - "api": ApiRefImpl { - "config": { - "id": "core.identity", - }, + "api": { + "$$type": "@backstage/ApiRef", + "id": "core.identity", + "toString": [Function], + "version": "v1", }, "deps": {}, "factory": [Function], diff --git a/packages/frontend-internal/src/apis/OpaqueApiRef.ts b/packages/frontend-internal/src/apis/OpaqueApiRef.ts new file mode 100644 index 0000000000..5e054a4bc5 --- /dev/null +++ b/packages/frontend-internal/src/apis/OpaqueApiRef.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2025 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 { ApiRef } from '@backstage/frontend-plugin-api'; +import { OpaqueType } from '@internal/opaque'; + +export const OpaqueApiRef = OpaqueType.create<{ + public: ApiRef; + versions: { + readonly version: 'v1'; + }; +}>({ + type: '@backstage/ApiRef', + versions: ['v1'], +}); diff --git a/packages/frontend-internal/src/apis/index.ts b/packages/frontend-internal/src/apis/index.ts new file mode 100644 index 0000000000..8476e86409 --- /dev/null +++ b/packages/frontend-internal/src/apis/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { OpaqueApiRef } from './OpaqueApiRef'; diff --git a/packages/frontend-internal/src/index.ts b/packages/frontend-internal/src/index.ts index 38bfdc53f8..4bd0348345 100644 --- a/packages/frontend-internal/src/index.ts +++ b/packages/frontend-internal/src/index.ts @@ -14,5 +14,6 @@ * limitations under the License. */ +export * from './apis'; export * from './routing'; export * from './wiring'; diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index 768f26c0eb..bf71922386 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -188,8 +188,9 @@ export type ApiHolder = { // @public export type ApiRef = { - id: string; - T: T; + readonly $$type: '@backstage/ApiRef'; + readonly id: string; + readonly T: T; }; // @public @@ -418,6 +419,11 @@ export function createApiFactory( // @public export function createApiRef(config: ApiRefConfig): ApiRef; +// @public +export function createApiRef(): { + with(config: ApiRefConfig): ApiRef; +}; + // @public export function createExtension< UOutput extends ExtensionDataRef, diff --git a/packages/frontend-plugin-api/src/apis/system/ApiRef.test.ts b/packages/frontend-plugin-api/src/apis/system/ApiRef.test.ts index dab872236f..556fc3e477 100644 --- a/packages/frontend-plugin-api/src/apis/system/ApiRef.test.ts +++ b/packages/frontend-plugin-api/src/apis/system/ApiRef.test.ts @@ -17,8 +17,17 @@ import { createApiRef } from './ApiRef'; describe('ApiRef', () => { - it('should be created', () => { + it('should be created with config', () => { const ref = createApiRef({ id: 'abc' }); + expect(ref.$$type).toBe('@backstage/ApiRef'); + expect(ref.id).toBe('abc'); + expect(String(ref)).toBe('apiRef{abc}'); + expect(() => ref.T).toThrow('tried to read ApiRef.T of apiRef{abc}'); + }); + + it('should be created with builder pattern', () => { + const ref = createApiRef().with({ id: 'abc' }); + expect(ref.$$type).toBe('@backstage/ApiRef'); expect(ref.id).toBe('abc'); expect(String(ref)).toBe('apiRef{abc}'); expect(() => ref.T).toThrow('tried to read ApiRef.T of apiRef{abc}'); @@ -47,4 +56,10 @@ describe('ApiRef', () => { ); } }); + + it('should reject invalid ids with builder pattern', () => { + expect(() => createApiRef().with({ id: '123' })).toThrow( + `API id must only contain period separated lowercase alphanum tokens with dashes, got '123'`, + ); + }); }); diff --git a/packages/frontend-plugin-api/src/apis/system/ApiRef.ts b/packages/frontend-plugin-api/src/apis/system/ApiRef.ts index 0db3d89bd5..0dc181591c 100644 --- a/packages/frontend-plugin-api/src/apis/system/ApiRef.ts +++ b/packages/frontend-plugin-api/src/apis/system/ApiRef.ts @@ -25,48 +25,85 @@ export type ApiRefConfig = { id: string; }; -class ApiRefImpl implements ApiRef { - constructor(private readonly config: ApiRefConfig) { - const valid = config.id - .split('.') - .flatMap(part => part.split('-')) - .every(part => part.match(/^[a-z][a-z0-9]*$/)); - if (!valid) { - throw new Error( - `API id must only contain period separated lowercase alphanum tokens with dashes, got '${config.id}'`, - ); - } +function validateId(id: string): void { + const valid = id + .split('.') + .flatMap(part => part.split('-')) + .every(part => part.match(/^[a-z][a-z0-9]*$/)); + if (!valid) { + throw new Error( + `API id must only contain period separated lowercase alphanum tokens with dashes, got '${id}'`, + ); } +} - get id(): string { - return this.config.id; - } - - // Utility for getting type of an api, using `typeof apiRef.T` - get T(): T { - throw new Error(`tried to read ApiRef.T of ${this}`); - } - - toString() { - return `apiRef{${this.config.id}}`; - } +function makeApiRef(id: string): ApiRef { + const ref = { + $$type: '@backstage/ApiRef' as const, + version: 'v1', + id, + toString() { + return `apiRef{${id}}`; + }, + }; + Object.defineProperty(ref, 'T', { + get(): T { + throw new Error(`tried to read ApiRef.T of ${this}`); + }, + enumerable: false, + }); + return ref as unknown as ApiRef; } /** - * Creates a reference to an API. The provided `id` is a stable identifier for - * the API implementation. + * Creates a reference to an API. * * @remarks * - * The frontend system infers the owning plugin for an API from the `id`. The - * recommended pattern is `plugin..*` (for example, + * The `id` is a stable identifier for the API implementation. The frontend + * system infers the owning plugin for an API from the `id`. The recommended + * pattern is `plugin..*` (for example, * `plugin.catalog.entity-presentation`). This ensures that other plugins can't * mistakenly override your API implementation. * - * @param config - The descriptor of the API to reference. - * @returns An API reference. + * The recommended way to create an API reference is: + * + * ```ts + * const myApiRef = createApiRef().with({ id: 'plugin.my.api' }); + * ``` + * + * For backwards compatibility, you can also pass the config directly: + * + * ```ts + * const myApiRef = createApiRef({ id: 'plugin.my.api' }); + * ``` + * * @public */ -export function createApiRef(config: ApiRefConfig): ApiRef { - return new ApiRefImpl(config); +export function createApiRef(config: ApiRefConfig): ApiRef; +/** + * Creates a reference to an API. + * + * @remarks + * + * Returns a builder with a `.with()` method for providing the `id`. + * + * @public + */ +export function createApiRef(): { + with(config: ApiRefConfig): ApiRef; +}; +export function createApiRef( + config?: ApiRefConfig, +): ApiRef | { with(config: ApiRefConfig): ApiRef } { + if (config) { + validateId(config.id); + return makeApiRef(config.id); + } + return { + with(withConfig: ApiRefConfig): ApiRef { + validateId(withConfig.id); + return makeApiRef(withConfig.id); + }, + }; } diff --git a/packages/frontend-plugin-api/src/apis/system/types.ts b/packages/frontend-plugin-api/src/apis/system/types.ts index 96614c320c..570e9a4e25 100644 --- a/packages/frontend-plugin-api/src/apis/system/types.ts +++ b/packages/frontend-plugin-api/src/apis/system/types.ts @@ -20,8 +20,9 @@ * @public */ export type ApiRef = { - id: string; - T: T; + readonly $$type: '@backstage/ApiRef'; + readonly id: string; + readonly T: T; }; /** From d911b7281160a2bc3acd40282d84bb4a43822891 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Mar 2026 16:40:44 +0100 Subject: [PATCH 03/91] frontend-plugin-api: add explicit ApiRef plugin ownership Add the new frontend ApiRef builder form while preserving compatibility with existing refs, and let frontend apps resolve API ownership through an explicit pluginId when provided. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .changeset/api-ref-plugin-owner-app.md | 5 ++ .changeset/api-ref-plugin-owner-core.md | 5 ++ .changeset/opaque-api-ref-type.md | 8 +- packages/core-plugin-api/report.api.md | 4 +- .../core-plugin-api/src/apis/system/ApiRef.ts | 22 ++++- .../src/wiring/createSpecializedApp.test.tsx | 53 ++++++++++++ .../src/wiring/createSpecializedApp.tsx | 9 +- .../src/apis/OpaqueApiRef.ts | 28 ------- packages/frontend-internal/src/apis/index.ts | 17 ---- packages/frontend-internal/src/index.ts | 1 - .../frontend-plugin-api/report-alpha.api.md | 4 +- packages/frontend-plugin-api/report.api.md | 38 ++++++--- .../src/apis/definitions/AlertApi.ts | 3 +- .../src/apis/definitions/AnalyticsApi.ts | 8 +- .../src/apis/definitions/AppLanguageApi.ts | 8 +- .../src/apis/definitions/AppThemeApi.ts | 8 +- .../src/apis/definitions/AppTreeApi.ts | 5 +- .../src/apis/definitions/ConfigApi.ts | 3 +- .../src/apis/definitions/DialogApi.ts | 3 +- .../src/apis/definitions/DiscoveryApi.ts | 8 +- .../src/apis/definitions/ErrorApi.ts | 3 +- .../src/apis/definitions/FeatureFlagsApi.ts | 8 +- .../src/apis/definitions/FetchApi.ts | 3 +- .../src/apis/definitions/IconsApi.ts | 3 +- .../src/apis/definitions/IdentityApi.ts | 8 +- .../src/apis/definitions/OAuthRequestApi.ts | 8 +- .../definitions/PluginHeaderActionsApi.ts | 8 +- .../src/apis/definitions/PluginWrapperApi.ts | 3 +- .../apis/definitions/RouteResolutionApi.ts | 3 +- .../src/apis/definitions/StorageApi.ts | 8 +- .../definitions/SwappableComponentsApi.ts | 8 +- .../src/apis/definitions/TranslationApi.ts | 8 +- .../src/apis/definitions/auth.ts | 84 ++++++++++++++++--- .../src/apis/system/ApiRef.test.ts | 3 +- .../src/apis/system/ApiRef.ts | 79 ++++++++++++----- .../src/apis/system/types.ts | 3 +- .../src/apis/system/useApi.test.tsx | 6 +- .../src/blueprints/ApiBlueprint.test.ts | 8 +- plugins/scaffolder-react/report-alpha.api.md | 4 +- plugins/scaffolder-react/report.api.md | 4 +- plugins/scaffolder/report-alpha.api.md | 4 +- plugins/scaffolder/report.api.md | 4 +- 42 files changed, 355 insertions(+), 155 deletions(-) create mode 100644 .changeset/api-ref-plugin-owner-app.md create mode 100644 .changeset/api-ref-plugin-owner-core.md delete mode 100644 packages/frontend-internal/src/apis/OpaqueApiRef.ts delete mode 100644 packages/frontend-internal/src/apis/index.ts diff --git a/.changeset/api-ref-plugin-owner-app.md b/.changeset/api-ref-plugin-owner-app.md new file mode 100644 index 0000000000..e9727a3255 --- /dev/null +++ b/.changeset/api-ref-plugin-owner-app.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-app-api': patch +--- + +Frontend apps now respect an explicit `pluginId` on `ApiRef`s when deciding which plugin owns an API factory. diff --git a/.changeset/api-ref-plugin-owner-core.md b/.changeset/api-ref-plugin-owner-core.md new file mode 100644 index 0000000000..005dc06424 --- /dev/null +++ b/.changeset/api-ref-plugin-owner-core.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-plugin-api': patch +--- + +Updated `createApiRef` to preserve the direct config call without deprecation warnings while staying compatible with the new frontend API ref typing. diff --git a/.changeset/opaque-api-ref-type.md b/.changeset/opaque-api-ref-type.md index b1d7caf063..7397982cf6 100644 --- a/.changeset/opaque-api-ref-type.md +++ b/.changeset/opaque-api-ref-type.md @@ -1,7 +1,5 @@ ---- -'@backstage/frontend-plugin-api': minor ---- +## '@backstage/frontend-plugin-api': patch -**BREAKING**: The `ApiRef` type is now an opaque type with a `$$type` discriminator field and `readonly` properties. This means that `ApiRef` instances can no longer be created as plain object literals. Use `createApiRef` to create API references. +Added a builder form for `createApiRef` in the new frontend system and deprecated the direct `createApiRef({ ... })` call in favor of `createApiRef().with({ ... })`. -Added a new builder pattern for creating API references: `createApiRef().with({ id: 'plugin.my.api' })`. The existing `createApiRef({ id: 'plugin.my.api' })` pattern continues to work. +`ApiRef` and `ApiRefConfig` now also support an explicit `pluginId`, making it possible to declare API ownership without encoding the plugin ID into the API ref ID. diff --git a/packages/core-plugin-api/report.api.md b/packages/core-plugin-api/report.api.md index 654bbe94d1..7165cfc697 100644 --- a/packages/core-plugin-api/report.api.md +++ b/packages/core-plugin-api/report.api.md @@ -28,7 +28,6 @@ import { ComponentType } from 'react'; import { ConfigApi } from '@backstage/frontend-plugin-api'; import { configApiRef } from '@backstage/frontend-plugin-api'; import { createApiFactory } from '@backstage/frontend-plugin-api'; -import { createApiRef } from '@backstage/frontend-plugin-api'; import { DiscoveryApi } from '@backstage/frontend-plugin-api'; import { discoveryApiRef } from '@backstage/frontend-plugin-api'; import { ErrorApi } from '@backstage/frontend-plugin-api'; @@ -256,7 +255,8 @@ export { configApiRef }; export { createApiFactory }; -export { createApiRef }; +// @public +export function createApiRef(config: ApiRefConfig): ApiRef; // @public export function createComponentExtension< diff --git a/packages/core-plugin-api/src/apis/system/ApiRef.ts b/packages/core-plugin-api/src/apis/system/ApiRef.ts index ffd074b9a7..8c3986c583 100644 --- a/packages/core-plugin-api/src/apis/system/ApiRef.ts +++ b/packages/core-plugin-api/src/apis/system/ApiRef.ts @@ -14,5 +14,23 @@ * limitations under the License. */ -export { createApiRef } from '@backstage/frontend-plugin-api'; -export type { ApiRefConfig } from '@backstage/frontend-plugin-api'; +import { + createApiRef as createFrontendApiRef, + type ApiRef, + type ApiRefConfig, +} from '@backstage/frontend-plugin-api'; + +const createFrontendApiRefCompat = createFrontendApiRef as ( + config: ApiRefConfig, +) => ApiRef; + +/** + * Creates a reference to an API. + * + * @public + */ +export function createApiRef(config: ApiRefConfig): ApiRef { + return createFrontendApiRefCompat(config); +} + +export type { ApiRefConfig }; diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx index e07e83677b..d01bc4fe9c 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx @@ -169,6 +169,7 @@ describe('createSpecializedApp', () => { "api": { "$$type": "@backstage/ApiRef", "id": "core.featureflags", + "pluginId": "app", "toString": [Function], "version": "v1", }, @@ -182,6 +183,7 @@ describe('createSpecializedApp', () => { "api": { "$$type": "@backstage/ApiRef", "id": "core.app-tree", + "pluginId": "app", "toString": [Function], "version": "v1", }, @@ -195,6 +197,7 @@ describe('createSpecializedApp', () => { "api": { "$$type": "@backstage/ApiRef", "id": "core.config", + "pluginId": "app", "toString": [Function], "version": "v1", }, @@ -208,6 +211,7 @@ describe('createSpecializedApp', () => { "api": { "$$type": "@backstage/ApiRef", "id": "core.route-resolution", + "pluginId": "app", "toString": [Function], "version": "v1", }, @@ -221,6 +225,7 @@ describe('createSpecializedApp', () => { "api": { "$$type": "@backstage/ApiRef", "id": "core.identity", + "pluginId": "app", "toString": [Function], "version": "v1", }, @@ -364,6 +369,54 @@ 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 allow API overrides within the same plugin', () => { const testApiRef = createApiRef<{ value: string }>({ id: 'test.api' }); diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx index fda00b70b1..7ab36f2fad 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx @@ -401,7 +401,7 @@ function createApiFactories(options: { const apiFactory = apiNode.instance?.getData(ApiBlueprint.dataRefs.factory); if (apiFactory) { const apiRefId = apiFactory.api.id; - const ownerId = getApiOwnerId(apiRefId); + const ownerId = getApiOwnerId(apiFactory.api); const pluginId = apiNode.spec.plugin.pluginId ?? 'app'; const existingFactory = factoriesById.get(apiRefId); @@ -455,7 +455,12 @@ function createApiFactories(options: { // 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 { +function getApiOwnerId(apiRef: { id: string; pluginId?: string }): string { + if (apiRef.pluginId) { + return apiRef.pluginId; + } + + const apiRefId = apiRef.id; const [prefix, ...rest] = apiRefId.split('.'); if (!prefix) { return apiRefId; diff --git a/packages/frontend-internal/src/apis/OpaqueApiRef.ts b/packages/frontend-internal/src/apis/OpaqueApiRef.ts deleted file mode 100644 index 5e054a4bc5..0000000000 --- a/packages/frontend-internal/src/apis/OpaqueApiRef.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright 2025 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 { ApiRef } from '@backstage/frontend-plugin-api'; -import { OpaqueType } from '@internal/opaque'; - -export const OpaqueApiRef = OpaqueType.create<{ - public: ApiRef; - versions: { - readonly version: 'v1'; - }; -}>({ - type: '@backstage/ApiRef', - versions: ['v1'], -}); diff --git a/packages/frontend-internal/src/apis/index.ts b/packages/frontend-internal/src/apis/index.ts deleted file mode 100644 index 8476e86409..0000000000 --- a/packages/frontend-internal/src/apis/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright 2025 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export { OpaqueApiRef } from './OpaqueApiRef'; diff --git a/packages/frontend-internal/src/index.ts b/packages/frontend-internal/src/index.ts index 4bd0348345..38bfdc53f8 100644 --- a/packages/frontend-internal/src/index.ts +++ b/packages/frontend-internal/src/index.ts @@ -14,6 +14,5 @@ * limitations under the License. */ -export * from './apis'; export * from './routing'; export * from './wiring'; diff --git a/packages/frontend-plugin-api/report-alpha.api.md b/packages/frontend-plugin-api/report-alpha.api.md index 76678107ef..c1a3c71a21 100644 --- a/packages/frontend-plugin-api/report-alpha.api.md +++ b/packages/frontend-plugin-api/report-alpha.api.md @@ -24,7 +24,9 @@ export type PluginWrapperApi = { }; // @public -export const pluginWrapperApiRef: ApiRef; +export const pluginWrapperApiRef: ApiRef & { + readonly $$type: '@backstage/ApiRef'; +}; // @public export const PluginWrapperBlueprint: ExtensionBlueprint<{ diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index bf71922386..17b3ecc75d 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -188,14 +188,16 @@ export type ApiHolder = { // @public export type ApiRef = { - readonly $$type: '@backstage/ApiRef'; + readonly $$type?: '@backstage/ApiRef'; readonly id: string; + readonly pluginId?: string; readonly T: T; }; // @public export type ApiRefConfig = { id: string; + pluginId?: string; }; // @public (undocumented) @@ -306,7 +308,9 @@ export interface AppTreeApi { } // @public -export const appTreeApiRef: ApiRef_2; +export const appTreeApiRef: ApiRef_2 & { + readonly $$type: '@backstage/ApiRef'; +}; // @public export const atlassianAuthApiRef: ApiRef< @@ -416,12 +420,16 @@ export function createApiFactory( instance: Impl, ): ApiFactory; -// @public -export function createApiRef(config: ApiRefConfig): ApiRef; +// @public @deprecated +export function createApiRef(config: ApiRefConfig): ApiRef & { + readonly $$type: '@backstage/ApiRef'; +}; // @public export function createApiRef(): { - with(config: ApiRefConfig): ApiRef; + with(config: ApiRefConfig): ApiRef & { + readonly $$type: '@backstage/ApiRef'; + }; }; // @public @@ -875,7 +883,9 @@ export interface DialogApiDialog { } // @public -export const dialogApiRef: ApiRef_2; +export const dialogApiRef: ApiRef_2 & { + readonly $$type: '@backstage/ApiRef'; +}; // @public export type DiscoveryApi = { @@ -1436,7 +1446,9 @@ export interface IconsApi { } // @public -export const iconsApiRef: ApiRef_2; +export const iconsApiRef: ApiRef_2 & { + readonly $$type: '@backstage/ApiRef'; +}; // @public export type IdentityApi = { @@ -1851,7 +1863,9 @@ export type PluginHeaderActionsApi = { }; // @public -export const pluginHeaderActionsApiRef: ApiRef_2; +export const pluginHeaderActionsApiRef: ApiRef_2 & { + readonly $$type: '@backstage/ApiRef'; +}; // @public (undocumented) export interface PluginOptions< @@ -1999,7 +2013,9 @@ export interface RouteResolutionApi { } // @public -export const routeResolutionApiRef: ApiRef_2; +export const routeResolutionApiRef: ApiRef_2 & { + readonly $$type: '@backstage/ApiRef'; +}; // @public export type SessionApi = { @@ -2127,7 +2143,9 @@ export interface SwappableComponentsApi { } // @public -export const swappableComponentsApiRef: ApiRef_2; +export const swappableComponentsApiRef: ApiRef_2 & { + readonly $$type: '@backstage/ApiRef'; +}; // @public (undocumented) export type TranslationApi = { diff --git a/packages/frontend-plugin-api/src/apis/definitions/AlertApi.ts b/packages/frontend-plugin-api/src/apis/definitions/AlertApi.ts index afdcb6a617..09d979ad9a 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/AlertApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/AlertApi.ts @@ -51,6 +51,7 @@ export type AlertApi = { * * @public */ -export const alertApiRef: ApiRef = createApiRef({ +export const alertApiRef: ApiRef = createApiRef().with({ id: 'core.alert', + pluginId: 'app', }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/AnalyticsApi.ts b/packages/frontend-plugin-api/src/apis/definitions/AnalyticsApi.ts index aa1f08ffbe..51acc1851d 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/AnalyticsApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/AnalyticsApi.ts @@ -151,6 +151,8 @@ export type AnalyticsApi = { * * @public */ -export const analyticsApiRef: ApiRef = createApiRef({ - id: 'core.analytics', -}); +export const analyticsApiRef: ApiRef = + createApiRef().with({ + id: 'core.analytics', + pluginId: 'app', + }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/AppLanguageApi.ts b/packages/frontend-plugin-api/src/apis/definitions/AppLanguageApi.ts index 36b97f9fff..c4a1c8be73 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/AppLanguageApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/AppLanguageApi.ts @@ -31,6 +31,8 @@ export type AppLanguageApi = { /** * @public */ -export const appLanguageApiRef: ApiRef = createApiRef({ - id: 'core.applanguage', -}); +export const appLanguageApiRef: ApiRef = + createApiRef().with({ + id: 'core.applanguage', + pluginId: 'app', + }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/AppThemeApi.ts b/packages/frontend-plugin-api/src/apis/definitions/AppThemeApi.ts index e771fad597..39561f5f96 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/AppThemeApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/AppThemeApi.ts @@ -82,6 +82,8 @@ export type AppThemeApi = { * * @public */ -export const appThemeApiRef: ApiRef = createApiRef({ - id: 'core.apptheme', -}); +export const appThemeApiRef: ApiRef = + createApiRef().with({ + id: 'core.apptheme', + pluginId: 'app', + }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts b/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts index 89902d2727..16369680f2 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts @@ -117,4 +117,7 @@ export interface AppTreeApi { * * @public */ -export const appTreeApiRef = createApiRef({ id: 'core.app-tree' }); +export const appTreeApiRef = createApiRef().with({ + id: 'core.app-tree', + pluginId: 'app', +}); diff --git a/packages/frontend-plugin-api/src/apis/definitions/ConfigApi.ts b/packages/frontend-plugin-api/src/apis/definitions/ConfigApi.ts index f935dfa3af..eb52c1cbca 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/ConfigApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/ConfigApi.ts @@ -29,6 +29,7 @@ export type ConfigApi = Config; * * @public */ -export const configApiRef: ApiRef = createApiRef({ +export const configApiRef: ApiRef = createApiRef().with({ id: 'core.config', + pluginId: 'app', }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/DialogApi.ts b/packages/frontend-plugin-api/src/apis/definitions/DialogApi.ts index d7ab2e5d75..bcb92f528a 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/DialogApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/DialogApi.ts @@ -173,6 +173,7 @@ export interface DialogApi { * * @public */ -export const dialogApiRef = createApiRef({ +export const dialogApiRef = createApiRef().with({ id: 'core.dialog', + pluginId: 'app', }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/DiscoveryApi.ts b/packages/frontend-plugin-api/src/apis/definitions/DiscoveryApi.ts index d23fe3db6c..fb348c156d 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/DiscoveryApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/DiscoveryApi.ts @@ -50,6 +50,8 @@ export type DiscoveryApi = { * * @public */ -export const discoveryApiRef: ApiRef = createApiRef({ - id: 'core.discovery', -}); +export const discoveryApiRef: ApiRef = + createApiRef().with({ + id: 'core.discovery', + pluginId: 'app', + }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/ErrorApi.ts b/packages/frontend-plugin-api/src/apis/definitions/ErrorApi.ts index 9c73d94cac..d106ccc05c 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/ErrorApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/ErrorApi.ts @@ -86,6 +86,7 @@ export type ErrorApi = { * * @public */ -export const errorApiRef: ApiRef = createApiRef({ +export const errorApiRef: ApiRef = createApiRef().with({ id: 'core.error', + pluginId: 'app', }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/FeatureFlagsApi.ts b/packages/frontend-plugin-api/src/apis/definitions/FeatureFlagsApi.ts index d4429975cc..7206dd9900 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/FeatureFlagsApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/FeatureFlagsApi.ts @@ -121,6 +121,8 @@ export interface FeatureFlagsApi { * * @public */ -export const featureFlagsApiRef: ApiRef = createApiRef({ - id: 'core.featureflags', -}); +export const featureFlagsApiRef: ApiRef = + createApiRef().with({ + id: 'core.featureflags', + pluginId: 'app', + }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/FetchApi.ts b/packages/frontend-plugin-api/src/apis/definitions/FetchApi.ts index aba0e53bb7..4e4909b7d4 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/FetchApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/FetchApi.ts @@ -46,6 +46,7 @@ export type FetchApi = { * * @public */ -export const fetchApiRef: ApiRef = createApiRef({ +export const fetchApiRef: ApiRef = createApiRef().with({ id: 'core.fetch', + pluginId: 'app', }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/IconsApi.ts b/packages/frontend-plugin-api/src/apis/definitions/IconsApi.ts index d22ebcce4a..a54ae7f3b1 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/IconsApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/IconsApi.ts @@ -41,6 +41,7 @@ export interface IconsApi { * * @public */ -export const iconsApiRef = createApiRef({ +export const iconsApiRef = createApiRef().with({ id: 'core.icons', + pluginId: 'app', }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/IdentityApi.ts b/packages/frontend-plugin-api/src/apis/definitions/IdentityApi.ts index 1b127a1971..dc23202c2e 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/IdentityApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/IdentityApi.ts @@ -51,6 +51,8 @@ export type IdentityApi = { * * @public */ -export const identityApiRef: ApiRef = createApiRef({ - id: 'core.identity', -}); +export const identityApiRef: ApiRef = + createApiRef().with({ + id: 'core.identity', + pluginId: 'app', + }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/OAuthRequestApi.ts b/packages/frontend-plugin-api/src/apis/definitions/OAuthRequestApi.ts index 75bf3a3864..0c199948af 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/OAuthRequestApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/OAuthRequestApi.ts @@ -126,6 +126,8 @@ export type OAuthRequestApi = { * * @public */ -export const oauthRequestApiRef: ApiRef = createApiRef({ - id: 'core.oauthrequest', -}); +export const oauthRequestApiRef: ApiRef = + createApiRef().with({ + id: 'core.oauthrequest', + pluginId: 'app', + }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/PluginHeaderActionsApi.ts b/packages/frontend-plugin-api/src/apis/definitions/PluginHeaderActionsApi.ts index 78d0e2623f..9e2d1ca0fd 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/PluginHeaderActionsApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/PluginHeaderActionsApi.ts @@ -40,6 +40,8 @@ export type PluginHeaderActionsApi = { * * @public */ -export const pluginHeaderActionsApiRef = createApiRef({ - id: 'core.plugin-header-actions', -}); +export const pluginHeaderActionsApiRef = + createApiRef().with({ + id: 'core.plugin-header-actions', + pluginId: 'app', + }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/PluginWrapperApi.ts b/packages/frontend-plugin-api/src/apis/definitions/PluginWrapperApi.ts index 8d9b224b1f..7965b8ccb4 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/PluginWrapperApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/PluginWrapperApi.ts @@ -47,6 +47,7 @@ export type PluginWrapperApi = { * * @public */ -export const pluginWrapperApiRef = createApiRef({ +export const pluginWrapperApiRef = createApiRef().with({ id: 'core.plugin-wrapper', + pluginId: 'app', }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/RouteResolutionApi.ts b/packages/frontend-plugin-api/src/apis/definitions/RouteResolutionApi.ts index 0c3ca9c4cf..9dea5d7bc2 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/RouteResolutionApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/RouteResolutionApi.ts @@ -65,6 +65,7 @@ export interface RouteResolutionApi { * * @public */ -export const routeResolutionApiRef = createApiRef({ +export const routeResolutionApiRef = createApiRef().with({ id: 'core.route-resolution', + pluginId: 'app', }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/StorageApi.ts b/packages/frontend-plugin-api/src/apis/definitions/StorageApi.ts index 1506e5f143..7e8372b6fb 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/StorageApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/StorageApi.ts @@ -105,6 +105,8 @@ export interface StorageApi { * * @public */ -export const storageApiRef: ApiRef = createApiRef({ - id: 'core.storage', -}); +export const storageApiRef: ApiRef = + createApiRef().with({ + id: 'core.storage', + pluginId: 'app', + }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/SwappableComponentsApi.ts b/packages/frontend-plugin-api/src/apis/definitions/SwappableComponentsApi.ts index 09dff04f43..47ffed91ef 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/SwappableComponentsApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/SwappableComponentsApi.ts @@ -36,6 +36,8 @@ export interface SwappableComponentsApi { * * @public */ -export const swappableComponentsApiRef = createApiRef({ - id: 'core.swappable-components', -}); +export const swappableComponentsApiRef = + createApiRef().with({ + id: 'core.swappable-components', + pluginId: 'app', + }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/TranslationApi.ts b/packages/frontend-plugin-api/src/apis/definitions/TranslationApi.ts index b569800e3e..6997269484 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/TranslationApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/TranslationApi.ts @@ -358,6 +358,8 @@ export type TranslationApi = { /** * @public */ -export const translationApiRef: ApiRef = createApiRef({ - id: 'core.translation', -}); +export const translationApiRef: ApiRef = + createApiRef().with({ + id: 'core.translation', + pluginId: 'app', + }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/auth.ts b/packages/frontend-plugin-api/src/apis/definitions/auth.ts index 76aa24e8f8..0c05047f24 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/auth.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/auth.ts @@ -28,7 +28,10 @@ import { Observable } from '@backstage/types'; * For example, a Google OAuth provider that supports OAuth 2 and OpenID Connect, * would be declared as follows: * - * const googleAuthApiRef = createApiRef({ ... }) + * const googleAuthApiRef = createApiRef().with({ + * id: 'core.auth.google', + * pluginId: 'app', + * }) */ /** @@ -339,8 +342,15 @@ export const googleAuthApiRef: ApiRef< ProfileInfoApi & BackstageIdentityApi & SessionApi -> = createApiRef({ +> = createApiRef< + OAuthApi & + OpenIdConnectApi & + ProfileInfoApi & + BackstageIdentityApi & + SessionApi +>().with({ id: 'core.auth.google', + pluginId: 'app', }); /** @@ -354,8 +364,11 @@ export const googleAuthApiRef: ApiRef< */ export const githubAuthApiRef: ApiRef< OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi -> = createApiRef({ +> = createApiRef< + OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi +>().with({ id: 'core.auth.github', + pluginId: 'app', }); /** @@ -373,8 +386,15 @@ export const oktaAuthApiRef: ApiRef< ProfileInfoApi & BackstageIdentityApi & SessionApi -> = createApiRef({ +> = createApiRef< + OAuthApi & + OpenIdConnectApi & + ProfileInfoApi & + BackstageIdentityApi & + SessionApi +>().with({ id: 'core.auth.okta', + pluginId: 'app', }); /** @@ -392,8 +412,15 @@ export const gitlabAuthApiRef: ApiRef< ProfileInfoApi & BackstageIdentityApi & SessionApi -> = createApiRef({ +> = createApiRef< + OAuthApi & + OpenIdConnectApi & + ProfileInfoApi & + BackstageIdentityApi & + SessionApi +>().with({ id: 'core.auth.gitlab', + pluginId: 'app', }); /** @@ -412,8 +439,15 @@ export const microsoftAuthApiRef: ApiRef< ProfileInfoApi & BackstageIdentityApi & SessionApi -> = createApiRef({ +> = createApiRef< + OAuthApi & + OpenIdConnectApi & + ProfileInfoApi & + BackstageIdentityApi & + SessionApi +>().with({ id: 'core.auth.microsoft', + pluginId: 'app', }); /** @@ -427,8 +461,15 @@ export const oneloginAuthApiRef: ApiRef< ProfileInfoApi & BackstageIdentityApi & SessionApi -> = createApiRef({ +> = createApiRef< + OAuthApi & + OpenIdConnectApi & + ProfileInfoApi & + BackstageIdentityApi & + SessionApi +>().with({ id: 'core.auth.onelogin', + pluginId: 'app', }); /** @@ -442,8 +483,11 @@ export const oneloginAuthApiRef: ApiRef< */ export const bitbucketAuthApiRef: ApiRef< OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi -> = createApiRef({ +> = createApiRef< + OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi +>().with({ id: 'core.auth.bitbucket', + pluginId: 'app', }); /** @@ -457,8 +501,11 @@ export const bitbucketAuthApiRef: ApiRef< */ export const bitbucketServerAuthApiRef: ApiRef< OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi -> = createApiRef({ +> = createApiRef< + OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi +>().with({ id: 'core.auth.bitbucket-server', + pluginId: 'app', }); /** @@ -472,8 +519,11 @@ export const bitbucketServerAuthApiRef: ApiRef< */ export const atlassianAuthApiRef: ApiRef< OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi -> = createApiRef({ +> = createApiRef< + OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi +>().with({ id: 'core.auth.atlassian', + pluginId: 'app', }); /** @@ -491,8 +541,15 @@ export const vmwareCloudAuthApiRef: ApiRef< ProfileInfoApi & BackstageIdentityApi & SessionApi -> = createApiRef({ +> = createApiRef< + OAuthApi & + OpenIdConnectApi & + ProfileInfoApi & + BackstageIdentityApi & + SessionApi +>().with({ id: 'core.auth.vmware-cloud', + pluginId: 'app', }); /** @@ -508,6 +565,9 @@ export const vmwareCloudAuthApiRef: ApiRef< */ export const openshiftAuthApiRef: ApiRef< OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi -> = createApiRef({ +> = createApiRef< + OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi +>().with({ id: 'core.auth.openshift', + pluginId: 'app', }); diff --git a/packages/frontend-plugin-api/src/apis/system/ApiRef.test.ts b/packages/frontend-plugin-api/src/apis/system/ApiRef.test.ts index 556fc3e477..b20134cc2a 100644 --- a/packages/frontend-plugin-api/src/apis/system/ApiRef.test.ts +++ b/packages/frontend-plugin-api/src/apis/system/ApiRef.test.ts @@ -26,9 +26,10 @@ describe('ApiRef', () => { }); it('should be created with builder pattern', () => { - const ref = createApiRef().with({ id: 'abc' }); + const ref = createApiRef().with({ id: 'abc', pluginId: 'test' }); expect(ref.$$type).toBe('@backstage/ApiRef'); expect(ref.id).toBe('abc'); + expect(ref.pluginId).toBe('test'); expect(String(ref)).toBe('apiRef{abc}'); expect(() => ref.T).toThrow('tried to read ApiRef.T of apiRef{abc}'); }); diff --git a/packages/frontend-plugin-api/src/apis/system/ApiRef.ts b/packages/frontend-plugin-api/src/apis/system/ApiRef.ts index 0dc181591c..3cc7fc6649 100644 --- a/packages/frontend-plugin-api/src/apis/system/ApiRef.ts +++ b/packages/frontend-plugin-api/src/apis/system/ApiRef.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { OpaqueType } from '@internal/opaque'; import type { ApiRef } from './types'; /** @@ -23,8 +24,21 @@ import type { ApiRef } from './types'; */ export type ApiRefConfig = { id: string; + pluginId?: string; }; +const OpaqueApiRef = OpaqueType.create<{ + public: ApiRef & { + readonly $$type: '@backstage/ApiRef'; + }; + versions: { + readonly version: 'v1'; + }; +}>({ + type: '@backstage/ApiRef', + versions: ['v1'], +}); + function validateId(id: string): void { const valid = id .split('.') @@ -37,22 +51,24 @@ function validateId(id: string): void { } } -function makeApiRef(id: string): ApiRef { - const ref = { - $$type: '@backstage/ApiRef' as const, - version: 'v1', - id, +function makeApiRef( + config: ApiRefConfig, +): ApiRef & { readonly $$type: '@backstage/ApiRef' } { + const ref = OpaqueApiRef.createInstance('v1', { + id: config.id, + ...(config.pluginId ? { pluginId: config.pluginId } : {}), + T: undefined as T, toString() { - return `apiRef{${id}}`; + return `apiRef{${config.id}}`; }, - }; + }) as ApiRef & { readonly $$type: '@backstage/ApiRef' }; Object.defineProperty(ref, 'T', { get(): T { throw new Error(`tried to read ApiRef.T of ${this}`); }, enumerable: false, }); - return ref as unknown as ApiRef; + return ref; } /** @@ -61,18 +77,22 @@ function makeApiRef(id: string): ApiRef { * @remarks * * The `id` is a stable identifier for the API implementation. The frontend - * system infers the owning plugin for an API from the `id`. The recommended - * pattern is `plugin..*` (for example, + * system infers the owning plugin for an API from the `id`, unless you provide + * a `pluginId` explicitly. The recommended pattern is `plugin..*` + * (for example, * `plugin.catalog.entity-presentation`). This ensures that other plugins can't * mistakenly override your API implementation. * * The recommended way to create an API reference is: * * ```ts - * const myApiRef = createApiRef().with({ id: 'plugin.my.api' }); + * const myApiRef = createApiRef().with({ + * id: 'my-api', + * pluginId: 'my-plugin', + * }); * ``` * - * For backwards compatibility, you can also pass the config directly: + * The legacy way to create an API reference is: * * ```ts * const myApiRef = createApiRef({ id: 'plugin.my.api' }); @@ -80,30 +100,47 @@ function makeApiRef(id: string): ApiRef { * * @public */ -export function createApiRef(config: ApiRefConfig): ApiRef; +/** + * Creates a reference to an API. + * + * @deprecated Use `createApiRef().with(...)` instead. + * @public + */ +export function createApiRef( + config: ApiRefConfig, +): ApiRef & { readonly $$type: '@backstage/ApiRef' }; /** * Creates a reference to an API. * * @remarks * - * Returns a builder with a `.with()` method for providing the `id`. + * Returns a builder with a `.with()` method for providing the API reference + * configuration. * * @public */ export function createApiRef(): { - with(config: ApiRefConfig): ApiRef; + with(config: ApiRefConfig): ApiRef & { + readonly $$type: '@backstage/ApiRef'; + }; }; -export function createApiRef( - config?: ApiRefConfig, -): ApiRef | { with(config: ApiRefConfig): ApiRef } { +export function createApiRef(config?: ApiRefConfig): + | (ApiRef & { readonly $$type: '@backstage/ApiRef' }) + | { + with(config: ApiRefConfig): ApiRef & { + readonly $$type: '@backstage/ApiRef'; + }; + } { if (config) { validateId(config.id); - return makeApiRef(config.id); + return makeApiRef(config); } return { - with(withConfig: ApiRefConfig): ApiRef { + with(withConfig: ApiRefConfig): ApiRef & { + readonly $$type: '@backstage/ApiRef'; + } { validateId(withConfig.id); - return makeApiRef(withConfig.id); + return makeApiRef(withConfig); }, }; } diff --git a/packages/frontend-plugin-api/src/apis/system/types.ts b/packages/frontend-plugin-api/src/apis/system/types.ts index 570e9a4e25..90e7365164 100644 --- a/packages/frontend-plugin-api/src/apis/system/types.ts +++ b/packages/frontend-plugin-api/src/apis/system/types.ts @@ -20,8 +20,9 @@ * @public */ export type ApiRef = { - readonly $$type: '@backstage/ApiRef'; + readonly $$type?: '@backstage/ApiRef'; readonly id: string; + readonly pluginId?: string; readonly T: T; }; diff --git a/packages/frontend-plugin-api/src/apis/system/useApi.test.tsx b/packages/frontend-plugin-api/src/apis/system/useApi.test.tsx index 7596105810..a5cfb627ac 100644 --- a/packages/frontend-plugin-api/src/apis/system/useApi.test.tsx +++ b/packages/frontend-plugin-api/src/apis/system/useApi.test.tsx @@ -38,7 +38,9 @@ describe('useApiHolder', () => { const renderedHook = renderHook(() => useApiHolder()); const holder = renderedHook.result.current; - expect(holder.get(createApiRef({ id: 'x' }))).toBeUndefined(); + expect( + holder.get(createApiRef().with({ id: 'x' })), + ).toBeUndefined(); }); }); @@ -53,7 +55,7 @@ describe('useApi', () => { const get = jest.fn(() => 'my-api-impl'); context.set({ 1: { get } }); - const apiRef = createApiRef({ id: 'x' }); + const apiRef = createApiRef().with({ id: 'x' }); const renderedHook = renderHook(() => useApi(apiRef)); const value = renderedHook.result.current; diff --git a/packages/frontend-plugin-api/src/blueprints/ApiBlueprint.test.ts b/packages/frontend-plugin-api/src/blueprints/ApiBlueprint.test.ts index 8043752687..8e9c35afc8 100644 --- a/packages/frontend-plugin-api/src/blueprints/ApiBlueprint.test.ts +++ b/packages/frontend-plugin-api/src/blueprints/ApiBlueprint.test.ts @@ -20,7 +20,7 @@ import { createApiRef } from '../apis/system'; describe('ApiBlueprint', () => { it('should create an extension with sensible defaults', () => { - const api = createApiRef<{ foo: string }>({ id: 'test' }); + const api = createApiRef<{ foo: string }>().with({ id: 'test' }); const extension = ApiBlueprint.make({ params: defineParams => @@ -57,8 +57,8 @@ describe('ApiBlueprint', () => { }); it('should properly type the API factory', () => { - const fooApi = createApiRef<{ foo: string }>({ id: 'foo' }); - const barApi = createApiRef<{ bar: string }>({ id: 'bar' }); + const fooApi = createApiRef<{ foo: string }>().with({ id: 'foo' }); + const barApi = createApiRef<{ bar: string }>().with({ id: 'bar' }); expect('test').not.toBe('failing without assertions'); @@ -152,7 +152,7 @@ describe('ApiBlueprint', () => { }); it('should create an extension with custom factory', () => { - const api = createApiRef<{ foo: string }>({ id: 'test' }); + const api = createApiRef<{ foo: string }>().with({ id: 'test' }); const factory = jest.fn(() => ({ foo: 'bar' })); const extension = ApiBlueprint.makeWithOverrides({ diff --git a/plugins/scaffolder-react/report-alpha.api.md b/plugins/scaffolder-react/report-alpha.api.md index c546f265be..9e01257c5c 100644 --- a/plugins/scaffolder-react/report-alpha.api.md +++ b/plugins/scaffolder-react/report-alpha.api.md @@ -200,7 +200,9 @@ export type FormFieldExtensionData< }; // @alpha (undocumented) -export const formFieldsApiRef: ApiRef; +export const formFieldsApiRef: ApiRef & { + readonly $$type: '@backstage/ApiRef'; +}; // @alpha (undocumented) export type FormValidation = { diff --git a/plugins/scaffolder-react/report.api.md b/plugins/scaffolder-react/report.api.md index daec52a7e3..d40b0278bf 100644 --- a/plugins/scaffolder-react/report.api.md +++ b/plugins/scaffolder-react/report.api.md @@ -208,7 +208,9 @@ export type ReviewStepProps = { export type ScaffolderApi = ScaffolderApi_2; // @public (undocumented) -export const scaffolderApiRef: ApiRef; +export const scaffolderApiRef: ApiRef & { + readonly $$type: '@backstage/ApiRef'; +}; // @public @deprecated (undocumented) export type ScaffolderDryRunOptions = ScaffolderDryRunOptions_2; diff --git a/plugins/scaffolder/report-alpha.api.md b/plugins/scaffolder/report-alpha.api.md index 7c96a74c8b..b2cd1bbcbe 100644 --- a/plugins/scaffolder/report-alpha.api.md +++ b/plugins/scaffolder/report-alpha.api.md @@ -479,7 +479,9 @@ export const formDecoratorsApi: OverridableExtensionDefinition<{ }>; // @alpha (undocumented) -export const formDecoratorsApiRef: ApiRef; +export const formDecoratorsApiRef: ApiRef & { + readonly $$type: '@backstage/ApiRef'; +}; export { formFieldsApiRef }; diff --git a/plugins/scaffolder/report.api.md b/plugins/scaffolder/report.api.md index 5c0fffbcc1..17fd25e646 100644 --- a/plugins/scaffolder/report.api.md +++ b/plugins/scaffolder/report.api.md @@ -528,7 +528,9 @@ export type RouterProps = { export type ScaffolderApi = ScaffolderApi_2; // @public @deprecated (undocumented) -export const scaffolderApiRef: ApiRef; +export const scaffolderApiRef: ApiRef & { + readonly $$type: '@backstage/ApiRef'; +}; // @public @deprecated export class ScaffolderClient extends ScaffolderClient_2 {} From 476df5ffd5a5a8ba7d9b780fc74059c170d52269 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Mar 2026 17:04:41 +0100 Subject: [PATCH 04/91] Fix changeset frontmatter formatting Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .changeset/opaque-api-ref-type.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/opaque-api-ref-type.md b/.changeset/opaque-api-ref-type.md index 7397982cf6..81f365b578 100644 --- a/.changeset/opaque-api-ref-type.md +++ b/.changeset/opaque-api-ref-type.md @@ -1,4 +1,6 @@ -## '@backstage/frontend-plugin-api': patch +--- +'@backstage/frontend-plugin-api': patch +--- Added a builder form for `createApiRef` in the new frontend system and deprecated the direct `createApiRef({ ... })` call in favor of `createApiRef().with({ ... })`. From 29b87812ba3171b3c7058681452e1e98eeeab3ca Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Mar 2026 17:31:06 +0100 Subject: [PATCH 05/91] Regenerate API reports Signed-off-by: Patrik Oldsberg Made-with: Cursor --- packages/app-example-plugin/report.api.md | 2 +- packages/cli-defaults/report.api.md | 4 +- packages/core-components/report-alpha.api.md | 4 +- packages/frontend-plugin-api/report.api.md | 8 ++- plugins/api-docs/report-alpha.api.md | 34 +++++------ plugins/app-visualizer/report.api.md | 8 +-- plugins/app/report.api.md | 4 +- plugins/auth/report.api.md | 2 +- plugins/catalog-graph/report-alpha.api.md | 14 ++--- plugins/catalog-import/report-alpha.api.md | 10 ++-- plugins/catalog-react/report-alpha.api.md | 30 +++++----- .../report-alpha.api.md | 4 +- plugins/catalog/report-alpha.api.md | 58 +++++++++---------- plugins/catalog/report.api.md | 2 +- plugins/devtools-react/report.api.md | 2 +- plugins/devtools/report-alpha.api.md | 2 +- plugins/home/report-alpha.api.md | 4 +- plugins/kubernetes-react/report-alpha.api.md | 8 +-- plugins/kubernetes/report-alpha.api.md | 6 +- plugins/mui-to-bui/report.api.md | 2 +- plugins/notifications/report-alpha.api.md | 14 ++--- plugins/org/report-alpha.api.md | 16 ++--- .../report.api.md | 28 ++++----- .../report.api.md | 2 +- .../report.api.md | 2 +- .../report.api.md | 22 +++---- .../report.api.md | 34 +++++------ .../report.api.md | 4 +- plugins/scaffolder-backend/report.api.md | 2 +- plugins/scaffolder-react/report-alpha.api.md | 2 +- plugins/scaffolder/report-alpha.api.md | 30 +++++----- .../report.api.md | 4 +- plugins/search/report-alpha.api.md | 4 +- plugins/techdocs/report-alpha.api.md | 10 ++-- plugins/user-settings/report-alpha.api.md | 12 ++-- 35 files changed, 197 insertions(+), 197 deletions(-) diff --git a/packages/app-example-plugin/report.api.md b/packages/app-example-plugin/report.api.md index 432174da68..0879f318aa 100644 --- a/packages/app-example-plugin/report.api.md +++ b/packages/app-example-plugin/report.api.md @@ -27,8 +27,8 @@ const examplePlugin: OverridableFrontendPlugin< title: string | undefined; }; configInput: { - title?: string | undefined; path?: string | undefined; + title?: string | undefined; }; output: | ExtensionDataRef diff --git a/packages/cli-defaults/report.api.md b/packages/cli-defaults/report.api.md index 573f8ee454..1a40836eaa 100644 --- a/packages/cli-defaults/report.api.md +++ b/packages/cli-defaults/report.api.md @@ -3,10 +3,8 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import { CliModule } from '@backstage/cli-node'; - // @public -const _default: CliModule[]; +const _default: any[]; export default _default; // (No @packageDocumentation comment for this package) diff --git a/packages/core-components/report-alpha.api.md b/packages/core-components/report-alpha.api.md index f373dd56ff..7b7f514dcf 100644 --- a/packages/core-components/report-alpha.api.md +++ b/packages/core-components/report-alpha.api.md @@ -12,8 +12,8 @@ export const coreComponentsTranslationRef: TranslationRef< readonly 'table.filter.title': 'Filters'; readonly 'table.filter.placeholder': 'All results'; readonly 'table.filter.clearAll': 'Clear all'; - readonly 'table.body.emptyDataSourceMessage': 'No records to display'; readonly 'table.header.actions': 'Actions'; + readonly 'table.body.emptyDataSourceMessage': 'No records to display'; readonly 'table.toolbar.search': 'Filter'; readonly 'table.pagination.labelDisplayedRows': '{from}-{to} of {count}'; readonly 'table.pagination.firstTooltip': 'First Page'; @@ -37,9 +37,9 @@ export const coreComponentsTranslationRef: TranslationRef< readonly 'signIn.guestProvider.subtitle': 'Enter as a Guest User.\n You will not have a verified identity, meaning some features might be unavailable.'; readonly skipToContent: 'Skip to content'; readonly 'copyTextButton.tooltipText': 'Text copied to clipboard'; - readonly 'simpleStepper.finish': 'Finish'; readonly 'simpleStepper.reset': 'Reset'; readonly 'simpleStepper.next': 'Next'; + readonly 'simpleStepper.finish': 'Finish'; readonly 'simpleStepper.skip': 'Skip'; readonly 'simpleStepper.back': 'Back'; readonly 'errorPage.title': 'Looks like someone dropped the mic!'; diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index 17b3ecc75d..72f22b4093 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -1792,8 +1792,8 @@ export const PageBlueprint: ExtensionBlueprint_2<{ title: string | undefined; }; configInput: { - title?: string | undefined; path?: string | undefined; + title?: string | undefined; }; dataRefs: never; }>; @@ -1907,7 +1907,9 @@ export type PluginWrapperApi = { }; // @public -export const pluginWrapperApiRef: ApiRef_2; +export const pluginWrapperApiRef: ApiRef_2 & { + readonly $$type: '@backstage/ApiRef'; +}; // @public export const PluginWrapperBlueprint: ExtensionBlueprint_2<{ @@ -2102,8 +2104,8 @@ export const SubPageBlueprint: ExtensionBlueprint_2<{ title: string | undefined; }; configInput: { - title?: string | undefined; path?: string | undefined; + title?: string | undefined; }; dataRefs: never; }>; diff --git a/plugins/api-docs/report-alpha.api.md b/plugins/api-docs/report-alpha.api.md index 0ee7ac5460..434cc38691 100644 --- a/plugins/api-docs/report-alpha.api.md +++ b/plugins/api-docs/report-alpha.api.md @@ -91,11 +91,11 @@ const _default: OverridableFrontendPlugin< name: 'consumed-apis'; config: { filter: FilterPredicate | undefined; - type: 'content' | 'info' | undefined; + type: 'info' | 'content' | undefined; }; configInput: { filter?: FilterPredicate | undefined; - type?: 'content' | 'info' | undefined; + type?: 'info' | 'content' | undefined; }; output: | ExtensionDataRef @@ -132,11 +132,11 @@ const _default: OverridableFrontendPlugin< name: 'consuming-components'; config: { filter: FilterPredicate | undefined; - type: 'content' | 'info' | undefined; + type: 'info' | 'content' | undefined; }; configInput: { filter?: FilterPredicate | undefined; - type?: 'content' | 'info' | undefined; + type?: 'info' | 'content' | undefined; }; output: | ExtensionDataRef @@ -173,11 +173,11 @@ const _default: OverridableFrontendPlugin< name: 'definition'; config: { filter: FilterPredicate | undefined; - type: 'content' | 'info' | undefined; + type: 'info' | 'content' | undefined; }; configInput: { filter?: FilterPredicate | undefined; - type?: 'content' | 'info' | undefined; + type?: 'info' | 'content' | undefined; }; output: | ExtensionDataRef @@ -214,11 +214,11 @@ const _default: OverridableFrontendPlugin< name: 'has-apis'; config: { filter: FilterPredicate | undefined; - type: 'content' | 'info' | undefined; + type: 'info' | 'content' | undefined; }; configInput: { filter?: FilterPredicate | undefined; - type?: 'content' | 'info' | undefined; + type?: 'info' | 'content' | undefined; }; output: | ExtensionDataRef @@ -255,11 +255,11 @@ const _default: OverridableFrontendPlugin< name: 'provided-apis'; config: { filter: FilterPredicate | undefined; - type: 'content' | 'info' | undefined; + type: 'info' | 'content' | undefined; }; configInput: { filter?: FilterPredicate | undefined; - type?: 'content' | 'info' | undefined; + type?: 'info' | 'content' | undefined; }; output: | ExtensionDataRef @@ -296,11 +296,11 @@ const _default: OverridableFrontendPlugin< name: 'providing-components'; config: { filter: FilterPredicate | undefined; - type: 'content' | 'info' | undefined; + type: 'info' | 'content' | undefined; }; configInput: { filter?: FilterPredicate | undefined; - type?: 'content' | 'info' | undefined; + type?: 'info' | 'content' | undefined; }; output: | ExtensionDataRef @@ -344,10 +344,10 @@ const _default: OverridableFrontendPlugin< }; configInput: { filter?: FilterPredicate | undefined; - title?: string | undefined; path?: string | undefined; - group?: string | false | undefined; + title?: string | undefined; icon?: string | undefined; + group?: string | false | undefined; }; output: | ExtensionDataRef @@ -414,10 +414,10 @@ const _default: OverridableFrontendPlugin< }; configInput: { filter?: FilterPredicate | undefined; - title?: string | undefined; path?: string | undefined; - group?: string | false | undefined; + title?: string | undefined; icon?: string | undefined; + group?: string | false | undefined; }; output: | ExtensionDataRef @@ -501,8 +501,8 @@ const _default: OverridableFrontendPlugin< }; configInput: { initiallySelectedFilter?: 'all' | 'owned' | 'starred' | undefined; - title?: string | undefined; path?: string | undefined; + title?: string | undefined; }; output: | ExtensionDataRef diff --git a/plugins/app-visualizer/report.api.md b/plugins/app-visualizer/report.api.md index 9ee06eccb0..2550026e98 100644 --- a/plugins/app-visualizer/report.api.md +++ b/plugins/app-visualizer/report.api.md @@ -49,8 +49,8 @@ const visualizerPlugin: OverridableFrontendPlugin< title: string | undefined; }; configInput: { - title?: string | undefined; path?: string | undefined; + title?: string | undefined; }; output: | ExtensionDataRef @@ -138,8 +138,8 @@ const visualizerPlugin: OverridableFrontendPlugin< title: string | undefined; }; configInput: { - title?: string | undefined; path?: string | undefined; + title?: string | undefined; }; output: | ExtensionDataRef @@ -176,8 +176,8 @@ const visualizerPlugin: OverridableFrontendPlugin< title: string | undefined; }; configInput: { - title?: string | undefined; path?: string | undefined; + title?: string | undefined; }; output: | ExtensionDataRef @@ -214,8 +214,8 @@ const visualizerPlugin: OverridableFrontendPlugin< title: string | undefined; }; configInput: { - title?: string | undefined; path?: string | undefined; + title?: string | undefined; }; output: | ExtensionDataRef diff --git a/plugins/app/report.api.md b/plugins/app/report.api.md index 0262c4c1ef..b6b23a0d3b 100644 --- a/plugins/app/report.api.md +++ b/plugins/app/report.api.md @@ -775,14 +775,14 @@ const appPlugin: OverridableFrontendPlugin< transientTimeoutMs: number; anchorOrigin: { horizontal: 'center' | 'left' | 'right'; - vertical: 'top' | 'bottom'; + vertical: 'bottom' | 'top'; }; }; configInput: { anchorOrigin?: | { horizontal?: 'center' | 'left' | 'right' | undefined; - vertical?: 'top' | 'bottom' | undefined; + vertical?: 'bottom' | 'top' | undefined; } | undefined; transientTimeoutMs?: number | undefined; diff --git a/plugins/auth/report.api.md b/plugins/auth/report.api.md index 8e8ac71d31..903b8397c9 100644 --- a/plugins/auth/report.api.md +++ b/plugins/auth/report.api.md @@ -28,8 +28,8 @@ const _default: OverridableFrontendPlugin< title: string | undefined; }; configInput: { - title?: string | undefined; path?: string | undefined; + title?: string | undefined; }; output: | ExtensionDataRef diff --git a/plugins/catalog-graph/report-alpha.api.md b/plugins/catalog-graph/report-alpha.api.md index f9d4542625..e1497f7623 100644 --- a/plugins/catalog-graph/report-alpha.api.md +++ b/plugins/catalog-graph/report-alpha.api.md @@ -95,14 +95,14 @@ const _default: OverridableFrontendPlugin< title: string | undefined; height: number | undefined; filter: FilterPredicate | undefined; - type: 'content' | 'info' | undefined; + type: 'info' | 'content' | undefined; }; configInput: { + title?: string | undefined; height?: number | undefined; - curve?: 'curveStepBefore' | 'curveMonotoneX' | undefined; direction?: 'TB' | 'BT' | 'LR' | 'RL' | undefined; zoom?: 'disabled' | 'enabled' | 'enable-on-click' | undefined; - title?: string | undefined; + curve?: 'curveStepBefore' | 'curveMonotoneX' | undefined; relations?: string[] | undefined; maxDepth?: number | undefined; kinds?: string[] | undefined; @@ -110,7 +110,7 @@ const _default: OverridableFrontendPlugin< relationPairs?: [string, string][] | undefined; unidirectional?: boolean | undefined; filter?: FilterPredicate | undefined; - type?: 'content' | 'info' | undefined; + type?: 'info' | 'content' | undefined; }; output: | ExtensionDataRef @@ -163,12 +163,12 @@ const _default: OverridableFrontendPlugin< title: string | undefined; }; configInput: { - curve?: 'curveStepBefore' | 'curveMonotoneX' | undefined; direction?: 'TB' | 'BT' | 'LR' | 'RL' | undefined; zoom?: 'disabled' | 'enabled' | 'enable-on-click' | undefined; + curve?: 'curveStepBefore' | 'curveMonotoneX' | undefined; relations?: string[] | undefined; - maxDepth?: number | undefined; rootEntityRefs?: string[] | undefined; + maxDepth?: number | undefined; kinds?: string[] | undefined; mergeRelations?: boolean | undefined; relationPairs?: [string, string][] | undefined; @@ -176,8 +176,8 @@ const _default: OverridableFrontendPlugin< selectedRelations?: string[] | undefined; selectedKinds?: string[] | undefined; showFilters?: boolean | undefined; - title?: string | undefined; path?: string | undefined; + title?: string | undefined; }; output: | ExtensionDataRef diff --git a/plugins/catalog-import/report-alpha.api.md b/plugins/catalog-import/report-alpha.api.md index b58b641050..70799ed325 100644 --- a/plugins/catalog-import/report-alpha.api.md +++ b/plugins/catalog-import/report-alpha.api.md @@ -34,8 +34,8 @@ export const catalogImportTranslationRef: TranslationRef< readonly 'importInfoCard.fileLinkDescription': 'The wizard analyzes the file, previews the entities, and adds them to the {{appTitle}} catalog.'; readonly 'importInfoCard.exampleDescription': 'The wizard discovers all {{catalogFilename}} files in the repository, previews the entities, and adds them to the {{appTitle}} catalog.'; readonly 'importInfoCard.preparePullRequestDescription': 'If no entities are found, the wizard will prepare a Pull Request that adds an example {{catalogFilename}} and prepares the {{appTitle}} catalog to load all entities as soon as the Pull Request is merged.'; - readonly 'importInfoCard.githubIntegration.label': 'GitHub only'; readonly 'importInfoCard.githubIntegration.title': 'Link to a repository'; + readonly 'importInfoCard.githubIntegration.label': 'GitHub only'; readonly 'importStepper.finish.title': 'Finish'; readonly 'importStepper.singleLocation.title': 'Select Locations'; readonly 'importStepper.singleLocation.description': 'Discovered Locations: 1'; @@ -62,8 +62,8 @@ export const catalogImportTranslationRef: TranslationRef< readonly 'importStepper.review.title': 'Review'; readonly 'stepFinishImportLocation.repository.title': 'The following Pull Request has been opened: '; readonly 'stepFinishImportLocation.repository.description': 'Your entities will be imported as soon as the Pull Request is merged.'; - readonly 'stepFinishImportLocation.locations.new': 'The following entities have been added to the catalog:'; readonly 'stepFinishImportLocation.locations.backButtonText': 'Register another'; + readonly 'stepFinishImportLocation.locations.new': 'The following entities have been added to the catalog:'; readonly 'stepFinishImportLocation.locations.existing': 'A refresh was triggered for the following locations:'; readonly 'stepFinishImportLocation.locations.viewButtonText': 'View Component'; readonly 'stepFinishImportLocation.backButtonText': 'Register another'; @@ -83,9 +83,9 @@ export const catalogImportTranslationRef: TranslationRef< readonly 'stepPrepareSelectLocations.nextButtonText': 'Review'; readonly 'stepPrepareSelectLocations.existingLocations.description': 'These locations already exist in the catalog:'; readonly 'stepReviewLocation.refresh': 'Refresh'; - readonly 'stepReviewLocation.import': 'Import'; - readonly 'stepReviewLocation.catalog.new': 'The following entities will be added to the catalog:'; readonly 'stepReviewLocation.catalog.exists': 'The following locations already exist in the catalog:'; + readonly 'stepReviewLocation.catalog.new': 'The following entities will be added to the catalog:'; + readonly 'stepReviewLocation.import': 'Import'; readonly 'stepReviewLocation.prepareResult.title': 'The following Pull Request has been opened: '; readonly 'stepReviewLocation.prepareResult.description': 'You can already import the location and {{appTitle}} will fetch the entities as soon as the Pull Request is merged.'; } @@ -121,8 +121,8 @@ const _default: OverridableFrontendPlugin< title: string | undefined; }; configInput: { - title?: string | undefined; path?: string | undefined; + title?: string | undefined; }; output: | ExtensionDataRef diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index 9c76a221dc..ee0b8bb3ff 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -73,17 +73,17 @@ export const catalogReactTranslationRef: TranslationRef< readonly 'inspectEntityDialog.jsonPage.title': 'Entity as JSON'; readonly 'inspectEntityDialog.jsonPage.description': 'This is the raw entity data as received from the catalog, on JSON form.'; readonly 'inspectEntityDialog.overviewPage.title': 'Overview'; - readonly 'inspectEntityDialog.overviewPage.metadata.title': 'Metadata'; - readonly 'inspectEntityDialog.overviewPage.labels': 'Labels'; readonly 'inspectEntityDialog.overviewPage.status.title': 'Status'; readonly 'inspectEntityDialog.overviewPage.identity.title': 'Identity'; + readonly 'inspectEntityDialog.overviewPage.metadata.title': 'Metadata'; readonly 'inspectEntityDialog.overviewPage.annotations': 'Annotations'; readonly 'inspectEntityDialog.overviewPage.tags': 'Tags'; + readonly 'inspectEntityDialog.overviewPage.labels': 'Labels'; readonly 'inspectEntityDialog.overviewPage.relation.title': 'Relations'; readonly 'inspectEntityDialog.yamlPage.title': 'Entity as YAML'; readonly 'inspectEntityDialog.yamlPage.description': 'This is the raw entity data as received from the catalog, on YAML form.'; - readonly 'inspectEntityDialog.tabNames.json': 'Raw JSON'; readonly 'inspectEntityDialog.tabNames.yaml': 'Raw YAML'; + readonly 'inspectEntityDialog.tabNames.json': 'Raw JSON'; readonly 'inspectEntityDialog.tabNames.overview': 'Overview'; readonly 'inspectEntityDialog.tabNames.ancestry': 'Ancestry'; readonly 'inspectEntityDialog.tabNames.colocated': 'Colocated'; @@ -108,17 +108,17 @@ export const catalogReactTranslationRef: TranslationRef< readonly 'userListPicker.personalFilter.ownedLabel': 'Owned'; readonly 'userListPicker.personalFilter.starredLabel': 'Starred'; readonly 'entityTableColumnTitle.name': 'Name'; - readonly 'entityTableColumnTitle.type': 'Type'; - readonly 'entityTableColumnTitle.label': 'Label'; + readonly 'entityTableColumnTitle.namespace': 'Namespace'; readonly 'entityTableColumnTitle.title': 'Title'; readonly 'entityTableColumnTitle.description': 'Description'; - readonly 'entityTableColumnTitle.system': 'System'; - readonly 'entityTableColumnTitle.namespace': 'Namespace'; - readonly 'entityTableColumnTitle.domain': 'Domain'; + readonly 'entityTableColumnTitle.type': 'Type'; + readonly 'entityTableColumnTitle.label': 'Label'; readonly 'entityTableColumnTitle.tags': 'Tags'; readonly 'entityTableColumnTitle.owner': 'Owner'; readonly 'entityTableColumnTitle.lifecycle': 'Lifecycle'; + readonly 'entityTableColumnTitle.system': 'System'; readonly 'entityTableColumnTitle.targets': 'Targets'; + readonly 'entityTableColumnTitle.domain': 'Domain'; readonly 'missingAnnotationEmptyState.title': 'Missing Annotation'; readonly 'missingAnnotationEmptyState.readMore': 'Read more'; readonly 'missingAnnotationEmptyState.annotationYaml': 'Add the annotation to your {{entityKind}} YAML as shown in the highlighted example below:'; @@ -213,11 +213,11 @@ export const EntityCardBlueprint: ExtensionBlueprint<{ inputs: {}; config: { filter: FilterPredicate | undefined; - type: 'content' | 'info' | undefined; + type: 'info' | 'content' | undefined; }; configInput: { filter?: FilterPredicate | undefined; - type?: 'content' | 'info' | undefined; + type?: 'info' | 'content' | undefined; }; dataRefs: { filterFunction: ConfigurableExtensionDataRef< @@ -305,10 +305,10 @@ export const EntityContentBlueprint: ExtensionBlueprint<{ }; configInput: { filter?: FilterPredicate | undefined; - title?: string | undefined; path?: string | undefined; - group?: string | false | undefined; + title?: string | undefined; icon?: string | undefined; + group?: string | false | undefined; }; dataRefs: { title: ConfigurableExtensionDataRef< @@ -535,8 +535,8 @@ export const EntityIconLinkBlueprint: ExtensionBlueprint<{ }; configInput: { filter?: FilterPredicate | undefined; - label?: string | undefined; title?: string | undefined; + label?: string | undefined; }; dataRefs: { useProps: ConfigurableExtensionDataRef< @@ -561,9 +561,8 @@ export const EntityIconLinkBlueprint: ExtensionBlueprint<{ export const EntityTableColumnTitle: ( input: EntityTableColumnTitleProps, ) => - | 'System' - | 'Title' | 'Domain' + | 'System' | 'Lifecycle' | 'Namespace' | 'Owner' @@ -572,6 +571,7 @@ export const EntityTableColumnTitle: ( | 'Name' | 'Description' | 'Targets' + | 'Title' | 'Label'; // @alpha (undocumented) diff --git a/plugins/catalog-unprocessed-entities/report-alpha.api.md b/plugins/catalog-unprocessed-entities/report-alpha.api.md index 7408831f87..2d254d15ac 100644 --- a/plugins/catalog-unprocessed-entities/report-alpha.api.md +++ b/plugins/catalog-unprocessed-entities/report-alpha.api.md @@ -70,8 +70,8 @@ const _default: OverridableFrontendPlugin< title: string | undefined; }; configInput: { - title?: string | undefined; path?: string | undefined; + title?: string | undefined; }; output: | ExtensionDataRef @@ -151,8 +151,8 @@ export const unprocessedEntitiesDevToolsContent: OverridableExtensionDefinition< title: string | undefined; }; configInput: { - title?: string | undefined; path?: string | undefined; + title?: string | undefined; }; output: | ExtensionDataRef diff --git a/plugins/catalog/report-alpha.api.md b/plugins/catalog/report-alpha.api.md index a0b092a453..d77b673744 100644 --- a/plugins/catalog/report-alpha.api.md +++ b/plugins/catalog/report-alpha.api.md @@ -46,8 +46,8 @@ export const catalogTranslationRef: TranslationRef< readonly 'indexPage.supportButtonContent': 'All your software catalog entities'; readonly 'entityPage.notFoundMessage': 'There is no {{kind}} with the requested {{link}}.'; readonly 'entityPage.notFoundLinkText': 'kind, namespace, and name'; - readonly 'aboutCard.title': 'About'; readonly 'aboutCard.unknown': 'unknown'; + readonly 'aboutCard.title': 'About'; readonly 'aboutCard.refreshButtonTitle': 'Schedule entity refresh'; readonly 'aboutCard.editButtonTitle': 'Edit Metadata'; readonly 'aboutCard.editButtonAriaLabel': 'Edit'; @@ -72,8 +72,8 @@ export const catalogTranslationRef: TranslationRef< readonly 'aboutCard.tagsField.value': 'No Tags'; readonly 'aboutCard.tagsField.label': 'Tags'; readonly 'aboutCard.targetsField.label': 'Targets'; - readonly 'searchResultItem.type': 'Type'; readonly 'searchResultItem.kind': 'Kind'; + readonly 'searchResultItem.type': 'Type'; readonly 'searchResultItem.owner': 'Owner'; readonly 'searchResultItem.lifecycle': 'Lifecycle'; readonly 'catalogTable.allFilters': 'All'; @@ -308,11 +308,11 @@ const _default: OverridableFrontendPlugin< 'entity-card:catalog/about': OverridableExtensionDefinition<{ config: { filter: FilterPredicate | undefined; - type: 'content' | 'info' | undefined; + type: 'info' | 'content' | undefined; }; configInput: { filter?: FilterPredicate | undefined; - type?: 'content' | 'info' | undefined; + type?: 'info' | 'content' | undefined; }; output: | ExtensionDataRef @@ -378,11 +378,11 @@ const _default: OverridableFrontendPlugin< name: 'depends-on-components'; config: { filter: FilterPredicate | undefined; - type: 'content' | 'info' | undefined; + type: 'info' | 'content' | undefined; }; configInput: { filter?: FilterPredicate | undefined; - type?: 'content' | 'info' | undefined; + type?: 'info' | 'content' | undefined; }; output: | ExtensionDataRef @@ -419,11 +419,11 @@ const _default: OverridableFrontendPlugin< name: 'depends-on-resources'; config: { filter: FilterPredicate | undefined; - type: 'content' | 'info' | undefined; + type: 'info' | 'content' | undefined; }; configInput: { filter?: FilterPredicate | undefined; - type?: 'content' | 'info' | undefined; + type?: 'info' | 'content' | undefined; }; output: | ExtensionDataRef @@ -460,11 +460,11 @@ const _default: OverridableFrontendPlugin< name: 'has-components'; config: { filter: FilterPredicate | undefined; - type: 'content' | 'info' | undefined; + type: 'info' | 'content' | undefined; }; configInput: { filter?: FilterPredicate | undefined; - type?: 'content' | 'info' | undefined; + type?: 'info' | 'content' | undefined; }; output: | ExtensionDataRef @@ -501,11 +501,11 @@ const _default: OverridableFrontendPlugin< name: 'has-resources'; config: { filter: FilterPredicate | undefined; - type: 'content' | 'info' | undefined; + type: 'info' | 'content' | undefined; }; configInput: { filter?: FilterPredicate | undefined; - type?: 'content' | 'info' | undefined; + type?: 'info' | 'content' | undefined; }; output: | ExtensionDataRef @@ -542,11 +542,11 @@ const _default: OverridableFrontendPlugin< name: 'has-subcomponents'; config: { filter: FilterPredicate | undefined; - type: 'content' | 'info' | undefined; + type: 'info' | 'content' | undefined; }; configInput: { filter?: FilterPredicate | undefined; - type?: 'content' | 'info' | undefined; + type?: 'info' | 'content' | undefined; }; output: | ExtensionDataRef @@ -583,11 +583,11 @@ const _default: OverridableFrontendPlugin< name: 'has-subdomains'; config: { filter: FilterPredicate | undefined; - type: 'content' | 'info' | undefined; + type: 'info' | 'content' | undefined; }; configInput: { filter?: FilterPredicate | undefined; - type?: 'content' | 'info' | undefined; + type?: 'info' | 'content' | undefined; }; output: | ExtensionDataRef @@ -624,11 +624,11 @@ const _default: OverridableFrontendPlugin< name: 'has-systems'; config: { filter: FilterPredicate | undefined; - type: 'content' | 'info' | undefined; + type: 'info' | 'content' | undefined; }; configInput: { filter?: FilterPredicate | undefined; - type?: 'content' | 'info' | undefined; + type?: 'info' | 'content' | undefined; }; output: | ExtensionDataRef @@ -665,11 +665,11 @@ const _default: OverridableFrontendPlugin< name: 'labels'; config: { filter: FilterPredicate | undefined; - type: 'content' | 'info' | undefined; + type: 'info' | 'content' | undefined; }; configInput: { filter?: FilterPredicate | undefined; - type?: 'content' | 'info' | undefined; + type?: 'info' | 'content' | undefined; }; output: | ExtensionDataRef @@ -706,11 +706,11 @@ const _default: OverridableFrontendPlugin< name: 'links'; config: { filter: FilterPredicate | undefined; - type: 'content' | 'info' | undefined; + type: 'info' | 'content' | undefined; }; configInput: { filter?: FilterPredicate | undefined; - type?: 'content' | 'info' | undefined; + type?: 'info' | 'content' | undefined; }; output: | ExtensionDataRef @@ -752,10 +752,10 @@ const _default: OverridableFrontendPlugin< }; configInput: { filter?: FilterPredicate | undefined; - title?: string | undefined; path?: string | undefined; - group?: string | false | undefined; + title?: string | undefined; icon?: string | undefined; + group?: string | false | undefined; }; output: | ExtensionDataRef @@ -941,8 +941,8 @@ const _default: OverridableFrontendPlugin< }; configInput: { filter?: FilterPredicate | undefined; - label?: string | undefined; title?: string | undefined; + label?: string | undefined; }; output: | ExtensionDataRef< @@ -996,7 +996,7 @@ const _default: OverridableFrontendPlugin< pagination: | boolean | { - mode: 'offset' | 'cursor'; + mode: 'cursor' | 'offset'; offset?: number | undefined; limit?: number | undefined; }; @@ -1007,13 +1007,13 @@ const _default: OverridableFrontendPlugin< pagination?: | boolean | { - mode: 'offset' | 'cursor'; + mode: 'cursor' | 'offset'; offset?: number | undefined; limit?: number | undefined; } | undefined; - title?: string | undefined; path?: string | undefined; + title?: string | undefined; }; output: | ExtensionDataRef @@ -1122,8 +1122,8 @@ const _default: OverridableFrontendPlugin< | undefined; defaultContentOrder?: 'title' | 'natural' | undefined; showNavItemIcons?: boolean | undefined; - title?: string | undefined; path?: string | undefined; + title?: string | undefined; }; output: | ExtensionDataRef diff --git a/plugins/catalog/report.api.md b/plugins/catalog/report.api.md index 48e3f53de2..6d482004bf 100644 --- a/plugins/catalog/report.api.md +++ b/plugins/catalog/report.api.md @@ -455,7 +455,7 @@ export function EntityRelationWarning(): JSX_2.Element | null; // @public (undocumented) export const EntitySwitch: { - (props: EntitySwitchProps): JSX.Element; + (props: EntitySwitchProps): JSX_2.Element; Case: (_props: EntitySwitchCaseProps) => null; }; diff --git a/plugins/devtools-react/report.api.md b/plugins/devtools-react/report.api.md index 6916afed84..5fb187ee6a 100644 --- a/plugins/devtools-react/report.api.md +++ b/plugins/devtools-react/report.api.md @@ -30,8 +30,8 @@ export const DevToolsContentBlueprint: ExtensionBlueprint<{ title: string | undefined; }; configInput: { - title?: string | undefined; path?: string | undefined; + title?: string | undefined; }; dataRefs: never; }>; diff --git a/plugins/devtools/report-alpha.api.md b/plugins/devtools/report-alpha.api.md index 753875eaaa..7be1867d5d 100644 --- a/plugins/devtools/report-alpha.api.md +++ b/plugins/devtools/report-alpha.api.md @@ -67,8 +67,8 @@ const _default: OverridableFrontendPlugin< title: string | undefined; }; configInput: { - title?: string | undefined; path?: string | undefined; + title?: string | undefined; }; output: | ExtensionDataRef diff --git a/plugins/home/report-alpha.api.md b/plugins/home/report-alpha.api.md index 0269f471fe..e1cc4f8f62 100644 --- a/plugins/home/report-alpha.api.md +++ b/plugins/home/report-alpha.api.md @@ -108,8 +108,8 @@ const _default: OverridableFrontendPlugin< title: string | undefined; }; configInput: { - title?: string | undefined; path?: string | undefined; + title?: string | undefined; }; output: | ExtensionDataRef @@ -224,9 +224,9 @@ export const homeTranslationRef: TranslationRef< readonly 'widgetSettingsOverlay.deleteWidgetTooltip': 'Delete widget'; readonly 'widgetSettingsOverlay.submitButtonTitle': 'Submit'; readonly 'starredEntityListItem.removeFavoriteEntityTitle': 'Remove entity from favorites'; + readonly 'visitList.few.title': 'The more pages you visit, the more pages will appear here.'; readonly 'visitList.empty.title': 'There are no visits to show yet.'; readonly 'visitList.empty.description': 'Once you start using Backstage, your visits will appear here as a quick link to carry on where you left off.'; - readonly 'visitList.few.title': 'The more pages you visit, the more pages will appear here.'; readonly 'quickStart.title': 'Onboarding'; readonly 'quickStart.description': 'Get started with Backstage'; readonly 'quickStart.learnMoreLinkTitle': 'Learn more'; diff --git a/plugins/kubernetes-react/report-alpha.api.md b/plugins/kubernetes-react/report-alpha.api.md index f638bf6789..adb92a772b 100644 --- a/plugins/kubernetes-react/report-alpha.api.md +++ b/plugins/kubernetes-react/report-alpha.api.md @@ -24,6 +24,9 @@ export const kubernetesReactTranslationRef: TranslationRef< readonly 'cluster.noPodsWithErrors': 'No pods with errors'; readonly 'pods.pods_one': '{{count}} pod'; readonly 'pods.pods_other': '{{count}} pods'; + readonly 'podsTable.unknown': 'unknown'; + readonly 'podsTable.status.running': 'Running'; + readonly 'podsTable.status.ok': 'OK'; readonly 'podsTable.columns.name': 'name'; readonly 'podsTable.columns.id': 'ID'; readonly 'podsTable.columns.status': 'status'; @@ -32,9 +35,6 @@ export const kubernetesReactTranslationRef: TranslationRef< readonly 'podsTable.columns.totalRestarts': 'total restarts'; readonly 'podsTable.columns.cpuUsage': 'CPU usage %'; readonly 'podsTable.columns.memoryUsage': 'Memory usage %'; - readonly 'podsTable.unknown': 'unknown'; - readonly 'podsTable.status.running': 'Running'; - readonly 'podsTable.status.ok': 'OK'; readonly 'errorPanel.message': 'There was a problem retrieving some Kubernetes resources for the entity: {{entityName}}. This could mean that the Error Reporting card is not completely accurate.'; readonly 'errorPanel.title': 'There was a problem retrieving Kubernetes objects'; readonly 'errorPanel.errorsLabel': 'Errors'; @@ -65,12 +65,12 @@ export const kubernetesReactTranslationRef: TranslationRef< readonly 'hpa.currentCpuUsageLabel': 'current CPU usage: {{value}}%'; readonly 'hpa.targetCpuUsage': 'target CPU usage:'; readonly 'hpa.targetCpuUsageLabel': 'target CPU usage: {{value}}%'; + readonly 'errorReporting.title': 'Error Reporting'; readonly 'errorReporting.columns.name': 'name'; readonly 'errorReporting.columns.kind': 'kind'; readonly 'errorReporting.columns.namespace': 'namespace'; readonly 'errorReporting.columns.messages': 'messages'; readonly 'errorReporting.columns.cluster': 'cluster'; - readonly 'errorReporting.title': 'Error Reporting'; readonly 'podLogs.title': 'No logs emitted'; readonly 'podLogs.description': 'No logs were emitted by the container'; readonly 'podLogs.buttonText': 'Logs'; diff --git a/plugins/kubernetes/report-alpha.api.md b/plugins/kubernetes/report-alpha.api.md index 7086976724..25090f7431 100644 --- a/plugins/kubernetes/report-alpha.api.md +++ b/plugins/kubernetes/report-alpha.api.md @@ -102,10 +102,10 @@ const _default: OverridableFrontendPlugin< }; configInput: { filter?: FilterPredicate | undefined; - title?: string | undefined; path?: string | undefined; - group?: string | false | undefined; + title?: string | undefined; icon?: string | undefined; + group?: string | false | undefined; }; output: | ExtensionDataRef @@ -168,8 +168,8 @@ const _default: OverridableFrontendPlugin< title: string | undefined; }; configInput: { - title?: string | undefined; path?: string | undefined; + title?: string | undefined; }; output: | ExtensionDataRef diff --git a/plugins/mui-to-bui/report.api.md b/plugins/mui-to-bui/report.api.md index f4bf566c9e..c1c97a49a2 100644 --- a/plugins/mui-to-bui/report.api.md +++ b/plugins/mui-to-bui/report.api.md @@ -42,8 +42,8 @@ const _default: OverridableFrontendPlugin< title: string | undefined; }; configInput: { - title?: string | undefined; path?: string | undefined; + title?: string | undefined; }; output: | ExtensionDataRef diff --git a/plugins/notifications/report-alpha.api.md b/plugins/notifications/report-alpha.api.md index e1feed0cde..ae826a05a1 100644 --- a/plugins/notifications/report-alpha.api.md +++ b/plugins/notifications/report-alpha.api.md @@ -48,8 +48,8 @@ const _default: OverridableFrontendPlugin< title: string | undefined; }; configInput: { - title?: string | undefined; path?: string | undefined; + title?: string | undefined; }; output: | ExtensionDataRef @@ -124,13 +124,13 @@ export default _default; export const notificationsTranslationRef: TranslationRef< 'plugin.notifications', { - readonly 'table.errors.markAllReadFailed': 'Failed to mark all notifications as read'; 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 'table.errors.markAllReadFailed': 'Failed to mark all notifications as read'; readonly 'table.emptyMessage': 'No records to display'; readonly 'table.bulkActions.markAllRead': 'Mark all read'; readonly 'table.bulkActions.markSelectedAsRead': 'Mark selected as read'; @@ -140,16 +140,16 @@ export const notificationsTranslationRef: TranslationRef< readonly 'table.confirmDialog.title': 'Are you sure?'; readonly 'table.confirmDialog.markAllReadDescription': 'Mark all notifications as read.'; readonly 'table.confirmDialog.markAllReadConfirmation': 'Mark All'; - readonly 'filters.view.all': 'All'; + readonly 'filters.title': 'Filters'; readonly 'filters.view.label': 'View'; + readonly 'filters.view.all': 'All'; readonly 'filters.view.read': 'Read notifications'; readonly 'filters.view.saved': 'Saved'; readonly 'filters.view.unread': 'Unread notifications'; - readonly 'filters.title': 'Filters'; readonly 'filters.severity.normal': 'Normal'; + readonly 'filters.severity.label': 'Min severity'; readonly 'filters.severity.high': 'High'; readonly 'filters.severity.low': 'Low'; - readonly 'filters.severity.label': 'Min severity'; readonly 'filters.severity.critical': 'Critical'; readonly 'filters.topic.label': 'Topic'; readonly 'filters.topic.anyTopic': 'Any topic'; @@ -161,12 +161,12 @@ export const notificationsTranslationRef: TranslationRef< readonly 'filters.sortBy.origin': 'Origin'; readonly 'filters.sortBy.label': 'Sort by'; readonly 'filters.sortBy.placeholder': 'Field to sort by'; + readonly 'filters.sortBy.topic': 'Topic'; readonly 'filters.sortBy.newest': 'Newest on top'; readonly 'filters.sortBy.oldest': 'Oldest on top'; - readonly 'filters.sortBy.topic': 'Topic'; + readonly 'settings.title': 'Notification settings'; readonly 'settings.table.origin': 'Origin'; readonly 'settings.table.topic': 'Topic'; - readonly 'settings.title': 'Notification settings'; readonly 'settings.errors.useNotificationFormat': 'useNotificationFormat must be used within a NotificationFormatProvider'; readonly 'settings.errorTitle': 'Failed to load settings'; readonly 'settings.noSettingsAvailable': 'No notification settings available, check back later'; diff --git a/plugins/org/report-alpha.api.md b/plugins/org/report-alpha.api.md index 44fd429bfa..76bbc52da2 100644 --- a/plugins/org/report-alpha.api.md +++ b/plugins/org/report-alpha.api.md @@ -25,11 +25,11 @@ const _default: OverridableFrontendPlugin< name: 'group-profile'; config: { filter: FilterPredicate | undefined; - type: 'content' | 'info' | undefined; + type: 'info' | 'content' | undefined; }; configInput: { filter?: FilterPredicate | undefined; - type?: 'content' | 'info' | undefined; + type?: 'info' | 'content' | undefined; }; output: | ExtensionDataRef @@ -66,13 +66,13 @@ const _default: OverridableFrontendPlugin< initialRelationAggregation: 'direct' | 'aggregated' | undefined; showAggregateMembersToggle: boolean | undefined; filter: FilterPredicate | undefined; - type: 'content' | 'info' | undefined; + type: 'info' | 'content' | undefined; }; configInput: { showAggregateMembersToggle?: boolean | undefined; initialRelationAggregation?: 'direct' | 'aggregated' | undefined; filter?: FilterPredicate | undefined; - type?: 'content' | 'info' | undefined; + type?: 'info' | 'content' | undefined; }; output: | ExtensionDataRef @@ -112,14 +112,14 @@ const _default: OverridableFrontendPlugin< showAggregateMembersToggle: boolean | undefined; ownedKinds: string[] | undefined; filter: FilterPredicate | undefined; - type: 'content' | 'info' | undefined; + type: 'info' | 'content' | undefined; }; configInput: { showAggregateMembersToggle?: boolean | undefined; initialRelationAggregation?: 'direct' | 'aggregated' | undefined; ownedKinds?: string[] | undefined; filter?: FilterPredicate | undefined; - type?: 'content' | 'info' | undefined; + type?: 'info' | 'content' | undefined; }; output: | ExtensionDataRef @@ -158,13 +158,13 @@ const _default: OverridableFrontendPlugin< maxRelations: number | undefined; hideIcons: boolean; filter: FilterPredicate | undefined; - type: 'content' | 'info' | undefined; + type: 'info' | 'content' | undefined; }; configInput: { hideIcons?: boolean | undefined; maxRelations?: number | undefined; filter?: FilterPredicate | undefined; - type?: 'content' | 'info' | undefined; + type?: 'info' | 'content' | undefined; }; output: | ExtensionDataRef diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/report.api.md b/plugins/scaffolder-backend-module-bitbucket-cloud/report.api.md index af34609213..a7050aa68c 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/report.api.md +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/report.api.md @@ -25,25 +25,25 @@ export const createBitbucketPipelinesRunAction: (options: { | { type?: string | undefined; source?: string | undefined; - selector?: - | { - type: string; - pattern: string; - } - | undefined; - pull_request?: - | { - id: string; - } - | undefined; commit?: | { type: string; hash: string; } | undefined; - destination?: string | undefined; + selector?: + | { + type: string; + pattern: string; + } + | undefined; ref_name?: string | undefined; + destination?: string | undefined; + pull_request?: + | { + id: string; + } + | undefined; ref_type?: string | undefined; destination_commit?: | { @@ -54,8 +54,8 @@ export const createBitbucketPipelinesRunAction: (options: { | undefined; variables?: | { - key: string; value: string; + key: string; secured: boolean; }[] | undefined; @@ -80,7 +80,7 @@ export function createPublishBitbucketCloudAction(options: { repoUrl: string; description?: string | undefined; defaultBranch?: string | undefined; - repoVisibility?: 'private' | 'public' | undefined; + repoVisibility?: 'public' | 'private' | undefined; gitCommitMessage?: string | undefined; sourcePath?: string | undefined; token?: string | undefined; diff --git a/plugins/scaffolder-backend-module-bitbucket-server/report.api.md b/plugins/scaffolder-backend-module-bitbucket-server/report.api.md index 929eb165c4..a52bee490e 100644 --- a/plugins/scaffolder-backend-module-bitbucket-server/report.api.md +++ b/plugins/scaffolder-backend-module-bitbucket-server/report.api.md @@ -20,7 +20,7 @@ export function createPublishBitbucketServerAction(options: { { repoUrl: string; description?: string | undefined; - repoVisibility?: 'private' | 'public' | undefined; + repoVisibility?: 'public' | 'private' | undefined; defaultBranch?: string | undefined; sourcePath?: string | undefined; enableLFS?: boolean | undefined; diff --git a/plugins/scaffolder-backend-module-gitea/report.api.md b/plugins/scaffolder-backend-module-gitea/report.api.md index afc8286365..8a0393abef 100644 --- a/plugins/scaffolder-backend-module-gitea/report.api.md +++ b/plugins/scaffolder-backend-module-gitea/report.api.md @@ -17,7 +17,7 @@ export function createPublishGiteaAction(options: { repoUrl: string; description: string; defaultBranch?: string | undefined; - repoVisibility?: 'private' | 'public' | undefined; + repoVisibility?: 'public' | 'private' | undefined; gitCommitMessage?: string | undefined; gitAuthorName?: string | undefined; gitAuthorEmail?: string | undefined; diff --git a/plugins/scaffolder-backend-module-github/report.api.md b/plugins/scaffolder-backend-module-github/report.api.md index 9e930a7866..3dbc72b57b 100644 --- a/plugins/scaffolder-backend-module-github/report.api.md +++ b/plugins/scaffolder-backend-module-github/report.api.md @@ -67,14 +67,14 @@ export function createGithubBranchProtectionAction(options: { bypassPullRequestAllowances?: | { apps?: string[] | undefined; - teams?: string[] | undefined; users?: string[] | undefined; + teams?: string[] | undefined; } | undefined; restrictions?: | { - teams: string[]; users: string[]; + teams: string[]; apps?: string[] | undefined; } | undefined; @@ -242,8 +242,8 @@ export function createGithubRepoCreateAction(options: { bypassPullRequestAllowances?: | { apps?: string[] | undefined; - teams?: string[] | undefined; users?: string[] | undefined; + teams?: string[] | undefined; } | undefined; collaborators?: @@ -279,7 +279,7 @@ export function createGithubRepoCreateAction(options: { protectDefaultBranch?: boolean | undefined; protectEnforceAdmins?: boolean | undefined; repoVariables?: Record | undefined; - repoVisibility?: 'internal' | 'private' | 'public' | undefined; + repoVisibility?: 'public' | 'internal' | 'private' | undefined; requireBranchesToBeUpToDate?: boolean | undefined; requireCodeOwnerReviews?: boolean | undefined; requiredApprovingReviewCount?: number | undefined; @@ -290,8 +290,8 @@ export function createGithubRepoCreateAction(options: { requireLastPushApproval?: boolean | undefined; restrictions?: | { - teams: string[]; users: string[]; + teams: string[]; apps?: string[] | undefined; } | undefined; @@ -306,7 +306,7 @@ export function createGithubRepoCreateAction(options: { subscribe?: boolean | undefined; token?: string | undefined; topics?: string[] | undefined; - workflowAccess?: 'none' | 'organization' | 'user' | undefined; + workflowAccess?: 'none' | 'user' | 'organization' | undefined; }, { remoteUrl: string; @@ -329,15 +329,15 @@ export function createGithubRepoPushAction(options: { bypassPullRequestAllowances?: | { apps?: string[] | undefined; - teams?: string[] | undefined; users?: string[] | undefined; + teams?: string[] | undefined; } | undefined; requiredApprovingReviewCount?: number | undefined; restrictions?: | { - teams: string[]; users: string[]; + teams: string[]; apps?: string[] | undefined; } | undefined; @@ -399,15 +399,15 @@ export function createPublishGithubAction(options: { bypassPullRequestAllowances?: | { apps?: string[] | undefined; - teams?: string[] | undefined; users?: string[] | undefined; + teams?: string[] | undefined; } | undefined; requiredApprovingReviewCount?: number | undefined; restrictions?: | { - teams: string[]; users: string[]; + teams: string[]; apps?: string[] | undefined; } | undefined; @@ -417,7 +417,7 @@ export function createPublishGithubAction(options: { requireBranchesToBeUpToDate?: boolean | undefined; requiredConversationResolution?: boolean | undefined; requireLastPushApproval?: boolean | undefined; - repoVisibility?: 'internal' | 'private' | 'public' | undefined; + repoVisibility?: 'public' | 'internal' | 'private' | undefined; defaultBranch?: string | undefined; protectDefaultBranch?: boolean | undefined; protectEnforceAdmins?: boolean | undefined; diff --git a/plugins/scaffolder-backend-module-gitlab/report.api.md b/plugins/scaffolder-backend-module-gitlab/report.api.md index cbe31b02f3..5f119024b7 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' | 'create' | 'update' | 'delete' | undefined; }, { projectid: string; @@ -193,7 +193,7 @@ export function createPublishGitlabAction(options: { }): TemplateAction< { repoUrl: string; - repoVisibility?: 'internal' | 'private' | 'public' | undefined; + repoVisibility?: 'public' | 'internal' | 'private' | undefined; defaultBranch?: string | undefined; gitCommitMessage?: string | undefined; gitAuthorName?: string | undefined; @@ -206,24 +206,24 @@ export function createPublishGitlabAction(options: { topics?: string[] | undefined; settings?: | { - visibility?: 'internal' | 'private' | 'public' | undefined; path?: string | undefined; description?: string | undefined; - merge_method?: 'merge' | 'rebase_merge' | 'ff' | undefined; + visibility?: 'public' | 'internal' | 'private' | undefined; topics?: string[] | undefined; + merge_method?: 'merge' | 'rebase_merge' | 'ff' | undefined; auto_devops_enabled?: boolean | undefined; - only_allow_merge_if_pipeline_succeeds?: boolean | undefined; - allow_merge_on_skipped_pipeline?: boolean | undefined; + ci_config_path?: string | undefined; + squash_option?: + | 'never' + | 'always' + | 'default_off' + | 'default_on' + | undefined; only_allow_merge_if_all_discussions_are_resolved?: | boolean | undefined; - squash_option?: - | 'always' - | 'never' - | 'default_on' - | 'default_off' - | undefined; - ci_config_path?: string | undefined; + only_allow_merge_if_pipeline_succeeds?: boolean | undefined; + allow_merge_on_skipped_pipeline?: boolean | undefined; } | undefined; branches?: @@ -236,15 +236,15 @@ export function createPublishGitlabAction(options: { | undefined; projectVariables?: | { - key: string; value: string; - raw?: boolean | undefined; + key: string; description?: string | undefined; protected?: boolean | undefined; + raw?: boolean | undefined; variable_type?: 'file' | 'env_var' | undefined; masked?: boolean | undefined; - environment_scope?: string | undefined; masked_and_hidden?: boolean | undefined; + environment_scope?: string | undefined; }[] | undefined; }, @@ -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' | 'create' | 'update' | 'delete' | 'skip' | undefined; projectid?: string | undefined; removeSourceBranch?: boolean | undefined; assignee?: string | undefined; diff --git a/plugins/scaffolder-backend-module-rails/report.api.md b/plugins/scaffolder-backend-module-rails/report.api.md index b09b75591b..8efc166c97 100644 --- a/plugins/scaffolder-backend-module-rails/report.api.md +++ b/plugins/scaffolder-backend-module-rails/report.api.md @@ -47,9 +47,8 @@ export function createFetchRailsAction(options: { values: { railsArguments?: | { - template?: string | undefined; api?: boolean | undefined; - force?: boolean | undefined; + template?: string | undefined; database?: | 'sqlite3' | 'mysql' @@ -61,6 +60,7 @@ export function createFetchRailsAction(options: { | 'jdbcpostgresql' | 'jdbc' | undefined; + force?: boolean | undefined; minimal?: boolean | undefined; railsVersion?: 'edge' | 'master' | 'dev' | 'fromImage' | undefined; skipActionCable?: boolean | undefined; diff --git a/plugins/scaffolder-backend/report.api.md b/plugins/scaffolder-backend/report.api.md index 8f9249cc5e..f203dd2faa 100644 --- a/plugins/scaffolder-backend/report.api.md +++ b/plugins/scaffolder-backend/report.api.md @@ -217,8 +217,8 @@ export const createFilesystemReadDirAction: () => TemplateAction< export const createFilesystemRenameAction: () => TemplateAction< { files: { - from: string; to: string; + from: string; overwrite?: boolean | undefined; }[]; }, diff --git a/plugins/scaffolder-react/report-alpha.api.md b/plugins/scaffolder-react/report-alpha.api.md index 9e01257c5c..609debaca2 100644 --- a/plugins/scaffolder-react/report-alpha.api.md +++ b/plugins/scaffolder-react/report-alpha.api.md @@ -318,8 +318,8 @@ export const scaffolderReactTranslationRef: TranslationRef< readonly 'stepper.reviewButtonText': 'Review'; readonly 'stepper.stepIndexLabel': 'Step {{index, number}}'; readonly 'passwordWidget.content': 'This widget is insecure. Please use [`ui:field: Secret`](https://backstage.io/docs/features/software-templates/writing-templates/#using-secrets) instead of `ui:widget: password`'; - readonly 'scaffolderPageContextMenu.createLabel': 'Create'; readonly 'scaffolderPageContextMenu.moreLabel': 'more'; + readonly 'scaffolderPageContextMenu.createLabel': 'Create'; readonly 'scaffolderPageContextMenu.editorLabel': 'Manage Templates'; readonly 'scaffolderPageContextMenu.actionsLabel': 'Installed Actions'; readonly 'scaffolderPageContextMenu.tasksLabel': 'Task List'; diff --git a/plugins/scaffolder/report-alpha.api.md b/plugins/scaffolder/report-alpha.api.md index b2cd1bbcbe..87cb54d687 100644 --- a/plugins/scaffolder/report-alpha.api.md +++ b/plugins/scaffolder/report-alpha.api.md @@ -144,8 +144,8 @@ const _default: OverridableFrontendPlugin< }; configInput: { filter?: FilterPredicate | undefined; - label?: string | undefined; title?: string | undefined; + label?: string | undefined; }; output: | ExtensionDataRef< @@ -200,8 +200,8 @@ const _default: OverridableFrontendPlugin< title: string | undefined; }; configInput: { - title?: string | undefined; path?: string | undefined; + title?: string | undefined; }; output: | ExtensionDataRef @@ -572,24 +572,24 @@ export const scaffolderTranslationRef: TranslationRef< readonly 'fields.repoOwnerPicker.title': 'Owner'; readonly 'fields.repoOwnerPicker.description': 'The owner of the repository'; readonly 'aboutCard.launchTemplate': 'Launch Template'; + readonly 'actionsPage.title': 'Installed actions'; + readonly 'actionsPage.action.output': 'Output'; + readonly 'actionsPage.action.input': 'Input'; + readonly 'actionsPage.action.examples': 'Examples'; readonly 'actionsPage.content.emptyState.title': 'No information to display'; readonly 'actionsPage.content.emptyState.description': 'There are no actions installed or there was an issue communicating with backend.'; readonly 'actionsPage.content.searchFieldPlaceholder': 'Search for an action'; - readonly 'actionsPage.title': 'Installed actions'; - readonly 'actionsPage.action.input': 'Input'; - readonly 'actionsPage.action.output': 'Output'; - readonly 'actionsPage.action.examples': 'Examples'; readonly 'actionsPage.subtitle': 'This is the collection of all installed actions'; readonly 'actionsPage.pageTitle': 'Create a New Component'; + readonly 'listTaskPage.title': 'List template tasks'; readonly 'listTaskPage.content.emptyState.title': 'No information to display'; readonly 'listTaskPage.content.emptyState.description': 'There are no tasks or there was an issue communicating with backend.'; - readonly 'listTaskPage.content.tableCell.template': 'Template'; + readonly 'listTaskPage.content.tableTitle': 'Tasks'; readonly 'listTaskPage.content.tableCell.status': 'Status'; + readonly 'listTaskPage.content.tableCell.template': 'Template'; readonly 'listTaskPage.content.tableCell.owner': 'Owner'; readonly 'listTaskPage.content.tableCell.created': 'Created'; readonly 'listTaskPage.content.tableCell.taskID': 'Task ID'; - readonly 'listTaskPage.content.tableTitle': 'Tasks'; - readonly 'listTaskPage.title': 'List template tasks'; readonly 'listTaskPage.subtitle': 'All tasks that have been started'; readonly 'listTaskPage.pageTitle': 'Templates Tasks'; readonly 'ownerListPicker.title': 'Task Owner'; @@ -614,28 +614,28 @@ export const scaffolderTranslationRef: TranslationRef< readonly 'templateEditorForm.stepper.emptyText': 'There are no spec parameters in the template to preview.'; readonly 'renderSchema.undefined': 'No schema defined'; readonly 'renderSchema.tableCell.name': 'Name'; - readonly 'renderSchema.tableCell.type': 'Type'; readonly 'renderSchema.tableCell.title': 'Title'; readonly 'renderSchema.tableCell.description': 'Description'; + readonly 'renderSchema.tableCell.type': 'Type'; + readonly 'templatingExtensions.title': 'Templating Extensions'; readonly 'templatingExtensions.content.values.title': 'Values'; readonly 'templatingExtensions.content.values.notAvailable': 'There are no global template values defined.'; readonly 'templatingExtensions.content.emptyState.title': 'No information to display'; readonly 'templatingExtensions.content.emptyState.description': 'There are no templating extensions available or there was an issue communicating with the backend.'; readonly 'templatingExtensions.content.filters.title': 'Filters'; - readonly 'templatingExtensions.content.filters.schema.input': 'Input'; readonly 'templatingExtensions.content.filters.schema.output': 'Output'; + readonly 'templatingExtensions.content.filters.schema.input': 'Input'; readonly 'templatingExtensions.content.filters.schema.arguments': 'Arguments'; readonly 'templatingExtensions.content.filters.examples': 'Examples'; readonly 'templatingExtensions.content.filters.notAvailable': 'There are no template filters defined.'; readonly 'templatingExtensions.content.filters.metadataAbsent': 'Filter metadata unavailable'; + readonly 'templatingExtensions.content.searchFieldPlaceholder': 'Search for an extension'; readonly 'templatingExtensions.content.functions.title': 'Functions'; readonly 'templatingExtensions.content.functions.schema.output': 'Output'; readonly 'templatingExtensions.content.functions.schema.arguments': 'Arguments'; readonly 'templatingExtensions.content.functions.examples': 'Examples'; readonly 'templatingExtensions.content.functions.notAvailable': 'There are no global template functions defined.'; readonly 'templatingExtensions.content.functions.metadataAbsent': 'Function metadata unavailable'; - readonly 'templatingExtensions.content.searchFieldPlaceholder': 'Search for an extension'; - readonly 'templatingExtensions.title': 'Templating Extensions'; readonly 'templatingExtensions.subtitle': 'This is the collection of available templating extensions'; readonly 'templatingExtensions.pageTitle': 'Templating Extensions'; readonly 'templateTypePicker.title': 'Categories'; @@ -699,12 +699,12 @@ export const scaffolderTranslationRef: TranslationRef< readonly 'templateEditorToolbar.addToCatalogDialogTitle': 'Publish changes'; readonly 'templateEditorToolbar.addToCatalogDialogContent.stepsIntroduction': 'Follow the instructions below to create or update a template:'; readonly 'templateEditorToolbar.addToCatalogDialogContent.stepsListItems': 'Save the template files in a local directory\nCreate a pull request to a new or existing git repository\nIf the template already exists, the changes will be reflected in the software catalog once the pull request gets merged\nBut if you are creating a new template, follow the documentation linked below to register the new template repository in software catalog'; - readonly 'templateEditorToolbar.addToCatalogDialogActions.documentationUrl': 'https://backstage.io/docs/features/software-templates/adding-templates/'; readonly 'templateEditorToolbar.addToCatalogDialogActions.documentationButton': 'Go to the documentation'; - readonly 'templateEditorToolbarFileMenu.button': 'File'; + readonly 'templateEditorToolbar.addToCatalogDialogActions.documentationUrl': 'https://backstage.io/docs/features/software-templates/adding-templates/'; readonly 'templateEditorToolbarFileMenu.options.openDirectory': 'Open template directory'; readonly 'templateEditorToolbarFileMenu.options.createDirectory': 'Create template directory'; readonly 'templateEditorToolbarFileMenu.options.closeEditor': 'Close template editor'; + readonly 'templateEditorToolbarFileMenu.button': 'File'; readonly 'templateEditorToolbarTemplatesMenu.button': 'Templates'; } >; diff --git a/plugins/search-backend-module-elasticsearch/report.api.md b/plugins/search-backend-module-elasticsearch/report.api.md index afe06dd45e..f90dc2c0d9 100644 --- a/plugins/search-backend-module-elasticsearch/report.api.md +++ b/plugins/search-backend-module-elasticsearch/report.api.md @@ -7,8 +7,8 @@ import { ApiResponse } from '@opensearch-project/opensearch'; import { ApiResponse as ApiResponse_2 } from '@elastic/elasticsearch'; import { BackendFeature } from '@backstage/backend-plugin-api'; import { BatchSearchEngineIndexer } from '@backstage/plugin-search-backend-node'; -import { BulkHelper } from '@elastic/elasticsearch/lib/Helpers'; -import { BulkStats } from '@elastic/elasticsearch/lib/Helpers'; +import { BulkHelper } from '@opensearch-project/opensearch/lib/Helpers.js'; +import { BulkStats } from '@opensearch-project/opensearch/lib/Helpers.js'; import { Config } from '@backstage/config'; import type { ConnectionOptions } from 'node:tls'; import { ExtensionPoint } from '@backstage/backend-plugin-api'; diff --git a/plugins/search/report-alpha.api.md b/plugins/search/report-alpha.api.md index e3f2d6108a..9a31942231 100644 --- a/plugins/search/report-alpha.api.md +++ b/plugins/search/report-alpha.api.md @@ -73,8 +73,8 @@ const _default: OverridableFrontendPlugin< }; configInput: { noTrack?: boolean | undefined; - title?: string | undefined; path?: string | undefined; + title?: string | undefined; }; output: | ExtensionDataRef @@ -242,8 +242,8 @@ export const searchPage: OverridableExtensionDefinition<{ }; configInput: { noTrack?: boolean | undefined; - title?: string | undefined; path?: string | undefined; + title?: string | undefined; }; output: | ExtensionDataRef diff --git a/plugins/techdocs/report-alpha.api.md b/plugins/techdocs/report-alpha.api.md index aa14098288..dc3b144665 100644 --- a/plugins/techdocs/report-alpha.api.md +++ b/plugins/techdocs/report-alpha.api.md @@ -133,10 +133,10 @@ const _default: OverridableFrontendPlugin< }; configInput: { filter?: FilterPredicate | undefined; - title?: string | undefined; path?: string | undefined; - group?: string | false | undefined; + title?: string | undefined; icon?: string | undefined; + group?: string | false | undefined; }; output: | ExtensionDataRef @@ -230,8 +230,8 @@ const _default: OverridableFrontendPlugin< }; configInput: { filter?: FilterPredicate | undefined; - label?: string | undefined; title?: string | undefined; + label?: string | undefined; }; output: | ExtensionDataRef< @@ -288,8 +288,8 @@ const _default: OverridableFrontendPlugin< title: string | undefined; }; configInput: { - title?: string | undefined; path?: string | undefined; + title?: string | undefined; }; output: | ExtensionDataRef @@ -366,8 +366,8 @@ const _default: OverridableFrontendPlugin< configInput: { withoutSearch?: boolean | undefined; withoutHeader?: boolean | undefined; - title?: string | undefined; path?: string | undefined; + title?: string | undefined; }; output: | ExtensionDataRef diff --git a/plugins/user-settings/report-alpha.api.md b/plugins/user-settings/report-alpha.api.md index c99709d777..35f7f1a262 100644 --- a/plugins/user-settings/report-alpha.api.md +++ b/plugins/user-settings/report-alpha.api.md @@ -50,8 +50,8 @@ const _default: OverridableFrontendPlugin< title: string | undefined; }; configInput: { - title?: string | undefined; path?: string | undefined; + title?: string | undefined; }; output: | ExtensionDataRef @@ -164,22 +164,22 @@ export const userSettingsTranslationRef: TranslationRef< readonly 'featureFlags.filterTitle': 'Filter'; readonly 'featureFlags.clearFilter': 'Clear filter'; readonly 'featureFlags.emptyFlags.title': 'No Feature Flags'; + readonly 'featureFlags.emptyFlags.description': 'Feature Flags make it possible for plugins to register features in Backstage for users to opt into. You can use this to split out logic in your code for manual A/B testing, etc.'; readonly 'featureFlags.emptyFlags.action.title': 'An example for how to add a feature flag is highlighted below:'; readonly 'featureFlags.emptyFlags.action.readMoreButtonTitle': 'Read More'; - readonly 'featureFlags.emptyFlags.description': 'Feature Flags make it possible for plugins to register features in Backstage for users to opt into. You can use this to split out logic in your code for manual A/B testing, etc.'; readonly 'featureFlags.flagItem.title.disable': 'Disable'; readonly 'featureFlags.flagItem.title.enable': 'Enable'; readonly 'featureFlags.flagItem.subtitle.registeredInApplication': 'Registered in the application'; readonly 'featureFlags.flagItem.subtitle.registeredInPlugin': 'Registered in {{pluginId}} plugin'; - readonly 'languageToggle.select': 'Select language {{language}}'; readonly 'languageToggle.title': 'Language'; readonly 'languageToggle.description': 'Change the language'; - readonly 'themeToggle.select': 'Select {{theme}}'; + readonly 'languageToggle.select': 'Select language {{language}}'; readonly 'themeToggle.title': 'Theme'; readonly 'themeToggle.description': 'Change the theme mode'; + readonly 'themeToggle.select': 'Select {{theme}}'; readonly 'themeToggle.names.auto': 'Auto'; - readonly 'themeToggle.names.dark': 'Dark'; readonly 'themeToggle.names.light': 'Light'; + readonly 'themeToggle.names.dark': 'Dark'; readonly 'themeToggle.selectAuto': 'Select Auto Theme'; readonly 'signOutMenu.title': 'Sign Out'; readonly 'signOutMenu.moreIconTitle': 'more'; @@ -194,9 +194,9 @@ export const userSettingsTranslationRef: TranslationRef< readonly 'identityCard.ownershipEntities': 'Ownership Entities'; readonly 'defaultProviderSettings.description': 'Provides authentication towards {{provider}} APIs and identities'; readonly 'emptyProviders.title': 'No Authentication Providers'; + readonly 'emptyProviders.description': 'You can add Authentication Providers to Backstage which allows you to use these providers to authenticate yourself.'; readonly 'emptyProviders.action.title': 'Open app-config.yaml and make the changes as highlighted below:'; readonly 'emptyProviders.action.readMoreButtonTitle': 'Read More'; - readonly 'emptyProviders.description': 'You can add Authentication Providers to Backstage which allows you to use these providers to authenticate yourself.'; readonly 'providerSettingsItem.title.signOut': 'Sign out from {{title}}'; readonly 'providerSettingsItem.title.signIn': 'Sign in to {{title}}'; readonly 'providerSettingsItem.buttonTitle.signOut': 'Sign out'; From e8d410d9159104d4b6b2b79de104f24883d1a025 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Mar 2026 17:49:36 +0100 Subject: [PATCH 06/91] Regenerate API reports Signed-off-by: Patrik Oldsberg Made-with: Cursor --- packages/app-example-plugin/report.api.md | 2 +- packages/cli-defaults/report.api.md | 4 +- packages/core-components/report-alpha.api.md | 4 +- packages/frontend-plugin-api/report.api.md | 4 +- plugins/api-docs/report-alpha.api.md | 34 +++++------ plugins/app-visualizer/report.api.md | 8 +-- plugins/app/report.api.md | 4 +- plugins/auth/report.api.md | 2 +- plugins/catalog-graph/report-alpha.api.md | 14 ++--- plugins/catalog-import/report-alpha.api.md | 10 ++-- plugins/catalog-react/report-alpha.api.md | 30 +++++----- .../report-alpha.api.md | 4 +- plugins/catalog/report-alpha.api.md | 58 +++++++++---------- plugins/catalog/report.api.md | 2 +- plugins/devtools-react/report.api.md | 2 +- plugins/devtools/report-alpha.api.md | 2 +- plugins/home/report-alpha.api.md | 4 +- plugins/kubernetes-react/report-alpha.api.md | 8 +-- plugins/kubernetes/report-alpha.api.md | 6 +- plugins/mui-to-bui/report.api.md | 2 +- plugins/notifications/report-alpha.api.md | 14 ++--- plugins/org/report-alpha.api.md | 16 ++--- .../report.api.md | 20 +++---- .../report.api.md | 2 +- .../report.api.md | 2 +- .../report.api.md | 22 +++---- .../report.api.md | 34 +++++------ .../report.api.md | 4 +- plugins/scaffolder-backend/report.api.md | 2 +- plugins/scaffolder-react/report-alpha.api.md | 2 +- plugins/scaffolder/report-alpha.api.md | 30 +++++----- .../report.api.md | 4 +- plugins/search/report-alpha.api.md | 4 +- plugins/techdocs/report-alpha.api.md | 10 ++-- plugins/user-settings/report-alpha.api.md | 12 ++-- 35 files changed, 192 insertions(+), 190 deletions(-) diff --git a/packages/app-example-plugin/report.api.md b/packages/app-example-plugin/report.api.md index 0879f318aa..432174da68 100644 --- a/packages/app-example-plugin/report.api.md +++ b/packages/app-example-plugin/report.api.md @@ -27,8 +27,8 @@ const examplePlugin: OverridableFrontendPlugin< title: string | undefined; }; configInput: { - path?: string | undefined; title?: string | undefined; + path?: string | undefined; }; output: | ExtensionDataRef diff --git a/packages/cli-defaults/report.api.md b/packages/cli-defaults/report.api.md index 1a40836eaa..573f8ee454 100644 --- a/packages/cli-defaults/report.api.md +++ b/packages/cli-defaults/report.api.md @@ -3,8 +3,10 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { CliModule } from '@backstage/cli-node'; + // @public -const _default: any[]; +const _default: CliModule[]; export default _default; // (No @packageDocumentation comment for this package) diff --git a/packages/core-components/report-alpha.api.md b/packages/core-components/report-alpha.api.md index 7b7f514dcf..f373dd56ff 100644 --- a/packages/core-components/report-alpha.api.md +++ b/packages/core-components/report-alpha.api.md @@ -12,8 +12,8 @@ export const coreComponentsTranslationRef: TranslationRef< readonly 'table.filter.title': 'Filters'; readonly 'table.filter.placeholder': 'All results'; readonly 'table.filter.clearAll': 'Clear all'; - readonly 'table.header.actions': 'Actions'; 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'; @@ -37,9 +37,9 @@ export const coreComponentsTranslationRef: TranslationRef< readonly 'signIn.guestProvider.subtitle': 'Enter as a Guest User.\n You will not have a verified identity, meaning some features might be unavailable.'; readonly skipToContent: 'Skip to content'; readonly 'copyTextButton.tooltipText': 'Text copied to clipboard'; + readonly 'simpleStepper.finish': 'Finish'; readonly 'simpleStepper.reset': 'Reset'; readonly 'simpleStepper.next': 'Next'; - readonly 'simpleStepper.finish': 'Finish'; readonly 'simpleStepper.skip': 'Skip'; readonly 'simpleStepper.back': 'Back'; readonly 'errorPage.title': 'Looks like someone dropped the mic!'; diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index 72f22b4093..f17ca670ea 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -1792,8 +1792,8 @@ export const PageBlueprint: ExtensionBlueprint_2<{ title: string | undefined; }; configInput: { - path?: string | undefined; title?: string | undefined; + path?: string | undefined; }; dataRefs: never; }>; @@ -2104,8 +2104,8 @@ export const SubPageBlueprint: ExtensionBlueprint_2<{ title: string | undefined; }; configInput: { - path?: string | undefined; title?: string | undefined; + path?: string | undefined; }; dataRefs: never; }>; diff --git a/plugins/api-docs/report-alpha.api.md b/plugins/api-docs/report-alpha.api.md index 434cc38691..0ee7ac5460 100644 --- a/plugins/api-docs/report-alpha.api.md +++ b/plugins/api-docs/report-alpha.api.md @@ -91,11 +91,11 @@ const _default: OverridableFrontendPlugin< name: 'consumed-apis'; config: { filter: FilterPredicate | undefined; - type: 'info' | 'content' | undefined; + type: 'content' | 'info' | undefined; }; configInput: { filter?: FilterPredicate | undefined; - type?: 'info' | 'content' | undefined; + type?: 'content' | 'info' | undefined; }; output: | ExtensionDataRef @@ -132,11 +132,11 @@ const _default: OverridableFrontendPlugin< name: 'consuming-components'; config: { filter: FilterPredicate | undefined; - type: 'info' | 'content' | undefined; + type: 'content' | 'info' | undefined; }; configInput: { filter?: FilterPredicate | undefined; - type?: 'info' | 'content' | undefined; + type?: 'content' | 'info' | undefined; }; output: | ExtensionDataRef @@ -173,11 +173,11 @@ const _default: OverridableFrontendPlugin< name: 'definition'; config: { filter: FilterPredicate | undefined; - type: 'info' | 'content' | undefined; + type: 'content' | 'info' | undefined; }; configInput: { filter?: FilterPredicate | undefined; - type?: 'info' | 'content' | undefined; + type?: 'content' | 'info' | undefined; }; output: | ExtensionDataRef @@ -214,11 +214,11 @@ const _default: OverridableFrontendPlugin< name: 'has-apis'; config: { filter: FilterPredicate | undefined; - type: 'info' | 'content' | undefined; + type: 'content' | 'info' | undefined; }; configInput: { filter?: FilterPredicate | undefined; - type?: 'info' | 'content' | undefined; + type?: 'content' | 'info' | undefined; }; output: | ExtensionDataRef @@ -255,11 +255,11 @@ const _default: OverridableFrontendPlugin< name: 'provided-apis'; config: { filter: FilterPredicate | undefined; - type: 'info' | 'content' | undefined; + type: 'content' | 'info' | undefined; }; configInput: { filter?: FilterPredicate | undefined; - type?: 'info' | 'content' | undefined; + type?: 'content' | 'info' | undefined; }; output: | ExtensionDataRef @@ -296,11 +296,11 @@ const _default: OverridableFrontendPlugin< name: 'providing-components'; config: { filter: FilterPredicate | undefined; - type: 'info' | 'content' | undefined; + type: 'content' | 'info' | undefined; }; configInput: { filter?: FilterPredicate | undefined; - type?: 'info' | 'content' | undefined; + type?: 'content' | 'info' | undefined; }; output: | ExtensionDataRef @@ -344,10 +344,10 @@ const _default: OverridableFrontendPlugin< }; configInput: { filter?: FilterPredicate | undefined; - path?: string | undefined; title?: string | undefined; - icon?: string | undefined; + path?: string | undefined; group?: string | false | undefined; + icon?: string | undefined; }; output: | ExtensionDataRef @@ -414,10 +414,10 @@ const _default: OverridableFrontendPlugin< }; configInput: { filter?: FilterPredicate | undefined; - path?: string | undefined; title?: string | undefined; - icon?: string | undefined; + path?: string | undefined; group?: string | false | undefined; + icon?: string | undefined; }; output: | ExtensionDataRef @@ -501,8 +501,8 @@ const _default: OverridableFrontendPlugin< }; configInput: { initiallySelectedFilter?: 'all' | 'owned' | 'starred' | undefined; - path?: string | undefined; title?: string | undefined; + path?: string | undefined; }; output: | ExtensionDataRef diff --git a/plugins/app-visualizer/report.api.md b/plugins/app-visualizer/report.api.md index 2550026e98..9ee06eccb0 100644 --- a/plugins/app-visualizer/report.api.md +++ b/plugins/app-visualizer/report.api.md @@ -49,8 +49,8 @@ const visualizerPlugin: OverridableFrontendPlugin< title: string | undefined; }; configInput: { - path?: string | undefined; title?: string | undefined; + path?: string | undefined; }; output: | ExtensionDataRef @@ -138,8 +138,8 @@ const visualizerPlugin: OverridableFrontendPlugin< title: string | undefined; }; configInput: { - path?: string | undefined; title?: string | undefined; + path?: string | undefined; }; output: | ExtensionDataRef @@ -176,8 +176,8 @@ const visualizerPlugin: OverridableFrontendPlugin< title: string | undefined; }; configInput: { - path?: string | undefined; title?: string | undefined; + path?: string | undefined; }; output: | ExtensionDataRef @@ -214,8 +214,8 @@ const visualizerPlugin: OverridableFrontendPlugin< title: string | undefined; }; configInput: { - path?: string | undefined; title?: string | undefined; + path?: string | undefined; }; output: | ExtensionDataRef diff --git a/plugins/app/report.api.md b/plugins/app/report.api.md index b6b23a0d3b..0262c4c1ef 100644 --- a/plugins/app/report.api.md +++ b/plugins/app/report.api.md @@ -775,14 +775,14 @@ const appPlugin: OverridableFrontendPlugin< transientTimeoutMs: number; anchorOrigin: { horizontal: 'center' | 'left' | 'right'; - vertical: 'bottom' | 'top'; + vertical: 'top' | 'bottom'; }; }; configInput: { anchorOrigin?: | { horizontal?: 'center' | 'left' | 'right' | undefined; - vertical?: 'bottom' | 'top' | undefined; + vertical?: 'top' | 'bottom' | undefined; } | undefined; transientTimeoutMs?: number | undefined; diff --git a/plugins/auth/report.api.md b/plugins/auth/report.api.md index 903b8397c9..8e8ac71d31 100644 --- a/plugins/auth/report.api.md +++ b/plugins/auth/report.api.md @@ -28,8 +28,8 @@ const _default: OverridableFrontendPlugin< title: string | undefined; }; configInput: { - path?: string | undefined; title?: string | undefined; + path?: string | undefined; }; output: | ExtensionDataRef diff --git a/plugins/catalog-graph/report-alpha.api.md b/plugins/catalog-graph/report-alpha.api.md index e1497f7623..f9d4542625 100644 --- a/plugins/catalog-graph/report-alpha.api.md +++ b/plugins/catalog-graph/report-alpha.api.md @@ -95,14 +95,14 @@ const _default: OverridableFrontendPlugin< title: string | undefined; height: number | undefined; filter: FilterPredicate | undefined; - type: 'info' | 'content' | undefined; + type: 'content' | 'info' | undefined; }; configInput: { - title?: string | undefined; height?: number | undefined; + curve?: 'curveStepBefore' | 'curveMonotoneX' | undefined; direction?: 'TB' | 'BT' | 'LR' | 'RL' | undefined; zoom?: 'disabled' | 'enabled' | 'enable-on-click' | undefined; - curve?: 'curveStepBefore' | 'curveMonotoneX' | undefined; + title?: string | undefined; relations?: string[] | undefined; maxDepth?: number | undefined; kinds?: string[] | undefined; @@ -110,7 +110,7 @@ const _default: OverridableFrontendPlugin< relationPairs?: [string, string][] | undefined; unidirectional?: boolean | undefined; filter?: FilterPredicate | undefined; - type?: 'info' | 'content' | undefined; + type?: 'content' | 'info' | undefined; }; output: | ExtensionDataRef @@ -163,12 +163,12 @@ const _default: OverridableFrontendPlugin< title: string | undefined; }; configInput: { + curve?: 'curveStepBefore' | 'curveMonotoneX' | undefined; direction?: 'TB' | 'BT' | 'LR' | 'RL' | undefined; zoom?: 'disabled' | 'enabled' | 'enable-on-click' | undefined; - curve?: 'curveStepBefore' | 'curveMonotoneX' | undefined; relations?: string[] | undefined; - rootEntityRefs?: string[] | undefined; maxDepth?: number | undefined; + rootEntityRefs?: string[] | undefined; kinds?: string[] | undefined; mergeRelations?: boolean | undefined; relationPairs?: [string, string][] | undefined; @@ -176,8 +176,8 @@ const _default: OverridableFrontendPlugin< selectedRelations?: string[] | undefined; selectedKinds?: string[] | undefined; showFilters?: boolean | undefined; - path?: string | undefined; title?: string | undefined; + path?: string | undefined; }; output: | ExtensionDataRef diff --git a/plugins/catalog-import/report-alpha.api.md b/plugins/catalog-import/report-alpha.api.md index 70799ed325..b58b641050 100644 --- a/plugins/catalog-import/report-alpha.api.md +++ b/plugins/catalog-import/report-alpha.api.md @@ -34,8 +34,8 @@ export const catalogImportTranslationRef: TranslationRef< readonly 'importInfoCard.fileLinkDescription': 'The wizard analyzes the file, previews the entities, and adds them to the {{appTitle}} catalog.'; readonly 'importInfoCard.exampleDescription': 'The wizard discovers all {{catalogFilename}} files in the repository, previews the entities, and adds them to the {{appTitle}} catalog.'; readonly 'importInfoCard.preparePullRequestDescription': 'If no entities are found, the wizard will prepare a Pull Request that adds an example {{catalogFilename}} and prepares the {{appTitle}} catalog to load all entities as soon as the Pull Request is merged.'; - readonly 'importInfoCard.githubIntegration.title': 'Link to a repository'; readonly 'importInfoCard.githubIntegration.label': 'GitHub only'; + readonly 'importInfoCard.githubIntegration.title': 'Link to a repository'; readonly 'importStepper.finish.title': 'Finish'; readonly 'importStepper.singleLocation.title': 'Select Locations'; readonly 'importStepper.singleLocation.description': 'Discovered Locations: 1'; @@ -62,8 +62,8 @@ export const catalogImportTranslationRef: TranslationRef< readonly 'importStepper.review.title': 'Review'; readonly 'stepFinishImportLocation.repository.title': 'The following Pull Request has been opened: '; readonly 'stepFinishImportLocation.repository.description': 'Your entities will be imported as soon as the Pull Request is merged.'; - readonly 'stepFinishImportLocation.locations.backButtonText': 'Register another'; readonly 'stepFinishImportLocation.locations.new': 'The following entities have been added to the catalog:'; + readonly 'stepFinishImportLocation.locations.backButtonText': 'Register another'; readonly 'stepFinishImportLocation.locations.existing': 'A refresh was triggered for the following locations:'; readonly 'stepFinishImportLocation.locations.viewButtonText': 'View Component'; readonly 'stepFinishImportLocation.backButtonText': 'Register another'; @@ -83,9 +83,9 @@ export const catalogImportTranslationRef: TranslationRef< readonly 'stepPrepareSelectLocations.nextButtonText': 'Review'; readonly 'stepPrepareSelectLocations.existingLocations.description': 'These locations already exist in the catalog:'; readonly 'stepReviewLocation.refresh': 'Refresh'; - readonly 'stepReviewLocation.catalog.exists': 'The following locations already exist in the catalog:'; - readonly 'stepReviewLocation.catalog.new': 'The following entities will be added to the catalog:'; readonly 'stepReviewLocation.import': 'Import'; + readonly 'stepReviewLocation.catalog.new': 'The following entities will be added to the catalog:'; + readonly 'stepReviewLocation.catalog.exists': 'The following locations already exist in the catalog:'; readonly 'stepReviewLocation.prepareResult.title': 'The following Pull Request has been opened: '; readonly 'stepReviewLocation.prepareResult.description': 'You can already import the location and {{appTitle}} will fetch the entities as soon as the Pull Request is merged.'; } @@ -121,8 +121,8 @@ const _default: OverridableFrontendPlugin< title: string | undefined; }; configInput: { - path?: string | undefined; title?: string | undefined; + path?: string | undefined; }; output: | ExtensionDataRef diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index ee0b8bb3ff..9c76a221dc 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -73,17 +73,17 @@ export const catalogReactTranslationRef: TranslationRef< readonly 'inspectEntityDialog.jsonPage.title': 'Entity as JSON'; readonly 'inspectEntityDialog.jsonPage.description': 'This is the raw entity data as received from the catalog, on JSON form.'; readonly 'inspectEntityDialog.overviewPage.title': 'Overview'; + readonly 'inspectEntityDialog.overviewPage.metadata.title': 'Metadata'; + readonly 'inspectEntityDialog.overviewPage.labels': 'Labels'; readonly 'inspectEntityDialog.overviewPage.status.title': 'Status'; readonly 'inspectEntityDialog.overviewPage.identity.title': 'Identity'; - readonly 'inspectEntityDialog.overviewPage.metadata.title': 'Metadata'; readonly 'inspectEntityDialog.overviewPage.annotations': 'Annotations'; readonly 'inspectEntityDialog.overviewPage.tags': 'Tags'; - readonly 'inspectEntityDialog.overviewPage.labels': 'Labels'; readonly 'inspectEntityDialog.overviewPage.relation.title': 'Relations'; readonly 'inspectEntityDialog.yamlPage.title': 'Entity as YAML'; readonly 'inspectEntityDialog.yamlPage.description': 'This is the raw entity data as received from the catalog, on YAML form.'; - readonly 'inspectEntityDialog.tabNames.yaml': 'Raw YAML'; readonly 'inspectEntityDialog.tabNames.json': 'Raw JSON'; + readonly 'inspectEntityDialog.tabNames.yaml': 'Raw YAML'; readonly 'inspectEntityDialog.tabNames.overview': 'Overview'; readonly 'inspectEntityDialog.tabNames.ancestry': 'Ancestry'; readonly 'inspectEntityDialog.tabNames.colocated': 'Colocated'; @@ -108,17 +108,17 @@ export const catalogReactTranslationRef: TranslationRef< readonly 'userListPicker.personalFilter.ownedLabel': 'Owned'; readonly 'userListPicker.personalFilter.starredLabel': 'Starred'; readonly 'entityTableColumnTitle.name': 'Name'; - readonly 'entityTableColumnTitle.namespace': 'Namespace'; - readonly 'entityTableColumnTitle.title': 'Title'; - readonly 'entityTableColumnTitle.description': 'Description'; readonly 'entityTableColumnTitle.type': 'Type'; readonly 'entityTableColumnTitle.label': 'Label'; + readonly 'entityTableColumnTitle.title': 'Title'; + readonly 'entityTableColumnTitle.description': 'Description'; + readonly 'entityTableColumnTitle.system': 'System'; + readonly 'entityTableColumnTitle.namespace': 'Namespace'; + readonly 'entityTableColumnTitle.domain': 'Domain'; readonly 'entityTableColumnTitle.tags': 'Tags'; readonly 'entityTableColumnTitle.owner': 'Owner'; readonly 'entityTableColumnTitle.lifecycle': 'Lifecycle'; - readonly 'entityTableColumnTitle.system': 'System'; readonly 'entityTableColumnTitle.targets': 'Targets'; - readonly 'entityTableColumnTitle.domain': 'Domain'; readonly 'missingAnnotationEmptyState.title': 'Missing Annotation'; readonly 'missingAnnotationEmptyState.readMore': 'Read more'; readonly 'missingAnnotationEmptyState.annotationYaml': 'Add the annotation to your {{entityKind}} YAML as shown in the highlighted example below:'; @@ -213,11 +213,11 @@ export const EntityCardBlueprint: ExtensionBlueprint<{ inputs: {}; config: { filter: FilterPredicate | undefined; - type: 'info' | 'content' | undefined; + type: 'content' | 'info' | undefined; }; configInput: { filter?: FilterPredicate | undefined; - type?: 'info' | 'content' | undefined; + type?: 'content' | 'info' | undefined; }; dataRefs: { filterFunction: ConfigurableExtensionDataRef< @@ -305,10 +305,10 @@ export const EntityContentBlueprint: ExtensionBlueprint<{ }; configInput: { filter?: FilterPredicate | undefined; - path?: string | undefined; title?: string | undefined; - icon?: string | undefined; + path?: string | undefined; group?: string | false | undefined; + icon?: string | undefined; }; dataRefs: { title: ConfigurableExtensionDataRef< @@ -535,8 +535,8 @@ export const EntityIconLinkBlueprint: ExtensionBlueprint<{ }; configInput: { filter?: FilterPredicate | undefined; - title?: string | undefined; label?: string | undefined; + title?: string | undefined; }; dataRefs: { useProps: ConfigurableExtensionDataRef< @@ -561,8 +561,9 @@ export const EntityIconLinkBlueprint: ExtensionBlueprint<{ export const EntityTableColumnTitle: ( input: EntityTableColumnTitleProps, ) => - | 'Domain' | 'System' + | 'Title' + | 'Domain' | 'Lifecycle' | 'Namespace' | 'Owner' @@ -571,7 +572,6 @@ export const EntityTableColumnTitle: ( | 'Name' | 'Description' | 'Targets' - | 'Title' | 'Label'; // @alpha (undocumented) diff --git a/plugins/catalog-unprocessed-entities/report-alpha.api.md b/plugins/catalog-unprocessed-entities/report-alpha.api.md index 2d254d15ac..7408831f87 100644 --- a/plugins/catalog-unprocessed-entities/report-alpha.api.md +++ b/plugins/catalog-unprocessed-entities/report-alpha.api.md @@ -70,8 +70,8 @@ const _default: OverridableFrontendPlugin< title: string | undefined; }; configInput: { - path?: string | undefined; title?: string | undefined; + path?: string | undefined; }; output: | ExtensionDataRef @@ -151,8 +151,8 @@ export const unprocessedEntitiesDevToolsContent: OverridableExtensionDefinition< title: string | undefined; }; configInput: { - path?: string | undefined; title?: string | undefined; + path?: string | undefined; }; output: | ExtensionDataRef diff --git a/plugins/catalog/report-alpha.api.md b/plugins/catalog/report-alpha.api.md index d77b673744..a0b092a453 100644 --- a/plugins/catalog/report-alpha.api.md +++ b/plugins/catalog/report-alpha.api.md @@ -46,8 +46,8 @@ export const catalogTranslationRef: TranslationRef< readonly 'indexPage.supportButtonContent': 'All your software catalog entities'; readonly 'entityPage.notFoundMessage': 'There is no {{kind}} with the requested {{link}}.'; readonly 'entityPage.notFoundLinkText': 'kind, namespace, and name'; - readonly 'aboutCard.unknown': 'unknown'; readonly 'aboutCard.title': 'About'; + readonly 'aboutCard.unknown': 'unknown'; readonly 'aboutCard.refreshButtonTitle': 'Schedule entity refresh'; readonly 'aboutCard.editButtonTitle': 'Edit Metadata'; readonly 'aboutCard.editButtonAriaLabel': 'Edit'; @@ -72,8 +72,8 @@ export const catalogTranslationRef: TranslationRef< readonly 'aboutCard.tagsField.value': 'No Tags'; readonly 'aboutCard.tagsField.label': 'Tags'; readonly 'aboutCard.targetsField.label': 'Targets'; - readonly 'searchResultItem.kind': 'Kind'; readonly 'searchResultItem.type': 'Type'; + readonly 'searchResultItem.kind': 'Kind'; readonly 'searchResultItem.owner': 'Owner'; readonly 'searchResultItem.lifecycle': 'Lifecycle'; readonly 'catalogTable.allFilters': 'All'; @@ -308,11 +308,11 @@ const _default: OverridableFrontendPlugin< 'entity-card:catalog/about': OverridableExtensionDefinition<{ config: { filter: FilterPredicate | undefined; - type: 'info' | 'content' | undefined; + type: 'content' | 'info' | undefined; }; configInput: { filter?: FilterPredicate | undefined; - type?: 'info' | 'content' | undefined; + type?: 'content' | 'info' | undefined; }; output: | ExtensionDataRef @@ -378,11 +378,11 @@ const _default: OverridableFrontendPlugin< name: 'depends-on-components'; config: { filter: FilterPredicate | undefined; - type: 'info' | 'content' | undefined; + type: 'content' | 'info' | undefined; }; configInput: { filter?: FilterPredicate | undefined; - type?: 'info' | 'content' | undefined; + type?: 'content' | 'info' | undefined; }; output: | ExtensionDataRef @@ -419,11 +419,11 @@ const _default: OverridableFrontendPlugin< name: 'depends-on-resources'; config: { filter: FilterPredicate | undefined; - type: 'info' | 'content' | undefined; + type: 'content' | 'info' | undefined; }; configInput: { filter?: FilterPredicate | undefined; - type?: 'info' | 'content' | undefined; + type?: 'content' | 'info' | undefined; }; output: | ExtensionDataRef @@ -460,11 +460,11 @@ const _default: OverridableFrontendPlugin< name: 'has-components'; config: { filter: FilterPredicate | undefined; - type: 'info' | 'content' | undefined; + type: 'content' | 'info' | undefined; }; configInput: { filter?: FilterPredicate | undefined; - type?: 'info' | 'content' | undefined; + type?: 'content' | 'info' | undefined; }; output: | ExtensionDataRef @@ -501,11 +501,11 @@ const _default: OverridableFrontendPlugin< name: 'has-resources'; config: { filter: FilterPredicate | undefined; - type: 'info' | 'content' | undefined; + type: 'content' | 'info' | undefined; }; configInput: { filter?: FilterPredicate | undefined; - type?: 'info' | 'content' | undefined; + type?: 'content' | 'info' | undefined; }; output: | ExtensionDataRef @@ -542,11 +542,11 @@ const _default: OverridableFrontendPlugin< name: 'has-subcomponents'; config: { filter: FilterPredicate | undefined; - type: 'info' | 'content' | undefined; + type: 'content' | 'info' | undefined; }; configInput: { filter?: FilterPredicate | undefined; - type?: 'info' | 'content' | undefined; + type?: 'content' | 'info' | undefined; }; output: | ExtensionDataRef @@ -583,11 +583,11 @@ const _default: OverridableFrontendPlugin< name: 'has-subdomains'; config: { filter: FilterPredicate | undefined; - type: 'info' | 'content' | undefined; + type: 'content' | 'info' | undefined; }; configInput: { filter?: FilterPredicate | undefined; - type?: 'info' | 'content' | undefined; + type?: 'content' | 'info' | undefined; }; output: | ExtensionDataRef @@ -624,11 +624,11 @@ const _default: OverridableFrontendPlugin< name: 'has-systems'; config: { filter: FilterPredicate | undefined; - type: 'info' | 'content' | undefined; + type: 'content' | 'info' | undefined; }; configInput: { filter?: FilterPredicate | undefined; - type?: 'info' | 'content' | undefined; + type?: 'content' | 'info' | undefined; }; output: | ExtensionDataRef @@ -665,11 +665,11 @@ const _default: OverridableFrontendPlugin< name: 'labels'; config: { filter: FilterPredicate | undefined; - type: 'info' | 'content' | undefined; + type: 'content' | 'info' | undefined; }; configInput: { filter?: FilterPredicate | undefined; - type?: 'info' | 'content' | undefined; + type?: 'content' | 'info' | undefined; }; output: | ExtensionDataRef @@ -706,11 +706,11 @@ const _default: OverridableFrontendPlugin< name: 'links'; config: { filter: FilterPredicate | undefined; - type: 'info' | 'content' | undefined; + type: 'content' | 'info' | undefined; }; configInput: { filter?: FilterPredicate | undefined; - type?: 'info' | 'content' | undefined; + type?: 'content' | 'info' | undefined; }; output: | ExtensionDataRef @@ -752,10 +752,10 @@ const _default: OverridableFrontendPlugin< }; configInput: { filter?: FilterPredicate | undefined; - path?: string | undefined; title?: string | undefined; - icon?: string | undefined; + path?: string | undefined; group?: string | false | undefined; + icon?: string | undefined; }; output: | ExtensionDataRef @@ -941,8 +941,8 @@ const _default: OverridableFrontendPlugin< }; configInput: { filter?: FilterPredicate | undefined; - title?: string | undefined; label?: string | undefined; + title?: string | undefined; }; output: | ExtensionDataRef< @@ -996,7 +996,7 @@ const _default: OverridableFrontendPlugin< pagination: | boolean | { - mode: 'cursor' | 'offset'; + mode: 'offset' | 'cursor'; offset?: number | undefined; limit?: number | undefined; }; @@ -1007,13 +1007,13 @@ const _default: OverridableFrontendPlugin< pagination?: | boolean | { - mode: 'cursor' | 'offset'; + mode: 'offset' | 'cursor'; offset?: number | undefined; limit?: number | undefined; } | undefined; - path?: string | undefined; title?: string | undefined; + path?: string | undefined; }; output: | ExtensionDataRef @@ -1122,8 +1122,8 @@ const _default: OverridableFrontendPlugin< | undefined; defaultContentOrder?: 'title' | 'natural' | undefined; showNavItemIcons?: boolean | undefined; - path?: string | undefined; title?: string | undefined; + path?: string | undefined; }; output: | ExtensionDataRef diff --git a/plugins/catalog/report.api.md b/plugins/catalog/report.api.md index 6d482004bf..48e3f53de2 100644 --- a/plugins/catalog/report.api.md +++ b/plugins/catalog/report.api.md @@ -455,7 +455,7 @@ export function EntityRelationWarning(): JSX_2.Element | null; // @public (undocumented) export const EntitySwitch: { - (props: EntitySwitchProps): JSX_2.Element; + (props: EntitySwitchProps): JSX.Element; Case: (_props: EntitySwitchCaseProps) => null; }; diff --git a/plugins/devtools-react/report.api.md b/plugins/devtools-react/report.api.md index 5fb187ee6a..6916afed84 100644 --- a/plugins/devtools-react/report.api.md +++ b/plugins/devtools-react/report.api.md @@ -30,8 +30,8 @@ export const DevToolsContentBlueprint: ExtensionBlueprint<{ title: string | undefined; }; configInput: { - path?: string | undefined; title?: string | undefined; + path?: string | undefined; }; dataRefs: never; }>; diff --git a/plugins/devtools/report-alpha.api.md b/plugins/devtools/report-alpha.api.md index 7be1867d5d..753875eaaa 100644 --- a/plugins/devtools/report-alpha.api.md +++ b/plugins/devtools/report-alpha.api.md @@ -67,8 +67,8 @@ const _default: OverridableFrontendPlugin< title: string | undefined; }; configInput: { - path?: string | undefined; title?: string | undefined; + path?: string | undefined; }; output: | ExtensionDataRef diff --git a/plugins/home/report-alpha.api.md b/plugins/home/report-alpha.api.md index e1cc4f8f62..0269f471fe 100644 --- a/plugins/home/report-alpha.api.md +++ b/plugins/home/report-alpha.api.md @@ -108,8 +108,8 @@ const _default: OverridableFrontendPlugin< title: string | undefined; }; configInput: { - path?: string | undefined; title?: string | undefined; + path?: string | undefined; }; output: | ExtensionDataRef @@ -224,9 +224,9 @@ export const homeTranslationRef: TranslationRef< readonly 'widgetSettingsOverlay.deleteWidgetTooltip': 'Delete widget'; readonly 'widgetSettingsOverlay.submitButtonTitle': 'Submit'; readonly 'starredEntityListItem.removeFavoriteEntityTitle': 'Remove entity from favorites'; - readonly 'visitList.few.title': 'The more pages you visit, the more pages will appear here.'; readonly 'visitList.empty.title': 'There are no visits to show yet.'; readonly 'visitList.empty.description': 'Once you start using Backstage, your visits will appear here as a quick link to carry on where you left off.'; + readonly 'visitList.few.title': 'The more pages you visit, the more pages will appear here.'; readonly 'quickStart.title': 'Onboarding'; readonly 'quickStart.description': 'Get started with Backstage'; readonly 'quickStart.learnMoreLinkTitle': 'Learn more'; diff --git a/plugins/kubernetes-react/report-alpha.api.md b/plugins/kubernetes-react/report-alpha.api.md index adb92a772b..f638bf6789 100644 --- a/plugins/kubernetes-react/report-alpha.api.md +++ b/plugins/kubernetes-react/report-alpha.api.md @@ -24,9 +24,6 @@ export const kubernetesReactTranslationRef: TranslationRef< readonly 'cluster.noPodsWithErrors': 'No pods with errors'; readonly 'pods.pods_one': '{{count}} pod'; readonly 'pods.pods_other': '{{count}} pods'; - readonly 'podsTable.unknown': 'unknown'; - readonly 'podsTable.status.running': 'Running'; - readonly 'podsTable.status.ok': 'OK'; readonly 'podsTable.columns.name': 'name'; readonly 'podsTable.columns.id': 'ID'; readonly 'podsTable.columns.status': 'status'; @@ -35,6 +32,9 @@ export const kubernetesReactTranslationRef: TranslationRef< readonly 'podsTable.columns.totalRestarts': 'total restarts'; readonly 'podsTable.columns.cpuUsage': 'CPU usage %'; readonly 'podsTable.columns.memoryUsage': 'Memory usage %'; + readonly 'podsTable.unknown': 'unknown'; + readonly 'podsTable.status.running': 'Running'; + readonly 'podsTable.status.ok': 'OK'; readonly 'errorPanel.message': 'There was a problem retrieving some Kubernetes resources for the entity: {{entityName}}. This could mean that the Error Reporting card is not completely accurate.'; readonly 'errorPanel.title': 'There was a problem retrieving Kubernetes objects'; readonly 'errorPanel.errorsLabel': 'Errors'; @@ -65,12 +65,12 @@ export const kubernetesReactTranslationRef: TranslationRef< readonly 'hpa.currentCpuUsageLabel': 'current CPU usage: {{value}}%'; readonly 'hpa.targetCpuUsage': 'target CPU usage:'; readonly 'hpa.targetCpuUsageLabel': 'target CPU usage: {{value}}%'; - readonly 'errorReporting.title': 'Error Reporting'; readonly 'errorReporting.columns.name': 'name'; readonly 'errorReporting.columns.kind': 'kind'; readonly 'errorReporting.columns.namespace': 'namespace'; readonly 'errorReporting.columns.messages': 'messages'; readonly 'errorReporting.columns.cluster': 'cluster'; + readonly 'errorReporting.title': 'Error Reporting'; readonly 'podLogs.title': 'No logs emitted'; readonly 'podLogs.description': 'No logs were emitted by the container'; readonly 'podLogs.buttonText': 'Logs'; diff --git a/plugins/kubernetes/report-alpha.api.md b/plugins/kubernetes/report-alpha.api.md index 25090f7431..7086976724 100644 --- a/plugins/kubernetes/report-alpha.api.md +++ b/plugins/kubernetes/report-alpha.api.md @@ -102,10 +102,10 @@ const _default: OverridableFrontendPlugin< }; configInput: { filter?: FilterPredicate | undefined; - path?: string | undefined; title?: string | undefined; - icon?: string | undefined; + path?: string | undefined; group?: string | false | undefined; + icon?: string | undefined; }; output: | ExtensionDataRef @@ -168,8 +168,8 @@ const _default: OverridableFrontendPlugin< title: string | undefined; }; configInput: { - path?: string | undefined; title?: string | undefined; + path?: string | undefined; }; output: | ExtensionDataRef diff --git a/plugins/mui-to-bui/report.api.md b/plugins/mui-to-bui/report.api.md index c1c97a49a2..f4bf566c9e 100644 --- a/plugins/mui-to-bui/report.api.md +++ b/plugins/mui-to-bui/report.api.md @@ -42,8 +42,8 @@ const _default: OverridableFrontendPlugin< title: string | undefined; }; configInput: { - path?: string | undefined; title?: string | undefined; + path?: string | undefined; }; output: | ExtensionDataRef diff --git a/plugins/notifications/report-alpha.api.md b/plugins/notifications/report-alpha.api.md index ae826a05a1..e1feed0cde 100644 --- a/plugins/notifications/report-alpha.api.md +++ b/plugins/notifications/report-alpha.api.md @@ -48,8 +48,8 @@ const _default: OverridableFrontendPlugin< title: string | undefined; }; configInput: { - path?: string | undefined; title?: string | undefined; + path?: string | undefined; }; output: | ExtensionDataRef @@ -124,13 +124,13 @@ export default _default; export const notificationsTranslationRef: TranslationRef< 'plugin.notifications', { + readonly 'table.errors.markAllReadFailed': 'Failed to mark all notifications as read'; 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 'table.errors.markAllReadFailed': 'Failed to mark all notifications as read'; readonly 'table.emptyMessage': 'No records to display'; readonly 'table.bulkActions.markAllRead': 'Mark all read'; readonly 'table.bulkActions.markSelectedAsRead': 'Mark selected as read'; @@ -140,16 +140,16 @@ export const notificationsTranslationRef: TranslationRef< readonly 'table.confirmDialog.title': 'Are you sure?'; readonly 'table.confirmDialog.markAllReadDescription': 'Mark all notifications as read.'; readonly 'table.confirmDialog.markAllReadConfirmation': 'Mark All'; - readonly 'filters.title': 'Filters'; - readonly 'filters.view.label': 'View'; readonly 'filters.view.all': 'All'; + readonly 'filters.view.label': 'View'; readonly 'filters.view.read': 'Read notifications'; readonly 'filters.view.saved': 'Saved'; readonly 'filters.view.unread': 'Unread notifications'; + readonly 'filters.title': 'Filters'; readonly 'filters.severity.normal': 'Normal'; - readonly 'filters.severity.label': 'Min severity'; readonly 'filters.severity.high': 'High'; readonly 'filters.severity.low': 'Low'; + readonly 'filters.severity.label': 'Min severity'; readonly 'filters.severity.critical': 'Critical'; readonly 'filters.topic.label': 'Topic'; readonly 'filters.topic.anyTopic': 'Any topic'; @@ -161,12 +161,12 @@ export const notificationsTranslationRef: TranslationRef< readonly 'filters.sortBy.origin': 'Origin'; readonly 'filters.sortBy.label': 'Sort by'; readonly 'filters.sortBy.placeholder': 'Field to sort by'; - readonly 'filters.sortBy.topic': 'Topic'; readonly 'filters.sortBy.newest': 'Newest on top'; readonly 'filters.sortBy.oldest': 'Oldest on top'; - readonly 'settings.title': 'Notification settings'; + readonly 'filters.sortBy.topic': 'Topic'; readonly 'settings.table.origin': 'Origin'; readonly 'settings.table.topic': 'Topic'; + readonly 'settings.title': 'Notification settings'; readonly 'settings.errors.useNotificationFormat': 'useNotificationFormat must be used within a NotificationFormatProvider'; readonly 'settings.errorTitle': 'Failed to load settings'; readonly 'settings.noSettingsAvailable': 'No notification settings available, check back later'; diff --git a/plugins/org/report-alpha.api.md b/plugins/org/report-alpha.api.md index 76bbc52da2..44fd429bfa 100644 --- a/plugins/org/report-alpha.api.md +++ b/plugins/org/report-alpha.api.md @@ -25,11 +25,11 @@ const _default: OverridableFrontendPlugin< name: 'group-profile'; config: { filter: FilterPredicate | undefined; - type: 'info' | 'content' | undefined; + type: 'content' | 'info' | undefined; }; configInput: { filter?: FilterPredicate | undefined; - type?: 'info' | 'content' | undefined; + type?: 'content' | 'info' | undefined; }; output: | ExtensionDataRef @@ -66,13 +66,13 @@ const _default: OverridableFrontendPlugin< initialRelationAggregation: 'direct' | 'aggregated' | undefined; showAggregateMembersToggle: boolean | undefined; filter: FilterPredicate | undefined; - type: 'info' | 'content' | undefined; + type: 'content' | 'info' | undefined; }; configInput: { showAggregateMembersToggle?: boolean | undefined; initialRelationAggregation?: 'direct' | 'aggregated' | undefined; filter?: FilterPredicate | undefined; - type?: 'info' | 'content' | undefined; + type?: 'content' | 'info' | undefined; }; output: | ExtensionDataRef @@ -112,14 +112,14 @@ const _default: OverridableFrontendPlugin< showAggregateMembersToggle: boolean | undefined; ownedKinds: string[] | undefined; filter: FilterPredicate | undefined; - type: 'info' | 'content' | undefined; + type: 'content' | 'info' | undefined; }; configInput: { showAggregateMembersToggle?: boolean | undefined; initialRelationAggregation?: 'direct' | 'aggregated' | undefined; ownedKinds?: string[] | undefined; filter?: FilterPredicate | undefined; - type?: 'info' | 'content' | undefined; + type?: 'content' | 'info' | undefined; }; output: | ExtensionDataRef @@ -158,13 +158,13 @@ const _default: OverridableFrontendPlugin< maxRelations: number | undefined; hideIcons: boolean; filter: FilterPredicate | undefined; - type: 'info' | 'content' | undefined; + type: 'content' | 'info' | undefined; }; configInput: { hideIcons?: boolean | undefined; maxRelations?: number | undefined; filter?: FilterPredicate | undefined; - type?: 'info' | 'content' | undefined; + type?: 'content' | 'info' | undefined; }; output: | ExtensionDataRef diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/report.api.md b/plugins/scaffolder-backend-module-bitbucket-cloud/report.api.md index a7050aa68c..af34609213 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/report.api.md +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/report.api.md @@ -25,25 +25,25 @@ export const createBitbucketPipelinesRunAction: (options: { | { type?: string | undefined; source?: string | undefined; - commit?: - | { - type: string; - hash: string; - } - | undefined; selector?: | { type: string; pattern: string; } | undefined; - ref_name?: string | undefined; - destination?: string | undefined; pull_request?: | { id: string; } | undefined; + commit?: + | { + type: string; + hash: string; + } + | undefined; + destination?: string | undefined; + ref_name?: string | undefined; ref_type?: string | undefined; destination_commit?: | { @@ -54,8 +54,8 @@ export const createBitbucketPipelinesRunAction: (options: { | undefined; variables?: | { - value: string; key: string; + value: string; secured: boolean; }[] | undefined; @@ -80,7 +80,7 @@ export function createPublishBitbucketCloudAction(options: { repoUrl: string; description?: string | undefined; defaultBranch?: string | undefined; - repoVisibility?: 'public' | 'private' | undefined; + repoVisibility?: 'private' | 'public' | undefined; gitCommitMessage?: string | undefined; sourcePath?: string | undefined; token?: string | undefined; diff --git a/plugins/scaffolder-backend-module-bitbucket-server/report.api.md b/plugins/scaffolder-backend-module-bitbucket-server/report.api.md index a52bee490e..929eb165c4 100644 --- a/plugins/scaffolder-backend-module-bitbucket-server/report.api.md +++ b/plugins/scaffolder-backend-module-bitbucket-server/report.api.md @@ -20,7 +20,7 @@ export function createPublishBitbucketServerAction(options: { { repoUrl: string; description?: string | undefined; - repoVisibility?: 'public' | 'private' | undefined; + repoVisibility?: 'private' | 'public' | undefined; defaultBranch?: string | undefined; sourcePath?: string | undefined; enableLFS?: boolean | undefined; diff --git a/plugins/scaffolder-backend-module-gitea/report.api.md b/plugins/scaffolder-backend-module-gitea/report.api.md index 8a0393abef..afc8286365 100644 --- a/plugins/scaffolder-backend-module-gitea/report.api.md +++ b/plugins/scaffolder-backend-module-gitea/report.api.md @@ -17,7 +17,7 @@ export function createPublishGiteaAction(options: { repoUrl: string; description: string; defaultBranch?: string | undefined; - repoVisibility?: 'public' | 'private' | undefined; + repoVisibility?: 'private' | 'public' | undefined; gitCommitMessage?: string | undefined; gitAuthorName?: string | undefined; gitAuthorEmail?: string | undefined; diff --git a/plugins/scaffolder-backend-module-github/report.api.md b/plugins/scaffolder-backend-module-github/report.api.md index 3dbc72b57b..9e930a7866 100644 --- a/plugins/scaffolder-backend-module-github/report.api.md +++ b/plugins/scaffolder-backend-module-github/report.api.md @@ -67,14 +67,14 @@ export function createGithubBranchProtectionAction(options: { bypassPullRequestAllowances?: | { apps?: string[] | undefined; - users?: string[] | undefined; teams?: string[] | undefined; + users?: string[] | undefined; } | undefined; restrictions?: | { - users: string[]; teams: string[]; + users: string[]; apps?: string[] | undefined; } | undefined; @@ -242,8 +242,8 @@ export function createGithubRepoCreateAction(options: { bypassPullRequestAllowances?: | { apps?: string[] | undefined; - users?: string[] | undefined; teams?: string[] | undefined; + users?: string[] | undefined; } | undefined; collaborators?: @@ -279,7 +279,7 @@ export function createGithubRepoCreateAction(options: { protectDefaultBranch?: boolean | undefined; protectEnforceAdmins?: boolean | undefined; repoVariables?: Record | undefined; - repoVisibility?: 'public' | 'internal' | 'private' | undefined; + repoVisibility?: 'internal' | 'private' | 'public' | undefined; requireBranchesToBeUpToDate?: boolean | undefined; requireCodeOwnerReviews?: boolean | undefined; requiredApprovingReviewCount?: number | undefined; @@ -290,8 +290,8 @@ export function createGithubRepoCreateAction(options: { requireLastPushApproval?: boolean | undefined; restrictions?: | { - users: string[]; teams: string[]; + users: string[]; apps?: string[] | undefined; } | undefined; @@ -306,7 +306,7 @@ export function createGithubRepoCreateAction(options: { subscribe?: boolean | undefined; token?: string | undefined; topics?: string[] | undefined; - workflowAccess?: 'none' | 'user' | 'organization' | undefined; + workflowAccess?: 'none' | 'organization' | 'user' | undefined; }, { remoteUrl: string; @@ -329,15 +329,15 @@ export function createGithubRepoPushAction(options: { bypassPullRequestAllowances?: | { apps?: string[] | undefined; - users?: string[] | undefined; teams?: string[] | undefined; + users?: string[] | undefined; } | undefined; requiredApprovingReviewCount?: number | undefined; restrictions?: | { - users: string[]; teams: string[]; + users: string[]; apps?: string[] | undefined; } | undefined; @@ -399,15 +399,15 @@ export function createPublishGithubAction(options: { bypassPullRequestAllowances?: | { apps?: string[] | undefined; - users?: string[] | undefined; teams?: string[] | undefined; + users?: string[] | undefined; } | undefined; requiredApprovingReviewCount?: number | undefined; restrictions?: | { - users: string[]; teams: string[]; + users: string[]; apps?: string[] | undefined; } | undefined; @@ -417,7 +417,7 @@ export function createPublishGithubAction(options: { requireBranchesToBeUpToDate?: boolean | undefined; requiredConversationResolution?: boolean | undefined; requireLastPushApproval?: boolean | undefined; - repoVisibility?: 'public' | 'internal' | 'private' | undefined; + repoVisibility?: 'internal' | 'private' | 'public' | undefined; defaultBranch?: string | undefined; protectDefaultBranch?: boolean | undefined; protectEnforceAdmins?: boolean | undefined; diff --git a/plugins/scaffolder-backend-module-gitlab/report.api.md b/plugins/scaffolder-backend-module-gitlab/report.api.md index 5f119024b7..cbe31b02f3 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' | 'create' | 'update' | 'delete' | undefined; + commitAction?: 'auto' | 'update' | 'delete' | 'create' | undefined; }, { projectid: string; @@ -193,7 +193,7 @@ export function createPublishGitlabAction(options: { }): TemplateAction< { repoUrl: string; - repoVisibility?: 'public' | 'internal' | 'private' | undefined; + repoVisibility?: 'internal' | 'private' | 'public' | undefined; defaultBranch?: string | undefined; gitCommitMessage?: string | undefined; gitAuthorName?: string | undefined; @@ -206,24 +206,24 @@ export function createPublishGitlabAction(options: { topics?: string[] | undefined; settings?: | { + visibility?: 'internal' | 'private' | 'public' | undefined; path?: string | undefined; description?: string | undefined; - visibility?: 'public' | 'internal' | 'private' | undefined; - topics?: string[] | undefined; merge_method?: 'merge' | 'rebase_merge' | 'ff' | undefined; + topics?: string[] | undefined; auto_devops_enabled?: boolean | undefined; - ci_config_path?: string | undefined; - squash_option?: - | 'never' - | 'always' - | 'default_off' - | 'default_on' - | undefined; + only_allow_merge_if_pipeline_succeeds?: boolean | undefined; + allow_merge_on_skipped_pipeline?: boolean | undefined; only_allow_merge_if_all_discussions_are_resolved?: | boolean | undefined; - only_allow_merge_if_pipeline_succeeds?: boolean | undefined; - allow_merge_on_skipped_pipeline?: boolean | undefined; + squash_option?: + | 'always' + | 'never' + | 'default_on' + | 'default_off' + | undefined; + ci_config_path?: string | undefined; } | undefined; branches?: @@ -236,15 +236,15 @@ export function createPublishGitlabAction(options: { | undefined; projectVariables?: | { - value: string; key: string; + value: string; + raw?: boolean | undefined; description?: string | undefined; protected?: boolean | undefined; - raw?: boolean | undefined; variable_type?: 'file' | 'env_var' | undefined; masked?: boolean | undefined; - masked_and_hidden?: boolean | undefined; environment_scope?: string | undefined; + masked_and_hidden?: boolean | undefined; }[] | undefined; }, @@ -271,7 +271,7 @@ export const createPublishGitlabMergeRequestAction: (options: { sourcePath?: string | undefined; targetPath?: string | undefined; token?: string | undefined; - commitAction?: 'auto' | 'create' | 'update' | 'delete' | 'skip' | undefined; + commitAction?: 'auto' | 'update' | 'delete' | 'create' | 'skip' | undefined; projectid?: string | undefined; removeSourceBranch?: boolean | undefined; assignee?: string | undefined; diff --git a/plugins/scaffolder-backend-module-rails/report.api.md b/plugins/scaffolder-backend-module-rails/report.api.md index 8efc166c97..b09b75591b 100644 --- a/plugins/scaffolder-backend-module-rails/report.api.md +++ b/plugins/scaffolder-backend-module-rails/report.api.md @@ -47,8 +47,9 @@ export function createFetchRailsAction(options: { values: { railsArguments?: | { - api?: boolean | undefined; template?: string | undefined; + api?: boolean | undefined; + force?: boolean | undefined; database?: | 'sqlite3' | 'mysql' @@ -60,7 +61,6 @@ export function createFetchRailsAction(options: { | 'jdbcpostgresql' | 'jdbc' | undefined; - force?: boolean | undefined; minimal?: boolean | undefined; railsVersion?: 'edge' | 'master' | 'dev' | 'fromImage' | undefined; skipActionCable?: boolean | undefined; diff --git a/plugins/scaffolder-backend/report.api.md b/plugins/scaffolder-backend/report.api.md index f203dd2faa..8f9249cc5e 100644 --- a/plugins/scaffolder-backend/report.api.md +++ b/plugins/scaffolder-backend/report.api.md @@ -217,8 +217,8 @@ export const createFilesystemReadDirAction: () => TemplateAction< export const createFilesystemRenameAction: () => TemplateAction< { files: { - to: string; from: string; + to: string; overwrite?: boolean | undefined; }[]; }, diff --git a/plugins/scaffolder-react/report-alpha.api.md b/plugins/scaffolder-react/report-alpha.api.md index 609debaca2..9e01257c5c 100644 --- a/plugins/scaffolder-react/report-alpha.api.md +++ b/plugins/scaffolder-react/report-alpha.api.md @@ -318,8 +318,8 @@ export const scaffolderReactTranslationRef: TranslationRef< readonly 'stepper.reviewButtonText': 'Review'; readonly 'stepper.stepIndexLabel': 'Step {{index, number}}'; readonly 'passwordWidget.content': 'This widget is insecure. Please use [`ui:field: Secret`](https://backstage.io/docs/features/software-templates/writing-templates/#using-secrets) instead of `ui:widget: password`'; - readonly 'scaffolderPageContextMenu.moreLabel': 'more'; readonly 'scaffolderPageContextMenu.createLabel': 'Create'; + readonly 'scaffolderPageContextMenu.moreLabel': 'more'; readonly 'scaffolderPageContextMenu.editorLabel': 'Manage Templates'; readonly 'scaffolderPageContextMenu.actionsLabel': 'Installed Actions'; readonly 'scaffolderPageContextMenu.tasksLabel': 'Task List'; diff --git a/plugins/scaffolder/report-alpha.api.md b/plugins/scaffolder/report-alpha.api.md index 87cb54d687..b2cd1bbcbe 100644 --- a/plugins/scaffolder/report-alpha.api.md +++ b/plugins/scaffolder/report-alpha.api.md @@ -144,8 +144,8 @@ const _default: OverridableFrontendPlugin< }; configInput: { filter?: FilterPredicate | undefined; - title?: string | undefined; label?: string | undefined; + title?: string | undefined; }; output: | ExtensionDataRef< @@ -200,8 +200,8 @@ const _default: OverridableFrontendPlugin< title: string | undefined; }; configInput: { - path?: string | undefined; title?: string | undefined; + path?: string | undefined; }; output: | ExtensionDataRef @@ -572,24 +572,24 @@ export const scaffolderTranslationRef: TranslationRef< readonly 'fields.repoOwnerPicker.title': 'Owner'; readonly 'fields.repoOwnerPicker.description': 'The owner of the repository'; readonly 'aboutCard.launchTemplate': 'Launch Template'; - readonly 'actionsPage.title': 'Installed actions'; - readonly 'actionsPage.action.output': 'Output'; - readonly 'actionsPage.action.input': 'Input'; - readonly 'actionsPage.action.examples': 'Examples'; readonly 'actionsPage.content.emptyState.title': 'No information to display'; readonly 'actionsPage.content.emptyState.description': 'There are no actions installed or there was an issue communicating with backend.'; readonly 'actionsPage.content.searchFieldPlaceholder': 'Search for an action'; + readonly 'actionsPage.title': 'Installed actions'; + readonly 'actionsPage.action.input': 'Input'; + readonly 'actionsPage.action.output': 'Output'; + readonly 'actionsPage.action.examples': 'Examples'; readonly 'actionsPage.subtitle': 'This is the collection of all installed actions'; readonly 'actionsPage.pageTitle': 'Create a New Component'; - readonly 'listTaskPage.title': 'List template tasks'; readonly 'listTaskPage.content.emptyState.title': 'No information to display'; readonly 'listTaskPage.content.emptyState.description': 'There are no tasks or there was an issue communicating with backend.'; - readonly 'listTaskPage.content.tableTitle': 'Tasks'; - readonly 'listTaskPage.content.tableCell.status': 'Status'; readonly 'listTaskPage.content.tableCell.template': 'Template'; + readonly 'listTaskPage.content.tableCell.status': 'Status'; readonly 'listTaskPage.content.tableCell.owner': 'Owner'; readonly 'listTaskPage.content.tableCell.created': 'Created'; readonly 'listTaskPage.content.tableCell.taskID': 'Task ID'; + readonly 'listTaskPage.content.tableTitle': 'Tasks'; + readonly 'listTaskPage.title': 'List template tasks'; readonly 'listTaskPage.subtitle': 'All tasks that have been started'; readonly 'listTaskPage.pageTitle': 'Templates Tasks'; readonly 'ownerListPicker.title': 'Task Owner'; @@ -614,28 +614,28 @@ export const scaffolderTranslationRef: TranslationRef< readonly 'templateEditorForm.stepper.emptyText': 'There are no spec parameters in the template to preview.'; readonly 'renderSchema.undefined': 'No schema defined'; readonly 'renderSchema.tableCell.name': 'Name'; + readonly 'renderSchema.tableCell.type': 'Type'; readonly 'renderSchema.tableCell.title': 'Title'; readonly 'renderSchema.tableCell.description': 'Description'; - readonly 'renderSchema.tableCell.type': 'Type'; - readonly 'templatingExtensions.title': 'Templating Extensions'; readonly 'templatingExtensions.content.values.title': 'Values'; readonly 'templatingExtensions.content.values.notAvailable': 'There are no global template values defined.'; readonly 'templatingExtensions.content.emptyState.title': 'No information to display'; readonly 'templatingExtensions.content.emptyState.description': 'There are no templating extensions available or there was an issue communicating with the backend.'; readonly 'templatingExtensions.content.filters.title': 'Filters'; - readonly 'templatingExtensions.content.filters.schema.output': 'Output'; readonly 'templatingExtensions.content.filters.schema.input': 'Input'; + readonly 'templatingExtensions.content.filters.schema.output': 'Output'; readonly 'templatingExtensions.content.filters.schema.arguments': 'Arguments'; readonly 'templatingExtensions.content.filters.examples': 'Examples'; readonly 'templatingExtensions.content.filters.notAvailable': 'There are no template filters defined.'; readonly 'templatingExtensions.content.filters.metadataAbsent': 'Filter metadata unavailable'; - readonly 'templatingExtensions.content.searchFieldPlaceholder': 'Search for an extension'; readonly 'templatingExtensions.content.functions.title': 'Functions'; readonly 'templatingExtensions.content.functions.schema.output': 'Output'; readonly 'templatingExtensions.content.functions.schema.arguments': 'Arguments'; readonly 'templatingExtensions.content.functions.examples': 'Examples'; readonly 'templatingExtensions.content.functions.notAvailable': 'There are no global template functions defined.'; readonly 'templatingExtensions.content.functions.metadataAbsent': 'Function metadata unavailable'; + readonly 'templatingExtensions.content.searchFieldPlaceholder': 'Search for an extension'; + readonly 'templatingExtensions.title': 'Templating Extensions'; readonly 'templatingExtensions.subtitle': 'This is the collection of available templating extensions'; readonly 'templatingExtensions.pageTitle': 'Templating Extensions'; readonly 'templateTypePicker.title': 'Categories'; @@ -699,12 +699,12 @@ export const scaffolderTranslationRef: TranslationRef< readonly 'templateEditorToolbar.addToCatalogDialogTitle': 'Publish changes'; readonly 'templateEditorToolbar.addToCatalogDialogContent.stepsIntroduction': 'Follow the instructions below to create or update a template:'; readonly 'templateEditorToolbar.addToCatalogDialogContent.stepsListItems': 'Save the template files in a local directory\nCreate a pull request to a new or existing git repository\nIf the template already exists, the changes will be reflected in the software catalog once the pull request gets merged\nBut if you are creating a new template, follow the documentation linked below to register the new template repository in software catalog'; - readonly 'templateEditorToolbar.addToCatalogDialogActions.documentationButton': 'Go to the documentation'; readonly 'templateEditorToolbar.addToCatalogDialogActions.documentationUrl': 'https://backstage.io/docs/features/software-templates/adding-templates/'; + readonly 'templateEditorToolbar.addToCatalogDialogActions.documentationButton': 'Go to the documentation'; + readonly 'templateEditorToolbarFileMenu.button': 'File'; readonly 'templateEditorToolbarFileMenu.options.openDirectory': 'Open template directory'; readonly 'templateEditorToolbarFileMenu.options.createDirectory': 'Create template directory'; readonly 'templateEditorToolbarFileMenu.options.closeEditor': 'Close template editor'; - readonly 'templateEditorToolbarFileMenu.button': 'File'; readonly 'templateEditorToolbarTemplatesMenu.button': 'Templates'; } >; diff --git a/plugins/search-backend-module-elasticsearch/report.api.md b/plugins/search-backend-module-elasticsearch/report.api.md index f90dc2c0d9..afe06dd45e 100644 --- a/plugins/search-backend-module-elasticsearch/report.api.md +++ b/plugins/search-backend-module-elasticsearch/report.api.md @@ -7,8 +7,8 @@ import { ApiResponse } from '@opensearch-project/opensearch'; import { ApiResponse as ApiResponse_2 } from '@elastic/elasticsearch'; import { BackendFeature } from '@backstage/backend-plugin-api'; import { BatchSearchEngineIndexer } from '@backstage/plugin-search-backend-node'; -import { BulkHelper } from '@opensearch-project/opensearch/lib/Helpers.js'; -import { BulkStats } from '@opensearch-project/opensearch/lib/Helpers.js'; +import { BulkHelper } from '@elastic/elasticsearch/lib/Helpers'; +import { BulkStats } from '@elastic/elasticsearch/lib/Helpers'; import { Config } from '@backstage/config'; import type { ConnectionOptions } from 'node:tls'; import { ExtensionPoint } from '@backstage/backend-plugin-api'; diff --git a/plugins/search/report-alpha.api.md b/plugins/search/report-alpha.api.md index 9a31942231..e3f2d6108a 100644 --- a/plugins/search/report-alpha.api.md +++ b/plugins/search/report-alpha.api.md @@ -73,8 +73,8 @@ const _default: OverridableFrontendPlugin< }; configInput: { noTrack?: boolean | undefined; - path?: string | undefined; title?: string | undefined; + path?: string | undefined; }; output: | ExtensionDataRef @@ -242,8 +242,8 @@ export const searchPage: OverridableExtensionDefinition<{ }; configInput: { noTrack?: boolean | undefined; - path?: string | undefined; title?: string | undefined; + path?: string | undefined; }; output: | ExtensionDataRef diff --git a/plugins/techdocs/report-alpha.api.md b/plugins/techdocs/report-alpha.api.md index dc3b144665..aa14098288 100644 --- a/plugins/techdocs/report-alpha.api.md +++ b/plugins/techdocs/report-alpha.api.md @@ -133,10 +133,10 @@ const _default: OverridableFrontendPlugin< }; configInput: { filter?: FilterPredicate | undefined; - path?: string | undefined; title?: string | undefined; - icon?: string | undefined; + path?: string | undefined; group?: string | false | undefined; + icon?: string | undefined; }; output: | ExtensionDataRef @@ -230,8 +230,8 @@ const _default: OverridableFrontendPlugin< }; configInput: { filter?: FilterPredicate | undefined; - title?: string | undefined; label?: string | undefined; + title?: string | undefined; }; output: | ExtensionDataRef< @@ -288,8 +288,8 @@ const _default: OverridableFrontendPlugin< title: string | undefined; }; configInput: { - path?: string | undefined; title?: string | undefined; + path?: string | undefined; }; output: | ExtensionDataRef @@ -366,8 +366,8 @@ const _default: OverridableFrontendPlugin< configInput: { withoutSearch?: boolean | undefined; withoutHeader?: boolean | undefined; - path?: string | undefined; title?: string | undefined; + path?: string | undefined; }; output: | ExtensionDataRef diff --git a/plugins/user-settings/report-alpha.api.md b/plugins/user-settings/report-alpha.api.md index 35f7f1a262..c99709d777 100644 --- a/plugins/user-settings/report-alpha.api.md +++ b/plugins/user-settings/report-alpha.api.md @@ -50,8 +50,8 @@ const _default: OverridableFrontendPlugin< title: string | undefined; }; configInput: { - path?: string | undefined; title?: string | undefined; + path?: string | undefined; }; output: | ExtensionDataRef @@ -164,22 +164,22 @@ export const userSettingsTranslationRef: TranslationRef< readonly 'featureFlags.filterTitle': 'Filter'; readonly 'featureFlags.clearFilter': 'Clear filter'; readonly 'featureFlags.emptyFlags.title': 'No Feature Flags'; - readonly 'featureFlags.emptyFlags.description': 'Feature Flags make it possible for plugins to register features in Backstage for users to opt into. You can use this to split out logic in your code for manual A/B testing, etc.'; readonly 'featureFlags.emptyFlags.action.title': 'An example for how to add a feature flag is highlighted below:'; readonly 'featureFlags.emptyFlags.action.readMoreButtonTitle': 'Read More'; + readonly 'featureFlags.emptyFlags.description': 'Feature Flags make it possible for plugins to register features in Backstage for users to opt into. You can use this to split out logic in your code for manual A/B testing, etc.'; readonly 'featureFlags.flagItem.title.disable': 'Disable'; readonly 'featureFlags.flagItem.title.enable': 'Enable'; readonly 'featureFlags.flagItem.subtitle.registeredInApplication': 'Registered in the application'; readonly 'featureFlags.flagItem.subtitle.registeredInPlugin': 'Registered in {{pluginId}} plugin'; + readonly 'languageToggle.select': 'Select language {{language}}'; readonly 'languageToggle.title': 'Language'; readonly 'languageToggle.description': 'Change the language'; - readonly 'languageToggle.select': 'Select language {{language}}'; + readonly 'themeToggle.select': 'Select {{theme}}'; readonly 'themeToggle.title': 'Theme'; readonly 'themeToggle.description': 'Change the theme mode'; - readonly 'themeToggle.select': 'Select {{theme}}'; readonly 'themeToggle.names.auto': 'Auto'; - readonly 'themeToggle.names.light': 'Light'; readonly 'themeToggle.names.dark': 'Dark'; + readonly 'themeToggle.names.light': 'Light'; readonly 'themeToggle.selectAuto': 'Select Auto Theme'; readonly 'signOutMenu.title': 'Sign Out'; readonly 'signOutMenu.moreIconTitle': 'more'; @@ -194,9 +194,9 @@ export const userSettingsTranslationRef: TranslationRef< readonly 'identityCard.ownershipEntities': 'Ownership Entities'; readonly 'defaultProviderSettings.description': 'Provides authentication towards {{provider}} APIs and identities'; readonly 'emptyProviders.title': 'No Authentication Providers'; - readonly 'emptyProviders.description': 'You can add Authentication Providers to Backstage which allows you to use these providers to authenticate yourself.'; readonly 'emptyProviders.action.title': 'Open app-config.yaml and make the changes as highlighted below:'; readonly 'emptyProviders.action.readMoreButtonTitle': 'Read More'; + readonly 'emptyProviders.description': 'You can add Authentication Providers to Backstage which allows you to use these providers to authenticate yourself.'; readonly 'providerSettingsItem.title.signOut': 'Sign out from {{title}}'; readonly 'providerSettingsItem.title.signIn': 'Sign in to {{title}}'; readonly 'providerSettingsItem.buttonTitle.signOut': 'Sign out'; From cb8a487bde14788c5ce8a99f9c7ca9e096820c9d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Mar 2026 19:04:34 +0100 Subject: [PATCH 07/91] frontend-plugin-api: rely on ApiRef inference Remove explicit ApiRef constant annotations from frontend API ref declarations and rely on the createApiRef type argument for inference instead. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../src/apis/definitions/AlertApi.ts | 4 +- .../src/apis/definitions/AnalyticsApi.ts | 11 ++- .../src/apis/definitions/AppLanguageApi.ts | 11 ++- .../src/apis/definitions/AppThemeApi.ts | 11 ++- .../src/apis/definitions/ConfigApi.ts | 4 +- .../src/apis/definitions/DiscoveryApi.ts | 11 ++- .../src/apis/definitions/ErrorApi.ts | 4 +- .../src/apis/definitions/FeatureFlagsApi.ts | 11 ++- .../src/apis/definitions/FetchApi.ts | 4 +- .../src/apis/definitions/IdentityApi.ts | 11 ++- .../src/apis/definitions/OAuthRequestApi.ts | 11 ++- .../src/apis/definitions/StorageApi.ts | 11 ++- .../src/apis/definitions/TranslationApi.ts | 11 ++- .../src/apis/definitions/auth.ts | 70 ++++--------------- 14 files changed, 65 insertions(+), 120 deletions(-) diff --git a/packages/frontend-plugin-api/src/apis/definitions/AlertApi.ts b/packages/frontend-plugin-api/src/apis/definitions/AlertApi.ts index 09d979ad9a..3dd7c8e443 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/AlertApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/AlertApi.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { createApiRef, ApiRef } from '../system'; +import { createApiRef } from '../system'; import { Observable } from '@backstage/types'; /** @@ -51,7 +51,7 @@ export type AlertApi = { * * @public */ -export const alertApiRef: ApiRef = createApiRef().with({ +export const alertApiRef = createApiRef().with({ id: 'core.alert', pluginId: 'app', }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/AnalyticsApi.ts b/packages/frontend-plugin-api/src/apis/definitions/AnalyticsApi.ts index 51acc1851d..bd5d9a4394 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/AnalyticsApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/AnalyticsApi.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { ApiRef, createApiRef } from '../system'; +import { createApiRef } from '../system'; import { AnalyticsContextValue } from '../../analytics/types'; /** @@ -151,8 +151,7 @@ export type AnalyticsApi = { * * @public */ -export const analyticsApiRef: ApiRef = - createApiRef().with({ - id: 'core.analytics', - pluginId: 'app', - }); +export const analyticsApiRef = createApiRef().with({ + id: 'core.analytics', + pluginId: 'app', +}); diff --git a/packages/frontend-plugin-api/src/apis/definitions/AppLanguageApi.ts b/packages/frontend-plugin-api/src/apis/definitions/AppLanguageApi.ts index c4a1c8be73..1b1b7437b5 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/AppLanguageApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/AppLanguageApi.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { ApiRef, createApiRef } from '../system'; +import { createApiRef } from '../system'; import { Observable } from '@backstage/types'; /** @public */ @@ -31,8 +31,7 @@ export type AppLanguageApi = { /** * @public */ -export const appLanguageApiRef: ApiRef = - createApiRef().with({ - id: 'core.applanguage', - pluginId: 'app', - }); +export const appLanguageApiRef = createApiRef().with({ + id: 'core.applanguage', + pluginId: 'app', +}); diff --git a/packages/frontend-plugin-api/src/apis/definitions/AppThemeApi.ts b/packages/frontend-plugin-api/src/apis/definitions/AppThemeApi.ts index 39561f5f96..66669e8cab 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/AppThemeApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/AppThemeApi.ts @@ -15,7 +15,7 @@ */ import { ReactNode } from 'react'; -import { ApiRef, createApiRef } from '../system'; +import { createApiRef } from '../system'; import { Observable } from '@backstage/types'; /** @@ -82,8 +82,7 @@ export type AppThemeApi = { * * @public */ -export const appThemeApiRef: ApiRef = - createApiRef().with({ - id: 'core.apptheme', - pluginId: 'app', - }); +export const appThemeApiRef = createApiRef().with({ + id: 'core.apptheme', + pluginId: 'app', +}); diff --git a/packages/frontend-plugin-api/src/apis/definitions/ConfigApi.ts b/packages/frontend-plugin-api/src/apis/definitions/ConfigApi.ts index eb52c1cbca..8cc1638d90 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/ConfigApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/ConfigApi.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ApiRef, createApiRef } from '../system'; +import { createApiRef } from '../system'; import type { Config } from '@backstage/config'; /** @@ -29,7 +29,7 @@ export type ConfigApi = Config; * * @public */ -export const configApiRef: ApiRef = createApiRef().with({ +export const configApiRef = createApiRef().with({ id: 'core.config', pluginId: 'app', }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/DiscoveryApi.ts b/packages/frontend-plugin-api/src/apis/definitions/DiscoveryApi.ts index fb348c156d..824174c8f6 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/DiscoveryApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/DiscoveryApi.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ApiRef, createApiRef } from '../system'; +import { createApiRef } from '../system'; /** * The discovery API is used to provide a mechanism for plugins to @@ -50,8 +50,7 @@ export type DiscoveryApi = { * * @public */ -export const discoveryApiRef: ApiRef = - createApiRef().with({ - id: 'core.discovery', - pluginId: 'app', - }); +export const discoveryApiRef = createApiRef().with({ + id: 'core.discovery', + pluginId: 'app', +}); diff --git a/packages/frontend-plugin-api/src/apis/definitions/ErrorApi.ts b/packages/frontend-plugin-api/src/apis/definitions/ErrorApi.ts index d106ccc05c..e7be718326 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/ErrorApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/ErrorApi.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { ApiRef, createApiRef } from '../system'; +import { createApiRef } from '../system'; import { Observable } from '@backstage/types'; /** @@ -86,7 +86,7 @@ export type ErrorApi = { * * @public */ -export const errorApiRef: ApiRef = createApiRef().with({ +export const errorApiRef = createApiRef().with({ id: 'core.error', pluginId: 'app', }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/FeatureFlagsApi.ts b/packages/frontend-plugin-api/src/apis/definitions/FeatureFlagsApi.ts index 7206dd9900..b7d1c4ccaa 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/FeatureFlagsApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/FeatureFlagsApi.ts @@ -16,7 +16,7 @@ /* We want to maintain the same information as an enum, so we disable the redeclaration warning */ /* eslint-disable @typescript-eslint/no-redeclare */ -import { ApiRef, createApiRef } from '../system'; +import { createApiRef } from '../system'; /** * Feature flag descriptor. @@ -121,8 +121,7 @@ export interface FeatureFlagsApi { * * @public */ -export const featureFlagsApiRef: ApiRef = - createApiRef().with({ - id: 'core.featureflags', - pluginId: 'app', - }); +export const featureFlagsApiRef = createApiRef().with({ + id: 'core.featureflags', + pluginId: 'app', +}); diff --git a/packages/frontend-plugin-api/src/apis/definitions/FetchApi.ts b/packages/frontend-plugin-api/src/apis/definitions/FetchApi.ts index 4e4909b7d4..5299878c34 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/FetchApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/FetchApi.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { ApiRef, createApiRef } from '../system'; +import { createApiRef } from '../system'; /** * A wrapper for the fetch API, that has additional behaviors such as the @@ -46,7 +46,7 @@ export type FetchApi = { * * @public */ -export const fetchApiRef: ApiRef = createApiRef().with({ +export const fetchApiRef = createApiRef().with({ id: 'core.fetch', pluginId: 'app', }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/IdentityApi.ts b/packages/frontend-plugin-api/src/apis/definitions/IdentityApi.ts index dc23202c2e..70b6ebf56a 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/IdentityApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/IdentityApi.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ApiRef, createApiRef } from '../system'; +import { createApiRef } from '../system'; import { BackstageUserIdentity, ProfileInfo } from './auth'; /** @@ -51,8 +51,7 @@ export type IdentityApi = { * * @public */ -export const identityApiRef: ApiRef = - createApiRef().with({ - id: 'core.identity', - pluginId: 'app', - }); +export const identityApiRef = createApiRef().with({ + id: 'core.identity', + pluginId: 'app', +}); diff --git a/packages/frontend-plugin-api/src/apis/definitions/OAuthRequestApi.ts b/packages/frontend-plugin-api/src/apis/definitions/OAuthRequestApi.ts index 0c199948af..d94ab44d15 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/OAuthRequestApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/OAuthRequestApi.ts @@ -15,7 +15,7 @@ */ import { Observable } from '@backstage/types'; -import { ApiRef, createApiRef } from '../system'; +import { createApiRef } from '../system'; import { AuthProviderInfo } from './auth'; /** @@ -126,8 +126,7 @@ export type OAuthRequestApi = { * * @public */ -export const oauthRequestApiRef: ApiRef = - createApiRef().with({ - id: 'core.oauthrequest', - pluginId: 'app', - }); +export const oauthRequestApiRef = createApiRef().with({ + id: 'core.oauthrequest', + pluginId: 'app', +}); diff --git a/packages/frontend-plugin-api/src/apis/definitions/StorageApi.ts b/packages/frontend-plugin-api/src/apis/definitions/StorageApi.ts index 7e8372b6fb..1624f8402c 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/StorageApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/StorageApi.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { ApiRef, createApiRef } from '../system'; +import { createApiRef } from '../system'; import { JsonValue, Observable } from '@backstage/types'; /** @@ -105,8 +105,7 @@ export interface StorageApi { * * @public */ -export const storageApiRef: ApiRef = - createApiRef().with({ - id: 'core.storage', - pluginId: 'app', - }); +export const storageApiRef = createApiRef().with({ + id: 'core.storage', + pluginId: 'app', +}); diff --git a/packages/frontend-plugin-api/src/apis/definitions/TranslationApi.ts b/packages/frontend-plugin-api/src/apis/definitions/TranslationApi.ts index 6997269484..ab50baf5e1 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/TranslationApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/TranslationApi.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { ApiRef, createApiRef } from '../system'; +import { createApiRef } from '../system'; import { Expand, ExpandRecursive, Observable } from '@backstage/types'; import { TranslationRef } from '../../translation'; import { JSX } from 'react'; @@ -358,8 +358,7 @@ export type TranslationApi = { /** * @public */ -export const translationApiRef: ApiRef = - createApiRef().with({ - id: 'core.translation', - pluginId: 'app', - }); +export const translationApiRef = createApiRef().with({ + id: 'core.translation', + pluginId: 'app', +}); diff --git a/packages/frontend-plugin-api/src/apis/definitions/auth.ts b/packages/frontend-plugin-api/src/apis/definitions/auth.ts index 0c05047f24..d686ed5d08 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/auth.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/auth.ts @@ -16,7 +16,7 @@ /* We want to maintain the same information as an enum, so we disable the redeclaration warning */ /* eslint-disable @typescript-eslint/no-redeclare */ -import { ApiRef, createApiRef } from '../system'; +import { createApiRef } from '../system'; import { IconComponent, IconElement } from '../../icons/types'; import { Observable } from '@backstage/types'; @@ -336,13 +336,7 @@ export type SessionApi = { * Note that the ID token payload is only guaranteed to contain the user's numerical Google ID, * email and expiration information. Do not rely on any other fields, as they might not be present. */ -export const googleAuthApiRef: ApiRef< - OAuthApi & - OpenIdConnectApi & - ProfileInfoApi & - BackstageIdentityApi & - SessionApi -> = createApiRef< +export const googleAuthApiRef = createApiRef< OAuthApi & OpenIdConnectApi & ProfileInfoApi & @@ -362,9 +356,7 @@ export const googleAuthApiRef: ApiRef< * See {@link https://developer.github.com/apps/building-oauth-apps/understanding-scopes-for-oauth-apps/} * for a full list of supported scopes. */ -export const githubAuthApiRef: ApiRef< - OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi -> = createApiRef< +export const githubAuthApiRef = createApiRef< OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi >().with({ id: 'core.auth.github', @@ -380,13 +372,7 @@ export const githubAuthApiRef: ApiRef< * See {@link https://developer.okta.com/docs/guides/implement-oauth-for-okta/scopes/} * for a full list of supported scopes. */ -export const oktaAuthApiRef: ApiRef< - OAuthApi & - OpenIdConnectApi & - ProfileInfoApi & - BackstageIdentityApi & - SessionApi -> = createApiRef< +export const oktaAuthApiRef = createApiRef< OAuthApi & OpenIdConnectApi & ProfileInfoApi & @@ -406,13 +392,7 @@ export const oktaAuthApiRef: ApiRef< * See {@link https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#limiting-scopes-of-a-personal-access-token} * for a full list of supported scopes. */ -export const gitlabAuthApiRef: ApiRef< - OAuthApi & - OpenIdConnectApi & - ProfileInfoApi & - BackstageIdentityApi & - SessionApi -> = createApiRef< +export const gitlabAuthApiRef = createApiRef< OAuthApi & OpenIdConnectApi & ProfileInfoApi & @@ -433,13 +413,7 @@ export const gitlabAuthApiRef: ApiRef< * - {@link https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-permissions-and-consent} * - {@link https://docs.microsoft.com/en-us/graph/permissions-reference} */ -export const microsoftAuthApiRef: ApiRef< - OAuthApi & - OpenIdConnectApi & - ProfileInfoApi & - BackstageIdentityApi & - SessionApi -> = createApiRef< +export const microsoftAuthApiRef = createApiRef< OAuthApi & OpenIdConnectApi & ProfileInfoApi & @@ -455,13 +429,7 @@ export const microsoftAuthApiRef: ApiRef< * * @public */ -export const oneloginAuthApiRef: ApiRef< - OAuthApi & - OpenIdConnectApi & - ProfileInfoApi & - BackstageIdentityApi & - SessionApi -> = createApiRef< +export const oneloginAuthApiRef = createApiRef< OAuthApi & OpenIdConnectApi & ProfileInfoApi & @@ -481,9 +449,7 @@ export const oneloginAuthApiRef: ApiRef< * See {@link https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud/} * for a full list of supported scopes. */ -export const bitbucketAuthApiRef: ApiRef< - OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi -> = createApiRef< +export const bitbucketAuthApiRef = createApiRef< OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi >().with({ id: 'core.auth.bitbucket', @@ -499,9 +465,7 @@ export const bitbucketAuthApiRef: ApiRef< * See {@link https://confluence.atlassian.com/bitbucketserver/bitbucket-oauth-2-0-provider-api-1108483661.html#BitbucketOAuth2.0providerAPI-scopes} * for a full list of supported scopes. */ -export const bitbucketServerAuthApiRef: ApiRef< - OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi -> = createApiRef< +export const bitbucketServerAuthApiRef = createApiRef< OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi >().with({ id: 'core.auth.bitbucket-server', @@ -517,9 +481,7 @@ export const bitbucketServerAuthApiRef: ApiRef< * See {@link https://developer.atlassian.com/cloud/jira/platform/scopes-for-connect-and-oauth-2-3LO-apps/} * for a full list of supported scopes. */ -export const atlassianAuthApiRef: ApiRef< - OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi -> = createApiRef< +export const atlassianAuthApiRef = createApiRef< OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi >().with({ id: 'core.auth.atlassian', @@ -535,13 +497,7 @@ export const atlassianAuthApiRef: ApiRef< * For more info about VMware Cloud identity and access management: * - {@link https://docs.vmware.com/en/VMware-Cloud-services/services/Using-VMware-Cloud-Services/GUID-53D39337-D93A-4B84-BD18-DDF43C21479A.html} */ -export const vmwareCloudAuthApiRef: ApiRef< - OAuthApi & - OpenIdConnectApi & - ProfileInfoApi & - BackstageIdentityApi & - SessionApi -> = createApiRef< +export const vmwareCloudAuthApiRef = createApiRef< OAuthApi & OpenIdConnectApi & ProfileInfoApi & @@ -563,9 +519,7 @@ export const vmwareCloudAuthApiRef: ApiRef< * {@link https://docs.redhat.com/en/documentation/openshift_container_platform/latest/html-single/authentication_and_authorization/index#tokens-scoping-about_configuring-internal-oauth} * for available scopes. */ -export const openshiftAuthApiRef: ApiRef< - OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi -> = createApiRef< +export const openshiftAuthApiRef = createApiRef< OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi >().with({ id: 'core.auth.openshift', From 7a960a0d75d8df7778073fd64fc11497f9c69eba Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Mar 2026 19:48:41 +0100 Subject: [PATCH 08/91] Regenerate API reports Update the frontend plugin API report after removing explicit ApiRef constant annotations from the frontend API ref declarations. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- packages/frontend-plugin-api/report.api.md | 118 +++++++++++++++------ 1 file changed, 83 insertions(+), 35 deletions(-) diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index f17ca670ea..52ee10a6ac 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -31,7 +31,9 @@ export type AlertApi = { }; // @public -export const alertApiRef: ApiRef; +export const alertApiRef: ApiRef_2 & { + readonly $$type: '@backstage/ApiRef'; +}; // @public export type AlertMessage = { @@ -46,7 +48,9 @@ export type AnalyticsApi = { }; // @public -export const analyticsApiRef: ApiRef; +export const analyticsApiRef: ApiRef_2 & { + readonly $$type: '@backstage/ApiRef'; +}; // @public export const AnalyticsContext: (options: { @@ -215,7 +219,9 @@ export type AppLanguageApi = { }; // @public (undocumented) -export const appLanguageApiRef: ApiRef; +export const appLanguageApiRef: ApiRef_2 & { + readonly $$type: '@backstage/ApiRef'; +}; // @public export interface AppNode { @@ -288,7 +294,9 @@ export type AppThemeApi = { }; // @public -export const appThemeApiRef: ApiRef; +export const appThemeApiRef: ApiRef_2 & { + readonly $$type: '@backstage/ApiRef'; +}; // @public export interface AppTree { @@ -313,9 +321,11 @@ export const appTreeApiRef: ApiRef_2 & { }; // @public -export const atlassianAuthApiRef: ApiRef< +export const atlassianAuthApiRef: ApiRef_2< OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi ->; +> & { + readonly $$type: '@backstage/ApiRef'; +}; // @public export type AuthProviderInfo = { @@ -353,20 +363,26 @@ export type BackstageUserIdentity = { }; // @public -export const bitbucketAuthApiRef: ApiRef< +export const bitbucketAuthApiRef: ApiRef_2< OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi ->; +> & { + readonly $$type: '@backstage/ApiRef'; +}; // @public -export const bitbucketServerAuthApiRef: ApiRef< +export const bitbucketServerAuthApiRef: ApiRef_2< OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi ->; +> & { + readonly $$type: '@backstage/ApiRef'; +}; // @public export type ConfigApi = Config; // @public -export const configApiRef: ApiRef; +export const configApiRef: ApiRef_2 & { + readonly $$type: '@backstage/ApiRef'; +}; // @public (undocumented) export interface ConfigurableExtensionDataRef< @@ -893,7 +909,9 @@ export type DiscoveryApi = { }; // @public -export const discoveryApiRef: ApiRef; +export const discoveryApiRef: ApiRef_2 & { + readonly $$type: '@backstage/ApiRef'; +}; // @public export type ErrorApi = { @@ -917,7 +935,9 @@ export type ErrorApiErrorContext = { }; // @public -export const errorApiRef: ApiRef; +export const errorApiRef: ApiRef_2 & { + readonly $$type: '@backstage/ApiRef'; +}; // @public (undocumented) export const ErrorDisplay: { @@ -1301,7 +1321,9 @@ export interface FeatureFlagsApi { } // @public -export const featureFlagsApiRef: ApiRef; +export const featureFlagsApiRef: ApiRef_2 & { + readonly $$type: '@backstage/ApiRef'; +}; // @public export type FeatureFlagsSaveOptions = { @@ -1333,7 +1355,9 @@ export type FetchApi = { }; // @public -export const fetchApiRef: ApiRef; +export const fetchApiRef: ApiRef_2 & { + readonly $$type: '@backstage/ApiRef'; +}; // @public (undocumented) export type FrontendFeature = @@ -1406,27 +1430,33 @@ export type FrontendPluginInfoOptions = { }; // @public -export const githubAuthApiRef: ApiRef< +export const githubAuthApiRef: ApiRef_2< OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi ->; +> & { + readonly $$type: '@backstage/ApiRef'; +}; // @public -export const gitlabAuthApiRef: ApiRef< +export const gitlabAuthApiRef: ApiRef_2< OAuthApi & OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & SessionApi ->; +> & { + readonly $$type: '@backstage/ApiRef'; +}; // @public -export const googleAuthApiRef: ApiRef< +export const googleAuthApiRef: ApiRef_2< OAuthApi & OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & SessionApi ->; +> & { + readonly $$type: '@backstage/ApiRef'; +}; // @public @deprecated export type IconComponent = ComponentType<{ @@ -1461,16 +1491,20 @@ export type IdentityApi = { }; // @public -export const identityApiRef: ApiRef; +export const identityApiRef: ApiRef_2 & { + readonly $$type: '@backstage/ApiRef'; +}; // @public -export const microsoftAuthApiRef: ApiRef< +export const microsoftAuthApiRef: ApiRef_2< OAuthApi & OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & SessionApi ->; +> & { + readonly $$type: '@backstage/ApiRef'; +}; // @public @deprecated export const NavItemBlueprint: ExtensionBlueprint_2<{ @@ -1533,7 +1567,9 @@ export type OAuthRequestApi = { }; // @public -export const oauthRequestApiRef: ApiRef; +export const oauthRequestApiRef: ApiRef_2 & { + readonly $$type: '@backstage/ApiRef'; +}; // @public export type OAuthRequester = ( @@ -1550,22 +1586,26 @@ export type OAuthRequesterOptions = { export type OAuthScope = string | string[]; // @public -export const oktaAuthApiRef: ApiRef< +export const oktaAuthApiRef: ApiRef_2< OAuthApi & OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & SessionApi ->; +> & { + readonly $$type: '@backstage/ApiRef'; +}; // @public -export const oneloginAuthApiRef: ApiRef< +export const oneloginAuthApiRef: ApiRef_2< OAuthApi & OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & SessionApi ->; +> & { + readonly $$type: '@backstage/ApiRef'; +}; // @public export type OpenIdConnectApi = { @@ -1573,9 +1613,11 @@ export type OpenIdConnectApi = { }; // @public -export const openshiftAuthApiRef: ApiRef< +export const openshiftAuthApiRef: ApiRef_2< OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi ->; +> & { + readonly $$type: '@backstage/ApiRef'; +}; // @public (undocumented) export interface OverridableExtensionDefinition< @@ -2055,7 +2097,9 @@ export interface StorageApi { } // @public -export const storageApiRef: ApiRef; +export const storageApiRef: ApiRef_2 & { + readonly $$type: '@backstage/ApiRef'; +}; // @public export type StorageValueSnapshot = @@ -2168,7 +2212,9 @@ export type TranslationApi = { }; // @public (undocumented) -export const translationApiRef: ApiRef; +export const translationApiRef: ApiRef_2 & { + readonly $$type: '@backstage/ApiRef'; +}; // @public (undocumented) export type TranslationFunction< @@ -2358,13 +2404,15 @@ export const useTranslationRef: ( }; // @public -export const vmwareCloudAuthApiRef: ApiRef< +export const vmwareCloudAuthApiRef: ApiRef_2< OAuthApi & OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & SessionApi ->; +> & { + readonly $$type: '@backstage/ApiRef'; +}; // @public @deprecated export function withApis( From 76b89c743702b061c1e2cf120be606f095d16542 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Mar 2026 20:52:41 +0100 Subject: [PATCH 09/91] api-ref: infer builder ids and plugin ownership Preserve literal API ref ids in the builder form while keeping the deprecated constructor compatible, and rely on explicit ownership metadata instead of the old core id fallback. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .changeset/opaque-api-ref-type.md | 4 +- .../src/apis/system/ApiRef.test.ts | 7 +- .../src/wiring/createSpecializedApp.test.tsx | 45 ++++++++ .../src/wiring/createSpecializedApp.tsx | 7 +- .../frontend-plugin-api/report-alpha.api.md | 5 +- packages/frontend-plugin-api/report.api.md | 103 ++++++++++++------ .../src/apis/system/ApiRef.test.ts | 30 ++++- .../src/apis/system/ApiRef.ts | 47 ++++---- .../src/apis/system/types.ts | 4 +- 9 files changed, 181 insertions(+), 71 deletions(-) diff --git a/.changeset/opaque-api-ref-type.md b/.changeset/opaque-api-ref-type.md index 81f365b578..a2862e30bc 100644 --- a/.changeset/opaque-api-ref-type.md +++ b/.changeset/opaque-api-ref-type.md @@ -2,6 +2,6 @@ '@backstage/frontend-plugin-api': patch --- -Added a builder form for `createApiRef` in the new frontend system and deprecated the direct `createApiRef({ ... })` call in favor of `createApiRef().with({ ... })`. +Added a builder form for `createApiRef` in the new frontend system and deprecated the direct `createApiRef({ ... })` call in favor of `createApiRef().with({ ... })`. The builder form now also preserves literal API ref IDs in the resulting `ApiRef` type. -`ApiRef` and `ApiRefConfig` now also support an explicit `pluginId`, making it possible to declare API ownership without encoding the plugin ID into the API ref ID. +`ApiRef` now also supports an explicit `pluginId`, and the `createApiRef().with({ ... })` form can use it to declare API ownership without encoding the plugin ID into the API ref ID. diff --git a/packages/core-plugin-api/src/apis/system/ApiRef.test.ts b/packages/core-plugin-api/src/apis/system/ApiRef.test.ts index 994cde44c6..5bfb83ce3e 100644 --- a/packages/core-plugin-api/src/apis/system/ApiRef.test.ts +++ b/packages/core-plugin-api/src/apis/system/ApiRef.test.ts @@ -22,7 +22,12 @@ describe('ApiRef', () => { expect(ref.$$type).toBe('@backstage/ApiRef'); expect(ref.id).toBe('abc'); expect(String(ref)).toBe('apiRef{abc}'); - expect(() => ref.T).toThrow('tried to read ApiRef.T of apiRef{abc}'); + expect(ref.T).toBeNull(); + }); + + it('should not accept pluginId in the core createApiRef config', () => { + // @ts-expect-error pluginId is not supported in core-plugin-api + createApiRef({ id: 'abc', pluginId: 'test' }); }); it('should reject invalid ids', () => { diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx index d01bc4fe9c..36f6ff250d 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx @@ -417,6 +417,51 @@ describe('createSpecializedApp', () => { expect(app.apis.get(testApiRef)).toEqual({ value: 'owner' }); }); + 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' }); + }); + it('should allow API overrides within the same plugin', () => { const testApiRef = createApiRef<{ value: string }>({ id: 'test.api' }); diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx index 7ab36f2fad..da77e31103 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx @@ -407,8 +407,8 @@ function createApiFactories(options: { // 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. + // 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; @@ -465,9 +465,6 @@ function getApiOwnerId(apiRef: { id: string; pluginId?: string }): string { if (!prefix) { return apiRefId; } - if (prefix === 'core') { - return 'app'; - } if (prefix === 'plugin' && rest[0]) { return rest[0]; } diff --git a/packages/frontend-plugin-api/report-alpha.api.md b/packages/frontend-plugin-api/report-alpha.api.md index c1a3c71a21..921ad1039c 100644 --- a/packages/frontend-plugin-api/report-alpha.api.md +++ b/packages/frontend-plugin-api/report-alpha.api.md @@ -24,7 +24,10 @@ export type PluginWrapperApi = { }; // @public -export const pluginWrapperApiRef: ApiRef & { +export const pluginWrapperApiRef: ApiRef< + PluginWrapperApi, + 'core.plugin-wrapper' +> & { readonly $$type: '@backstage/ApiRef'; }; diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index 52ee10a6ac..2534d83eed 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -31,7 +31,7 @@ export type AlertApi = { }; // @public -export const alertApiRef: ApiRef_2 & { +export const alertApiRef: ApiRef_2 & { readonly $$type: '@backstage/ApiRef'; }; @@ -48,7 +48,7 @@ export type AnalyticsApi = { }; // @public -export const analyticsApiRef: ApiRef_2 & { +export const analyticsApiRef: ApiRef_2 & { readonly $$type: '@backstage/ApiRef'; }; @@ -191,9 +191,9 @@ export type ApiHolder = { }; // @public -export type ApiRef = { +export type ApiRef = { readonly $$type?: '@backstage/ApiRef'; - readonly id: string; + readonly id: TId; readonly pluginId?: string; readonly T: T; }; @@ -201,7 +201,6 @@ export type ApiRef = { // @public export type ApiRefConfig = { id: string; - pluginId?: string; }; // @public (undocumented) @@ -219,7 +218,7 @@ export type AppLanguageApi = { }; // @public (undocumented) -export const appLanguageApiRef: ApiRef_2 & { +export const appLanguageApiRef: ApiRef_2 & { readonly $$type: '@backstage/ApiRef'; }; @@ -294,7 +293,7 @@ export type AppThemeApi = { }; // @public -export const appThemeApiRef: ApiRef_2 & { +export const appThemeApiRef: ApiRef_2 & { readonly $$type: '@backstage/ApiRef'; }; @@ -316,13 +315,14 @@ export interface AppTreeApi { } // @public -export const appTreeApiRef: ApiRef_2 & { +export const appTreeApiRef: ApiRef_2 & { readonly $$type: '@backstage/ApiRef'; }; // @public export const atlassianAuthApiRef: ApiRef_2< - OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi + OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi, + 'core.auth.atlassian' > & { readonly $$type: '@backstage/ApiRef'; }; @@ -364,14 +364,16 @@ export type BackstageUserIdentity = { // @public export const bitbucketAuthApiRef: ApiRef_2< - OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi + OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi, + 'core.auth.bitbucket' > & { readonly $$type: '@backstage/ApiRef'; }; // @public export const bitbucketServerAuthApiRef: ApiRef_2< - OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi + OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi, + 'core.auth.bitbucket-server' > & { readonly $$type: '@backstage/ApiRef'; }; @@ -380,7 +382,7 @@ export const bitbucketServerAuthApiRef: ApiRef_2< export type ConfigApi = Config; // @public -export const configApiRef: ApiRef_2 & { +export const configApiRef: ApiRef_2 & { readonly $$type: '@backstage/ApiRef'; }; @@ -443,7 +445,12 @@ export function createApiRef(config: ApiRefConfig): ApiRef & { // @public export function createApiRef(): { - with(config: ApiRefConfig): ApiRef & { + with( + config: ApiRefConfig & { + id: TId; + pluginId?: string; + }, + ): ApiRef & { readonly $$type: '@backstage/ApiRef'; }; }; @@ -899,7 +906,7 @@ export interface DialogApiDialog { } // @public -export const dialogApiRef: ApiRef_2 & { +export const dialogApiRef: ApiRef_2 & { readonly $$type: '@backstage/ApiRef'; }; @@ -909,7 +916,7 @@ export type DiscoveryApi = { }; // @public -export const discoveryApiRef: ApiRef_2 & { +export const discoveryApiRef: ApiRef_2 & { readonly $$type: '@backstage/ApiRef'; }; @@ -935,7 +942,7 @@ export type ErrorApiErrorContext = { }; // @public -export const errorApiRef: ApiRef_2 & { +export const errorApiRef: ApiRef_2 & { readonly $$type: '@backstage/ApiRef'; }; @@ -1321,7 +1328,10 @@ export interface FeatureFlagsApi { } // @public -export const featureFlagsApiRef: ApiRef_2 & { +export const featureFlagsApiRef: ApiRef_2< + FeatureFlagsApi, + 'core.featureflags' +> & { readonly $$type: '@backstage/ApiRef'; }; @@ -1355,7 +1365,7 @@ export type FetchApi = { }; // @public -export const fetchApiRef: ApiRef_2 & { +export const fetchApiRef: ApiRef_2 & { readonly $$type: '@backstage/ApiRef'; }; @@ -1431,7 +1441,8 @@ export type FrontendPluginInfoOptions = { // @public export const githubAuthApiRef: ApiRef_2< - OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi + OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi, + 'core.auth.github' > & { readonly $$type: '@backstage/ApiRef'; }; @@ -1442,7 +1453,8 @@ export const gitlabAuthApiRef: ApiRef_2< OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & - SessionApi + SessionApi, + 'core.auth.gitlab' > & { readonly $$type: '@backstage/ApiRef'; }; @@ -1453,7 +1465,8 @@ export const googleAuthApiRef: ApiRef_2< OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & - SessionApi + SessionApi, + 'core.auth.google' > & { readonly $$type: '@backstage/ApiRef'; }; @@ -1476,7 +1489,7 @@ export interface IconsApi { } // @public -export const iconsApiRef: ApiRef_2 & { +export const iconsApiRef: ApiRef_2 & { readonly $$type: '@backstage/ApiRef'; }; @@ -1491,7 +1504,7 @@ export type IdentityApi = { }; // @public -export const identityApiRef: ApiRef_2 & { +export const identityApiRef: ApiRef_2 & { readonly $$type: '@backstage/ApiRef'; }; @@ -1501,7 +1514,8 @@ export const microsoftAuthApiRef: ApiRef_2< OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & - SessionApi + SessionApi, + 'core.auth.microsoft' > & { readonly $$type: '@backstage/ApiRef'; }; @@ -1567,7 +1581,10 @@ export type OAuthRequestApi = { }; // @public -export const oauthRequestApiRef: ApiRef_2 & { +export const oauthRequestApiRef: ApiRef_2< + OAuthRequestApi, + 'core.oauthrequest' +> & { readonly $$type: '@backstage/ApiRef'; }; @@ -1591,7 +1608,8 @@ export const oktaAuthApiRef: ApiRef_2< OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & - SessionApi + SessionApi, + 'core.auth.okta' > & { readonly $$type: '@backstage/ApiRef'; }; @@ -1602,7 +1620,8 @@ export const oneloginAuthApiRef: ApiRef_2< OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & - SessionApi + SessionApi, + 'core.auth.onelogin' > & { readonly $$type: '@backstage/ApiRef'; }; @@ -1614,7 +1633,8 @@ export type OpenIdConnectApi = { // @public export const openshiftAuthApiRef: ApiRef_2< - OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi + OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi, + 'core.auth.openshift' > & { readonly $$type: '@backstage/ApiRef'; }; @@ -1905,7 +1925,10 @@ export type PluginHeaderActionsApi = { }; // @public -export const pluginHeaderActionsApiRef: ApiRef_2 & { +export const pluginHeaderActionsApiRef: ApiRef_2< + PluginHeaderActionsApi, + 'core.plugin-header-actions' +> & { readonly $$type: '@backstage/ApiRef'; }; @@ -1949,7 +1972,10 @@ export type PluginWrapperApi = { }; // @public -export const pluginWrapperApiRef: ApiRef_2 & { +export const pluginWrapperApiRef: ApiRef_2< + PluginWrapperApi, + 'core.plugin-wrapper' +> & { readonly $$type: '@backstage/ApiRef'; }; @@ -2057,7 +2083,10 @@ export interface RouteResolutionApi { } // @public -export const routeResolutionApiRef: ApiRef_2 & { +export const routeResolutionApiRef: ApiRef_2< + RouteResolutionApi, + 'core.route-resolution' +> & { readonly $$type: '@backstage/ApiRef'; }; @@ -2097,7 +2126,7 @@ export interface StorageApi { } // @public -export const storageApiRef: ApiRef_2 & { +export const storageApiRef: ApiRef_2 & { readonly $$type: '@backstage/ApiRef'; }; @@ -2189,7 +2218,10 @@ export interface SwappableComponentsApi { } // @public -export const swappableComponentsApiRef: ApiRef_2 & { +export const swappableComponentsApiRef: ApiRef_2< + SwappableComponentsApi, + 'core.swappable-components' +> & { readonly $$type: '@backstage/ApiRef'; }; @@ -2212,7 +2244,7 @@ export type TranslationApi = { }; // @public (undocumented) -export const translationApiRef: ApiRef_2 & { +export const translationApiRef: ApiRef_2 & { readonly $$type: '@backstage/ApiRef'; }; @@ -2409,7 +2441,8 @@ export const vmwareCloudAuthApiRef: ApiRef_2< OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & - SessionApi + SessionApi, + 'core.auth.vmware-cloud' > & { readonly $$type: '@backstage/ApiRef'; }; diff --git a/packages/frontend-plugin-api/src/apis/system/ApiRef.test.ts b/packages/frontend-plugin-api/src/apis/system/ApiRef.test.ts index b20134cc2a..ff4b83b978 100644 --- a/packages/frontend-plugin-api/src/apis/system/ApiRef.test.ts +++ b/packages/frontend-plugin-api/src/apis/system/ApiRef.test.ts @@ -15,6 +15,7 @@ */ import { createApiRef } from './ApiRef'; +import type { ApiRef as ApiRefType } from './types'; describe('ApiRef', () => { it('should be created with config', () => { @@ -22,7 +23,22 @@ describe('ApiRef', () => { expect(ref.$$type).toBe('@backstage/ApiRef'); expect(ref.id).toBe('abc'); expect(String(ref)).toBe('apiRef{abc}'); - expect(() => ref.T).toThrow('tried to read ApiRef.T of apiRef{abc}'); + expect(ref.T).toBeNull(); + }); + + it('should not accept pluginId with deprecated config form', () => { + // @ts-expect-error pluginId is only supported through .with(...) + createApiRef({ id: 'abc', pluginId: 'test' }); + }); + + it('should keep the deprecated config form id wide', () => { + const ref = createApiRef({ id: 'abc' }); + const wideRef: ApiRefType = ref; + expect(wideRef.id).toBe('abc'); + + // @ts-expect-error deprecated config form should not infer literal ids + const literalRef: ApiRefType = ref; + expect(literalRef.id).toBe('abc'); }); it('should be created with builder pattern', () => { @@ -31,7 +47,17 @@ describe('ApiRef', () => { expect(ref.id).toBe('abc'); expect(ref.pluginId).toBe('test'); expect(String(ref)).toBe('apiRef{abc}'); - expect(() => ref.T).toThrow('tried to read ApiRef.T of apiRef{abc}'); + expect(ref.T).toBeNull(); + }); + + it('should infer literal ids with builder pattern', () => { + const ref = createApiRef().with({ id: 'abc', pluginId: 'test' }); + const literalRef: ApiRefType = ref; + expect(literalRef.id).toBe('abc'); + + // @ts-expect-error builder pattern should preserve literal ids + const wrongLiteralRef: ApiRefType = ref; + expect(wrongLiteralRef.id).toBe('abc'); }); it('should reject invalid ids', () => { diff --git a/packages/frontend-plugin-api/src/apis/system/ApiRef.ts b/packages/frontend-plugin-api/src/apis/system/ApiRef.ts index 3cc7fc6649..15f10874fc 100644 --- a/packages/frontend-plugin-api/src/apis/system/ApiRef.ts +++ b/packages/frontend-plugin-api/src/apis/system/ApiRef.ts @@ -24,6 +24,10 @@ import type { ApiRef } from './types'; */ export type ApiRefConfig = { id: string; +}; + +type ApiRefBuilderConfig = { + id: TId; pluginId?: string; }; @@ -51,24 +55,17 @@ function validateId(id: string): void { } } -function makeApiRef( - config: ApiRefConfig, -): ApiRef & { readonly $$type: '@backstage/ApiRef' } { - const ref = OpaqueApiRef.createInstance('v1', { +function makeApiRef( + config: ApiRefBuilderConfig, +): ApiRef & { readonly $$type: '@backstage/ApiRef' } { + return OpaqueApiRef.createInstance('v1', { id: config.id, ...(config.pluginId ? { pluginId: config.pluginId } : {}), - T: undefined as T, + T: null as unknown as T, toString() { return `apiRef{${config.id}}`; }, - }) as ApiRef & { readonly $$type: '@backstage/ApiRef' }; - Object.defineProperty(ref, 'T', { - get(): T { - throw new Error(`tried to read ApiRef.T of ${this}`); - }, - enumerable: false, - }); - return ref; + }) as ApiRef & { readonly $$type: '@backstage/ApiRef' }; } /** @@ -77,9 +74,9 @@ function makeApiRef( * @remarks * * The `id` is a stable identifier for the API implementation. The frontend - * system infers the owning plugin for an API from the `id`, unless you provide - * a `pluginId` explicitly. The recommended pattern is `plugin..*` - * (for example, + * system infers the owning plugin for an API from the `id`. When using the + * builder form, you can instead provide a `pluginId` explicitly. The + * recommended pattern is `plugin..*` (for example, * `plugin.catalog.entity-presentation`). This ensures that other plugins can't * mistakenly override your API implementation. * @@ -120,27 +117,31 @@ export function createApiRef( * @public */ export function createApiRef(): { - with(config: ApiRefConfig): ApiRef & { + with( + config: ApiRefConfig & { id: TId; pluginId?: string }, + ): ApiRef & { readonly $$type: '@backstage/ApiRef'; }; }; export function createApiRef(config?: ApiRefConfig): | (ApiRef & { readonly $$type: '@backstage/ApiRef' }) | { - with(config: ApiRefConfig): ApiRef & { + with( + config: ApiRefConfig & { id: TId; pluginId?: string }, + ): ApiRef & { readonly $$type: '@backstage/ApiRef'; }; } { if (config) { validateId(config.id); - return makeApiRef(config); + return makeApiRef(config); } return { - with(withConfig: ApiRefConfig): ApiRef & { - readonly $$type: '@backstage/ApiRef'; - } { + with( + withConfig: ApiRefConfig & { id: TId; pluginId?: string }, + ): ApiRef & { readonly $$type: '@backstage/ApiRef' } { validateId(withConfig.id); - return makeApiRef(withConfig); + return makeApiRef(withConfig); }, }; } diff --git a/packages/frontend-plugin-api/src/apis/system/types.ts b/packages/frontend-plugin-api/src/apis/system/types.ts index 90e7365164..50911ded1e 100644 --- a/packages/frontend-plugin-api/src/apis/system/types.ts +++ b/packages/frontend-plugin-api/src/apis/system/types.ts @@ -19,9 +19,9 @@ * * @public */ -export type ApiRef = { +export type ApiRef = { readonly $$type?: '@backstage/ApiRef'; - readonly id: string; + readonly id: TId; readonly pluginId?: string; readonly T: T; }; From ccc6b25f879b8b640a51b2a151061302a7a43a17 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 17 Mar 2026 09:34:48 +0100 Subject: [PATCH 10/91] api-ref: keep plugin ownership metadata internal Hide plugin ownership metadata from the public ApiRef type while preserving internal ownership resolution for the builder-based API ref flow. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .changeset/opaque-api-ref-type.md | 2 +- .../src/wiring/createSpecializedApp.test.tsx | 5 +++++ .../frontend-app-api/src/wiring/createSpecializedApp.tsx | 7 ++++--- packages/frontend-plugin-api/report.api.md | 1 - .../frontend-plugin-api/src/apis/system/ApiRef.test.ts | 7 ++++++- packages/frontend-plugin-api/src/apis/system/types.ts | 1 - 6 files changed, 16 insertions(+), 7 deletions(-) diff --git a/.changeset/opaque-api-ref-type.md b/.changeset/opaque-api-ref-type.md index a2862e30bc..6ae300f556 100644 --- a/.changeset/opaque-api-ref-type.md +++ b/.changeset/opaque-api-ref-type.md @@ -4,4 +4,4 @@ Added a builder form for `createApiRef` in the new frontend system and deprecated the direct `createApiRef({ ... })` call in favor of `createApiRef().with({ ... })`. The builder form now also preserves literal API ref IDs in the resulting `ApiRef` type. -`ApiRef` now also supports an explicit `pluginId`, and the `createApiRef().with({ ... })` form can use it to declare API ownership without encoding the plugin ID into the API ref ID. +The `createApiRef().with({ ... })` form can also use an explicit `pluginId` to declare API ownership without encoding the plugin ID into the API ref ID, while keeping that metadata internal to runtime handling. diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx index 36f6ff250d..f954ba5750 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx @@ -168,6 +168,7 @@ describe('createSpecializedApp', () => { "factory": { "api": { "$$type": "@backstage/ApiRef", + "T": null, "id": "core.featureflags", "pluginId": "app", "toString": [Function], @@ -182,6 +183,7 @@ describe('createSpecializedApp', () => { "factory": { "api": { "$$type": "@backstage/ApiRef", + "T": null, "id": "core.app-tree", "pluginId": "app", "toString": [Function], @@ -196,6 +198,7 @@ describe('createSpecializedApp', () => { "factory": { "api": { "$$type": "@backstage/ApiRef", + "T": null, "id": "core.config", "pluginId": "app", "toString": [Function], @@ -210,6 +213,7 @@ describe('createSpecializedApp', () => { "factory": { "api": { "$$type": "@backstage/ApiRef", + "T": null, "id": "core.route-resolution", "pluginId": "app", "toString": [Function], @@ -224,6 +228,7 @@ describe('createSpecializedApp', () => { "factory": { "api": { "$$type": "@backstage/ApiRef", + "T": null, "id": "core.identity", "pluginId": "app", "toString": [Function], diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx index da77e31103..fd009db479 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx @@ -455,9 +455,10 @@ function createApiFactories(options: { // 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; pluginId?: string }): string { - if (apiRef.pluginId) { - return apiRef.pluginId; +function getApiOwnerId(apiRef: { id: string }): string { + const pluginId = (apiRef as { pluginId?: string }).pluginId; + if (pluginId) { + return pluginId; } const apiRefId = apiRef.id; diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index 2534d83eed..ae36baf2a6 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -194,7 +194,6 @@ export type ApiHolder = { export type ApiRef = { readonly $$type?: '@backstage/ApiRef'; readonly id: TId; - readonly pluginId?: string; readonly T: T; }; diff --git a/packages/frontend-plugin-api/src/apis/system/ApiRef.test.ts b/packages/frontend-plugin-api/src/apis/system/ApiRef.test.ts index ff4b83b978..37bf0205be 100644 --- a/packages/frontend-plugin-api/src/apis/system/ApiRef.test.ts +++ b/packages/frontend-plugin-api/src/apis/system/ApiRef.test.ts @@ -27,6 +27,8 @@ describe('ApiRef', () => { }); it('should not accept pluginId with deprecated config form', () => { + expect(createApiRef({ id: 'abc' }).id).toBe('abc'); + // @ts-expect-error pluginId is only supported through .with(...) createApiRef({ id: 'abc', pluginId: 'test' }); }); @@ -45,9 +47,12 @@ describe('ApiRef', () => { const ref = createApiRef().with({ id: 'abc', pluginId: 'test' }); expect(ref.$$type).toBe('@backstage/ApiRef'); expect(ref.id).toBe('abc'); - expect(ref.pluginId).toBe('test'); expect(String(ref)).toBe('apiRef{abc}'); expect(ref.T).toBeNull(); + expect((ref as { pluginId?: string }).pluginId).toBe('test'); + + // @ts-expect-error pluginId is internal runtime metadata + expect(ref.pluginId).toBe('test'); }); it('should infer literal ids with builder pattern', () => { diff --git a/packages/frontend-plugin-api/src/apis/system/types.ts b/packages/frontend-plugin-api/src/apis/system/types.ts index 50911ded1e..5bc6136bdf 100644 --- a/packages/frontend-plugin-api/src/apis/system/types.ts +++ b/packages/frontend-plugin-api/src/apis/system/types.ts @@ -22,7 +22,6 @@ export type ApiRef = { readonly $$type?: '@backstage/ApiRef'; readonly id: TId; - readonly pluginId?: string; readonly T: T; }; From 4690f20880a8174c77d106d9372b66a5e6454b00 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 17 Mar 2026 10:30:59 +0100 Subject: [PATCH 11/91] api-ref: use opaque metadata for owner lookup Read ApiRef plugin ownership through the internal opaque type helper and gracefully fall back to legacy ID inference for unsupported ref shapes. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../src/wiring/createSpecializedApp.test.tsx | 59 +++++++++++++++++++ .../src/wiring/createSpecializedApp.tsx | 14 ++++- .../src/apis/system/ApiRef.ts | 4 +- 3 files changed, 73 insertions(+), 4 deletions(-) diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx index f954ba5750..8a4e2b5c9b 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx @@ -16,6 +16,7 @@ import { AppTreeApi, + type ApiRef, appTreeApiRef, coreExtensionData, createExtension, @@ -422,6 +423,64 @@ describe('createSpecializedApp', () => { expect(app.apis.get(testApiRef)).toEqual({ value: 'owner' }); }); + it('should ignore plugin ownership metadata from unsupported opaque ApiRefs', () => { + 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'; + }; + + 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: 'other' }); + }); + it('should not infer app ownership from core-prefixed API ids', () => { const testApiRef = createApiRef<{ value: string }>({ id: 'core.shared' }); diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx index fd009db479..20c00c8929 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx @@ -51,6 +51,8 @@ import { resolveExtensionDefinition, toInternalExtension, } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { OpaqueApiRef } from '../../../frontend-plugin-api/src/apis/system/ApiRef'; import { extractRouteInfoFromAppNode, @@ -456,9 +458,15 @@ function createApiFactories(options: { // 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 { - const pluginId = (apiRef as { pluginId?: string }).pluginId; - if (pluginId) { - return pluginId; + if (OpaqueApiRef.isType(apiRef)) { + try { + const { pluginId } = OpaqueApiRef.toInternal(apiRef); + if (pluginId) { + return pluginId; + } + } catch { + // Fall back to legacy ID inference for unsupported opaque ApiRef versions. + } } const apiRefId = apiRef.id; diff --git a/packages/frontend-plugin-api/src/apis/system/ApiRef.ts b/packages/frontend-plugin-api/src/apis/system/ApiRef.ts index 15f10874fc..2d86f0a8ad 100644 --- a/packages/frontend-plugin-api/src/apis/system/ApiRef.ts +++ b/packages/frontend-plugin-api/src/apis/system/ApiRef.ts @@ -31,12 +31,14 @@ type ApiRefBuilderConfig = { pluginId?: string; }; -const OpaqueApiRef = OpaqueType.create<{ +/** @internal */ +export const OpaqueApiRef = OpaqueType.create<{ public: ApiRef & { readonly $$type: '@backstage/ApiRef'; }; versions: { readonly version: 'v1'; + readonly pluginId?: string; }; }>({ type: '@backstage/ApiRef', From 90c3a9d1c074c409268fdc7b39f449c491e43c78 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 17 Mar 2026 10:46:42 +0100 Subject: [PATCH 12/91] api-ref: preserve const ids in builder types Use a const type parameter for createApiRef().with(...) so literal API ref ids stay narrow instead of widening to string. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- packages/frontend-plugin-api/report.api.md | 2 +- packages/frontend-plugin-api/src/apis/system/ApiRef.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index ae36baf2a6..e3113e1d9b 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -444,7 +444,7 @@ export function createApiRef(config: ApiRefConfig): ApiRef & { // @public export function createApiRef(): { - with( + with( config: ApiRefConfig & { id: TId; pluginId?: string; diff --git a/packages/frontend-plugin-api/src/apis/system/ApiRef.ts b/packages/frontend-plugin-api/src/apis/system/ApiRef.ts index 2d86f0a8ad..2327cb0631 100644 --- a/packages/frontend-plugin-api/src/apis/system/ApiRef.ts +++ b/packages/frontend-plugin-api/src/apis/system/ApiRef.ts @@ -119,7 +119,7 @@ export function createApiRef( * @public */ export function createApiRef(): { - with( + with( config: ApiRefConfig & { id: TId; pluginId?: string }, ): ApiRef & { readonly $$type: '@backstage/ApiRef'; @@ -128,7 +128,7 @@ export function createApiRef(): { export function createApiRef(config?: ApiRefConfig): | (ApiRef & { readonly $$type: '@backstage/ApiRef' }) | { - with( + with( config: ApiRefConfig & { id: TId; pluginId?: string }, ): ApiRef & { readonly $$type: '@backstage/ApiRef'; @@ -139,7 +139,7 @@ export function createApiRef(config?: ApiRefConfig): return makeApiRef(config); } return { - with( + with( withConfig: ApiRefConfig & { id: TId; pluginId?: string }, ): ApiRef & { readonly $$type: '@backstage/ApiRef' } { validateId(withConfig.id); From abc12cd8e174b6a2a40a98e54b53822a597fa19e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 17 Mar 2026 10:54:11 +0100 Subject: [PATCH 13/91] fix: address review feedback for actions CLI module Fix argument parsing bugs in the execute command where actionId at index 0 was incorrectly skipped when --instance was absent, and flag values matching the actionId string were erroneously removed. Add --help support to the execute command for CLI report generation. Add missing bin script and cli-report.md for cli-module-actions. Add resolveAuth tests. Bump backend-defaults changeset to minor for the security-relevant auth change. Replace cross-fetch with native fetch in cli-module-auth to avoid punycode deprecation warnings during CLI report generation. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .changeset/allow-user-action-invocation.md | 2 +- .../bin/backstage-cli-module-actions | 32 +++++ packages/cli-module-actions/cli-report.md | 94 +++++++++++++ packages/cli-module-actions/package.json | 4 +- .../src/commands/execute.ts | 41 +++++- .../src/lib/resolveAuth.test.ts | 124 ++++++++++++++++++ packages/cli-module-auth/package.json | 1 - packages/cli-module-auth/src/lib/http.test.ts | 7 +- packages/cli-module-auth/src/lib/http.ts | 1 - packages/cli/cli-report.md | 78 +++++++++++ yarn.lock | 3 +- 11 files changed, 375 insertions(+), 12 deletions(-) create mode 100755 packages/cli-module-actions/bin/backstage-cli-module-actions create mode 100644 packages/cli-module-actions/cli-report.md create mode 100644 packages/cli-module-actions/src/lib/resolveAuth.test.ts diff --git a/.changeset/allow-user-action-invocation.md b/.changeset/allow-user-action-invocation.md index 299c38b41b..d9ac46a60e 100644 --- a/.changeset/allow-user-action-invocation.md +++ b/.changeset/allow-user-action-invocation.md @@ -1,5 +1,5 @@ --- -'@backstage/backend-defaults': patch +'@backstage/backend-defaults': minor --- The actions registry invoke endpoint now accepts direct user credentials in addition to service principals, enabling CLI and other direct user clients to invoke actions. diff --git a/packages/cli-module-actions/bin/backstage-cli-module-actions b/packages/cli-module-actions/bin/backstage-cli-module-actions new file mode 100755 index 0000000000..59f8267233 --- /dev/null +++ b/packages/cli-module-actions/bin/backstage-cli-module-actions @@ -0,0 +1,32 @@ +#!/usr/bin/env node +/* + * Copyright 2025 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. + */ + +const path = require('node:path'); + +/* eslint-disable-next-line no-restricted-syntax */ +const isLocal = require('node:fs').existsSync( + path.resolve(__dirname, '../src'), +); + +if (isLocal) { + require('@backstage/cli-node/config/nodeTransform.cjs'); +} + +const { runCliModule } = require('@backstage/cli-node'); +const cliModule = require(isLocal ? '../src/index' : '..').default; +const pkg = require('../package.json'); +runCliModule({ module: cliModule, name: pkg.name, version: pkg.version }); diff --git a/packages/cli-module-actions/cli-report.md b/packages/cli-module-actions/cli-report.md new file mode 100644 index 0000000000..bbbab62b3f --- /dev/null +++ b/packages/cli-module-actions/cli-report.md @@ -0,0 +1,94 @@ +## CLI Report file for "@backstage/cli-module-actions" + +> Do not edit this file. It is a report generated by `yarn build:api-reports` + +### `backstage-cli-module-actions` + +``` +Usage: @backstage/cli-module-actions [options] [command] + +Options: + -V, --version + -h, --help + +Commands: + actions [command] + help [command] +``` + +### `backstage-cli-module-actions actions` + +``` +Usage: @backstage/cli-module-actions actions [options] [command] [command] + +Options: + -h, --help + +Commands: + execute + help [command] + list + sources [command] +``` + +### `backstage-cli-module-actions actions execute` + +``` +Usage: @backstage/cli-module-actions actions execute + +Options: + --instance + -h, --help +``` + +### `backstage-cli-module-actions actions list` + +``` +Usage: @backstage/cli-module-actions actions list + +Options: + --instance + -h, --help +``` + +### `backstage-cli-module-actions actions sources` + +``` +Usage: @backstage/cli-module-actions actions sources [options] [command] [command] + +Options: + -h, --help + +Commands: + add + help [command] + list + remove +``` + +### `backstage-cli-module-actions actions sources add` + +``` +Usage: @backstage/cli-module-actions actions sources add + +Options: + -h, --help +``` + +### `backstage-cli-module-actions actions sources list` + +``` +Usage: @backstage/cli-module-actions actions sources list + +Options: + -h, --help +``` + +### `backstage-cli-module-actions actions sources remove` + +``` +Usage: @backstage/cli-module-actions actions sources remove + +Options: + -h, --help +``` diff --git a/packages/cli-module-actions/package.json b/packages/cli-module-actions/package.json index ca3c423999..fe19a8a90d 100644 --- a/packages/cli-module-actions/package.json +++ b/packages/cli-module-actions/package.json @@ -20,8 +20,10 @@ "main": "src/index.ts", "types": "src/index.ts", "files": [ - "dist" + "dist", + "bin" ], + "bin": "bin/backstage-cli-module-actions", "scripts": { "build": "backstage-cli package build", "clean": "backstage-cli package clean", diff --git a/packages/cli-module-actions/src/commands/execute.ts b/packages/cli-module-actions/src/commands/execute.ts index fccb1e1595..4ff955096b 100644 --- a/packages/cli-module-actions/src/commands/execute.ts +++ b/packages/cli-module-actions/src/commands/execute.ts @@ -21,12 +21,45 @@ import { schemaToFlags } from '../lib/schemaToFlags'; import { resolveAuth } from '../lib/resolveAuth'; export default async ({ args, info }: CliCommandContext) => { + if (args.includes('--help') || args.includes('-h')) { + cli( + { + help: info, + parameters: [''], + flags: { + instance: { + type: String, + description: 'Name of the instance to use', + }, + }, + }, + undefined, + args, + ); + return; + } + const instanceIdx = args.indexOf('--instance'); const instanceFlag = instanceIdx !== -1 ? args[instanceIdx + 1] : undefined; - const actionId = args.find( - (a, i) => !a.startsWith('-') && i !== instanceIdx + 1, - ); + // Skip flag names, flag values (the argument after a known flag), and + // the --instance value position so we only pick up positional arguments. + const skipIndices = new Set(); + if (instanceIdx !== -1) { + skipIndices.add(instanceIdx); + skipIndices.add(instanceIdx + 1); + } + + let actionId: string | undefined; + let actionIdIdx = -1; + for (let i = 0; i < args.length; i++) { + if (!skipIndices.has(i) && !args[i].startsWith('-')) { + actionId = args[i]; + actionIdIdx = i; + break; + } + } + if (!actionId) { process.stderr.write('Usage: actions execute [flags]\n'); process.exit(1); @@ -46,7 +79,7 @@ export default async ({ args, info }: CliCommandContext) => { const schemaFlags = schemaToFlags(action.schema.input as any); - const flagArgs = args.filter(a => a !== actionId); + const flagArgs = args.filter((_, i) => i !== actionIdIdx); const { flags } = cli( { diff --git a/packages/cli-module-actions/src/lib/resolveAuth.test.ts b/packages/cli-module-actions/src/lib/resolveAuth.test.ts new file mode 100644 index 0000000000..f2702cf757 --- /dev/null +++ b/packages/cli-module-actions/src/lib/resolveAuth.test.ts @@ -0,0 +1,124 @@ +/* + * Copyright 2025 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 { resolveAuth } from './resolveAuth'; +import { + getSelectedInstance, + getInstanceConfig, + accessTokenNeedsRefresh, + refreshAccessToken, + getSecretStore, + type StoredInstance, +} from '@backstage/cli-module-auth'; + +jest.mock('@backstage/cli-module-auth', () => ({ + getSelectedInstance: jest.fn(), + getInstanceConfig: jest.fn(), + accessTokenNeedsRefresh: jest.fn(), + refreshAccessToken: jest.fn(), + getSecretStore: jest.fn(), +})); + +const mockGetSelectedInstance = getSelectedInstance as jest.MockedFunction< + typeof getSelectedInstance +>; +const mockGetInstanceConfig = getInstanceConfig as jest.MockedFunction< + typeof getInstanceConfig +>; +const mockAccessTokenNeedsRefresh = + accessTokenNeedsRefresh as jest.MockedFunction< + typeof accessTokenNeedsRefresh + >; +const mockRefreshAccessToken = refreshAccessToken as jest.MockedFunction< + typeof refreshAccessToken +>; +const mockGetSecretStore = getSecretStore as jest.MockedFunction< + typeof getSecretStore +>; + +describe('resolveAuth', () => { + const mockInstance: StoredInstance = { + name: 'production', + baseUrl: 'https://backstage.example.com', + clientId: 'my-client', + issuedAt: Date.now(), + accessTokenExpiresAt: Date.now() + 3600_000, + }; + + const mockSecretStore = { + get: jest.fn(), + set: jest.fn(), + delete: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetSelectedInstance.mockResolvedValue(mockInstance); + mockAccessTokenNeedsRefresh.mockReturnValue(false); + mockGetSecretStore.mockResolvedValue(mockSecretStore); + mockSecretStore.get.mockResolvedValue('test-access-token'); + mockGetInstanceConfig.mockResolvedValue(['catalog', 'scaffolder']); + }); + + it('resolves auth with the selected instance and stored token', async () => { + const result = await resolveAuth(); + + expect(mockGetSelectedInstance).toHaveBeenCalledWith(undefined); + expect(mockAccessTokenNeedsRefresh).toHaveBeenCalledWith(mockInstance); + expect(mockRefreshAccessToken).not.toHaveBeenCalled(); + expect(result).toEqual({ + instance: mockInstance, + accessToken: 'test-access-token', + pluginSources: ['catalog', 'scaffolder'], + }); + }); + + it('passes instance name flag to getSelectedInstance', async () => { + await resolveAuth('staging'); + + expect(mockGetSelectedInstance).toHaveBeenCalledWith('staging'); + }); + + it('refreshes the access token when it is about to expire', async () => { + const refreshedInstance = { + ...mockInstance, + accessTokenExpiresAt: Date.now() + 7200_000, + }; + mockAccessTokenNeedsRefresh.mockReturnValue(true); + mockRefreshAccessToken.mockResolvedValue(refreshedInstance); + + const result = await resolveAuth(); + + expect(mockRefreshAccessToken).toHaveBeenCalledWith('production'); + expect(result.instance).toBe(refreshedInstance); + }); + + it('throws when no access token is stored', async () => { + mockSecretStore.get.mockResolvedValue(undefined); + + await expect(resolveAuth()).rejects.toThrow( + 'No access token found. Run "auth login" to authenticate.', + ); + }); + + it('returns empty plugin sources when none are configured', async () => { + mockGetInstanceConfig.mockResolvedValue(undefined); + + const result = await resolveAuth(); + + expect(result.pluginSources).toEqual([]); + }); +}); diff --git a/packages/cli-module-auth/package.json b/packages/cli-module-auth/package.json index 86bb491f7a..94169e998e 100644 --- a/packages/cli-module-auth/package.json +++ b/packages/cli-module-auth/package.json @@ -35,7 +35,6 @@ "@backstage/cli-node": "workspace:^", "@backstage/errors": "workspace:^", "cleye": "^2.3.0", - "cross-fetch": "^4.0.0", "fs-extra": "^11.2.0", "glob": "^7.1.7", "inquirer": "^8.2.0", diff --git a/packages/cli-module-auth/src/lib/http.test.ts b/packages/cli-module-auth/src/lib/http.test.ts index 74416a0dee..40ab7009b1 100644 --- a/packages/cli-module-auth/src/lib/http.test.ts +++ b/packages/cli-module-auth/src/lib/http.test.ts @@ -14,12 +14,13 @@ * limitations under the License. */ -import fetch from 'cross-fetch'; import { httpJson } from './http'; -jest.mock('cross-fetch'); +const mockFetch = jest.fn() as jest.MockedFunction; -const mockFetch = fetch as jest.MockedFunction; +beforeEach(() => { + global.fetch = mockFetch; +}); describe('http', () => { beforeEach(() => { diff --git a/packages/cli-module-auth/src/lib/http.ts b/packages/cli-module-auth/src/lib/http.ts index 789c627606..4861a07722 100644 --- a/packages/cli-module-auth/src/lib/http.ts +++ b/packages/cli-module-auth/src/lib/http.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import fetch from 'cross-fetch'; import { ResponseError } from '@backstage/errors'; /** @public */ diff --git a/packages/cli/cli-report.md b/packages/cli/cli-report.md index bb22326786..4408c6e969 100644 --- a/packages/cli/cli-report.md +++ b/packages/cli/cli-report.md @@ -12,6 +12,7 @@ Options: -h, --help Commands: + actions [command] auth [command] build-workspace config [command] @@ -31,6 +32,83 @@ Commands: versions:migrate ``` +### `backstage-cli actions` + +``` +Usage: backstage-cli actions [options] [command] [command] + +Options: + -h, --help + +Commands: + execute + help [command] + list + sources [command] +``` + +### `backstage-cli actions execute` + +``` +Usage: backstage-cli actions execute + +Options: + --instance + -h, --help +``` + +### `backstage-cli actions list` + +``` +Usage: backstage-cli actions list + +Options: + --instance + -h, --help +``` + +### `backstage-cli actions sources` + +``` +Usage: backstage-cli actions sources [options] [command] [command] + +Options: + -h, --help + +Commands: + add + help [command] + list + remove +``` + +### `backstage-cli actions sources add` + +``` +Usage: backstage-cli actions sources add + +Options: + -h, --help +``` + +### `backstage-cli actions sources list` + +``` +Usage: backstage-cli actions sources list + +Options: + -h, --help +``` + +### `backstage-cli actions sources remove` + +``` +Usage: backstage-cli actions sources remove + +Options: + -h, --help +``` + ### `backstage-cli auth` ``` diff --git a/yarn.lock b/yarn.lock index daff483d5a..a01ca993a3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2830,6 +2830,8 @@ __metadata: "@backstage/cli-module-auth": "workspace:^" "@backstage/cli-node": "workspace:^" cleye: "npm:^2.3.0" + bin: + cli-module-actions: bin/backstage-cli-module-actions languageName: unknown linkType: soft @@ -2844,7 +2846,6 @@ __metadata: "@types/fs-extra": "npm:^11.0.0" "@types/proper-lockfile": "npm:^4" cleye: "npm:^2.3.0" - cross-fetch: "npm:^4.0.0" fs-extra: "npm:^11.2.0" glob: "npm:^7.1.7" inquirer: "npm:^8.2.0" From edf2b775815b21153c9fe779f28be673e50add19 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 17 Mar 2026 10:54:26 +0100 Subject: [PATCH 14/91] cli-module-new: add template for CLI module packages Add a new `cli-module` template to the Backstage CLI that scaffolds CLI module packages. This includes adding the `cli-module` role to the template system, with proper naming conventions and prompts. The generated package includes: - A bin entry point for standalone execution - An index.ts with createCliModule setup - An example command using cleye - Standard package.json with cli-module role Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .changeset/add-cli-module-template-support.md | 5 +++ .changeset/add-cli-module-template.md | 5 +++ .../src/lib/defaultTemplates.ts | 1 + .../collectPortableTemplateInput.ts | 1 + .../preparation/resolvePackageParams.test.ts | 7 ++++ .../lib/preparation/resolvePackageParams.ts | 2 ++ packages/cli-module-new/src/lib/types.ts | 3 +- .../cli/templates/cli-module/.eslintrc.js.hbs | 1 + .../cli/templates/cli-module/README.md.hbs | 5 +++ .../cli/templates/cli-module/bin/{{binName}} | 32 +++++++++++++++++ .../cli/templates/cli-module/package.json.hbs | 35 +++++++++++++++++++ .../cli-module/portable-template.yaml | 5 +++ .../cli-module/src/commands/example.ts | 18 ++++++++++ .../cli/templates/cli-module/src/index.ts.hbs | 20 +++++++++++ 14 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 .changeset/add-cli-module-template-support.md create mode 100644 .changeset/add-cli-module-template.md create mode 100644 packages/cli/templates/cli-module/.eslintrc.js.hbs create mode 100644 packages/cli/templates/cli-module/README.md.hbs create mode 100644 packages/cli/templates/cli-module/bin/{{binName}} create mode 100644 packages/cli/templates/cli-module/package.json.hbs create mode 100644 packages/cli/templates/cli-module/portable-template.yaml create mode 100644 packages/cli/templates/cli-module/src/commands/example.ts create mode 100644 packages/cli/templates/cli-module/src/index.ts.hbs diff --git a/.changeset/add-cli-module-template-support.md b/.changeset/add-cli-module-template-support.md new file mode 100644 index 0000000000..be965d882d --- /dev/null +++ b/.changeset/add-cli-module-template-support.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli-module-new': patch +--- + +Added support for the `cli-module` template role for scaffolding new CLI module packages. diff --git a/.changeset/add-cli-module-template.md b/.changeset/add-cli-module-template.md new file mode 100644 index 0000000000..f6b169906f --- /dev/null +++ b/.changeset/add-cli-module-template.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Added a new `cli-module` template for creating CLI module packages. diff --git a/packages/cli-module-new/src/lib/defaultTemplates.ts b/packages/cli-module-new/src/lib/defaultTemplates.ts index 9d1543c452..dea35a6e99 100644 --- a/packages/cli-module-new/src/lib/defaultTemplates.ts +++ b/packages/cli-module-new/src/lib/defaultTemplates.ts @@ -23,6 +23,7 @@ export const defaultTemplates = [ '@backstage/cli/templates/plugin-common-library', '@backstage/cli/templates/web-library', '@backstage/cli/templates/node-library', + '@backstage/cli/templates/cli-module', '@backstage/cli/templates/catalog-provider-module', '@backstage/cli/templates/scaffolder-backend-module', ]; diff --git a/packages/cli-module-new/src/lib/preparation/collectPortableTemplateInput.ts b/packages/cli-module-new/src/lib/preparation/collectPortableTemplateInput.ts index 7279801d62..63e3791a49 100644 --- a/packages/cli-module-new/src/lib/preparation/collectPortableTemplateInput.ts +++ b/packages/cli-module-new/src/lib/preparation/collectPortableTemplateInput.ts @@ -160,6 +160,7 @@ export function getPromptsForRole( case 'web-library': case 'node-library': case 'common-library': + case 'cli-module': return [namePrompt()]; case 'plugin-web-library': case 'plugin-node-library': diff --git a/packages/cli-module-new/src/lib/preparation/resolvePackageParams.test.ts b/packages/cli-module-new/src/lib/preparation/resolvePackageParams.test.ts index df128d4755..f279b702b1 100644 --- a/packages/cli-module-new/src/lib/preparation/resolvePackageParams.test.ts +++ b/packages/cli-module-new/src/lib/preparation/resolvePackageParams.test.ts @@ -37,6 +37,13 @@ describe.each([ packagePath: 'packages/test', }, ], + [ + { role: 'cli-module', name: 'test' }, + { + packageName: '@internal/cli-module-test', + packagePath: 'packages/cli-module-test', + }, + ], [ { role: 'plugin-web-library', pluginId: 'test' }, { diff --git a/packages/cli-module-new/src/lib/preparation/resolvePackageParams.ts b/packages/cli-module-new/src/lib/preparation/resolvePackageParams.ts index dd85f49fad..8a4f87c5c6 100644 --- a/packages/cli-module-new/src/lib/preparation/resolvePackageParams.ts +++ b/packages/cli-module-new/src/lib/preparation/resolvePackageParams.ts @@ -48,6 +48,8 @@ function getBaseNameForRole( case 'node-library': case 'common-library': return roleParams.name; + case 'cli-module': + return `cli-module-${roleParams.name}`; case 'plugin-web-library': return `${roleParams.pluginId}-react`; case 'plugin-node-library': diff --git a/packages/cli-module-new/src/lib/types.ts b/packages/cli-module-new/src/lib/types.ts index 9f221ea7aa..83a195a43a 100644 --- a/packages/cli-module-new/src/lib/types.ts +++ b/packages/cli-module-new/src/lib/types.ts @@ -50,6 +50,7 @@ export const TEMPLATE_ROLES = [ 'web-library', 'node-library', 'common-library', + 'cli-module', 'plugin-web-library', 'plugin-node-library', 'plugin-common-library', @@ -80,7 +81,7 @@ export type PortableTemplateParams = { export type PortableTemplateInputRoleParams = | { - role: 'web-library' | 'node-library' | 'common-library'; + role: 'web-library' | 'node-library' | 'common-library' | 'cli-module'; name: string; } | { diff --git a/packages/cli/templates/cli-module/.eslintrc.js.hbs b/packages/cli/templates/cli-module/.eslintrc.js.hbs new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/packages/cli/templates/cli-module/.eslintrc.js.hbs @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/packages/cli/templates/cli-module/README.md.hbs b/packages/cli/templates/cli-module/README.md.hbs new file mode 100644 index 0000000000..657b45805b --- /dev/null +++ b/packages/cli/templates/cli-module/README.md.hbs @@ -0,0 +1,5 @@ +# {{packageName}} + +A CLI module that adds commands to the Backstage CLI. + +_This package was created through the Backstage CLI_ diff --git a/packages/cli/templates/cli-module/bin/{{binName}} b/packages/cli/templates/cli-module/bin/{{binName}} new file mode 100644 index 0000000000..59f8267233 --- /dev/null +++ b/packages/cli/templates/cli-module/bin/{{binName}} @@ -0,0 +1,32 @@ +#!/usr/bin/env node +/* + * Copyright 2025 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. + */ + +const path = require('node:path'); + +/* eslint-disable-next-line no-restricted-syntax */ +const isLocal = require('node:fs').existsSync( + path.resolve(__dirname, '../src'), +); + +if (isLocal) { + require('@backstage/cli-node/config/nodeTransform.cjs'); +} + +const { runCliModule } = require('@backstage/cli-node'); +const cliModule = require(isLocal ? '../src/index' : '..').default; +const pkg = require('../package.json'); +runCliModule({ module: cliModule, name: pkg.name, version: pkg.version }); diff --git a/packages/cli/templates/cli-module/package.json.hbs b/packages/cli/templates/cli-module/package.json.hbs new file mode 100644 index 0000000000..5e077cca83 --- /dev/null +++ b/packages/cli/templates/cli-module/package.json.hbs @@ -0,0 +1,35 @@ +{ + "name": "{{packageName}}", + "description": "CLI module for Backstage CLI", + "main": "src/index.ts", + "types": "src/index.ts", + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "cli-module" + }, + "scripts": { + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/cli-common": "{{versionQuery '@backstage/cli-common'}}", + "@backstage/cli-node": "{{versionQuery '@backstage/cli-node'}}", + "cleye": "^2.3.0" + }, + "devDependencies": { + "@backstage/cli": "{{versionQuery '@backstage/cli'}}" + }, + "files": [ + "dist", + "bin" + ], + "bin": "bin/{{binName}}" +} diff --git a/packages/cli/templates/cli-module/portable-template.yaml b/packages/cli/templates/cli-module/portable-template.yaml new file mode 100644 index 0000000000..72b6c2786b --- /dev/null +++ b/packages/cli/templates/cli-module/portable-template.yaml @@ -0,0 +1,5 @@ +name: cli-module +role: cli-module +description: A CLI module that adds commands to the Backstage CLI +values: + binName: 'backstage-cli-module-{{ name }}' diff --git a/packages/cli/templates/cli-module/src/commands/example.ts b/packages/cli/templates/cli-module/src/commands/example.ts new file mode 100644 index 0000000000..03a8f88fc2 --- /dev/null +++ b/packages/cli/templates/cli-module/src/commands/example.ts @@ -0,0 +1,18 @@ +import { cli } from 'cleye'; +import type { CliCommandContext } from '@backstage/cli-node'; + +export default async ({ args, info }: CliCommandContext) => { + const { flags } = cli( + { + help: info, + booleanFlagNegation: true, + flags: {}, + }, + undefined, + args, + ); + + void flags; + + console.log('Hello from example command!'); +}; diff --git a/packages/cli/templates/cli-module/src/index.ts.hbs b/packages/cli/templates/cli-module/src/index.ts.hbs new file mode 100644 index 0000000000..a947d3d666 --- /dev/null +++ b/packages/cli/templates/cli-module/src/index.ts.hbs @@ -0,0 +1,20 @@ +/***/ +/** + * CLI module for the Backstage CLI. + * + * @packageDocumentation + */ + +import { createCliModule } from '@backstage/cli-node'; +import packageJson from '../package.json'; + +export default createCliModule({ + packageJson, + init: async reg => { + reg.addCommand({ + path: ['example'], + description: 'An example command', + execute: { loader: () => import('./commands/example') }, + }); + }, +}); From 7f05f5759b703ed3243c7464b669751b186b3ff1 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 17 Mar 2026 10:59:07 +0100 Subject: [PATCH 15/91] Use --name flag in example command template Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../cli/templates/cli-module/src/commands/example.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/cli/templates/cli-module/src/commands/example.ts b/packages/cli/templates/cli-module/src/commands/example.ts index 03a8f88fc2..66722307b0 100644 --- a/packages/cli/templates/cli-module/src/commands/example.ts +++ b/packages/cli/templates/cli-module/src/commands/example.ts @@ -6,13 +6,17 @@ export default async ({ args, info }: CliCommandContext) => { { help: info, booleanFlagNegation: true, - flags: {}, + flags: { + name: { + type: String, + description: 'Your name', + }, + }, }, undefined, args, ); - void flags; - - console.log('Hello from example command!'); + const name = flags.name ?? 'World'; + console.log(`Hello, ${name}!`); }; From cc0693ec40f9e0416b2152d6a22f20ab77f1f463 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 17 Mar 2026 11:05:00 +0100 Subject: [PATCH 16/91] api-ref: move opaque helper to frontend-internal Share the internal ApiRef opaque helper through frontend-internal and fail fast when ApiRef-shaped values have an unsupported opaque version. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../src/wiring/createSpecializedApp.test.tsx | 73 +++++++++---------- .../src/wiring/createSpecializedApp.tsx | 13 +--- .../src/apis/OpaqueApiRef.ts | 31 ++++++++ packages/frontend-internal/src/apis/index.ts | 17 +++++ packages/frontend-internal/src/index.ts | 1 + .../src/apis/system/ApiRef.ts | 16 +--- 6 files changed, 87 insertions(+), 64 deletions(-) create mode 100644 packages/frontend-internal/src/apis/OpaqueApiRef.ts create mode 100644 packages/frontend-internal/src/apis/index.ts diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx index 8a4e2b5c9b..5a011c058e 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx @@ -423,7 +423,7 @@ describe('createSpecializedApp', () => { expect(app.apis.get(testApiRef)).toEqual({ value: 'owner' }); }); - it('should ignore plugin ownership metadata from unsupported opaque ApiRefs', () => { + it('should reject unsupported opaque ApiRef versions', () => { const testApiRef = { $$type: '@backstage/ApiRef', version: 'v0', @@ -439,46 +439,39 @@ describe('createSpecializedApp', () => { readonly 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(() => + 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.apis.get(testApiRef)).toEqual({ value: 'other' }); + ).toThrow("Invalid opaque type instance, got version 'v0', expected 'v1'"); }); it('should not infer app ownership from core-prefixed API ids', () => { diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx index 20c00c8929..0c47dca6f6 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx @@ -43,6 +43,7 @@ import { import { ApiFactoryRegistry, ApiResolver } from '@backstage/core-app-api'; import { createExtensionDataContainer, + OpaqueApiRef, OpaqueFrontendPlugin, } from '@internal/frontend'; @@ -51,8 +52,6 @@ import { resolveExtensionDefinition, toInternalExtension, } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition'; -// eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { OpaqueApiRef } from '../../../frontend-plugin-api/src/apis/system/ApiRef'; import { extractRouteInfoFromAppNode, @@ -459,13 +458,9 @@ function createApiFactories(options: { // might need to wait for some future update for API factories. function getApiOwnerId(apiRef: { id: string }): string { if (OpaqueApiRef.isType(apiRef)) { - try { - const { pluginId } = OpaqueApiRef.toInternal(apiRef); - if (pluginId) { - return pluginId; - } - } catch { - // Fall back to legacy ID inference for unsupported opaque ApiRef versions. + const { pluginId } = OpaqueApiRef.toInternal(apiRef); + if (pluginId) { + return pluginId; } } diff --git a/packages/frontend-internal/src/apis/OpaqueApiRef.ts b/packages/frontend-internal/src/apis/OpaqueApiRef.ts new file mode 100644 index 0000000000..8a1b058641 --- /dev/null +++ b/packages/frontend-internal/src/apis/OpaqueApiRef.ts @@ -0,0 +1,31 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { ApiRef } from '@backstage/frontend-plugin-api'; +import { OpaqueType } from '@internal/opaque'; + +export const OpaqueApiRef = OpaqueType.create<{ + public: ApiRef & { + readonly $$type: '@backstage/ApiRef'; + }; + versions: { + readonly version: 'v1'; + readonly pluginId?: string; + }; +}>({ + type: '@backstage/ApiRef', + versions: ['v1'], +}); diff --git a/packages/frontend-internal/src/apis/index.ts b/packages/frontend-internal/src/apis/index.ts new file mode 100644 index 0000000000..f445683652 --- /dev/null +++ b/packages/frontend-internal/src/apis/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './OpaqueApiRef'; diff --git a/packages/frontend-internal/src/index.ts b/packages/frontend-internal/src/index.ts index 38bfdc53f8..447b488fca 100644 --- a/packages/frontend-internal/src/index.ts +++ b/packages/frontend-internal/src/index.ts @@ -15,4 +15,5 @@ */ export * from './routing'; +export * from './apis'; export * from './wiring'; diff --git a/packages/frontend-plugin-api/src/apis/system/ApiRef.ts b/packages/frontend-plugin-api/src/apis/system/ApiRef.ts index 2327cb0631..557341d98a 100644 --- a/packages/frontend-plugin-api/src/apis/system/ApiRef.ts +++ b/packages/frontend-plugin-api/src/apis/system/ApiRef.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { OpaqueType } from '@internal/opaque'; +import { OpaqueApiRef } from '@internal/frontend'; import type { ApiRef } from './types'; /** @@ -31,20 +31,6 @@ type ApiRefBuilderConfig = { pluginId?: string; }; -/** @internal */ -export const OpaqueApiRef = OpaqueType.create<{ - public: ApiRef & { - readonly $$type: '@backstage/ApiRef'; - }; - versions: { - readonly version: 'v1'; - readonly pluginId?: string; - }; -}>({ - type: '@backstage/ApiRef', - versions: ['v1'], -}); - function validateId(id: string): void { const valid = id .split('.') From ac560a24cb0941f5dd1cf74af28d5d3380c35cf2 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 17 Mar 2026 11:27:46 +0100 Subject: [PATCH 17/91] Regenerate API reports Update downstream app plugin API reports after the ApiRef type changes affected generated union output. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- plugins/app-react/report.api.md | 4 ++-- plugins/app/report.api.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/app-react/report.api.md b/plugins/app-react/report.api.md index a4e63e254b..4e3fff6e42 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]: IconComponent | IconElement; + [x: string]: IconElement | IconComponent; }, 'core.icons', {} @@ -97,7 +97,7 @@ export const IconBundleBlueprint: ExtensionBlueprint<{ dataRefs: { icons: ConfigurableExtensionDataRef< { - [x: string]: IconComponent | IconElement; + [x: string]: IconElement | IconComponent; }, 'core.icons', {} diff --git a/plugins/app/report.api.md b/plugins/app/report.api.md index 0262c4c1ef..8347e5f447 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]: IconComponent | IconElement; + [x: string]: IconElement | IconComponent; }, 'core.icons', {} From 5fd78ba82f534014c7440f96dac94a65e32ae8e9 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 17 Mar 2026 11:37:13 +0100 Subject: [PATCH 18/91] frontend-plugin-api, frontend-app-api: API review cleanup Remove @backstage/core-plugin-api leakage from the @backstage/frontend-app-api public API surface. Rename PluginOptions to CreateFrontendPluginOptions with a deprecated alias. Remove ResolvedExtensionInputs from the main @backstage/frontend-plugin-api entry point. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .changeset/fix-api-review-frontend-app-api.md | 5 ++ .../fix-api-review-frontend-plugin-api.md | 5 ++ packages/frontend-app-api/report.api.md | 7 +- .../src/tree/instantiateAppNodeTree.ts | 3 +- .../src/wiring/createPluginInfoAttacher.ts | 2 +- .../src/wiring/createSpecializedApp.test.tsx | 3 +- .../src/wiring/createSpecializedApp.tsx | 28 ++++---- .../src/wiring/InternalExtensionDefinition.ts | 3 +- packages/frontend-plugin-api/report.api.md | 68 ++++++++++--------- .../src/wiring/createExtension.ts | 3 +- .../src/wiring/createFrontendPlugin.ts | 26 ++++++- .../frontend-plugin-api/src/wiring/index.ts | 2 +- 12 files changed, 95 insertions(+), 60 deletions(-) create mode 100644 .changeset/fix-api-review-frontend-app-api.md create mode 100644 .changeset/fix-api-review-frontend-plugin-api.md diff --git a/.changeset/fix-api-review-frontend-app-api.md b/.changeset/fix-api-review-frontend-app-api.md new file mode 100644 index 0000000000..ff287ac2a5 --- /dev/null +++ b/.changeset/fix-api-review-frontend-app-api.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-app-api': patch +--- + +Removed `@backstage/core-plugin-api` leakage from the public API surface. All types such as `ApiHolder` and `ConfigApi` are now imported from `@backstage/frontend-plugin-api`. diff --git a/.changeset/fix-api-review-frontend-plugin-api.md b/.changeset/fix-api-review-frontend-plugin-api.md new file mode 100644 index 0000000000..10458d8ebb --- /dev/null +++ b/.changeset/fix-api-review-frontend-plugin-api.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-plugin-api': minor +--- + +Renamed `PluginOptions` to `CreateFrontendPluginOptions` and deprecated the old name. Removed `ResolvedExtensionInputs` from the main entry point; it is still available as an inline type in extension factory signatures. diff --git a/packages/frontend-app-api/report.api.md b/packages/frontend-app-api/report.api.md index bf69cc83e6..0a0b23203b 100644 --- a/packages/frontend-app-api/report.api.md +++ b/packages/frontend-app-api/report.api.md @@ -3,11 +3,10 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import { ApiHolder } from '@backstage/core-plugin-api'; -import { ApiHolder as ApiHolder_2 } from '@backstage/frontend-plugin-api'; +import { ApiHolder } from '@backstage/frontend-plugin-api'; import { AppNode } from '@backstage/frontend-plugin-api'; import { AppTree } from '@backstage/frontend-plugin-api'; -import { ConfigApi } from '@backstage/core-plugin-api'; +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'; @@ -186,7 +185,7 @@ export type ExtensionFactoryMiddleware = ( }) => ExtensionDataContainer, context: { node: AppNode; - apis: ApiHolder_2; + apis: ApiHolder; config?: JsonObject; }, ) => Iterable>; diff --git a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts index 9e8e3cc2eb..3de09acf41 100644 --- a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts +++ b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts @@ -19,8 +19,9 @@ import { ExtensionDataContainer, ExtensionDataRef, ExtensionInput, - ResolvedExtensionInputs, } from '@backstage/frontend-plugin-api'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { ResolvedExtensionInputs } from '../../../frontend-plugin-api/src/wiring/createExtension'; import { ExtensionFactoryMiddleware } from '../wiring/types'; import mapValues from 'lodash/mapValues'; import { AppNode, AppNodeInstance } from '@backstage/frontend-plugin-api'; diff --git a/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.ts b/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.ts index bfab0ae9a3..9a88590d7c 100644 --- a/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.ts +++ b/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { ConfigApi } from '@backstage/core-plugin-api'; +import { ConfigApi } from '@backstage/frontend-plugin-api'; import { FrontendFeature, FrontendPluginInfo, diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx index 51c31aca25..aabb725f8a 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx @@ -28,12 +28,13 @@ import { createExtensionInput, useRouteRef, analyticsApiRef, + configApiRef, createExtensionDataRef, + featureFlagsApiRef, } from '@backstage/frontend-plugin-api'; import { screen, render } from '@testing-library/react'; import { createSpecializedApp } from './createSpecializedApp'; import { mockApis, TestApiRegistry } from '@backstage/test-utils'; -import { configApiRef, featureFlagsApiRef } from '@backstage/core-plugin-api'; import { MemoryRouter } from 'react-router-dom'; import { ApiProvider, ConfigReader } from '@backstage/core-app-api'; import { Fragment } from 'react'; diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx index fda00b70b1..63d9924147 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx @@ -16,30 +16,28 @@ import { ConfigReader } from '@backstage/config'; import { + AnyApiFactory, ApiBlueprint, + ApiHolder, + AppNode, AppTree, AppTreeApi, appTreeApiRef, - RouteRef, - ExternalRouteRef, - SubRouteRef, AnyRouteRefParams, - RouteFunc, - RouteResolutionApi, - createApiFactory, - routeResolutionApiRef, - AppNode, - FrontendFeature, -} from '@backstage/frontend-plugin-api'; -import { ExtensionFactoryMiddleware } from './types'; -import { - AnyApiFactory, - ApiHolder, ConfigApi, configApiRef, + createApiFactory, + ExternalRouteRef, featureFlagsApiRef, + FrontendFeature, identityApiRef, -} from '@backstage/core-plugin-api'; + RouteFunc, + RouteRef, + RouteResolutionApi, + routeResolutionApiRef, + SubRouteRef, +} from '@backstage/frontend-plugin-api'; +import { ExtensionFactoryMiddleware } from './types'; import { ApiFactoryRegistry, ApiResolver } from '@backstage/core-app-api'; import { createExtensionDataContainer, diff --git a/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts b/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts index b7c3674f9a..d6a4eb2eab 100644 --- a/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts +++ b/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts @@ -24,8 +24,9 @@ import { ExtensionDefinitionParameters, ExtensionInput, PortableSchema, - ResolvedExtensionInputs, } from '@backstage/frontend-plugin-api'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { ResolvedExtensionInputs } from '../../../frontend-plugin-api/src/wiring/createExtension'; import { OpaqueType } from '@internal/opaque'; export const OpaqueExtensionDefinition = OpaqueType.create<{ diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index 768f26c0eb..b5a24b0eaa 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -729,13 +729,45 @@ 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) + info?: FrontendPluginInfoOptions; + // (undocumented) + pluginId: TId; + // (undocumented) + routes?: TRoutes; + title?: string; +} + // @public export function createRouteRef< TParams extends @@ -1847,8 +1879,8 @@ export type PluginHeaderActionsApi = { // @public export const pluginHeaderActionsApiRef: ApiRef_2; -// @public (undocumented) -export interface PluginOptions< +// @public @deprecated (undocumented) +export type PluginOptions< TId extends string, TRoutes extends { [name in string]: RouteRef | SubRouteRef; @@ -1857,22 +1889,7 @@ export interface PluginOptions< [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; -} +> = CreateFrontendPluginOptions; // @public export type PluginWrapperApi = { @@ -1950,19 +1967,6 @@ 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/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts index 329205ae76..0a1e9c91f8 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts @@ -53,7 +53,8 @@ type ResolvedExtensionInput = /** * Converts an extension input map into a matching collection of resolved inputs. - * @public + * + * @ignore */ export type ResolvedExtensionInputs< TInputs extends { diff --git a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts index 08329b6fd4..f4e9a18990 100644 --- a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts +++ b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts @@ -170,8 +170,12 @@ export interface FrontendPlugin< info(): Promise; } -/** @public */ -export interface PluginOptions< +/** + * Options for {@link createFrontendPlugin}. + * + * @public + */ +export interface CreateFrontendPluginOptions< TId extends string, TRoutes extends { [name in string]: RouteRef | SubRouteRef }, TExternalRoutes extends { [name in string]: ExternalRouteRef }, @@ -194,6 +198,17 @@ export interface PluginOptions< info?: FrontendPluginInfoOptions; } +/** + * @deprecated Use {@link CreateFrontendPluginOptions} instead. + * @public + */ +export type PluginOptions< + TId extends string, + TRoutes extends { [name in string]: RouteRef | SubRouteRef }, + TExternalRoutes extends { [name in string]: ExternalRouteRef }, + TExtensions extends readonly ExtensionDefinition[], +> = CreateFrontendPluginOptions; + /** * Creates a new plugin that can be installed in a Backstage app. * @@ -230,7 +245,12 @@ export function createFrontendPlugin< TRoutes extends { [name in string]: RouteRef | SubRouteRef } = {}, TExternalRoutes extends { [name in string]: ExternalRouteRef } = {}, >( - options: PluginOptions, + options: CreateFrontendPluginOptions< + TId, + TRoutes, + TExternalRoutes, + TExtensions + >, ): OverridableFrontendPlugin< TRoutes, TExternalRoutes, diff --git a/packages/frontend-plugin-api/src/wiring/index.ts b/packages/frontend-plugin-api/src/wiring/index.ts index d3acef46b6..f3a39cc60d 100644 --- a/packages/frontend-plugin-api/src/wiring/index.ts +++ b/packages/frontend-plugin-api/src/wiring/index.ts @@ -22,7 +22,6 @@ export { type ExtensionDefinitionParameters, type CreateExtensionOptions, type OverridableExtensionDefinition, - type ResolvedExtensionInputs, } from './createExtension'; export { createExtensionInput, @@ -36,6 +35,7 @@ export { } from './createExtensionDataRef'; export { createFrontendPlugin, + type CreateFrontendPluginOptions, type FrontendPlugin, type OverridableFrontendPlugin, type PluginOptions, From f6f53a282fccaf11cb1c2eca70d7083ed4b3afcd Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 17 Mar 2026 11:44:46 +0100 Subject: [PATCH 19/91] chore: retrigger CI for flaky test failure The `plugins/api-docs/src/alpha.test.tsx` test failure in Test 24.x was a flaky failure (passes locally and on Node 22.x). Signed-off-by: Patrik Oldsberg Made-with: Cursor From 925e0895b32ae94f2b331abb4ed559be5353be7e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 5 Mar 2026 15:40:42 +0100 Subject: [PATCH 20/91] 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 21/91] 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 22/91] 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 23/91] 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 24/91] 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 25/91] 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 26/91] 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 27/91] 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 28/91] 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 29/91] 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 30/91] 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 31/91] 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 32/91] 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 33/91] 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 34/91] 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 35/91] 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 36/91] 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 37/91] 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 38/91] 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 39/91] 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 40/91] 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 41/91] 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 42/91] 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 43/91] 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 44/91] 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 45/91] 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 46/91] 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 47/91] 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 48/91] 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 49/91] 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 50/91] 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 51/91] 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 52/91] 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 53/91] 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 54/91] 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 55/91] 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 56/91] 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 57/91] 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 58/91] 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 59/91] 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 60/91] 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 61/91] 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 62/91] 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 63/91] 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 64/91] 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 65/91] 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 66/91] 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 67/91] 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 68/91] 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 69/91] 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 70/91] 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 71/91] 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 72/91] 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 73/91] 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 74/91] 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 75/91] 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 76/91] 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 77/91] 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 78/91] 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 79/91] 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 80/91] 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 81/91] 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 82/91] 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 83/91] 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 84/91] 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 85/91] 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 a6b84e13ec7e55b09950c97f8d2911317d6c6d32 Mon Sep 17 00:00:00 2001 From: Johan Persson Date: Tue, 17 Mar 2026 12:02:49 +0100 Subject: [PATCH 86/91] fix(ui): make Checkbox children optional with a11y warning Made children optional and only render the label wrapper div when children are provided. When used without children, a dev warning is shown if no aria-label or aria-labelledby is set. Signed-off-by: Johan Persson --- .changeset/fix-checkbox-empty-label-gap.md | 7 +++++++ packages/ui/report.api.md | 2 +- packages/ui/src/components/Checkbox/Checkbox.tsx | 14 ++++++++++++-- packages/ui/src/components/Checkbox/types.ts | 2 +- 4 files changed, 21 insertions(+), 4 deletions(-) create mode 100644 .changeset/fix-checkbox-empty-label-gap.md diff --git a/.changeset/fix-checkbox-empty-label-gap.md b/.changeset/fix-checkbox-empty-label-gap.md new file mode 100644 index 0000000000..323d8e7bab --- /dev/null +++ b/.changeset/fix-checkbox-empty-label-gap.md @@ -0,0 +1,7 @@ +--- +'@backstage/ui': patch +--- + +Made Checkbox `children` optional and added a dev warning when neither a visible label, `aria-label`, nor `aria-labelledby` is provided. The label wrapper div is no longer rendered when there are no children, removing the unnecessary gap. + +**Affected components:** Checkbox diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index 0b11df6055..364592a6ff 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -839,7 +839,7 @@ export const CheckboxDefinition: { // @public (undocumented) export type CheckboxOwnProps = { - children: React.ReactNode; + children?: React.ReactNode; className?: string; }; diff --git a/packages/ui/src/components/Checkbox/Checkbox.tsx b/packages/ui/src/components/Checkbox/Checkbox.tsx index d7b0bd6f76..84a420ad8b 100644 --- a/packages/ui/src/components/Checkbox/Checkbox.tsx +++ b/packages/ui/src/components/Checkbox/Checkbox.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { forwardRef } from 'react'; +import { forwardRef, useEffect } from 'react'; import { Checkbox as RACheckbox } from 'react-aria-components'; import type { CheckboxProps } from './types'; import { useDefinition } from '../../hooks/useDefinition'; @@ -29,6 +29,16 @@ export const Checkbox = forwardRef( props, ); const { classes, children } = ownProps; + const ariaLabel = restProps['aria-label']; + const ariaLabelledBy = restProps['aria-labelledby']; + + useEffect(() => { + if (!children && !ariaLabel && !ariaLabelledBy) { + console.warn( + 'Checkbox requires either a visible label, aria-label, or aria-labelledby for accessibility', + ); + } + }, [children, ariaLabel, ariaLabelledBy]); return ( ( )}
-
{children}
+ {children != null &&
{children}
} )} diff --git a/packages/ui/src/components/Checkbox/types.ts b/packages/ui/src/components/Checkbox/types.ts index d9b220eb2a..5cdef0e423 100644 --- a/packages/ui/src/components/Checkbox/types.ts +++ b/packages/ui/src/components/Checkbox/types.ts @@ -17,7 +17,7 @@ import type { CheckboxProps as RACheckboxProps } from 'react-aria-components'; /** @public */ export type CheckboxOwnProps = { - children: React.ReactNode; + children?: React.ReactNode; className?: string; }; From 545129a9d8fc68eeacf38fb508560595d87ddd8b Mon Sep 17 00:00:00 2001 From: Johan Persson Date: Tue, 17 Mar 2026 12:04:51 +0100 Subject: [PATCH 87/91] fix(ui): use aria-label for Table selection checkboxes Updated Table selection checkboxes to use aria-label instead of empty fragment children, improving accessibility and removing the unnecessary label gap in the selection cells. Signed-off-by: Johan Persson --- .changeset/fix-table-row-hover-selection.md | 7 +++++++ packages/ui/src/components/Table/components/Row.tsx | 4 +--- .../ui/src/components/Table/components/TableHeader.tsx | 4 +--- 3 files changed, 9 insertions(+), 6 deletions(-) create mode 100644 .changeset/fix-table-row-hover-selection.md diff --git a/.changeset/fix-table-row-hover-selection.md b/.changeset/fix-table-row-hover-selection.md new file mode 100644 index 0000000000..2543b22389 --- /dev/null +++ b/.changeset/fix-table-row-hover-selection.md @@ -0,0 +1,7 @@ +--- +'@backstage/ui': patch +--- + +Updated Table selection checkboxes to use `aria-label` instead of empty fragment children, improving accessibility and removing the unnecessary label gap in the selection cells. + +**Affected components:** Table diff --git a/packages/ui/src/components/Table/components/Row.tsx b/packages/ui/src/components/Table/components/Row.tsx index 9d4a15f601..6677f51308 100644 --- a/packages/ui/src/components/Table/components/Row.tsx +++ b/packages/ui/src/components/Table/components/Row.tsx @@ -73,9 +73,7 @@ export function Row(props: RowProps) { {selectionBehavior === 'toggle' && selectionMode === 'multiple' && ( - - <> - + )} diff --git a/packages/ui/src/components/Table/components/TableHeader.tsx b/packages/ui/src/components/Table/components/TableHeader.tsx index 4c7bce3488..8b1b8ff23d 100644 --- a/packages/ui/src/components/Table/components/TableHeader.tsx +++ b/packages/ui/src/components/Table/components/TableHeader.tsx @@ -43,9 +43,7 @@ export const TableHeader = (props: TableHeaderProps) => { className={classes.headSelection} > - - <> - + )} From 1f9682bf2894cb794f6d995d52ee584fb4188a46 Mon Sep 17 00:00:00 2001 From: Johan Persson Date: Tue, 17 Mar 2026 12:08:12 +0100 Subject: [PATCH 88/91] fix(ui): make Table row a bg consumer for correct hover/selection states Table rows now participate in the bg provider/consumer system, using the appropriate neutral token level based on their container background. On a neutral-1 surface (e.g. inside a Card), rows step up to neutral-2 tokens for hover, selected, pressed, and disabled states. Signed-off-by: Johan Persson --- .changeset/fix-table-row-bg-consumer.md | 7 +++ .../ui/src/components/Table/Table.module.css | 54 +++++++++++++++++++ .../src/components/Table/components/Row.tsx | 3 +- .../ui/src/components/Table/definition.ts | 1 + 4 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-table-row-bg-consumer.md diff --git a/.changeset/fix-table-row-bg-consumer.md b/.changeset/fix-table-row-bg-consumer.md new file mode 100644 index 0000000000..3c8d64198c --- /dev/null +++ b/.changeset/fix-table-row-bg-consumer.md @@ -0,0 +1,7 @@ +--- +'@backstage/ui': patch +--- + +Fixed Table row hover, selected, pressed, and disabled background states to use the correct neutral token level based on the container background. + +**Affected components:** Table diff --git a/packages/ui/src/components/Table/Table.module.css b/packages/ui/src/components/Table/Table.module.css index 62723db6c8..953ccbed65 100644 --- a/packages/ui/src/components/Table/Table.module.css +++ b/packages/ui/src/components/Table/Table.module.css @@ -121,6 +121,60 @@ background-color: var(--bui-bg-neutral-1-pressed); } + &[data-on-bg='neutral-1'] { + &:hover { + background-color: var(--bui-bg-neutral-2-hover); + } + + &[data-selected] { + background-color: var(--bui-bg-neutral-2-pressed); + } + + &[data-pressed] { + background-color: var(--bui-bg-neutral-2-pressed); + } + + &[data-disabled] { + background-color: var(--bui-bg-neutral-2-disabled); + } + } + + &[data-on-bg='neutral-2'] { + &:hover { + background-color: var(--bui-bg-neutral-3-hover); + } + + &[data-selected] { + background-color: var(--bui-bg-neutral-3-pressed); + } + + &[data-pressed] { + background-color: var(--bui-bg-neutral-3-pressed); + } + + &[data-disabled] { + background-color: var(--bui-bg-neutral-3-disabled); + } + } + + &[data-on-bg='neutral-3'] { + &:hover { + background-color: var(--bui-bg-neutral-4-hover); + } + + &[data-selected] { + background-color: var(--bui-bg-neutral-4-pressed); + } + + &[data-pressed] { + background-color: var(--bui-bg-neutral-4-pressed); + } + + &[data-disabled] { + background-color: var(--bui-bg-neutral-4-disabled); + } + } + &[data-href], &[data-selection-mode], &[data-react-aria-pressable='true'] { diff --git a/packages/ui/src/components/Table/components/Row.tsx b/packages/ui/src/components/Table/components/Row.tsx index 6677f51308..e82bc23e6f 100644 --- a/packages/ui/src/components/Table/components/Row.tsx +++ b/packages/ui/src/components/Table/components/Row.tsx @@ -30,7 +30,7 @@ import { Flex } from '../../Flex'; /** @public */ export function Row(props: RowProps) { - const { ownProps, restProps, analytics } = useDefinition( + const { ownProps, restProps, dataAttributes, analytics } = useDefinition( RowDefinition, props, ); @@ -85,6 +85,7 @@ export function Row(props: RowProps) { ()({ export const RowDefinition = defineComponent()({ styles, analytics: true, + bg: 'consumer', classNames: { root: 'bui-TableRow', cell: 'bui-TableCell', From a91000018a7ff4c61ce0f3beea40a184d7487d3c Mon Sep 17 00:00:00 2001 From: Johan Persson Date: Tue, 17 Mar 2026 12:42:49 +0100 Subject: [PATCH 89/91] fix(ui): improve screen reader output for Checkbox and Table selection Added aria-hidden to the Checkbox indicator div to prevent screen readers from announcing decorative icons. Added aria-label to the Table header selection column wrapper. Signed-off-by: Johan Persson --- packages/ui/src/components/Checkbox/Checkbox.tsx | 2 +- packages/ui/src/components/Table/components/TableHeader.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/Checkbox/Checkbox.tsx b/packages/ui/src/components/Checkbox/Checkbox.tsx index 84a420ad8b..e0d4605739 100644 --- a/packages/ui/src/components/Checkbox/Checkbox.tsx +++ b/packages/ui/src/components/Checkbox/Checkbox.tsx @@ -49,7 +49,7 @@ export const Checkbox = forwardRef( > {({ isIndeterminate }) => ( <> -
+