From e8e595b691cb70da1256325b38ac0bf27cefe42b 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 | 84 ++++++++++++++++------------- 1 file changed, 47 insertions(+), 37 deletions(-) diff --git a/packages/shared/src/open-feature.ts b/packages/shared/src/open-feature.ts index fc82bc908..36021d1ee 100644 --- a/packages/shared/src/open-feature.ts +++ b/packages/shared/src/open-feature.ts @@ -217,6 +217,47 @@ 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 +291,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 9d767b28a72df026bba002f64fc98d1f305f97d1 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 | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/open-feature.ts b/packages/shared/src/open-feature.ts index 36021d1ee..3684778a1 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; } @@ -218,10 +238,13 @@ export abstract class OpenFeatureCommonAPI< ): this; protected initializeProviderForDomain(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(() => { @@ -255,6 +278,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 840428a914bc4a2010a650ff21d47272bc833616 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 be6c7f845..049b5a4c0 100644 --- a/packages/web/src/open-feature.ts +++ b/packages/web/src/open-feature.ts @@ -389,6 +389,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 f116cad1218d5320513e761dc2b6b9b41e1a3859 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 19d6a7bfc0433e9e58ee6bba465ca87a5898526c 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 | 30 ++++---- packages/web/src/open-feature.ts | 109 +++++++++++++++++++++++++--- 2 files changed, 114 insertions(+), 25 deletions(-) diff --git a/packages/shared/src/open-feature.ts b/packages/shared/src/open-feature.ts index 3684778a1..db12a3b11 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(wrapper: ProviderWrapper, domain?: string): Promise | void { @@ -285,7 +287,7 @@ 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); @@ -318,17 +320,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 049b5a4c0..6e701eb7b 100644 --- a/packages/web/src/open-feature.ts +++ b/packages/web/src/open-feature.ts @@ -16,6 +16,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'); @@ -86,10 +97,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. @@ -107,24 +119,41 @@ 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) { @@ -134,7 +163,7 @@ export class OpenFeatureAPI } } - await this.setAwaitableProvider(domain, provider); + await this.setAwaitableProvider(domain, provider, skipInitialization); } /** @@ -150,10 +179,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. @@ -167,24 +197,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) { @@ -194,7 +250,34 @@ 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 @@ -249,6 +332,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 cc5b891c866536e3a8ecdb038b7e08b3b571cc21 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 6e701eb7b..5317df8d3 100644 --- a/packages/web/src/open-feature.ts +++ b/packages/web/src/open-feature.ts @@ -21,7 +21,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; @@ -53,6 +53,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'); @@ -82,6 +84,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. @@ -141,6 +151,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. @@ -219,6 +235,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) { @@ -332,8 +354,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) { @@ -474,8 +494,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 9f906778127d28a2c23f91730ec5d9aef4df24de 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 b535a19f468caf714c7f0e57113c3eaf54c86862 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 5317df8d3..5df3c75f1 100644 --- a/packages/web/src/open-feature.ts +++ b/packages/web/src/open-feature.ts @@ -21,7 +21,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; @@ -495,6 +495,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 d919ef0d463c4793c97114c1e7fcd328f1b3faf2 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 | 23 ++++++++++++----------- packages/web/src/open-feature.ts | 18 +++++++----------- 2 files changed, 19 insertions(+), 22 deletions(-) diff --git a/packages/shared/src/open-feature.ts b/packages/shared/src/open-feature.ts index db12a3b11..0f48dfe83 100644 --- a/packages/shared/src/open-feature.ts +++ b/packages/shared/src/open-feature.ts @@ -251,11 +251,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 }); + emitter?.emit(AllProviderEvents.Ready, { ...payload }); }); - this._apiEmitter?.emit(AllProviderEvents.Ready, { clientName: domain, domain, providerName: wrapper.provider.metadata.name }); + this._apiEmitter?.emit(AllProviderEvents.Ready, { ...payload }); }) .catch((error) => { // if this is a fatal error, transition to FATAL status @@ -264,20 +266,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 5df3c75f1..f175f23f0 100644 --- a/packages/web/src/open-feature.ts +++ b/packages/web/src/open-feature.ts @@ -282,21 +282,17 @@ export class OpenFeatureAPI : 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); } }