From 93cb970c263e7fba1b88a762a79e83d175857ca4 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Thu, 21 May 2026 09:31:50 +0200 Subject: [PATCH 1/2] fix: defer inline login enrollment --- packages/shared/src/contexts/AuthContext.tsx | 53 ++++++++++++++----- .../shared/src/contexts/BootProvider.spec.tsx | 29 ++++++++++ packages/webapp/pages/_app.tsx | 19 ++++--- 3 files changed, 79 insertions(+), 22 deletions(-) diff --git a/packages/shared/src/contexts/AuthContext.tsx b/packages/shared/src/contexts/AuthContext.tsx index 77d77959223..064d4efa510 100644 --- a/packages/shared/src/contexts/AuthContext.tsx +++ b/packages/shared/src/contexts/AuthContext.tsx @@ -8,7 +8,7 @@ import React, { } from 'react'; import type { QueryObserverResult } from '@tanstack/react-query'; import { useRouter } from 'next/router'; -import { useFeatureValue } from '@growthbook/growthbook-react'; +import { GrowthBookContext } from '@growthbook/growthbook-react'; import type { AnonymousUser, LoggedUser } from '../lib/user'; import { deleteAccount, logout as dispatchLogout } from '../lib/user'; import type { AccessToken, Boot, Visit } from '../lib/boot'; @@ -49,6 +49,12 @@ type ShowLoginParams = { options?: LoginOptions; }; +type GrowthBookContextData = { + growthbook?: { + getFeatureValue: (id: string, defaultValue: T) => T | undefined; + }; +}; + export interface AuthContextData { user?: LoggedUser; isLoggedIn: boolean; @@ -79,9 +85,12 @@ export interface AuthContextData { isGdprCovered?: boolean; isValidRegion?: boolean; isFunnel?: boolean; + inlineLoginEnabled?: boolean; } const isExtension = checkIsExtension(); +const inlineLoginFeatureId = 'inline_login'; +const inlineLoginDefaultValue: boolean = false; const AuthContext = React.createContext(null); export const useAuthContext = (): AuthContextData => useContext(AuthContext); export default AuthContext; @@ -159,20 +168,37 @@ export const AuthContextProvider = ({ isAndroidApp, }: AuthContextProviderProps): ReactElement => { const [loginState, setLoginState] = useState(null); + const [inlineLoginEnabled, setInlineLoginEnabled] = useState(); + const inlineLoginEnabledRef = useRef(); const endUser = user && 'providers' in user ? user : null; const referral = user?.referralId || user?.referrer; const referralOrigin = user?.referralOrigin; const router = useRouter(); const isFunnelRef = useRef(!!router?.pathname?.startsWith(webFunnelPrefix)); + const growthbookContext = useContext( + GrowthBookContext, + ) as unknown as GrowthBookContextData; + const growthbook = growthbookContext?.growthbook; const isValidRegion = useMemo( () => !invalidPlusRegions.includes(geo?.region), [geo?.region], ); - // Inline-login experiment flag. Source of truth for the local default lives - // in `lib/featureManagement.ts` as `featureInlineLogin`. We can't import it - // here because `featureManagement` → `graphql/posts` → `AuthContext` would - // be a cycle, so the default is duplicated below; keep them in sync. - const isInlineLoginEnabled = useFeatureValue('inline_login', true); + const evaluateInlineLogin = useCallback((): boolean => { + if (!isNullOrUndefined(inlineLoginEnabledRef.current)) { + return inlineLoginEnabledRef.current; + } + + const isEnabled = + growthbook?.getFeatureValue( + inlineLoginFeatureId, + inlineLoginDefaultValue, + ) === true; + + inlineLoginEnabledRef.current = isEnabled; + setInlineLoginEnabled(isEnabled); + + return isEnabled; + }, [growthbook]); return ( { const hasCompanion = !!isCompanionActivated(); @@ -196,6 +223,7 @@ export const AuthContextProvider = ({ } const params = new URLSearchParams(globalThis?.location.search); + const shouldUseInlineLogin = !isExtension && evaluateInlineLogin(); setLoginState({ ...options, trigger }); if (isExtension) { @@ -206,19 +234,20 @@ export const AuthContextProvider = ({ params.set(AFTER_AUTH_PARAM, window.location.pathname); } + const onboardingPath = isExtension + ? `${onboardingUrl}?${params.toString()}` + : `/onboarding?${params.toString()}`; + // Inline login experiment: render the modal in-place instead of // redirecting to /onboarding. Extension keeps the redirect because // it has no host page to mount the modal on. - if (isInlineLoginEnabled && !isExtension) { + if (shouldUseInlineLogin) { return; } - const onboardingPath = `${onboardingUrl}?${params.toString()}`; - router.push( - isExtension ? onboardingPath : `/onboarding?${params.toString()}`, - ); + router.push(onboardingPath); }, - [router, isInlineLoginEnabled], + [evaluateInlineLogin, router], ), closeLogin: useCallback(() => setLoginState(null), []), loginState, diff --git a/packages/shared/src/contexts/BootProvider.spec.tsx b/packages/shared/src/contexts/BootProvider.spec.tsx index 821266be25a..e6f8e6622fe 100644 --- a/packages/shared/src/contexts/BootProvider.spec.tsx +++ b/packages/shared/src/contexts/BootProvider.spec.tsx @@ -417,6 +417,7 @@ const AuthMock = ({ updatedUser, loginTrigger }: AuthMockProps) => { getRedirectUri, trackingId, anonymous, + inlineLoginEnabled, } = useContext(AuthContext); return ( @@ -461,6 +462,9 @@ const AuthMock = ({ updatedUser, loginTrigger }: AuthMockProps) => { Tracking ID Anonymous User + + Inline Login Enabled + ); }; @@ -501,6 +505,31 @@ it('should trigger show login callback', async () => { await expectToHaveTestValue(login, JSON.stringify({ trigger: expected })); }); +it('should evaluate inline login only after auth intent', async () => { + renderComponent(, { + ...defaultBootData, + user: defaultAnonymousUser, + exp: { + f: '{}', + e: [], + a: [], + features: { + inline_login: { + defaultValue: true, + }, + }, + }, + }); + + const login = await screen.findByText('Log in'); + const inlineLogin = await screen.findByText('Inline Login Enabled'); + await expectToHaveTestValue(inlineLogin, 'undefined'); + + fireEvent.click(login); + + await expectToHaveTestValue(inlineLogin, 'true'); +}); + it('should trigger close login callback', async () => { const expected = AuthTriggers.Comment; renderComponent(, { diff --git a/packages/webapp/pages/_app.tsx b/packages/webapp/pages/_app.tsx index cac9d520332..5ed6e000567 100644 --- a/packages/webapp/pages/_app.tsx +++ b/packages/webapp/pages/_app.tsx @@ -52,8 +52,6 @@ import { WebKitMessageHandlers, } from '@dailydotdev/shared/src/lib/ios'; import { useCheckLocation } from '@dailydotdev/shared/src/hooks/useCheckLocation'; -import { useFeature } from '@dailydotdev/shared/src/components/GrowthBookProvider'; -import { featureInlineLogin } from '@dailydotdev/shared/src/lib/featureManagement'; import Seo, { defaultSeo, defaultSeoTitle } from '../next-seo'; import useWebappVersion from '../hooks/useWebappVersion'; import { getAppOrigin, getSiteOrigin } from '../lib/seo'; @@ -104,8 +102,8 @@ const onboardingExcludedPaths = [ '/jobs', '/settings', ]; -// When the inline_login experiment is on, we only force the rest of onboarding -// when the user lands on the main feed — everywhere else they can keep +// Once auth intent assigns the user to inline_login, only force the rest of +// onboarding when they land on the main feed. Everywhere else they can keep // browsing after the inline first step. const mainFeedPathnames = new Set([ '/', @@ -177,8 +175,8 @@ function InternalApp({ Component, pageProps, router }: AppProps): ReactElement { shouldShowLogin, closeLogin, loginState, + inlineLoginEnabled, } = useAuthContext(); - const isInlineLoginEnabled = useFeature(featureInlineLogin); const { showBanner, onAcceptCookies, onOpenBanner, onHideBanner } = useCookieBanner(); useWebVitals(); @@ -240,9 +238,10 @@ function InternalApp({ Component, pageProps, router }: AppProps): ReactElement { return; } - // Inline login experiment: defer the rest of onboarding until the user - // navigates to the main feed; otherwise let them keep browsing. - if (isInlineLoginEnabled && !mainFeedPathnames.has(router.pathname)) { + // Inline login experiment: after auth intent enrolls the user, defer the + // rest of onboarding until they navigate to the main feed; otherwise let + // them keep browsing. + if (inlineLoginEnabled && !mainFeedPathnames.has(router.pathname)) { return; } @@ -255,7 +254,7 @@ function InternalApp({ Component, pageProps, router }: AppProps): ReactElement { router, router.pathname, isOnboardingComplete, - isInlineLoginEnabled, + inlineLoginEnabled, ]); useEffect(() => { @@ -407,7 +406,7 @@ function InternalApp({ Component, pageProps, router }: AppProps): ReactElement { {getLayout(, pageProps, layoutProps)} - {isInlineLoginEnabled && shouldShowLogin && ( + {inlineLoginEnabled && shouldShowLogin && ( Date: Thu, 21 May 2026 11:53:01 +0200 Subject: [PATCH 2/2] fix: small cleanup --- packages/shared/src/contexts/AuthContext.tsx | 38 +++++-------------- .../shared/src/contexts/BootProvider.spec.tsx | 34 +++++++++++++---- packages/webapp/pages/_app.tsx | 21 ++++++---- 3 files changed, 48 insertions(+), 45 deletions(-) diff --git a/packages/shared/src/contexts/AuthContext.tsx b/packages/shared/src/contexts/AuthContext.tsx index 064d4efa510..adeba0120a7 100644 --- a/packages/shared/src/contexts/AuthContext.tsx +++ b/packages/shared/src/contexts/AuthContext.tsx @@ -8,6 +8,7 @@ import React, { } from 'react'; import type { QueryObserverResult } from '@tanstack/react-query'; import { useRouter } from 'next/router'; +import type { GrowthBookContextValue } from '@growthbook/growthbook-react'; import { GrowthBookContext } from '@growthbook/growthbook-react'; import type { AnonymousUser, LoggedUser } from '../lib/user'; import { deleteAccount, logout as dispatchLogout } from '../lib/user'; @@ -49,12 +50,6 @@ type ShowLoginParams = { options?: LoginOptions; }; -type GrowthBookContextData = { - growthbook?: { - getFeatureValue: (id: string, defaultValue: T) => T | undefined; - }; -}; - export interface AuthContextData { user?: LoggedUser; isLoggedIn: boolean; @@ -85,7 +80,6 @@ export interface AuthContextData { isGdprCovered?: boolean; isValidRegion?: boolean; isFunnel?: boolean; - inlineLoginEnabled?: boolean; } const isExtension = checkIsExtension(); @@ -168,8 +162,6 @@ export const AuthContextProvider = ({ isAndroidApp, }: AuthContextProviderProps): ReactElement => { const [loginState, setLoginState] = useState(null); - const [inlineLoginEnabled, setInlineLoginEnabled] = useState(); - const inlineLoginEnabledRef = useRef(); const endUser = user && 'providers' in user ? user : null; const referral = user?.referralId || user?.referrer; const referralOrigin = user?.referralOrigin; @@ -177,28 +169,12 @@ export const AuthContextProvider = ({ const isFunnelRef = useRef(!!router?.pathname?.startsWith(webFunnelPrefix)); const growthbookContext = useContext( GrowthBookContext, - ) as unknown as GrowthBookContextData; + ) as unknown as GrowthBookContextValue; const growthbook = growthbookContext?.growthbook; const isValidRegion = useMemo( () => !invalidPlusRegions.includes(geo?.region), [geo?.region], ); - const evaluateInlineLogin = useCallback((): boolean => { - if (!isNullOrUndefined(inlineLoginEnabledRef.current)) { - return inlineLoginEnabledRef.current; - } - - const isEnabled = - growthbook?.getFeatureValue( - inlineLoginFeatureId, - inlineLoginDefaultValue, - ) === true; - - inlineLoginEnabledRef.current = isEnabled; - setInlineLoginEnabled(isEnabled); - - return isEnabled; - }, [growthbook]); return ( { const hasCompanion = !!isCompanionActivated(); @@ -223,7 +198,12 @@ export const AuthContextProvider = ({ } const params = new URLSearchParams(globalThis?.location.search); - const shouldUseInlineLogin = !isExtension && evaluateInlineLogin(); + const shouldUseInlineLogin = + !isExtension && + growthbook?.getFeatureValue( + inlineLoginFeatureId, + inlineLoginDefaultValue, + ) === true; setLoginState({ ...options, trigger }); if (isExtension) { @@ -247,7 +227,7 @@ export const AuthContextProvider = ({ router.push(onboardingPath); }, - [evaluateInlineLogin, router], + [growthbook, router], ), closeLogin: useCallback(() => setLoginState(null), []), loginState, diff --git a/packages/shared/src/contexts/BootProvider.spec.tsx b/packages/shared/src/contexts/BootProvider.spec.tsx index e6f8e6622fe..74f905be17d 100644 --- a/packages/shared/src/contexts/BootProvider.spec.tsx +++ b/packages/shared/src/contexts/BootProvider.spec.tsx @@ -1,5 +1,7 @@ import type { ReactNode } from 'react'; import React, { useContext } from 'react'; +import type { NextRouter } from 'next/router'; +import { useRouter } from 'next/router'; import nock from 'nock'; import type { RenderResult } from '@testing-library/react'; import { fireEvent, render, screen } from '@testing-library/react'; @@ -47,9 +49,19 @@ jest.mock('../lib/user', () => { const getRedirectUriMock = jest.fn(); +const mockUseRouter = (router: Partial = {}) => { + jest.mocked(useRouter).mockReturnValue({ + query: {}, + push: jest.fn(), + pathname: '/', + ...router, + } as unknown as NextRouter); +}; + beforeEach(() => { nock.cleanAll(); localStorage.clear(); + mockUseRouter(); }); const defaultAlerts: Alerts = { filter: true, rankLastSeen: undefined }; @@ -417,7 +429,6 @@ const AuthMock = ({ updatedUser, loginTrigger }: AuthMockProps) => { getRedirectUri, trackingId, anonymous, - inlineLoginEnabled, } = useContext(AuthContext); return ( @@ -462,9 +473,6 @@ const AuthMock = ({ updatedUser, loginTrigger }: AuthMockProps) => { Tracking ID Anonymous User - - Inline Login Enabled - ); }; @@ -505,7 +513,13 @@ it('should trigger show login callback', async () => { await expectToHaveTestValue(login, JSON.stringify({ trigger: expected })); }); -it('should evaluate inline login only after auth intent', async () => { +it('should keep inline login on page when enabled after auth intent', async () => { + const push = jest.fn(); + mockUseRouter({ + push, + pathname: '/posts/shared', + }); + renderComponent(, { ...defaultBootData, user: defaultAnonymousUser, @@ -522,12 +536,16 @@ it('should evaluate inline login only after auth intent', async () => { }); const login = await screen.findByText('Log in'); - const inlineLogin = await screen.findByText('Inline Login Enabled'); - await expectToHaveTestValue(inlineLogin, 'undefined'); + await expectToHaveTestValue(login, 'null'); + expect(push).not.toHaveBeenCalled(); fireEvent.click(login); - await expectToHaveTestValue(inlineLogin, 'true'); + await expectToHaveTestValue( + login, + JSON.stringify({ trigger: AuthTriggers.Comment }), + ); + expect(push).not.toHaveBeenCalled(); }); it('should trigger close login callback', async () => { diff --git a/packages/webapp/pages/_app.tsx b/packages/webapp/pages/_app.tsx index 5ed6e000567..381a4250cd5 100644 --- a/packages/webapp/pages/_app.tsx +++ b/packages/webapp/pages/_app.tsx @@ -21,6 +21,7 @@ import { ProgressiveEnhancementContextProvider } from '@dailydotdev/shared/src/c import { SubscriptionContextProvider } from '@dailydotdev/shared/src/contexts/SubscriptionContext'; import { ShortcutsProvider } from '@dailydotdev/shared/src/features/shortcuts/contexts/ShortcutsProvider'; import { canonicalFromRouter } from '@dailydotdev/shared/src/lib/canonical'; +import { featureInlineLogin } from '@dailydotdev/shared/src/lib/featureManagement'; import '@dailydotdev/shared/src/styles/globals.css'; import useLogPageView from '@dailydotdev/shared/src/hooks/log/useLogPageView'; import { BootDataProvider } from '@dailydotdev/shared/src/contexts/BootProvider'; @@ -36,7 +37,10 @@ import { LazyModal } from '@dailydotdev/shared/src/components/modals/common/type import { defaultQueryClientConfig } from '@dailydotdev/shared/src/lib/query'; import { useWebVitals } from '@dailydotdev/shared/src/hooks/useWebVitals'; import { LazyModalElement } from '@dailydotdev/shared/src/components/modals/LazyModalElement'; -import { useManualScrollRestoration } from '@dailydotdev/shared/src/hooks'; +import { + useConditionalFeature, + useManualScrollRestoration, +} from '@dailydotdev/shared/src/hooks'; import { useScrollbarWidth } from '@dailydotdev/shared/src/hooks/useScrollbarWidth'; import { PushNotificationContextProvider } from '@dailydotdev/shared/src/contexts/PushNotificationContext'; import { SerwistProvider } from '@serwist/turbopack/react'; @@ -102,9 +106,8 @@ const onboardingExcludedPaths = [ '/jobs', '/settings', ]; -// Once auth intent assigns the user to inline_login, only force the rest of -// onboarding when they land on the main feed. Everywhere else they can keep -// browsing after the inline first step. +// While inline_login is active for an auth intent, only force the rest of +// onboarding when the user lands on the main feed. const mainFeedPathnames = new Set([ '/', '/popular', @@ -175,8 +178,11 @@ function InternalApp({ Component, pageProps, router }: AppProps): ReactElement { shouldShowLogin, closeLogin, loginState, - inlineLoginEnabled, } = useAuthContext(); + const { value: inlineLoginEnabled } = useConditionalFeature({ + feature: featureInlineLogin, + shouldEvaluate: shouldShowLogin, + }); const { showBanner, onAcceptCookies, onOpenBanner, onHideBanner } = useCookieBanner(); useWebVitals(); @@ -238,9 +244,8 @@ function InternalApp({ Component, pageProps, router }: AppProps): ReactElement { return; } - // Inline login experiment: after auth intent enrolls the user, defer the - // rest of onboarding until they navigate to the main feed; otherwise let - // them keep browsing. + // Inline login experiment: while the auth intent is active, defer the rest + // of onboarding until they navigate to the main feed. if (inlineLoginEnabled && !mainFeedPathnames.has(router.pathname)) { return; }