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}`, );