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(
() => () => {