From 2325e4bf48fb4621029ab5afd5a827ce138923ea Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 12 Mar 2026 14:38:12 +0100 Subject: [PATCH] frontend-app-api: integrate predicate context into app phases Compute and cache extension predicate context as part of app phase transitions so sign-in and non-sign-in flows finalize against the same gating state. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../src/wiring/createSpecializedApp.test.tsx | 80 +++++++++++++++++++ .../frontend-defaults/src/createApp.test.tsx | 50 ++++++++++++ packages/frontend-defaults/src/createApp.tsx | 27 ++----- 3 files changed, 138 insertions(+), 19 deletions(-) diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx index cd1356d4cb..1695897a14 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx @@ -1731,6 +1731,86 @@ describe('createSpecializedApp', () => { ); }); + it('should reuse predicate context gathered during sign-in completion', 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((name: string) => name === 'test-flag'), + registerFlag: jest.fn(), + getRegisteredFlags: () => [], + save: jest.fn(), + } as unknown as typeof featureFlagsApiRef.T; + const gatedAppPlugin = appPluginOriginal.withOverrides({ + extensions: [ + appPluginOriginal.getExtension('app/layout').override({ + if: { featureFlags: { $contains: 'test-flag' } }, + factory: () => [ + coreExtensionData.reactElement(
Flagged Layout
), + ], + }), + ], + }); + + const preparedApp = prepareSpecializedApp({ + features: [ + gatedAppPlugin, + createFrontendModule({ + pluginId: 'app', + extensions: [ + ApiBlueprint.make({ + params: defineParams => + defineParams({ + api: featureFlagsApiRef, + deps: {}, + factory: () => featureFlagsApi, + }), + }), + gatedAppPlugin.getExtension('sign-in-page:app').override({ + factory: () => { + function SignInPage(props: { + onSignInSuccess(identity: IdentityApi): void; + }) { + useEffect(() => { + props.onSignInSuccess(identityApi); + }, [props]); + return
Custom Sign In
; + } + + return [signInPageComponentDataRef(SignInPage)]; + }, + }), + ], + }), + ], + }); + + const signIn = preparedApp.getSignIn(); + render(signIn!.element); + await expect( + screen.findByText('Custom Sign In'), + ).resolves.toBeInTheDocument(); + + await signIn!.complete; + expect(featureFlagsApi.isActive).toHaveBeenCalledWith('test-flag'); + + const finalizedApp = preparedApp.finalize(); + render( + finalizedApp.tree.root.instance!.getData( + coreExtensionData.reactElement, + ), + ); + + expect(screen.getByText('Flagged Layout')).toBeInTheDocument(); + }); + it('should gate finalize behind internal async sign-in finalization', async () => { const identityApi = { getProfileInfo: async () => ({ displayName: 'Test User' }), diff --git a/packages/frontend-defaults/src/createApp.test.tsx b/packages/frontend-defaults/src/createApp.test.tsx index af8517624a..540fb5e41c 100644 --- a/packages/frontend-defaults/src/createApp.test.tsx +++ b/packages/frontend-defaults/src/createApp.test.tsx @@ -386,6 +386,56 @@ describe('createApp', () => { ).resolves.toBeInTheDocument(); }); + it('should evaluate extension if predicates before rendering apps without sign-in', async () => { + const featureFlagsApi = { + isActive: jest.fn((name: string) => name === 'test-flag'), + registerFlag: jest.fn(), + getRegisteredFlags: () => [], + save: jest.fn(), + } as unknown as typeof featureFlagsApiRef.T; + const app = createApp({ + advanced: { + configLoader: async () => ({ config: mockApis.config() }), + }, + features: [ + appPlugin, + createFrontendModule({ + pluginId: 'app', + extensions: [ + ApiBlueprint.make({ + params: defineParams => + defineParams({ + api: featureFlagsApiRef, + deps: {}, + factory: () => featureFlagsApi, + }), + }), + ], + }), + createFrontendPlugin({ + pluginId: 'test', + featureFlags: [{ name: 'test-flag' }], + extensions: [ + PageBlueprint.make({ + if: { featureFlags: { $contains: 'test-flag' } }, + params: { + path: '/', + loader: async () =>
Flagged Page
, + }, + }), + ], + }), + ], + }); + + await renderWithEffects(app.createRoot()); + + await expect( + screen.findByText('Flagged Page'), + ).resolves.toBeInTheDocument(); + expect(featureFlagsApi.isActive).toHaveBeenCalledWith('test-flag'); + }); + it('should make the app structure available through the AppTreeApi', async () => { let appTreeApi: AppTreeApi | undefined = undefined; diff --git a/packages/frontend-defaults/src/createApp.tsx b/packages/frontend-defaults/src/createApp.tsx index 8a47088f62..50f8db64f7 100644 --- a/packages/frontend-defaults/src/createApp.tsx +++ b/packages/frontend-defaults/src/createApp.tsx @@ -133,6 +133,8 @@ export function createApp(options?: CreateAppOptions): { }; } + await preparedApp.buildPredicateContext(); + return { default: () => renderFinalizedApp(preparedApp.finalize()), }; @@ -164,12 +166,15 @@ function PreparedAppRoot(props: { let cancelled = false; const runFinalize = async () => { try { - const predicateContext = + if (signIn) { + await signIn.complete; + } else { await props.preparedApp.buildPredicateContext(); + } if (cancelled) { return; } - setFinalizedApp(props.preparedApp.finalize(predicateContext)); + setFinalizedApp(props.preparedApp.finalize()); } catch (error) { if (cancelled) { return; @@ -177,23 +182,7 @@ function PreparedAppRoot(props: { setFinalizeError(error as Error); } }; - if (signIn) { - void signIn.complete - .then(() => { - if (cancelled) { - return; - } - void runFinalize(); - }) - .catch(error => { - if (cancelled) { - return; - } - setFinalizeError(error); - }); - } else { - setFinalizedApp(props.preparedApp.finalize()); - } + void runFinalize(); return () => { cancelled = true; };