mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-23 00:04:06 +00:00
feat: add dev-time feature flag overrides via localStorage (#9075)
## Summary
- Adds `localStorage`-based dev-time override for feature flags, with
`ff:` key prefix (e.g.
`localStorage.setItem('ff:team_workspaces_enabled', 'true')`)
- Override priority: dev localStorage > remoteConfig >
serverFeatureFlags
- Guarded by `import.meta.env.DEV` — tree-shaken to empty function in
production builds
- Extracts `resolveFlag` helper in `useFeatureFlags` to eliminate
repeated fallback pattern
Fixes #9054
## Test plan
- [x] `getDevOverride` unit tests: boolean/number/string/object parsing,
prefix isolation, invalid JSON warning
- [x] `api.getServerFeature` / `serverSupportsFeature` override tests
- [x] `useFeatureFlags` override priority tests, including
`teamWorkspacesEnabled` bypassing guards
- [x] Production build verified: `getDevOverride` compiles to empty
function body, localStorage never accessed
- [x] `pnpm typecheck`, `pnpm lint` clean
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9075-feat-add-dev-time-feature-flag-overrides-via-localStorage-30f6d73d365081b394d3ccc461987b1a)
by [Unito](https://www.unito.io)
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { isReactive, isReadonly } from 'vue'
|
||||
|
||||
import {
|
||||
@@ -175,4 +175,49 @@ describe('useFeatureFlags', () => {
|
||||
expect(flags.linearToggleEnabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dev override via localStorage', () => {
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('resolveFlag returns localStorage override over remoteConfig and server value', () => {
|
||||
vi.mocked(api.getServerFeature).mockReturnValue(false)
|
||||
localStorage.setItem('ff:model_upload_button_enabled', 'true')
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.modelUploadButtonEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('resolveFlag falls through to server when no override is set', () => {
|
||||
vi.mocked(api.getServerFeature).mockImplementation(
|
||||
(path, defaultValue) => {
|
||||
if (path === ServerFeatureFlag.ASSET_RENAME_ENABLED) return true
|
||||
return defaultValue
|
||||
}
|
||||
)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.assetRenameEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('direct server flags delegate override to api.getServerFeature', () => {
|
||||
vi.mocked(api.getServerFeature).mockImplementation((path) => {
|
||||
if (path === ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA)
|
||||
return 'overridden'
|
||||
return undefined
|
||||
})
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.supportsPreviewMetadata).toBe('overridden')
|
||||
})
|
||||
|
||||
it('teamWorkspacesEnabled override bypasses isCloud and isAuthenticatedConfigLoaded guards', () => {
|
||||
vi.mocked(distributionTypes).isCloud = false
|
||||
localStorage.setItem('ff:team_workspaces_enabled', 'true')
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
remoteConfig
|
||||
} from '@/platform/remoteConfig/remoteConfig'
|
||||
import { api } from '@/scripts/api'
|
||||
import { getDevOverride } from '@/utils/devFeatureFlagOverride'
|
||||
|
||||
/**
|
||||
* Known server feature flags (top-level, not extensions)
|
||||
@@ -24,6 +25,19 @@ export enum ServerFeatureFlag {
|
||||
NODE_REPLACEMENTS = 'node_replacements'
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a feature flag value with dev override > remoteConfig > serverFeature priority.
|
||||
*/
|
||||
function resolveFlag<T>(
|
||||
flagKey: string,
|
||||
remoteConfigValue: T | undefined,
|
||||
defaultValue: T
|
||||
): T {
|
||||
const override = getDevOverride<T>(flagKey)
|
||||
if (override !== undefined) return override
|
||||
return remoteConfigValue ?? api.getServerFeature(flagKey, defaultValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for reactive access to server-side feature flags
|
||||
*/
|
||||
@@ -39,38 +53,40 @@ export function useFeatureFlags() {
|
||||
return api.getServerFeature(ServerFeatureFlag.MANAGER_SUPPORTS_V4)
|
||||
},
|
||||
get modelUploadButtonEnabled() {
|
||||
return (
|
||||
remoteConfig.value.model_upload_button_enabled ??
|
||||
api.getServerFeature(
|
||||
ServerFeatureFlag.MODEL_UPLOAD_BUTTON_ENABLED,
|
||||
false
|
||||
)
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.MODEL_UPLOAD_BUTTON_ENABLED,
|
||||
remoteConfig.value.model_upload_button_enabled,
|
||||
false
|
||||
)
|
||||
},
|
||||
get assetRenameEnabled() {
|
||||
return (
|
||||
remoteConfig.value.asset_rename_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.ASSET_RENAME_ENABLED, false)
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.ASSET_RENAME_ENABLED,
|
||||
remoteConfig.value.asset_rename_enabled,
|
||||
false
|
||||
)
|
||||
},
|
||||
get privateModelsEnabled() {
|
||||
return (
|
||||
remoteConfig.value.private_models_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.PRIVATE_MODELS_ENABLED, false)
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.PRIVATE_MODELS_ENABLED,
|
||||
remoteConfig.value.private_models_enabled,
|
||||
false
|
||||
)
|
||||
},
|
||||
get onboardingSurveyEnabled() {
|
||||
return (
|
||||
remoteConfig.value.onboarding_survey_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.ONBOARDING_SURVEY_ENABLED, false)
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.ONBOARDING_SURVEY_ENABLED,
|
||||
remoteConfig.value.onboarding_survey_enabled,
|
||||
false
|
||||
)
|
||||
},
|
||||
get linearToggleEnabled() {
|
||||
if (isNightly) return true
|
||||
|
||||
return (
|
||||
remoteConfig.value.linear_toggle_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.LINEAR_TOGGLE_ENABLED, false)
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.LINEAR_TOGGLE_ENABLED,
|
||||
remoteConfig.value.linear_toggle_enabled,
|
||||
false
|
||||
)
|
||||
},
|
||||
/**
|
||||
@@ -80,11 +96,12 @@ export function useFeatureFlags() {
|
||||
* and prevents race conditions during initialization.
|
||||
*/
|
||||
get teamWorkspacesEnabled() {
|
||||
if (!isCloud) return false
|
||||
const override = getDevOverride<boolean>(
|
||||
ServerFeatureFlag.TEAM_WORKSPACES_ENABLED
|
||||
)
|
||||
if (override !== undefined) return override
|
||||
|
||||
// Only return true if authenticated config has been loaded.
|
||||
// This prevents race conditions where code checks this flag before
|
||||
// WorkspaceAuthGate has refreshed the config with auth.
|
||||
if (!isCloud) return false
|
||||
if (!isAuthenticatedConfigLoaded.value) return false
|
||||
|
||||
return (
|
||||
@@ -93,9 +110,10 @@ export function useFeatureFlags() {
|
||||
)
|
||||
},
|
||||
get userSecretsEnabled() {
|
||||
return (
|
||||
remoteConfig.value.user_secrets_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.USER_SECRETS_ENABLED, false)
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.USER_SECRETS_ENABLED,
|
||||
remoteConfig.value.user_secrets_enabled,
|
||||
false
|
||||
)
|
||||
},
|
||||
get nodeReplacementsEnabled() {
|
||||
|
||||
@@ -233,4 +233,37 @@ describe('API Feature Flags', () => {
|
||||
expect(flag.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dev override via localStorage', () => {
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('getServerFeature returns localStorage override over server value', () => {
|
||||
api.serverFeatureFlags.value = { some_flag: false }
|
||||
localStorage.setItem('ff:some_flag', 'true')
|
||||
|
||||
expect(api.getServerFeature('some_flag')).toBe(true)
|
||||
})
|
||||
|
||||
it('serverSupportsFeature returns localStorage override over server value', () => {
|
||||
api.serverFeatureFlags.value = { some_flag: false }
|
||||
localStorage.setItem('ff:some_flag', 'true')
|
||||
|
||||
expect(api.serverSupportsFeature('some_flag')).toBe(true)
|
||||
})
|
||||
|
||||
it('getServerFeature falls through when no override is set', () => {
|
||||
api.serverFeatureFlags.value = { some_flag: 'server_value' }
|
||||
|
||||
expect(api.getServerFeature('some_flag')).toBe('server_value')
|
||||
})
|
||||
|
||||
it('getServerFeature override works with numeric values', () => {
|
||||
api.serverFeatureFlags.value = { max_upload_size: 100 }
|
||||
localStorage.setItem('ff:max_upload_size', '999')
|
||||
|
||||
expect(api.getServerFeature('max_upload_size')).toBe(999)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import { trimEnd } from 'es-toolkit'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import defaultClientFeatureFlags from '@/config/clientFeatureFlags.json' with { type: 'json' }
|
||||
import { getDevOverride } from '@/utils/devFeatureFlagOverride'
|
||||
import type {
|
||||
ModelFile,
|
||||
ModelFolderInfo
|
||||
@@ -1299,6 +1300,8 @@ export class ComfyApi extends EventTarget {
|
||||
* @returns true if the feature is supported, false otherwise
|
||||
*/
|
||||
serverSupportsFeature(featureName: string): boolean {
|
||||
const override = getDevOverride<boolean>(featureName)
|
||||
if (override !== undefined) return override
|
||||
return get(this.serverFeatureFlags.value, featureName) === true
|
||||
}
|
||||
|
||||
@@ -1309,6 +1312,8 @@ export class ComfyApi extends EventTarget {
|
||||
* @returns The feature value or default
|
||||
*/
|
||||
getServerFeature<T = unknown>(featureName: string, defaultValue?: T): T {
|
||||
const override = getDevOverride<T>(featureName)
|
||||
if (override !== undefined) return override
|
||||
return get(this.serverFeatureFlags.value, featureName, defaultValue) as T
|
||||
}
|
||||
|
||||
|
||||
60
src/utils/devFeatureFlagOverride.test.ts
Normal file
60
src/utils/devFeatureFlagOverride.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getDevOverride } from '@/utils/devFeatureFlagOverride'
|
||||
|
||||
describe('getDevOverride', () => {
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns undefined when no override is set', () => {
|
||||
expect(getDevOverride('some_flag')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns parsed boolean true', () => {
|
||||
localStorage.setItem('ff:some_flag', 'true')
|
||||
expect(getDevOverride<boolean>('some_flag')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns parsed boolean false', () => {
|
||||
localStorage.setItem('ff:some_flag', 'false')
|
||||
expect(getDevOverride<boolean>('some_flag')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns parsed number', () => {
|
||||
localStorage.setItem('ff:max_upload_size', '209715200')
|
||||
expect(getDevOverride<number>('max_upload_size')).toBe(209715200)
|
||||
})
|
||||
|
||||
it('returns parsed string', () => {
|
||||
localStorage.setItem('ff:some_flag', '"hello"')
|
||||
expect(getDevOverride<string>('some_flag')).toBe('hello')
|
||||
})
|
||||
|
||||
it('returns parsed object', () => {
|
||||
localStorage.setItem('ff:complex', '{"nested": true}')
|
||||
expect(getDevOverride<Record<string, boolean>>('complex')).toEqual({
|
||||
nested: true
|
||||
})
|
||||
})
|
||||
|
||||
it('uses ff: prefix for localStorage keys', () => {
|
||||
localStorage.setItem('some_flag', 'true')
|
||||
expect(getDevOverride('some_flag')).toBeUndefined()
|
||||
|
||||
localStorage.setItem('ff:some_flag', 'true')
|
||||
expect(getDevOverride('some_flag')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns undefined and warns on invalid JSON', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
localStorage.setItem('ff:bad', 'True')
|
||||
|
||||
expect(getDevOverride('bad')).toBeUndefined()
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'[ff] Invalid JSON for override "bad":',
|
||||
'True'
|
||||
)
|
||||
})
|
||||
})
|
||||
26
src/utils/devFeatureFlagOverride.ts
Normal file
26
src/utils/devFeatureFlagOverride.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
const FF_PREFIX = 'ff:'
|
||||
|
||||
/**
|
||||
* Gets a dev-time feature flag override from localStorage.
|
||||
* Stripped from production builds via import.meta.env.DEV tree-shaking.
|
||||
*
|
||||
* Returns undefined (not null) as the "no override" sentinel because
|
||||
* null is a valid JSON value — JSON.parse('null') returns null.
|
||||
* Using undefined avoids ambiguity between "no override set" and
|
||||
* "override explicitly set to null".
|
||||
*
|
||||
* Usage in browser console:
|
||||
* localStorage.setItem('ff:team_workspaces_enabled', 'true')
|
||||
* localStorage.removeItem('ff:team_workspaces_enabled')
|
||||
*/
|
||||
export function getDevOverride<T>(flagKey: string): T | undefined {
|
||||
if (!import.meta.env.DEV) return undefined
|
||||
const raw = localStorage.getItem(`${FF_PREFIX}${flagKey}`)
|
||||
if (raw === null) return undefined
|
||||
try {
|
||||
return JSON.parse(raw) as T
|
||||
} catch {
|
||||
console.warn(`[ff] Invalid JSON for override "${flagKey}":`, raw)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user