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 <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-03-12 14:38:12 +01:00
parent 6e198233c2
commit 2325e4bf48
3 changed files with 138 additions and 19 deletions
@@ -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(<div>Flagged Layout</div>),
],
}),
],
});
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 <div>Custom Sign In</div>;
}
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' }),
@@ -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 () => <div>Flagged Page</div>,
},
}),
],
}),
],
});
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;
+8 -19
View File
@@ -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;
};