diff --git a/packages/frontend-app-api/src/wiring/phaseApis.tsx b/packages/frontend-app-api/src/wiring/phaseApis.tsx index 08eb20c17a..b6d38d6e4b 100644 --- a/packages/frontend-app-api/src/wiring/phaseApis.tsx +++ b/packages/frontend-app-api/src/wiring/phaseApis.tsx @@ -260,25 +260,20 @@ export function instantiateAndInitializePhaseTree(options: { routeResolutionApi: RouteResolutionApiProxy; appTreeApi: AppTreeApiProxy; routeRefsById: ReturnType; - 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'; -} diff --git a/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx b/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx index a1a3ff02e2..69731d00ab 100644 --- a/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx +++ b/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx @@ -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 | 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(); - - 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, ), + bootstrapChildNode, + createReactElementInstance(), ); } @@ -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(dataRef: ExtensionDataRef) { 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(); + } }, }; } diff --git a/packages/frontend-defaults/src/createApp.test.tsx b/packages/frontend-defaults/src/createApp.test.tsx index 4cc9e367f8..512475c711 100644 --- a/packages/frontend-defaults/src/createApp.test.tsx +++ b/packages/frontend-defaults/src/createApp.test.tsx @@ -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(); diff --git a/packages/frontend-defaults/src/createApp.tsx b/packages/frontend-defaults/src/createApp.tsx index 2dd75031c2..728ec29e42 100644 --- a/packages/frontend-defaults/src/createApp.tsx +++ b/packages/frontend-defaults/src/createApp.tsx @@ -152,22 +152,12 @@ function PreparedAppRoot(props: { const [finalizedApp, setFinalizedApp] = useState< FinalizedSpecializedApp | undefined >(); - const [bootstrapError, setBootstrapError] = useState(); useEffect( - () => props.preparedApp.onFinalized(setFinalizedApp, setBootstrapError), + () => props.preparedApp.onFinalized(setFinalizedApp), [props.preparedApp], ); - if (bootstrapError) { - return ( - <> -
{bootstrapError.message}
-

Error in app

- - ); - } - if (!finalizedApp) { return bootstrapApp.element; }