From 89e2f8134e7945f404658387cf328dde3c906c83 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Tue, 13 Oct 2026 13:57:06 +0530 Subject: [PATCH 1/3] feat(experimentation): hide experimentation UI when the experimentation infra isn't deployed --- .../__tests__/useFeatureExperimentFreeze.test.ts | 14 +++++++------- .../common/hooks/useFeatureExperimentFreeze.ts | 3 ++- frontend/common/types/responses.ts | 1 + frontend/common/utils/utils.tsx | 8 ++++++++ .../modals/create-feature/tabs/FeatureValueTab.tsx | 2 +- .../navigation/navbars/EnvironmentNavbar.tsx | 2 +- .../web/components/pages/ExperimentDetailPage.tsx | 2 +- frontend/web/components/pages/ExperimentsPage.tsx | 2 +- .../EnvironmentSettingsPage.tsx | 2 +- 9 files changed, 23 insertions(+), 13 deletions(-) diff --git a/frontend/common/hooks/__tests__/useFeatureExperimentFreeze.test.ts b/frontend/common/hooks/__tests__/useFeatureExperimentFreeze.test.ts index acd94a1d4e00..e4a0833803cc 100644 --- a/frontend/common/hooks/__tests__/useFeatureExperimentFreeze.test.ts +++ b/frontend/common/hooks/__tests__/useFeatureExperimentFreeze.test.ts @@ -10,7 +10,7 @@ jest.mock('react', () => ({ jest.mock('common/utils/utils', () => ({ __esModule: true, default: { - getFlagsmithHasFeature: jest.fn(), + hasExperimentationFeature: jest.fn(), }, })) @@ -21,9 +21,9 @@ import { Experiment } from 'common/types/responses' const mockUseGetExperimentsQuery = useGetExperimentsQuery as jest.MockedFunction -const mockGetFlagsmithHasFeature = - Utils.getFlagsmithHasFeature as jest.MockedFunction< - typeof Utils.getFlagsmithHasFeature +const mockHasExperimentationFeature = + Utils.hasExperimentationFeature as jest.MockedFunction< + typeof Utils.hasExperimentationFeature > const makeExperiment = ( @@ -56,7 +56,7 @@ const withResults = (experiments: Experiment[]) => describe('useFeatureExperimentFreeze', () => { beforeEach(() => { jest.clearAllMocks() - mockGetFlagsmithHasFeature.mockReturnValue(true) + mockHasExperimentationFeature.mockReturnValue(true) }) it('returns isFrozen true when a running experiment exists for the feature', () => { @@ -125,8 +125,8 @@ describe('useFeatureExperimentFreeze', () => { ) }) - it('skips query when experimental_flags feature is disabled', () => { - mockGetFlagsmithHasFeature.mockReturnValue(false) + it('skips query when experimentation is unavailable', () => { + mockHasExperimentationFeature.mockReturnValue(false) mockUseGetExperimentsQuery.mockReturnValue(empty) const result = useFeatureExperimentFreeze(42, 'env-123') diff --git a/frontend/common/hooks/useFeatureExperimentFreeze.ts b/frontend/common/hooks/useFeatureExperimentFreeze.ts index 33a0aea79eed..0ace540e380e 100644 --- a/frontend/common/hooks/useFeatureExperimentFreeze.ts +++ b/frontend/common/hooks/useFeatureExperimentFreeze.ts @@ -13,7 +13,8 @@ export function useFeatureExperimentFreeze( featureId: number | undefined, environmentId: string, ): FeatureExperimentFreeze { - const isExperimentEnabled = Utils.getFlagsmithHasFeature('experimental_flags') + const isExperimentEnabled = + Utils.hasExperimentationFeature('experimental_flags') const skip = !isExperimentEnabled || !featureId || !environmentId const { data, isLoading } = useGetExperimentsQuery( { environmentId, status: 'running' }, diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index f8646bc07045..003cde03cede 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -1262,6 +1262,7 @@ export type Version = { ci_commit_sha: string image_tag: string has_email_provider: boolean + experimentation_enabled: boolean is_enterprise: boolean is_saas: boolean 'self_hosted_data'?: { diff --git a/frontend/common/utils/utils.tsx b/frontend/common/utils/utils.tsx index a7718160284e..f0c313526b38 100644 --- a/frontend/common/utils/utils.tsx +++ b/frontend/common/utils/utils.tsx @@ -668,6 +668,14 @@ const Utils = Object.assign({}, BaseUtils, { (permission) => permission.permission_key === key, ) }, + hasExperimentationFeature(key: string) { + // Experimentation features need both their flag and the experimentation infra. + return ( + Utils.getFlagsmithHasFeature(key) && + !!selectBuildVersion(getStore().getState())?.backend + ?.experimentation_enabled + ) + }, hasIntegration(key: string) { const data = Utils.getIntegrationData() as | Record diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureValueTab.tsx b/frontend/web/components/modals/create-feature/tabs/FeatureValueTab.tsx index 64b7aa9967df..ee1e2992502b 100644 --- a/frontend/web/components/modals/create-feature/tabs/FeatureValueTab.tsx +++ b/frontend/web/components/modals/create-feature/tabs/FeatureValueTab.tsx @@ -507,7 +507,7 @@ const FeatureValueTab: FC = ({ = ({ > Features - {Utils.getFlagsmithHasFeature('experimental_flags') ? ( + {Utils.hasExperimentationFeature('experimental_flags') ? ( { useParams() const history = useHistory() const numericId = Number(experimentId) - const hasFeature = Utils.getFlagsmithHasFeature('experimental_flags') + const hasFeature = Utils.hasExperimentationFeature('experimental_flags') const { data: experiment, diff --git a/frontend/web/components/pages/ExperimentsPage.tsx b/frontend/web/components/pages/ExperimentsPage.tsx index 22013a752f30..f7018a5fc92a 100644 --- a/frontend/web/components/pages/ExperimentsPage.tsx +++ b/frontend/web/components/pages/ExperimentsPage.tsx @@ -81,7 +81,7 @@ const ExperimentsPage: FC = () => { if (!environmentId || !projectId) return null - const hasExperiments = Utils.getFlagsmithHasFeature('experimental_flags') + const hasExperiments = Utils.hasExperimentationFeature('experimental_flags') const hasFakeDoor = Utils.getFlagsmithHasFeature('experiments_fake_door') if (!Utils.isOrgOnFreePlan() && !hasExperiments && hasFakeDoor) { diff --git a/frontend/web/components/pages/environment-settings/EnvironmentSettingsPage.tsx b/frontend/web/components/pages/environment-settings/EnvironmentSettingsPage.tsx index 471e64266887..e84c6121be06 100644 --- a/frontend/web/components/pages/environment-settings/EnvironmentSettingsPage.tsx +++ b/frontend/web/components/pages/environment-settings/EnvironmentSettingsPage.tsx @@ -119,7 +119,7 @@ const EnvironmentSettingsPage: React.FC = () => { const has4EyesPermission = Utils.getPlansPermission('4_EYES') const metadataEnable = Utils.getPlansPermission('METADATA') - const warehouseEnabled = Utils.getFlagsmithHasFeature( + const warehouseEnabled = Utils.hasExperimentationFeature( 'experimentation_warehouse_connection', ) const cohortSyncEnabled = getSegmentSources().some( From 47b9a648970f15641bc7d5684c0c2ea686a320c6 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Tue, 13 Oct 2026 14:16:32 +0530 Subject: [PATCH 2/3] fix(experimentation): wait for the build version before gating experimentation UI --- .../useFeatureExperimentFreeze.test.ts | 25 ++++---- .../useHasExperimentationFeature.test.ts | 61 +++++++++++++++++++ .../hooks/useFeatureExperimentFreeze.ts | 6 +- .../hooks/useHasExperimentationFeature.ts | 20 ++++++ frontend/common/utils/utils.tsx | 8 --- .../create-feature/tabs/FeatureValueTab.tsx | 5 +- .../navigation/navbars/EnvironmentNavbar.tsx | 5 +- .../components/pages/ExperimentDetailPage.tsx | 9 +-- .../web/components/pages/ExperimentsPage.tsx | 11 ++-- .../EnvironmentSettingsPage.tsx | 3 +- 10 files changed, 120 insertions(+), 33 deletions(-) create mode 100644 frontend/common/hooks/__tests__/useHasExperimentationFeature.test.ts create mode 100644 frontend/common/hooks/useHasExperimentationFeature.ts diff --git a/frontend/common/hooks/__tests__/useFeatureExperimentFreeze.test.ts b/frontend/common/hooks/__tests__/useFeatureExperimentFreeze.test.ts index e4a0833803cc..8db7958e1b4f 100644 --- a/frontend/common/hooks/__tests__/useFeatureExperimentFreeze.test.ts +++ b/frontend/common/hooks/__tests__/useFeatureExperimentFreeze.test.ts @@ -7,23 +7,20 @@ jest.mock('react', () => ({ useMemo: (fn: () => any) => fn(), })) -jest.mock('common/utils/utils', () => ({ - __esModule: true, - default: { - hasExperimentationFeature: jest.fn(), - }, +jest.mock('common/hooks/useHasExperimentationFeature', () => ({ + useHasExperimentationFeature: jest.fn(), })) import { useFeatureExperimentFreeze } from 'common/hooks/useFeatureExperimentFreeze' import { useGetExperimentsQuery } from 'common/services/useExperiment' -import Utils from 'common/utils/utils' +import { useHasExperimentationFeature } from 'common/hooks/useHasExperimentationFeature' import { Experiment } from 'common/types/responses' const mockUseGetExperimentsQuery = useGetExperimentsQuery as jest.MockedFunction -const mockHasExperimentationFeature = - Utils.hasExperimentationFeature as jest.MockedFunction< - typeof Utils.hasExperimentationFeature +const mockUseHasExperimentationFeature = + useHasExperimentationFeature as jest.MockedFunction< + typeof useHasExperimentationFeature > const makeExperiment = ( @@ -56,7 +53,10 @@ const withResults = (experiments: Experiment[]) => describe('useFeatureExperimentFreeze', () => { beforeEach(() => { jest.clearAllMocks() - mockHasExperimentationFeature.mockReturnValue(true) + mockUseHasExperimentationFeature.mockReturnValue({ + enabled: true, + isLoading: false, + }) }) it('returns isFrozen true when a running experiment exists for the feature', () => { @@ -126,7 +126,10 @@ describe('useFeatureExperimentFreeze', () => { }) it('skips query when experimentation is unavailable', () => { - mockHasExperimentationFeature.mockReturnValue(false) + mockUseHasExperimentationFeature.mockReturnValue({ + enabled: false, + isLoading: false, + }) mockUseGetExperimentsQuery.mockReturnValue(empty) const result = useFeatureExperimentFreeze(42, 'env-123') diff --git a/frontend/common/hooks/__tests__/useHasExperimentationFeature.test.ts b/frontend/common/hooks/__tests__/useHasExperimentationFeature.test.ts new file mode 100644 index 000000000000..9e81cc2fa131 --- /dev/null +++ b/frontend/common/hooks/__tests__/useHasExperimentationFeature.test.ts @@ -0,0 +1,61 @@ +jest.mock('common/services/useBuildVersion', () => ({ + useGetBuildVersionQuery: jest.fn(), +})) + +jest.mock('common/utils/utils', () => ({ + __esModule: true, + default: { + getFlagsmithHasFeature: jest.fn(), + }, +})) + +import { useHasExperimentationFeature } from 'common/hooks/useHasExperimentationFeature' +import { useGetBuildVersionQuery } from 'common/services/useBuildVersion' +import Utils from 'common/utils/utils' + +const mockUseGetBuildVersionQuery = + useGetBuildVersionQuery as jest.MockedFunction +const mockGetFlagsmithHasFeature = + Utils.getFlagsmithHasFeature as jest.MockedFunction< + typeof Utils.getFlagsmithHasFeature + > + +describe('useHasExperimentationFeature', () => { + beforeEach(() => { + jest.clearAllMocks() + mockGetFlagsmithHasFeature.mockReturnValue(true) + }) + + it('reports loading while the build version is pending', () => { + mockUseGetBuildVersionQuery.mockReturnValue({ + data: undefined, + isLoading: true, + } as any) + + const result = useHasExperimentationFeature('experimental_flags') + + expect(result).toEqual({ enabled: false, isLoading: true }) + }) + + it('is disabled when the experimentation infra is not deployed', () => { + mockUseGetBuildVersionQuery.mockReturnValue({ + data: { backend: { experimentation_enabled: false } }, + isLoading: false, + } as any) + + const result = useHasExperimentationFeature('experimental_flags') + + expect(result).toEqual({ enabled: false, isLoading: false }) + }) + + it('is enabled when the flag is on and the experimentation infra is deployed', () => { + mockUseGetBuildVersionQuery.mockReturnValue({ + data: { backend: { experimentation_enabled: true } }, + isLoading: false, + } as any) + + const result = useHasExperimentationFeature('experimental_flags') + + expect(result).toEqual({ enabled: true, isLoading: false }) + }) +}) diff --git a/frontend/common/hooks/useFeatureExperimentFreeze.ts b/frontend/common/hooks/useFeatureExperimentFreeze.ts index 0ace540e380e..e4b516848936 100644 --- a/frontend/common/hooks/useFeatureExperimentFreeze.ts +++ b/frontend/common/hooks/useFeatureExperimentFreeze.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react' import { useGetExperimentsQuery } from 'common/services/useExperiment' import type { Experiment } from 'common/types/responses' -import Utils from 'common/utils/utils' +import { useHasExperimentationFeature } from './useHasExperimentationFeature' export type FeatureExperimentFreeze = { isFrozen: boolean @@ -13,8 +13,8 @@ export function useFeatureExperimentFreeze( featureId: number | undefined, environmentId: string, ): FeatureExperimentFreeze { - const isExperimentEnabled = - Utils.hasExperimentationFeature('experimental_flags') + const { enabled: isExperimentEnabled } = + useHasExperimentationFeature('experimental_flags') const skip = !isExperimentEnabled || !featureId || !environmentId const { data, isLoading } = useGetExperimentsQuery( { environmentId, status: 'running' }, diff --git a/frontend/common/hooks/useHasExperimentationFeature.ts b/frontend/common/hooks/useHasExperimentationFeature.ts new file mode 100644 index 000000000000..d0d2a59794d3 --- /dev/null +++ b/frontend/common/hooks/useHasExperimentationFeature.ts @@ -0,0 +1,20 @@ +import { useGetBuildVersionQuery } from 'common/services/useBuildVersion' +import Utils from 'common/utils/utils' + +export type ExperimentationFeature = { + enabled: boolean + isLoading: boolean +} + +// Experimentation features need both their flag and the experimentation infra. +export function useHasExperimentationFeature( + flag: string, +): ExperimentationFeature { + const { data, isLoading } = useGetBuildVersionQuery({}) + return { + enabled: + Utils.getFlagsmithHasFeature(flag) && + !!data?.backend?.experimentation_enabled, + isLoading, + } +} diff --git a/frontend/common/utils/utils.tsx b/frontend/common/utils/utils.tsx index f0c313526b38..a7718160284e 100644 --- a/frontend/common/utils/utils.tsx +++ b/frontend/common/utils/utils.tsx @@ -668,14 +668,6 @@ const Utils = Object.assign({}, BaseUtils, { (permission) => permission.permission_key === key, ) }, - hasExperimentationFeature(key: string) { - // Experimentation features need both their flag and the experimentation infra. - return ( - Utils.getFlagsmithHasFeature(key) && - !!selectBuildVersion(getStore().getState())?.backend - ?.experimentation_enabled - ) - }, hasIntegration(key: string) { const data = Utils.getIntegrationData() as | Record diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureValueTab.tsx b/frontend/web/components/modals/create-feature/tabs/FeatureValueTab.tsx index ee1e2992502b..385d7baf4ecb 100644 --- a/frontend/web/components/modals/create-feature/tabs/FeatureValueTab.tsx +++ b/frontend/web/components/modals/create-feature/tabs/FeatureValueTab.tsx @@ -31,6 +31,7 @@ import { VARIATION_WEIGHT_FIELD, } from 'common/utils/multivariate' import { FeatureExperimentFreeze } from 'common/hooks/useFeatureExperimentFreeze' +import { useHasExperimentationFeature } from 'common/hooks/useHasExperimentationFeature' import ExperimentFreezeNotice from 'components/modals/create-feature/components/ExperimentFreezeNotice' import { useHasPermission } from 'common/providers/Permission' import { ProjectPermission } from 'common/types/permissions.types' @@ -120,6 +121,8 @@ const FeatureValueTab: FC = ({ level: 'project', permission: ProjectPermission.CREATE_FEATURE, }) + const { enabled: hasExperiments } = + useHasExperimentationFeature('experimental_flags') const default_enabled = featureState.enabled ?? false const initial_value = featureState.feature_state_value @@ -507,7 +510,7 @@ const FeatureValueTab: FC = ({ = ({ changeRequestsData?.count) || 0 const scheduled = scheduledData?.count || 0 + const { enabled: hasExperiments } = + useHasExperimentationFeature('experimental_flags') const inner = (
= ({ > Features - {Utils.hasExperimentationFeature('experimental_flags') ? ( + {hasExperiments ? ( { useParams() const history = useHistory() const numericId = Number(experimentId) - const hasFeature = Utils.hasExperimentationFeature('experimental_flags') + const { enabled: hasFeature, isLoading: isLoadingFeature } = + useHasExperimentationFeature('experimental_flags') const { data: experiment, @@ -53,14 +54,14 @@ const ExperimentDetailPage: FC = () => { const results = bayesianResults?.payload ?? undefined - if (!hasFeature) { + if (!isLoadingFeature && !hasFeature) { history.replace( `/project/${projectId}/environment/${environmentId}/features`, ) return null } - if (isLoading) { + if (isLoadingFeature || isLoading) { return (
diff --git a/frontend/web/components/pages/ExperimentsPage.tsx b/frontend/web/components/pages/ExperimentsPage.tsx index f7018a5fc92a..ec35f341a29a 100644 --- a/frontend/web/components/pages/ExperimentsPage.tsx +++ b/frontend/web/components/pages/ExperimentsPage.tsx @@ -1,6 +1,7 @@ import { FC, useCallback, useEffect, useState } from 'react' import { useHistory } from 'react-router-dom' import Utils from 'common/utils/utils' +import { useHasExperimentationFeature } from 'common/hooks/useHasExperimentationFeature' import { useRouteContext } from 'components/providers/RouteContext' import { useGetExperimentsQuery } from 'common/services/useExperiment' import { useGetWarehouseConnectionsQuery } from 'common/services/useWarehouseConnection' @@ -38,6 +39,9 @@ const ExperimentsPage: FC = () => { setIsCreating(false) }, [environmentId]) + const { enabled: hasExperiments, isLoading: isLoadingFeature } = + useHasExperimentationFeature('experimental_flags') + const { data: experimentsData, isLoading } = useGetExperimentsQuery( { environmentId: environmentId ?? '', @@ -46,13 +50,13 @@ const ExperimentsPage: FC = () => { q: search || undefined, status: activeTab !== 'all' ? activeTab : undefined, }, - { skip: !environmentId }, + { skip: !environmentId || !hasExperiments }, ) const { data: warehouseConnections, isLoading: isLoadingWarehouse } = useGetWarehouseConnectionsQuery( { environmentId: environmentId ?? '', exclude_event_stats: true }, - { skip: !environmentId }, + { skip: !environmentId || !hasExperiments }, ) const hasWarehouse = (warehouseConnections?.length ?? 0) > 0 @@ -79,9 +83,8 @@ const ExperimentsPage: FC = () => { [statusCounts], ) - if (!environmentId || !projectId) return null + if (!environmentId || !projectId || isLoadingFeature) return null - const hasExperiments = Utils.hasExperimentationFeature('experimental_flags') const hasFakeDoor = Utils.getFlagsmithHasFeature('experiments_fake_door') if (!Utils.isOrgOnFreePlan() && !hasExperiments && hasFakeDoor) { diff --git a/frontend/web/components/pages/environment-settings/EnvironmentSettingsPage.tsx b/frontend/web/components/pages/environment-settings/EnvironmentSettingsPage.tsx index e84c6121be06..6de8b24812a0 100644 --- a/frontend/web/components/pages/environment-settings/EnvironmentSettingsPage.tsx +++ b/frontend/web/components/pages/environment-settings/EnvironmentSettingsPage.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' import FieldLabel from 'components/base/forms/FieldLabel' import ConfirmRemoveEnvironment from 'components/modals/ConfirmRemoveEnvironment' import ProjectStore from 'common/stores/project-store' +import { useHasExperimentationFeature } from 'common/hooks/useHasExperimentationFeature' import ConfigProvider from 'common/providers/ConfigProvider' import CreateWebhookModal from 'components/modals/CreateWebhook' import ConfirmRemoveWebhook from 'components/modals/ConfirmRemoveWebhook' @@ -119,7 +120,7 @@ const EnvironmentSettingsPage: React.FC = () => { const has4EyesPermission = Utils.getPlansPermission('4_EYES') const metadataEnable = Utils.getPlansPermission('METADATA') - const warehouseEnabled = Utils.hasExperimentationFeature( + const { enabled: warehouseEnabled } = useHasExperimentationFeature( 'experimentation_warehouse_connection', ) const cohortSyncEnabled = getSegmentSources().some( From 2c1f688a163f6cdc809b969046b725c84c3b77c8 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Tue, 13 Oct 2026 15:10:23 +0530 Subject: [PATCH 3/3] fix(experimentation): keep the freeze check loading until the build version loads --- .../__tests__/useFeatureExperimentFreeze.test.ts | 12 ++++++++++++ frontend/common/hooks/useFeatureExperimentFreeze.ts | 4 ++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/frontend/common/hooks/__tests__/useFeatureExperimentFreeze.test.ts b/frontend/common/hooks/__tests__/useFeatureExperimentFreeze.test.ts index 8db7958e1b4f..584ce40fbc2b 100644 --- a/frontend/common/hooks/__tests__/useFeatureExperimentFreeze.test.ts +++ b/frontend/common/hooks/__tests__/useFeatureExperimentFreeze.test.ts @@ -141,6 +141,18 @@ describe('useFeatureExperimentFreeze', () => { ) }) + it('returns isLoading true while the experimentation check is loading', () => { + mockUseHasExperimentationFeature.mockReturnValue({ + enabled: false, + isLoading: true, + }) + mockUseGetExperimentsQuery.mockReturnValue(empty) + + const result = useFeatureExperimentFreeze(42, 'env-123') + + expect(result.isLoading).toBe(true) + }) + it('queries only running experiments when feature is enabled', () => { mockUseGetExperimentsQuery.mockReturnValue(empty) diff --git a/frontend/common/hooks/useFeatureExperimentFreeze.ts b/frontend/common/hooks/useFeatureExperimentFreeze.ts index e4b516848936..fd2b768c1360 100644 --- a/frontend/common/hooks/useFeatureExperimentFreeze.ts +++ b/frontend/common/hooks/useFeatureExperimentFreeze.ts @@ -13,7 +13,7 @@ export function useFeatureExperimentFreeze( featureId: number | undefined, environmentId: string, ): FeatureExperimentFreeze { - const { enabled: isExperimentEnabled } = + const { enabled: isExperimentEnabled, isLoading: isVersionLoading } = useHasExperimentationFeature('experimental_flags') const skip = !isExperimentEnabled || !featureId || !environmentId const { data, isLoading } = useGetExperimentsQuery( @@ -29,6 +29,6 @@ export function useFeatureExperimentFreeze( return { experiment, isFrozen: experiment !== null, - isLoading, + isLoading: isVersionLoading || isLoading, } }