From 2519ad13228234af8c92303a435f93c5a3fac119 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Wed, 5 Nov 2025 16:54:36 -0500 Subject: [PATCH 1/2] feat: add context change listeners for flag subscriptions Signed-off-by: Jonathan Norris --- package-lock.json | 8 +- .../src/events/generic-event-emitter.ts | 2 +- packages/web/src/client/client.ts | 3 +- .../client/internal/open-feature-client.ts | 128 ++++++- .../evaluation-details-with-subscription.ts | 104 +++++ packages/web/src/evaluation/evaluation.ts | 62 +++ packages/web/src/evaluation/index.ts | 1 + packages/web/src/events/events.ts | 4 +- packages/web/src/open-feature.ts | 15 +- .../test/context-change-subscription.spec.ts | 355 ++++++++++++++++++ 10 files changed, 657 insertions(+), 25 deletions(-) create mode 100644 packages/web/src/evaluation/evaluation-details-with-subscription.ts create mode 100644 packages/web/test/context-change-subscription.spec.ts diff --git a/package-lock.json b/package-lock.json index d1ccb0510..50a08af01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,7 +50,7 @@ "ts-node": "^10.9.2", "tslib": "^2.3.0", "typedoc": "^0.26.0", - "typescript": "^5.8.3", + "typescript": "^4.7.4", "uuid": "^11.0.0" }, "engines": { @@ -18809,7 +18809,7 @@ "playwright": "^1.53.2", "rxjs": "~7.8.0", "tslib": "^2.3.0", - "typescript": "^5.9.3", + "typescript": "^5.8.3", "vitest": "^3.2.4", "zone.js": "~0.15.0" } @@ -21673,7 +21673,7 @@ }, "packages/react": { "name": "@openfeature/react-sdk", - "version": "1.0.1", + "version": "1.0.2", "license": "Apache-2.0", "devDependencies": { "@openfeature/core": "*", @@ -21706,7 +21706,7 @@ }, "packages/web": { "name": "@openfeature/web-sdk", - "version": "1.7.0", + "version": "1.7.1", "license": "Apache-2.0", "devDependencies": { "@openfeature/core": "^1.9.0" diff --git a/packages/shared/src/events/generic-event-emitter.ts b/packages/shared/src/events/generic-event-emitter.ts index 27033a066..d8c04a511 100644 --- a/packages/shared/src/events/generic-event-emitter.ts +++ b/packages/shared/src/events/generic-event-emitter.ts @@ -1,4 +1,4 @@ -import type { Logger, ManageLogger} from '../logger'; +import type { Logger, ManageLogger } from '../logger'; import { SafeLogger } from '../logger'; import type { ProviderEventEmitter } from './provider-event-emitter'; import type { EventContext, EventDetails, EventHandler } from './eventing'; diff --git a/packages/web/src/client/client.ts b/packages/web/src/client/client.ts index be3f058ab..800ded0f7 100644 --- a/packages/web/src/client/client.ts +++ b/packages/web/src/client/client.ts @@ -1,5 +1,5 @@ import type { ClientMetadata, EvaluationLifeCycle, Eventing, ManageLogger } from '@openfeature/core'; -import type { Features } from '../evaluation'; +import type { Features, ContextChangeSubscriptions } from '../evaluation'; import type { ProviderStatus } from '../provider'; import type { ProviderEvents } from '../events'; import type { Tracking } from '../tracking'; @@ -7,6 +7,7 @@ import type { Tracking } from '../tracking'; export interface Client extends EvaluationLifeCycle, Features, + ContextChangeSubscriptions, ManageLogger, Eventing, Tracking { diff --git a/packages/web/src/client/internal/open-feature-client.ts b/packages/web/src/client/internal/open-feature-client.ts index 3f3855ee9..09da05e98 100644 --- a/packages/web/src/client/internal/open-feature-client.ts +++ b/packages/web/src/client/internal/open-feature-client.ts @@ -25,7 +25,8 @@ import { MapHookData, } from '@openfeature/core'; import type { FlagEvaluationOptions } from '../../evaluation'; -import type { ProviderEvents } from '../../events'; +import { EvaluationDetailsWithSubscription } from '../../evaluation'; +import { ProviderEvents } from '../../events'; import type { InternalEventEmitter } from '../../events/internal/internal-event-emitter'; import type { Hook } from '../../hooks'; import type { Provider } from '../../provider'; @@ -136,7 +137,13 @@ export class OpenFeatureClient implements Client { defaultValue: boolean, options?: FlagEvaluationOptions, ): EvaluationDetails { - return this.evaluate(flagKey, this._provider.resolveBooleanEvaluation, defaultValue, 'boolean', options); + return this.evaluateWithSubscription( + flagKey, + this._provider.resolveBooleanEvaluation, + defaultValue, + 'boolean', + options, + ); } getStringValue(flagKey: string, defaultValue: T, options?: FlagEvaluationOptions): T { @@ -148,7 +155,7 @@ export class OpenFeatureClient implements Client { defaultValue: T, options?: FlagEvaluationOptions, ): EvaluationDetails { - return this.evaluate( + return this.evaluateWithSubscription( flagKey, // this isolates providers from our restricted string generic argument. this._provider.resolveStringEvaluation as () => EvaluationDetails, @@ -167,7 +174,7 @@ export class OpenFeatureClient implements Client { defaultValue: T, options?: FlagEvaluationOptions, ): EvaluationDetails { - return this.evaluate( + return this.evaluateWithSubscription( flagKey, // this isolates providers from our restricted number generic argument. this._provider.resolveNumberEvaluation as () => EvaluationDetails, @@ -190,7 +197,107 @@ export class OpenFeatureClient implements Client { defaultValue: T, options?: FlagEvaluationOptions, ): EvaluationDetails { - return this.evaluate(flagKey, this._provider.resolveObjectEvaluation, defaultValue, 'object', options); + return this.evaluateWithSubscription( + flagKey, + this._provider.resolveObjectEvaluation, + defaultValue, + 'object', + options, + ); + } + + onBooleanContextChanged( + flagKey: string, + defaultValue: boolean, + callback: (newDetails: EvaluationDetails, oldDetails: EvaluationDetails) => void, + options?: FlagEvaluationOptions, + ): () => void { + return this.subscribeToContextChanges(flagKey, defaultValue, 'boolean', callback, options); + } + + onStringContextChanged( + flagKey: string, + defaultValue: string, + callback: (newDetails: EvaluationDetails, oldDetails: EvaluationDetails) => void, + options?: FlagEvaluationOptions, + ): () => void { + return this.subscribeToContextChanges(flagKey, defaultValue, 'string', callback, options); + } + + onNumberContextChanged( + flagKey: string, + defaultValue: number, + callback: (newDetails: EvaluationDetails, oldDetails: EvaluationDetails) => void, + options?: FlagEvaluationOptions, + ): () => void { + return this.subscribeToContextChanges(flagKey, defaultValue, 'number', callback, options); + } + + onObjectContextChanged( + flagKey: string, + defaultValue: T, + callback: (newDetails: EvaluationDetails, oldDetails: EvaluationDetails) => void, + options?: FlagEvaluationOptions, + ): () => void { + return this.subscribeToContextChanges(flagKey, defaultValue, 'object', callback, options); + } + + private subscribeToContextChanges( + flagKey: string, + defaultValue: T, + flagType: FlagValueType, + callback: (newDetails: EvaluationDetails, oldDetails: EvaluationDetails) => void, + options?: FlagEvaluationOptions, + ): () => void { + let currentDetails: EvaluationDetails; + + switch (flagType) { + case 'boolean': + currentDetails = this.getBooleanDetails(flagKey, defaultValue as boolean, options) as EvaluationDetails; + break; + case 'string': + currentDetails = this.getStringDetails(flagKey, defaultValue as string, options) as EvaluationDetails; + break; + case 'number': + currentDetails = this.getNumberDetails(flagKey, defaultValue as number, options) as EvaluationDetails; + break; + case 'object': + currentDetails = this.getObjectDetails(flagKey, defaultValue as JsonValue, options) as EvaluationDetails; + break; + default: + throw new Error(`Unsupported flag type: ${flagType}`); + } + + const handler = () => { + const oldDetails = { ...currentDetails }; + let newDetails: EvaluationDetails; + + switch (flagType) { + case 'boolean': + newDetails = this.getBooleanDetails(flagKey, defaultValue as boolean, options) as EvaluationDetails; + break; + case 'string': + newDetails = this.getStringDetails(flagKey, defaultValue as string, options) as EvaluationDetails; + break; + case 'number': + newDetails = this.getNumberDetails(flagKey, defaultValue as number, options) as EvaluationDetails; + break; + case 'object': + newDetails = this.getObjectDetails(flagKey, defaultValue as JsonValue, options) as EvaluationDetails; + break; + default: + return; + } + + currentDetails = newDetails; + callback(newDetails, oldDetails); + }; + + this.addHandler(ProviderEvents.ContextChanged, handler, {}); + + return () => { + this.removeHandler(ProviderEvents.ContextChanged, handler); + }; } track(occurrenceKey: string, occurrenceDetails: TrackingEventDetails = {}): void { @@ -211,6 +318,17 @@ export class OpenFeatureClient implements Client { } } + private evaluateWithSubscription( + flagKey: string, + resolver: (flagKey: string, defaultValue: T, context: EvaluationContext, logger: Logger) => ResolutionDetails, + defaultValue: T, + flagType: FlagValueType, + options: FlagEvaluationOptions = {}, + ): EvaluationDetails { + const details = this.evaluate(flagKey, resolver, defaultValue, flagType, options); + return new EvaluationDetailsWithSubscription(this, flagKey, defaultValue, flagType, details, options); + } + private evaluate( flagKey: string, resolver: (flagKey: string, defaultValue: T, context: EvaluationContext, logger: Logger) => ResolutionDetails, diff --git a/packages/web/src/evaluation/evaluation-details-with-subscription.ts b/packages/web/src/evaluation/evaluation-details-with-subscription.ts new file mode 100644 index 000000000..268ea4a6f --- /dev/null +++ b/packages/web/src/evaluation/evaluation-details-with-subscription.ts @@ -0,0 +1,104 @@ +import type { EvaluationDetails, ErrorCode, FlagValue, FlagValueType, JsonValue } from '@openfeature/core'; +import type { Client } from '../client'; +import { ProviderEvents } from '../events'; +import type { FlagEvaluationOptions } from './evaluation'; + +export class EvaluationDetailsWithSubscription implements EvaluationDetails { + private _details: EvaluationDetails; + private readonly _flagKey: string; + private readonly _defaultValue: T; + private readonly _flagType: FlagValueType; + private readonly _options?: FlagEvaluationOptions; + + constructor( + private readonly client: Client, + flagKey: string, + defaultValue: T, + flagType: FlagValueType, + initialDetails: EvaluationDetails, + options?: FlagEvaluationOptions, + ) { + this._details = initialDetails; + this._flagKey = flagKey; + this._defaultValue = defaultValue; + this._flagType = flagType; + this._options = options; + } + + get flagKey(): string { + return this._details.flagKey; + } + + get value(): T { + return this._details.value; + } + + get variant(): string | undefined { + return this._details.variant; + } + + get flagMetadata(): Readonly> { + return this._details.flagMetadata; + } + + get reason(): string | undefined { + return this._details.reason; + } + + get errorCode(): ErrorCode | undefined { + return this._details.errorCode; + } + + get errorMessage(): string | undefined { + return this._details.errorMessage; + } + + onContextChanged(callback: (newDetails: EvaluationDetails, oldDetails: EvaluationDetails) => void): () => void { + const handler = () => { + const oldDetails = { ...this._details }; + let newDetails: EvaluationDetails; + + switch (this._flagType) { + case 'boolean': + newDetails = this.client.getBooleanDetails( + this._flagKey, + this._defaultValue as boolean, + this._options, + ) as EvaluationDetails; + break; + case 'string': + newDetails = this.client.getStringDetails( + this._flagKey, + this._defaultValue as string, + this._options, + ) as EvaluationDetails; + break; + case 'number': + newDetails = this.client.getNumberDetails( + this._flagKey, + this._defaultValue as number, + this._options, + ) as EvaluationDetails; + break; + case 'object': + newDetails = this.client.getObjectDetails( + this._flagKey, + this._defaultValue as JsonValue, + this._options, + ) as EvaluationDetails; + break; + default: + return; + } + + this._details = newDetails; + callback(newDetails, oldDetails); + }; + + this.client.addHandler(ProviderEvents.ContextChanged, handler); + + return () => { + this.client.removeHandler(ProviderEvents.ContextChanged, handler); + }; + } +} diff --git a/packages/web/src/evaluation/evaluation.ts b/packages/web/src/evaluation/evaluation.ts index b839ea823..517ec2fb9 100644 --- a/packages/web/src/evaluation/evaluation.ts +++ b/packages/web/src/evaluation/evaluation.ts @@ -5,6 +5,68 @@ export interface FlagEvaluationOptions { hookHints?: HookHints; } +export interface ContextChangeSubscriptions { + /** + * Subscribes to context changes for a boolean flag. + * @param flagKey The flag key uniquely identifies a particular flag + * @param defaultValue The value returned if an error occurs + * @param callback Function called when context changes, receives new and old evaluation details + * @param options Additional flag evaluation options + * @returns Unsubscribe function to remove the listener + */ + onBooleanContextChanged( + flagKey: string, + defaultValue: boolean, + callback: (newDetails: EvaluationDetails, oldDetails: EvaluationDetails) => void, + options?: FlagEvaluationOptions, + ): () => void; + + /** + * Subscribes to context changes for a string flag. + * @param flagKey The flag key uniquely identifies a particular flag + * @param defaultValue The value returned if an error occurs + * @param callback Function called when context changes, receives new and old evaluation details + * @param options Additional flag evaluation options + * @returns Unsubscribe function to remove the listener + */ + onStringContextChanged( + flagKey: string, + defaultValue: string, + callback: (newDetails: EvaluationDetails, oldDetails: EvaluationDetails) => void, + options?: FlagEvaluationOptions, + ): () => void; + + /** + * Subscribes to context changes for a number flag. + * @param flagKey The flag key uniquely identifies a particular flag + * @param defaultValue The value returned if an error occurs + * @param callback Function called when context changes, receives new and old evaluation details + * @param options Additional flag evaluation options + * @returns Unsubscribe function to remove the listener + */ + onNumberContextChanged( + flagKey: string, + defaultValue: number, + callback: (newDetails: EvaluationDetails, oldDetails: EvaluationDetails) => void, + options?: FlagEvaluationOptions, + ): () => void; + + /** + * Subscribes to context changes for an object flag. + * @param flagKey The flag key uniquely identifies a particular flag + * @param defaultValue The value returned if an error occurs + * @param callback Function called when context changes, receives new and old evaluation details + * @param options Additional flag evaluation options + * @returns Unsubscribe function to remove the listener + */ + onObjectContextChanged( + flagKey: string, + defaultValue: T, + callback: (newDetails: EvaluationDetails, oldDetails: EvaluationDetails) => void, + options?: FlagEvaluationOptions, + ): () => void; +} + export interface Features { /** * Performs a flag evaluation that returns a boolean. diff --git a/packages/web/src/evaluation/index.ts b/packages/web/src/evaluation/index.ts index 79c222c37..1312d4d46 100644 --- a/packages/web/src/evaluation/index.ts +++ b/packages/web/src/evaluation/index.ts @@ -1 +1,2 @@ export * from './evaluation'; +export * from './evaluation-details-with-subscription'; diff --git a/packages/web/src/events/events.ts b/packages/web/src/events/events.ts index 71c2d6247..d50472ddd 100644 --- a/packages/web/src/events/events.ts +++ b/packages/web/src/events/events.ts @@ -1,8 +1,8 @@ import { ClientProviderEvents } from '@openfeature/core'; -export { ClientProviderEvents as ProviderEvents}; +export { ClientProviderEvents as ProviderEvents }; /** * A subset of events that can be directly emitted by providers. */ -export type ProviderEmittableEvents = Exclude; \ No newline at end of file +export type ProviderEmittableEvents = Exclude; diff --git a/packages/web/src/open-feature.ts b/packages/web/src/open-feature.ts index be6c7f845..131f8c273 100644 --- a/packages/web/src/open-feature.ts +++ b/packages/web/src/open-feature.ts @@ -1,19 +1,10 @@ -import type { - ClientProviderStatus, - EvaluationContext, - GenericEventEmitter, - ManageContext} from '@openfeature/core'; -import { - OpenFeatureCommonAPI, - ProviderWrapper, - objectOrUndefined, - stringOrUndefined, -} from '@openfeature/core'; +import type { ClientProviderStatus, EvaluationContext, GenericEventEmitter, ManageContext } from '@openfeature/core'; +import { OpenFeatureCommonAPI, ProviderWrapper, objectOrUndefined, stringOrUndefined } from '@openfeature/core'; import type { Client } from './client'; import { OpenFeatureClient } from './client/internal/open-feature-client'; import { OpenFeatureEventEmitter, ProviderEvents } from './events'; import type { Hook } from './hooks'; -import type { Provider} from './provider'; +import type { Provider } from './provider'; import { NOOP_PROVIDER, ProviderStatus } from './provider'; // use a symbol as a key for the global singleton diff --git a/packages/web/test/context-change-subscription.spec.ts b/packages/web/test/context-change-subscription.spec.ts new file mode 100644 index 000000000..9b2bf869e --- /dev/null +++ b/packages/web/test/context-change-subscription.spec.ts @@ -0,0 +1,355 @@ +import { v4 as uuid } from 'uuid'; +import type { Provider, ResolutionDetails, JsonValue, EvaluationDetails } from '../src'; +import { OpenFeature } from '../src'; + +const BOOLEAN_VALUE = true; +const STRING_VALUE = 'val'; +const NUMBER_VALUE = 10; +const OBJECT_VALUE: JsonValue = { key: 'value' }; + +const REASON = 'mocked-value'; + +const MOCK_PROVIDER: Provider = { + metadata: { + name: 'mock', + }, + events: undefined, + hooks: [], + initialize(): Promise { + return Promise.resolve(undefined); + }, + onClose(): Promise { + return Promise.resolve(undefined); + }, + onContextChange(): Promise { + return Promise.resolve(undefined); + }, + + resolveNumberEvaluation: jest.fn((): ResolutionDetails => { + return { + value: NUMBER_VALUE, + reason: REASON, + }; + }), + + resolveObjectEvaluation: jest.fn((): ResolutionDetails => { + return >{ + value: OBJECT_VALUE as U, + reason: REASON, + }; + }) as () => ResolutionDetails, + + resolveBooleanEvaluation: jest.fn((): ResolutionDetails => { + return { + value: BOOLEAN_VALUE, + reason: REASON, + }; + }), + resolveStringEvaluation: jest.fn((): ResolutionDetails => { + return { + value: STRING_VALUE, + reason: REASON, + }; + }), +}; + +describe('Context Change Subscriptions', () => { + let domain: string; + + beforeEach(() => { + domain = uuid(); + OpenFeature.setProvider(domain, MOCK_PROVIDER); + jest.clearAllMocks(); + }); + + afterEach(async () => { + await OpenFeature.clearProviders(); + OpenFeature.clearHandlers(); + jest.clearAllMocks(); + }); + + describe('Client-level onBooleanContextChanged', () => { + it('should fire callback when context changes', (done) => { + const client = OpenFeature.getClient(domain); + let callCount = 0; + + client.onBooleanContextChanged('test-flag', false, (newDetails, oldDetails) => { + callCount++; + if (callCount === 1) { + expect(newDetails.value).toBe(BOOLEAN_VALUE); + expect(oldDetails.value).toBe(BOOLEAN_VALUE); + expect(newDetails.flagKey).toBe('test-flag'); + expect(oldDetails.flagKey).toBe('test-flag'); + done(); + } + }); + + OpenFeature.setContext(domain, { user: 'test' }); + }); + + it('should pass correct old and new details on context change', (done) => { + const client = OpenFeature.getClient(domain); + + client.onBooleanContextChanged('test-flag', false, (newDetails, oldDetails) => { + expect(oldDetails.value).toBeDefined(); + expect(newDetails.value).toBeDefined(); + expect(oldDetails.flagKey).toBe('test-flag'); + expect(newDetails.flagKey).toBe('test-flag'); + done(); + }); + + OpenFeature.setContext(domain, { user: 'test' }); + }); + + it('should unsubscribe when unsubscribe function is called', (done) => { + const client = OpenFeature.getClient(domain); + let callCount = 0; + + const unsubscribe = client.onBooleanContextChanged('test-flag', false, () => { + callCount++; + }); + + OpenFeature.setContext(domain, { user: 'test1' }); + + setTimeout(() => { + unsubscribe(); + OpenFeature.setContext(domain, { user: 'test2' }); + + setTimeout(() => { + expect(callCount).toBe(1); + done(); + }, 50); + }, 50); + }); + + it('should support multiple subscribers to same flag', (done) => { + const client = OpenFeature.getClient(domain); + let callCount1 = 0; + let callCount2 = 0; + + client.onBooleanContextChanged('test-flag', false, () => { + callCount1++; + }); + + client.onBooleanContextChanged('test-flag', false, () => { + callCount2++; + if (callCount1 === 1 && callCount2 === 1) { + done(); + } + }); + + OpenFeature.setContext(domain, { user: 'test' }); + }); + }); + + describe('Client-level onStringContextChanged', () => { + it('should fire callback when context changes', (done) => { + const client = OpenFeature.getClient(domain); + + client.onStringContextChanged('test-flag', 'default', (newDetails, oldDetails) => { + expect(newDetails.value).toBe(STRING_VALUE); + expect(oldDetails.value).toBe(STRING_VALUE); + expect(newDetails.flagKey).toBe('test-flag'); + done(); + }); + + OpenFeature.setContext(domain, { user: 'test' }); + }); + }); + + describe('Client-level onNumberContextChanged', () => { + it('should fire callback when context changes', (done) => { + const client = OpenFeature.getClient(domain); + + client.onNumberContextChanged('test-flag', 0, (newDetails, oldDetails) => { + expect(newDetails.value).toBe(NUMBER_VALUE); + expect(oldDetails.value).toBe(NUMBER_VALUE); + expect(newDetails.flagKey).toBe('test-flag'); + done(); + }); + + OpenFeature.setContext(domain, { user: 'test' }); + }); + }); + + describe('Client-level onObjectContextChanged', () => { + it('should fire callback when context changes', (done) => { + const client = OpenFeature.getClient(domain); + + client.onObjectContextChanged('test-flag', {}, (newDetails, oldDetails) => { + expect(newDetails.value).toEqual(OBJECT_VALUE); + expect(oldDetails.value).toEqual({}); + expect(newDetails.flagKey).toBe('test-flag'); + done(); + }); + + OpenFeature.setContext(domain, { user: 'test' }); + }); + }); + + describe('EvaluationDetails onContextChanged', () => { + it('should fire callback when context changes', (done) => { + const client = OpenFeature.getClient(domain); + const details = client.getBooleanDetails('test-flag', false); + + if ('onContextChanged' in details && typeof details.onContextChanged === 'function') { + ( + details as { + onContextChanged: ( + callback: (newDetails: EvaluationDetails, oldDetails: EvaluationDetails) => void, + ) => () => void; + } + ).onContextChanged((newDetails, oldDetails) => { + expect(newDetails.value).toBe(BOOLEAN_VALUE); + expect(oldDetails.value).toBe(BOOLEAN_VALUE); + expect(newDetails.flagKey).toBe('test-flag'); + done(); + }); + + OpenFeature.setContext(domain, { user: 'test' }); + } else { + done(new Error('onContextChanged method not found on EvaluationDetails')); + } + }); + + it('should pass correct old and new details', (done) => { + const client = OpenFeature.getClient(domain); + const details = client.getBooleanDetails('test-flag', false); + + if ('onContextChanged' in details && typeof details.onContextChanged === 'function') { + ( + details as { + onContextChanged: ( + callback: (newDetails: EvaluationDetails, oldDetails: EvaluationDetails) => void, + ) => () => void; + } + ).onContextChanged((newDetails, oldDetails) => { + expect(oldDetails.flagKey).toBe('test-flag'); + expect(newDetails.flagKey).toBe('test-flag'); + expect(typeof oldDetails.value).toBe('boolean'); + expect(typeof newDetails.value).toBe('boolean'); + done(); + }); + + OpenFeature.setContext(domain, { user: 'test' }); + } else { + done(new Error('onContextChanged method not found on EvaluationDetails')); + } + }); + + it('should unsubscribe when unsubscribe function is called', (done) => { + const client = OpenFeature.getClient(domain); + const details = client.getBooleanDetails('test-flag', false); + let callCount = 0; + + if ('onContextChanged' in details && typeof details.onContextChanged === 'function') { + const unsubscribe = details.onContextChanged(() => { + callCount++; + }); + + OpenFeature.setContext(domain, { user: 'test1' }); + + setTimeout(() => { + unsubscribe(); + OpenFeature.setContext(domain, { user: 'test2' }); + + setTimeout(() => { + expect(callCount).toBe(1); + done(); + }, 50); + }, 50); + } else { + done(new Error('onContextChanged method not found on EvaluationDetails')); + } + }); + + it('should work with string details', (done) => { + const client = OpenFeature.getClient(domain); + const details = client.getStringDetails('test-flag', 'default'); + + if ('onContextChanged' in details && typeof details.onContextChanged === 'function') { + ( + details as { + onContextChanged: ( + callback: (newDetails: EvaluationDetails, oldDetails: EvaluationDetails) => void, + ) => () => void; + } + ).onContextChanged((newDetails) => { + expect(newDetails.value).toBe(STRING_VALUE); + done(); + }); + + OpenFeature.setContext(domain, { user: 'test' }); + } else { + done(new Error('onContextChanged method not found on EvaluationDetails')); + } + }); + + it('should work with number details', (done) => { + const client = OpenFeature.getClient(domain); + const details = client.getNumberDetails('test-flag', 0); + + if ('onContextChanged' in details && typeof details.onContextChanged === 'function') { + ( + details as { + onContextChanged: ( + callback: (newDetails: EvaluationDetails, oldDetails: EvaluationDetails) => void, + ) => () => void; + } + ).onContextChanged((newDetails) => { + expect(newDetails.value).toBe(NUMBER_VALUE); + done(); + }); + + OpenFeature.setContext(domain, { user: 'test' }); + } else { + done(new Error('onContextChanged method not found on EvaluationDetails')); + } + }); + + it('should work with object details', (done) => { + const client = OpenFeature.getClient(domain); + const details = client.getObjectDetails('test-flag', {}); + + if ('onContextChanged' in details && typeof details.onContextChanged === 'function') { + ( + details as { + onContextChanged: ( + callback: (newDetails: EvaluationDetails, oldDetails: EvaluationDetails) => void, + ) => () => void; + } + ).onContextChanged((newDetails) => { + expect(newDetails.value).toEqual(OBJECT_VALUE); + done(); + }); + + OpenFeature.setContext(domain, { user: 'test' }); + } else { + done(new Error('onContextChanged method not found on EvaluationDetails')); + } + }); + + it('should continue firing across multiple context changes', (done) => { + const client = OpenFeature.getClient(domain); + const details = client.getBooleanDetails('test-flag', false); + let callCount = 0; + + if ('onContextChanged' in details && typeof details.onContextChanged === 'function') { + details.onContextChanged(() => { + callCount++; + if (callCount === 2) { + done(); + } + }); + + OpenFeature.setContext(domain, { user: 'test1' }); + + setTimeout(() => { + OpenFeature.setContext(domain, { user: 'test2' }); + }, 50); + } else { + done(new Error('onContextChanged method not found on EvaluationDetails')); + } + }); + }); +}); From 2a84c0f7b8276da082585f4c90bbe1f0b8bfe828 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Wed, 5 Nov 2025 17:06:24 -0500 Subject: [PATCH 2/2] feat: update context change listeners to fire immediately for client-level subscriptions Signed-off-by: Jonathan Norris --- .../client/internal/open-feature-client.ts | 2 + .../test/context-change-subscription.spec.ts | 61 ++++++++++++++----- 2 files changed, 49 insertions(+), 14 deletions(-) diff --git a/packages/web/src/client/internal/open-feature-client.ts b/packages/web/src/client/internal/open-feature-client.ts index 09da05e98..ffa7b9a72 100644 --- a/packages/web/src/client/internal/open-feature-client.ts +++ b/packages/web/src/client/internal/open-feature-client.ts @@ -268,6 +268,8 @@ export class OpenFeatureClient implements Client { throw new Error(`Unsupported flag type: ${flagType}`); } + callback(currentDetails, { ...currentDetails }); + const handler = () => { const oldDetails = { ...currentDetails }; let newDetails: EvaluationDetails; diff --git a/packages/web/test/context-change-subscription.spec.ts b/packages/web/test/context-change-subscription.spec.ts index 9b2bf869e..17d2a4e05 100644 --- a/packages/web/test/context-change-subscription.spec.ts +++ b/packages/web/test/context-change-subscription.spec.ts @@ -69,17 +69,22 @@ describe('Context Change Subscriptions', () => { }); describe('Client-level onBooleanContextChanged', () => { - it('should fire callback when context changes', (done) => { + it('should fire callback immediately and when context changes', (done) => { const client = OpenFeature.getClient(domain); let callCount = 0; client.onBooleanContextChanged('test-flag', false, (newDetails, oldDetails) => { callCount++; if (callCount === 1) { + // Initial callback expect(newDetails.value).toBe(BOOLEAN_VALUE); expect(oldDetails.value).toBe(BOOLEAN_VALUE); expect(newDetails.flagKey).toBe('test-flag'); expect(oldDetails.flagKey).toBe('test-flag'); + } else if (callCount === 2) { + // Context change callback + expect(newDetails.value).toBe(BOOLEAN_VALUE); + expect(oldDetails.value).toBe(BOOLEAN_VALUE); done(); } }); @@ -87,15 +92,20 @@ describe('Context Change Subscriptions', () => { OpenFeature.setContext(domain, { user: 'test' }); }); - it('should pass correct old and new details on context change', (done) => { + it('should pass correct old and new details immediately and on context change', (done) => { const client = OpenFeature.getClient(domain); + let callCount = 0; client.onBooleanContextChanged('test-flag', false, (newDetails, oldDetails) => { + callCount++; expect(oldDetails.value).toBeDefined(); expect(newDetails.value).toBeDefined(); expect(oldDetails.flagKey).toBe('test-flag'); expect(newDetails.flagKey).toBe('test-flag'); - done(); + + if (callCount === 2) { + done(); + } }); OpenFeature.setContext(domain, { user: 'test' }); @@ -109,14 +119,17 @@ describe('Context Change Subscriptions', () => { callCount++; }); + // Callback fires immediately (callCount = 1) OpenFeature.setContext(domain, { user: 'test1' }); setTimeout(() => { + // Callback fires on context change (callCount = 2) unsubscribe(); OpenFeature.setContext(domain, { user: 'test2' }); setTimeout(() => { - expect(callCount).toBe(1); + // Should only be 2 (initial + one context change before unsubscribe) + expect(callCount).toBe(2); done(); }, 50); }, 50); @@ -133,7 +146,8 @@ describe('Context Change Subscriptions', () => { client.onBooleanContextChanged('test-flag', false, () => { callCount2++; - if (callCount1 === 1 && callCount2 === 1) { + // Both should have fired once initially, then once on context change + if (callCount1 === 2 && callCount2 === 2) { done(); } }); @@ -143,14 +157,18 @@ describe('Context Change Subscriptions', () => { }); describe('Client-level onStringContextChanged', () => { - it('should fire callback when context changes', (done) => { + it('should fire callback immediately and when context changes', (done) => { const client = OpenFeature.getClient(domain); + let callCount = 0; client.onStringContextChanged('test-flag', 'default', (newDetails, oldDetails) => { + callCount++; expect(newDetails.value).toBe(STRING_VALUE); expect(oldDetails.value).toBe(STRING_VALUE); expect(newDetails.flagKey).toBe('test-flag'); - done(); + if (callCount === 2) { + done(); + } }); OpenFeature.setContext(domain, { user: 'test' }); @@ -158,14 +176,18 @@ describe('Context Change Subscriptions', () => { }); describe('Client-level onNumberContextChanged', () => { - it('should fire callback when context changes', (done) => { + it('should fire callback immediately and when context changes', (done) => { const client = OpenFeature.getClient(domain); + let callCount = 0; client.onNumberContextChanged('test-flag', 0, (newDetails, oldDetails) => { + callCount++; expect(newDetails.value).toBe(NUMBER_VALUE); expect(oldDetails.value).toBe(NUMBER_VALUE); expect(newDetails.flagKey).toBe('test-flag'); - done(); + if (callCount === 2) { + done(); + } }); OpenFeature.setContext(domain, { user: 'test' }); @@ -173,14 +195,23 @@ describe('Context Change Subscriptions', () => { }); describe('Client-level onObjectContextChanged', () => { - it('should fire callback when context changes', (done) => { + it('should fire callback immediately and when context changes', (done) => { const client = OpenFeature.getClient(domain); + let callCount = 0; client.onObjectContextChanged('test-flag', {}, (newDetails, oldDetails) => { - expect(newDetails.value).toEqual(OBJECT_VALUE); - expect(oldDetails.value).toEqual({}); - expect(newDetails.flagKey).toBe('test-flag'); - done(); + callCount++; + if (callCount === 1) { + // Initial callback - both old and new are same initially + expect(newDetails.value).toEqual(OBJECT_VALUE); + expect(oldDetails.value).toEqual(OBJECT_VALUE); + } else if (callCount === 2) { + // Context change callback + expect(newDetails.value).toEqual(OBJECT_VALUE); + expect(oldDetails.value).toEqual(OBJECT_VALUE); + expect(newDetails.flagKey).toBe('test-flag'); + done(); + } }); OpenFeature.setContext(domain, { user: 'test' }); @@ -254,6 +285,7 @@ describe('Context Change Subscriptions', () => { OpenFeature.setContext(domain, { user: 'test2' }); setTimeout(() => { + // Should only be 1 (one context change before unsubscribe) expect(callCount).toBe(1); done(); }, 50); @@ -337,6 +369,7 @@ describe('Context Change Subscriptions', () => { if ('onContextChanged' in details && typeof details.onContextChanged === 'function') { details.onContextChanged(() => { callCount++; + // Two context changes if (callCount === 2) { done(); }