Skip to content

Commit 842e998

Browse files
committed
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 <krishansharma0327@gmail.com>
1 parent 3c01041 commit 842e998

6 files changed

Lines changed: 229 additions & 38 deletions

File tree

packages/nest/test/open-feature.module.spec.ts

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -19,25 +19,6 @@ describe('OpenFeatureModule', () => {
1919
await moduleRef.close();
2020
});
2121

22-
describe('without configured providers', () => {
23-
let moduleWithoutProvidersRef: TestingModule;
24-
beforeAll(async () => {
25-
moduleWithoutProvidersRef = await Test.createTestingModule({
26-
imports: [OpenFeatureModule.forRoot({})],
27-
}).compile();
28-
});
29-
30-
afterAll(async () => {
31-
await moduleWithoutProvidersRef.close();
32-
});
33-
34-
it('should return the SDKs default provider and not throw', async () => {
35-
expect(() => {
36-
moduleWithoutProvidersRef.get<Client>(getOpenFeatureClientToken());
37-
}).not.toThrow();
38-
});
39-
});
40-
4122
it('should return the default provider', async () => {
4223
const client = moduleRef.get<Client>(getOpenFeatureClientToken());
4324
expect(client).toBeDefined();
@@ -91,6 +72,27 @@ describe('OpenFeatureModule', () => {
9172
OpenFeature.clearHooks();
9273
}
9374
});
75+
76+
// Placed after provider-dependent tests: closing this inner module calls OpenFeature.close()
77+
// which now fully resets API state per spec requirement 1.6.2.
78+
describe('without configured providers', () => {
79+
let moduleWithoutProvidersRef: TestingModule;
80+
beforeAll(async () => {
81+
moduleWithoutProvidersRef = await Test.createTestingModule({
82+
imports: [OpenFeatureModule.forRoot({})],
83+
}).compile();
84+
});
85+
86+
afterAll(async () => {
87+
await moduleWithoutProvidersRef.close();
88+
});
89+
90+
it('should return the SDKs default provider and not throw', async () => {
91+
expect(() => {
92+
moduleWithoutProvidersRef.get<Client>(getOpenFeatureClientToken());
93+
}).not.toThrow();
94+
});
95+
});
9496
});
9597

9698
describe('handlers', () => {

packages/server/src/open-feature.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,13 @@ export class OpenFeatureAPI
219219
);
220220
}
221221

222+
async close(): Promise<void> {
223+
await super.close();
224+
this._domainScopedProviders.clear();
225+
this._defaultProvider = new ProviderWrapper(NOOP_PROVIDER, ProviderStatus.NOT_READY, this._statusEnumType);
226+
this._transactionContextPropagator = NOOP_TRANSACTION_CONTEXT_PROPAGATOR;
227+
}
228+
222229
/**
223230
* Clears all registered providers and resets the default provider.
224231
* @returns {Promise<void>}

packages/server/test/open-feature.spec.ts

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Paradigm } from '@openfeature/core';
2-
import type { Provider, ProviderStatus } from '../src';
3-
import { OpenFeature, OpenFeatureAPI } from '../src';
2+
import type { Hook, Provider, ProviderStatus, TransactionContextPropagator } from '../src';
3+
import { NOOP_PROVIDER, OpenFeature, OpenFeatureAPI, ProviderEvents } from '../src';
44
import { OpenFeatureClient } from '../src/client/internal/open-feature-client';
55

66
const mockProvider = (config?: { initialStatus?: ProviderStatus; runsOn?: Paradigm }) => {
@@ -230,4 +230,94 @@ describe('OpenFeature', () => {
230230
expect(provider3.onClose).toHaveBeenCalled();
231231
});
232232
});
233+
234+
describe('Requirement 1.6.2', () => {
235+
it('resets the default provider to no-op after close()', async () => {
236+
const provider = mockProvider();
237+
OpenFeature.setProvider(provider);
238+
expect(OpenFeature.providerMetadata.name).toBe('mock-events-success');
239+
await OpenFeature.close();
240+
expect(OpenFeature.providerMetadata.name).toBe(NOOP_PROVIDER.metadata.name);
241+
});
242+
243+
it('clears domain-scoped providers after close()', async () => {
244+
const provider = mockProvider();
245+
OpenFeature.setProvider('domain1', provider);
246+
expect(OpenFeature.getProvider('domain1')).toBe(provider);
247+
await OpenFeature.close();
248+
expect(OpenFeature.getProvider('domain1').metadata.name).toBe(NOOP_PROVIDER.metadata.name);
249+
});
250+
251+
it('clears global hooks after close()', async () => {
252+
const hook = { before: jest.fn() } as unknown as Hook;
253+
OpenFeature.addHooks(hook);
254+
expect(OpenFeature.getHooks()).toHaveLength(1);
255+
await OpenFeature.close();
256+
expect(OpenFeature.getHooks()).toHaveLength(0);
257+
});
258+
259+
it('clears global evaluation context after close()', async () => {
260+
OpenFeature.setContext({ user: 'test' });
261+
expect(OpenFeature.getContext()).toEqual({ user: 'test' });
262+
await OpenFeature.close();
263+
expect(OpenFeature.getContext()).toEqual({});
264+
});
265+
266+
it('removes API-level event handlers after close()', async () => {
267+
const handler = jest.fn();
268+
OpenFeature.addHandler(ProviderEvents.Ready, handler);
269+
expect(OpenFeature.getHandlers(ProviderEvents.Ready)).toHaveLength(1);
270+
await OpenFeature.close();
271+
expect(OpenFeature.getHandlers(ProviderEvents.Ready)).toHaveLength(0);
272+
});
273+
274+
it('resets the transaction context propagator to the default after close()', async () => {
275+
const customPropagator: TransactionContextPropagator = {
276+
getTransactionContext: jest.fn(() => ({ custom: true })),
277+
setTransactionContext: jest.fn(),
278+
};
279+
OpenFeature.setTransactionContextPropagator(customPropagator);
280+
expect(OpenFeature.getTransactionContext()).toEqual({ custom: true });
281+
await OpenFeature.close();
282+
expect(OpenFeature.getTransactionContext()).toEqual({});
283+
});
284+
});
285+
286+
describe('clearProviders() remains provider-only', () => {
287+
afterEach(async () => {
288+
OpenFeature.clearHooks();
289+
OpenFeature.clearHandlers();
290+
OpenFeature.setContext({});
291+
});
292+
293+
it('does not clear global hooks', async () => {
294+
const hook = { before: jest.fn() } as unknown as Hook;
295+
OpenFeature.addHooks(hook);
296+
await OpenFeature.clearProviders();
297+
expect(OpenFeature.getHooks()).toHaveLength(1);
298+
});
299+
300+
it('does not clear global evaluation context', async () => {
301+
OpenFeature.setContext({ user: 'test' });
302+
await OpenFeature.clearProviders();
303+
expect(OpenFeature.getContext()).toEqual({ user: 'test' });
304+
});
305+
306+
it('does not remove API-level event handlers', async () => {
307+
const handler = jest.fn();
308+
OpenFeature.addHandler(ProviderEvents.Ready, handler);
309+
await OpenFeature.clearProviders();
310+
expect(OpenFeature.getHandlers(ProviderEvents.Ready)).toHaveLength(1);
311+
});
312+
313+
it('does not reset the transaction context propagator', async () => {
314+
const customPropagator: TransactionContextPropagator = {
315+
getTransactionContext: jest.fn(() => ({ custom: true })),
316+
setTransactionContext: jest.fn(),
317+
};
318+
OpenFeature.setTransactionContextPropagator(customPropagator);
319+
await OpenFeature.clearProviders();
320+
expect(OpenFeature.getTransactionContext()).toEqual({ custom: true });
321+
});
322+
});
233323
});

packages/shared/src/open-feature.ts

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,31 @@ export abstract class OpenFeatureCommonAPI<
395395
}
396396

397397
async close(): Promise<void> {
398+
await this._shutdownAllProviders();
399+
this._hooks = [];
400+
this._context = {};
401+
this._domainScopedContext.clear();
402+
this._clientEventHandlers.clear();
403+
this._clientEvents.clear();
404+
this._apiEmitter.removeAllHandlers();
405+
}
406+
407+
protected async clearProvidersAndSetDefault(defaultProvider: P): Promise<void> {
408+
try {
409+
await this._shutdownAllProviders();
410+
} catch (err) {
411+
this._logger.error('Unable to cleanly close providers. Resetting to the default configuration.');
412+
} finally {
413+
this._domainScopedProviders.clear();
414+
this._defaultProvider = new ProviderWrapper<P, AnyProviderStatus>(
415+
defaultProvider,
416+
this._statusEnumType.NOT_READY,
417+
this._statusEnumType,
418+
);
419+
}
420+
}
421+
422+
private async _shutdownAllProviders(): Promise<void> {
398423
try {
399424
await this?._defaultProvider.provider?.onClose?.();
400425
} catch (err) {
@@ -414,21 +439,6 @@ export abstract class OpenFeatureCommonAPI<
414439
);
415440
}
416441

417-
protected async clearProvidersAndSetDefault(defaultProvider: P): Promise<void> {
418-
try {
419-
await this.close();
420-
} catch (err) {
421-
this._logger.error('Unable to cleanly close providers. Resetting to the default configuration.');
422-
} finally {
423-
this._domainScopedProviders.clear();
424-
this._defaultProvider = new ProviderWrapper<P, AnyProviderStatus>(
425-
defaultProvider,
426-
this._statusEnumType.NOT_READY,
427-
this._statusEnumType,
428-
);
429-
}
430-
}
431-
432442
private get allProviders(): P[] {
433443
return [
434444
...[...this._domainScopedProviders.values()].map((wrappers) => wrappers.provider),

packages/web/src/open-feature.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,12 @@ export class OpenFeatureAPI
361361
);
362362
}
363363

364+
async close(): Promise<void> {
365+
await super.close();
366+
this._domainScopedProviders.clear();
367+
this._defaultProvider = new ProviderWrapper(NOOP_PROVIDER, ProviderStatus.NOT_READY, this._statusEnumType);
368+
}
369+
364370
/**
365371
* Clears all registered providers and resets the default provider.
366372
* @returns {Promise<void>}

packages/web/test/open-feature.spec.ts

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Paradigm } from '@openfeature/core';
2-
import type { Provider } from '../src';
3-
import { OpenFeature, OpenFeatureAPI, ProviderStatus } from '../src';
2+
import type { Hook, Provider } from '../src';
3+
import { NOOP_PROVIDER, OpenFeature, OpenFeatureAPI, ProviderEvents, ProviderStatus } from '../src';
44
import { OpenFeatureClient } from '../src/client/internal/open-feature-client';
55

66
const mockProvider = (config?: { initialStatus?: ProviderStatus; runsOn?: Paradigm }) => {
@@ -256,4 +256,80 @@ describe('OpenFeature', () => {
256256
});
257257
});
258258
});
259+
260+
describe('Requirement 1.6.2', () => {
261+
it('resets the default provider to no-op after close()', async () => {
262+
const provider = mockProvider();
263+
OpenFeature.setProvider(provider);
264+
expect(OpenFeature.providerMetadata.name).toBe('mock-events-success');
265+
await OpenFeature.close();
266+
expect(OpenFeature.providerMetadata.name).toBe(NOOP_PROVIDER.metadata.name);
267+
});
268+
269+
it('clears domain-scoped providers after close()', async () => {
270+
const provider = mockProvider();
271+
OpenFeature.setProvider('domain1', provider);
272+
expect(OpenFeature.getProvider('domain1')).toBe(provider);
273+
await OpenFeature.close();
274+
expect(OpenFeature.getProvider('domain1').metadata.name).toBe(NOOP_PROVIDER.metadata.name);
275+
});
276+
277+
it('clears global hooks after close()', async () => {
278+
const hook = { before: jest.fn() } as unknown as Hook;
279+
OpenFeature.addHooks(hook);
280+
expect(OpenFeature.getHooks()).toHaveLength(1);
281+
await OpenFeature.close();
282+
expect(OpenFeature.getHooks()).toHaveLength(0);
283+
});
284+
285+
it('clears global evaluation context after close()', async () => {
286+
await OpenFeature.setContext({ user: 'test' });
287+
expect(OpenFeature.getContext()).toEqual({ user: 'test' });
288+
await OpenFeature.close();
289+
expect(OpenFeature.getContext()).toEqual({});
290+
});
291+
292+
it('clears domain-scoped evaluation context after close()', async () => {
293+
await OpenFeature.setContext('domain1', { user: 'test' });
294+
expect(OpenFeature.getContext('domain1')).toEqual({ user: 'test' });
295+
await OpenFeature.close();
296+
expect(OpenFeature.getContext('domain1')).toEqual({});
297+
});
298+
299+
it('removes API-level event handlers after close()', async () => {
300+
const handler = jest.fn();
301+
OpenFeature.addHandler(ProviderEvents.Ready, handler);
302+
expect(OpenFeature.getHandlers(ProviderEvents.Ready)).toHaveLength(1);
303+
await OpenFeature.close();
304+
expect(OpenFeature.getHandlers(ProviderEvents.Ready)).toHaveLength(0);
305+
});
306+
});
307+
308+
describe('clearProviders() remains provider-only', () => {
309+
afterEach(async () => {
310+
OpenFeature.clearHooks();
311+
OpenFeature.clearHandlers();
312+
await OpenFeature.setContext({});
313+
});
314+
315+
it('does not clear global hooks', async () => {
316+
const hook = { before: jest.fn() } as unknown as Hook;
317+
OpenFeature.addHooks(hook);
318+
await OpenFeature.clearProviders();
319+
expect(OpenFeature.getHooks()).toHaveLength(1);
320+
});
321+
322+
it('does not clear global evaluation context', async () => {
323+
await OpenFeature.setContext({ user: 'test' });
324+
await OpenFeature.clearProviders();
325+
expect(OpenFeature.getContext()).toEqual({ user: 'test' });
326+
});
327+
328+
it('does not remove API-level event handlers', async () => {
329+
const handler = jest.fn();
330+
OpenFeature.addHandler(ProviderEvents.Ready, handler);
331+
await OpenFeature.clearProviders();
332+
expect(OpenFeature.getHandlers(ProviderEvents.Ready)).toHaveLength(1);
333+
});
334+
});
259335
});

0 commit comments

Comments
 (0)