frontend-app-api: finalize bootstrap failures through app root

Resolve async finalization failures by building a finalized app that throws from app/root.children, and simplify the identity proxy target callback to a synchronous fire-and-forget hook.

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-03-16 10:14:38 +01:00
parent 89fd91eaf8
commit 217de172dc
2 changed files with 71 additions and 103 deletions
@@ -166,25 +166,19 @@ export class RouteResolutionApiProxy implements RouteResolutionApi {
export class PreparedAppIdentityProxy extends AppIdentityProxy {
#onTargetSet?:
| ((
identityApi: Parameters<AppIdentityProxy['setTarget']>[0],
) => Promise<void> | void)
| ((identityApi: Parameters<AppIdentityProxy['setTarget']>[0]) => void)
| undefined;
#onTargetError?: ((error: unknown) => void) | undefined;
setTargetHandlers(options: {
onTargetSet?(
identityApi: Parameters<AppIdentityProxy['setTarget']>[0],
): Promise<void> | void;
onTargetError?(error: unknown): void;
): void;
}) {
this.#onTargetSet = options.onTargetSet;
this.#onTargetError = options.onTargetError;
}
clearTargetHandlers() {
this.#onTargetSet = undefined;
this.#onTargetError = undefined;
}
override setTarget(
@@ -198,16 +192,8 @@ export class PreparedAppIdentityProxy extends AppIdentityProxy {
return;
}
const onTargetError = this.#onTargetError;
this.clearTargetHandlers();
try {
void Promise.resolve(onTargetSet(identityApi)).catch(error => {
onTargetError?.(error);
});
} catch (error) {
onTargetError?.(error);
}
onTargetSet(identityApi);
}
}
@@ -38,7 +38,7 @@ import {
OpaqueFrontendPlugin,
} from '@internal/frontend';
import { OpaqueType } from '@internal/opaque';
import { ComponentType, ReactNode, useSyncExternalStore } from 'react';
import { ComponentType, ReactNode } from 'react';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import {
@@ -150,12 +150,6 @@ type FinalizationState = {
reject(error: unknown): void;
};
type BootstrapErrorStore = {
getSnapshot(): Error | undefined;
subscribe(listener: () => void): () => void;
report(error: Error): void;
};
type InternalSpecializedAppSessionState = {
apis: ApiHolder;
identityApi?: IdentityApi;
@@ -372,7 +366,6 @@ export function prepareSpecializedApp(
apis: phase.apis,
predicateReferences,
});
const bootstrapErrorStore = createBootstrapErrorStore();
let signInRuntime: SignInRuntime | undefined;
let cachedSessionState = providedSessionState;
let sessionStatePromise: Promise<SpecializedAppSessionState> | undefined;
@@ -523,10 +516,49 @@ export function prepareSpecializedApp(
return finalizedApp;
}
function reportBootstrapFailure(error: unknown) {
const bootstrapFailure = asError(error);
bootstrapError = bootstrapFailure;
bootstrapErrorStore.report(bootstrapFailure);
function finalizeFromBootstrapError(error: Error): FinalizedSpecializedApp {
if (finalized) {
return finalized;
}
bootstrapError = error;
const finalizedSessionState =
cachedSessionState ??
OpaqueSpecializedAppSessionState.createInstance('v1', {
apis: phase.apis,
identityApi:
signInRuntime?.readyIdentityApi ?? providedSessionData?.identityApi,
predicateContext: EMPTY_PREDICATE_CONTEXT,
});
cachedSessionState = finalizedSessionState;
prepareFinalizedTree({
tree,
});
clearFinalizationBoundaryInstances(tree);
attachThrowingFinalizationChild(tree, error);
instantiateAndInitializePhaseTree({
tree,
apis: phase.apis,
collector,
extensionFactoryMiddleware: mergedExtensionFactoryMiddleware,
routeResolutionApi: phase.routeResolutionApi,
appTreeApi: phase.appTreeApi,
routeRefsById,
});
const element = tree.root.instance?.getData(coreExtensionData.reactElement);
if (!element) {
throw new Error('Expected finalized app tree to expose a root element');
}
const finalizedApp: FinalizedSpecializedApp = {
element,
sessionState: finalizedSessionState,
tree,
};
finalized = finalizedApp;
return finalizedApp;
}
function getFinalizationState(): FinalizationState {
@@ -571,10 +603,13 @@ export function prepareSpecializedApp(
finalization.resolve(finalizedApp);
})
.catch(error => {
finalizationState = undefined;
reportBootstrapFailure(error);
finalization.reject(bootstrapError ?? asError(error));
try {
const finalizedApp = finalizeFromBootstrapError(asError(error));
finalization.resolve(finalizedApp);
} catch (finalizationError) {
finalizationState = undefined;
finalization.reject(finalizationError);
}
});
return finalization.promise;
@@ -591,12 +626,7 @@ export function prepareSpecializedApp(
if (!providedSessionState) {
phase.identityApiProxy.setTargetHandlers({
onTargetSet(identityApi) {
return beginFinalization(
startSignInFinalize(runtime, identityApi),
).then(() => {});
},
onTargetError(error) {
reportBootstrapFailure(error);
beginFinalization(startSignInFinalize(runtime, identityApi));
},
});
}
@@ -610,7 +640,6 @@ export function prepareSpecializedApp(
appTreeApi: phase.appTreeApi,
extensionFactoryMiddleware: mergedExtensionFactoryMiddleware,
disableSignIn: Boolean(providedSessionState),
bootstrapErrorStore,
skipBootstrapChild({ child }) {
return bootstrapClassification.deferredRoots.has(child);
},
@@ -639,12 +668,6 @@ export function prepareSpecializedApp(
let subscribed = true;
if (bootstrapError) {
return () => {
subscribed = false;
};
}
if (finalized) {
const finalizedApp = finalized;
Promise.resolve().then(() => {
@@ -789,7 +812,6 @@ function createBootstrapApp(options: {
appTreeApi: AppTreeApiProxy;
extensionFactoryMiddleware?: ExtensionFactoryMiddleware;
disableSignIn?: boolean;
bootstrapErrorStore: BootstrapErrorStore;
skipBootstrapChild?(ctx: {
node: AppNode;
input: string;
@@ -800,11 +822,6 @@ function createBootstrapApp(options: {
bootstrapApp: BootstrapSpecializedApp;
requiresSignIn: boolean;
} {
prepareBootstrapErrorThrower({
tree: options.tree,
store: options.bootstrapErrorStore,
});
const signInPageNode = getAppRootNode(options.tree)?.edges.attachments.get(
'signInPage',
)?.[0];
@@ -817,6 +834,8 @@ function createBootstrapApp(options: {
routeResolutionApi: options.routeResolutionApi,
appTreeApi: options.appTreeApi,
routeRefsById: options.routeRefsById,
stopAtAttachment: ({ node, input }) =>
isSessionBoundaryAttachment(node, input),
skipChild: options.skipBootstrapChild,
onMissingApi: options.onMissingApi,
});
@@ -846,37 +865,6 @@ function prepareFinalizedTree(options: { tree: AppTree }) {
}
}
function prepareBootstrapErrorThrower(options: {
tree: AppTree;
store: BootstrapErrorStore;
}) {
const bootstrapChildNode = getAppRootNode(
options.tree,
)?.edges.attachments.get('children')?.[0];
if (!bootstrapChildNode) {
return;
}
function BootstrapErrorThrower() {
const bootstrapError = useSyncExternalStore(
options.store.subscribe,
options.store.getSnapshot,
options.store.getSnapshot,
);
if (bootstrapError) {
throw bootstrapError;
}
return null;
}
setNodeInstance(
bootstrapChildNode,
createReactElementInstance(<BootstrapErrorThrower />),
);
}
function clearFinalizationBoundaryInstances(tree: AppTree) {
clearNodeInstance(tree.root);
@@ -939,27 +927,21 @@ function createReactElementInstance(value: ReactNode): AppNodeInstance {
};
}
function createBootstrapErrorStore(): BootstrapErrorStore {
let snapshot: Error | undefined;
const listeners = new Set<() => void>();
function attachThrowingFinalizationChild(tree: AppTree, error: Error) {
const bootstrapChildNode =
getAppRootNode(tree)?.edges.attachments.get('children')?.[0];
if (!bootstrapChildNode) {
throw error;
}
return {
getSnapshot() {
return snapshot;
},
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
report(error) {
snapshot = error;
for (const listener of listeners) {
listener();
}
},
};
function ThrowBootstrapError(): never {
throw error;
}
setNodeInstance(
bootstrapChildNode,
createReactElementInstance(<ThrowBootstrapError />),
);
}
function setNodeInstance(node: AppNode, instance?: AppNodeInstance) {