diff --git a/.changeset/feature-flag-env-vars.md b/.changeset/feature-flag-env-vars.md new file mode 100644 index 000000000..25d7d7d01 --- /dev/null +++ b/.changeset/feature-flag-env-vars.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Add build-time client config overrides via environment variables, with typed deterministic experiment bucketing helpers for progressive feature rollout and A/B testing. diff --git a/.changeset/sw-session-resync-flags.md b/.changeset/sw-session-resync-flags.md new file mode 100644 index 000000000..a35d36b6d --- /dev/null +++ b/.changeset/sw-session-resync-flags.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Add phased service-worker session re-sync controls (foreground resync, visible heartbeat, adaptive backoff/jitter). diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 9b4c9acbb..d9a365eeb 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -34,6 +34,36 @@ runs: env: INPUTS_INSTALL_COMMAND: ${{ inputs.install-command }} + - name: Inject runtime config overrides + if: ${{ env.CLIENT_CONFIG_OVERRIDES_JSON != '' }} + shell: bash + working-directory: ${{ github.workspace }} + run: node scripts/inject-client-config.js + env: + CLIENT_CONFIG_OVERRIDES_JSON: ${{ env.CLIENT_CONFIG_OVERRIDES_JSON }} + CLIENT_CONFIG_OVERRIDES_STRICT: ${{ env.CLIENT_CONFIG_OVERRIDES_STRICT }} + + - name: Display injected config + if: ${{ env.CLIENT_CONFIG_OVERRIDES_JSON != '' }} + shell: bash + working-directory: ${{ github.workspace }} + run: | + summary_file="${GITHUB_STEP_SUMMARY:-}" + echo "::group::Injected Client Config" + experiments_json="$(jq -c '.experiments // "No experiments configured"' config.json 2>/dev/null || echo 'config.json not readable')" + echo "$experiments_json" + echo "::endgroup::" + + if [[ -n "$summary_file" ]]; then + { + echo "### Injected client config" + echo + echo "\`\`\`json" + echo "$experiments_json" + echo "\`\`\`" + } >> "$summary_file" + fi + - name: Build app if: ${{ inputs.build == 'true' }} shell: bash diff --git a/.github/workflows/cloudflare-web-deploy.yml b/.github/workflows/cloudflare-web-deploy.yml index d3d2c4461..e32dbf68e 100644 --- a/.github/workflows/cloudflare-web-deploy.yml +++ b/.github/workflows/cloudflare-web-deploy.yml @@ -40,6 +40,10 @@ jobs: plan: if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest + environment: preview + env: + CLIENT_CONFIG_OVERRIDES_JSON: ${{ vars.CLIENT_CONFIG_OVERRIDES_JSON }} + CLIENT_CONFIG_OVERRIDES_STRICT: ${{ vars.CLIENT_CONFIG_OVERRIDES_STRICT || 'false' }} permissions: contents: read pull-requests: write @@ -73,6 +77,10 @@ jobs: apply: if: (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest + environment: production + env: + CLIENT_CONFIG_OVERRIDES_JSON: ${{ vars.CLIENT_CONFIG_OVERRIDES_JSON }} + CLIENT_CONFIG_OVERRIDES_STRICT: ${{ vars.CLIENT_CONFIG_OVERRIDES_STRICT || 'false' }} permissions: contents: read defaults: diff --git a/.github/workflows/cloudflare-web-preview.yml b/.github/workflows/cloudflare-web-preview.yml index 8b93a4bb9..82046559c 100644 --- a/.github/workflows/cloudflare-web-preview.yml +++ b/.github/workflows/cloudflare-web-preview.yml @@ -32,9 +32,13 @@ jobs: deploy: if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'push' runs-on: ubuntu-latest + environment: preview permissions: contents: read pull-requests: write + env: + CLIENT_CONFIG_OVERRIDES_JSON: ${{ vars.CLIENT_CONFIG_OVERRIDES_JSON }} + CLIENT_CONFIG_OVERRIDES_STRICT: ${{ vars.CLIENT_CONFIG_OVERRIDES_STRICT || 'false' }} steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/config.json b/config.json index 1bdffb675..f51dd942a 100644 --- a/config.json +++ b/config.json @@ -13,10 +13,29 @@ "webPushAppID": "moe.sable.app.sygnal" }, + "experiments": { + "sessionSyncStrategy": { + "enabled": false, + "rolloutPercentage": 0, + "controlVariant": "control", + "variants": ["session-sync-heartbeat", "session-sync-adaptive"] + } + }, + "slidingSync": { "enabled": true }, + "sessionSync": { + "phase1ForegroundResync": false, + "phase2VisibleHeartbeat": false, + "phase3AdaptiveBackoffJitter": false, + "foregroundDebounceMs": 1500, + "heartbeatIntervalMs": 600000, + "resumeHeartbeatSuppressMs": 60000, + "heartbeatMaxBackoffMs": 1800000 + }, + "featuredCommunities": { "openAsDefault": false, "spaces": [ diff --git a/knip.json b/knip.json index c6cca1d75..f45161f97 100644 --- a/knip.json +++ b/knip.json @@ -1,6 +1,6 @@ { "$schema": "https://unpkg.com/knip@5/schema.json", - "entry": ["src/sw.ts", "scripts/normalize-imports.js"], + "entry": ["src/sw.ts", "scripts/normalize-imports.js", "scripts/inject-client-config.js"], "ignoreExportsUsedInFile": { "interface": true, "type": true diff --git a/scripts/inject-client-config.js b/scripts/inject-client-config.js new file mode 100644 index 000000000..b7c62c096 --- /dev/null +++ b/scripts/inject-client-config.js @@ -0,0 +1,71 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import process from 'node:process'; +import { PrefixedLogger } from './utils/console-style.js'; + +const CONFIG_PATH = 'config.json'; +const OVERRIDES_ENV = 'CLIENT_CONFIG_OVERRIDES_JSON'; +const STRICT_ENV = 'CLIENT_CONFIG_OVERRIDES_STRICT'; +const logger = new PrefixedLogger('[config-inject]'); + +const formatError = (error) => { + if (error instanceof Error) return error.stack ?? error.message; + return String(error); +}; + +const isPlainObject = (value) => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const deepMerge = (target, source) => { + if (!isPlainObject(target) || !isPlainObject(source)) return source; + + const merged = { ...target }; + Object.entries(source).forEach(([key, value]) => { + const targetValue = merged[key]; + merged[key] = + isPlainObject(targetValue) && isPlainObject(value) ? deepMerge(targetValue, value) : value; + }); + return merged; +}; + +const failOnError = process.env[STRICT_ENV] === 'true'; +const overridesRaw = process.env[OVERRIDES_ENV]; + +if (!overridesRaw) { + logger.info(`No ${OVERRIDES_ENV} provided; leaving ${CONFIG_PATH} unchanged.`); + process.exit(0); +} + +let fileConfig; +let overrides; + +try { + const file = await readFile(CONFIG_PATH, 'utf8'); + fileConfig = JSON.parse(file); +} catch (error) { + logger.error(`Failed reading ${CONFIG_PATH}: ${formatError(error)}`); + process.exit(1); +} + +try { + overrides = JSON.parse(overridesRaw); + if (!isPlainObject(overrides)) { + throw new Error(`${OVERRIDES_ENV} must be a JSON object.`); + } +} catch (error) { + const message = `[config-inject] Invalid ${OVERRIDES_ENV}; ${ + failOnError ? 'failing build' : 'skipping overrides' + }.`; + if (failOnError) { + logger.error(`${message} ${formatError(error)}`); + process.exit(1); + } + logger.info(`[warning] ${message} ${formatError(error)}`); + process.exit(0); +} + +const mergedConfig = deepMerge(fileConfig, overrides); + +await writeFile(CONFIG_PATH, `${JSON.stringify(mergedConfig, null, 2)}\n`, 'utf8'); +logger.info( + `Applied overrides to ${CONFIG_PATH}. Top-level keys: ${Object.keys(overrides).join(', ')}` +); diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx index b717f2261..70074dd51 100644 --- a/src/app/features/settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/settings/developer-tools/DevelopTools.tsx @@ -11,6 +11,7 @@ import { copyToClipboard } from '$utils/dom'; import { SequenceCardStyle } from '$features/settings/styles.css'; import { AccountData } from './AccountData'; import { SyncDiagnostics } from './SyncDiagnostics'; +import { ExperimentsPanel } from './ExperimentsPanel'; import { DebugLogViewer } from './DebugLogViewer'; import { SentrySettings } from './SentrySettings'; @@ -115,6 +116,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) { )} {developerTools && } + {developerTools && } {developerTools && ( { + if (!config.experiments) return []; + return Object.entries(config.experiments).map(([key, experimentConfig]) => ({ + key, + config: experimentConfig, + selection: selectExperimentVariant(key, experimentConfig, userId), + })); + }, [config.experiments, userId]); + + if (experiments.length === 0) { + return ( + + Features & Experiments + + No experiments configured + + + ); + } + + return ( + + Features & Experiments + + {experiments.map(({ key, config: experimentConfig, selection }) => ( + + + + + Enabled: + + + {selection.enabled ? 'Yes' : 'No'} + + + {selection.enabled && ( + <> + + + Rollout: + + {selection.rolloutPercentage}% + + + + Your Variant: + + + {selection.variant} + {selection.inExperiment && ' (in experiment)'} + {!selection.inExperiment && ' (control)'} + + + {experimentConfig.variants && experimentConfig.variants.length > 0 && ( + + + Treatment Variants: + + + {experimentConfig.variants + .filter((v) => v !== experimentConfig.controlVariant) + .join(', ')} + + + )} + + )} + + + ))} + + + ); +} diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts index 7fd5f2325..e0a09afa0 100644 --- a/src/app/hooks/useAppVisibility.ts +++ b/src/app/hooks/useAppVisibility.ts @@ -1,23 +1,112 @@ -import { useEffect } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { MatrixClient } from '$types/matrix-sdk'; +import { Session } from '$state/sessions'; import { useAtom } from 'jotai'; import { togglePusher } from '../features/settings/notifications/PushNotifications'; import { appEvents } from '../utils/appEvents'; -import { useClientConfig } from './useClientConfig'; +import { useClientConfig, useExperimentVariant } from './useClientConfig'; import { useSetting } from '../state/hooks/settings'; import { settingsAtom } from '../state/settings'; import { pushSubscriptionAtom } from '../state/pushSubscription'; import { mobileOrTablet } from '../utils/user-agent'; import { createDebugLogger } from '../utils/debugLogger'; +import { pushSessionToSW } from '../../sw-session'; const debugLog = createDebugLogger('AppVisibility'); -export function useAppVisibility(mx: MatrixClient | undefined) { +const DEFAULT_FOREGROUND_DEBOUNCE_MS = 1500; +const DEFAULT_HEARTBEAT_INTERVAL_MS = 10 * 60 * 1000; +const DEFAULT_RESUME_HEARTBEAT_SUPPRESS_MS = 60 * 1000; +const DEFAULT_HEARTBEAT_MAX_BACKOFF_MS = 30 * 60 * 1000; + +export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: Session) { const clientConfig = useClientConfig(); const [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications'); const pushSubAtom = useAtom(pushSubscriptionAtom); const isMobile = mobileOrTablet(); + const sessionSyncConfig = clientConfig.sessionSync; + const sessionSyncVariant = useExperimentVariant('sessionSyncStrategy', activeSession?.userId); + + // Derive phase flags from experiment variant; fall back to direct config when not in experiment. + const inSessionSync = sessionSyncVariant.inExperiment; + const syncVariant = sessionSyncVariant.variant; + const phase1ForegroundResync = inSessionSync + ? syncVariant === 'session-sync-heartbeat' || syncVariant === 'session-sync-adaptive' + : sessionSyncConfig?.phase1ForegroundResync === true; + const phase2VisibleHeartbeat = inSessionSync + ? syncVariant === 'session-sync-heartbeat' || syncVariant === 'session-sync-adaptive' + : sessionSyncConfig?.phase2VisibleHeartbeat === true; + const phase3AdaptiveBackoffJitter = inSessionSync + ? syncVariant === 'session-sync-adaptive' + : sessionSyncConfig?.phase3AdaptiveBackoffJitter === true; + + const foregroundDebounceMs = Math.max( + 0, + sessionSyncConfig?.foregroundDebounceMs ?? DEFAULT_FOREGROUND_DEBOUNCE_MS + ); + const heartbeatIntervalMs = Math.max( + 1000, + sessionSyncConfig?.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS + ); + const resumeHeartbeatSuppressMs = Math.max( + 0, + sessionSyncConfig?.resumeHeartbeatSuppressMs ?? DEFAULT_RESUME_HEARTBEAT_SUPPRESS_MS + ); + const heartbeatMaxBackoffMs = Math.max( + heartbeatIntervalMs, + sessionSyncConfig?.heartbeatMaxBackoffMs ?? DEFAULT_HEARTBEAT_MAX_BACKOFF_MS + ); + + const lastForegroundPushAtRef = useRef(0); + const suppressHeartbeatUntilRef = useRef(0); + const heartbeatFailuresRef = useRef(0); + + const pushSessionNow = useCallback( + (reason: 'foreground' | 'focus' | 'heartbeat'): 'sent' | 'skipped' => { + const baseUrl = activeSession?.baseUrl; + const accessToken = activeSession?.accessToken; + const userId = activeSession?.userId; + const canPush = + !!mx && + typeof baseUrl === 'string' && + typeof accessToken === 'string' && + typeof userId === 'string' && + 'serviceWorker' in navigator && + !!navigator.serviceWorker.controller; + + if (!canPush) { + debugLog.warn('network', 'Skipped SW session sync', { + reason, + hasClient: !!mx, + hasBaseUrl: !!baseUrl, + hasAccessToken: !!accessToken, + hasUserId: !!userId, + hasSwController: !!navigator.serviceWorker?.controller, + }); + return 'skipped'; + } + + pushSessionToSW(baseUrl, accessToken, userId); + debugLog.info('network', 'Pushed session to SW', { + reason, + phase1ForegroundResync, + phase2VisibleHeartbeat, + phase3AdaptiveBackoffJitter, + }); + return 'sent'; + }, + [ + activeSession?.accessToken, + activeSession?.baseUrl, + activeSession?.userId, + mx, + phase1ForegroundResync, + phase2VisibleHeartbeat, + phase3AdaptiveBackoffJitter, + ] + ); + useEffect(() => { const handleVisibilityChange = () => { const isVisible = document.visibilityState === 'visible'; @@ -29,15 +118,56 @@ export function useAppVisibility(mx: MatrixClient | undefined) { appEvents.onVisibilityChange?.(isVisible); if (!isVisible) { appEvents.onVisibilityHidden?.(); + return; + } + + if (!phase1ForegroundResync) return; + + const now = Date.now(); + if (now - lastForegroundPushAtRef.current < foregroundDebounceMs) return; + lastForegroundPushAtRef.current = now; + + if ( + pushSessionNow('foreground') === 'sent' && + phase3AdaptiveBackoffJitter && + phase2VisibleHeartbeat + ) { + suppressHeartbeatUntilRef.current = now + resumeHeartbeatSuppressMs; + } + }; + + const handleFocus = () => { + if (!phase1ForegroundResync) return; + if (document.visibilityState !== 'visible') return; + + const now = Date.now(); + if (now - lastForegroundPushAtRef.current < foregroundDebounceMs) return; + lastForegroundPushAtRef.current = now; + + if ( + pushSessionNow('focus') === 'sent' && + phase3AdaptiveBackoffJitter && + phase2VisibleHeartbeat + ) { + suppressHeartbeatUntilRef.current = now + resumeHeartbeatSuppressMs; } }; document.addEventListener('visibilitychange', handleVisibilityChange); + window.addEventListener('focus', handleFocus); return () => { document.removeEventListener('visibilitychange', handleVisibilityChange); + window.removeEventListener('focus', handleFocus); }; - }, []); + }, [ + foregroundDebounceMs, + phase1ForegroundResync, + phase2VisibleHeartbeat, + phase3AdaptiveBackoffJitter, + pushSessionNow, + resumeHeartbeatSuppressMs, + ]); useEffect(() => { if (!mx) return; @@ -52,4 +182,65 @@ export function useAppVisibility(mx: MatrixClient | undefined) { appEvents.onVisibilityChange = null; }; }, [mx, clientConfig, usePushNotifications, pushSubAtom, isMobile]); + + useEffect(() => { + if (!phase2VisibleHeartbeat) return undefined; + + // Reset adaptive backoff/suppression so a config or session change starts fresh. + heartbeatFailuresRef.current = 0; + suppressHeartbeatUntilRef.current = 0; + + let timeoutId: number | undefined; + + const getDelayMs = (): number => { + let delay = heartbeatIntervalMs; + + if (phase3AdaptiveBackoffJitter) { + const failures = heartbeatFailuresRef.current; + const backoffFactor = Math.min(2 ** failures, heartbeatMaxBackoffMs / heartbeatIntervalMs); + delay = Math.min(heartbeatMaxBackoffMs, Math.round(heartbeatIntervalMs * backoffFactor)); + + // Add +-20% jitter to avoid synchronized heartbeat spikes across many clients. + const jitter = 0.8 + Math.random() * 0.4; + delay = Math.max(1000, Math.round(delay * jitter)); + } + + return delay; + }; + + const tick = () => { + const now = Date.now(); + + if (document.visibilityState !== 'visible' || !navigator.onLine) { + timeoutId = window.setTimeout(tick, getDelayMs()); + return; + } + + if (phase3AdaptiveBackoffJitter && now < suppressHeartbeatUntilRef.current) { + timeoutId = window.setTimeout(tick, getDelayMs()); + return; + } + + const result = pushSessionNow('heartbeat'); + if (phase3AdaptiveBackoffJitter) { + // Only reset on a successful send; 'skipped' (prerequisites not ready) + // should not grow the backoff — those aren't push failures. + if (result === 'sent') heartbeatFailuresRef.current = 0; + } + + timeoutId = window.setTimeout(tick, getDelayMs()); + }; + + timeoutId = window.setTimeout(tick, getDelayMs()); + + return () => { + if (timeoutId !== undefined) window.clearTimeout(timeoutId); + }; + }, [ + heartbeatIntervalMs, + heartbeatMaxBackoffMs, + phase2VisibleHeartbeat, + phase3AdaptiveBackoffJitter, + pushSessionNow, + ]); } diff --git a/src/app/hooks/useClientConfig.test.ts b/src/app/hooks/useClientConfig.test.ts new file mode 100644 index 000000000..5071c5f7c --- /dev/null +++ b/src/app/hooks/useClientConfig.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from 'vitest'; +import { selectExperimentVariant, type ExperimentConfig } from './useClientConfig'; + +const baseExperiment: ExperimentConfig = { + enabled: true, + rolloutPercentage: 100, + controlVariant: 'control', + variants: ['alpha', 'beta'], +}; + +describe('selectExperimentVariant', () => { + it('returns control when experiment is disabled', () => { + const result = selectExperimentVariant( + 'threadUI', + { ...baseExperiment, enabled: false }, + '@alice:example.org' + ); + + expect(result.inExperiment).toBe(false); + expect(result.variant).toBe('control'); + }); + + it('returns control when subject id is missing', () => { + const result = selectExperimentVariant('threadUI', baseExperiment, undefined); + + expect(result.inExperiment).toBe(false); + expect(result.variant).toBe('control'); + }); + + it('returns control when rollout is 0', () => { + const result = selectExperimentVariant( + 'threadUI', + { ...baseExperiment, rolloutPercentage: 0 }, + '@alice:example.org' + ); + + expect(result.inExperiment).toBe(false); + expect(result.variant).toBe('control'); + expect(result.rolloutPercentage).toBe(0); + }); + + it('normalizes rollout less than 0 to 0', () => { + const result = selectExperimentVariant( + 'threadUI', + { ...baseExperiment, rolloutPercentage: -10 }, + '@alice:example.org' + ); + + expect(result.inExperiment).toBe(false); + expect(result.variant).toBe('control'); + expect(result.rolloutPercentage).toBe(0); + }); + + it('normalizes rollout greater than 100 to 100', () => { + const result = selectExperimentVariant( + 'threadUI', + { ...baseExperiment, rolloutPercentage: 999 }, + '@alice:example.org' + ); + + expect(result.inExperiment).toBe(true); + expect(result.rolloutPercentage).toBe(100); + expect(['alpha', 'beta']).toContain(result.variant); + }); + + it('falls back to control when variants are missing after filtering', () => { + const result = selectExperimentVariant( + 'threadUI', + { + ...baseExperiment, + variants: ['', 'control'], + }, + '@alice:example.org' + ); + + expect(result.inExperiment).toBe(false); + expect(result.variant).toBe('control'); + }); + + it('is deterministic for the same key and subject', () => { + const first = selectExperimentVariant('threadUI', baseExperiment, '@alice:example.org'); + const second = selectExperimentVariant('threadUI', baseExperiment, '@alice:example.org'); + + expect(second).toEqual(first); + }); + + it('uses default control variant when none is provided', () => { + const result = selectExperimentVariant( + 'threadUI', + { + enabled: true, + rolloutPercentage: 100, + variants: ['alpha'], + }, + '@alice:example.org' + ); + + expect(result.inExperiment).toBe(true); + expect(result.variant).toBe('alpha'); + }); +}); diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index 87685337d..8d1cbb4ac 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -5,6 +5,21 @@ export type HashRouterConfig = { basename?: string; }; +export type ExperimentConfig = { + enabled?: boolean; + rolloutPercentage?: number; + variants?: string[]; + controlVariant?: string; +}; + +export type ExperimentSelection = { + key: string; + enabled: boolean; + rolloutPercentage: number; + variant: string; + inExperiment: boolean; +}; + export type ClientConfig = { defaultHomeserver?: number; homeserverList?: string[]; @@ -14,6 +29,8 @@ export type ClientConfig = { disableAccountSwitcher?: boolean; hideUsernamePasswordFields?: boolean; + experiments?: Record; + pushNotificationDetails?: { pushNotifyUrl?: string; vapidPublicKey?: string; @@ -42,6 +59,16 @@ export type ClientConfig = { hashRouter?: HashRouterConfig; matrixToBaseUrl?: string; + + sessionSync?: { + phase1ForegroundResync?: boolean; + phase2VisibleHeartbeat?: boolean; + phase3AdaptiveBackoffJitter?: boolean; + foregroundDebounceMs?: number; + heartbeatIntervalMs?: number; + resumeHeartbeatSuppressMs?: number; + heartbeatMaxBackoffMs?: number; + }; }; const ClientConfigContext = createContext(null); @@ -54,6 +81,74 @@ export function useClientConfig(): ClientConfig { return config; } +const DEFAULT_CONTROL_VARIANT = 'control'; + +const normalizeRolloutPercentage = (value?: number): number => { + if (typeof value !== 'number' || Number.isNaN(value)) return 100; + if (value < 0) return 0; + if (value > 100) return 100; + return value; +}; + +const hashToUInt32 = (input: string): number => { + let hash = 0; + for (let index = 0; index < input.length; index += 1) { + hash = (hash * 131 + input.charCodeAt(index)) % 4294967291; + } + return hash; +}; + +export const selectExperimentVariant = ( + key: string, + experiment: ExperimentConfig | undefined, + subjectId: string | undefined +): ExperimentSelection => { + const controlVariant = experiment?.controlVariant ?? DEFAULT_CONTROL_VARIANT; + const variants = (experiment?.variants?.filter((variant) => variant.length > 0) ?? []).filter( + (variant) => variant !== controlVariant + ); + + const enabled = Boolean(experiment?.enabled); + const rolloutPercentage = normalizeRolloutPercentage(experiment?.rolloutPercentage); + + if (!enabled || !subjectId || variants.length === 0 || rolloutPercentage === 0) { + return { + key, + enabled, + rolloutPercentage, + variant: controlVariant, + inExperiment: false, + }; + } + + // Two independent hashes keep rollout and variant assignment stable but decorrelated. + const rolloutBucket = hashToUInt32(`${key}:rollout:${subjectId}`) % 10000; + const rolloutCutoff = Math.floor(rolloutPercentage * 100); + if (rolloutBucket >= rolloutCutoff) { + return { + key, + enabled, + rolloutPercentage, + variant: controlVariant, + inExperiment: false, + }; + } + + const variantIndex = hashToUInt32(`${key}:variant:${subjectId}`) % variants.length; + return { + key, + enabled, + rolloutPercentage, + variant: variants[variantIndex], + inExperiment: true, + }; +}; + +export const useExperimentVariant = (key: string, subjectId?: string): ExperimentSelection => { + const clientConfig = useClientConfig(); + return selectExperimentVariant(key, clientConfig.experiments?.[key], subjectId); +}; + export const clientDefaultServer = (clientConfig: ClientConfig): string => clientConfig.homeserverList?.[clientConfig.defaultHomeserver ?? 0] ?? 'matrix.org'; diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 1a653e950..0d41d5ecf 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -254,7 +254,7 @@ export function ClientRoot({ children }: ClientRootProps) { useSyncNicknames(mx); useLogoutListener(mx); - useAppVisibility(mx); + useAppVisibility(mx, activeSession); useEffect( () => () => {