diff --git a/.changeset/developer-tools.md b/.changeset/developer-tools.md
new file mode 100644
index 000000000..b2bcf5229
--- /dev/null
+++ b/.changeset/developer-tools.md
@@ -0,0 +1,5 @@
+---
+default: minor
+---
+
+Add build-time experiment flag injection, typed deterministic bucketing helpers, and a DevTools panel to force-rotate Megolm encryption sessions per room.
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/knip.json b/knip.json
index 6cc8c8581..83f45fc19 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"],
"ignore": ["oxlint.config.ts", "oxfmt.config.ts"],
"ignoreExportsUsedInFile": {
"interface": true,
diff --git a/scripts/inject-client-config.js b/scripts/inject-client-config.js
new file mode 100644
index 000000000..0b5fcd3ad
--- /dev/null
+++ b/scripts/inject-client-config.js
@@ -0,0 +1,75 @@
+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);
+
+// Keys that could trigger prototype pollution via bracket assignment.
+const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
+
+const deepMerge = (target, source) => {
+ if (!isPlainObject(target) || !isPlainObject(source)) return source;
+
+ const merged = { ...target };
+ Object.entries(source).forEach(([key, value]) => {
+ if (UNSAFE_KEYS.has(key)) return;
+ 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/components/AccountDataEditor.tsx b/src/app/components/AccountDataEditor.tsx
index 131ae7fab..994f30f20 100644
--- a/src/app/components/AccountDataEditor.tsx
+++ b/src/app/components/AccountDataEditor.tsx
@@ -197,8 +197,9 @@ type AccountDataViewProps = {
type: string;
defaultContent: string;
onEdit: () => void;
+ onDelete?: () => void;
};
-function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps) {
+function AccountDataView({ type, defaultContent, onEdit, onDelete }: AccountDataViewProps) {
return (
Edit
+ {onDelete && (
+
+ )}
JSON Content
@@ -246,6 +252,7 @@ export type AccountDataEditorProps = {
type?: string;
content?: object;
submitChange: AccountDataSubmitCallback;
+ onDelete?: () => void;
requestClose: () => void;
};
@@ -253,6 +260,7 @@ export function AccountDataEditor({
type,
content,
submitChange,
+ onDelete,
requestClose,
}: AccountDataEditorProps) {
const [data, setData] = useState({
@@ -315,6 +323,7 @@ export function AccountDataEditor({
type={data.type}
defaultContent={contentJSONStr}
onEdit={() => setEdit(true)}
+ onDelete={onDelete}
/>
)}
diff --git a/src/app/components/room-avatar/AvatarImage.tsx b/src/app/components/room-avatar/AvatarImage.tsx
index f322bce0e..ca31343a5 100644
--- a/src/app/components/room-avatar/AvatarImage.tsx
+++ b/src/app/components/room-avatar/AvatarImage.tsx
@@ -6,6 +6,22 @@ import { settingsAtom } from '$state/settings';
import { useSetting } from '$state/hooks/settings';
import * as css from './RoomAvatar.css';
+// Module-level cache: maps a Matrix media URL → processed blob URL so that
+// SVG processing only runs once per unique image, even as virtual-list items
+// unmount and remount. MXC URLs are content-addressed and never change, so
+// the mapping is stable for the lifetime of the page.
+const svgBlobCache = new Map();
+
+/** Number of SVG blob URLs currently held in the module-level cache. */
+export function getSvgCacheSize(): number {
+ return svgBlobCache.size;
+}
+
+/** Clear all SVG blob URLs from the module-level cache. */
+export function clearSvgBlobCache(): void {
+ svgBlobCache.clear();
+}
+
type AvatarImageProps = {
src: string;
alt?: string;
@@ -23,9 +39,15 @@ export function AvatarImage({ src, alt, uniformIcons, onError }: AvatarImageProp
useEffect(() => {
let isMounted = true;
- let objectUrl: string | null = null;
const processImage = async () => {
+ // Return the cached blob URL immediately — no network round-trip needed.
+ const cachedBlobUrl = svgBlobCache.get(src);
+ if (cachedBlobUrl) {
+ setProcessedSrc(cachedBlobUrl);
+ return;
+ }
+
try {
const res = await fetch(src, { mode: 'cors' });
const contentType = res.headers.get('content-type');
@@ -46,8 +68,10 @@ export function AvatarImage({ src, alt, uniformIcons, onError }: AvatarImageProp
const newSvgString = serializer.serializeToString(doc);
const blob = new Blob([newSvgString], { type: 'image/svg+xml' });
- objectUrl = URL.createObjectURL(blob);
- if (isMounted) setProcessedSrc(objectUrl);
+ const blobUrl = URL.createObjectURL(blob);
+ // Store in module cache so future remounts skip processing.
+ svgBlobCache.set(src, blobUrl);
+ if (isMounted) setProcessedSrc(blobUrl);
} else if (isMounted) setProcessedSrc(src);
} catch {
if (isMounted) setProcessedSrc(src);
@@ -58,9 +82,8 @@ export function AvatarImage({ src, alt, uniformIcons, onError }: AvatarImageProp
return () => {
isMounted = false;
- if (objectUrl) {
- URL.revokeObjectURL(objectUrl);
- }
+ // Blob URLs are retained in svgBlobCache — do not revoke them here so
+ // that subsequent remounts can use the cached result without re-fetching.
};
}, [src]);
diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx
index 100119726..4ae4a9881 100644
--- a/src/app/features/settings/developer-tools/DevelopTools.tsx
+++ b/src/app/features/settings/developer-tools/DevelopTools.tsx
@@ -1,5 +1,6 @@
-import { useCallback, useState } from 'react';
-import { Box, Text, Scroll, Switch, Button } from 'folds';
+import { useCallback, useEffect, useState } from 'react';
+import { Box, Text, Scroll, Switch, Button, Spinner, color } from 'folds';
+import { KnownMembership } from '$types/matrix-sdk';
import { PageContent } from '$components/page';
import { SequenceCard } from '$components/sequence-card';
import { SettingTile } from '$components/setting-tile';
@@ -8,14 +9,25 @@ import { settingsAtom } from '$state/settings';
import { useMatrixClient } from '$hooks/useMatrixClient';
import type { AccountDataSubmitCallback } from '$components/AccountDataEditor';
import { AccountDataEditor } from '$components/AccountDataEditor';
+import {
+ clearMediaCache,
+ clearInMemoryBlobCache,
+ getBlobCacheStats,
+ getBlobCacheStatsAsync,
+} from '$hooks/useBlobCache';
import { copyToClipboard } from '$utils/dom';
import { SequenceCardStyle } from '$features/settings/styles.css';
+import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
+import { getSvgCacheSize, clearSvgBlobCache } from '$components/room-avatar/AvatarImage';
import { SettingsSectionPage } from '../SettingsSectionPage';
import { AccountData } from './AccountData';
import { SyncDiagnostics } from './SyncDiagnostics';
+import { ExperimentsPanel } from './ExperimentsPanel';
import { DebugLogViewer } from './DebugLogViewer';
import { SentrySettings } from './SentrySettings';
+const JOIN_MEMBERSHIP: string = KnownMembership.Join;
+
type DeveloperToolsProps = {
requestBack?: () => void;
requestClose: () => void;
@@ -25,6 +37,97 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp
const [developerTools, setDeveloperTools] = useSetting(settingsAtom, 'developerTools');
const [expand, setExpend] = useState(false);
const [accountDataType, setAccountDataType] = useState();
+ const [cacheStats, setCacheStats] = useState(() => getBlobCacheStats());
+ const [svgCacheSize, setSvgCacheSize] = useState(0);
+ const [swCacheStats, setSwCacheStats] = useState({ count: 0, sizeMB: 0 });
+
+ useEffect(() => {
+ // Async-load persistent cache metadata (requires Cache API) and SVG cache size
+ getBlobCacheStatsAsync()
+ .then(setCacheStats)
+ .catch(() => undefined);
+ setSvgCacheSize(getSvgCacheSize());
+ // Read SW media cache from page context (same origin, shared with the SW)
+ caches
+ .open('sable-media-sw-v1')
+ .then(async (cache) => {
+ const requests = await cache.keys();
+ const responses = await Promise.all(requests.map((req) => cache.match(req)));
+ const totalBytes = responses.reduce((sum, resp) => {
+ if (!resp) return sum;
+ const cl = resp.headers.get('content-length');
+ return cl ? sum + parseInt(cl, 10) : sum;
+ }, 0);
+ setSwCacheStats({ count: requests.length, sizeMB: totalBytes / (1024 * 1024) });
+ })
+ .catch(() => undefined);
+ }, []);
+
+ const [clearCacheState, clearMediaCacheAction] = useAsyncCallback(
+ useCallback(async () => {
+ await clearMediaCache();
+ setCacheStats(getBlobCacheStats());
+ }, [])
+ );
+
+ const clearInMemoryAction = useCallback(() => {
+ clearInMemoryBlobCache();
+ setCacheStats(getBlobCacheStats());
+ }, []);
+
+ const clearSvgCacheAction = useCallback(() => {
+ clearSvgBlobCache();
+ setSvgCacheSize(getSvgCacheSize());
+ }, []);
+
+ const [clearSwCacheState, clearSwCacheAction] = useAsyncCallback(
+ useCallback(async () => {
+ await caches.delete('sable-media-sw-v1');
+ setSwCacheStats({ count: 0, sizeMB: 0 });
+ }, [])
+ );
+
+ const [rotateState, rotateAllSessions] = useAsyncCallback<
+ { rotated: number; total: number },
+ Error,
+ []
+ >(
+ useCallback(async () => {
+ if (
+ !window.confirm(
+ 'This will discard all current Megolm encryption sessions and start new ones. Continue?'
+ )
+ ) {
+ throw new Error('Cancelled');
+ }
+
+ const crypto = mx.getCrypto();
+ if (!crypto) throw new Error('Crypto module not available');
+
+ const encryptedRooms = mx
+ .getRooms()
+ .filter(
+ (room) => room.getMyMembership() === JOIN_MEMBERSHIP && mx.isRoomEncrypted(room.roomId)
+ );
+
+ const results = await Promise.allSettled(
+ encryptedRooms.map((room) => crypto.forceDiscardSession(room.roomId))
+ );
+ const rotated = results.filter((r) => r.status === 'fulfilled').length;
+
+ // Proactively start session creation + key sharing with all devices
+ // (including bridge bots). fire-and-forget per room, but surface failures.
+ encryptedRooms.forEach((room) => {
+ Promise.resolve()
+ .then(() => crypto.prepareToEncrypt(room))
+ .catch((error) => {
+ console.error('Failed to prepare room encryption', room.roomId, error);
+ });
+ });
+
+ return { rotated, total: encryptedRooms.length };
+ }, [mx])
+ );
const submitAccountData: AccountDataSubmitCallback = useCallback(
async (type, content) => {
@@ -34,6 +137,20 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp
[mx]
);
+ const deleteAccountData = useCallback(
+ (type: string) => {
+ if (
+ !window.confirm(
+ `Delete account data '${type}'?\n\nNote: Matrix does not support deleting account data events. This will overwrite the content with an empty object {}. The event type key will remain.`
+ )
+ )
+ return;
+ // as never: developer tools delete arbitrary account data types beyond the typed enum.
+ mx.setAccountData(type as never, {} as never).then(() => setAccountDataType(undefined));
+ },
+ [mx]
+ );
+
if (accountDataType !== undefined) {
return (
deleteAccountData(accountDataType) : undefined}
requestClose={() => setAccountDataType(undefined)}
/>
);
@@ -110,6 +228,183 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp
)}
{developerTools && }
+ {developerTools && }
+ {developerTools && (
+
+ Encryption
+
+
+ )
+ }
+ >
+
+ {rotateState.status === AsyncStatus.Loading ? 'Rotating…' : 'Rotate'}
+
+
+ }
+ >
+ {rotateState.status === AsyncStatus.Success && (
+
+ Sessions discarded for {rotateState.data.rotated} of{' '}
+ {rotateState.data.total} encrypted rooms. Key sharing is starting in the
+ background — send a message in an affected room to confirm delivery to
+ bridges.
+
+ )}
+ {rotateState.status === AsyncStatus.Error && (
+
+ {rotateState.error.message}
+
+ )}
+
+
+
+ )}
+ {developerTools && (
+
+ Caches
+
+
+ Clear
+
+ }
+ />
+
+ Clear
+
+ }
+ />
+
+ )
+ }
+ >
+
+ {clearSwCacheState.status === AsyncStatus.Loading
+ ? 'Clearing…'
+ : 'Clear'}
+
+
+ }
+ >
+ {clearSwCacheState.status === AsyncStatus.Success && (
+
+ Service worker cache cleared.
+
+ )}
+ {clearSwCacheState.status === AsyncStatus.Error && (
+
+ {clearSwCacheState.error.message}
+
+ )}
+
+
+ )
+ }
+ >
+
+ {clearCacheState.status === AsyncStatus.Loading ? 'Clearing…' : 'Clear'}
+
+
+ }
+ >
+ {clearCacheState.status === AsyncStatus.Success && (
+
+ Persistent cache cleared.
+
+ )}
+ {clearCacheState.status === AsyncStatus.Error && (
+
+ {clearCacheState.error.message}
+
+ )}
+
+
+
+
+ )}
{developerTools && (
): string[] {
+ const fromConfig = Object.keys(configExperiments ?? {});
+ const fromBuild = Object.keys(INJECTED_EXPERIMENT_FLAGS);
+ const fromStorage = Object.keys(localStorage)
+ .filter((k) => k.startsWith(EXPERIMENT_OVERRIDE_PREFIX))
+ .map((k) => k.slice(EXPERIMENT_OVERRIDE_PREFIX.length));
+
+ return Array.from(new Set([...fromConfig, ...fromBuild, ...fromStorage])).toSorted();
+}
+
+function getEffectiveValue(
+ key: string,
+ configExperiments?: Record
+): { value: boolean; source: 'override' | 'config' | 'build' | 'default' } {
+ const lsValue = localStorage.getItem(`${EXPERIMENT_OVERRIDE_PREFIX}${key}`);
+ if (lsValue !== null) return { value: lsValue === 'true', source: 'override' };
+ if (configExperiments && key in configExperiments)
+ return { value: configExperiments[key]?.enabled ?? false, source: 'config' };
+ if (key in INJECTED_EXPERIMENT_FLAGS)
+ return { value: INJECTED_EXPERIMENT_FLAGS[key] ?? false, source: 'build' };
+ return { value: false, source: 'default' };
+}
+
+export function ExperimentsPanel() {
+ const config = useClientConfig();
+ const [, forceUpdate] = useState(0);
+ const refresh = useCallback(() => forceUpdate((n) => n + 1), []);
+
+ const keys = getActiveExperimentKeys(config.experiments);
+
+ if (keys.length === 0) {
+ return (
+
+ Experiments
+
+ No experiment flags are defined. Set VITE_FEATURE_* env vars at build time or
+ add an experiments field to config.json.
+
+
+ );
+ }
+
+ return (
+
+ Experiments
+
+ Override experiment flags for this session. Changes are stored in localStorage and take
+ effect immediately on next render.
+
+
+ {keys.map((key) => {
+ const { value, source } = getEffectiveValue(key, config.experiments);
+ const hasOverride = source === 'override';
+ return (
+
+ {hasOverride && (
+
+ )}
+ {
+ setExperimentOverride(key, v);
+ refresh();
+ }}
+ />
+
+ }
+ />
+ );
+ })}
+
+
+ );
+}
diff --git a/src/app/hooks/useBlobCache.ts b/src/app/hooks/useBlobCache.ts
index 96ac18f74..381e5537c 100644
--- a/src/app/hooks/useBlobCache.ts
+++ b/src/app/hooks/useBlobCache.ts
@@ -1,12 +1,207 @@
import { useState, useEffect } from 'react';
+const CACHE_NAME = 'sable-media-v1';
+const MAX_CACHE_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
+const MAX_CACHE_SIZE_MB = 500; // Configurable limit
+
const imageBlobCache = new Map();
const inflightRequests = new Map>();
-export function getBlobCacheStats(): { cacheSize: number; inflightCount: number } {
- return { cacheSize: imageBlobCache.size, inflightCount: inflightRequests.size };
+type CacheMetadata = {
+ url: string;
+ size: number;
+ cachedAt: number;
+};
+
+let cacheMetadata: CacheMetadata[] = [];
+let metadataLoaded = false;
+
+/**
+ * Open the Cache API storage for media blobs.
+ * Persistent across page reloads and shared between tabs.
+ */
+async function openMediaCache(): Promise {
+ return await caches.open(CACHE_NAME);
+}
+
+/**
+ * Load cache metadata from Cache API headers.
+ * This tracks size and age for eviction logic.
+ */
+async function loadCacheMetadata(): Promise {
+ if (metadataLoaded) return;
+
+ try {
+ const cache = await openMediaCache();
+ const requests = await cache.keys();
+
+ const metadataPromises = requests.map(async (request) => {
+ const response = await cache.match(request);
+ if (!response) return null;
+
+ const cachedAt = parseInt(response.headers.get('X-Cached-At') ?? '0', 10);
+ const size = parseInt(response.headers.get('X-Size') ?? '0', 10);
+
+ return {
+ url: request.url,
+ size,
+ cachedAt,
+ };
+ });
+
+ const metadata = (await Promise.all(metadataPromises)).filter(
+ (m): m is CacheMetadata => m !== null
+ );
+
+ cacheMetadata = metadata.toSorted((a, b) => a.cachedAt - b.cachedAt); // LRU order
+ metadataLoaded = true;
+ } catch {
+ // Cache API unavailable — metadata stays empty
+ }
+}
+
+/**
+ * Store media blob in Cache API with metadata headers.
+ * Runs cache size check and eviction if needed.
+ */
+async function cacheMedia(url: string, blob: Blob): Promise {
+ try {
+ await loadCacheMetadata();
+
+ const cache = await openMediaCache();
+ const response = new Response(blob, {
+ headers: {
+ 'Content-Type': blob.type,
+ 'X-Cached-At': Date.now().toString(),
+ 'X-Size': blob.size.toString(),
+ },
+ });
+
+ await cache.put(url, response);
+
+ // Update metadata
+ cacheMetadata.push({
+ url,
+ size: blob.size,
+ cachedAt: Date.now(),
+ });
+
+ // Check size and evict if needed
+ await evictIfNeeded();
+ } catch {
+ // Cache write failed — continue without persistent cache
+ }
+}
+
+/**
+ * Retrieve media blob from Cache API.
+ * Returns undefined if not cached or expired.
+ */
+async function getCachedMedia(url: string): Promise {
+ try {
+ const cache = await openMediaCache();
+ const response = await cache.match(url);
+ if (!response) return undefined;
+
+ // Check expiry
+ const cachedAt = parseInt(response.headers.get('X-Cached-At') ?? '0', 10);
+ if (Date.now() - cachedAt > MAX_CACHE_AGE_MS) {
+ cache.delete(url); // Expired
+ cacheMetadata = cacheMetadata.filter((m) => m.url !== url);
+ return undefined;
+ }
+
+ return await response.blob();
+ } catch {
+ return undefined;
+ }
+}
+
+/**
+ * Evict oldest entries if cache exceeds size limit.
+ * Uses LRU (Least Recently Used) eviction strategy.
+ */
+async function evictIfNeeded(): Promise {
+ const totalSizeBytes = cacheMetadata.reduce((sum, m) => sum + m.size, 0);
+ const totalSizeMB = totalSizeBytes / (1024 * 1024);
+
+ if (totalSizeMB <= MAX_CACHE_SIZE_MB) return;
+
+ try {
+ const cache = await openMediaCache();
+ const toEvict = Math.ceil(cacheMetadata.length * 0.1); // Evict 10% of entries
+
+ const toDelete: CacheMetadata[] = [];
+ for (let i = 0; i < toEvict && cacheMetadata.length > 0; i++) {
+ const oldest = cacheMetadata.shift();
+ if (oldest) {
+ toDelete.push(oldest);
+ }
+ }
+
+ // Delete all in parallel
+ await Promise.all(toDelete.map((m) => cache.delete(m.url)));
+ } catch {
+ // Eviction failed — continue anyway
+ }
+}
+
+/**
+ * Clear the in-memory blob cache and any in-flight fetch requests.
+ * Does not affect persistent Cache API storage.
+ */
+export function clearInMemoryBlobCache(): void {
+ imageBlobCache.clear();
+ inflightRequests.clear();
+}
+
+/**
+ * Clear all media from persistent cache.
+ * Also clears the in-memory cache.
+ * Useful for "Clear Cache" settings option.
+ */
+export async function clearMediaCache(): Promise {
+ try {
+ await caches.delete(CACHE_NAME);
+ cacheMetadata = [];
+ metadataLoaded = false;
+ clearInMemoryBlobCache();
+ } catch {
+ // Cache clear failed — silent ignore
+ }
+}
+
+/**
+ * Get cache statistics for metrics/debugging.
+ */
+export function getBlobCacheStats(): {
+ cacheSize: number;
+ inflightCount: number;
+ persistentCacheSizeMB: number;
+ persistentCacheCount: number;
+} {
+ const totalSizeBytes = cacheMetadata.reduce((sum, m) => sum + m.size, 0);
+ return {
+ cacheSize: imageBlobCache.size,
+ inflightCount: inflightRequests.size,
+ persistentCacheSizeMB: totalSizeBytes / (1024 * 1024),
+ persistentCacheCount: cacheMetadata.length,
+ };
}
+/**
+ * Async version of getBlobCacheStats that first ensures cache metadata is
+ * loaded from the Cache API. Use this in settings/diagnostics panels.
+ */
+export async function getBlobCacheStatsAsync(): Promise> {
+ await loadCacheMetadata();
+ return getBlobCacheStats();
+}
+
+/**
+ * Hook to fetch and cache media blobs with persistent storage.
+ * Checks in-memory cache first, then Cache API, then fetches from network.
+ */
export function useBlobCache(url?: string): string | undefined {
const [cacheState, setCacheState] = useState<{ sourceUrl?: string; blobUrl?: string }>({
sourceUrl: url,
@@ -21,23 +216,38 @@ export function useBlobCache(url?: string): string | undefined {
}
useEffect(() => {
- if (!url || imageBlobCache.has(url)) return undefined;
+ if (!url) return undefined;
+
+ // Check memory cache first (instant)
+ if (imageBlobCache.has(url)) {
+ return undefined;
+ }
let isMounted = true;
const fetchBlob = async () => {
+ // Check if another component is already fetching this URL
if (inflightRequests.has(url)) {
try {
const existingBlobUrl = await inflightRequests.get(url);
if (isMounted) setCacheState({ sourceUrl: url, blobUrl: existingBlobUrl });
} catch {
- // Inflight request failed, silently ignore (consistent with fetchBlob behavior)
+ // Inflight request failed, silently ignore
}
return;
}
const requestPromise = (async () => {
try {
+ // Check persistent cache (fast, survives reloads)
+ const cachedBlob = await getCachedMedia(url);
+ if (cachedBlob) {
+ const objectUrl = URL.createObjectURL(cachedBlob);
+ imageBlobCache.set(url, objectUrl);
+ return objectUrl;
+ }
+
+ // Fetch from network (slow)
const res = await fetch(url, { mode: 'cors' });
if (!res.ok) {
throw new Error(`Failed to fetch blob: ${res.status} ${res.statusText}`);
@@ -45,7 +255,10 @@ export function useBlobCache(url?: string): string | undefined {
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
+ // Store in both caches
imageBlobCache.set(url, objectUrl);
+ cacheMedia(url, blob); // Non-blocking persistent storage
+
return objectUrl;
} catch (e) {
inflightRequests.delete(url);
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 6cb2a9ad3..f942ad2bc 100644
--- a/src/app/hooks/useClientConfig.ts
+++ b/src/app/hooks/useClientConfig.ts
@@ -7,6 +7,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[];
@@ -16,6 +31,8 @@ export type ClientConfig = {
disableAccountSwitcher?: boolean;
hideUsernamePasswordFields?: boolean;
+ experiments?: Record;
+
pushNotificationDetails?: {
pushNotifyUrl?: string;
vapidPublicKey?: string;
@@ -66,6 +83,84 @@ export function useOptionalClientConfig(): ClientConfig | null {
return useContext(ClientConfigContext);
}
+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);
+};
+
+const EXPERIMENT_OVERRIDE_PREFIX = 'sable_exp_';
+
+export const setExperimentOverride = (key: string, value: boolean | null): void => {
+ if (value === null) {
+ localStorage.removeItem(`${EXPERIMENT_OVERRIDE_PREFIX}${key}`);
+ } else {
+ localStorage.setItem(`${EXPERIMENT_OVERRIDE_PREFIX}${key}`, String(value));
+ }
+};
+
export const clientDefaultServer = (clientConfig: ClientConfig): string =>
clientConfig.homeserverList?.[clientConfig.defaultHomeserver ?? 0] ?? 'matrix.org';
diff --git a/src/ext.d.ts b/src/ext.d.ts
index 7ee0a20a8..cbded3fb2 100644
--- a/src/ext.d.ts
+++ b/src/ext.d.ts
@@ -3,6 +3,7 @@
declare const APP_VERSION: string;
declare const BUILD_HASH: string;
declare const IS_RELEASE_TAG: boolean;
+declare const INJECTED_EXPERIMENT_FLAGS: Record;
declare module 'browser-encrypt-attachment' {
export interface EncryptedAttachmentInfo {
diff --git a/vite.config.ts b/vite.config.ts
index bfa79f67c..7faf3c439 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -50,6 +50,15 @@ const resolveBuildHash = (): string | undefined => {
const appVersion = packageJson.version;
const buildHash = resolveBuildHash();
+const injectedExperimentFlags: Record = Object.fromEntries(
+ Object.entries(process.env)
+ .filter(([k]) => k.startsWith('VITE_FEATURE_'))
+ .map(([k, v]) => [
+ k.slice('VITE_FEATURE_'.length).toLowerCase().replace(/_/g, '-'),
+ v === 'true' || v === '1',
+ ])
+);
+
const isReleaseTag = (() => {
const envVal = process.env.VITE_IS_RELEASE_TAG;
if (envVal !== undefined && envVal !== '') return envVal === 'true';
@@ -131,6 +140,7 @@ export default defineConfig(({ command }) => ({
APP_VERSION: JSON.stringify(appVersion),
BUILD_HASH: JSON.stringify(buildHash ?? ''),
IS_RELEASE_TAG: JSON.stringify(isReleaseTag),
+ INJECTED_EXPERIMENT_FLAGS: JSON.stringify(injectedExperimentFlags),
},
resolve: {
alias: {