frontend-app-api: store bootstrap errors for app root
Route bootstrap finalization failures through an external store that re-enters React at app/root.children, and simplify prepared app finalization to use a success-only callback. Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com> Made-with: Cursor
This commit is contained in:
@@ -260,25 +260,20 @@ export function instantiateAndInitializePhaseTree(options: {
|
||||
routeResolutionApi: RouteResolutionApiProxy;
|
||||
appTreeApi: AppTreeApiProxy;
|
||||
routeRefsById: ReturnType<typeof collectRouteIds>;
|
||||
stopAtSessionBoundary?: boolean;
|
||||
skipChild?(ctx: { node: AppNode; input: string; child: AppNode }): boolean;
|
||||
onMissingApi?(ctx: { node: AppNode; apiRefId: string }): void;
|
||||
predicateContext?: ExtensionPredicateContext;
|
||||
stopAtAttachment?(ctx: { node: AppNode; input: string }): boolean;
|
||||
}) {
|
||||
let stopAtAttachment = options.stopAtAttachment;
|
||||
if (options.stopAtSessionBoundary) {
|
||||
stopAtAttachment = ({ node, input }) =>
|
||||
isSessionBoundaryAttachment(node, input);
|
||||
}
|
||||
|
||||
instantiateAppNodeTree(
|
||||
options.tree.root,
|
||||
options.apis,
|
||||
options.collector,
|
||||
options.extensionFactoryMiddleware,
|
||||
{
|
||||
...(stopAtAttachment ? { stopAtAttachment } : {}),
|
||||
...(options.stopAtAttachment
|
||||
? { stopAtAttachment: options.stopAtAttachment }
|
||||
: {}),
|
||||
skipChild: options.skipChild,
|
||||
onMissingApi: options.onMissingApi,
|
||||
predicateContext: options.predicateContext,
|
||||
@@ -306,7 +301,3 @@ export function setIdentityApiTarget(options: {
|
||||
signOutTargetUrl: options.signOutTargetUrl,
|
||||
});
|
||||
}
|
||||
|
||||
function isSessionBoundaryAttachment(node: AppNode, input: string) {
|
||||
return node.spec.id === 'app/root' && input === 'children';
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
OpaqueFrontendPlugin,
|
||||
} from '@internal/frontend';
|
||||
import { OpaqueType } from '@internal/opaque';
|
||||
import { ComponentType, ReactNode, useLayoutEffect, useState } from 'react';
|
||||
import { ComponentType, ReactNode, useSyncExternalStore } from 'react';
|
||||
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import {
|
||||
@@ -150,6 +150,12 @@ 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;
|
||||
@@ -244,10 +250,7 @@ export type PrepareSpecializedAppOptions = {
|
||||
*/
|
||||
export type PreparedSpecializedApp = {
|
||||
getBootstrapApp(): BootstrapSpecializedApp;
|
||||
onFinalized(
|
||||
callback: (app: FinalizedSpecializedApp) => void,
|
||||
onError?: (error: Error) => void,
|
||||
): () => void;
|
||||
onFinalized(callback: (app: FinalizedSpecializedApp) => void): () => void;
|
||||
finalize(options?: {
|
||||
sessionState?: SpecializedAppSessionState;
|
||||
}): FinalizedSpecializedApp;
|
||||
@@ -369,6 +372,7 @@ export function prepareSpecializedApp(
|
||||
apis: phase.apis,
|
||||
predicateReferences,
|
||||
});
|
||||
const bootstrapErrorStore = createBootstrapErrorStore();
|
||||
let signInRuntime: SignInRuntime | undefined;
|
||||
let cachedSessionState = providedSessionState;
|
||||
let sessionStatePromise: Promise<SpecializedAppSessionState> | undefined;
|
||||
@@ -376,8 +380,6 @@ export function prepareSpecializedApp(
|
||||
let bootstrapApp: BootstrapSpecializedApp | undefined;
|
||||
let bootstrapError: Error | undefined;
|
||||
let finalizationState: FinalizationState | undefined;
|
||||
let bootstrapErrorReporter: ((error: Error) => void) | undefined;
|
||||
let pendingBootstrapError: Error | undefined;
|
||||
|
||||
function updateIdentityApiTarget(identityApi?: IdentityApi) {
|
||||
if (!identityApi) {
|
||||
@@ -524,12 +526,7 @@ export function prepareSpecializedApp(
|
||||
function reportBootstrapFailure(error: unknown) {
|
||||
const bootstrapFailure = asError(error);
|
||||
bootstrapError = bootstrapFailure;
|
||||
if (bootstrapErrorReporter) {
|
||||
bootstrapErrorReporter(bootstrapFailure);
|
||||
return;
|
||||
}
|
||||
|
||||
pendingBootstrapError = bootstrapFailure;
|
||||
bootstrapErrorStore.report(bootstrapFailure);
|
||||
}
|
||||
|
||||
function getFinalizationState(): FinalizationState {
|
||||
@@ -576,13 +573,8 @@ export function prepareSpecializedApp(
|
||||
.catch(error => {
|
||||
finalizationState = undefined;
|
||||
|
||||
if (signInRuntime?.requiresSignIn) {
|
||||
finalization.reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
reportBootstrapFailure(error);
|
||||
finalization.reject(bootstrapError);
|
||||
finalization.reject(bootstrapError ?? asError(error));
|
||||
});
|
||||
|
||||
return finalization.promise;
|
||||
@@ -618,19 +610,7 @@ export function prepareSpecializedApp(
|
||||
appTreeApi: phase.appTreeApi,
|
||||
extensionFactoryMiddleware: mergedExtensionFactoryMiddleware,
|
||||
disableSignIn: Boolean(providedSessionState),
|
||||
registerBootstrapErrorReporter(reporter) {
|
||||
bootstrapErrorReporter = reporter;
|
||||
if (pendingBootstrapError) {
|
||||
reporter(pendingBootstrapError);
|
||||
pendingBootstrapError = undefined;
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (bootstrapErrorReporter === reporter) {
|
||||
bootstrapErrorReporter = undefined;
|
||||
}
|
||||
};
|
||||
},
|
||||
bootstrapErrorStore,
|
||||
skipBootstrapChild({ child }) {
|
||||
return bootstrapClassification.deferredRoots.has(child);
|
||||
},
|
||||
@@ -654,18 +634,12 @@ export function prepareSpecializedApp(
|
||||
|
||||
return {
|
||||
getBootstrapApp,
|
||||
onFinalized(callback, onError) {
|
||||
onFinalized(callback) {
|
||||
getBootstrapApp();
|
||||
|
||||
let subscribed = true;
|
||||
|
||||
if (bootstrapError) {
|
||||
const currentBootstrapError = bootstrapError;
|
||||
Promise.resolve().then(() => {
|
||||
if (subscribed) {
|
||||
onError?.(currentBootstrapError);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
subscribed = false;
|
||||
};
|
||||
@@ -692,11 +666,7 @@ export function prepareSpecializedApp(
|
||||
callback(finalizedApp);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
if (subscribed) {
|
||||
onError?.(asError(error));
|
||||
}
|
||||
});
|
||||
.catch(() => {});
|
||||
|
||||
return () => {
|
||||
subscribed = false;
|
||||
@@ -819,7 +789,7 @@ function createBootstrapApp(options: {
|
||||
appTreeApi: AppTreeApiProxy;
|
||||
extensionFactoryMiddleware?: ExtensionFactoryMiddleware;
|
||||
disableSignIn?: boolean;
|
||||
registerBootstrapErrorReporter(reporter: (error: Error) => void): () => void;
|
||||
bootstrapErrorStore: BootstrapErrorStore;
|
||||
skipBootstrapChild?(ctx: {
|
||||
node: AppNode;
|
||||
input: string;
|
||||
@@ -830,6 +800,11 @@ function createBootstrapApp(options: {
|
||||
bootstrapApp: BootstrapSpecializedApp;
|
||||
requiresSignIn: boolean;
|
||||
} {
|
||||
prepareBootstrapErrorThrower({
|
||||
tree: options.tree,
|
||||
store: options.bootstrapErrorStore,
|
||||
});
|
||||
|
||||
const signInPageNode = getAppRootNode(options.tree)?.edges.attachments.get(
|
||||
'signInPage',
|
||||
)?.[0];
|
||||
@@ -842,14 +817,9 @@ function createBootstrapApp(options: {
|
||||
routeResolutionApi: options.routeResolutionApi,
|
||||
appTreeApi: options.appTreeApi,
|
||||
routeRefsById: options.routeRefsById,
|
||||
stopAtSessionBoundary: true,
|
||||
skipChild: options.skipBootstrapChild,
|
||||
onMissingApi: options.onMissingApi,
|
||||
});
|
||||
prepareBootstrapErrorBoundary({
|
||||
tree: options.tree,
|
||||
registerBootstrapErrorReporter: options.registerBootstrapErrorReporter,
|
||||
});
|
||||
|
||||
const element = options.tree.root.instance?.getData(
|
||||
coreExtensionData.reactElement,
|
||||
@@ -876,38 +846,34 @@ function prepareFinalizedTree(options: { tree: AppTree }) {
|
||||
}
|
||||
}
|
||||
|
||||
function prepareBootstrapErrorBoundary(options: {
|
||||
function prepareBootstrapErrorThrower(options: {
|
||||
tree: AppTree;
|
||||
registerBootstrapErrorReporter(reporter: (error: Error) => void): () => void;
|
||||
store: BootstrapErrorStore;
|
||||
}) {
|
||||
const rootNode = options.tree.root;
|
||||
const rootInstance = rootNode.instance;
|
||||
if (!rootInstance) {
|
||||
const bootstrapChildNode = getAppRootNode(
|
||||
options.tree,
|
||||
)?.edges.attachments.get('children')?.[0];
|
||||
if (!bootstrapChildNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rootElement = rootInstance.getData(coreExtensionData.reactElement);
|
||||
if (!rootElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
function PreparedBootstrapRoot() {
|
||||
const [bootstrapError, setBootstrapError] = useState<Error>();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
return options.registerBootstrapErrorReporter(setBootstrapError);
|
||||
}, []);
|
||||
function BootstrapErrorThrower() {
|
||||
const bootstrapError = useSyncExternalStore(
|
||||
options.store.subscribe,
|
||||
options.store.getSnapshot,
|
||||
options.store.getSnapshot,
|
||||
);
|
||||
|
||||
if (bootstrapError) {
|
||||
throw bootstrapError;
|
||||
}
|
||||
|
||||
return rootElement;
|
||||
return null;
|
||||
}
|
||||
|
||||
setNodeInstance(
|
||||
rootNode,
|
||||
createReactElementOverrideInstance(rootInstance, <PreparedBootstrapRoot />),
|
||||
bootstrapChildNode,
|
||||
createReactElementInstance(<BootstrapErrorThrower />),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -959,23 +925,39 @@ function isSessionBoundaryAttachment(node: AppNode, input: string) {
|
||||
return node.spec.id === 'app/root' && input === 'children';
|
||||
}
|
||||
|
||||
function createReactElementOverrideInstance(
|
||||
instance: AppNodeInstance,
|
||||
value: ReactNode,
|
||||
): AppNodeInstance {
|
||||
function createReactElementInstance(value: ReactNode): AppNodeInstance {
|
||||
return {
|
||||
getDataRefs() {
|
||||
const refs = Array.from(instance.getDataRefs());
|
||||
if (!refs.some(ref => ref.id === coreExtensionData.reactElement.id)) {
|
||||
refs.push(coreExtensionData.reactElement);
|
||||
}
|
||||
return refs[Symbol.iterator]();
|
||||
return [coreExtensionData.reactElement].values();
|
||||
},
|
||||
getData<TValue>(dataRef: ExtensionDataRef<TValue>) {
|
||||
if (dataRef.id === coreExtensionData.reactElement.id) {
|
||||
return value as TValue;
|
||||
}
|
||||
return instance.getData(dataRef);
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createBootstrapErrorStore(): BootstrapErrorStore {
|
||||
let snapshot: Error | undefined;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
return {
|
||||
getSnapshot() {
|
||||
return snapshot;
|
||||
},
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
report(error) {
|
||||
snapshot = error;
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -291,9 +291,6 @@ describe('createApp', () => {
|
||||
triggerSignInSuccess(identityApi);
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByText(/Error in app/),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText('sign-in bootstrap failed'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
@@ -152,22 +152,12 @@ function PreparedAppRoot(props: {
|
||||
const [finalizedApp, setFinalizedApp] = useState<
|
||||
FinalizedSpecializedApp | undefined
|
||||
>();
|
||||
const [bootstrapError, setBootstrapError] = useState<Error | undefined>();
|
||||
|
||||
useEffect(
|
||||
() => props.preparedApp.onFinalized(setFinalizedApp, setBootstrapError),
|
||||
() => props.preparedApp.onFinalized(setFinalizedApp),
|
||||
[props.preparedApp],
|
||||
);
|
||||
|
||||
if (bootstrapError) {
|
||||
return (
|
||||
<>
|
||||
<div>{bootstrapError.message}</div>
|
||||
<h1>Error in app</h1>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!finalizedApp) {
|
||||
return bootstrapApp.element;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user