Repository: artsy/eigen
Pull request #11696: chore: update unleash implementation
Author: login: dblandin
Created: 2025-03-14T16:06:01Z | Merged: 2025-03-18T18:55:45Z

### Description

Updates our Unleash integration to rely less on custom hooks/providers and more on the official [@unleash/proxy-client-react](https://github.com/Unleash/proxy-client-react) library.

Up until now we've been disabling the client's default behavior of refreshing Unleash flags (and variants) in the background – instead we disable the initial fetch and explicitly request a refresh upon the app entering the foreground. In my testing, refreshing these toggles in the background didn't cause any glaring issue. Depending on the how the experiment is setup, I suppose this could cause a noticeable UI shift if the user happens to be on a targeted screen at the moment targeting changes on the backend. Worth testing a bit more.

cc/ @anandaroop 

### PR Checklist

- [x] I have tested my changes on **iOS** and **Android**.
- [x] I hid my changes behind a **[feature flag]**, or they don't need one.
- [x] I have included **screenshots** or **videos**, or I have not changed the UI.
- [x] I have added **tests**, or my changes don't require any.
- [x] I added an **[app state migration]**, or my changes do not require one.
- [x] I have documented any **follow-up work** that this PR will require, or it does not require any.
- [x] I have added a **changelog entry** below, or my changes do not require one.

### To the reviewers 👀

- [ ] I would like **at least one** of the reviewers to **run** this PR on the simulator or device.

<details><summary>Changelog updates</summary>

### Changelog updates

<!-- 📝 Please fill out at least one of these sections. -->
<!-- ⓘ 'User-facing' changes will be published as release notes. -->
<!-- ⌫ Feel free to remove sections that don't apply. -->
<!-- • Write a markdown list or just a single paragraph, but stick to plain text. -->
<!-- 📖 eg. `Enable lotsByFollowedArtists` or `Fix phone input misalignment`. -->
<!-- 🤷‍♂️ Replace this entire block with the hashtag `#nochangelog` to avoid updating the changelog. -->
<!-- ⚠️ Prefix with `[NEEDS EXTERNAL QA]` if a change requires external QA -->

#### Dev changes

- chore: update unleash implementation

<!-- end_changelog_updates -->

</details>

Need help with something? Have a look at our [docs], or get in touch with us.

[app state migration]: ../blob/main/docs/adding_state_migrations.md
[feature flag]: ../blob/main/docs/developing_a_feature.md
[docs]: ../blob/main/docs/README.md



Diff:
diff --git a/docs/add_an_experiment.md b/docs/add_an_experiment.md
index 319104db692..f71a8602b61 100644
--- a/docs/add_an_experiment.md
+++ b/docs/add_an_experiment.md
@@ -4,11 +4,17 @@ We are using Unleash to run A/B Testing experiments at Artsy. In order to create
 
 ### Contents
 
-- [Adding an experiment to Unleash dashboard](#adding-an-experiment-to-unleash-dashboard)
-- [Adding an experiment to Eigen](#adding-an-experiment-to-eigen)
-- [Using an experiment](#using-an-experiment)
-- [Removing/killing an experiment](#removingkilling-an-experiment)
-- [Adding overrides](#adding-an-override)
+- [Adding a New Experiment](#adding-a-new-experiment)
+  - [Contents](#contents)
+  - [Adding an experiment to Unleash dashboard](#adding-an-experiment-to-unleash-dashboard)
+  - [Adding an experiment to Eigen](#adding-an-experiment-to-eigen)
+  - [Using an experiment](#using-an-experiment)
+    - [Querying for a single flag](#querying-for-a-single-flag)
+    - [Querying for a single experiment](#querying-for-a-single-experiment)
+    - [Tracking an experiment](#tracking-an-experiment)
+  - [Winding down a completed Experiment](#winding-down-a-completed-experiment)
+  - [Adding an Override](#adding-an-override)
+  - [Still need help?](#still-need-help)
 
 ## Adding an experiment to Unleash dashboard
 
@@ -24,7 +30,6 @@ We are using Unleash to run A/B Testing experiments at Artsy. In order to create
 
 ```ts
   "our-new-experiment": {
-    fallbackEnabled: false,
     description: "Experiment description",
     payloadSuggestions: ["payload-1", "payload-2"] // If applicable
   },
@@ -34,19 +39,16 @@ or if we want a variant we can use something like
 
 ```ts
   "our-new-experiment": {
-    fallbackEnabled: true,
-    fallbackVariant: "the-variant-name",
     description: "Experiment description",
     payloadSuggestions: ["payload-1", "payload-2"] // If applicable
   },
 ```
 
-| value                | description                                                                                                      |
-| -------------------- | ---------------------------------------------------------------------------------------------------------------- |
-| `fallbackEnabled`    | (`boolean`) the experiment flag has a fallback value in case we don't receive anything from Unleash client sdk   |
-| `fallbackVariant`    | (`string` or `undefined`) fallback value, applicable only if `fallbackEnabled` is set to `true`                  |
-| `description`        | (`string`) a string describing your experiment                                                                   |
-| `payloadSuggestions` | (`string[]`) a list of strings useful for quickly setting an admin override from Dev Settings > Experiments menu |
+| value                | description                                                                |
+| -------------------- | -------------------------------------------------------------------------- |
+| `description`        | (`string`) a string describing your experiment                             |
+| `variantSuggestions` | (`string[]`) a list of variant options, for use via the Dev Menu interface |
+| `payloadSuggestions` | (`string[]`) a list of payload options, for use via the Dev Menu interface |
 
 Don't forget to add some tracking on this, using `reportExperimentVariant`. Look for other examples in the code.
 
@@ -72,13 +74,19 @@ You can access the flag value in a functional react component using `useExperime
 You can access the variant value in a functional react component using `useExperimentVariant`.
 
 ```diff
-+ const ourNewExperiment = useExperimentVariant("our-new-experiment")
-  return (
-    <>
-+    {ourNewExperiment.enabled && ourNewExperiment.payload === "payloadA" && <AComponent />}
-+    {ourNewExperiment.enabled && ourNewExperiment.payload === "payloadB" && <BComponent custom={ourNewExperiment.payload} />}
-    <>
-  )
++ const { variant } = useExperimentVariant("our-new-experiment")
++
++ return (
++   <>
++     {variant.enabled ? (
++      {variant.name === "control" && <ControlComponent />}
++      {variant.name === "variant-b" && <ExperimentComponent payload={variant.payload} />}
++      {variant.name === "variant-c" && <ExperimentComponent payload={variant.payload} />}
++     ) : (
++      <ControlComponent />
++     )}
++   <>
++ )
 ```
 
 > Note: Avoid using `experiment.variant` and instead use the `experiment.payload` for rendering your UI! it's more future proof and it's more convenient this way to create multiple experiments using the same flag where you update the control variant
@@ -89,15 +97,24 @@ In order to track an experiment, you can use the `trackExperiment` helper that c
 
 ```diff
 + const { trackExperiment } = useExperimentVariant("our-new-experiment")
++
++ trackExperiment({
++   context_owner_screen: OwnerType.artist,
++   context_owner_id: "4d8b92b34eb68a1b2c0003f4",
++   context_owner_slug: "andy-warhol",
++   context_owner_type: OwnerType.artist,
++ })
 ```
 
-## Removing/Killing an Experiment
+## Winding down a completed Experiment
+
+Once an experiment is done, we'll have a winning variant. In order to roll out the winning variant for everyone, we can promote the variant to 100% of users in the Unleash dashboard.
+
+For example, in the previous experiment, we were testing if the `variant-b` variant performed better than `control` we'd promote `variant-b` to 100% of variant requests.
 
-Once an experiment is done, usually we have a winner variant. In order to roll out that variant for everyone targeted by it, we will need to set it as a default strategy before "killing" it.
+Afterwards we can update Eigen to remove the experiment code, leaving only the winning variant in place.
 
-For example, in the previous experiment, we were testing if `varA` is performing better than `varB`. Assuming that it actually did, we then set `varA` as a default variant.
-Then we need to update eigen to remove the experiment code and use only the winner variant code.
-After that, we can archive that experiment in the Unleash dashboard.
+We should leave the experiment active in Unleash until clients are no longer requesting a variant for the experiment. This can take some time as users naturally upgrade.
 
 ## Adding an Override
 
diff --git a/package.json b/package.json
index 270e8cbd972..07010c62f17 100644
--- a/package.json
+++ b/package.json
@@ -134,6 +134,7 @@
     "@shopify/flash-list": "1.7.1",
     "@stripe/stripe-react-native": "0.39.0",
     "@styled-system/theme-get": "5.1.2",
+    "@unleash/proxy-client-react": "4.5.2",
     "autosuggest-highlight": "3.3.4",
     "easy-peasy": "6.0.5",
     "events": "3.3.0",
@@ -197,7 +198,7 @@
     "styled-components": "5.3.6",
     "styled-system": "5.1.5",
     "supercluster": "7.1.5",
-    "unleash-proxy-client": "3.1.1",
+    "unleash-proxy-client": "3.7.3",
     "url": "0.11.3",
     "uuid": "10.0.0",
     "victory-native": "36.6.8",
diff --git a/src/app/Providers.tsx b/src/app/Providers.tsx
index 8086baa05a9..5b44ed04b41 100644
--- a/src/app/Providers.tsx
+++ b/src/app/Providers.tsx
@@ -1,8 +1,10 @@
 import { Screen, ScreenDimensionsProvider, Spinner, Theme } from "@artsy/palette-mobile"
 import { ActionSheetProvider } from "@expo/react-native-action-sheet"
 import { PortalProvider } from "@gorhom/portal"
+import FlagProvider from "@unleash/proxy-client-react"
 import { ArtworkListsProvider } from "app/Components/ArtworkLists/ArtworkListsContext"
 import { ShareSheetProvider } from "app/Components/ShareSheet/ShareSheetContext"
+import { WrappedFlagProvider } from "app/system/flags/Components/WrappedFlagProvider"
 import { getRelayEnvironment } from "app/system/relay/defaultEnvironment"
 import { useFeatureFlag } from "app/utils/hooks/useFeatureFlag"
 import { ProvideScreenDimensions } from "app/utils/hooks/useScreenDimensions"
@@ -17,7 +19,6 @@ import { ToastProvider } from "./Components/Toast/toastHook"
 import { GlobalStore, GlobalStoreProvider } from "./store/GlobalStore"
 import { GravityWebsocketContextProvider } from "./utils/Websockets/GravityWebsocketContext"
 import { combineProviders } from "./utils/combineProviders"
-import { UnleashProvider } from "./utils/experiments/UnleashProvider"
 import { track } from "./utils/track"
 
 export const Providers: React.FC<{ children: React.ReactNode }> = ({ children }) =>
@@ -27,7 +28,7 @@ export const Providers: React.FC<{ children: React.ReactNode }> = ({ children })
       GestureHandlerProvider,
       TrackingProvider,
       GlobalStoreProvider,
-      UnleashProvider, // uses: GlobalStoreProvider
+      WrappedFlagProvider,
       SafeAreaProvider,
       ProvideScreenDimensions, // uses: SafeAreaProvider
       // FIXME: Only use one from palette-mobile
@@ -59,6 +60,7 @@ export const TestProviders: React.FC<{ skipRelay?: boolean; includeNavigation?:
       includeNavigation && NavigationTestsProvider,
       TrackingProvider,
       GlobalStoreProvider,
+      TestFlagProvider,
       SafeAreaProvider,
       PortalProvider,
       ProvideScreenDimensions,
@@ -79,6 +81,10 @@ export const TestProviders: React.FC<{ skipRelay?: boolean; includeNavigation?:
 
 // Providers with preset props
 
+const TestFlagProvider: React.FC = ({ children }) => {
+  return <FlagProvider startClient={false}>{children}</FlagProvider>
+}
+
 const GestureHandlerProvider = (props: { children?: React.ReactNode }) => (
   <GestureHandlerRootView style={{ flex: 1 }} {...props} />
 )
diff --git a/src/app/Scenes/Artwork/Components/ArtworkError.tsx b/src/app/Scenes/Artwork/Components/ArtworkError.tsx
index 5bc658a93a0..8d797cce8f0 100644
--- a/src/app/Scenes/Artwork/Components/ArtworkError.tsx
+++ b/src/app/Scenes/Artwork/Components/ArtworkError.tsx
@@ -11,8 +11,8 @@ import {
   DEFAULT_RECS_MODEL_VERSION,
   RECOMMENDATION_MODEL_EXPERIMENT_NAME,
 } from "app/Scenes/NewWorksForYou/NewWorksForYou"
+import { useExperimentVariant } from "app/system/flags/hooks/useExperimentVariant"
 import { goBack } from "app/system/navigation/navigate"
-import { useExperimentVariant } from "app/utils/experiments/hooks"
 import { withSuspense } from "app/utils/hooks/withSuspense"
 import { ScrollView } from "react-native"
 import { graphql, useFragment, useLazyLoadQuery } from "react-relay"
@@ -78,13 +78,11 @@ export const ArtworkError: React.FC<ArtworkErrorProps> = ({ homePage, me, viewer
 
 export const ArtworkErrorScreen: React.FC<{}> = withSuspense({
   Component: () => {
-    const worksForYouRecommendationsModel = useExperimentVariant(
-      RECOMMENDATION_MODEL_EXPERIMENT_NAME
-    )
+    const { variant } = useExperimentVariant(RECOMMENDATION_MODEL_EXPERIMENT_NAME)
     const data = useLazyLoadQuery<ArtworkErrorQuery>(
       ArtworkErrorScreenQuery,
       {
-        version: worksForYouRecommendationsModel.payload || DEFAULT_RECS_MODEL_VERSION,
+        version: variant.payload?.value || DEFAULT_RECS_MODEL_VERSION,
       },
       { fetchPolicy: "network-only" }
     )
diff --git a/src/app/Scenes/HomeView/HomeView.tsx b/src/app/Scenes/HomeView/HomeView.tsx
index 8bf6a913aa0..993b94f959e 100644
--- a/src/app/Scenes/HomeView/HomeView.tsx
+++ b/src/app/Scenes/HomeView/HomeView.tsx
@@ -19,10 +19,10 @@ import { useHomeViewTracking } from "app/Scenes/HomeView/hooks/useHomeViewTracki
 import { Playground } from "app/Scenes/Playground/Playground"
 import { searchQueryDefaultVariables } from "app/Scenes/Search/Search"
 import { GlobalStore } from "app/store/GlobalStore"
+import { useExperimentVariant } from "app/system/flags/hooks/useExperimentVariant"
 import { navigate } from "app/system/navigation/navigate"
 import { getRelayEnvironment } from "app/system/relay/defaultEnvironment"
 import { useBottomTabsScrollToTop } from "app/utils/bottomTabsHelper"
-import { useExperimentVariant } from "app/utils/experiments/hooks"
 import { extractNodes } from "app/utils/extractNodes"
 import { useDevToggle } from "app/utils/hooks/useDevToggle"
 import { useFeatureFlag } from "app/utils/hooks/useFeatureFlag"
diff --git a/src/app/Scenes/HomeView/Sections/Section.tsx b/src/app/Scenes/HomeView/Sections/Section.tsx
index 99af0f07f02..89ba4cde5f6 100644
--- a/src/app/Scenes/HomeView/Sections/Section.tsx
+++ b/src/app/Scenes/HomeView/Sections/Section.tsx
@@ -19,7 +19,7 @@ import { HomeViewSectionSalesQueryRenderer } from "app/Scenes/HomeView/Sections/
 import { HomeViewSectionShowsQueryRenderer } from "app/Scenes/HomeView/Sections/HomeViewSectionShows"
 import { HomeViewSectionTasksQueryRenderer } from "app/Scenes/HomeView/Sections/HomeViewSectionTasks"
 import { HomeViewSectionViewingRoomsQueryRenderer } from "app/Scenes/HomeView/Sections/HomeViewSectionViewingRooms"
-import { useExperimentVariant } from "app/utils/experiments/hooks"
+import { useExperimentVariant } from "app/system/flags/hooks/useExperimentVariant"
 import { useFeatureFlag } from "app/utils/hooks/useFeatureFlag"
 import { CleanRelayFragment } from "app/utils/relayHelpers"
 
@@ -37,7 +37,7 @@ export interface SectionSharedProps extends FlexProps {
 
 export const Section: React.FC<SectionProps> = ({ section, ...rest }) => {
   const enableNavigationPills = useFeatureFlag("AREnableHomeViewQuickLinks")
-  const { enabled: quickLinksExperimentEnabled, variant } = useExperimentVariant(
+  const { variant: quickLinksExperimentVariant } = useExperimentVariant(
     "onyx_quick-links-experiment"
   )
 
@@ -96,7 +96,11 @@ export const Section: React.FC<SectionProps> = ({ section, ...rest }) => {
     case "HomeViewSectionTasks":
       return <HomeViewSectionTasksQueryRenderer sectionID={section.internalID} {...rest} />
     case "HomeViewSectionNavigationPills": {
-      if (enableNavigationPills && quickLinksExperimentEnabled && variant === "experiment") {
+      if (
+        enableNavigationPills &&
+        quickLinksExperimentVariant.enabled &&
+        quickLinksExperimentVariant.name === "experiment"
+      ) {
         return (
           <HomeViewSectionNavigationPillsQueryRenderer sectionID={section.internalID} {...rest} />
         )
diff --git a/src/app/store/AuthModel.ts b/src/app/store/AuthModel.ts
index 49f38855690..e6512ae0511 100644
--- a/src/app/store/AuthModel.ts
+++ b/src/app/store/AuthModel.ts
@@ -16,7 +16,6 @@ import {
   handleSignUpError,
   showError,
 } from "app/utils/auth/authHelpers"
-import { updateExperimentsContext } from "app/utils/experiments/helpers"
 import { isArtsyEmail } from "app/utils/general"
 import { SegmentTrackingProvider } from "app/utils/track/SegmentTrackingProvider"
 import { postEventToProviders } from "app/utils/track/providers"
@@ -969,7 +968,6 @@ export const getAuthModel = (): AuthModel => ({
       // upload events for Android devices. If they update the package, we can remove it.
       SiftReactNative.upload()
       SegmentTrackingProvider.identify?.(userId, { is_temporary_user: 0 })
-      updateExperimentsContext({ userId })
     }
 
     state.sessionState.isUserIdentified = true
@@ -992,7 +990,6 @@ export const getAuthModel = (): AuthModel => ({
     GlobalStore.actions.artsyPrefs.pushPromptLogic.setPushPermissionsRequestedThisSession(false)
     SiftReactNative.unsetUserId()
     SegmentTrackingProvider.identify?.(undefined, { is_temporary_user: 1 })
-    updateExperimentsContext({ userId: undefined })
 
     await Promise.all([
       Platform.OS === "ios"
diff --git a/src/app/store/config/ExperimentsModel.ts b/src/app/store/config/ExperimentsModel.ts
index ab49f36f223..39550025b9b 100644
--- a/src/app/store/config/ExperimentsModel.ts
+++ b/src/app/store/config/ExperimentsModel.ts
@@ -1,5 +1,5 @@
 import { assignDeep } from "app/store/persistence"
-import { EXPERIMENT_NAME } from "app/utils/experiments/experiments"
+import { EXPERIMENT_NAME } from "app/system/flags/experiments"
 import { action, Action } from "easy-peasy"
 
 export interface ExperimentsModel {
diff --git a/src/app/system/devTools/DevMenu/Components/DevTools.tsx b/src/app/system/devTools/DevMenu/Components/DevTools.tsx
index 6dbf11dbc1a..658d77e11d6 100644
--- a/src/app/system/devTools/DevMenu/Components/DevTools.tsx
+++ b/src/app/system/devTools/DevMenu/Components/DevTools.tsx
@@ -11,10 +11,10 @@ import { Versions } from "app/store/migration"
 import { DevMenuButtonItem } from "app/system/devTools/DevMenu/Components/DevMenuButtonItem"
 import { DevToggleItem } from "app/system/devTools/DevMenu/Components/DevToggleItem"
 import { eigenSentryReleaseName } from "app/system/errorReporting/setupSentry"
+import { useUnleashEnvironment } from "app/system/flags/hooks/useUnleashEnvironment"
 import { dismissModal, navigate } from "app/system/navigation/navigate"
 import { _globalCacheRef } from "app/system/relay/defaultEnvironment"
 import { saveToken } from "app/utils/PushNotification"
-import { useUnleashEnvironment } from "app/utils/experiments/hooks"
 import { requestSystemPermissions } from "app/utils/requestPushNotificationsPermission"
 import { capitalize, sortBy } from "lodash"
 import { useState } from "react"
diff --git a/src/app/system/devTools/DevMenu/Components/ExperimentFlagItem.tsx b/src/app/system/devTools/DevMenu/Components/ExperimentFlagItem.tsx
index aab05a57a28..122b3de1ce0 100644
--- a/src/app/system/devTools/DevMenu/Components/ExperimentFlagItem.tsx
+++ b/src/app/system/devTools/DevMenu/Components/ExperimentFlagItem.tsx
@@ -11,15 +11,11 @@ import {
   useSpace,
 } from "@artsy/palette-mobile"
 import Clipboard from "@react-native-clipboard/clipboard"
+import { IVariant, useFlags } from "@unleash/proxy-client-react"
 import { FONTS } from "app/Components/HTML"
 import { useToast } from "app/Components/Toast/toastHook"
 import { GlobalStore } from "app/store/GlobalStore"
-import {
-  EXPERIMENT_NAME,
-  ExperimentDescriptor,
-  experiments,
-} from "app/utils/experiments/experiments"
-import { useExperimentVariant } from "app/utils/experiments/hooks"
+import { EXPERIMENT_NAME, ExperimentDescriptor, experiments } from "app/system/flags/experiments"
 import { isEmpty } from "lodash"
 import { useState } from "react"
 import { Alert, Modal, TextInput, TouchableOpacity, TouchableWithoutFeedback } from "react-native"
@@ -28,7 +24,7 @@ export const ExperimentFlagItem: React.FC<{ description: string; flag: EXPERIMEN
   description,
   flag,
 }) => {
-  const experiment = useExperimentVariant(flag)
+  const unleashVariant = useFlags().find((e) => e.name === flag)?.variant as IVariant
   const localPayloadOverrides = GlobalStore.useAppState(
     (s) => s.artsyPrefs.experiments.localPayloadOverrides
   )
@@ -42,8 +38,8 @@ export const ExperimentFlagItem: React.FC<{ description: string; flag: EXPERIMEN
   const localExperiment = experiments[flag] as ExperimentDescriptor
 
   const toast = useToast()
-  const [variant, setVariant] = useState(experiment.variant)
-  const [payload, setPayload] = useState(experiment.payload)
+  const [variant, setVariant] = useState(localVariantOverrides[flag])
+  const [payload, setPayload] = useState(localPayloadOverrides[flag])
   const space = useSpace()
   const [visible, setVisible] = useState(false)
 
@@ -117,7 +113,8 @@ export const ExperimentFlagItem: React.FC<{ description: string; flag: EXPERIMEN
                                 setVariant(variantSuggestion)
                               }}
                             >
-                              {variantSuggestion}
+                              {variantSuggestion}{" "}
+                              {unleashVariant?.name === variantSuggestion && "(default)"}
                             </Pill>
                           )
                         })}
@@ -134,7 +131,7 @@ export const ExperimentFlagItem: React.FC<{ description: string; flag: EXPERIMEN
                             setVariant("control")
                           }}
                         >
-                          control {experiment.unleashVariant === "control" && "(default)"}
+                          control {unleashVariant.name === "control" && "(default)"}
                         </Pill>
                         <Pill
                           variant="badge"
@@ -142,7 +139,7 @@ export const ExperimentFlagItem: React.FC<{ description: string; flag: EXPERIMEN
                             setVariant("experiment")
                           }}
                         >
-                          experiment {experiment.unleashVariant === "experiment" && "(default)"}
+                          experiment {unleashVariant.name === "experiment" && "(default)"}
                         </Pill>
                       </Flex>
                     )}
@@ -170,7 +167,7 @@ export const ExperimentFlagItem: React.FC<{ description: string; flag: EXPERIMEN
                               }}
                             >
                               {payloadSuggestion}{" "}
-                              {experiment.unleashPayload === payloadSuggestion && "(default)"}
+                              {unleashVariant.payload?.value === payloadSuggestion && "(default)"}
                             </Pill>
                           )
                         })}
@@ -183,7 +180,7 @@ export const ExperimentFlagItem: React.FC<{ description: string; flag: EXPERIMEN
               <Flex position="absolute" bottom={space(2)} px={2}>
                 <Button
                   block
-                  disabled={hasOverride}
+                  disabled={!hasOverride}
                   variant="outline"
                   onPress={() => {
                     Alert.alert(
@@ -197,8 +194,8 @@ export const ExperimentFlagItem: React.FC<{ description: string; flag: EXPERIMEN
                         {
                           text: "Reset",
                           onPress: () => {
-                            setVariant(experiment.variant)
-                            setPayload(experiment.payload)
+                            setVariant("")
+                            setPayload("")
                             setLocalVariantOverride({ key: flag, value: null })
                             setLocalPayloadOverride({ key: flag, value: null })
                             setVisible(false)
@@ -235,7 +232,7 @@ export const ExperimentFlagItem: React.FC<{ description: string; flag: EXPERIMEN
       </Text>
       <Text>
         <Text color="black60">Status:</Text>{" "}
-        {experiment.enabled ? (
+        {unleashVariant.enabled ? (
           <Text fontWeight="bold" color="blue100">
             enabled
           </Text>
@@ -246,7 +243,8 @@ export const ExperimentFlagItem: React.FC<{ description: string; flag: EXPERIMEN
         )}
       </Text>
       <Text>
-        <Text color="black60">Variant:</Text> <Text fontWeight="bold">{experiment.variant}</Text>
+        <Text color="black60">Variant:</Text>{" "}
+        <Text fontWeight="bold">{localVariantOverrides[flag] || unleashVariant.name}</Text>
         <TouchableOpacity
           onPress={() => {
             setVisible(true)
@@ -258,7 +256,7 @@ export const ExperimentFlagItem: React.FC<{ description: string; flag: EXPERIMEN
       <Flex flexDirection="row" flexWrap="wrap">
         <Text color="black60">Payload: </Text>
         <Text fontWeight="bold" selectable>
-          {experiment.payload || "--"}
+          {unleashVariant.payload?.value || "--"}
           <TouchableOpacity
             onPress={() => {
               setVisible(true)
diff --git a/src/app/system/devTools/DevMenu/Components/Experiments.tsx b/src/app/system/devTools/DevMenu/Components/Experiments.tsx
index df35c10ba44..a6c8a1db5ef 100644
--- a/src/app/system/devTools/DevMenu/Components/Experiments.tsx
+++ b/src/app/system/devTools/DevMenu/Components/Experiments.tsx
@@ -3,7 +3,7 @@ import { Expandable } from "app/Components/Expandable"
 import { GlobalStore } from "app/store/GlobalStore"
 import { DevMenuButtonItem } from "app/system/devTools/DevMenu/Components/DevMenuButtonItem"
 import { ExperimentFlagItem } from "app/system/devTools/DevMenu/Components/ExperimentFlagItem"
-import { EXPERIMENT_NAME, experiments } from "app/utils/experiments/experiments"
+import { EXPERIMENT_NAME, experiments } from "app/system/flags/experiments"
 import { isEmpty } from "lodash"
 import { Alert } from "react-native"
 
diff --git a/src/app/system/flags/Components/WrappedFlagProvider.tsx b/src/app/system/flags/Components/WrappedFlagProvider.tsx
new file mode 100644
index 00000000000..39e7247a25e
--- /dev/null
+++ b/src/app/system/flags/Components/WrappedFlagProvider.tsx
@@ -0,0 +1,55 @@
+import AsyncStorage from "@react-native-async-storage/async-storage"
+import FlagProvider, { IConfig, useFlagsStatus } from "@unleash/proxy-client-react"
+import { useUnleashEnvironment } from "app/system/flags/hooks/useUnleashEnvironment"
+import { useUnleashInitializer } from "app/system/flags/hooks/useUnleashInitializer"
+import { useUnleashListener } from "app/system/flags/hooks/useUnleashListener"
+import Keys from "react-native-keys"
+
+export const WrappedFlagProvider: React.FC = ({ children }) => {
+  const { unleashEnv: env } = useUnleashEnvironment()
+
+  const storageName = (name: string) => `unleash-values:${name}`
+
+  const config: IConfig = {
+    url:
+      env === "production"
+        ? Keys.secureFor("UNLEASH_PROXY_URL_PRODUCTION")
+        : Keys.secureFor("UNLEASH_PROXY_URL_STAGING"),
+    clientKey:
+      env === "production"
+        ? Keys.secureFor("UNLEASH_PROXY_CLIENT_KEY_PRODUCTION")
+        : Keys.secureFor("UNLEASH_PROXY_CLIENT_KEY_STAGING"),
+    appName: "eigen",
+    storageProvider: {
+      save: async (name: string, data: any) =>
+        AsyncStorage.setItem(storageName(name), JSON.stringify(data)),
+      get: async (name: string) => {
+        const data = await AsyncStorage.getItem(storageName(name))
+        return data ? JSON.parse(data) : undefined
+      },
+    },
+  }
+
+  return (
+    <FlagProvider config={config} startClient={false}>
+      <UnleashInitializer children={children} />
+    </FlagProvider>
+  )
+}
+
+const UnleashInitializer: React.FC = ({ children }) => {
+  useUnleashListener()
+  useUnleashInitializer()
+
+  return <UnleashReady children={children} />
+}
+
+const UnleashReady: React.FC = ({ children }) => {
+  const { flagsReady: unleashFlagsReady, flagsError: unleashError } = useFlagsStatus()
+  const isUnleashReady = unleashFlagsReady || unleashError
+
+  if (!isUnleashReady) {
+    return null
+  }
+  return <>{children}</>
+}
diff --git a/src/app/system/flags/experiments.ts b/src/app/system/flags/experiments.ts
new file mode 100644
index 00000000000..cf188811683
--- /dev/null
+++ b/src/app/system/flags/experiments.ts
@@ -0,0 +1,20 @@
+export type ExperimentDescriptor = {
+  readonly description: string
+  readonly payloadSuggestions?: string[]
+  readonly variantSuggestions?: string[]
+}
+
+export const experiments = {
+  "eigen-new-works-for-you-recommendations-model": {
+    description: "value controlling which model to use for new works for you recs",
+  },
+  "onyx_artwork-card-save-and-follow-cta-redesign": {
+    description: "Redesign Save CTA and Add Follow CTA on Artwork Grid/Rail",
+    variantSuggestions: ["variant-a", "variant-b", "variant-c"],
+  },
+  "onyx_quick-links-experiment": {
+    description: "Add quick links section to home view",
+  },
+} satisfies { [key: string]: ExperimentDescriptor }
+
+export type EXPERIMENT_NAME = keyof typeof experiments
diff --git a/src/app/system/flags/hooks/useExperimentFlag.ts b/src/app/system/flags/hooks/useExperimentFlag.ts
new file mode 100644
index 00000000000..d765f3da9b6
--- /dev/null
+++ b/src/app/system/flags/hooks/useExperimentFlag.ts
@@ -0,0 +1,6 @@
+import { useFlag } from "@unleash/proxy-client-react"
+import { EXPERIMENT_NAME } from "app/system/flags/experiments"
+
+export function useExperimentFlag(name: EXPERIMENT_NAME) {
+  return useFlag(name)
+}
diff --git a/src/app/system/flags/hooks/useExperimentVariant.ts b/src/app/system/flags/hooks/useExperimentVariant.ts
new file mode 100644
index 00000000000..c7bfcdfce29
--- /dev/null
+++ b/src/app/system/flags/hooks/useExperimentVariant.ts
@@ -0,0 +1,68 @@
+import { IVariant, useVariant } from "@unleash/proxy-client-react"
+import { GlobalStore } from "app/store/GlobalStore"
+import { EXPERIMENT_NAME } from "app/system/flags/experiments"
+import { ContextProps, reportExperimentVariant } from "app/system/flags/reporter"
+
+export function useExperimentVariant(name: EXPERIMENT_NAME): {
+  variant: IVariant
+  trackExperiment: (contextProps?: ContextProps) => void
+} {
+  const localVariantOverrides = GlobalStore.useAppState(
+    (s) => s.artsyPrefs.experiments.localVariantOverrides
+  )
+  const localPayloadOverrides = GlobalStore.useAppState(
+    (s) => s.artsyPrefs.experiments.localPayloadOverrides
+  )
+  const { variant, overrideApplied } = maybeOverrideVariant(
+    name,
+    useVariant(name),
+    localVariantOverrides,
+    localPayloadOverrides
+  ) as { variant: IVariant; overrideApplied: boolean }
+
+  const trackExperiment = (contextProps?: ContextProps) => {
+    if (!variant.enabled || overrideApplied) {
+      if (__DEV__) {
+        console.warn(
+          `[unleash] ignoring request to track experiment because the variant is disabled or overridden, flag=${name} variant=${variant.name}`
+        )
+      }
+      return
+    }
+
+    reportExperimentVariant({
+      experimentName: name,
+      variantName: variant.name,
+      ...(contextProps as ContextProps),
+    })
+  }
+
+  return {
+    variant,
+    trackExperiment,
+  }
+}
+
+function maybeOverrideVariant(
+  name: EXPERIMENT_NAME,
+  variant: Partial<IVariant>,
+  localVariantOverrides: Record<string, string>,
+  localPayloadOverrides: Record<string, string>
+): { variant: Partial<IVariant>; overrideApplied: boolean } {
+  let overrideApplied = false
+
+  if (localVariantOverrides[name]) {
+    variant.name = String(localVariantOverrides[name])
+    overrideApplied = true
+  }
+
+  if (localPayloadOverrides[name]) {
+    variant.payload = {
+      type: "string",
+      value: String(localPayloadOverrides[name]),
+    }
+    overrideApplied = true
+  }
+
+  return { variant, overrideApplied }
+}
diff --git a/src/app/system/flags/hooks/useUnleashEnvironment.ts b/src/app/system/flags/hooks/useUnleashEnvironment.ts
new file mode 100644
index 00000000000..f552140ae29
--- /dev/null
+++ b/src/app/system/flags/hooks/useUnleashEnvironment.ts
@@ -0,0 +1,16 @@
+import { useDevToggle } from "app/utils/hooks/useDevToggle"
+import { useIsStaging } from "app/utils/hooks/useIsStaging"
+
+export function useUnleashEnvironment(): { unleashEnv: "staging" | "production" } {
+  const isStaging = useIsStaging()
+  const useProductionUnleash = useDevToggle("DTUseProductionUnleash")
+  const unleashEnv = __DEV__
+    ? useProductionUnleash
+      ? "production"
+      : "staging"
+    : isStaging
+      ? "staging"
+      : "production"
+
+  return { unleashEnv }
+}
diff --git a/src/app/system/flags/hooks/useUnleashInitializer.ts b/src/app/system/flags/hooks/useUnleashInitializer.ts
new file mode 100644
index 00000000000..68e7bc74fa2
--- /dev/null
+++ b/src/app/system/flags/hooks/useUnleashInitializer.ts
@@ -0,0 +1,24 @@
+import { useUnleashClient } from "@unleash/proxy-client-react"
+import { GlobalStore } from "app/store/GlobalStore"
+import { useEffect } from "react"
+
+export const useUnleashInitializer = () => {
+  const client = useUnleashClient()
+  const userID = GlobalStore.useAppState((state) => state.auth.userID)
+
+  useEffect(() => {
+    if (userID) {
+      if (client.getContext().userId !== userID) {
+        client.setContextField("userId", userID)
+      }
+
+      client.start()
+    } else {
+      client.stop()
+    }
+
+    return () => {
+      client.stop()
+    }
+  }, [userID])
+}
diff --git a/src/app/system/flags/hooks/useUnleashListener.ts b/src/app/system/flags/hooks/useUnleashListener.ts
new file mode 100644
index 00000000000..b0729384437
--- /dev/null
+++ b/src/app/system/flags/hooks/useUnleashListener.ts
@@ -0,0 +1,42 @@
+import { useUnleashClient } from "@unleash/proxy-client-react"
+import { useEffect } from "react"
+
+export const useUnleashListener = () => {
+  const client = useUnleashClient()
+
+  const onInitialized = () => {
+    if (__DEV__) {
+      console.log("[unleash] initialized")
+    }
+  }
+
+  const onReady = () => {
+    if (__DEV__) {
+      console.log("[unleash] ready")
+    }
+  }
+
+  const onUpdate = () => {
+    if (__DEV__) {
+      console.log("[unleash] updated")
+    }
+  }
+
+  const onError = (e: Error) => {
+    console.error(`[unleash] error ${JSON.stringify(e)}`)
+  }
+
+  useEffect(() => {
+    client.on("initialized", onInitialized)
+    client.on("ready", onReady)
+    client.on("update", onUpdate)
+    client.on("error", onError)
+
+    return () => {
+      client.off("initialized", onInitialized)
+      client.off("ready", onReady)
+      client.off("update", onUpdate)
+      client.off("error", onError)
+    }
+  }, [])
+}
diff --git a/src/app/utils/experiments/reporter.ts b/src/app/system/flags/reporter.ts
similarity index 100%
rename from src/app/utils/experiments/reporter.ts
rename to src/app/system/flags/reporter.ts
diff --git a/src/app/utils/experiments/UnleashProvider.tsx b/src/app/utils/experiments/UnleashProvider.tsx
deleted file mode 100644
index 52608b0f968..00000000000
--- a/src/app/utils/experiments/UnleashProvider.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-import { GlobalStore } from "app/store/GlobalStore"
-import useAppState from "app/utils/useAppState"
-import { createContext, useCallback, useEffect, useState } from "react"
-import { forceFetchToggles } from "./helpers"
-import { useUnleashEnvironment } from "./hooks"
-import { getUnleashClient } from "./unleashClient"
-
-interface UnleashContext {
-  lastUpdate: Date | null
-}
-
-export const UnleashContext = createContext<UnleashContext>({ lastUpdate: null })
-
-export function UnleashProvider({ children }: { children?: React.ReactNode }) {
-  const [lastUpdate, setLastUpdate] = useState<UnleashContext["lastUpdate"]>(null)
-  const isHydrated = GlobalStore.useAppState((state) => state.sessionState.isHydrated)
-  const userId = GlobalStore.useAppState((store) => store?.auth?.userID)
-
-  const { unleashEnv } = useUnleashEnvironment()
-
-  useEffect(() => {
-    if (isHydrated && userId) {
-      const client = getUnleashClient({ env: unleashEnv, userId })
-
-      client.on("initialized", () => {
-        if (__DEV__) {
-          console.log("Unleash initialized")
-        }
-      })
-
-      client.on("ready", () => {
-        if (__DEV__) {
-          console.log("Unleash ready")
-        }
-      })
-
-      client.on("update", () => {
-        if (__DEV__) {
-          console.log("Unleash updated")
-        }
-        setLastUpdate(new Date())
-      })
-
-      client.on("error", () => {
-        console.error("Unleash error")
-      })
-
-      client.on("impression", () => {})
-
-      // starts unleash client each time user logs in
-      client.start()
-
-      return () => {
-        client.stop()
-      }
-    }
-  }, [unleashEnv, isHydrated, userId])
-
-  const onForeground = useCallback(() => {
-    forceFetchToggles(unleashEnv)
-  }, [unleashEnv])
-  useAppState({ onForeground })
-
-  return <UnleashContext.Provider value={{ lastUpdate }}>{children}</UnleashContext.Provider>
-}
diff --git a/src/app/utils/experiments/experiments.ts b/src/app/utils/experiments/experiments.ts
deleted file mode 100644
index 3daea786b77..00000000000
--- a/src/app/utils/experiments/experiments.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-export type ExperimentDescriptorUnion =
-  | { readonly fallbackEnabled: false }
-  | {
-      readonly fallbackEnabled: true
-      readonly fallbackVariant?: string
-      readonly fallbackPayload?: string
-    }
-
-export type ExperimentDescriptor = {
-  readonly description: string
-  // value suggestions for the payload ideally matching the ones coming from Unleash
-  readonly payloadSuggestions?: string[]
-  readonly variantSuggestions?: string[]
-} & ExperimentDescriptorUnion
-
-export const experiments = {
-  "eigen-new-works-for-you-recommendations-model": {
-    fallbackEnabled: false,
-    description: "value controlling which model to use for new works for you recs",
-  },
-  "CX-impressions-tracking-home-rail-views": {
-    fallbackEnabled: false,
-    description: "Allow tracking impression for home screen rails",
-  },
-  "onyx_artwork-card-save-and-follow-cta-redesign": {
-    fallbackEnabled: true,
-    fallbackVariant: "variant-a",
-    description: "Redesign Save CTA and Add Follow CTA on Artwork Grid/Rail",
-    payloadSuggestions: ["variant-a", "variant-b", "variant-c"],
-    variantSuggestions: ["variant-a", "variant-b", "variant-c"],
-  },
-  "onyx_quick-links-experiment": {
-    description: "Add quick links section to home view",
-    fallbackEnabled: true,
-    fallbackVariant: "control",
-    variantSuggestions: ["control", "experiment"],
-  },
-} satisfies { [key: string]: ExperimentDescriptor }
-
-export type EXPERIMENT_NAME = keyof typeof experiments
diff --git a/src/app/utils/experiments/helpers.ts b/src/app/utils/experiments/helpers.ts
deleted file mode 100644
index ca9b12c7e1b..00000000000
--- a/src/app/utils/experiments/helpers.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { throttle } from "lodash"
-import { IMutableContext } from "unleash-proxy-client"
-import { getUnleashClient } from "./unleashClient"
-
-export function updateExperimentsContext(newContext: IMutableContext) {
-  const client = getUnleashClient({ userId: newContext.userId })
-  return client.updateContext(newContext)
-}
-
-// this might get called multiple times in a row because of ios having many providers but one client.
-// we will make sure to throttle it.
-export const forceFetchToggles = throttle(unthrottledForceFetch, 2000, { trailing: false })
-function unthrottledForceFetch(unleashEnv: "production" | "staging") {
-  const client = getUnleashClient({ env: unleashEnv })
-  client.start()
-}
diff --git a/src/app/utils/experiments/hooks.ts b/src/app/utils/experiments/hooks.ts
deleted file mode 100644
index 14bd0fdbe51..00000000000
--- a/src/app/utils/experiments/hooks.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-import { GlobalStore } from "app/store/GlobalStore"
-import { EXPERIMENT_NAME } from "app/utils/experiments/experiments"
-import { ContextProps, reportExperimentVariant } from "app/utils/experiments/reporter"
-import { useDevToggle } from "app/utils/hooks/useDevToggle"
-import { useIsStaging } from "app/utils/hooks/useIsStaging"
-import { useContext, useEffect, useState } from "react"
-import { UnleashContext } from "./UnleashProvider"
-import { getUnleashClient } from "./unleashClient"
-
-export function useExperimentFlag(name: EXPERIMENT_NAME) {
-  const client = getUnleashClient()
-  const [enabled, setEnabled] = useState(client.isEnabled(name))
-
-  const { lastUpdate } = useContext(UnleashContext)
-  useEffect(() => {
-    setEnabled(client.isEnabled(name))
-  }, [lastUpdate])
-
-  return enabled
-}
-
-export function useExperimentVariant(name: EXPERIMENT_NAME): {
-  enabled: boolean
-  variant: string
-  payload?: string
-  unleashVariant: string
-  unleashPayload?: string
-  trackExperiment: (contextProps?: ContextProps) => void
-} {
-  const client = getUnleashClient()
-  const [enabled, setEnabled] = useState(client.isEnabled(name))
-  const [variant, setVariant] = useState(client.getVariant(name))
-  const localPayloadOverrides = GlobalStore.useAppState(
-    (s) => s.artsyPrefs.experiments.localPayloadOverrides
-  )
-  const localVariantOverrides = GlobalStore.useAppState(
-    (s) => s.artsyPrefs.experiments.localVariantOverrides
-  )
-
-  const { lastUpdate } = useContext(UnleashContext)
-  useEffect(() => {
-    setEnabled(client.isEnabled(name))
-    setVariant(client.getVariant(name))
-  }, [lastUpdate])
-
-  const unleashVariant = variant?.name ?? "default-variant"
-  const unleashPayload = variant?.payload?.value
-
-  const trackExperiment = (contextProps?: ContextProps) => {
-    if (!enabled) {
-      return
-    }
-
-    reportExperimentVariant({
-      experimentName: name,
-      variantName: localVariantOverrides[name] || unleashVariant,
-      payload: localPayloadOverrides[name] || unleashPayload,
-      ...(contextProps as ContextProps),
-    })
-  }
-
-  return {
-    enabled: enabled ?? false,
-    variant: localVariantOverrides[name] || unleashVariant,
-    payload: localPayloadOverrides[name] || unleashPayload,
-    unleashVariant,
-    unleashPayload,
-    trackExperiment,
-  }
-}
-
-export function useUnleashEnvironment(): { unleashEnv: "staging" | "production" } {
-  const isStaging = useIsStaging()
-  const useProductionUnleash = useDevToggle("DTUseProductionUnleash")
-  const unleashEnv = __DEV__
-    ? useProductionUnleash
-      ? "production"
-      : "staging"
-    : isStaging
-      ? "staging"
-      : "production"
-
-  return { unleashEnv }
-}
diff --git a/src/app/utils/experiments/unleashClient.ts b/src/app/utils/experiments/unleashClient.ts
deleted file mode 100644
index 5f22d0150ff..00000000000
--- a/src/app/utils/experiments/unleashClient.ts
+++ /dev/null
@@ -1,137 +0,0 @@
-import AsyncStorage from "@react-native-async-storage/async-storage"
-import {
-  EXPERIMENT_NAME,
-  ExperimentDescriptorUnion,
-  experiments,
-} from "app/utils/experiments/experiments"
-import { nullToUndef } from "app/utils/nullAndUndef"
-import Keys from "react-native-keys"
-import { UnleashClient } from "unleash-proxy-client"
-
-// We want to return a phony unleash client for oss builds
-// Some typescript magic so we only have to specify public fields
-type PublicFields<T> = {
-  [K in keyof T]: T[K] extends { [P in keyof T[K]]: T[K][P] } ? K : never
-}[keyof T]
-
-type PublicType<T> = Pick<T, PublicFields<T>>
-
-export type PublicUnleashClient = PublicType<UnleashClient>
-
-/**
- * This will return an unleash client
- * If `env` is undefined, it will use whatever environment we are currently using,
- * production by default.
- * If a specific env is asked, then it will either use the current one if it's right,
- * or create a new one in the requested env.
- */
-export function getUnleashClient(props?: {
-  env?: "production" | "staging"
-  userId?: string | null
-}): PublicUnleashClient {
-  const oss = Keys.OSS
-  if (oss === "true") {
-    return fakeUnleashClient
-  }
-
-  if (!unleashClient || (props?.env !== undefined && props.env !== envBeingUsed)) {
-    if (props?.env !== undefined) {
-      envBeingUsed = props?.env
-    }
-    unleashClient = createUnleashClient(nullToUndef(props?.userId))
-  }
-  return unleashClient
-}
-
-let unleashClient: UnleashClient | null = null
-
-const storageName = (name: string) => `unleash-values:${name}`
-
-const createUnleashClient = (userId: string | undefined) => {
-  console.debug("Unleash creating client", envBeingUsed)
-
-  return new UnleashClient({
-    url:
-      envBeingUsed === "production"
-        ? Keys.secureFor("UNLEASH_PROXY_URL_PRODUCTION")
-        : Keys.secureFor("UNLEASH_PROXY_URL_STAGING"),
-    clientKey:
-      envBeingUsed === "production"
-        ? Keys.secureFor("UNLEASH_PROXY_CLIENT_KEY_PRODUCTION")
-        : Keys.secureFor("UNLEASH_PROXY_CLIENT_KEY_STAGING"),
-    appName: "eigen",
-    context: {
-      userId,
-    },
-    refreshInterval: 0, // don't refresh. we will manually refresh on appStart and goingForeground.
-    storageProvider: {
-      save: async (name, data) => AsyncStorage.setItem(storageName(name), JSON.stringify(data)),
-      get: async (name) => {
-        const data = await AsyncStorage.getItem(storageName(name))
-        return data ? JSON.parse(data) : undefined
-      },
-    },
-    bootstrapOverride: false,
-    bootstrap: (Object.keys(experiments) as EXPERIMENT_NAME[]).map((key) => {
-      const experiment = experiments[key] as ExperimentDescriptorUnion
-
-      if (experiment.fallbackEnabled) {
-        return {
-          name: key,
-          enabled: true,
-          variant: {
-            enabled: true,
-            name: experiment.fallbackVariant ?? "disabled",
-            payload:
-              experiment.fallbackPayload === undefined
-                ? undefined
-                : { type: "string", value: String(experiment.fallbackPayload) },
-          },
-          impressionData: true,
-        }
-      }
-      return {
-        name: key,
-        enabled: false,
-        variant: {
-          enabled: experiment.fallbackEnabled,
-          name: "disabled",
-          payload: undefined,
-        },
-        impressionData: true,
-      }
-    }),
-  })
-}
-
-let envBeingUsed: "production" | "staging" = "production"
-
-// Phony unleash client for oss contributors
-const fakeUnleashClient: PublicUnleashClient = {
-  isEnabled: () => false,
-  getVariant: () => ({ name: "disabled", enabled: false }),
-  getAllToggles() {
-    return []
-  },
-  updateContext: () => Promise.resolve(),
-  getContext: () => ({ appName: "eigen" }),
-  start: () => Promise.resolve(),
-  stop: () => Promise.resolve(),
-  on: (_event: string, _callback: Function, _ctx?: any) => {
-    console.log("[oss] unleash not available, returning fake client")
-    return {} as UnleashClient
-  },
-  once: (_event: string, _callback: Function, _ctx?: any) => {
-    console.log("[oss] unleash not available, returning fake client")
-    return {} as UnleashClient
-  },
-  emit: (_event: string, _callback: Function, _ctx?: any) => {
-    console.log("[oss] unleash not available, returning fake client")
-    return {} as UnleashClient
-  },
-  off: (_event: string, _callback?: Function) => {
-    console.log("[oss] unleash not available, returning fake client")
-    return {} as UnleashClient
-  },
-  setContextField: (_field: string, _value: string) => Promise.resolve(),
-}
diff --git a/src/app/utils/useHideSplashScreen.ts b/src/app/utils/useHideSplashScreen.ts
index 8a0e0728256..78fa5991b69 100644
--- a/src/app/utils/useHideSplashScreen.ts
+++ b/src/app/utils/useHideSplashScreen.ts
@@ -1,3 +1,4 @@
+import { useFlagsStatus } from "@unleash/proxy-client-react"
 import { homeViewScreenQueryVariables } from "app/Scenes/HomeView/HomeView"
 import { GlobalStore } from "app/store/GlobalStore"
 import { usePrefetch } from "app/utils/queryPrefetching"
@@ -8,6 +9,8 @@ import RNBootSplash from "react-native-bootsplash"
 const HOME_VIEW_SPLASH_SCREEN_DELAY = 500
 
 export const useHideSplashScreen = () => {
+  const { flagsReady: unleashFlagsReady, flagsError: unleashError } = useFlagsStatus()
+  const isUnleashReady = unleashFlagsReady || unleashError
   const isNavigationReady = GlobalStore.useAppState((state) => state.sessionState.isNavigationReady)
   const isHydrated = GlobalStore.useAppState((state) => state.sessionState.isHydrated)
   const isLoggedIn = GlobalStore.useAppState((state) => state.auth.userAccessToken)
@@ -20,7 +23,7 @@ export const useHideSplashScreen = () => {
     }
 
     if (isHydrated) {
-      if (isLoggedIn && isNavigationReady) {
+      if (isLoggedIn && isNavigationReady && isUnleashReady) {
         Linking.getInitialURL().then((url) => {
           const isDeepLink = !!url
           if (!isDeepLink) {
@@ -38,5 +41,5 @@ export const useHideSplashScreen = () => {
         hideSplashScreen()
       }
     }
-  }, [isHydrated, isLoggedIn, isNavigationReady])
+  }, [isHydrated, isLoggedIn, isNavigationReady, isUnleashReady])
 }
diff --git a/yarn.lock b/yarn.lock
index 0bec8497068..d41e7c1680b 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1824,7 +1824,7 @@
     "@babel/parser" "^7.26.9"
     "@babel/types" "^7.26.9"
 
-"@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3":
+"@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3", "@babel/traverse@^7.25.3":
   version "7.26.4"
   resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.26.4.tgz#ac3a2a84b908dde6d463c3bfa2c5fdc1653574bd"
   integrity sha512-fH+b7Y4p3yqvApJALCPJcwb0/XaOSgtK4pzV6WVjPR5GLFQBRI7pfoX2V2iM48NXvX07NUxxm1Vw98YjqTcU5w==
@@ -1866,19 +1866,6 @@
     debug "^4.3.1"
     globals "^11.1.0"
 
-"@babel/traverse@^7.25.3":
-  version "7.26.4"
-  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.26.4.tgz#ac3a2a84b908dde6d463c3bfa2c5fdc1653574bd"
-  integrity sha512-fH+b7Y4p3yqvApJALCPJcwb0/XaOSgtK4pzV6WVjPR5GLFQBRI7pfoX2V2iM48NXvX07NUxxm1Vw98YjqTcU5w==
-  dependencies:
-    "@babel/code-frame" "^7.26.2"
-    "@babel/generator" "^7.26.3"
-    "@babel/parser" "^7.26.3"
-    "@babel/template" "^7.25.9"
-    "@babel/types" "^7.26.3"
-    debug "^4.3.1"
-    globals "^11.1.0"
-
 "@babel/traverse@^7.25.7":
   version "7.25.7"
   resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.25.7.tgz#83e367619be1cab8e4f2892ef30ba04c26a40fa8"
@@ -5635,6 +5622,11 @@
     "@typescript-eslint/types" "7.18.0"
     eslint-visitor-keys "^3.4.3"
 
+"@unleash/proxy-client-react@4.5.2":
+  version "4.5.2"
+  resolved "https://registry.yarnpkg.com/@unleash/proxy-client-react/-/proxy-client-react-4.5.2.tgz#8668eb0259d50fc5f2adb5d4c95a5515d9f71065"
+  integrity sha512-mxml+6+hH64qpTml768Suf955n5YttCZgmsw1TJYmUJ1pa+X+dBYvAM0Mdg8L+uUU7hz1xFrOeWtgtJRe+17eQ==
+
 "@xmldom/xmldom@^0.8.8":
   version "0.8.10"
   resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.8.10.tgz#a1337ca426aa61cef9fe15b5b28e340a72f6fa99"
@@ -14699,16 +14691,7 @@ string-natural-compare@^3.0.1:
   resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4"
   integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==
 
-"string-width-cjs@npm:string-width@^4.2.0":
-  version "4.2.3"
-  resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
-  integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
-  dependencies:
-    emoji-regex "^8.0.0"
-    is-fullwidth-code-point "^3.0.0"
-    strip-ansi "^6.0.1"
-
-string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
+"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
   version "4.2.3"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
   integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -14822,7 +14805,7 @@ stringify-entities@^3.1.0:
     character-entities-legacy "^1.0.0"
     xtend "^4.0.0"
 
-"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
+"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
   version "6.0.1"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
   integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -14836,13 +14819,6 @@ strip-ansi@^5.0.0, strip-ansi@^5.2.0:
   dependencies:
     ansi-regex "^4.1.0"
 
-strip-ansi@^6.0.0, strip-ansi@^6.0.1:
-  version "6.0.1"
-  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
-  integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
-  dependencies:
-    ansi-regex "^5.0.1"
-
 strip-ansi@^7.0.1:
   version "7.0.1"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.0.1.tgz#61740a08ce36b61e50e65653f07060d000975fb2"
@@ -15548,10 +15524,10 @@ universalify@^2.0.0:
   resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717"
   integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==
 
-unleash-proxy-client@3.1.1:
-  version "3.1.1"
-  resolved "https://registry.yarnpkg.com/unleash-proxy-client/-/unleash-proxy-client-3.1.1.tgz#d26d505b18c1abfd2f7e1bf4e3e60723749695ef"
-  integrity sha512-XsGjCLo9vXOdIEuhVribMcLGNFqjB3RRhyA32/8nyKaMbxqmrxNLZiNFFTbDkOI9bQsFW3PHSONGRtlyoRpb+g==
+unleash-proxy-client@3.7.3:
+  version "3.7.3"
+  resolved "https://registry.yarnpkg.com/unleash-proxy-client/-/unleash-proxy-client-3.7.3.tgz#2f69b4d4a3ea673f6f1c647b50e5a4433b955045"
+  integrity sha512-Cd7ovrhAIpwveNFdtpzs7HO0WeArC2fbjIVGL2Cjza8bHD+jJ1JbSuy3tFuKvvUkbVKq/EGV0RgosEa/3UVLgg==
   dependencies:
     tiny-emitter "^2.1.0"
     uuid "^9.0.1"
@@ -16276,7 +16252,7 @@ word-wrap@^1.2.3, word-wrap@~1.2.3:
   resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.4.tgz#cb4b50ec9aca570abd1f52f33cd45b6c61739a9f"
   integrity sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA==
 
-"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
+"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
   version "7.0.0"
   resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
   integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@@ -16294,15 +16270,6 @@ wrap-ansi@^6.2.0:
     string-width "^4.1.0"
     strip-ansi "^6.0.0"
 
-wrap-ansi@^7.0.0:
-  version "7.0.0"
-  resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
-  integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
-  dependencies:
-    ansi-styles "^4.0.0"
-    string-width "^4.1.0"
-    strip-ansi "^6.0.0"
-
 wrap-ansi@^8.1.0:
   version "8.1.0"
   resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"


Reviews:
user: login: dblandin
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T16:07:04Z
user: login: dblandin
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T16:10:32Z
user: login: dblandin
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T16:11:53Z
user: login: dblandin
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T16:13:54Z
user: login: dblandin
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T16:16:40Z
user: login: dblandin
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T16:18:29Z
user: login: dblandin
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T16:20:56Z
user: login: dblandin
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T16:21:56Z
user: login: dblandin
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T16:23:34Z
user: login: dblandin
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T16:26:17Z
user: login: dblandin
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T16:27:24Z
user: login: dblandin
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T16:28:06Z
user: login: dblandin
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T19:05:43Z
user: login: dblandin
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T19:06:56Z
user: login: damassi
body: These updates look good 👌  

One request tho: would you mind restructuring the code so that everything "system" related (eg feature architecture) is located in the `system` directory? Thats the place for framework-esque code, as separate from product code, which brings us in line with folio and our web implementations. 

See https://github.com/artsy/eigen/tree/main/src/app/system
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-03-15T16:20:02Z
user: login: gkartalis
body: Thanks! The changes here do make sense - I would also want to hear from @MounirDhahri and @dariakoko who actively contributed to unleash in the past as well
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-03-17T10:07:08Z
user: login: anandaroop
body: Nice
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-03-17T14:39:34Z
user: login: dblandin
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T14:49:56Z
user: login: dblandin
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T14:51:10Z
user: login: MounirDhahri
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-03-17T14:59:29Z
user: login: anandaroop
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T15:30:20Z
user: login: anandaroop
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T15:30:30Z
user: login: dariakoko
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T11:21:37Z
user: login: dariakoko
body: Thanks Devon!
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T11:22:24Z
user: login: dblandin
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T13:35:37Z
user: login: dblandin
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T13:37:59Z
user: login: anandaroop
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T14:19:28Z
user: login: anandaroop
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-03-18T15:19:05Z
user: login: MounirDhahri
body: Thanks for the changes update - looks good to me
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-03-18T15:36:58Z
user: login: dblandin
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T18:40:33Z

Review comments:
diff_hunk: @@ -34,19 +39,16 @@ or if we want a variant we can use something like
 
 ```ts
   "our-new-experiment": {
-    fallbackEnabled: true,
-    fallbackVariant: "the-variant-name",
path: docs/add_an_experiment.md
user: login: dblandin
body: **walkthrough**: I think we can remove this fallback behavior. If the Unleash backend is unavailable – it's reasonable to default all requested experiments to a disabled state.
created_at: 2025-03-14T16:07:04Z
author_association: MEMBER
diff_hunk: @@ -197,7 +198,7 @@
     "styled-components": "5.3.6",
     "styled-system": "5.1.5",
     "supercluster": "7.1.5",
-    "unleash-proxy-client": "3.1.1",
+    "unleash-proxy-client": "3.7.3",
path: package.json
user: login: dblandin
body: **walkthrough**: This library is peer dependency of @unleash/proxy-client-react and still needs to be listed here.
created_at: 2025-03-14T16:10:32Z
author_association: MEMBER
diff_hunk: @@ -117,7 +117,8 @@ export const ExperimentFlagItem: React.FC<{ description: string; flag: EXPERIMEN
                                 setVariant(variantSuggestion)
                               }}
                             >
-                              {variantSuggestion}
+                              {variantSuggestion}{" "}
+                              {experiment.unleashVariant === variantSuggestion && "(default)"}
path: src/app/system/devTools/DevMenu/Components/ExperimentFlagItem.tsx
user: login: dblandin
body: **walkthrough**: I noticed this "(default)" UI treatment for the fallback control/experiment values and missed it when explicitly specifying variantSuggestions so added here as well.
created_at: 2025-03-14T16:11:53Z
author_association: MEMBER
diff_hunk: @@ -183,7 +184,7 @@ export const ExperimentFlagItem: React.FC<{ description: string; flag: EXPERIMEN
               <Flex position="absolute" bottom={space(2)} px={2}>
                 <Button
                   block
-                  disabled={hasOverride}
+                  disabled={!hasOverride}
path: src/app/system/devTools/DevMenu/Components/ExperimentFlagItem.tsx
user: login: dblandin
body: **walkthrough**: The button to reset an override should be available when an override is set.
created_at: 2025-03-14T16:13:54Z
author_association: MEMBER
diff_hunk: @@ -42,8 +42,8 @@ export const ExperimentFlagItem: React.FC<{ description: string; flag: EXPERIMEN
   const localExperiment = experiments[flag] as ExperimentDescriptor
 
   const toast = useToast()
-  const [variant, setVariant] = useState(experiment.variant)
-  const [payload, setPayload] = useState(experiment.payload)
+  const [variant, setVariant] = useState(localVariantOverrides[flag])
+  const [payload, setPayload] = useState(localPayloadOverrides[flag])
path: src/app/system/devTools/DevMenu/Components/ExperimentFlagItem.tsx
user: login: dblandin
body: **walkthrough**: It felt a bit odd to me to echo the remote Unleash value in the override input – so this change shows an override if present, otherwise the input is empty (or you can tap on the suggestions).
created_at: 2025-03-14T16:16:40Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,38 @@
+import AsyncStorage from "@react-native-async-storage/async-storage"
+import FlagProvider from "@unleash/proxy-client-react"
+import { UnleashInitializer } from "app/utils/UnleashInitializer"
+import { useUnleashEnvironment } from "app/utils/experiments/hooks"
+import Keys from "react-native-keys"
+
+export const InitializedFlagProvider: React.FC = ({ children }) => {
+  const { unleashEnv: env } = useUnleashEnvironment()
+
+  const storageName = (name: string) => `unleash-values:${name}`
+
+  const config = {
+    url:
+      env === "production"
+        ? Keys.secureFor("UNLEASH_PROXY_URL_PRODUCTION")
+        : Keys.secureFor("UNLEASH_PROXY_URL_STAGING"),
+    clientKey:
+      env === "production"
+        ? Keys.secureFor("UNLEASH_PROXY_CLIENT_KEY_PRODUCTION")
+        : Keys.secureFor("UNLEASH_PROXY_CLIENT_KEY_STAGING"),
+    appName: "eigen",
+    storageProvider: {
+      save: async (name: string, data: any) =>
+        AsyncStorage.setItem(storageName(name), JSON.stringify(data)),
+      get: async (name: string) => {
+        const data = await AsyncStorage.getItem(storageName(name))
+        return data ? JSON.parse(data) : undefined
+      },
+    },
+  }
path: src/app/utils/InitializedFlagProvider.tsx
user: login: dblandin
body: **walkthrough**: Mostly ported from the previous setup but note that the bootstrap data has been removed and we're not overriding the refresh behavior (defaults to every 30s).
created_at: 2025-03-14T16:18:29Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,29 @@
+import { useUnleashClient } from "@unleash/proxy-client-react"
+import { GlobalStore } from "app/store/GlobalStore"
+import { useUnleashListener } from "app/utils/useUnleashListener"
+import { useEffect } from "react"
+
+export const UnleashInitializer: React.FC = ({ children }) => {
+  const client = useUnleashClient()
+  const userID = GlobalStore.useAppState((state) => state.auth.userID)
+
+  useUnleashListener()
+
+  useEffect(() => {
+    if (userID) {
+      if (client.getContext().userId !== userID) {
+        client.setContextField("userId", userID)
+      }
+
+      client.start()
+    } else {
+      client.stop()
+    }
+
+    return () => {
+      client.stop()
+    }
+  }, [userID])
path: src/app/utils/UnleashInitializer.tsx
user: login: dblandin
body: **walkthrough**: This is now the only place where we switch the very important `userId` attribute in the Unleash context. Includes dismount behavior top stop the client.
created_at: 2025-03-14T16:20:55Z
author_association: MEMBER
diff_hunk: @@ -1,39 +1,23 @@
-export type ExperimentDescriptorUnion =
-  | { readonly fallbackEnabled: false }
-  | {
-      readonly fallbackEnabled: true
-      readonly fallbackVariant?: string
-      readonly fallbackPayload?: string
-    }
-
 export type ExperimentDescriptor = {
path: src/app/utils/experiments/experiments.ts
user: login: dblandin
body: **walkthrough**: Simplified the experiment definition to focus on the name, description, and variant/payload suggestions.
created_at: 2025-03-14T16:21:56Z
author_association: MEMBER
diff_hunk: @@ -1,39 +1,19 @@
-export type ExperimentDescriptorUnion =
-  | { readonly fallbackEnabled: false }
-  | {
-      readonly fallbackEnabled: true
-      readonly fallbackVariant?: string
-      readonly fallbackPayload?: string
-    }
-
 export type ExperimentDescriptor = {
   readonly description: string
-  // value suggestions for the payload ideally matching the ones coming from Unleash
   readonly payloadSuggestions?: string[]
   readonly variantSuggestions?: string[]
-} & ExperimentDescriptorUnion
+}
 
 export const experiments = {
   "eigen-new-works-for-you-recommendations-model": {
-    fallbackEnabled: false,
     description: "value controlling which model to use for new works for you recs",
   },
-  "CX-impressions-tracking-home-rail-views": {
path: src/app/utils/experiments/experiments.ts
user: login: dblandin
body: **walkthrough**: This experiment is no longer referenced in Eigen anywhere and a bit confusingly doesn't seem to be setup as an experiment in Unleash.
created_at: 2025-03-14T16:23:34Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,6 @@
+import { useFlag } from "@unleash/proxy-client-react"
+import { EXPERIMENT_NAME } from "app/utils/experiments/experiments"
+
+export function useExperimentFlag(name: EXPERIMENT_NAME) {
+  return useFlag(name)
path: src/app/utils/experiments/hooks/useExperimentFlag.ts
user: login: dblandin
body: **walkthrough**: We could easily update callers to invoke `useFlag` directly but wanted to avoid the extra diff here. While this hook isn't currently used anywhere, if we do continue want to use simple (non-experiment) boolean flags from Unleash – might be nice to give it some naming outside of the experiment space.
created_at: 2025-03-14T16:26:16Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,43 @@
+import { IVariant, useVariant } from "@unleash/proxy-client-react"
+import { GlobalStore } from "app/store/GlobalStore"
+import { EXPERIMENT_NAME } from "app/utils/experiments/experiments"
+import { ContextProps, reportExperimentVariant } from "app/utils/experiments/reporter"
+
+export function useExperimentVariant(name: EXPERIMENT_NAME): {
+  enabled: boolean
+  variant: string
+  payload?: string
+  unleashVariant: string
+  unleashPayload?: string
+  trackExperiment: (contextProps?: ContextProps) => void
+} {
+  const localPayloadOverrides = GlobalStore.useAppState(
+    (s) => s.artsyPrefs.experiments.localPayloadOverrides
+  )
+  const localVariantOverrides = GlobalStore.useAppState(
+    (s) => s.artsyPrefs.experiments.localVariantOverrides
+  )
+
+  const variant = useVariant(name) as IVariant
+
+  const trackExperiment = (contextProps?: ContextProps) => {
+    if (!variant.enabled || localVariantOverrides[name]) {
path: src/app/utils/experiments/hooks/useExperimentVariant.ts
user: login: dblandin
body: **walkthrough**: This is a meaningful change. If we're using an variant via an override - we do not want to report the experiment impression to our analytics backends.
created_at: 2025-03-14T16:27:24Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,42 @@
+import { useUnleashClient } from "@unleash/proxy-client-react"
+import { useEffect } from "react"
+
+export const useUnleashListener = () => {
+  const client = useUnleashClient()
+
+  const onInitialized = () => {
+    if (__DEV__) {
+      console.log("[unleash] initialized")
+    }
+  }
+
+  const onReady = () => {
+    if (__DEV__) {
+      console.log("[unleash] ready")
+    }
+  }
+
+  const onUpdate = () => {
+    if (__DEV__) {
+      console.log("[unleash] updated")
+    }
+  }
+
+  const onError = (e: Error) => {
+    console.error(`[unleash] error ${JSON.stringify(e)}`)
+  }
+
+  useEffect(() => {
+    client.on("initialized", onInitialized)
+    client.on("ready", onReady)
+    client.on("update", onUpdate)
+    client.on("error", onError)
+
+    return () => {
+      client.off("initialized", onInitialized)
+      client.off("ready", onReady)
+      client.off("update", onUpdate)
+      client.off("error", onError)
path: src/app/utils/useUnleashListener.ts
user: login: dblandin
body: **walkthrough**: Important to deregister event listeners on dismount – otherwise these log items can multiply over time.
created_at: 2025-03-14T16:28:06Z
author_association: MEMBER
diff_hunk: @@ -28,7 +28,7 @@ export const ExperimentFlagItem: React.FC<{ description: string; flag: EXPERIMEN
   description,
   flag,
 }) => {
-  const experiment = useExperimentVariant(flag)
+  const unleashVariant = useFlags().find((e) => e.name === flag)?.variant as IVariant
path: src/app/system/devTools/DevMenu/Components/ExperimentFlagItem.tsx
user: login: dblandin
body: **walkthrough**: Accessing the variant this way is a bit more convoluted but avoids sending usage metrics to Unleash, which ideally would represent only requests from collector-facing screens.
created_at: 2025-03-14T19:05:43Z
author_association: MEMBER
diff_hunk: @@ -38,5 +41,5 @@ export const useHideSplashScreen = () => {
         hideSplashScreen()
       }
     }
-  }, [isHydrated, isLoggedIn, isNavigationReady])
+  }, [isHydrated, isLoggedIn, isNavigationReady, isUnleashReady])
path: src/app/utils/useHideSplashScreen.ts
user: login: dblandin
body: **walkthrough**: Gives Unleash the opportunity to initialize (and adjust any UI) before we hide the splash screen.
created_at: 2025-03-14T19:06:56Z
author_association: MEMBER
diff_hunk: @@ -34,19 +39,16 @@ or if we want a variant we can use something like
 
 ```ts
   "our-new-experiment": {
-    fallbackEnabled: true,
-    fallbackVariant: "the-variant-name",
     description: "Experiment description",
     payloadSuggestions: ["payload-1", "payload-2"] // If applicable
   },
 ```
 
-| value                | description                                                                                                      |
-| -------------------- | ---------------------------------------------------------------------------------------------------------------- |
-| `fallbackEnabled`    | (`boolean`) the experiment flag has a fallback value in case we don't receive anything from Unleash client sdk   |
-| `fallbackVariant`    | (`string` or `undefined`) fallback value, applicable only if `fallbackEnabled` is set to `true`                  |
-| `description`        | (`string`) a string describing your experiment                                                                   |
-| `payloadSuggestions` | (`string[]`) a list of strings useful for quickly setting an admin override from Dev Settings > Experiments menu |
+| value                | description                                    |
+| -------------------- | ---------------------------------------------- |
+| `description`        | (`string`) a string describing your experiment |
+| `variantSuggestions` | (`string[]`) a list of variant options         |
+| `payloadSuggestions` | (`string[]`) a list of payload options         |
path: docs/add_an_experiment.md
user: login: anandaroop
body: **suggestion (non-blocking):** Should we append something like "for use in Dev menu" here to clarify the purpose of these two?

(Assuming that **_is_** the only purpose, correct me if otherwise)
created_at: 2025-03-17T14:15:30Z
author_association: MEMBER
diff_hunk: @@ -1,39 +1,19 @@
-export type ExperimentDescriptorUnion =
-  | { readonly fallbackEnabled: false }
-  | {
-      readonly fallbackEnabled: true
-      readonly fallbackVariant?: string
-      readonly fallbackPayload?: string
-    }
-
 export type ExperimentDescriptor = {
   readonly description: string
-  // value suggestions for the payload ideally matching the ones coming from Unleash
   readonly payloadSuggestions?: string[]
   readonly variantSuggestions?: string[]
-} & ExperimentDescriptorUnion
+}
 
 export const experiments = {
   "eigen-new-works-for-you-recommendations-model": {
-    fallbackEnabled: false,
     description: "value controlling which model to use for new works for you recs",
   },
-  "CX-impressions-tracking-home-rail-views": {
-    fallbackEnabled: false,
-    description: "Allow tracking impression for home screen rails",
-  },
   "onyx_artwork-card-save-and-follow-cta-redesign": {
-    fallbackEnabled: true,
-    fallbackVariant: "variant-a",
     description: "Redesign Save CTA and Add Follow CTA on Artwork Grid/Rail",
-    payloadSuggestions: ["variant-a", "variant-b", "variant-c"],
-    variantSuggestions: ["variant-a", "variant-b", "variant-c"],
+    variantSuggestions: ["control", "experiment", "variant-c"],
   },
   "onyx_quick-links-experiment": {
     description: "Add quick links section to home view",
-    fallbackEnabled: true,
-    fallbackVariant: "control",
-    variantSuggestions: ["control", "experiment"],
path: src/app/utils/experiments/experiments.ts
user: login: anandaroop
body: 
**question:** Is this `variantSuggestions` removal deliberate? Might not be needed now that we've QA'd this, but was it incorrect for this to have been here?
created_at: 2025-03-17T14:26:56Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,3 @@
+export * from "./useExperimentFlag"
+export * from "./useExperimentVariant"
+export * from "./useUnleashEnvironment"
path: src/app/utils/experiments/hooks/index.ts
user: login: anandaroop
body: **question:** Re-exporting is still not a common pattern in Eigen, so I just want to confirm that this is fine? (i.e. that the reasons we avoid it on web do not apply in Eigen)
created_at: 2025-03-17T14:38:47Z
author_association: MEMBER
diff_hunk: @@ -1,39 +1,19 @@
-export type ExperimentDescriptorUnion =
-  | { readonly fallbackEnabled: false }
-  | {
-      readonly fallbackEnabled: true
-      readonly fallbackVariant?: string
-      readonly fallbackPayload?: string
-    }
-
 export type ExperimentDescriptor = {
   readonly description: string
-  // value suggestions for the payload ideally matching the ones coming from Unleash
   readonly payloadSuggestions?: string[]
   readonly variantSuggestions?: string[]
-} & ExperimentDescriptorUnion
+}
 
 export const experiments = {
   "eigen-new-works-for-you-recommendations-model": {
-    fallbackEnabled: false,
     description: "value controlling which model to use for new works for you recs",
   },
-  "CX-impressions-tracking-home-rail-views": {
-    fallbackEnabled: false,
-    description: "Allow tracking impression for home screen rails",
-  },
   "onyx_artwork-card-save-and-follow-cta-redesign": {
-    fallbackEnabled: true,
-    fallbackVariant: "variant-a",
     description: "Redesign Save CTA and Add Follow CTA on Artwork Grid/Rail",
-    payloadSuggestions: ["variant-a", "variant-b", "variant-c"],
-    variantSuggestions: ["variant-a", "variant-b", "variant-c"],
+    variantSuggestions: ["control", "experiment", "variant-c"],
   },
   "onyx_quick-links-experiment": {
     description: "Add quick links section to home view",
-    fallbackEnabled: true,
-    fallbackVariant: "control",
-    variantSuggestions: ["control", "experiment"],
path: src/app/utils/experiments/experiments.ts
user: login: dblandin
body: The DevMenu code provides a default set of variant suggestions: `control` and `experiment` for any defined experiment so while this line isn't incorrect, it isn't necessary given that default set provided.
created_at: 2025-03-17T14:49:56Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,3 @@
+export * from "./useExperimentFlag"
+export * from "./useExperimentVariant"
+export * from "./useUnleashEnvironment"
path: src/app/utils/experiments/hooks/index.ts
user: login: dblandin
body: Hmmm, I think I ported this from elsewhere so I thought it was already a pattern in Eigen. But happy to drop this index file and re-exporting 👍🏼 
created_at: 2025-03-17T14:51:09Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,29 @@
+import { useUnleashClient } from "@unleash/proxy-client-react"
+import { GlobalStore } from "app/store/GlobalStore"
+import { useUnleashListener } from "app/utils/useUnleashListener"
+import { useEffect } from "react"
+
+export const UnleashInitializer: React.FC = ({ children }) => {
+  const client = useUnleashClient()
+  const userID = GlobalStore.useAppState((state) => state.auth.userID)
+
+  useUnleashListener()
+
+  useEffect(() => {
+    if (userID) {
+      if (client.getContext().userId !== userID) {
+        client.setContextField("userId", userID)
+      }
+
+      client.start()
+    } else {
+      client.stop()
+    }
+
+    return () => {
+      client.stop()
+    }
+  }, [userID])
path: src/app/utils/UnleashInitializer.tsx
user: login: MounirDhahri
body: thanks - this makes things easier
created_at: 2025-03-17T14:57:50Z
author_association: MEMBER
diff_hunk: @@ -1,39 +1,19 @@
-export type ExperimentDescriptorUnion =
-  | { readonly fallbackEnabled: false }
-  | {
-      readonly fallbackEnabled: true
-      readonly fallbackVariant?: string
-      readonly fallbackPayload?: string
-    }
-
 export type ExperimentDescriptor = {
   readonly description: string
-  // value suggestions for the payload ideally matching the ones coming from Unleash
   readonly payloadSuggestions?: string[]
   readonly variantSuggestions?: string[]
-} & ExperimentDescriptorUnion
+}
 
 export const experiments = {
   "eigen-new-works-for-you-recommendations-model": {
-    fallbackEnabled: false,
     description: "value controlling which model to use for new works for you recs",
   },
-  "CX-impressions-tracking-home-rail-views": {
-    fallbackEnabled: false,
-    description: "Allow tracking impression for home screen rails",
-  },
   "onyx_artwork-card-save-and-follow-cta-redesign": {
-    fallbackEnabled: true,
-    fallbackVariant: "variant-a",
     description: "Redesign Save CTA and Add Follow CTA on Artwork Grid/Rail",
-    payloadSuggestions: ["variant-a", "variant-b", "variant-c"],
-    variantSuggestions: ["variant-a", "variant-b", "variant-c"],
+    variantSuggestions: ["control", "experiment", "variant-c"],
   },
   "onyx_quick-links-experiment": {
     description: "Add quick links section to home view",
-    fallbackEnabled: true,
-    fallbackVariant: "control",
-    variantSuggestions: ["control", "experiment"],
path: src/app/utils/experiments/experiments.ts
user: login: anandaroop
body: Ah makes sense.
created_at: 2025-03-17T15:30:20Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,3 @@
+export * from "./useExperimentFlag"
+export * from "./useExperimentVariant"
+export * from "./useUnleashEnvironment"
path: src/app/utils/experiments/hooks/index.ts
user: login: anandaroop
body: Definitely exists already in Eigen! Just not common, but that may be fine still.
created_at: 2025-03-17T15:30:30Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,3 @@
+export * from "./useExperimentFlag"
+export * from "./useExperimentVariant"
+export * from "./useUnleashEnvironment"
path: src/app/utils/experiments/hooks/index.ts
user: login: dariakoko
body: > want to confirm that this is fine? (i.e. that the reasons we avoid it on web do not apply in Eigen)

It is a common patter in React and React Native. Do you know why we are avoiding this pattern on web?
created_at: 2025-03-18T11:21:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -72,13 +74,19 @@ You can access the flag value in a functional react component using `useExperime
 You can access the variant value in a functional react component using `useExperimentVariant`.
 
 ```diff
-+ const ourNewExperiment = useExperimentVariant("our-new-experiment")
-  return (
-    <>
-+    {ourNewExperiment.enabled && ourNewExperiment.payload === "payloadA" && <AComponent />}
-+    {ourNewExperiment.enabled && ourNewExperiment.payload === "payloadB" && <BComponent custom={ourNewExperiment.payload} />}
-    <>
-  )
++ const { variant } = useExperimentVariant("our-new-experiment")
++
++ return (
++   <>
++     {variant.enabled ? (
++      {variant.name === "control" && <ControlComponent />}
++      {variant.name === "variant-b" && <ExperimentComponent payload={variant.payload} />}
++      {variant.name === "variant-c" && <ExperimentComponent payload={variant.payload} />}
++     ) : (
++      <ControlComponent />
++     )}
++   <>
++ )
 ```
path: docs/add_an_experiment.md
user: login: dblandin
body: **walkthrough**: I updated the experiment setup example here. I primarily wanted to clearly communicate that you still need to handle the case in which the experiment/variant is disabled – defaulting back to the control behavior (if there is one).
created_at: 2025-03-18T13:35:37Z
author_association: MEMBER
diff_hunk: @@ -89,15 +97,24 @@ In order to track an experiment, you can use the `trackExperiment` helper that c
 
 ```diff
 + const { trackExperiment } = useExperimentVariant("our-new-experiment")
++
++ trackExperiment({
++   context_owner_screen: OwnerType.artist,
++   context_owner_id: "4d8b92b34eb68a1b2c0003f4",
++   context_owner_slug: "andy-warhol",
++   context_owner_type: OwnerType.artist,
++ })
 ```
 
-## Removing/Killing an Experiment
+## Winding down a completed Experiment
+
+Once an experiment is done, we'll have a winning variant. In order to roll out the winning variant for everyone, we can promote the variant to 100% of users in the Unleash dashboard.
+
+For example, in the previous experiment, we were testing if the `variant-b` variant performed better than `control` we'd promote `variant-b` to 100% of variant requests.
 
-Once an experiment is done, usually we have a winner variant. In order to roll out that variant for everyone targeted by it, we will need to set it as a default strategy before "killing" it.
+Afterwards we can update Eigen to remove the experiment code, leavning only the winning variant in place.
 
-For example, in the previous experiment, we were testing if `varA` is performing better than `varB`. Assuming that it actually did, we then set `varA` as a default variant.
-Then we need to update eigen to remove the experiment code and use only the winner variant code.
-After that, we can archive that experiment in the Unleash dashboard.
+We should leave the experiment active in Unleash until clients are no longer requesting a variant for the experiment. This can take some time as users naturally upgrade.
path: docs/add_an_experiment.md
user: login: dblandin
body: **walkthrough**: Updated the guidance here to first promote the winning variant within the Unleash admin UI. Cleaning up the code in Eigen can take place right away but we should wait until clients are no longer requesting an experiment variant before archiving the experiment – which would default any client requests to a disabled state.
created_at: 2025-03-18T13:37:59Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,3 @@
+export * from "./useExperimentFlag"
+export * from "./useExperimentVariant"
+export * from "./useUnleashEnvironment"
path: src/app/utils/experiments/hooks/index.ts
user: login: anandaroop
body: I think because of the impact on things like tree shaking, bundle splitting, which are less of concern in native apps.
created_at: 2025-03-18T14:19:28Z
author_association: MEMBER
diff_hunk: @@ -89,15 +97,24 @@ In order to track an experiment, you can use the `trackExperiment` helper that c
 
 ```diff
 + const { trackExperiment } = useExperimentVariant("our-new-experiment")
++
++ trackExperiment({
++   context_owner_screen: OwnerType.artist,
++   context_owner_id: "4d8b92b34eb68a1b2c0003f4",
++   context_owner_slug: "andy-warhol",
++   context_owner_type: OwnerType.artist,
++ })
 ```
 
-## Removing/Killing an Experiment
+## Winding down a completed Experiment
+
+Once an experiment is done, we'll have a winning variant. In order to roll out the winning variant for everyone, we can promote the variant to 100% of users in the Unleash dashboard.
+
+For example, in the previous experiment, we were testing if the `variant-b` variant performed better than `control` we'd promote `variant-b` to 100% of variant requests.
 
-Once an experiment is done, usually we have a winner variant. In order to roll out that variant for everyone targeted by it, we will need to set it as a default strategy before "killing" it.
+Afterwards we can update Eigen to remove the experiment code, leavning only the winning variant in place.
 
-For example, in the previous experiment, we were testing if `varA` is performing better than `varB`. Assuming that it actually did, we then set `varA` as a default variant.
-Then we need to update eigen to remove the experiment code and use only the winner variant code.
-After that, we can archive that experiment in the Unleash dashboard.
+We should leave the experiment active in Unleash until clients are no longer requesting a variant for the experiment. This can take some time as users naturally upgrade.
path: docs/add_an_experiment.md
user: login: anandaroop
body: > no longer requesting a variant

**question (non-blocking):** What's the best way to assess this? Even [old/outdated flags](https://unleash.artsy.net/projects/default/features/onyx_new_submission_flow) get reported in the Unleash dashboard as being requested and exposed presently.

I'd assume we'd have to monitor this semi-manually via Eigen version number in DD/Sentry, but curious if you have a smarter way.
created_at: 2025-03-18T15:12:44Z
author_association: MEMBER
diff_hunk: @@ -89,15 +97,24 @@ In order to track an experiment, you can use the `trackExperiment` helper that c
 
 ```diff
 + const { trackExperiment } = useExperimentVariant("our-new-experiment")
++
++ trackExperiment({
++   context_owner_screen: OwnerType.artist,
++   context_owner_id: "4d8b92b34eb68a1b2c0003f4",
++   context_owner_slug: "andy-warhol",
++   context_owner_type: OwnerType.artist,
++ })
 ```
 
-## Removing/Killing an Experiment
+## Winding down a completed Experiment
+
+Once an experiment is done, we'll have a winning variant. In order to roll out the winning variant for everyone, we can promote the variant to 100% of users in the Unleash dashboard.
+
+For example, in the previous experiment, we were testing if the `variant-b` variant performed better than `control` we'd promote `variant-b` to 100% of variant requests.
 
-Once an experiment is done, usually we have a winner variant. In order to roll out that variant for everyone targeted by it, we will need to set it as a default strategy before "killing" it.
+Afterwards we can update Eigen to remove the experiment code, leavning only the winning variant in place.
path: docs/add_an_experiment.md
user: login: anandaroop
body: ```suggestion
Afterwards we can update Eigen to remove the experiment code, leaving only the winning variant in place.
```
created_at: 2025-03-18T15:15:11Z
author_association: MEMBER
diff_hunk: @@ -89,15 +97,24 @@ In order to track an experiment, you can use the `trackExperiment` helper that c
 
 ```diff
 + const { trackExperiment } = useExperimentVariant("our-new-experiment")
++
++ trackExperiment({
++   context_owner_screen: OwnerType.artist,
++   context_owner_id: "4d8b92b34eb68a1b2c0003f4",
++   context_owner_slug: "andy-warhol",
++   context_owner_type: OwnerType.artist,
++ })
 ```
 
-## Removing/Killing an Experiment
+## Winding down a completed Experiment
+
+Once an experiment is done, we'll have a winning variant. In order to roll out the winning variant for everyone, we can promote the variant to 100% of users in the Unleash dashboard.
+
+For example, in the previous experiment, we were testing if the `variant-b` variant performed better than `control` we'd promote `variant-b` to 100% of variant requests.
 
-Once an experiment is done, usually we have a winner variant. In order to roll out that variant for everyone targeted by it, we will need to set it as a default strategy before "killing" it.
+Afterwards we can update Eigen to remove the experiment code, leavning only the winning variant in place.
 
-For example, in the previous experiment, we were testing if `varA` is performing better than `varB`. Assuming that it actually did, we then set `varA` as a default variant.
-Then we need to update eigen to remove the experiment code and use only the winner variant code.
-After that, we can archive that experiment in the Unleash dashboard.
+We should leave the experiment active in Unleash until clients are no longer requesting a variant for the experiment. This can take some time as users naturally upgrade.
path: docs/add_an_experiment.md
user: login: dblandin
body: I'm hopeful that we can rely on Unleash's own visibility into feature/variant requests once Force's implementation is updated: https://github.com/artsy/force/pull/15263. Right now the usage metrics are unreliable because we fetch all unarchived toggles up front, as if they were actively checked for UI/feature purposes.

Once the implementation is updated, Unleash should be able to surface when flags can be safely archived based on recent activity.

In the meantime, we can continue to approximate that readiness based on active app version numbers we see out in the wild.
created_at: 2025-03-18T18:40:32Z
author_association: MEMBER

PR comments:
user: login: ArtsyOpenSource
created_at: 2025-03-14T16:56:52Z
body: 
<!--
  0 failure: 
  0 warning: 
  
  1 markdown notices
  DangerID: danger-id-Danger;
-->



### This PR contains the following changes:

- Dev changes (chore: update unleash implementation - dblandin)
<p align="right">
  Generated by :no_entry_sign: <a href="https://danger.systems/js">dangerJS</a> against 0b5dbd9e119877484e740bb6e5eb984327776902
</p>

author_association: CONTRIBUTOR
user: login: dblandin
created_at: 2025-03-18T13:49:06Z
body: Thanks all for the review so far! I just pushed up some changes which should address your comments.

What's different since the first round of review requests?
- Moved all of this infra/framework experiment-related code to src/app/system
- Updates to the "Add an Experiment" documentation
- Updates to the useExperimentVariant API, mainly simplifying it to return a typed variant directly to the caller and extracting the override behavior.

cc/ @MounirDhahri @dariakoko @briansw @gkartalis @damassi @anandaroop if you'd like to take another look!
author_association: MEMBER
user: login: dblandin
created_at: 2025-03-18T15:49:09Z
body: I went ahead and created an RFC issue to cover the new dependency introduced here: https://github.com/artsy/eigen/issues/11713
author_association: MEMBER