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 <poldsberg@gmail.com> Made-with: Cursor
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -90,6 +90,10 @@ export class FrontendApiResolver implements ApiHolder {
|
||||
return this.load(ref);
|
||||
}
|
||||
|
||||
isMaterialized(apiRefId: string) {
|
||||
return this.apis.has(apiRefId);
|
||||
}
|
||||
|
||||
invalidate(apiRefIds?: Iterable<string>) {
|
||||
if (apiRefIds === undefined) {
|
||||
this.apis.clear();
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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(<div>Bootstrap Reader</div>),
|
||||
];
|
||||
},
|
||||
}),
|
||||
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(<div>Final Reader</div>),
|
||||
];
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
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(<div>Bootstrap Element</div>),
|
||||
],
|
||||
}),
|
||||
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(<div>Final Reader</div>),
|
||||
];
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
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' }),
|
||||
|
||||
Reference in New Issue
Block a user