frontend-app-api: route sign-in errors through app boundary

Rethrow sign-in bootstrap failures from inside the prepared sign-in tree so the app root extension boundary handles them instead of createApp keeping its own error state. This keeps bootstrap error handling aligned with the rest of the extension tree.

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-03-12 22:13:45 +01:00
parent f4c03772a4
commit 72dd26a3a6
3 changed files with 98 additions and 21 deletions
@@ -187,6 +187,86 @@ describe('createApp', () => {
).resolves.toBeInTheDocument();
});
it('should surface sign-in bootstrap errors through the app root boundary', async () => {
const identityApi = {
getProfileInfo: async () => ({ displayName: 'Test User' }),
getBackstageIdentity: async () => ({
type: 'user' as const,
userEntityRef: 'user:default/test-user',
ownershipEntityRefs: ['user:default/test-user'],
}),
getCredentials: async () => ({ token: 'token' }),
signOut: async () => {},
};
const featureFlagsApi = {
isActive: jest.fn(() => {
throw new Error('sign-in bootstrap failed');
}),
registerFlag: jest.fn(),
getRegisteredFlags: () => [],
save: jest.fn(),
} as unknown as typeof featureFlagsApiRef.T;
const app = createApp({
advanced: {
configLoader: async () => ({ config: mockApis.config() }),
},
features: [
appPluginOriginal,
createFrontendModule({
pluginId: 'app',
extensions: [
ApiBlueprint.make({
params: defineParams =>
defineParams({
api: featureFlagsApiRef,
deps: {},
factory: () => featureFlagsApi,
}),
}),
appPluginOriginal.getExtension('sign-in-page:app').override({
factory: () => {
function SignInPage(props: {
onSignInSuccess(identity: IdentityApi): void;
}) {
useEffect(() => {
props.onSignInSuccess(identityApi);
}, [props]);
return <div>Custom Sign In</div>;
}
return [signInPageComponentDataRef(SignInPage)];
},
}),
],
}),
createFrontendPlugin({
pluginId: 'test',
featureFlags: [{ name: 'test-flag' }],
extensions: [
PageBlueprint.make({
if: { featureFlags: { $contains: 'test-flag' } },
params: {
path: '/',
loader: async () => <div>Flagged Page</div>,
},
}),
],
}),
],
});
await renderWithEffects(app.createRoot());
await expect(
screen.findByText(/Error in app/),
).resolves.toBeInTheDocument();
await expect(
screen.findByText('sign-in bootstrap failed'),
).resolves.toBeInTheDocument();
});
it('should deduplicate features keeping the last received one', async () => {
const duplicatedFeatureId = 'test';
const app = createApp({
+1 -7
View File
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { JSX, lazy, ReactNode, Suspense, useReducer, useState } from 'react';
import { JSX, lazy, ReactNode, Suspense, useReducer } from 'react';
import {
ConfigApi,
coreExtensionData,
@@ -151,13 +151,8 @@ function PreparedAppRoot(props: {
}): JSX.Element {
const signIn = props.preparedApp.getSignIn();
const SignIn = signIn.Component;
const [finalizeError, setFinalizeError] = useState<Error>();
const [, triggerRerender] = useReducer((count: number) => count + 1, 0);
if (finalizeError) {
throw finalizeError;
}
const finalizedApp: FinalizedSpecializedApp | undefined =
props.preparedApp.tryFinalize();
@@ -167,7 +162,6 @@ function PreparedAppRoot(props: {
onReady={() => {
triggerRerender();
}}
onError={setFinalizeError}
/>
);
}
+17 -14
View File
@@ -22,6 +22,7 @@ import {
JSX,
} from 'react';
import {
ExtensionBoundary,
coreExtensionData,
discoveryApiRef,
fetchApiRef,
@@ -84,7 +85,7 @@ export const AppRoot = createExtension({
),
},
output: [coreExtensionData.reactElement],
factory({ inputs, apis }) {
factory({ inputs, apis, node }) {
if (isProtectedApp()) {
const identityApi = apis.get(identityApiRef);
if (!identityApi) {
@@ -123,19 +124,21 @@ export const AppRoot = createExtension({
return [
coreExtensionData.reactElement(
<AppRouter
SignInPageComponent={inputs.signInPage?.get(
SignInPageBlueprint.dataRefs.component,
)}
RouterComponent={inputs.router?.get(
RouterBlueprint.dataRefs.component,
)}
extraElements={inputs.elements?.map(el =>
el.get(coreExtensionData.reactElement),
)}
>
{content}
</AppRouter>,
<ExtensionBoundary node={node}>
<AppRouter
SignInPageComponent={inputs.signInPage?.get(
SignInPageBlueprint.dataRefs.component,
)}
RouterComponent={inputs.router?.get(
RouterBlueprint.dataRefs.component,
)}
extraElements={inputs.elements?.map(el =>
el.get(coreExtensionData.reactElement),
)}
>
{content}
</AppRouter>
</ExtensionBoundary>,
),
];
},