From b6e68acaf4b768085600aade5a2b7829f40578b5 Mon Sep 17 00:00:00 2001 From: AmarTrebinjac Date: Tue, 12 May 2026 08:55:17 +0000 Subject: [PATCH 1/3] feat(experiments): gate new_d1_experience on intro quest presence Only evaluate `featureNewD1Experience` for users who actually have intro quests assigned. Introduces a shared `useNewD1ExperienceFeature` hook that ANDs `hasIntroQuests` into the existing `shouldEvaluate`, so any user whose quest dashboard returns an empty `intro` array sees the feature as false regardless of GrowthBook assignment. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/shared/src/components/Feed.tsx | 5 +- .../buttons/ToggleClickbaitShield.tsx | 6 +- .../cards/common/ClickbaitShield.tsx | 6 +- .../src/components/feeds/FeedContainer.tsx | 6 +- .../filters/IntroQuestButton.spec.tsx | 12 +- .../components/filters/IntroQuestButton.tsx | 6 +- .../src/components/layout/common.spec.tsx | 4 + .../shared/src/components/layout/common.tsx | 6 +- .../useNewD1ExperienceFeature.spec.tsx | 150 ++++++++++++++++++ .../profile/useProfileCompletionIndicator.ts | 6 +- .../src/hooks/useNewD1ExperienceFeature.ts | 27 ++++ 11 files changed, 201 insertions(+), 33 deletions(-) create mode 100644 packages/shared/src/hooks/__tests__/useNewD1ExperienceFeature.spec.tsx create mode 100644 packages/shared/src/hooks/useNewD1ExperienceFeature.ts diff --git a/packages/shared/src/components/Feed.tsx b/packages/shared/src/components/Feed.tsx index e26c70c3aa6..bb5b8ad2377 100644 --- a/packages/shared/src/components/Feed.tsx +++ b/packages/shared/src/components/Feed.tsx @@ -67,8 +67,8 @@ import { briefCardFeedFeature, briefFeedEntrypointPage, featureFeedAdTemplate, - featureNewD1Experience, } from '../lib/featureManagement'; +import { useNewD1ExperienceFeature } from '../hooks/useNewD1ExperienceFeature'; import type { AwardProps } from '../graphql/njord'; import { getProductsQueryOptions } from '../graphql/njord'; import { useUpdateQuery } from '../hooks/useUpdateQuery'; @@ -263,8 +263,7 @@ export default function Feed({ feature: briefCardFeedFeature, shouldEvaluate: shouldEvaluateBriefCard, }); - const { value: isNewD1Experience } = useConditionalFeature({ - feature: featureNewD1Experience, + const { value: isNewD1Experience } = useNewD1ExperienceFeature({ shouldEvaluate: shouldEvaluateBriefCard, }); const showBriefCard = diff --git a/packages/shared/src/components/buttons/ToggleClickbaitShield.tsx b/packages/shared/src/components/buttons/ToggleClickbaitShield.tsx index 210c160e973..5a238c5a371 100644 --- a/packages/shared/src/components/buttons/ToggleClickbaitShield.tsx +++ b/packages/shared/src/components/buttons/ToggleClickbaitShield.tsx @@ -21,8 +21,7 @@ import { useAuthContext } from '../../contexts/AuthContext'; import { webappUrl } from '../../lib/constants'; import { FeedSettingsMenu } from '../feeds/FeedSettings/types'; import { Tooltip } from '../tooltip/Tooltip'; -import { useConditionalFeature } from '../../hooks/useConditionalFeature'; -import { featureNewD1Experience } from '../../lib/featureManagement'; +import { useNewD1ExperienceFeature } from '../../hooks/useNewD1ExperienceFeature'; export const ToggleClickbaitShield = ({ origin, @@ -41,8 +40,7 @@ export const ToggleClickbaitShield = ({ const { user } = useAuthContext(); const { maxTries, hasUsedFreeTrial, triesLeft } = useClickbaitTries(); const isClickbaitShieldEnabled = flags?.clickbaitShieldEnabled ?? false; - const { value: isNewD1Experience } = useConditionalFeature({ - feature: featureNewD1Experience, + const { value: isNewD1Experience } = useNewD1ExperienceFeature({ shouldEvaluate: !isPlus, }); diff --git a/packages/shared/src/components/cards/common/ClickbaitShield.tsx b/packages/shared/src/components/cards/common/ClickbaitShield.tsx index de7333fadf3..fb3c8273354 100644 --- a/packages/shared/src/components/cards/common/ClickbaitShield.tsx +++ b/packages/shared/src/components/cards/common/ClickbaitShield.tsx @@ -17,8 +17,7 @@ import { FeedSettingsMenu } from '../../feeds/FeedSettings/types'; import { useAuthContext } from '../../../contexts/AuthContext'; import { webappUrl } from '../../../lib/constants'; import { Tooltip } from '../../tooltip/Tooltip'; -import { useConditionalFeature } from '../../../hooks/useConditionalFeature'; -import { featureNewD1Experience } from '../../../lib/featureManagement'; +import { useNewD1ExperienceFeature } from '../../../hooks/useNewD1ExperienceFeature'; export const ClickbaitShield = ({ post, @@ -33,8 +32,7 @@ export const ClickbaitShield = ({ const router = useRouter(); const { user } = useAuthContext(); const { hasUsedFreeTrial, triesLeft } = useClickbaitTries(); - const { value: isNewD1Experience } = useConditionalFeature({ - feature: featureNewD1Experience, + const { value: isNewD1Experience } = useNewD1ExperienceFeature({ shouldEvaluate: !isPlus, }); diff --git a/packages/shared/src/components/feeds/FeedContainer.tsx b/packages/shared/src/components/feeds/FeedContainer.tsx index ea757248fc7..dbcac7df244 100644 --- a/packages/shared/src/components/feeds/FeedContainer.tsx +++ b/packages/shared/src/components/feeds/FeedContainer.tsx @@ -30,8 +30,7 @@ import { } from '../../lib/image'; import { useUploadCv } from '../../features/profile/hooks/useUploadCv'; import { TargetId } from '../../lib/log'; -import { useConditionalFeature } from '../../hooks/useConditionalFeature'; -import { featureNewD1Experience } from '../../lib/featureManagement'; +import { useNewD1ExperienceFeature } from '../../hooks/useNewD1ExperienceFeature'; export interface FeedContainerProps { children: ReactNode; @@ -197,8 +196,7 @@ export const FeedContainer = ({ }); const shouldEvaluateBanner = !!marketingCta && shouldShow && activeFeedName === SharedFeedPage.MyFeed; - const { value: isNewD1Experience } = useConditionalFeature({ - feature: featureNewD1Experience, + const { value: isNewD1Experience } = useNewD1ExperienceFeature({ shouldEvaluate: shouldEvaluateBanner, }); const shouldShowBanner = shouldEvaluateBanner && !isNewD1Experience; diff --git a/packages/shared/src/components/filters/IntroQuestButton.spec.tsx b/packages/shared/src/components/filters/IntroQuestButton.spec.tsx index c69ce2c8332..931716ad374 100644 --- a/packages/shared/src/components/filters/IntroQuestButton.spec.tsx +++ b/packages/shared/src/components/filters/IntroQuestButton.spec.tsx @@ -6,7 +6,7 @@ import { useSettingsContext } from '../../contexts/SettingsContext'; import { useLazyModal } from '../../hooks/useLazyModal'; import { ActionType } from '../../graphql/actions'; import { useActions, useViewSize, ViewSize } from '../../hooks'; -import { useConditionalFeature } from '../../hooks/useConditionalFeature'; +import { useNewD1ExperienceFeature } from '../../hooks/useNewD1ExperienceFeature'; import { useQuestDashboard } from '../../hooks/useQuestDashboard'; import { IntroQuestButton } from './IntroQuestButton'; import { LazyModal } from '../modals/common/types'; @@ -37,8 +37,8 @@ jest.mock('../../hooks', () => ({ useViewSize: jest.fn(), })); -jest.mock('../../hooks/useConditionalFeature', () => ({ - useConditionalFeature: jest.fn(), +jest.mock('../../hooks/useNewD1ExperienceFeature', () => ({ + useNewD1ExperienceFeature: jest.fn(), })); jest.mock('../../hooks/useQuestDashboard', () => ({ @@ -60,7 +60,7 @@ const mockUseSettingsContext = useSettingsContext as jest.Mock; const mockUseLazyModal = useLazyModal as jest.Mock; const mockUseActions = useActions as jest.Mock; const mockUseViewSize = useViewSize as jest.Mock; -const mockUseConditionalFeature = useConditionalFeature as jest.Mock; +const mockUseNewD1ExperienceFeature = useNewD1ExperienceFeature as jest.Mock; const mockUseQuestDashboard = useQuestDashboard as jest.Mock; const openModal = jest.fn(); @@ -102,7 +102,7 @@ describe('IntroQuestButton', () => { checkHasCompleted: jest.fn(() => false), }); mockUseViewSize.mockImplementation((size) => size === ViewSize.Laptop); - mockUseConditionalFeature.mockReturnValue({ value: true }); + mockUseNewD1ExperienceFeature.mockReturnValue({ value: true }); mockUseQuestDashboard.mockReturnValue({ data: { intro: [ @@ -218,7 +218,7 @@ describe('IntroQuestButton', () => { }); it('does not render when new D1 experience flag is off', () => { - mockUseConditionalFeature.mockReturnValue({ value: false }); + mockUseNewD1ExperienceFeature.mockReturnValue({ value: false }); render(); diff --git a/packages/shared/src/components/filters/IntroQuestButton.tsx b/packages/shared/src/components/filters/IntroQuestButton.tsx index 58dd3dde7ee..fff5da67b9f 100644 --- a/packages/shared/src/components/filters/IntroQuestButton.tsx +++ b/packages/shared/src/components/filters/IntroQuestButton.tsx @@ -11,8 +11,7 @@ import { ActionType } from '../../graphql/actions'; import { useActions, useViewSize, ViewSize } from '../../hooks'; import { useLazyModal } from '../../hooks/useLazyModal'; import { LazyModal } from '../modals/common/types'; -import { useConditionalFeature } from '../../hooks/useConditionalFeature'; -import { featureNewD1Experience } from '../../lib/featureManagement'; +import { useNewD1ExperienceFeature } from '../../hooks/useNewD1ExperienceFeature'; import { useQuestDashboard } from '../../hooks/useQuestDashboard'; import { QuestStatus } from '../../graphql/quests'; @@ -25,8 +24,7 @@ export function IntroQuestButton(): ReactElement | null { const { openModal } = useLazyModal(); const isLaptop = useViewSize(ViewSize.Laptop); const { checkHasCompleted } = useActions(); - const { value: isNewD1Experience } = useConditionalFeature({ - feature: featureNewD1Experience, + const { value: isNewD1Experience } = useNewD1ExperienceFeature({ shouldEvaluate: isAuthReady && isLoggedIn && loadedSettings, }); const { data } = useQuestDashboard(); diff --git a/packages/shared/src/components/layout/common.spec.tsx b/packages/shared/src/components/layout/common.spec.tsx index e8519f558b2..0806f65de98 100644 --- a/packages/shared/src/components/layout/common.spec.tsx +++ b/packages/shared/src/components/layout/common.spec.tsx @@ -94,6 +94,10 @@ jest.mock('../tooltip/Tooltip', () => ({ }, })); +jest.mock('../../hooks/useNewD1ExperienceFeature', () => ({ + useNewD1ExperienceFeature: jest.fn().mockReturnValue({ value: false }), +})); + const mockUseAuthContext = useAuthContext as jest.Mock; const mockUseLogContext = useLogContext as jest.Mock; const mockUseActions = useActions as jest.Mock; diff --git a/packages/shared/src/components/layout/common.tsx b/packages/shared/src/components/layout/common.tsx index dc6109ed20b..b06c9036a61 100644 --- a/packages/shared/src/components/layout/common.tsx +++ b/packages/shared/src/components/layout/common.tsx @@ -47,8 +47,7 @@ import { import { downloadBrowserExtension } from '../../lib/constants'; import { anchorDefaultRel } from '../../lib/strings'; import ConditionalWrapper from '../ConditionalWrapper'; -import { useConditionalFeature } from '../../hooks/useConditionalFeature'; -import { featureNewD1Experience } from '../../lib/featureManagement'; +import { useNewD1ExperienceFeature } from '../../hooks/useNewD1ExperienceFeature'; type State = [T, Dispatch>]; @@ -113,8 +112,7 @@ export const SearchControlHeader = ({ isActionsFetched && canInstallExtension && !hasDismissedInstallExtension; - const { value: isNewD1Experience } = useConditionalFeature({ - feature: featureNewD1Experience, + const { value: isNewD1Experience } = useNewD1ExperienceFeature({ shouldEvaluate: shouldEvaluateInstallExtensionPrompt, }); diff --git a/packages/shared/src/hooks/__tests__/useNewD1ExperienceFeature.spec.tsx b/packages/shared/src/hooks/__tests__/useNewD1ExperienceFeature.spec.tsx new file mode 100644 index 00000000000..759ea8faa74 --- /dev/null +++ b/packages/shared/src/hooks/__tests__/useNewD1ExperienceFeature.spec.tsx @@ -0,0 +1,150 @@ +import { renderHook } from '@testing-library/react'; +import { useNewD1ExperienceFeature } from '../useNewD1ExperienceFeature'; +import { useConditionalFeature } from '../useConditionalFeature'; +import { useQuestDashboard } from '../useQuestDashboard'; +import { + QuestRewardType, + QuestStatus, + QuestType, + type UserQuest, +} from '../../graphql/quests'; +import { featureNewD1Experience } from '../../lib/featureManagement'; + +jest.mock('../useConditionalFeature', () => ({ + useConditionalFeature: jest.fn(), +})); + +jest.mock('../useQuestDashboard', () => ({ + useQuestDashboard: jest.fn(), +})); + +const mockUseConditionalFeature = useConditionalFeature as jest.Mock; +const mockUseQuestDashboard = useQuestDashboard as jest.Mock; + +const buildIntroQuest = (overrides: Partial = {}): UserQuest => ({ + userQuestId: 'uq-1', + rotationId: 'rot-1', + progress: 0, + status: QuestStatus.InProgress, + completedAt: null, + claimedAt: null, + locked: false, + claimable: false, + quest: { + id: 'quest-1', + name: 'Install the browser extension', + description: 'Pin daily.dev.', + type: QuestType.Intro, + eventType: 'extension_install', + targetCount: 1, + }, + rewards: [{ type: QuestRewardType.Xp, amount: 10 }], + ...overrides, +}); + +describe('useNewD1ExperienceFeature', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseConditionalFeature.mockReturnValue({ + value: true, + isLoading: false, + }); + }); + + it('returns false when intro quests are empty even if GrowthBook resolves true', () => { + mockUseQuestDashboard.mockReturnValue({ + data: { intro: [] }, + isLoading: false, + }); + + const { result } = renderHook(() => + useNewD1ExperienceFeature({ shouldEvaluate: true }), + ); + + expect(result.current.value).toBe(false); + expect(mockUseConditionalFeature).toHaveBeenCalledWith({ + feature: featureNewD1Experience, + shouldEvaluate: false, + }); + }); + + it('returns false while quest dashboard data is unavailable', () => { + mockUseQuestDashboard.mockReturnValue({ + data: undefined, + isLoading: true, + }); + + const { result } = renderHook(() => + useNewD1ExperienceFeature({ shouldEvaluate: true }), + ); + + expect(result.current.value).toBe(false); + expect(result.current.isLoading).toBe(true); + }); + + it('delegates to GrowthBook value when intro quests exist', () => { + mockUseQuestDashboard.mockReturnValue({ + data: { intro: [buildIntroQuest()] }, + isLoading: false, + }); + + const { result } = renderHook(() => + useNewD1ExperienceFeature({ shouldEvaluate: true }), + ); + + expect(result.current.value).toBe(true); + expect(mockUseConditionalFeature).toHaveBeenCalledWith({ + feature: featureNewD1Experience, + shouldEvaluate: true, + }); + }); + + it('returns false when GrowthBook value is false even with intro quests', () => { + mockUseConditionalFeature.mockReturnValue({ + value: false, + isLoading: false, + }); + mockUseQuestDashboard.mockReturnValue({ + data: { intro: [buildIntroQuest()] }, + isLoading: false, + }); + + const { result } = renderHook(() => + useNewD1ExperienceFeature({ shouldEvaluate: true }), + ); + + expect(result.current.value).toBe(false); + }); + + it('returns false without evaluating when shouldEvaluate is false', () => { + mockUseQuestDashboard.mockReturnValue({ + data: { intro: [buildIntroQuest()] }, + isLoading: false, + }); + + const { result } = renderHook(() => + useNewD1ExperienceFeature({ shouldEvaluate: false }), + ); + + expect(result.current.value).toBe(false); + expect(mockUseConditionalFeature).toHaveBeenCalledWith({ + feature: featureNewD1Experience, + shouldEvaluate: false, + }); + }); + + it('treats missing shouldEvaluate as true', () => { + mockUseQuestDashboard.mockReturnValue({ + data: { intro: [buildIntroQuest()] }, + isLoading: false, + }); + + const { result } = renderHook(() => useNewD1ExperienceFeature()); + + expect(result.current.value).toBe(true); + expect(mockUseConditionalFeature).toHaveBeenCalledWith({ + feature: featureNewD1Experience, + shouldEvaluate: true, + }); + }); +}); diff --git a/packages/shared/src/hooks/profile/useProfileCompletionIndicator.ts b/packages/shared/src/hooks/profile/useProfileCompletionIndicator.ts index 8e31af5c437..92b321bdbde 100644 --- a/packages/shared/src/hooks/profile/useProfileCompletionIndicator.ts +++ b/packages/shared/src/hooks/profile/useProfileCompletionIndicator.ts @@ -2,8 +2,7 @@ import { useCallback } from 'react'; import { useAuthContext } from '../../contexts/AuthContext'; import { useActions } from '../useActions'; import { ActionType } from '../../graphql/actions'; -import { useConditionalFeature } from '../useConditionalFeature'; -import { featureNewD1Experience } from '../../lib/featureManagement'; +import { useNewD1ExperienceFeature } from '../useNewD1ExperienceFeature'; interface UseProfileCompletionIndicator { showIndicator: boolean; @@ -29,8 +28,7 @@ export const useProfileCompletionIndicator = const shouldEvaluate = isActionsFetched && !isDismissed && profileCompletionPercentage < 100; - const { value: isNewD1Experience } = useConditionalFeature({ - feature: featureNewD1Experience, + const { value: isNewD1Experience } = useNewD1ExperienceFeature({ shouldEvaluate, }); diff --git a/packages/shared/src/hooks/useNewD1ExperienceFeature.ts b/packages/shared/src/hooks/useNewD1ExperienceFeature.ts new file mode 100644 index 00000000000..8ff0bdc98c7 --- /dev/null +++ b/packages/shared/src/hooks/useNewD1ExperienceFeature.ts @@ -0,0 +1,27 @@ +import { featureNewD1Experience } from '../lib/featureManagement'; +import { useConditionalFeature } from './useConditionalFeature'; +import { useQuestDashboard } from './useQuestDashboard'; + +interface UseNewD1ExperienceFeature { + value: boolean; + isLoading: boolean; +} + +export const useNewD1ExperienceFeature = ({ + shouldEvaluate, +}: { + shouldEvaluate?: boolean; +} = {}): UseNewD1ExperienceFeature => { + const { data, isLoading: isQuestsLoading } = useQuestDashboard(); + const hasIntroQuests = (data?.intro?.length ?? 0) > 0; + const evaluate = shouldEvaluate !== false && hasIntroQuests; + const { value, isLoading } = useConditionalFeature({ + feature: featureNewD1Experience, + shouldEvaluate: evaluate, + }); + + return { + value: evaluate && value, + isLoading: isLoading || isQuestsLoading, + }; +}; From be3f5230db87a859155ec885187616056a76f449 Mon Sep 17 00:00:00 2001 From: AmarTrebinjac Date: Tue, 12 May 2026 08:57:46 +0000 Subject: [PATCH 2/3] refactor(hooks): simplify useNewD1ExperienceFeature gating Default `shouldEvaluate` to `true` and rename the gating local to `enabled` so the predicate reads directly instead of going through a `!== false` comparison. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/shared/src/hooks/useNewD1ExperienceFeature.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/hooks/useNewD1ExperienceFeature.ts b/packages/shared/src/hooks/useNewD1ExperienceFeature.ts index 8ff0bdc98c7..6cd42e444af 100644 --- a/packages/shared/src/hooks/useNewD1ExperienceFeature.ts +++ b/packages/shared/src/hooks/useNewD1ExperienceFeature.ts @@ -8,20 +8,20 @@ interface UseNewD1ExperienceFeature { } export const useNewD1ExperienceFeature = ({ - shouldEvaluate, + shouldEvaluate = true, }: { shouldEvaluate?: boolean; } = {}): UseNewD1ExperienceFeature => { const { data, isLoading: isQuestsLoading } = useQuestDashboard(); const hasIntroQuests = (data?.intro?.length ?? 0) > 0; - const evaluate = shouldEvaluate !== false && hasIntroQuests; + const enabled = shouldEvaluate && hasIntroQuests; const { value, isLoading } = useConditionalFeature({ feature: featureNewD1Experience, - shouldEvaluate: evaluate, + shouldEvaluate: enabled, }); return { - value: evaluate && value, + value: enabled && value, isLoading: isLoading || isQuestsLoading, }; }; From 3ae5701cc21ef30d0606b1423570995408cb2605 Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Tue, 12 May 2026 13:42:21 +0200 Subject: [PATCH 3/3] fix --- packages/shared/src/hooks/useNewD1ExperienceFeature.ts | 4 +++- packages/shared/src/hooks/useQuestDashboard.ts | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/hooks/useNewD1ExperienceFeature.ts b/packages/shared/src/hooks/useNewD1ExperienceFeature.ts index 6cd42e444af..53d09837c73 100644 --- a/packages/shared/src/hooks/useNewD1ExperienceFeature.ts +++ b/packages/shared/src/hooks/useNewD1ExperienceFeature.ts @@ -12,7 +12,9 @@ export const useNewD1ExperienceFeature = ({ }: { shouldEvaluate?: boolean; } = {}): UseNewD1ExperienceFeature => { - const { data, isLoading: isQuestsLoading } = useQuestDashboard(); + const { data, isLoading: isQuestsLoading } = useQuestDashboard({ + enabled: shouldEvaluate, + }); const hasIntroQuests = (data?.intro?.length ?? 0) > 0; const enabled = shouldEvaluate && hasIntroQuests; const { value, isLoading } = useConditionalFeature({ diff --git a/packages/shared/src/hooks/useQuestDashboard.ts b/packages/shared/src/hooks/useQuestDashboard.ts index b18b8c008ff..f44fec33797 100644 --- a/packages/shared/src/hooks/useQuestDashboard.ts +++ b/packages/shared/src/hooks/useQuestDashboard.ts @@ -5,7 +5,9 @@ import { QUEST_DASHBOARD_QUERY } from '../graphql/quests'; import { RequestKey, StaleTime, generateQueryKey } from '../lib/query'; import { useRequestProtocol } from './useRequestProtocol'; -export const useQuestDashboard = () => { +export const useQuestDashboard = ({ + enabled = true, +}: { enabled?: boolean } = {}) => { const { isLoggedIn, user } = useAuthContext(); const { requestMethod } = useRequestProtocol(); @@ -18,7 +20,7 @@ export const useQuestDashboard = () => { return result.questDashboard; }, - enabled: isLoggedIn, + enabled: isLoggedIn && enabled, staleTime: StaleTime.OneMinute, retry: false, });