From 842e9986948b29caf81014b0c047ab64f11bd783 Mon Sep 17 00:00:00 2001 From: Krishan Kant Sharma Date: Fri, 19 Jun 2026 12:57:37 -0500 Subject: [PATCH 1/2] fix: reset API state on close Implements spec requirement 1.6.2: close() now fully resets all API-managed state after provider shutdown, including providers, global and domain-scoped hooks, event handlers, global evaluation context, domain-scoped evaluation context, and (server) transaction context propagator. clearProviders() remains provider-only for backward compatibility, as guided by the maintainers. Fixes #1374. Signed-off-by: Krishan Kant Sharma --- .../nest/test/open-feature.module.spec.ts | 40 ++++---- packages/server/src/open-feature.ts | 7 ++ packages/server/test/open-feature.spec.ts | 94 ++++++++++++++++++- packages/shared/src/open-feature.ts | 40 +++++--- packages/web/src/open-feature.ts | 6 ++ packages/web/test/open-feature.spec.ts | 80 +++++++++++++++- 6 files changed, 229 insertions(+), 38 deletions(-) diff --git a/packages/nest/test/open-feature.module.spec.ts b/packages/nest/test/open-feature.module.spec.ts index 38e23c5867..c0dc785fb4 100644 --- a/packages/nest/test/open-feature.module.spec.ts +++ b/packages/nest/test/open-feature.module.spec.ts @@ -19,25 +19,6 @@ describe('OpenFeatureModule', () => { await moduleRef.close(); }); - describe('without configured providers', () => { - let moduleWithoutProvidersRef: TestingModule; - beforeAll(async () => { - moduleWithoutProvidersRef = await Test.createTestingModule({ - imports: [OpenFeatureModule.forRoot({})], - }).compile(); - }); - - afterAll(async () => { - await moduleWithoutProvidersRef.close(); - }); - - it('should return the SDKs default provider and not throw', async () => { - expect(() => { - moduleWithoutProvidersRef.get(getOpenFeatureClientToken()); - }).not.toThrow(); - }); - }); - it('should return the default provider', async () => { const client = moduleRef.get(getOpenFeatureClientToken()); expect(client).toBeDefined(); @@ -91,6 +72,27 @@ describe('OpenFeatureModule', () => { OpenFeature.clearHooks(); } }); + + // Placed after provider-dependent tests: closing this inner module calls OpenFeature.close() + // which now fully resets API state per spec requirement 1.6.2. + describe('without configured providers', () => { + let moduleWithoutProvidersRef: TestingModule; + beforeAll(async () => { + moduleWithoutProvidersRef = await Test.createTestingModule({ + imports: [OpenFeatureModule.forRoot({})], + }).compile(); + }); + + afterAll(async () => { + await moduleWithoutProvidersRef.close(); + }); + + it('should return the SDKs default provider and not throw', async () => { + expect(() => { + moduleWithoutProvidersRef.get(getOpenFeatureClientToken()); + }).not.toThrow(); + }); + }); }); describe('handlers', () => { diff --git a/packages/server/src/open-feature.ts b/packages/server/src/open-feature.ts index 1bc491937d..511325134e 100644 --- a/packages/server/src/open-feature.ts +++ b/packages/server/src/open-feature.ts @@ -219,6 +219,13 @@ export class OpenFeatureAPI ); } + async close(): Promise { + await super.close(); + this._domainScopedProviders.clear(); + this._defaultProvider = new ProviderWrapper(NOOP_PROVIDER, ProviderStatus.NOT_READY, this._statusEnumType); + this._transactionContextPropagator = NOOP_TRANSACTION_CONTEXT_PROPAGATOR; + } + /** * Clears all registered providers and resets the default provider. * @returns {Promise} diff --git a/packages/server/test/open-feature.spec.ts b/packages/server/test/open-feature.spec.ts index fe41f83542..0fbad5afc5 100644 --- a/packages/server/test/open-feature.spec.ts +++ b/packages/server/test/open-feature.spec.ts @@ -1,6 +1,6 @@ import type { Paradigm } from '@openfeature/core'; -import type { Provider, ProviderStatus } from '../src'; -import { OpenFeature, OpenFeatureAPI } from '../src'; +import type { Hook, Provider, ProviderStatus, TransactionContextPropagator } from '../src'; +import { NOOP_PROVIDER, OpenFeature, OpenFeatureAPI, ProviderEvents } from '../src'; import { OpenFeatureClient } from '../src/client/internal/open-feature-client'; const mockProvider = (config?: { initialStatus?: ProviderStatus; runsOn?: Paradigm }) => { @@ -230,4 +230,94 @@ describe('OpenFeature', () => { expect(provider3.onClose).toHaveBeenCalled(); }); }); + + describe('Requirement 1.6.2', () => { + it('resets the default provider to no-op after close()', async () => { + const provider = mockProvider(); + OpenFeature.setProvider(provider); + expect(OpenFeature.providerMetadata.name).toBe('mock-events-success'); + await OpenFeature.close(); + expect(OpenFeature.providerMetadata.name).toBe(NOOP_PROVIDER.metadata.name); + }); + + it('clears domain-scoped providers after close()', async () => { + const provider = mockProvider(); + OpenFeature.setProvider('domain1', provider); + expect(OpenFeature.getProvider('domain1')).toBe(provider); + await OpenFeature.close(); + expect(OpenFeature.getProvider('domain1').metadata.name).toBe(NOOP_PROVIDER.metadata.name); + }); + + it('clears global hooks after close()', async () => { + const hook = { before: jest.fn() } as unknown as Hook; + OpenFeature.addHooks(hook); + expect(OpenFeature.getHooks()).toHaveLength(1); + await OpenFeature.close(); + expect(OpenFeature.getHooks()).toHaveLength(0); + }); + + it('clears global evaluation context after close()', async () => { + OpenFeature.setContext({ user: 'test' }); + expect(OpenFeature.getContext()).toEqual({ user: 'test' }); + await OpenFeature.close(); + expect(OpenFeature.getContext()).toEqual({}); + }); + + it('removes API-level event handlers after close()', async () => { + const handler = jest.fn(); + OpenFeature.addHandler(ProviderEvents.Ready, handler); + expect(OpenFeature.getHandlers(ProviderEvents.Ready)).toHaveLength(1); + await OpenFeature.close(); + expect(OpenFeature.getHandlers(ProviderEvents.Ready)).toHaveLength(0); + }); + + it('resets the transaction context propagator to the default after close()', async () => { + const customPropagator: TransactionContextPropagator = { + getTransactionContext: jest.fn(() => ({ custom: true })), + setTransactionContext: jest.fn(), + }; + OpenFeature.setTransactionContextPropagator(customPropagator); + expect(OpenFeature.getTransactionContext()).toEqual({ custom: true }); + await OpenFeature.close(); + expect(OpenFeature.getTransactionContext()).toEqual({}); + }); + }); + + describe('clearProviders() remains provider-only', () => { + afterEach(async () => { + OpenFeature.clearHooks(); + OpenFeature.clearHandlers(); + OpenFeature.setContext({}); + }); + + it('does not clear global hooks', async () => { + const hook = { before: jest.fn() } as unknown as Hook; + OpenFeature.addHooks(hook); + await OpenFeature.clearProviders(); + expect(OpenFeature.getHooks()).toHaveLength(1); + }); + + it('does not clear global evaluation context', async () => { + OpenFeature.setContext({ user: 'test' }); + await OpenFeature.clearProviders(); + expect(OpenFeature.getContext()).toEqual({ user: 'test' }); + }); + + it('does not remove API-level event handlers', async () => { + const handler = jest.fn(); + OpenFeature.addHandler(ProviderEvents.Ready, handler); + await OpenFeature.clearProviders(); + expect(OpenFeature.getHandlers(ProviderEvents.Ready)).toHaveLength(1); + }); + + it('does not reset the transaction context propagator', async () => { + const customPropagator: TransactionContextPropagator = { + getTransactionContext: jest.fn(() => ({ custom: true })), + setTransactionContext: jest.fn(), + }; + OpenFeature.setTransactionContextPropagator(customPropagator); + await OpenFeature.clearProviders(); + expect(OpenFeature.getTransactionContext()).toEqual({ custom: true }); + }); + }); }); diff --git a/packages/shared/src/open-feature.ts b/packages/shared/src/open-feature.ts index 00142b5670..bde3e25c40 100644 --- a/packages/shared/src/open-feature.ts +++ b/packages/shared/src/open-feature.ts @@ -395,6 +395,31 @@ export abstract class OpenFeatureCommonAPI< } async close(): Promise { + await this._shutdownAllProviders(); + this._hooks = []; + this._context = {}; + this._domainScopedContext.clear(); + this._clientEventHandlers.clear(); + this._clientEvents.clear(); + this._apiEmitter.removeAllHandlers(); + } + + protected async clearProvidersAndSetDefault(defaultProvider: P): Promise { + try { + await this._shutdownAllProviders(); + } catch (err) { + this._logger.error('Unable to cleanly close providers. Resetting to the default configuration.'); + } finally { + this._domainScopedProviders.clear(); + this._defaultProvider = new ProviderWrapper( + defaultProvider, + this._statusEnumType.NOT_READY, + this._statusEnumType, + ); + } + } + + private async _shutdownAllProviders(): Promise { try { await this?._defaultProvider.provider?.onClose?.(); } catch (err) { @@ -414,21 +439,6 @@ export abstract class OpenFeatureCommonAPI< ); } - protected async clearProvidersAndSetDefault(defaultProvider: P): Promise { - try { - await this.close(); - } catch (err) { - this._logger.error('Unable to cleanly close providers. Resetting to the default configuration.'); - } finally { - this._domainScopedProviders.clear(); - this._defaultProvider = new ProviderWrapper( - defaultProvider, - this._statusEnumType.NOT_READY, - this._statusEnumType, - ); - } - } - private get allProviders(): P[] { return [ ...[...this._domainScopedProviders.values()].map((wrappers) => wrappers.provider), diff --git a/packages/web/src/open-feature.ts b/packages/web/src/open-feature.ts index 131f8c2731..bc0796d09c 100644 --- a/packages/web/src/open-feature.ts +++ b/packages/web/src/open-feature.ts @@ -361,6 +361,12 @@ export class OpenFeatureAPI ); } + async close(): Promise { + await super.close(); + this._domainScopedProviders.clear(); + this._defaultProvider = new ProviderWrapper(NOOP_PROVIDER, ProviderStatus.NOT_READY, this._statusEnumType); + } + /** * Clears all registered providers and resets the default provider. * @returns {Promise} diff --git a/packages/web/test/open-feature.spec.ts b/packages/web/test/open-feature.spec.ts index 308d8f43b4..17f816db19 100644 --- a/packages/web/test/open-feature.spec.ts +++ b/packages/web/test/open-feature.spec.ts @@ -1,6 +1,6 @@ import type { Paradigm } from '@openfeature/core'; -import type { Provider } from '../src'; -import { OpenFeature, OpenFeatureAPI, ProviderStatus } from '../src'; +import type { Hook, Provider } from '../src'; +import { NOOP_PROVIDER, OpenFeature, OpenFeatureAPI, ProviderEvents, ProviderStatus } from '../src'; import { OpenFeatureClient } from '../src/client/internal/open-feature-client'; const mockProvider = (config?: { initialStatus?: ProviderStatus; runsOn?: Paradigm }) => { @@ -256,4 +256,80 @@ describe('OpenFeature', () => { }); }); }); + + describe('Requirement 1.6.2', () => { + it('resets the default provider to no-op after close()', async () => { + const provider = mockProvider(); + OpenFeature.setProvider(provider); + expect(OpenFeature.providerMetadata.name).toBe('mock-events-success'); + await OpenFeature.close(); + expect(OpenFeature.providerMetadata.name).toBe(NOOP_PROVIDER.metadata.name); + }); + + it('clears domain-scoped providers after close()', async () => { + const provider = mockProvider(); + OpenFeature.setProvider('domain1', provider); + expect(OpenFeature.getProvider('domain1')).toBe(provider); + await OpenFeature.close(); + expect(OpenFeature.getProvider('domain1').metadata.name).toBe(NOOP_PROVIDER.metadata.name); + }); + + it('clears global hooks after close()', async () => { + const hook = { before: jest.fn() } as unknown as Hook; + OpenFeature.addHooks(hook); + expect(OpenFeature.getHooks()).toHaveLength(1); + await OpenFeature.close(); + expect(OpenFeature.getHooks()).toHaveLength(0); + }); + + it('clears global evaluation context after close()', async () => { + await OpenFeature.setContext({ user: 'test' }); + expect(OpenFeature.getContext()).toEqual({ user: 'test' }); + await OpenFeature.close(); + expect(OpenFeature.getContext()).toEqual({}); + }); + + it('clears domain-scoped evaluation context after close()', async () => { + await OpenFeature.setContext('domain1', { user: 'test' }); + expect(OpenFeature.getContext('domain1')).toEqual({ user: 'test' }); + await OpenFeature.close(); + expect(OpenFeature.getContext('domain1')).toEqual({}); + }); + + it('removes API-level event handlers after close()', async () => { + const handler = jest.fn(); + OpenFeature.addHandler(ProviderEvents.Ready, handler); + expect(OpenFeature.getHandlers(ProviderEvents.Ready)).toHaveLength(1); + await OpenFeature.close(); + expect(OpenFeature.getHandlers(ProviderEvents.Ready)).toHaveLength(0); + }); + }); + + describe('clearProviders() remains provider-only', () => { + afterEach(async () => { + OpenFeature.clearHooks(); + OpenFeature.clearHandlers(); + await OpenFeature.setContext({}); + }); + + it('does not clear global hooks', async () => { + const hook = { before: jest.fn() } as unknown as Hook; + OpenFeature.addHooks(hook); + await OpenFeature.clearProviders(); + expect(OpenFeature.getHooks()).toHaveLength(1); + }); + + it('does not clear global evaluation context', async () => { + await OpenFeature.setContext({ user: 'test' }); + await OpenFeature.clearProviders(); + expect(OpenFeature.getContext()).toEqual({ user: 'test' }); + }); + + it('does not remove API-level event handlers', async () => { + const handler = jest.fn(); + OpenFeature.addHandler(ProviderEvents.Ready, handler); + await OpenFeature.clearProviders(); + expect(OpenFeature.getHandlers(ProviderEvents.Ready)).toHaveLength(1); + }); + }); }); From 237053184ef95d602e5a57673cfa3f6aaf3783ce Mon Sep 17 00:00:00 2001 From: Krishan Kant Sharma Date: Fri, 19 Jun 2026 14:38:30 -0500 Subject: [PATCH 2/2] fix: deduplicate provider shutdown on close Signed-off-by: Krishan Kant Sharma --- packages/server/test/open-feature.spec.ts | 8 ++++++++ packages/shared/src/open-feature.ts | 17 +++++++---------- packages/web/src/open-feature.ts | 1 - packages/web/test/open-feature.spec.ts | 15 +++++++++++++++ 4 files changed, 30 insertions(+), 11 deletions(-) diff --git a/packages/server/test/open-feature.spec.ts b/packages/server/test/open-feature.spec.ts index 0fbad5afc5..8e91e3d475 100644 --- a/packages/server/test/open-feature.spec.ts +++ b/packages/server/test/open-feature.spec.ts @@ -281,6 +281,14 @@ describe('OpenFeature', () => { await OpenFeature.close(); expect(OpenFeature.getTransactionContext()).toEqual({}); }); + + it('calls provider onClose only once when the same provider instance is registered in multiple scopes', async () => { + const provider = mockProvider(); + OpenFeature.setProvider(provider); + OpenFeature.setProvider('domain1', provider); + await OpenFeature.close(); + expect(provider.onClose).toHaveBeenCalledTimes(1); + }); }); describe('clearProviders() remains provider-only', () => { diff --git a/packages/shared/src/open-feature.ts b/packages/shared/src/open-feature.ts index bde3e25c40..d491cac450 100644 --- a/packages/shared/src/open-feature.ts +++ b/packages/shared/src/open-feature.ts @@ -420,20 +420,17 @@ export abstract class OpenFeatureCommonAPI< } private async _shutdownAllProviders(): Promise { - try { - await this?._defaultProvider.provider?.onClose?.(); - } catch (err) { - this.handleShutdownError(this._defaultProvider.provider, err); - } - - const wrappers = Array.from(this._domainScopedProviders); + const uniqueProviders = new Set

([ + this._defaultProvider.provider, + ...Array.from(this._domainScopedProviders.values()).map((wrapper) => wrapper.provider), + ]); await Promise.all( - wrappers.map(async ([, wrapper]) => { + Array.from(uniqueProviders).map(async (provider) => { try { - await wrapper?.provider.onClose?.(); + await provider?.onClose?.(); } catch (err) { - this.handleShutdownError(wrapper?.provider, err); + this.handleShutdownError(provider, err); } }), ); diff --git a/packages/web/src/open-feature.ts b/packages/web/src/open-feature.ts index bc0796d09c..9d378dc062 100644 --- a/packages/web/src/open-feature.ts +++ b/packages/web/src/open-feature.ts @@ -373,7 +373,6 @@ export class OpenFeatureAPI */ async clearProviders(): Promise { await super.clearProvidersAndSetDefault(NOOP_PROVIDER); - this._domainScopedContext.clear(); } private async runProviderContextChangeHandler( diff --git a/packages/web/test/open-feature.spec.ts b/packages/web/test/open-feature.spec.ts index 17f816db19..e6af0eedb2 100644 --- a/packages/web/test/open-feature.spec.ts +++ b/packages/web/test/open-feature.spec.ts @@ -303,6 +303,14 @@ describe('OpenFeature', () => { await OpenFeature.close(); expect(OpenFeature.getHandlers(ProviderEvents.Ready)).toHaveLength(0); }); + + it('calls provider onClose only once when the same provider instance is registered in multiple scopes', async () => { + const provider = mockProvider(); + OpenFeature.setProvider(provider); + OpenFeature.setProvider('domain1', provider); + await OpenFeature.close(); + expect(provider.onClose).toHaveBeenCalledTimes(1); + }); }); describe('clearProviders() remains provider-only', () => { @@ -331,5 +339,12 @@ describe('OpenFeature', () => { await OpenFeature.clearProviders(); expect(OpenFeature.getHandlers(ProviderEvents.Ready)).toHaveLength(1); }); + + it('does not clear domain-scoped evaluation context', async () => { + await OpenFeature.setContext('domain1', { user: 'test' }); + await OpenFeature.clearProviders(); + expect(OpenFeature.getContext('domain1')).toEqual({ user: 'test' }); + await OpenFeature.clearContext('domain1'); + }); }); });