Skip to content

Commit db34e96

Browse files
authored
feat: add feature flag capabilities to across app (#1935)
We want to be able to toggle features on and off for specific users with Amplitude experiment. This PR introduces a mechanism for initialising feature flags across the frontend with the relevant user data. The feature flags are fetched on application load and when the wallet provider changes the connected wallet. The feature flags are also cached in localstorage, so that subsequent refreshes will be faster. This is the first small step towards feature flags. In the future, we might want to * support feature flag payloads so that properties like text, colours, and images can be changed from amplitude without requiring a commit. * Improve speed by caching feature flags in vercel functions on the edge https://github.com/user-attachments/assets/f60f5a66-28f8-457c-bc7b-86b316a340c3 feature flag configuration page: https://app.amplitude.com/experiment/risklabs/414806/config/680059/configure
1 parent e1df808 commit db34e96

File tree

12 files changed

+174
-21
lines changed

12 files changed

+174
-21
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"@across-protocol/contracts-v4.1.1": "npm:@across-protocol/contracts@4.1.1",
1010
"@across-protocol/sdk": "^4.3.98",
1111
"@amplitude/analytics-browser": "^2.3.5",
12+
"@amplitude/experiment-js-client": "^1.18.0",
1213
"@balancer-labs/sdk": "1.1.6-beta.16",
1314
"@coral-xyz/borsh": "^0.30.1",
1415
"@emotion/react": "^11.13.0",

src/components/Footer/Footer.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
StyledTwitterIcon,
88
} from "./Footer.styles";
99
import { ReactComponent as DiscordLogo } from "assets/icons/discord.svg";
10+
import { useFeatureFlag } from "../../hooks";
1011

1112
export const NAV_LINKS = [
1213
{
@@ -42,6 +43,7 @@ export const NAV_LINKS = [
4243
];
4344

4445
const Footer = () => {
46+
const hasDemoFlag = useFeatureFlag("demo-flag");
4547
return (
4648
<Wrapper>
4749
<LinksContainer>
@@ -63,6 +65,7 @@ const Footer = () => {
6365
rel="noopener noreferrer"
6466
>
6567
<FooterLogo />
68+
{hasDemoFlag && <p> - Demo feature flag active</p>}
6669
</AccentLink>
6770
</Wrapper>
6871
);
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const FEATURE_FLAGS = ["demo-flag"] as const;
2+
export type FeatureFlag = (typeof FEATURE_FLAGS)[number];
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { createContext } from "react";
2+
import { Variants } from "@amplitude/experiment-js-client";
3+
4+
interface FeatureFlagsContextValue {
5+
flags: Variants;
6+
isLoading: boolean;
7+
isInitialized: boolean;
8+
initializeFeatureFlags: () => void;
9+
fetchFlags: () => Promise<void>;
10+
}
11+
12+
export const FeatureFlagsContext =
13+
createContext<FeatureFlagsContextValue | null>(null);
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { ReactNode, useCallback, useRef, useState } from "react";
2+
import {
3+
Experiment,
4+
ExperimentClient,
5+
Variants,
6+
} from "@amplitude/experiment-js-client";
7+
import { FEATURE_FLAGS } from "./feature-flags";
8+
import { FeatureFlagsContext } from "./featureFlagsContext";
9+
10+
const publicDeploymentKey = "client-jAXxBGw14klnGdfOPcGCrIpbvpvBXZqL";
11+
12+
export function FeatureFlagsProvider({ children }: { children: ReactNode }) {
13+
const [flags, setFlags] = useState<Variants>({});
14+
const [isLoading, setIsLoading] = useState(false);
15+
const [isInitialized, setIsInitialized] = useState(false);
16+
const experimentClientRef = useRef<ExperimentClient | null>(null);
17+
18+
const initializeFeatureFlags = useCallback(() => {
19+
if (experimentClientRef.current) {
20+
console.warn("Experiment client already initialized");
21+
return;
22+
}
23+
experimentClientRef.current = Experiment.initializeWithAmplitudeAnalytics(
24+
publicDeploymentKey,
25+
{}
26+
);
27+
// set flags from localstorage cache before a fetch is initialized
28+
setFlags(experimentClientRef.current.all());
29+
setIsInitialized(true);
30+
}, []);
31+
32+
const fetchFlags = useCallback(async () => {
33+
if (!experimentClientRef.current) {
34+
console.error(
35+
"Experiment client not initialized. Call initializeExperiment() first."
36+
);
37+
return;
38+
}
39+
40+
try {
41+
setIsLoading(true);
42+
await experimentClientRef.current.fetch(undefined, {
43+
flagKeys: [...FEATURE_FLAGS],
44+
});
45+
setFlags(experimentClientRef.current.all());
46+
} catch (error) {
47+
console.error("Failed to fetch feature flags:", error);
48+
} finally {
49+
setIsLoading(false);
50+
}
51+
}, []);
52+
53+
return (
54+
<FeatureFlagsContext.Provider
55+
value={{
56+
flags,
57+
isLoading,
58+
isInitialized,
59+
initializeFeatureFlags,
60+
fetchFlags,
61+
}}
62+
>
63+
{children}
64+
</FeatureFlagsContext.Provider>
65+
);
66+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { useContext } from "react";
2+
3+
import { FeatureFlag } from "./feature-flags";
4+
import { FeatureFlagsContext } from "./featureFlagsContext";
5+
6+
export const useFeatureFlag = (featureFlag: FeatureFlag): boolean => {
7+
const context = useContext(FeatureFlagsContext);
8+
if (!context) {
9+
throw new Error("useFeatureFlag must be used within FeatureFlagsProvider");
10+
}
11+
return context.flags[featureFlag]?.value === "on";
12+
};
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { useContext } from "react";
2+
import { FeatureFlagsContext } from "./featureFlagsContext";
3+
4+
export const useFeatureFlagsContext = () => {
5+
const context = useContext(FeatureFlagsContext);
6+
if (!context) {
7+
throw new Error("useFeatureFlags must be used within FeatureFlagsProvider");
8+
}
9+
return context;
10+
};

src/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,5 @@ export * from "./useWalletTrace";
1717
export * from "./useQueue";
1818
export * from "./useAmplitude";
1919
export * from "./useRewardSummary";
20+
export * from "./feature-flags/useFeatureFlag";
2021
export * from "./useTokenInput";

src/hooks/useInitialUserPropTraces.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
1-
import { useState, useEffect } from "react";
1+
import { useEffect, useState } from "react";
22

33
import { useConnection } from "hooks";
44
import {
5+
identifyReferrer,
56
identifyUserWallet,
6-
setUserId,
77
identifyWalletChainId,
8-
identifyReferrer,
8+
setUserId,
99
} from "utils/amplitude";
1010
import { ampli } from "ampli";
11+
import { useFeatureFlagsContext } from "./feature-flags/useFeatureFlagsContext";
1112

1213
export function useInitialUserPropTraces(isAmpliLoaded: boolean) {
14+
const { fetchFlags } = useFeatureFlagsContext();
1315
const [areInitialUserPropsSet, setAreInitialUserPropsSet] = useState(false);
1416
const [prevTrackedAccount, setPrevTrackedAccount] = useState<
1517
string | undefined
@@ -47,6 +49,9 @@ export function useInitialUserPropTraces(isAmpliLoaded: boolean) {
4749
await identifyReferrer()?.promise;
4850

4951
setAreInitialUserPropsSet(true);
52+
53+
// Fetch feature flags AFTER userId is set
54+
await fetchFlags();
5055
setPrevTrackedAccount(account);
5156
})();
5257
}, [
@@ -56,6 +61,7 @@ export function useInitialUserPropTraces(isAmpliLoaded: boolean) {
5661
prevTrackedAccount,
5762
chainId,
5863
connector,
64+
fetchFlags,
5965
]);
6066

6167
useEffect(() => {

src/hooks/useLoadAmpli.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
1-
import { useState, useEffect } from "react";
1+
import { useEffect, useState } from "react";
22
import * as amplitude from "@amplitude/analytics-browser";
33

44
import { ampli } from "ampli";
55
import {
66
amplitudeAPIKey,
7+
amplitudeServerUrl,
78
isAmplitudeLoggingEnabled,
89
isProductionBuild,
9-
amplitudeServerUrl,
1010
} from "utils";
11+
import { useFeatureFlagsContext } from "./feature-flags/useFeatureFlagsContext";
1112

1213
export function useLoadAmpli() {
1314
const [isAmpliLoaded, setIsAmpliLoaded] = useState(false);
15+
const { initializeFeatureFlags } = useFeatureFlagsContext();
1416

1517
useEffect(() => {
1618
if (amplitudeAPIKey && !isAmpliLoaded) {
@@ -40,10 +42,11 @@ export function useLoadAmpli() {
4042
}).promise
4143
)
4244
.then(() => {
45+
initializeFeatureFlags();
4346
setIsAmpliLoaded(true);
4447
});
4548
}
46-
}, [isAmpliLoaded]);
49+
}, [isAmpliLoaded, initializeFeatureFlags]);
4750

4851
return { isAmpliLoaded };
4952
}

0 commit comments

Comments
 (0)