Skip to content

feat(dev-tools): rotate sessions tool and experiment feature flags#700

Open
Just-Insane wants to merge 17 commits into
SableClient:devfrom
Just-Insane:feat/developer-tools
Open

feat(dev-tools): rotate sessions tool and experiment feature flags#700
Just-Insane wants to merge 17 commits into
SableClient:devfrom
Just-Insane:feat/developer-tools

Conversation

@Just-Insane
Copy link
Copy Markdown
Contributor

@Just-Insane Just-Insane commented Apr 17, 2026

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 typed useExperimentVariant hook for deterministic percentage-based user bucketing, an Experiments panel in Developer Tools listing active experiments and their variant assignments, and a prototype-pollution guard in deepMerge.

Fixes #

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update

Checklist:

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings

AI disclosure:

  • Partially AI assisted (clarify which code was AI assisted and briefly explain what it does).
  • Fully AI generated (explain what all the generated code does in moderate detail).

useExperimentVariant hashes userId + experimentKey to a float in [0, 1) and compares it against rolloutPercentage for consistent user assignment across reloads. The deepMerge guard rejects keys matching __proto__, constructor, and prototype before recursing. The Rotate Sessions button and Experiments panel UI were written manually.

@Just-Insane Just-Insane force-pushed the feat/developer-tools branch from d82f8e4 to 0625646 Compare May 11, 2026 01:44
@Just-Insane Just-Insane force-pushed the feat/developer-tools branch 2 times, most recently from 3dc1788 to 90967a2 Compare May 14, 2026 19:41
Just-Insane and others added 8 commits May 14, 2026 16:41
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.
@Just-Insane Just-Insane force-pushed the feat/developer-tools branch from e551b10 to bafe036 Compare May 14, 2026 20:42
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.
@Just-Insane Just-Insane marked this pull request as ready for review May 19, 2026 23:38
@Just-Insane Just-Insane requested review from 7w1 and hazre as code owners May 19, 2026 23:38
Copilot AI review requested due to automatic review settings May 19, 2026 23:38
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.json override injection via CLIENT_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, so persistentCacheSizeMB/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. If cache.delete rejects (or is slow), the error won’t be caught by this try/catch, and the delete may race with subsequent reads. Use await cache.delete(url) (or explicitly void 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

  • evictIfNeeded evicts a fixed 10% of entries once, but the cache can still remain over MAX_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

  • useEffect exits early when imageBlobCache.has(url) is true, but cacheState.blobUrl can still be undefined if the cache was populated between render and the effect running. Consider setting cacheState from imageBlobCache.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 in existingBlobUrl being undefined and stored into state. Use a non-null assertion / early return when get(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 thread vite.config.ts
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={
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants