diff --git a/src/analytics/index.tsx b/src/analytics/index.tsx index 07af41473e..d765c46e2a 100644 --- a/src/analytics/index.tsx +++ b/src/analytics/index.tsx @@ -9,6 +9,10 @@ import {Platform} from 'react-native' import {type Result, type WidenPrimitives} from '@growthbook/growthbook-react' import {Logger} from '#/logger' +import { + getCachedIsBetaUser, + subscribeToCachedIsBetaUser, +} from '#/state/preferences/beta-user-cache' import { Features, features as feats, @@ -33,7 +37,7 @@ import {type Metrics, metrics} from '#/analytics/metrics' import * as refParams from '#/analytics/misc/refParams' import * as env from '#/env' import {useGeolocationServiceResponse} from '#/geolocation/service' -import {account, device} from '#/storage' +import {device} from '#/storage' export * as utils from '#/analytics/utils' export const features = {init, refresh} @@ -131,7 +135,7 @@ export const setupDeviceId = getAndMigrateDeviceId() /** * Reads the per-account cached `isBetaUser` flag for `did`, kept in sync with - * writes from `BetaUserStorageSync` and the beta settings toggle. + * PDS preference query results and the beta settings toggle. * * This deliberately does not use `useStorage`, whose `useState` seeds once and * only updates via the change listener. The consuming `AnalyticsContext` lives @@ -146,17 +150,13 @@ function useAccountIsBetaUser(did: string | undefined): boolean | undefined { const subscribe = useCallback( (onChange: () => void) => { if (!did) return () => {} - const sub = account.addOnValueChangedListener( - [did, 'isBetaUser'], - onChange, - ) - return () => sub.remove() + return subscribeToCachedIsBetaUser(did, onChange) }, [did], ) const getSnapshot = useCallback(() => { if (!did) return undefined - return account.get([did, 'isBetaUser']) + return getCachedIsBetaUser(did) }, [did]) return useSyncExternalStore(subscribe, getSnapshot) } diff --git a/src/screens/Settings/BetaFeaturesSettings.tsx b/src/screens/Settings/BetaFeaturesSettings.tsx index 28f561e719..a8d2aeba58 100644 --- a/src/screens/Settings/BetaFeaturesSettings.tsx +++ b/src/screens/Settings/BetaFeaturesSettings.tsx @@ -5,6 +5,7 @@ import {type NativeStackScreenProps} from '@react-navigation/native-stack' import {type CommonNavigatorParams} from '#/lib/routes/types' import {logger} from '#/logger' +import {setCachedIsBetaUser} from '#/state/preferences/beta-user-cache' import { usePreferencesQuery, useSetIsBetaUserMutation, @@ -25,7 +26,6 @@ import {Text} from '#/components/Typography' import {features, useAnalytics} from '#/analytics' import {getTargetedFeatures} from '#/analytics/features' import {IS_WEB} from '#/env' -import {account} from '#/storage' type Props = NativeStackScreenProps< CommonNavigatorParams, @@ -74,7 +74,7 @@ export function BetaFeaturesSettingsScreen({}: Props) { * account-specific. */ if (currentAccount) { - account.set([currentAccount.did, 'isBetaUser'], next) + setCachedIsBetaUser(currentAccount.did, next) } ax.metric('betaFeatures:toggle', { enabled: next, diff --git a/src/state/preferences/beta-user-cache.ts b/src/state/preferences/beta-user-cache.ts new file mode 100644 index 0000000000..c2a31e5334 --- /dev/null +++ b/src/state/preferences/beta-user-cache.ts @@ -0,0 +1,64 @@ +import {account} from '#/storage' + +const values = new Map() +const listeners = new Map void>>() + +/** + * Returns the last known beta-user preference for an account. + * + * The first read for a DID in this process is hydrated synchronously from + * persistent storage so cold starts retain the last value fetched from the + * PDS. Subsequent reads, including request-header reads, stay in memory. + */ +export function getCachedIsBetaUser(did: string): boolean | undefined { + if (!values.has(did)) { + try { + values.set(did, account.get([did, 'isBetaUser'])) + } catch { + values.set(did, undefined) + } + } + return values.get(did) +} + +/** + * Updates the runtime cache and its persistent cold-start snapshot. + * + * Call this only with a value confirmed by the PDS, which remains the source + * of truth for the preference. + */ +export function setCachedIsBetaUser(did: string, value: boolean): void { + if (getCachedIsBetaUser(did) === value) return + account.set([did, 'isBetaUser'], value) + values.set(did, value) + listeners.get(did)?.forEach(listener => listener()) +} + +/** + * Subscribe to runtime cache changes for one account. + */ +export function subscribeToCachedIsBetaUser( + did: string, + listener: () => void, +): () => void { + let didListeners = listeners.get(did) + if (!didListeners) { + didListeners = new Set() + listeners.set(did, didListeners) + } + didListeners.add(listener) + + return () => { + didListeners.delete(listener) + if (didListeners.size === 0) listeners.delete(did) + } +} + +/** + * Drops the in-memory value so the next read rehydrates from persistence. + * This is useful when persistence is changed outside this cache. + */ +export function invalidateCachedIsBetaUser(did: string): void { + if (!values.delete(did)) return + listeners.get(did)?.forEach(listener => listener()) +} diff --git a/src/state/preferences/beta-user-sync.tsx b/src/state/preferences/beta-user-sync.tsx index f8cead4955..edad7fc911 100644 --- a/src/state/preferences/beta-user-sync.tsx +++ b/src/state/preferences/beta-user-sync.tsx @@ -2,12 +2,12 @@ import {useEffect} from 'react' import {usePreferencesQuery} from '#/state/queries/preferences' import {useSession} from '#/state/session' -import {account} from '#/storage' +import {getCachedIsBetaUser, setCachedIsBetaUser} from './beta-user-cache' /** - * Caches `bskyAppState.isBetaUser` from preferences into synchronous device - * storage so analytics can read it at init (before beta-gated features are - * evaluated). Scoped per account, since `isBetaUser` is account-specific: + * Caches `bskyAppState.isBetaUser` from preferences in memory and synchronous + * device storage so analytics can read it at init (before beta-gated features + * are evaluated). Scoped per account, since `isBetaUser` is account-specific: * a global cache would let one account's value leak into another after a * switch, until the new account's preferences loaded. Must be mounted below * `QueryProvider`, since the analytics providers that consume the cached value @@ -27,10 +27,10 @@ export function BetaUserStorageSync() { if (isBetaUser === undefined) return /* * Guard against a redundant write on every warm boot. Writing triggers the - * storage change listener, which re-renders the analytics subtree. + * cache change listener, which re-renders the analytics subtree. */ - if (account.get([did, 'isBetaUser']) === isBetaUser) return - account.set([did, 'isBetaUser'], isBetaUser) + if (getCachedIsBetaUser(did) === isBetaUser) return + setCachedIsBetaUser(did, isBetaUser) }, [did, isBetaUser]) return null diff --git a/src/state/session/__tests__/clients-test.ts b/src/state/session/__tests__/clients-test.ts index 50cf70fede..1dfa93e0ea 100644 --- a/src/state/session/__tests__/clients-test.ts +++ b/src/state/session/__tests__/clients-test.ts @@ -14,7 +14,12 @@ jest.mock('jwt-decode', () => ({ })) import {BLUESKY_PROXY_HEADER, CHAT_PROXY_SERVICE} from '#/lib/constants' +import { + invalidateCachedIsBetaUser, + setCachedIsBetaUser, +} from '#/state/preferences/beta-user-cache' import {app, chat, com} from '#/lexicons' +import {account} from '#/storage' import {configureGlobalAppLabelers} from '../additional-moderation-authorities' import { buildAppviewClient, @@ -84,6 +89,8 @@ describe('buildAppviewClient', () => { beforeEach(() => { fetchMock = makeProfileFetch() configureGlobalAppLabelers([]) + account.remove([DID, 'isBetaUser']) + invalidateCachedIsBetaUser(DID) }) it('passes through the session did', () => { @@ -111,6 +118,46 @@ describe('buildAppviewClient', () => { ).toBe(BLUESKY_PROXY_HEADER.get()) }) + it.each([true, false])( + 'emits the current beta user header when the cached value is %s', + async isBetaUser => { + const client = buildAppviewClient(makeSession(fetchMock)) + setCachedIsBetaUser(DID, isBetaUser) + + await client.call(app.bsky.actor.getProfile, {actor: HANDLE}) + + expect( + headersFor(fetchMock, 'app.bsky.actor.getProfile').get( + 'x-bsky-is-beta-user', + ), + ).toBe(String(isBetaUser)) + }, + ) + + it('omits the beta user header when the preference is not cached', async () => { + const client = buildAppviewClient(makeSession(fetchMock)) + + await client.call(app.bsky.actor.getProfile, {actor: HANDLE}) + + expect( + headersFor(fetchMock, 'app.bsky.actor.getProfile').get( + 'x-bsky-is-beta-user', + ), + ).toBeNull() + }) + + it('reads the persisted beta preference only once', async () => { + account.set([DID, 'isBetaUser'], true) + const getSpy = jest.spyOn(account, 'get') + const client = buildAppviewClient(makeSession(fetchMock)) + + await client.call(app.bsky.actor.getProfile, {actor: HANDLE}) + await client.call(app.bsky.actor.getProfile, {actor: HANDLE}) + + expect(getSpy).toHaveBeenCalledTimes(1) + getSpy.mockRestore() + }) + it('emits an account subscription exactly once', async () => { const client = buildAppviewClient(makeSession(fetchMock)) client.setLabelers(['did:plc:labeler']) diff --git a/src/state/session/clients.ts b/src/state/session/clients.ts index 026b2d8bd8..c685bf90b2 100644 --- a/src/state/session/clients.ts +++ b/src/state/session/clients.ts @@ -7,8 +7,33 @@ import { PUBLIC_BSKY_SERVICE, } from '#/lib/constants' import {createLexClient} from '#/lib/lexClient' +import {getCachedIsBetaUser} from '#/state/preferences/beta-user-cache' import {networkAwareFetch} from './network' +const IS_BETA_USER_HEADER = 'X-Bsky-Is-Beta-User' + +/** + * Add account-scoped headers to appview requests. + * + * Values are read from memory per request so preference changes are reflected + * immediately without rebuilding the session bundle. + */ +function withAppviewRequestHeaders(agent: Agent): Agent { + return { + get did() { + return agent.did + }, + fetchHandler(path, init) { + const headers = new Headers(init?.headers) + const isBetaUser = agent.did ? getCachedIsBetaUser(agent.did) : undefined + if (isBetaUser !== undefined) { + headers.set(IS_BETA_USER_HEADER, String(isBetaUser)) + } + return agent.fetchHandler(path, {...init, headers}) + }, + } +} + /** * Build the signed-in appview {@link Client}. * @@ -28,7 +53,9 @@ import {networkAwareFetch} from './network' * fetch, which is `networkAwareFetch` wrapped in the disposal kill switch. */ export function buildAppviewClient(agent: Agent): Client { - return createLexClient(agent, {service: BLUESKY_PROXY_HEADER.get()}) + return createLexClient(withAppviewRequestHeaders(agent), { + service: BLUESKY_PROXY_HEADER.get(), + }) } /** diff --git a/src/storage/schema.ts b/src/storage/schema.ts index 53b4ffb022..c8620a423f 100644 --- a/src/storage/schema.ts +++ b/src/storage/schema.ts @@ -98,10 +98,9 @@ export type Account = { recentGifs?: Gif[] /** - * Cached from preferences (`bskyAppState.isBetaUser`) so the GrowthBook - * `isBetaUser` attribute can be set synchronously at analytics init, before - * beta-gated features (e.g. SearchV2Enable) are first evaluated. Written back - * when preferences load. + * Persistent cold-start snapshot of `bskyAppState.isBetaUser`. Hydrates the + * runtime cache so the GrowthBook attribute and request header are available + * synchronously before preferences load. Written back when preferences load. * * Scoped per account, since `isBetaUser` is account-specific preference data. * Reading it globally would let a beta account's value leak into a non-beta