From d6e1d727aa254f5c672c7feb18cd139bb25f1b28 Mon Sep 17 00:00:00 2001 From: MattIPv4 Date: Tue, 25 Nov 2025 18:25:15 +0000 Subject: [PATCH 1/9] Abstract initialization into dedicated method Signed-off-by: MattIPv4 --- packages/shared/src/open-feature.ts | 95 ++++++++++++++++++----------- 1 file changed, 58 insertions(+), 37 deletions(-) diff --git a/packages/shared/src/open-feature.ts b/packages/shared/src/open-feature.ts index 00142b567..6680c60ea 100644 --- a/packages/shared/src/open-feature.ts +++ b/packages/shared/src/open-feature.ts @@ -217,6 +217,58 @@ export abstract class OpenFeatureCommonAPI< contextOrUndefined?: EvaluationContext, ): this; + protected initializeProviderForDomain( + wrapper: ProviderWrapper, + domain?: string, + ): Promise | void { + if (typeof wrapper.provider.initialize !== 'function') { + return; + } + + return wrapper.provider + .initialize(domain ? (this._domainScopedContext.get(domain) ?? this._context) : this._context) + .then(() => { + wrapper.status = this._statusEnumType.READY; + // fetch the most recent event emitters, some may have been added during init + this.getAssociatedEventEmitters(domain).forEach((emitter) => { + emitter?.emit(AllProviderEvents.Ready, { + clientName: domain, + domain, + providerName: wrapper.provider.metadata.name, + }); + }); + this._apiEmitter?.emit(AllProviderEvents.Ready, { + clientName: domain, + domain, + providerName: wrapper.provider.metadata.name, + }); + }) + .catch((error) => { + // if this is a fatal error, transition to FATAL status + if ((error as OpenFeatureError)?.code === ErrorCode.PROVIDER_FATAL) { + wrapper.status = this._statusEnumType.FATAL; + } else { + wrapper.status = this._statusEnumType.ERROR; + } + this.getAssociatedEventEmitters(domain).forEach((emitter) => { + emitter?.emit(AllProviderEvents.Error, { + clientName: domain, + domain, + providerName: wrapper.provider.metadata.name, + message: error?.message, + }); + }); + this._apiEmitter?.emit(AllProviderEvents.Error, { + clientName: domain, + domain, + providerName: wrapper.provider.metadata.name, + message: error?.message, + }); + // rethrow after emitting error events, so that public methods can control error handling + throw error; + }); + } + protected setAwaitableProvider(domainOrProvider?: string | P, providerOrUndefined?: P): Promise | void { const domain = stringOrUndefined(domainOrProvider); const provider = objectOrUndefined

(domainOrProvider) ?? objectOrUndefined

(providerOrUndefined); @@ -250,43 +302,12 @@ export abstract class OpenFeatureCommonAPI< this._statusEnumType, ); - // initialize the provider if it implements "initialize" and it's not already registered - if (typeof provider.initialize === 'function' && !this.allProviders.includes(provider)) { - initializationPromise = provider - .initialize?.(domain ? (this._domainScopedContext.get(domain) ?? this._context) : this._context) - ?.then(() => { - wrappedProvider.status = this._statusEnumType.READY; - // fetch the most recent event emitters, some may have been added during init - this.getAssociatedEventEmitters(domain).forEach((emitter) => { - emitter?.emit(AllProviderEvents.Ready, { clientName: domain, domain, providerName }); - }); - this._apiEmitter?.emit(AllProviderEvents.Ready, { clientName: domain, domain, providerName }); - }) - ?.catch((error) => { - // if this is a fatal error, transition to FATAL status - if ((error as OpenFeatureError)?.code === ErrorCode.PROVIDER_FATAL) { - wrappedProvider.status = this._statusEnumType.FATAL; - } else { - wrappedProvider.status = this._statusEnumType.ERROR; - } - this.getAssociatedEventEmitters(domain).forEach((emitter) => { - emitter?.emit(AllProviderEvents.Error, { - clientName: domain, - domain, - providerName, - message: error?.message, - }); - }); - this._apiEmitter?.emit(AllProviderEvents.Error, { - clientName: domain, - domain, - providerName, - message: error?.message, - }); - // rethrow after emitting error events, so that public methods can control error handling - throw error; - }); - } else { + // initialize the provider if it's not already registered and it implements "initialize" + if (!this.allProviders.includes(provider)) { + initializationPromise = this.initializeProviderForDomain(wrappedProvider, domain); + } + + if (!initializationPromise) { wrappedProvider.status = this._statusEnumType.READY; emitters.forEach((emitter) => { emitter?.emit(AllProviderEvents.Ready, { clientName: domain, domain, providerName }); From 59129972e9587c224d29cd1f873680da9f5995fa Mon Sep 17 00:00:00 2001 From: MattIPv4 Date: Tue, 25 Nov 2025 18:28:27 +0000 Subject: [PATCH 2/9] Track whether initialization has run in wrapper Signed-off-by: MattIPv4 --- packages/shared/src/open-feature.ts | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/open-feature.ts b/packages/shared/src/open-feature.ts index 6680c60ea..598e57c51 100644 --- a/packages/shared/src/open-feature.ts +++ b/packages/shared/src/open-feature.ts @@ -27,6 +27,8 @@ type AnyProviderStatus = ClientProviderStatus | ServerProviderStatus; */ export class ProviderWrapper

, S extends AnyProviderStatus> { private _pendingContextChanges = 0; + private _initializing = false; + private _initialized = false; constructor( private _provider: P, @@ -49,6 +51,8 @@ export class ProviderWrapper

, S exte this._status = _statusEnumType.ERROR as S; } }); + + this._initialized = !(typeof _provider.initialize === 'function'); } get provider(): P { @@ -67,6 +71,22 @@ export class ProviderWrapper

, S exte this._status = status; } + get initializing() { + return this._initializing; + } + + set initializing(initializing: boolean) { + this._initializing = initializing; + } + + get initialized() { + return this._initialized; + } + + set initialized(initialized: boolean) { + this._initialized = initialized; + } + get allContextChangesSettled() { return this._pendingContextChanges === 0; } @@ -221,10 +241,11 @@ export abstract class OpenFeatureCommonAPI< wrapper: ProviderWrapper, domain?: string, ): Promise | void { - if (typeof wrapper.provider.initialize !== 'function') { + if (typeof wrapper.provider.initialize !== 'function' || wrapper.initializing || wrapper.initialized) { return; } + wrapper.initializing = true; return wrapper.provider .initialize(domain ? (this._domainScopedContext.get(domain) ?? this._context) : this._context) .then(() => { @@ -266,6 +287,10 @@ export abstract class OpenFeatureCommonAPI< }); // rethrow after emitting error events, so that public methods can control error handling throw error; + }) + .finally(() => { + wrapper.initialized = true; + wrapper.initializing = false; }); } From a63261a916cce91865c9df6cd678e2719acb051a Mon Sep 17 00:00:00 2001 From: MattIPv4 Date: Tue, 25 Nov 2025 18:28:54 +0000 Subject: [PATCH 3/9] Call initialization from context change if not initialized Signed-off-by: MattIPv4 --- packages/web/src/open-feature.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/web/src/open-feature.ts b/packages/web/src/open-feature.ts index 131f8c273..b8beeba64 100644 --- a/packages/web/src/open-feature.ts +++ b/packages/web/src/open-feature.ts @@ -380,6 +380,14 @@ export class OpenFeatureAPI const providerName = wrapper.provider?.metadata?.name || 'unnamed-provider'; try { + // if the provider hasn't initialized yet, and isn't actively initializing, initialize instead of running context change handler + // the provider will be in this state if the user requested delayed initialization until the first context change + const initializationPromise = this.initializeProviderForDomain(wrapper, domain); + if (initializationPromise) { + await initializationPromise; + return; + } + if (typeof wrapper.provider.onContextChange === 'function') { const maybePromise = wrapper.provider.onContextChange(oldContext, newContext); From 39a982adc4b9f64628852e444821e857433ddc05 Mon Sep 17 00:00:00 2001 From: MattIPv4 Date: Tue, 25 Nov 2025 18:40:38 +0000 Subject: [PATCH 4/9] Fix bad test mock for provider initialization Signed-off-by: MattIPv4 --- packages/web/test/evaluation-context.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/test/evaluation-context.spec.ts b/packages/web/test/evaluation-context.spec.ts index 288301c88..3d2064ba7 100644 --- a/packages/web/test/evaluation-context.spec.ts +++ b/packages/web/test/evaluation-context.spec.ts @@ -1,7 +1,7 @@ import type { EvaluationContext, JsonValue, Provider, ProviderMetadata, ResolutionDetails } from '../src'; import { OpenFeature } from '../src'; -const initializeMock = jest.fn(); +const initializeMock = jest.fn().mockResolvedValue(undefined); class MockProvider implements Provider { readonly metadata: ProviderMetadata; From 23a0340e9ff67316c922f109ae8f4484109acade Mon Sep 17 00:00:00 2001 From: MattIPv4 Date: Tue, 25 Nov 2025 20:03:05 +0000 Subject: [PATCH 5/9] Accept a validateContext to skip initialization Signed-off-by: MattIPv4 --- packages/shared/src/open-feature.ts | 34 +++++---- packages/web/src/open-feature.ts | 112 +++++++++++++++++++++++++--- 2 files changed, 121 insertions(+), 25 deletions(-) diff --git a/packages/shared/src/open-feature.ts b/packages/shared/src/open-feature.ts index 598e57c51..2c360a2aa 100644 --- a/packages/shared/src/open-feature.ts +++ b/packages/shared/src/open-feature.ts @@ -228,13 +228,15 @@ export abstract class OpenFeatureCommonAPI< abstract setProviderAndWait( clientOrProvider?: string | P, providerContextOrUndefined?: P | EvaluationContext, - contextOrUndefined?: EvaluationContext, + contextOptionsOrUndefined?: EvaluationContext | Record, + optionsOrUndefined?: Record, ): Promise; abstract setProvider( clientOrProvider?: string | P, providerContextOrUndefined?: P | EvaluationContext, - contextOrUndefined?: EvaluationContext, + contextOptionsOrUndefined?: EvaluationContext | Record, + optionsOrUndefined?: Record, ): this; protected initializeProviderForDomain( @@ -294,7 +296,11 @@ export abstract class OpenFeatureCommonAPI< }); } - protected setAwaitableProvider(domainOrProvider?: string | P, providerOrUndefined?: P): Promise | void { + protected setAwaitableProvider( + domainOrProvider?: string | P, + providerOrUndefined?: P, + skipInitialization = false, + ): Promise | void { const domain = stringOrUndefined(domainOrProvider); const provider = objectOrUndefined

(domainOrProvider) ?? objectOrUndefined

(providerOrUndefined); @@ -327,17 +333,19 @@ export abstract class OpenFeatureCommonAPI< this._statusEnumType, ); - // initialize the provider if it's not already registered and it implements "initialize" - if (!this.allProviders.includes(provider)) { - initializationPromise = this.initializeProviderForDomain(wrappedProvider, domain); - } + if (!skipInitialization) { + // initialize the provider if it's not already registered and it implements "initialize" + if (!this.allProviders.includes(provider)) { + initializationPromise = this.initializeProviderForDomain(wrappedProvider, domain); + } - if (!initializationPromise) { - wrappedProvider.status = this._statusEnumType.READY; - emitters.forEach((emitter) => { - emitter?.emit(AllProviderEvents.Ready, { clientName: domain, domain, providerName }); - }); - this._apiEmitter?.emit(AllProviderEvents.Ready, { clientName: domain, domain, providerName }); + if (!initializationPromise) { + wrappedProvider.status = this._statusEnumType.READY; + emitters.forEach((emitter) => { + emitter?.emit(AllProviderEvents.Ready, { clientName: domain, domain, providerName }); + }); + this._apiEmitter?.emit(AllProviderEvents.Ready, { clientName: domain, domain, providerName }); + } } if (domain) { diff --git a/packages/web/src/open-feature.ts b/packages/web/src/open-feature.ts index b8beeba64..8718e4156 100644 --- a/packages/web/src/open-feature.ts +++ b/packages/web/src/open-feature.ts @@ -7,6 +7,17 @@ import type { Hook } from './hooks'; import type { Provider } from './provider'; import { NOOP_PROVIDER, ProviderStatus } from './provider'; +interface ProviderOptions { + /** + * If provided, will be used to check if the current context is valid during initialization and context changes. + * When calling `setProvider`, returning `false` will skip provider initialization. Throwing will move the provider to the ERROR state. + * When calling `setProviderAndWait`, returning `false` will skip provider initialization. Throwing will reject the promise. + * TODO: When calling `setContext`, returning `false` will skip provider context change handling. Throwing will move the provider to the ERROR state. + * @param context The evaluation context to validate. + */ + validateContext?: (context: EvaluationContext) => boolean; +} + // use a symbol as a key for the global singleton const GLOBAL_OPENFEATURE_API_KEY = Symbol.for('@openfeature/web-sdk/api'); @@ -77,10 +88,11 @@ export class OpenFeatureAPI * Setting a provider supersedes the current provider used in new and existing unbound clients. * @param {Provider} provider The provider responsible for flag evaluations. * @param {EvaluationContext} context The evaluation context to use for flag evaluations. + * @param {ProviderOptions} [options] Options for setting the provider. * @returns {Promise} * @throws {Error} If the provider throws an exception during initialization. */ - setProviderAndWait(provider: Provider, context: EvaluationContext): Promise; + setProviderAndWait(provider: Provider, context: EvaluationContext, options?: ProviderOptions): Promise; /** * Sets the provider that OpenFeature will use for flag evaluations on clients bound to the same domain. * A promise is returned that resolves when the provider is ready. @@ -98,24 +110,46 @@ export class OpenFeatureAPI * @param {string} domain The name to identify the client * @param {Provider} provider The provider responsible for flag evaluations. * @param {EvaluationContext} context The evaluation context to use for flag evaluations. + * @param {ProviderOptions} [options] Options for setting the provider. * @returns {Promise} * @throws {Error} If the provider throws an exception during initialization. */ - setProviderAndWait(domain: string, provider: Provider, context: EvaluationContext): Promise; + setProviderAndWait( + domain: string, + provider: Provider, + context: EvaluationContext, + options?: ProviderOptions, + ): Promise; async setProviderAndWait( clientOrProvider?: string | Provider, providerContextOrUndefined?: Provider | EvaluationContext, - contextOrUndefined?: EvaluationContext, + contextOptionsOrUndefined?: EvaluationContext | ProviderOptions, + optionsOrUndefined?: ProviderOptions, ): Promise { const domain = stringOrUndefined(clientOrProvider); const provider = domain ? objectOrUndefined(providerContextOrUndefined) : objectOrUndefined(clientOrProvider); const context = domain - ? objectOrUndefined(contextOrUndefined) + ? objectOrUndefined(contextOptionsOrUndefined) : objectOrUndefined(providerContextOrUndefined); + const options = domain + ? objectOrUndefined(optionsOrUndefined) + : objectOrUndefined(contextOptionsOrUndefined); + let skipInitialization = false; if (context) { + // validate the context to decide if we should initialize the provider with it. + if (typeof options?.validateContext === 'function') { + // allow any error to propagate here to reject the promise. + skipInitialization = !options.validateContext(context); + if (skipInitialization) { + this._logger.debug( + `Skipping provider initialization during setProviderAndWait for domain '${domain ?? 'default'}' due to validateContext returning false.`, + ); + } + } + // synonymously setting context prior to provider initialization. // No context change event will be emitted. if (domain) { @@ -125,7 +159,7 @@ export class OpenFeatureAPI } } - await this.setAwaitableProvider(domain, provider); + await this.setAwaitableProvider(domain, provider, skipInitialization); } /** @@ -141,10 +175,11 @@ export class OpenFeatureAPI * This provider will be used by domainless clients and clients associated with domains to which no provider is bound. * Setting a provider supersedes the current provider used in new and existing unbound clients. * @param {Provider} provider The provider responsible for flag evaluations. - * @param context {EvaluationContext} The evaluation context to use for flag evaluations. + * @param {EvaluationContext} context The evaluation context to use for flag evaluations. + * @param {ProviderOptions} [options] Options for setting the provider. * @returns {this} OpenFeature API */ - setProvider(provider: Provider, context: EvaluationContext): this; + setProvider(provider: Provider, context: EvaluationContext, options?: ProviderOptions): this; /** * Sets the provider for flag evaluations of providers with the given name. * Setting a provider supersedes the current provider used in new and existing clients bound to the same domain. @@ -158,24 +193,50 @@ export class OpenFeatureAPI * Setting a provider supersedes the current provider used in new and existing clients bound to the same domain. * @param {string} domain The name to identify the client * @param {Provider} provider The provider responsible for flag evaluations. - * @param context {EvaluationContext} The evaluation context to use for flag evaluations. + * @param {EvaluationContext} context The evaluation context to use for flag evaluations. + * @param {ProviderOptions} [options] Options for setting the provider. * @returns {this} OpenFeature API */ - setProvider(domain: string, provider: Provider, context: EvaluationContext): this; + setProvider(domain: string, provider: Provider, context: EvaluationContext, options?: ProviderOptions): this; setProvider( domainOrProvider?: string | Provider, providerContextOrUndefined?: Provider | EvaluationContext, - contextOrUndefined?: EvaluationContext, + contextOptionsOrUndefined?: EvaluationContext | ProviderOptions, + optionsOrUndefined?: ProviderOptions, ): this { const domain = stringOrUndefined(domainOrProvider); const provider = domain ? objectOrUndefined(providerContextOrUndefined) : objectOrUndefined(domainOrProvider); const context = domain - ? objectOrUndefined(contextOrUndefined) + ? objectOrUndefined(contextOptionsOrUndefined) : objectOrUndefined(providerContextOrUndefined); + const options = domain + ? objectOrUndefined(optionsOrUndefined) + : objectOrUndefined(contextOptionsOrUndefined); + let skipInitialization = false; + let validateContextError: unknown; if (context) { + // validate the context to decide if we should initialize the provider with it. + if (typeof options?.validateContext === 'function') { + try { + skipInitialization = !options.validateContext(context); + if (skipInitialization) { + this._logger.debug( + `Skipping provider initialization during setProvider for domain '${domain ?? 'default'}' due to validateContext returning false.`, + ); + } + } catch (err) { + // capture the error to move the provider to ERROR state after setting it. + validateContextError = err; + skipInitialization = true; + this._logger.debug( + `Skipping provider initialization during setProvider for domain '${domain ?? 'default'}' due to validateContext throwing an error.`, + ); + } + } + // synonymously setting context prior to provider initialization. // No context change event will be emitted. if (domain) { @@ -185,7 +246,32 @@ export class OpenFeatureAPI } } - const maybePromise = this.setAwaitableProvider(domain, provider); + const maybePromise = this.setAwaitableProvider(domain, provider, skipInitialization); + + // If there was a validation error with the context, move the newly created provider to ERROR state. + // We know we've skipped initialization if this happens, so no need to worry about the promise changing the state later. + if (validateContextError) { + const wrapper = domain ? this._domainScopedProviders.get(domain) : this._defaultProvider; + if (wrapper) { + wrapper.status = this._statusEnumType.ERROR; + const providerName = wrapper.provider?.metadata?.name || 'unnamed-provider'; + this.getAssociatedEventEmitters(domain).forEach((emitter) => { + emitter?.emit(ProviderEvents.Error, { + clientName: domain, + domain, + providerName, + message: `Error validating context during setProvider: ${validateContextError instanceof Error ? validateContextError.message : String(validateContextError)}`, + }); + }); + this._apiEmitter?.emit(ProviderEvents.Error, { + clientName: domain, + domain, + providerName, + message: `Error validating context during setProvider: ${validateContextError instanceof Error ? validateContextError.message : String(validateContextError)}`, + }); + this._logger.error('Error validating context during setProvider:', validateContextError); + } + } // The setProvider method doesn't return a promise so we need to catch and // log any errors that occur during provider initialization to avoid having @@ -240,6 +326,8 @@ export class OpenFeatureAPI const domain = stringOrUndefined(domainOrContext); const context = objectOrUndefined(domainOrContext) ?? objectOrUndefined(contextOrUndefined) ?? {}; + // TODO: We need to store and call `validateContext` here if provided in `setProvider` options + if (domain) { const wrapper = this._domainScopedProviders.get(domain); if (wrapper) { From d3b683d1d67afd0bac8018a1aac9c943a288549b Mon Sep 17 00:00:00 2001 From: MattIPv4 Date: Tue, 25 Nov 2025 21:33:20 +0000 Subject: [PATCH 6/9] Run validateContext during any context change Signed-off-by: MattIPv4 --- packages/web/src/open-feature.ts | 39 ++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/packages/web/src/open-feature.ts b/packages/web/src/open-feature.ts index 8718e4156..50bf46d9c 100644 --- a/packages/web/src/open-feature.ts +++ b/packages/web/src/open-feature.ts @@ -12,7 +12,7 @@ interface ProviderOptions { * If provided, will be used to check if the current context is valid during initialization and context changes. * When calling `setProvider`, returning `false` will skip provider initialization. Throwing will move the provider to the ERROR state. * When calling `setProviderAndWait`, returning `false` will skip provider initialization. Throwing will reject the promise. - * TODO: When calling `setContext`, returning `false` will skip provider context change handling. Throwing will move the provider to the ERROR state. + * When calling `setContext`, returning `false` will skip provider context change handling. Throwing will move the provider to the ERROR state. * @param context The evaluation context to validate. */ validateContext?: (context: EvaluationContext) => boolean; @@ -44,6 +44,8 @@ export class OpenFeatureAPI ); protected _domainScopedProviders: Map> = new Map(); protected _createEventEmitter = () => new OpenFeatureEventEmitter(); + protected _defaultOptions: ProviderOptions = {}; + protected _domainScopedOptions: Map = new Map(); private constructor() { super('client'); @@ -73,6 +75,14 @@ export class OpenFeatureAPI return this._domainScopedProviders.get(domain)?.status ?? this._defaultProvider.status; } + private getProviderOptions(domain?: string): ProviderOptions { + if (!domain) { + return this._defaultOptions; + } + + return this._domainScopedOptions.get(domain) ?? this._defaultOptions; + } + /** * Sets the default provider for flag evaluations and returns a promise that resolves when the provider is ready. * This provider will be used by domainless clients and clients associated with domains to which no provider is bound. @@ -137,6 +147,12 @@ export class OpenFeatureAPI ? objectOrUndefined(optionsOrUndefined) : objectOrUndefined(contextOptionsOrUndefined); + if (domain) { + this._domainScopedOptions.set(domain, options ?? {}); + } else { + this._defaultOptions = options ?? {}; + } + let skipInitialization = false; if (context) { // validate the context to decide if we should initialize the provider with it. @@ -215,6 +231,12 @@ export class OpenFeatureAPI ? objectOrUndefined(optionsOrUndefined) : objectOrUndefined(contextOptionsOrUndefined); + if (domain) { + this._domainScopedOptions.set(domain, options ?? {}); + } else { + this._defaultOptions = options ?? {}; + } + let skipInitialization = false; let validateContextError: unknown; if (context) { @@ -326,8 +348,6 @@ export class OpenFeatureAPI const domain = stringOrUndefined(domainOrContext); const context = objectOrUndefined(domainOrContext) ?? objectOrUndefined(contextOrUndefined) ?? {}; - // TODO: We need to store and call `validateContext` here if provided in `setProvider` options - if (domain) { const wrapper = this._domainScopedProviders.get(domain); if (wrapper) { @@ -468,8 +488,19 @@ export class OpenFeatureAPI const providerName = wrapper.provider?.metadata?.name || 'unnamed-provider'; try { + // validate the context to decide if we should run the context change handler. + const options = this.getProviderOptions(domain); + if (typeof options.validateContext === 'function') { + if (!options.validateContext(newContext)) { + this._logger.debug( + `Skipping context change for domain '${domain ?? 'default'}' due to validateContext returning false.`, + ); + return; + } + } + // if the provider hasn't initialized yet, and isn't actively initializing, initialize instead of running context change handler - // the provider will be in this state if the user requested delayed initialization until the first context change + // the provider will be in this state if validateContext was used during setProvider to skip initialization const initializationPromise = this.initializeProviderForDomain(wrapper, domain); if (initializationPromise) { await initializationPromise; From 9c3e82ea30f52009e5a41b8bd3df8f220b8f9909 Mon Sep 17 00:00:00 2001 From: MattIPv4 Date: Tue, 25 Nov 2025 22:19:02 +0000 Subject: [PATCH 7/9] Add test suite for validateContext option Signed-off-by: MattIPv4 --- packages/web/test/validate-context.spec.ts | 293 +++++++++++++++++++++ 1 file changed, 293 insertions(+) create mode 100644 packages/web/test/validate-context.spec.ts diff --git a/packages/web/test/validate-context.spec.ts b/packages/web/test/validate-context.spec.ts new file mode 100644 index 000000000..f858bf1c9 --- /dev/null +++ b/packages/web/test/validate-context.spec.ts @@ -0,0 +1,293 @@ +import type { JsonValue, Provider, ProviderMetadata, ResolutionDetails } from '../src'; +import { NOOP_PROVIDER, OpenFeature, ProviderStatus } from '../src'; + +const initializeMock = jest.fn().mockResolvedValue(undefined); +const contextChangeMock = jest.fn().mockResolvedValue(undefined); + +class MockProvider implements Provider { + readonly metadata: ProviderMetadata; + + constructor(options?: { name?: string }) { + this.metadata = { name: options?.name ?? 'mock-provider' }; + } + + initialize = initializeMock; + onContextChange = contextChangeMock; + + resolveBooleanEvaluation(): ResolutionDetails { + throw new Error('Not implemented'); + } + + resolveNumberEvaluation(): ResolutionDetails { + throw new Error('Not implemented'); + } + + resolveObjectEvaluation(): ResolutionDetails { + throw new Error('Not implemented'); + } + + resolveStringEvaluation(): ResolutionDetails { + throw new Error('Not implemented'); + } +} + +describe('validateContext', () => { + afterEach(async () => { + await OpenFeature.clearContexts(); + await OpenFeature.setProviderAndWait(NOOP_PROVIDER, {}); + jest.clearAllMocks(); + }); + + describe('when validateContext is not provided', () => { + it('should call initialize on setProvider', async () => { + const provider = new MockProvider(); + OpenFeature.setProvider(provider, {}); + + await new Promise(process.nextTick); + + expect(initializeMock).toHaveBeenCalledTimes(1); + expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.READY); + }); + + it('should call initialize on setProviderAndWait', async () => { + const provider = new MockProvider(); + await OpenFeature.setProviderAndWait(provider, {}); + + expect(initializeMock).toHaveBeenCalledTimes(1); + expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.READY); + }); + + it('should not call initialize on context change', async () => { + const provider = new MockProvider(); + await OpenFeature.setProviderAndWait(provider, {}); + + expect(initializeMock).toHaveBeenCalledTimes(1); + expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.READY); + + await OpenFeature.setContext({ user: 'test-user' }); + + expect(initializeMock).toHaveBeenCalledTimes(1); + expect(contextChangeMock).toHaveBeenCalledTimes(1); + }); + }); + + describe('when validateContext evaluates to true', () => { + it('should call initialize on setProvider', async () => { + const provider = new MockProvider(); + const validateContext = jest.fn().mockReturnValue(true); + OpenFeature.setProvider(provider, {}, { validateContext }); + + await new Promise(process.nextTick); + + expect(validateContext).toHaveBeenCalledTimes(1); + expect(initializeMock).toHaveBeenCalledTimes(1); + expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.READY); + }); + + it('should call initialize on setProviderAndWait', async () => { + const provider = new MockProvider(); + const validateContext = jest.fn().mockReturnValue(true); + await OpenFeature.setProviderAndWait(provider, {}, { validateContext }); + + expect(validateContext).toHaveBeenCalledTimes(1); + expect(initializeMock).toHaveBeenCalledTimes(1); + expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.READY); + }); + + describe('when the provider is initialized', () => { + it('should not call initialize again on context change', async () => { + const provider = new MockProvider(); + const validateContext = jest.fn().mockReturnValue(true); + await OpenFeature.setProviderAndWait(provider, {}, { validateContext }); + + expect(validateContext).toHaveBeenCalledTimes(1); + expect(initializeMock).toHaveBeenCalledTimes(1); + expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.READY); + + await OpenFeature.setContext({ user: 'test-user' }); + + expect(validateContext).toHaveBeenCalledTimes(2); + expect(initializeMock).toHaveBeenCalledTimes(1); + expect(contextChangeMock).toHaveBeenCalledTimes(1); + }); + }); + + describe('when the provider is not yet initialized', () => { + it('should call initialize on the first valid context change', async () => { + const provider = new MockProvider(); + const validateContext = jest.fn().mockReturnValue(false); + OpenFeature.setProvider(provider, {}, { validateContext }); + + expect(validateContext).toHaveBeenCalledTimes(1); + expect(initializeMock).toHaveBeenCalledTimes(0); + expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.NOT_READY); + + validateContext.mockReturnValue(true); + await OpenFeature.setContext({ user: 'test-user' }); + + expect(validateContext).toHaveBeenCalledTimes(2); + expect(initializeMock).toHaveBeenCalledTimes(1); + expect(contextChangeMock).toHaveBeenCalledTimes(0); + expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.READY); + + await OpenFeature.setContext({ user: 'another-user' }); + + expect(validateContext).toHaveBeenCalledTimes(3); + expect(initializeMock).toHaveBeenCalledTimes(1); + expect(contextChangeMock).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('when validateContext evaluates to false', () => { + it('should not call initialize on setProvider', async () => { + const provider = new MockProvider(); + const validateContext = jest.fn().mockReturnValue(false); + OpenFeature.setProvider(provider, {}, { validateContext }); + + await new Promise(process.nextTick); + + expect(validateContext).toHaveBeenCalledTimes(1); + expect(initializeMock).toHaveBeenCalledTimes(0); + expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.NOT_READY); + }); + + it('should not call initialize on setProviderAndWait', async () => { + const provider = new MockProvider(); + const validateContext = jest.fn().mockReturnValue(false); + await OpenFeature.setProviderAndWait(provider, {}, { validateContext }); + + expect(validateContext).toHaveBeenCalledTimes(1); + expect(initializeMock).toHaveBeenCalledTimes(0); + expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.NOT_READY); + }); + + describe('when the provider is initialized', () => { + it('should not process a context change that fails validation', async () => { + const provider = new MockProvider(); + const validateContext = jest.fn().mockReturnValue(true); + await OpenFeature.setProviderAndWait(provider, {}, { validateContext }); + + expect(validateContext).toHaveBeenCalledTimes(1); + expect(initializeMock).toHaveBeenCalledTimes(1); + expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.READY); + + validateContext.mockReturnValue(false); + await OpenFeature.setContext({ user: 'test-user' }); + + expect(validateContext).toHaveBeenCalledTimes(2); + expect(initializeMock).toHaveBeenCalledTimes(1); + expect(contextChangeMock).toHaveBeenCalledTimes(0); + }); + }); + + describe('when the provider is not yet initialized', () => { + it('should not call initialize until a valid context is provided', async () => { + const provider = new MockProvider(); + const validateContext = jest.fn().mockReturnValue(false); + OpenFeature.setProvider(provider, {}, { validateContext }); + + expect(validateContext).toHaveBeenCalledTimes(1); + expect(initializeMock).toHaveBeenCalledTimes(0); + expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.NOT_READY); + + await OpenFeature.setContext({ user: 'test-user' }); + + expect(validateContext).toHaveBeenCalledTimes(2); + expect(initializeMock).toHaveBeenCalledTimes(0); + expect(contextChangeMock).toHaveBeenCalledTimes(0); + expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.NOT_READY); + + validateContext.mockReturnValue(true); + await OpenFeature.setContext({ user: 'another-user' }); + + expect(validateContext).toHaveBeenCalledTimes(3); + expect(initializeMock).toHaveBeenCalledTimes(1); + expect(contextChangeMock).toHaveBeenCalledTimes(0); + expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.READY); + + await OpenFeature.setContext({ user: 'final-user' }); + + expect(validateContext).toHaveBeenCalledTimes(4); + expect(initializeMock).toHaveBeenCalledTimes(1); + expect(contextChangeMock).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('when validateContext throws an error', () => { + it('should move to ERROR status on setProvider', async () => { + const provider = new MockProvider(); + const validateContext = jest.fn().mockImplementation(() => { + throw new Error('Validation error'); + }); + OpenFeature.setProvider(provider, {}, { validateContext }); + + await new Promise(process.nextTick); + + expect(validateContext).toHaveBeenCalledTimes(1); + expect(initializeMock).toHaveBeenCalledTimes(0); + expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.ERROR); + }); + + it('should propagate error on setProviderAndWait', async () => { + const provider = new MockProvider(); + const validateContext = jest.fn().mockImplementation(() => { + throw new Error('Validation error'); + }); + + await expect(OpenFeature.setProviderAndWait(provider, {}, { validateContext })).rejects.toThrow( + 'Validation error', + ); + + expect(validateContext).toHaveBeenCalledTimes(1); + expect(initializeMock).toHaveBeenCalledTimes(0); + expect(OpenFeature.getProvider()).toBe(NOOP_PROVIDER); + expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.READY); + }); + + describe('when the provider is initialized', () => { + it('should move to ERROR status on context change', async () => { + const provider = new MockProvider(); + const validateContext = jest.fn().mockReturnValue(true); + await OpenFeature.setProviderAndWait(provider, {}, { validateContext }); + + expect(validateContext).toHaveBeenCalledTimes(1); + expect(initializeMock).toHaveBeenCalledTimes(1); + expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.READY); + + validateContext.mockImplementation(() => { + throw new Error('Validation error'); + }); + await OpenFeature.setContext({ user: 'test-user' }); + + expect(validateContext).toHaveBeenCalledTimes(2); + expect(initializeMock).toHaveBeenCalledTimes(1); + expect(contextChangeMock).toHaveBeenCalledTimes(0); + expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.ERROR); + }); + }); + + describe('when the provider is not yet initialized', () => { + it('should move to ERROR status on context change', async () => { + const provider = new MockProvider(); + const validateContext = jest.fn().mockReturnValue(false); + await OpenFeature.setProviderAndWait(provider, {}, { validateContext }); + + expect(validateContext).toHaveBeenCalledTimes(1); + expect(initializeMock).toHaveBeenCalledTimes(0); + expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.NOT_READY); + + validateContext.mockImplementation(() => { + throw new Error('Validation error'); + }); + await OpenFeature.setContext({ user: 'test-user' }); + + expect(validateContext).toHaveBeenCalledTimes(2); + expect(initializeMock).toHaveBeenCalledTimes(0); + expect(contextChangeMock).toHaveBeenCalledTimes(0); + expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.ERROR); + }); + }); + }); +}); From cafd9df2ffd9dfbb0a15fc3117f1f55581240c6f Mon Sep 17 00:00:00 2001 From: MattIPv4 Date: Tue, 25 Nov 2025 22:38:36 +0000 Subject: [PATCH 8/9] Clarify stored context update when reconcile skipped Signed-off-by: MattIPv4 --- packages/web/src/open-feature.ts | 3 ++- packages/web/test/validate-context.spec.ts | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/web/src/open-feature.ts b/packages/web/src/open-feature.ts index 50bf46d9c..0f5943077 100644 --- a/packages/web/src/open-feature.ts +++ b/packages/web/src/open-feature.ts @@ -12,7 +12,7 @@ interface ProviderOptions { * If provided, will be used to check if the current context is valid during initialization and context changes. * When calling `setProvider`, returning `false` will skip provider initialization. Throwing will move the provider to the ERROR state. * When calling `setProviderAndWait`, returning `false` will skip provider initialization. Throwing will reject the promise. - * When calling `setContext`, returning `false` will skip provider context change handling. Throwing will move the provider to the ERROR state. + * When calling `setContext`, returning `false` will skip provider reconciliation of the context change. Throwing will move the provider to the ERROR state. * @param context The evaluation context to validate. */ validateContext?: (context: EvaluationContext) => boolean; @@ -489,6 +489,7 @@ export class OpenFeatureAPI try { // validate the context to decide if we should run the context change handler. + // notably, the stored context will still be updated by this point, this just skips reconciliation. const options = this.getProviderOptions(domain); if (typeof options.validateContext === 'function') { if (!options.validateContext(newContext)) { diff --git a/packages/web/test/validate-context.spec.ts b/packages/web/test/validate-context.spec.ts index f858bf1c9..24a6e090b 100644 --- a/packages/web/test/validate-context.spec.ts +++ b/packages/web/test/validate-context.spec.ts @@ -68,6 +68,7 @@ describe('validateContext', () => { expect(initializeMock).toHaveBeenCalledTimes(1); expect(contextChangeMock).toHaveBeenCalledTimes(1); + expect(OpenFeature.getContext()).toEqual({ user: 'test-user' }); }); }); @@ -109,6 +110,7 @@ describe('validateContext', () => { expect(validateContext).toHaveBeenCalledTimes(2); expect(initializeMock).toHaveBeenCalledTimes(1); expect(contextChangeMock).toHaveBeenCalledTimes(1); + expect(OpenFeature.getContext()).toEqual({ user: 'test-user' }); }); }); @@ -128,6 +130,7 @@ describe('validateContext', () => { expect(validateContext).toHaveBeenCalledTimes(2); expect(initializeMock).toHaveBeenCalledTimes(1); expect(contextChangeMock).toHaveBeenCalledTimes(0); + expect(OpenFeature.getContext()).toEqual({ user: 'test-user' }); expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.READY); await OpenFeature.setContext({ user: 'another-user' }); @@ -135,6 +138,7 @@ describe('validateContext', () => { expect(validateContext).toHaveBeenCalledTimes(3); expect(initializeMock).toHaveBeenCalledTimes(1); expect(contextChangeMock).toHaveBeenCalledTimes(1); + expect(OpenFeature.getContext()).toEqual({ user: 'another-user' }); }); }); }); @@ -178,6 +182,7 @@ describe('validateContext', () => { expect(validateContext).toHaveBeenCalledTimes(2); expect(initializeMock).toHaveBeenCalledTimes(1); expect(contextChangeMock).toHaveBeenCalledTimes(0); + expect(OpenFeature.getContext()).toEqual({ user: 'test-user' }); }); }); @@ -196,6 +201,7 @@ describe('validateContext', () => { expect(validateContext).toHaveBeenCalledTimes(2); expect(initializeMock).toHaveBeenCalledTimes(0); expect(contextChangeMock).toHaveBeenCalledTimes(0); + expect(OpenFeature.getContext()).toEqual({ user: 'test-user' }); expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.NOT_READY); validateContext.mockReturnValue(true); @@ -204,6 +210,7 @@ describe('validateContext', () => { expect(validateContext).toHaveBeenCalledTimes(3); expect(initializeMock).toHaveBeenCalledTimes(1); expect(contextChangeMock).toHaveBeenCalledTimes(0); + expect(OpenFeature.getContext()).toEqual({ user: 'another-user' }); expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.READY); await OpenFeature.setContext({ user: 'final-user' }); @@ -211,6 +218,7 @@ describe('validateContext', () => { expect(validateContext).toHaveBeenCalledTimes(4); expect(initializeMock).toHaveBeenCalledTimes(1); expect(contextChangeMock).toHaveBeenCalledTimes(1); + expect(OpenFeature.getContext()).toEqual({ user: 'final-user' }); }); }); }); @@ -264,6 +272,7 @@ describe('validateContext', () => { expect(validateContext).toHaveBeenCalledTimes(2); expect(initializeMock).toHaveBeenCalledTimes(1); expect(contextChangeMock).toHaveBeenCalledTimes(0); + expect(OpenFeature.getContext()).toEqual({ user: 'test-user' }); expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.ERROR); }); }); @@ -286,6 +295,7 @@ describe('validateContext', () => { expect(validateContext).toHaveBeenCalledTimes(2); expect(initializeMock).toHaveBeenCalledTimes(0); expect(contextChangeMock).toHaveBeenCalledTimes(0); + expect(OpenFeature.getContext()).toEqual({ user: 'test-user' }); expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.ERROR); }); }); From 96f5f42c65b06748bad30ed39458117bffdc9116 Mon Sep 17 00:00:00 2001 From: MattIPv4 Date: Wed, 26 Nov 2025 01:22:09 +0000 Subject: [PATCH 9/9] Deduplicate complex event payloads Signed-off-by: MattIPv4 --- packages/shared/src/open-feature.ts | 31 +++++++++++------------------ packages/web/src/open-feature.ts | 18 +++++++---------- 2 files changed, 19 insertions(+), 30 deletions(-) diff --git a/packages/shared/src/open-feature.ts b/packages/shared/src/open-feature.ts index 2c360a2aa..06a416dda 100644 --- a/packages/shared/src/open-feature.ts +++ b/packages/shared/src/open-feature.ts @@ -252,19 +252,13 @@ export abstract class OpenFeatureCommonAPI< .initialize(domain ? (this._domainScopedContext.get(domain) ?? this._context) : this._context) .then(() => { wrapper.status = this._statusEnumType.READY; + const payload = { clientName: domain, domain, providerName: wrapper.provider.metadata.name }; + // fetch the most recent event emitters, some may have been added during init this.getAssociatedEventEmitters(domain).forEach((emitter) => { - emitter?.emit(AllProviderEvents.Ready, { - clientName: domain, - domain, - providerName: wrapper.provider.metadata.name, - }); - }); - this._apiEmitter?.emit(AllProviderEvents.Ready, { - clientName: domain, - domain, - providerName: wrapper.provider.metadata.name, + emitter?.emit(AllProviderEvents.Ready, { ...payload }); }); + this._apiEmitter?.emit(AllProviderEvents.Ready, { ...payload }); }) .catch((error) => { // if this is a fatal error, transition to FATAL status @@ -273,20 +267,19 @@ export abstract class OpenFeatureCommonAPI< } else { wrapper.status = this._statusEnumType.ERROR; } - this.getAssociatedEventEmitters(domain).forEach((emitter) => { - emitter?.emit(AllProviderEvents.Error, { - clientName: domain, - domain, - providerName: wrapper.provider.metadata.name, - message: error?.message, - }); - }); - this._apiEmitter?.emit(AllProviderEvents.Error, { + const payload = { clientName: domain, domain, providerName: wrapper.provider.metadata.name, message: error?.message, + }; + + // fetch the most recent event emitters, some may have been added during init + this.getAssociatedEventEmitters(domain).forEach((emitter) => { + emitter?.emit(AllProviderEvents.Error, { ...payload }); }); + this._apiEmitter?.emit(AllProviderEvents.Error, { ...payload }); + // rethrow after emitting error events, so that public methods can control error handling throw error; }) diff --git a/packages/web/src/open-feature.ts b/packages/web/src/open-feature.ts index 0f5943077..d6d1d00e8 100644 --- a/packages/web/src/open-feature.ts +++ b/packages/web/src/open-feature.ts @@ -276,21 +276,17 @@ export class OpenFeatureAPI const wrapper = domain ? this._domainScopedProviders.get(domain) : this._defaultProvider; if (wrapper) { wrapper.status = this._statusEnumType.ERROR; - const providerName = wrapper.provider?.metadata?.name || 'unnamed-provider'; - this.getAssociatedEventEmitters(domain).forEach((emitter) => { - emitter?.emit(ProviderEvents.Error, { - clientName: domain, - domain, - providerName, - message: `Error validating context during setProvider: ${validateContextError instanceof Error ? validateContextError.message : String(validateContextError)}`, - }); - }); - this._apiEmitter?.emit(ProviderEvents.Error, { + const payload = { clientName: domain, domain, - providerName, + providerName: wrapper.provider?.metadata?.name || 'unnamed-provider', message: `Error validating context during setProvider: ${validateContextError instanceof Error ? validateContextError.message : String(validateContextError)}`, + }; + + this.getAssociatedEventEmitters(domain).forEach((emitter) => { + emitter?.emit(ProviderEvents.Error, { ...payload }); }); + this._apiEmitter?.emit(ProviderEvents.Error, { ...payload }); this._logger.error('Error validating context during setProvider:', validateContextError); } }