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 <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-03-16 17:02:01 +01:00
parent da11157729
commit d7ed46b83c
3 changed files with 90 additions and 4 deletions
+16 -2
View File
@@ -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.
@@ -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<boolean>({ 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<boolean>({ id: 'test.false' });
const dependentApiRef = createApiRef<string>({ 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,
});
});
});
@@ -107,7 +107,7 @@ export class FrontendApiResolver implements ApiHolder {
private load<T>(ref: ApiRef<T>, 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}`,
);