From 6101c32bd992c871497203bcb6d509ba4610fea5 Mon Sep 17 00:00:00 2001 From: dan Date: Thu, 18 Apr 2024 16:26:05 +0100 Subject: [PATCH 1/2] [Statsig] Prefetch configs for other accounts (#3607) * Poll both current and other accounts * Make createStatsigOptions a function * Pass prefetchUsers with the initial request * Add initializeCalled check * Be resilient to object identity changes * Decrease poll interval to 1 minute --- src/lib/statsig/statsig.tsx | 67 +++++++++++++++++++++++++------------ 1 file changed, 46 insertions(+), 21 deletions(-) diff --git a/src/lib/statsig/statsig.tsx b/src/lib/statsig/statsig.tsx index 36f030e3c6..151b365d34 100644 --- a/src/lib/statsig/statsig.tsx +++ b/src/lib/statsig/statsig.tsx @@ -8,6 +8,7 @@ import {logger} from '#/logger' import {isWeb} from '#/platform/detection' import {IS_TESTFLIGHT} from 'lib/app-info' import {useSession} from '../../state/session' +import {useNonReactiveCallback} from '../hooks/useNonReactiveCallback' import {LogEvents} from './events' import {Gate} from './gates' @@ -34,19 +35,23 @@ if (isWeb && typeof window !== 'undefined') { export type {LogEvents} -const statsigOptions = { - environment: { - tier: - process.env.NODE_ENV === 'development' - ? 'development' - : IS_TESTFLIGHT - ? 'staging' - : 'production', - }, - // Don't block on waiting for network. The fetched config will kick in on next load. - // This ensures the UI is always consistent and doesn't update mid-session. - // Note this makes cold load (no local storage) and private mode return `false` for all gates. - initTimeoutMs: 1, +function createStatsigOptions(prefetchUsers: StatsigUser[]) { + return { + environment: { + tier: + process.env.NODE_ENV === 'development' + ? 'development' + : IS_TESTFLIGHT + ? 'staging' + : 'production', + }, + // Don't block on waiting for network. The fetched config will kick in on next load. + // This ensures the UI is always consistent and doesn't update mid-session. + // Note this makes cold load (no local storage) and private mode return `false` for all gates. + initTimeoutMs: 1, + // Get fresh flags for other accounts as well, if any. + prefetchUsers, + } } type FlatJSONRecord = Record< @@ -160,9 +165,25 @@ AppState.addEventListener('change', (state: AppStateStatus) => { }) export function Provider({children}: {children: React.ReactNode}) { - const {currentAccount} = useSession() + const {currentAccount, accounts} = useSession() const did = currentAccount?.did const currentStatsigUser = React.useMemo(() => toStatsigUser(did), [did]) + + const otherDidsConcatenated = accounts + .map(account => account.did) + .filter(accountDid => accountDid !== did) + .join(' ') // We're only interested in DID changes. + const otherStatsigUsers = React.useMemo( + () => otherDidsConcatenated.split(' ').map(toStatsigUser), + [otherDidsConcatenated], + ) + const statsigOptions = React.useMemo( + () => createStatsigOptions(otherStatsigUsers), + [otherStatsigUsers], + ) + + // Have our own cache in front of Statsig. + // This ensures the results remain stable until the active DID changes. const [gateCache, setGateCache] = React.useState(() => new Map()) const [prevDid, setPrevDid] = React.useState(did) if (did !== prevDid) { @@ -170,15 +191,19 @@ export function Provider({children}: {children: React.ReactNode}) { setGateCache(new Map()) } - React.useEffect(() => { - function refresh() { - // This will not affect the current session. - // Statsig will put the results into local storage and we'll pick it up on next load. - Statsig.updateUser(currentStatsigUser) + // Periodically poll Statsig to get the current rule evaluations for all stored accounts. + // These changes are prefetched and stored, but don't get applied until the active DID changes. + // This ensures that when you switch an account, it already has fresh results by then. + const handleIntervalTick = useNonReactiveCallback(() => { + if (Statsig.initializeCalled()) { + // Note: Only first five will be taken into account by Statsig. + Statsig.prefetchUsers([currentStatsigUser, ...otherStatsigUsers]) } - const id = setInterval(refresh, 3 * 60e3 /* 3 min */) + }) + React.useEffect(() => { + const id = setInterval(handleIntervalTick, 60e3 /* 1 min */) return () => clearInterval(id) - }, [currentStatsigUser]) + }, [handleIntervalTick]) return ( From bef7d8a325f2bf63fd096093fe1b0eac05c711a3 Mon Sep 17 00:00:00 2001 From: dan Date: Thu, 18 Apr 2024 17:53:51 +0100 Subject: [PATCH 2/2] [Statsig] Slightly block the UI on gates (#3608) --- src/lib/statsig/statsig.tsx | 26 ++++++++++++++++++++++++++ src/state/session/index.tsx | 15 ++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/lib/statsig/statsig.tsx b/src/lib/statsig/statsig.tsx index 151b365d34..c43d2bf8a3 100644 --- a/src/lib/statsig/statsig.tsx +++ b/src/lib/statsig/statsig.tsx @@ -8,6 +8,7 @@ import {logger} from '#/logger' import {isWeb} from '#/platform/detection' import {IS_TESTFLIGHT} from 'lib/app-info' import {useSession} from '../../state/session' +import {timeout} from '../async/timeout' import {useNonReactiveCallback} from '../hooks/useNonReactiveCallback' import {LogEvents} from './events' import {Gate} from './gates' @@ -164,6 +165,31 @@ AppState.addEventListener('change', (state: AppStateStatus) => { } }) +export async function tryFetchGates( + did: string, + strategy: 'prefer-low-latency' | 'prefer-fresh-gates', +) { + try { + let timeoutMs = 250 // Don't block the UI if we can't do this fast. + if (strategy === 'prefer-fresh-gates') { + // Use this for less common operations where the user would be OK with a delay. + timeoutMs = 1500 + } + // Note: This condition is currently false the very first render because + // Statsig has not initialized yet. In the future, we can fix this by + // doing the initialization ourselves instead of relying on the provider. + if (Statsig.initializeCalled()) { + await Promise.race([ + timeout(timeoutMs), + Statsig.prefetchUsers([toStatsigUser(did)]), + ]) + } + } catch (e) { + // Don't leak errors to the calling code, this is meant to be always safe. + console.error(e) + } +} + export function Provider({children}: {children: React.ReactNode}) { const {currentAccount, accounts} = useSession() const did = currentAccount?.did diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index b88181ebda..1d60eaf8fc 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -9,7 +9,7 @@ import {jwtDecode} from 'jwt-decode' import {track} from '#/lib/analytics/analytics' import {networkRetry} from '#/lib/async/retry' import {IS_TEST_USER} from '#/lib/constants' -import {logEvent, LogEvents} from '#/lib/statsig/statsig' +import {logEvent, LogEvents, tryFetchGates} from '#/lib/statsig/statsig' import {hasProp} from '#/lib/type-guards' import {logger} from '#/logger' import {isWeb} from '#/platform/detection' @@ -243,6 +243,10 @@ export function Provider({children}: React.PropsWithChildren<{}>) { if (!agent.session) { throw new Error(`session: createAccount failed to establish a session`) } + const fetchingGates = tryFetchGates( + agent.session.did, + 'prefer-fresh-gates', + ) const deactivated = isSessionDeactivated(agent.session.accessJwt) if (!deactivated) { @@ -283,6 +287,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ) __globalAgent = agent + await fetchingGates upsertAccount(account) logger.debug(`session: created account`, {}, logger.DebugContext.session) @@ -303,6 +308,10 @@ export function Provider({children}: React.PropsWithChildren<{}>) { if (!agent.session) { throw new Error(`session: login failed to establish a session`) } + const fetchingGates = tryFetchGates( + agent.session.did, + 'prefer-fresh-gates', + ) const account: SessionAccount = { service: agent.service.toString(), @@ -330,6 +339,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { __globalAgent = agent // @ts-ignore if (IS_DEV && isWeb) window.agent = agent + await fetchingGates upsertAccount(account) logger.debug(`session: logged in`, {}, logger.DebugContext.session) @@ -362,6 +372,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const initSession = React.useCallback( async account => { logger.debug(`session: initSession`, {}, logger.DebugContext.session) + const fetchingGates = tryFetchGates(account.did, 'prefer-low-latency') const agent = new BskyAgent({ service: account.service, @@ -406,6 +417,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { agent.session = prevSession __globalAgent = agent + await fetchingGates upsertAccount(account) if (prevSession.deactivated) { @@ -442,6 +454,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { try { const freshAccount = await resumeSessionWithFreshAccount() __globalAgent = agent + await fetchingGates upsertAccount(freshAccount) } catch (e) { /*