From 186c81bdfebba3e84e3e4e095efa6e09b297f0c9 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 31 Mar 2026 10:46:35 -0400 Subject: [PATCH 01/16] feat(dev-tools): add rotate-sessions developer tool --- .changeset/devtool-rotate-sessions.md | 5 + .../settings/developer-tools/DevelopTools.tsx | 98 ++++++++++++++++++- 2 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 .changeset/devtool-rotate-sessions.md diff --git a/.changeset/devtool-rotate-sessions.md b/.changeset/devtool-rotate-sessions.md new file mode 100644 index 000000000..cae1bda0c --- /dev/null +++ b/.changeset/devtool-rotate-sessions.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Add rotate-encryption-sessions developer tool to force Megolm session rotation for testing diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx index 100119726..4dfda9a97 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 { 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'; @@ -10,6 +11,7 @@ import type { AccountDataSubmitCallback } from '$components/AccountDataEditor'; import { AccountDataEditor } from '$components/AccountDataEditor'; import { copyToClipboard } from '$utils/dom'; import { SequenceCardStyle } from '$features/settings/styles.css'; +import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; import { SettingsSectionPage } from '../SettingsSectionPage'; import { AccountData } from './AccountData'; import { SyncDiagnostics } from './SyncDiagnostics'; @@ -26,6 +28,49 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp const [expand, setExpend] = useState(false); const [accountDataType, setAccountDataType] = useState(); + 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() === KnownMembership.Join && 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) => { // TODO: remove cast once account data typing is unified. @@ -110,6 +155,57 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp )} {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 && ( Date: Wed, 15 Apr 2026 18:22:49 -0400 Subject: [PATCH 02/16] docs(changeset): clarify Megolm session rotation description Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/devtool-rotate-sessions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/devtool-rotate-sessions.md b/.changeset/devtool-rotate-sessions.md index cae1bda0c..686fb292f 100644 --- a/.changeset/devtool-rotate-sessions.md +++ b/.changeset/devtool-rotate-sessions.md @@ -2,4 +2,4 @@ default: patch --- -Add rotate-encryption-sessions developer tool to force Megolm session rotation for testing +Add developer tool to force-rotate outbound Megolm encryption sessions per room, useful for testing key rotation and bridge session recovery From 70c36b2c45e0920ae4ae8671a141a76bf5599222 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 29 Mar 2026 14:14:26 -0400 Subject: [PATCH 03/16] feat(flags): inject client config from GH environment variables at build Add scripts/inject-client-config.js which reads HOMESERVER_LIST, ELEMENT_CALL_URL, EXPERIMENTS and other config keys from the GH Actions environment and merges them into config.json at build time. CI workflows pass these through via env; the setup action prints an injected-config summary in the job summary. knip.json updated to include the new script as an entry point. --- .github/actions/setup/action.yml | 30 +++++++++ .github/workflows/cloudflare-web-deploy.yml | 8 +++ .github/workflows/cloudflare-web-preview.yml | 4 ++ knip.json | 2 +- scripts/inject-client-config.js | 71 ++++++++++++++++++++ 5 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 scripts/inject-client-config.js 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..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(', ')}` +); From f96423fd5425be875f6db96f87fd66a7a3927098 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 29 Mar 2026 14:14:51 -0400 Subject: [PATCH 04/16] feat(flags): add typed experiment bucketing helper with rollout percentages useClientConfig.ts gains getExperimentVariant() which deterministically buckets a userId into a variant using a hash of userId+experimentName, then checks it against rolloutPercentage. Experiment defaults shape is typed so all callers get compile-time checking of known experiment names. --- src/app/hooks/useClientConfig.ts | 85 ++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index 6cb2a9ad3..540e053a3 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,74 @@ 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); +}; + export const clientDefaultServer = (clientConfig: ClientConfig): string => clientConfig.homeserverList?.[clientConfig.defaultHomeserver ?? 0] ?? 'matrix.org'; From ea9f286c3913b7b6e256db7942e10e3903774958 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 29 Mar 2026 14:14:59 -0400 Subject: [PATCH 05/16] feat(devtools): add Experiments panel to developer tools settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ExperimentsPanel shows every experiment name, current variant, rollout percentage, and whether the user is enrolled — readable without opening the console. DevelopTools.tsx wires it into the developer settings tab. --- .../settings/developer-tools/DevelopTools.tsx | 2 + .../developer-tools/ExperimentsPanel.tsx | 107 ++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 src/app/features/settings/developer-tools/ExperimentsPanel.tsx diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx index 4dfda9a97..ae5602173 100644 --- a/src/app/features/settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/settings/developer-tools/DevelopTools.tsx @@ -15,6 +15,7 @@ import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; import { SettingsSectionPage } from '../SettingsSectionPage'; import { AccountData } from './AccountData'; import { SyncDiagnostics } from './SyncDiagnostics'; +import { ExperimentsPanel } from './ExperimentsPanel'; import { DebugLogViewer } from './DebugLogViewer'; import { SentrySettings } from './SentrySettings'; @@ -155,6 +156,7 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp )} {developerTools && } + {developerTools && } {developerTools && ( Encryption diff --git a/src/app/features/settings/developer-tools/ExperimentsPanel.tsx b/src/app/features/settings/developer-tools/ExperimentsPanel.tsx new file mode 100644 index 000000000..e484b7c5d --- /dev/null +++ b/src/app/features/settings/developer-tools/ExperimentsPanel.tsx @@ -0,0 +1,107 @@ +import { useState, useCallback } from 'react'; +import { Box, Text, Switch, Button } from 'folds'; +import { SequenceCard } from '$components/sequence-card'; +import { SettingTile } from '$components/setting-tile'; +import { SequenceCardStyle } from '$features/settings/styles.css'; +import { useClientConfig, setExperimentOverride } from '$hooks/useClientConfig'; + +const EXPERIMENT_OVERRIDE_PREFIX = 'sable_exp_'; + +function getActiveExperimentKeys(configExperiments?: Record): 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] ?? 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(); + }} + /> + + } + /> + ); + })} + + + ); +} From a9fa51521ce7e28bc7d53373dfd59d5bef6ef204 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 29 Mar 2026 14:15:24 -0400 Subject: [PATCH 06/16] test(flags): cover experiment bucketing and add changeset --- .changeset/feature-flag-env-vars.md | 5 ++ src/app/hooks/useClientConfig.test.ts | 101 ++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 .changeset/feature-flag-env-vars.md create mode 100644 src/app/hooks/useClientConfig.test.ts 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/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'); + }); +}); From 7953f374a96ef83796c5d14c16bdf4110fd66a3b Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 12 Apr 2026 17:33:26 -0400 Subject: [PATCH 07/16] fix(security): block prototype-polluting keys in deepMerge --- scripts/inject-client-config.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/inject-client-config.js b/scripts/inject-client-config.js index b7c62c096..0b5fcd3ad 100644 --- a/scripts/inject-client-config.js +++ b/scripts/inject-client-config.js @@ -15,11 +15,15 @@ const formatError = (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; From bafe036a23610093de5a81c164877450f6c9b007 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 14 May 2026 15:12:13 -0400 Subject: [PATCH 08/16] fix: backport developer tools cleanup --- .../features/settings/developer-tools/DevelopTools.tsx | 5 +++-- src/app/hooks/useClientConfig.ts | 10 ++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx index ae5602173..33a7080a2 100644 --- a/src/app/features/settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/settings/developer-tools/DevelopTools.tsx @@ -19,6 +19,8 @@ import { ExperimentsPanel } from './ExperimentsPanel'; import { DebugLogViewer } from './DebugLogViewer'; import { SentrySettings } from './SentrySettings'; +const JOIN_MEMBERSHIP: string = KnownMembership.Join; + type DeveloperToolsProps = { requestBack?: () => void; requestClose: () => void; @@ -49,8 +51,7 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp const encryptedRooms = mx .getRooms() .filter( - (room) => - room.getMyMembership() === KnownMembership.Join && mx.isRoomEncrypted(room.roomId) + (room) => room.getMyMembership() === JOIN_MEMBERSHIP && mx.isRoomEncrypted(room.roomId) ); const results = await Promise.allSettled( diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index 540e053a3..093b7663c 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -151,6 +151,16 @@ export const useExperimentVariant = (key: string, subjectId?: string): Experimen 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'; From a8625b22d54e56c0fc78ede0c5ca500118d3a833 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 18 May 2026 19:09:56 -0400 Subject: [PATCH 09/16] fix(developer-tools): inject INJECTED_EXPERIMENT_FLAGS into Vite build define block --- vite.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/vite.config.ts b/vite.config.ts index bfa79f67c..b3844754b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -131,6 +131,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: { From c869524c0f2d3c46305cf7469c840f95f8fc2435 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 18 May 2026 19:58:49 -0400 Subject: [PATCH 10/16] feat(developer-tools): add media cache stats and clear button Shows persistent media cache size and file count. The Clear button calls clearMediaCache() (Cache API) and refreshes the displayed stats. clearMediaCache() was already exported from useBlobCache but had no UI. --- .../settings/developer-tools/DevelopTools.tsx | 65 ++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx index 33a7080a2..e0eeb72e6 100644 --- a/src/app/features/settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/settings/developer-tools/DevelopTools.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react'; +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'; @@ -9,6 +9,7 @@ import { settingsAtom } from '$state/settings'; import { useMatrixClient } from '$hooks/useMatrixClient'; import type { AccountDataSubmitCallback } from '$components/AccountDataEditor'; import { AccountDataEditor } from '$components/AccountDataEditor'; +import { clearMediaCache, getBlobCacheStats } from '$hooks/useBlobCache'; import { copyToClipboard } from '$utils/dom'; import { SequenceCardStyle } from '$features/settings/styles.css'; import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; @@ -30,6 +31,18 @@ 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()); + + useEffect(() => { + setCacheStats(getBlobCacheStats()); + }, []); + + const [clearCacheState, clearMediaCacheAction] = useAsyncCallback( + useCallback(async () => { + await clearMediaCache(); + setCacheStats(getBlobCacheStats()); + }, []) + ); const [rotateState, rotateAllSessions] = useAsyncCallback< { rotated: number; total: number }, @@ -209,6 +222,56 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp )} + {developerTools && ( + + Caches + + + ) + } + > + + {clearCacheState.status === AsyncStatus.Loading + ? 'Clearing…' + : 'Clear'} + + + } + > + {clearCacheState.status === AsyncStatus.Success && ( + + Media cache cleared. + + )} + {clearCacheState.status === AsyncStatus.Error && ( + + {clearCacheState.error.message} + + )} + + + + )} {developerTools && ( Date: Mon, 18 May 2026 19:59:33 -0400 Subject: [PATCH 11/16] style: apply oxfmt formatting --- src/app/features/settings/developer-tools/DevelopTools.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx index e0eeb72e6..137e5442e 100644 --- a/src/app/features/settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/settings/developer-tools/DevelopTools.tsx @@ -251,9 +251,7 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp } > - {clearCacheState.status === AsyncStatus.Loading - ? 'Clearing…' - : 'Clear'} + {clearCacheState.status === AsyncStatus.Loading ? 'Clearing…' : 'Clear'} } From f849f178e13f45a57b79f3304752ab4d71058128 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 18 May 2026 20:05:21 -0400 Subject: [PATCH 12/16] fix(developer-tools): define INJECTED_EXPERIMENT_FLAGS in Vite build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit injectedExperimentFlags was referenced in vite.config.ts define block but never declared, causing JSON.stringify(undefined) to return undefined and Vite to skip the substitution entirely — crashing the app with a runtime ReferenceError. Parse VITE_FEATURE_* env vars into the flags object at build time. Add the global declaration to ext.d.ts. Fix ExperimentsPanel to use ExperimentConfig instead of boolean for the config.experiments parameter type (extracts .enabled where needed). Fix useClientConfig non-null array access (variantIndex is always in bounds). --- .../settings/developer-tools/ExperimentsPanel.tsx | 12 ++++++++---- src/app/hooks/useClientConfig.ts | 2 +- src/ext.d.ts | 1 + vite.config.ts | 6 ++++++ 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/app/features/settings/developer-tools/ExperimentsPanel.tsx b/src/app/features/settings/developer-tools/ExperimentsPanel.tsx index e484b7c5d..71fe19a3f 100644 --- a/src/app/features/settings/developer-tools/ExperimentsPanel.tsx +++ b/src/app/features/settings/developer-tools/ExperimentsPanel.tsx @@ -3,11 +3,15 @@ import { Box, Text, Switch, Button } from 'folds'; import { SequenceCard } from '$components/sequence-card'; import { SettingTile } from '$components/setting-tile'; import { SequenceCardStyle } from '$features/settings/styles.css'; -import { useClientConfig, setExperimentOverride } from '$hooks/useClientConfig'; +import { + useClientConfig, + setExperimentOverride, + type ExperimentConfig, +} from '$hooks/useClientConfig'; const EXPERIMENT_OVERRIDE_PREFIX = 'sable_exp_'; -function getActiveExperimentKeys(configExperiments?: Record): string[] { +function getActiveExperimentKeys(configExperiments?: Record): string[] { const fromConfig = Object.keys(configExperiments ?? {}); const fromBuild = Object.keys(INJECTED_EXPERIMENT_FLAGS); const fromStorage = Object.keys(localStorage) @@ -19,12 +23,12 @@ function getActiveExperimentKeys(configExperiments?: Record): s function getEffectiveValue( key: string, - configExperiments?: Record + 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] ?? false, source: 'config' }; + 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' }; diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index 093b7663c..f942ad2bc 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -141,7 +141,7 @@ export const selectExperimentVariant = ( key, enabled, rolloutPercentage, - variant: variants[variantIndex], + variant: variants[variantIndex]!, inExperiment: true, }; }; 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 b3844754b..340bbc473 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -50,6 +50,12 @@ const resolveBuildHash = (): string | undefined => { const appVersion = packageJson.version; const buildHash = resolveBuildHash(); +const injectedExperimentFlags: Record = Object.fromEntries( + Object.entries(process.env) + .filter(([key]) => key.startsWith('VITE_FEATURE_')) + .map(([key, val]) => [key.slice('VITE_FEATURE_'.length), val === 'true' || val === '1']) +); + const isReleaseTag = (() => { const envVal = process.env.VITE_IS_RELEASE_TAG; if (envVal !== undefined && envVal !== '') return envVal === 'true'; From 86d7eaddc07fed8d9eef8dc45b709c1a386d0f7d Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 18 May 2026 20:05:21 -0400 Subject: [PATCH 13/16] fix(developer-tools): define INJECTED_EXPERIMENT_FLAGS in Vite build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit injectedExperimentFlags was referenced in vite.config.ts define block but never declared, causing JSON.stringify(undefined) to return undefined and Vite to skip the substitution entirely — crashing the app with a runtime ReferenceError. Parse VITE_FEATURE_* env vars into the flags object at build time. Add the global declaration to ext.d.ts. Fix ExperimentsPanel to use ExperimentConfig instead of boolean for the config.experiments parameter type (extracts .enabled where needed). Fix useClientConfig non-null array access (variantIndex is always in bounds). --- vite.config.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/vite.config.ts b/vite.config.ts index 340bbc473..7faf3c439 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -52,8 +52,11 @@ const buildHash = resolveBuildHash(); const injectedExperimentFlags: Record = Object.fromEntries( Object.entries(process.env) - .filter(([key]) => key.startsWith('VITE_FEATURE_')) - .map(([key, val]) => [key.slice('VITE_FEATURE_'.length), val === 'true' || val === '1']) + .filter(([k]) => k.startsWith('VITE_FEATURE_')) + .map(([k, v]) => [ + k.slice('VITE_FEATURE_'.length).toLowerCase().replace(/_/g, '-'), + v === 'true' || v === '1', + ]) ); const isReleaseTag = (() => { From 91f20bb005e94d9accaa7b58c13e79057a4fa17c Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 18 May 2026 21:07:33 -0400 Subject: [PATCH 14/16] feat(developer-tools): show all three cache types with individual clear buttons --- .../settings/developer-tools/DevelopTools.tsx | 33 ++- src/app/hooks/useBlobCache.ts | 212 +++++++++++++++++- 2 files changed, 238 insertions(+), 7 deletions(-) diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx index 137e5442e..5422816db 100644 --- a/src/app/features/settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/settings/developer-tools/DevelopTools.tsx @@ -9,7 +9,7 @@ import { settingsAtom } from '$state/settings'; import { useMatrixClient } from '$hooks/useMatrixClient'; import type { AccountDataSubmitCallback } from '$components/AccountDataEditor'; import { AccountDataEditor } from '$components/AccountDataEditor'; -import { clearMediaCache, getBlobCacheStats } from '$hooks/useBlobCache'; +import { clearMediaCache, clearInMemoryBlobCache, getBlobCacheStats } from '$hooks/useBlobCache'; import { copyToClipboard } from '$utils/dom'; import { SequenceCardStyle } from '$features/settings/styles.css'; import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; @@ -44,6 +44,11 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp }, []) ); + const clearInMemoryAction = useCallback(() => { + clearInMemoryBlobCache(); + setCacheStats(getBlobCacheStats()); + }, []); + const [rotateState, rotateAllSessions] = useAsyncCallback< { rotated: number; total: number }, Error, @@ -231,9 +236,26 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp direction="Column" gap="400" > + + Clear + + } + /> {clearCacheState.status === AsyncStatus.Success && ( - Media cache cleared. + Persistent cache cleared. )} {clearCacheState.status === AsyncStatus.Error && ( @@ -267,6 +289,11 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp )} + )} diff --git a/src/app/hooks/useBlobCache.ts b/src/app/hooks/useBlobCache.ts index 96ac18f74..380b4ddf1 100644 --- a/src/app/hooks/useBlobCache.ts +++ b/src/app/hooks/useBlobCache.ts @@ -1,12 +1,198 @@ 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, + }; +} + +/** + * 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 +207,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 +246,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); From ff38ef505d06c7ab36daf144e52d617551a7ce67 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 18 May 2026 22:31:38 -0400 Subject: [PATCH 15/16] fix(dev-tools): async cache stats, add SW tile and descriptions --- .../components/room-avatar/AvatarImage.tsx | 30 ++++- .../settings/developer-tools/DevelopTools.tsx | 103 +++++++++++++++++- src/app/hooks/useBlobCache.ts | 9 ++ 3 files changed, 130 insertions(+), 12 deletions(-) diff --git a/src/app/components/room-avatar/AvatarImage.tsx b/src/app/components/room-avatar/AvatarImage.tsx index f322bce0e..dd8079055 100644 --- a/src/app/components/room-avatar/AvatarImage.tsx +++ b/src/app/components/room-avatar/AvatarImage.tsx @@ -6,6 +6,17 @@ 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; +} + type AvatarImageProps = { src: string; alt?: string; @@ -23,9 +34,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 +63,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 +77,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 5422816db..b9b996c6f 100644 --- a/src/app/features/settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/settings/developer-tools/DevelopTools.tsx @@ -9,10 +9,16 @@ 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 } from '$hooks/useBlobCache'; +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 } from '$components/room-avatar/AvatarImage'; import { SettingsSectionPage } from '../SettingsSectionPage'; import { AccountData } from './AccountData'; import { SyncDiagnostics } from './SyncDiagnostics'; @@ -32,9 +38,29 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp 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(() => { - setCacheStats(getBlobCacheStats()); + // 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( @@ -49,6 +75,13 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp setCacheStats(getBlobCacheStats()); }, []); + 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, @@ -99,6 +132,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)} /> ); @@ -236,10 +284,15 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp direction="Column" gap="400" > + } /> + + ) + } + > + + {clearSwCacheState.status === AsyncStatus.Loading + ? 'Clearing…' + : 'Clear'} + + + } + > + {clearSwCacheState.status === AsyncStatus.Success && ( + + Service worker cache cleared. + + )} + {clearSwCacheState.status === AsyncStatus.Error && ( + + {clearSwCacheState.error.message} + + )} + diff --git a/src/app/hooks/useBlobCache.ts b/src/app/hooks/useBlobCache.ts index 380b4ddf1..381e5537c 100644 --- a/src/app/hooks/useBlobCache.ts +++ b/src/app/hooks/useBlobCache.ts @@ -189,6 +189,15 @@ export function getBlobCacheStats(): { }; } +/** + * 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. From d571afe06fbc67f67da499d26cc5f3ec1a7312ba Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 14:25:22 -0400 Subject: [PATCH 16/16] chore: consolidate changeset --- .changeset/developer-tools.md | 5 +++++ .changeset/devtool-rotate-sessions.md | 5 ----- .changeset/feature-flag-env-vars.md | 5 ----- 3 files changed, 5 insertions(+), 10 deletions(-) create mode 100644 .changeset/developer-tools.md delete mode 100644 .changeset/devtool-rotate-sessions.md delete mode 100644 .changeset/feature-flag-env-vars.md 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/.changeset/devtool-rotate-sessions.md b/.changeset/devtool-rotate-sessions.md deleted file mode 100644 index 686fb292f..000000000 --- a/.changeset/devtool-rotate-sessions.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -default: patch ---- - -Add developer tool to force-rotate outbound Megolm encryption sessions per room, useful for testing key rotation and bridge session recovery diff --git a/.changeset/feature-flag-env-vars.md b/.changeset/feature-flag-env-vars.md deleted file mode 100644 index 25d7d7d01..000000000 --- a/.changeset/feature-flag-env-vars.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -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.