From 6b35d3de1c5b80585dfa040c81809aea4ab2498e Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:40:15 -0700 Subject: [PATCH] Revert "Send beta user state with AppView requests (#11474)" (#11510) --- src/analytics/index.tsx | 16 ++--- src/screens/Settings/BetaFeaturesSettings.tsx | 4 +- src/state/preferences/beta-user-cache.ts | 64 ------------------- src/state/preferences/beta-user-sync.tsx | 14 ++-- src/state/session/__tests__/clients-test.ts | 47 -------------- src/state/session/clients.ts | 29 +-------- src/storage/schema.ts | 7 +- 7 files changed, 22 insertions(+), 159 deletions(-) delete mode 100644 src/state/preferences/beta-user-cache.ts diff --git a/src/analytics/index.tsx b/src/analytics/index.tsx index d765c46e2a..07af41473e 100644 --- a/src/analytics/index.tsx +++ b/src/analytics/index.tsx @@ -9,10 +9,6 @@ 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, @@ -37,7 +33,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 {device} from '#/storage' +import {account, device} from '#/storage' export * as utils from '#/analytics/utils' export const features = {init, refresh} @@ -135,7 +131,7 @@ export const setupDeviceId = getAndMigrateDeviceId() /** * Reads the per-account cached `isBetaUser` flag for `did`, kept in sync with - * PDS preference query results and the beta settings toggle. + * writes from `BetaUserStorageSync` 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 @@ -150,13 +146,17 @@ function useAccountIsBetaUser(did: string | undefined): boolean | undefined { const subscribe = useCallback( (onChange: () => void) => { if (!did) return () => {} - return subscribeToCachedIsBetaUser(did, onChange) + const sub = account.addOnValueChangedListener( + [did, 'isBetaUser'], + onChange, + ) + return () => sub.remove() }, [did], ) const getSnapshot = useCallback(() => { if (!did) return undefined - return getCachedIsBetaUser(did) + return account.get([did, 'isBetaUser']) }, [did]) return useSyncExternalStore(subscribe, getSnapshot) } diff --git a/src/screens/Settings/BetaFeaturesSettings.tsx b/src/screens/Settings/BetaFeaturesSettings.tsx index a8d2aeba58..28f561e719 100644 --- a/src/screens/Settings/BetaFeaturesSettings.tsx +++ b/src/screens/Settings/BetaFeaturesSettings.tsx @@ -5,7 +5,6 @@ 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, @@ -26,6 +25,7 @@ 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) { - setCachedIsBetaUser(currentAccount.did, next) + account.set([currentAccount.did, 'isBetaUser'], 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 deleted file mode 100644 index c2a31e5334..0000000000 --- a/src/state/preferences/beta-user-cache.ts +++ /dev/null @@ -1,64 +0,0 @@ -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 edad7fc911..f8cead4955 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 {getCachedIsBetaUser, setCachedIsBetaUser} from './beta-user-cache' +import {account} from '#/storage' /** - * 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: + * 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: * 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 - * cache change listener, which re-renders the analytics subtree. + * storage change listener, which re-renders the analytics subtree. */ - if (getCachedIsBetaUser(did) === isBetaUser) return - setCachedIsBetaUser(did, isBetaUser) + if (account.get([did, 'isBetaUser']) === isBetaUser) return + account.set([did, 'isBetaUser'], isBetaUser) }, [did, isBetaUser]) return null diff --git a/src/state/session/__tests__/clients-test.ts b/src/state/session/__tests__/clients-test.ts index 1dfa93e0ea..50cf70fede 100644 --- a/src/state/session/__tests__/clients-test.ts +++ b/src/state/session/__tests__/clients-test.ts @@ -14,12 +14,7 @@ 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, @@ -89,8 +84,6 @@ describe('buildAppviewClient', () => { beforeEach(() => { fetchMock = makeProfileFetch() configureGlobalAppLabelers([]) - account.remove([DID, 'isBetaUser']) - invalidateCachedIsBetaUser(DID) }) it('passes through the session did', () => { @@ -118,46 +111,6 @@ 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 c685bf90b2..026b2d8bd8 100644 --- a/src/state/session/clients.ts +++ b/src/state/session/clients.ts @@ -7,33 +7,8 @@ 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}. * @@ -53,9 +28,7 @@ function withAppviewRequestHeaders(agent: Agent): Agent { * fetch, which is `networkAwareFetch` wrapped in the disposal kill switch. */ export function buildAppviewClient(agent: Agent): Client { - return createLexClient(withAppviewRequestHeaders(agent), { - service: BLUESKY_PROXY_HEADER.get(), - }) + return createLexClient(agent, {service: BLUESKY_PROXY_HEADER.get()}) } /** diff --git a/src/storage/schema.ts b/src/storage/schema.ts index c8620a423f..53b4ffb022 100644 --- a/src/storage/schema.ts +++ b/src/storage/schema.ts @@ -98,9 +98,10 @@ export type Account = { recentGifs?: Gif[] /** - * 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. + * 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. * * 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