feat(dev-tools): rotate sessions tool and experiment feature flags#700
Open
Just-Insane wants to merge 17 commits into
Open
feat(dev-tools): rotate sessions tool and experiment feature flags#700Just-Insane wants to merge 17 commits into
Just-Insane wants to merge 17 commits into
Conversation
d82f8e4 to
0625646
Compare
3dc1788 to
90967a2
Compare
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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.
…ntages 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.
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.
e551b10 to
bafe036
Compare
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.
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).
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).
AccountDataEditor was called with onDelete in DevelopTools.tsx but the prop was missing from the type definition. Add onDelete?: () => void to AccountDataEditorProps and wire it to a Delete button in AccountDataView.
Contributor
There was a problem hiding this comment.
Pull request overview
Adds developer tooling for experimenting with feature flags/experiments and for forcing Matrix encryption session rotation, plus introduces build-time client config injection used by CI deployments.
Changes:
- Add deterministic experiment bucketing helpers (
selectExperimentVariant/useExperimentVariant) with a DevTools Experiments panel and build-time injected experiment flags. - Add DevTools action to rotate Megolm sessions across joined encrypted rooms (discard +
prepareToEncrypt). - Add CI-time
config.jsonoverride injection viaCLIENT_CONFIG_OVERRIDES_JSON, plus new media/SVG cache diagnostics surfaced in DevTools.
Reviewed changes
Copilot reviewed 14 out of 15 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
vite.config.ts |
Adds build-time injection of INJECTED_EXPERIMENT_FLAGS via define. |
src/ext.d.ts |
Declares global INJECTED_EXPERIMENT_FLAGS type for TS. |
src/app/hooks/useClientConfig.ts |
Introduces experiment config types + deterministic variant selection hook/utilities. |
src/app/hooks/useClientConfig.test.ts |
Adds unit tests for experiment variant selection behavior. |
src/app/hooks/useBlobCache.ts |
Expands blob caching to include persistent Cache API storage + stats/clearing helpers. |
src/app/features/settings/developer-tools/ExperimentsPanel.tsx |
New DevTools UI for listing/toggling experiment flags. |
src/app/features/settings/developer-tools/DevelopTools.tsx |
Adds Rotate Sessions action, experiments panel, and cache diagnostics/clear actions; adds account-data delete flow. |
src/app/components/room-avatar/AvatarImage.tsx |
Adds module-level SVG processed blob URL cache and exposes size getter. |
src/app/components/AccountDataEditor.tsx |
Adds optional Delete button plumbing to the account data editor view. |
scripts/inject-client-config.js |
New build script to deep-merge env-provided JSON into config.json with prototype-pollution guard. |
knip.json |
Marks the new script as an entrypoint for unused-code detection. |
.github/workflows/cloudflare-web-preview.yml |
Passes config override env vars and sets preview environment. |
.github/workflows/cloudflare-web-deploy.yml |
Passes config override env vars; sets preview/production environments. |
.github/actions/setup/action.yml |
Runs config injection + prints injected experiments summary during CI setup. |
.changeset/developer-tools.md |
Declares a minor release for the added dev-tools functionality. |
Comments suppressed due to low confidence (5)
src/app/hooks/useBlobCache.ts:88
cacheMetadata.push(...)doesn’t dedupe/update existing entries when the same URL is cached multiple times, sopersistentCacheSizeMB/eviction decisions can drift over time (duplicates inflate size; eviction may delete already-deleted URLs). Before pushing, remove any existing metadata entry for the URL (or store metadata in a Map keyed by URL).
await cache.put(url, response);
// Update metadata
cacheMetadata.push({
url,
size: blob.size,
cachedAt: Date.now(),
});
src/app/hooks/useBlobCache.ts:112
- When expiring an entry,
cache.delete(url)is not awaited. Ifcache.deleterejects (or is slow), the error won’t be caught by thistry/catch, and the delete may race with subsequent reads. Useawait cache.delete(url)(or explicitlyvoid cache.delete(url).catch(...)) so failures are handled consistently.
// 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;
}
src/app/hooks/useBlobCache.ts:133
evictIfNeededevicts a fixed 10% of entries once, but the cache can still remain overMAX_CACHE_SIZE_MB(especially after large inserts). Consider evicting in a loop until under the limit (or evict based on bytes removed) so the size cap is actually enforced.
async function evictIfNeeded(): Promise<void> {
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
src/app/hooks/useBlobCache.ts:224
useEffectexits early whenimageBlobCache.has(url)is true, butcacheState.blobUrlcan still be undefined if the cache was populated between render and the effect running. Consider settingcacheStatefromimageBlobCache.get(url)in this branch to avoid the hook getting stuck on the original URL.
useEffect(() => {
if (!url) return undefined;
// Check memory cache first (instant)
if (imageBlobCache.has(url)) {
return undefined;
}
src/app/hooks/useBlobCache.ts:234
inflightRequests.get(url)is typed as possibly undefined; if the map is mutated unexpectedly this can result inexistingBlobUrlbeing undefined and stored into state. Use a non-null assertion / early return whenget(url)returns undefined to keep state consistent.
// 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 {
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+53
to
+59
| const injectedExperimentFlags: Record<string, boolean> = 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', | ||
| ]) |
Comment on lines
+149
to
+162
| 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)); | ||
| } | ||
| }; |
Comment on lines
+60
to
+61
| Override experiment flags for this session. Changes are stored in localStorage and take | ||
| effect immediately on next render. |
Comment on lines
+24
to
+35
| function getEffectiveValue( | ||
| key: string, | ||
| configExperiments?: Record<string, ExperimentConfig> | ||
| ): { 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' }; | ||
| } |
Comment on lines
+9
to
+18
| // 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<string, string>(); | ||
|
|
||
| /** Number of SVG blob URLs currently held in the module-level cache. */ | ||
| export function getSvgCacheSize(): number { | ||
| return svgBlobCache.size; | ||
| } |
Comment on lines
+52
to
+57
| 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; |
Comment on lines
+136
to
+144
| (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)); |
Comment on lines
+278
to
+350
| {developerTools && ( | ||
| <Box direction="Column" gap="100"> | ||
| <Text size="L400">Caches</Text> | ||
| <SequenceCard | ||
| className={SequenceCardStyle} | ||
| variant="SurfaceVariant" | ||
| direction="Column" | ||
| gap="400" | ||
| > | ||
| <SettingTile | ||
| focusId="svg-cache" | ||
| title="SVG Avatar Cache" | ||
| description={`${svgCacheSize} ${svgCacheSize === 1 ? 'item' : 'items'} · processed SVG avatars, reused while app is open · cleared on reload`} | ||
| /> | ||
| <SettingTile | ||
| focusId="clear-in-memory-cache" | ||
| title="In-Memory Media Cache" | ||
| description={`${cacheStats.cacheSize} ${cacheStats.cacheSize === 1 ? 'item' : 'items'} · authenticated media blob URLs held for this session · cleared on reload`} | ||
| after={ | ||
| <Button | ||
| onClick={clearInMemoryAction} | ||
| variant="Secondary" | ||
| fill="Soft" | ||
| size="300" | ||
| radii="300" | ||
| outlined | ||
| > | ||
| <Text size="B300">Clear</Text> | ||
| </Button> | ||
| } | ||
| /> | ||
| <SettingTile | ||
| focusId="clear-sw-cache" | ||
| title="Service Worker Media Cache" | ||
| description={`${swCacheStats.count} ${swCacheStats.count === 1 ? 'file' : 'files'} · ${swCacheStats.sizeMB.toFixed(1)} MB · intercepted media requests served offline by the service worker`} | ||
| after={ | ||
| <Button | ||
| onClick={clearSwCacheAction} | ||
| variant="Secondary" | ||
| fill="Soft" | ||
| size="300" | ||
| radii="300" | ||
| outlined | ||
| disabled={clearSwCacheState.status === AsyncStatus.Loading} | ||
| before={ | ||
| clearSwCacheState.status === AsyncStatus.Loading && ( | ||
| <Spinner size="100" variant="Secondary" /> | ||
| ) | ||
| } | ||
| > | ||
| <Text size="B300"> | ||
| {clearSwCacheState.status === AsyncStatus.Loading | ||
| ? 'Clearing…' | ||
| : 'Clear'} | ||
| </Text> | ||
| </Button> | ||
| } | ||
| > | ||
| {clearSwCacheState.status === AsyncStatus.Success && ( | ||
| <Text size="T200" style={{ color: color.Success.Main }}> | ||
| Service worker cache cleared. | ||
| </Text> | ||
| )} | ||
| {clearSwCacheState.status === AsyncStatus.Error && ( | ||
| <Text size="T200" style={{ color: color.Critical.Main }}> | ||
| {clearSwCacheState.error.message} | ||
| </Text> | ||
| )} | ||
| </SettingTile> | ||
| <SettingTile | ||
| focusId="clear-media-cache" | ||
| title="Persistent Media Cache" | ||
| description={`${cacheStats.persistentCacheCount} ${cacheStats.persistentCacheCount === 1 ? 'file' : 'files'} · ${cacheStats.persistentCacheSizeMB.toFixed(1)} MB · authenticated media blobs persisted between sessions`} |
Comment on lines
+56
to
+78
| return ( | ||
| <Box direction="Column" gap="100"> | ||
| <Text size="L400">Experiments</Text> | ||
| <Text size="T200" style={{ opacity: 0.7 }}> | ||
| Override experiment flags for this session. Changes are stored in localStorage and take | ||
| effect immediately on next render. | ||
| </Text> | ||
| <SequenceCard | ||
| className={SequenceCardStyle} | ||
| variant="SurfaceVariant" | ||
| direction="Column" | ||
| gap="400" | ||
| > | ||
| {keys.map((key) => { | ||
| const { value, source } = getEffectiveValue(key, config.experiments); | ||
| const hasOverride = source === 'override'; | ||
| return ( | ||
| <SettingTile | ||
| key={key} | ||
| focusId={`experiment-${key}`} | ||
| title={key} | ||
| description={`Source: ${source}`} | ||
| after={ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Consolidates two developer-tooling additions.
Rotate Sessions tool — adds a "Rotate Sessions" button to the Developer Tools settings panel that calls
prepareToEncrypt()on all joined rooms to force Megolm session rotation, useful for testing encryption key rotation flows and debugging bridge session recovery.Experiment feature flags — adds build-time client-config injection via
CLIENT_CONFIG_*environment variables, a typeduseExperimentVarianthook for deterministic percentage-based user bucketing, an Experiments panel in Developer Tools listing active experiments and their variant assignments, and a prototype-pollution guard indeepMerge.Fixes #
Type of change
Checklist:
AI disclosure:
useExperimentVarianthashesuserId + experimentKeyto a float in [0, 1) and compares it againstrolloutPercentagefor consistent user assignment across reloads. ThedeepMergeguard rejects keys matching__proto__,constructor, andprototypebefore recursing. The Rotate Sessions button and Experiments panel UI were written manually.