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 <poldsberg@gmail.com> Made-with: Cursor
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -338,12 +338,28 @@ export function createAppNodeInstance(options: {
|
||||
apis: ApiHolder;
|
||||
attachments: ReadonlyMap<string, AppNode[]>;
|
||||
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<string, unknown>();
|
||||
const extensionDataRefs = new Set<ExtensionDataRef<unknown>>();
|
||||
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<string, unknown>;
|
||||
}
|
||||
| Record<string, unknown>,
|
||||
): 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<string, unknown>;
|
||||
} =
|
||||
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;
|
||||
|
||||
@@ -40,6 +40,16 @@ export class FrontendApiRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
set(factory: AnyApiFactory) {
|
||||
this.factories.set(factory.api.id, factory);
|
||||
}
|
||||
|
||||
setAll(factories: Iterable<AnyApiFactory>) {
|
||||
for (const factory of factories) {
|
||||
this.set(factory);
|
||||
}
|
||||
}
|
||||
|
||||
get<T>(
|
||||
api: ApiRef<T>,
|
||||
): ApiFactory<T, T, { [name: string]: unknown }> | undefined {
|
||||
@@ -80,6 +90,17 @@ export class FrontendApiResolver implements ApiHolder {
|
||||
return this.load(ref);
|
||||
}
|
||||
|
||||
invalidate(apiRefIds?: Iterable<string>) {
|
||||
if (apiRefIds === undefined) {
|
||||
this.apis.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const apiRefId of apiRefIds) {
|
||||
this.apis.delete(apiRefId);
|
||||
}
|
||||
}
|
||||
|
||||
private load<T>(ref: ApiRef<T>, loading: AnyApiRef[] = []): T | undefined {
|
||||
const existing = this.apis.get(ref.id);
|
||||
if (existing) {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -24,6 +24,8 @@ const DEFAULT_WARNING_CODES: Array<keyof AppErrorTypes> = [
|
||||
'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 {
|
||||
|
||||
Reference in New Issue
Block a user