From 89a3578826ed6a3f286bd29afaff89c9230fbf49 Mon Sep 17 00:00:00 2001 From: Thomas Heartman Date: Wed, 12 Nov 2025 12:00:27 +0100 Subject: [PATCH] fix: allow external flag resolver to override false experiments with variants in getAll (#10966) Fixes a bug / uncovered edge case in the flag resolver in Unleash: If a local experiment was defined as false (the typical default value), then that flag could only ever be returned as a boolean from the `ui-config` endpoint. In other words, even if the external resolver has a variant for that flag, the UI would never get the variant. The fix is to not just check `isEnabled` for false flags, but instead: - use `getVariant` - then check `variant.enabled` (in which case we have a variant and can return it) - else check `variant.feature_enabled`, falling back to `isEnabled` only if `feature_enabled` is null/undefined. --- src/lib/util/flag-resolver.test.ts | 58 ++++++++++++++++++++++++++---- src/lib/util/flag-resolver.ts | 9 ++++- 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/src/lib/util/flag-resolver.test.ts b/src/lib/util/flag-resolver.test.ts index a3a4a67607..25a4b7cd56 100644 --- a/src/lib/util/flag-resolver.test.ts +++ b/src/lib/util/flag-resolver.test.ts @@ -29,12 +29,11 @@ test('should produce UI flags with extra dynamic flags', () => { test('should use external resolver for dynamic flags', () => { const externalResolver = { - isEnabled: (name: string) => { - if (name === 'extraFlag') { - return true; - } - }, - getVariant: () => defaultVariant, + isEnabled: (name: string) => name === 'extraFlag', + getVariant: (name: string) => ({ + ...defaultVariant, + feature_enabled: name === 'extraFlag', + }), getStaticContext: () => ({}), }; @@ -225,6 +224,53 @@ test('should call external resolver getVariant when not overridden to be true, e ); }); +test('should allow overriding false experiments with externally resolved variants when getting all flags (getAll)', () => { + const variant = { + enabled: true, + name: 'variant', + }; + + const externalResolver = { + isEnabled: () => false, + getVariant: () => variant, + getStaticContext: () => ({}), + }; + + const config = { + flags: { willStayBool: true, willBeVariant: false }, + externalResolver, + }; + + const resolver = new FlagResolver(config as IExperimentalOptions); + const flags = resolver.getAll() as typeof config.flags; + + expect(flags.willStayBool).toStrictEqual(true); + expect(flags.willBeVariant).toStrictEqual(variant); +}); + +test('should fall back to isEnabled if variant.feature_enabled is not defined in getAll', () => { + const variant = { + enabled: false, + name: 'variant', + }; + + const externalResolver = { + isEnabled: () => true, + getVariant: () => variant, + getStaticContext: () => ({}), + }; + + const config = { + flags: { flag: false }, + externalResolver, + }; + + const resolver = new FlagResolver(config as IExperimentalOptions); + const flags = resolver.getAll() as typeof config.flags; + + expect(flags.flag).toStrictEqual(true); +}); + test('should call external resolver getStaticContext ', () => { const variant = { enabled: true, diff --git a/src/lib/util/flag-resolver.ts b/src/lib/util/flag-resolver.ts index 85a6f08028..4705c82b1e 100644 --- a/src/lib/util/flag-resolver.ts +++ b/src/lib/util/flag-resolver.ts @@ -27,10 +27,17 @@ export default class FlagResolver implements IFlagResolver { const flag = flags[flagName]; if (typeof flag === 'boolean') { if (!flag) { - flags[flagName] = this.externalResolver.isEnabled( + const variant = this.externalResolver.getVariant( flagName, context, ); + if (variant.enabled) { + flags[flagName] = variant; + } else { + flags[flagName] = + variant.feature_enabled ?? + this.externalResolver.isEnabled(flagName, context); + } } } else { if (!flag?.enabled) {