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 {