Repository: open-feature/js-sdk (ts)

Issue:
Allow evaluation context to be managed per provider in the web SDK
### Observed behavior

The lib allows for setting named providers and getting the client associated with that provider by name also, but removes (from `js-sdk` and `server-sdk`) the ability to set context on the client level (`client.setContext(...)`), rather than global level (`OpenFeature.setContext(...)`). This means that when I instantiate two different providers/clients and then set some context which is specific to the first one, followed by setting some different context that is specific to the second one, the second `setContext` overwrites the context set for the first provider/client.

### Expected Behavior

I have two proposals:

1. The `setContext` function should be added to the `Client` object, similar to how this is handled in the other JS SDKs.
2. The `OpenFeature.setContext` function could take a name property, similar to the `setProvider` function, so that the context is only associated with the provider and client of the same name.

### Steps to reproduce

See "Observed behavior" above

Pull request:
feat: add evaluation context management to the web SDK
## This PR

- adds the ability to manage named context in the web SDK

### Related Issues

Fixes #685 

### Notes

This change requires additions to the spec before it can be merged. I'll mark this PR as a draft until the spec has been updated.

I also noticed that we currently use the term "named clients" to describe the scoped providers. I believe this terminology is confusing and made writing the JSDocs difficult because the client name shouldn't be important when setting context. I think we should consider using the term "Provider Namespace". In my opinion, this more accurately describes the behavior and could be introduced in a non-breaking way.


Patch:
diff --git a/packages/client/src/open-feature.ts b/packages/client/src/open-feature.ts
index b2454bf0..0e85046b 100644
--- a/packages/client/src/open-feature.ts
+++ b/packages/client/src/open-feature.ts
@@ -1,4 +1,10 @@
-import { EvaluationContext, ManageContext, OpenFeatureCommonAPI } from '@openfeature/core';
+import {
+  EvaluationContext,
+  ManageContext,
+  OpenFeatureCommonAPI,
+  objectOrUndefined,
+  stringOrUndefined,
+} from '@openfeature/core';
 import { Client, OpenFeatureClient } from './client';
 import { NOOP_PROVIDER, Provider } from './provider';
 import { OpenFeatureEventEmitter } from './events';
@@ -16,8 +22,8 @@ export class OpenFeatureAPI extends OpenFeatureCommonAPI<Provider, Hook> impleme
   protected _events = new OpenFeatureEventEmitter();
   protected _defaultProvider: Provider = NOOP_PROVIDER;
   protected _createEventEmitter = () => new OpenFeatureEventEmitter();
+  protected _namedProviderContext: Map<string, EvaluationContext> = new Map();
 
-  // eslint-disable-next-line @typescript-eslint/no-empty-function
   private constructor() {
     super('client');
   }
@@ -38,26 +44,120 @@ export class OpenFeatureAPI extends OpenFeatureCommonAPI<Provider, Hook> impleme
     return instance;
   }
 
-  async setContext(context: EvaluationContext): Promise<void> {
-    const oldContext = this._context;
-    this._context = context;
-
-    const allProviders = [this._defaultProvider, ...this._clientProviders.values()];
-    await Promise.all(
-      allProviders.map(async (provider) => {
-        try {
-          return await provider.onContextChange?.(oldContext, context);
-        } catch (err) {
-          this._logger?.error(`Error running context change handler of provider ${provider.metadata.name}:`, err);
-        }
-      }),
-    );
+  /**
+   * Sets the evaluation context globally.
+   * This will be used by all providers that have not been overridden with a named client.
+   * @param {EvaluationContext} context Evaluation context
+   * @example
+   * await OpenFeature.setContext({ region: "us" });
+   */
+  async setContext(context: EvaluationContext): Promise<void>;
+  /**
+   * Sets the evaluation context for a specific provider.
+   * This will only affect providers with a matching client name.
+   * @param {string} clientName The name to identify the client
+   * @param {EvaluationContext} context Evaluation context
+   * @example
+   * await OpenFeature.setContext("test", { scope: "provider" });
+   * OpenFeature.setProvider(new MyProvider()) // Uses the default context
+   * OpenFeature.setProvider("test", new MyProvider()) // Uses context: { scope: "provider" }
+   */
+  async setContext(clientName: string, context: EvaluationContext): Promise<void>;
+  async setContext<T extends EvaluationContext>(nameOrContext: T | string, contextOrUndefined?: T): Promise<void> {
+    const clientName = stringOrUndefined(nameOrContext);
+    const context = objectOrUndefined<T>(nameOrContext) ?? objectOrUndefined(contextOrUndefined) ?? {};
+
+    if (clientName) {
+      const provider = this._clientProviders.get(clientName);
+      if (provider) {
+        const oldContext = this.getContext(clientName);
+        this._namedProviderContext.set(clientName, context);
+        await this.runProviderContextChangeHandler(provider, oldContext, context);
+      } else {
+        this._namedProviderContext.set(clientName, context);
+      }
+    } else {
+      const oldContext = this._context;
+      this._context = context;
+
+      const providersWithoutContextOverride = Array.from(this._clientProviders.entries())
+        .filter(([name]) => !this._namedProviderContext.has(name))
+        .reduce<Provider[]>((acc, [, provider]) => {
+          acc.push(provider);
+          return acc;
+        }, []);
+
+      const allProviders = [this._defaultProvider, ...providersWithoutContextOverride];
+      await Promise.all(
+        allProviders.map((provider) => this.runProviderContextChangeHandler(provider, oldContext, context)),
+      );
+    }
   }
 
-  getContext(): EvaluationContext {
+  /**
+   * Access the global evaluation context.
+   * @returns {EvaluationContext} Evaluation context
+   */
+  getContext(): EvaluationContext;
+  /**
+   * Access the evaluation context for a specific named client.
+   * The global evaluation context is returned if a matching named client is not found.
+   * @param {string} clientName The name to identify the client
+   * @returns {EvaluationContext} Evaluation context
+   */
+  getContext(clientName: string): EvaluationContext;
+  getContext(nameOrUndefined?: string): EvaluationContext {
+    const clientName = stringOrUndefined(nameOrUndefined);
+    if (clientName) {
+      const context = this._namedProviderContext.get(clientName);
+      if (context) {
+        return context;
+      } else {
+        this._logger.debug(`Unable to find context for '${clientName}'.`);
+      }
+    }
     return this._context;
   }
 
+  /**
+   * Resets the global evaluation context to an empty object.
+   */
+  clearContext(): Promise<void>;
+  /**
+   * Removes the evaluation context for a specific named client.
+   * @param {string} clientName The name to identify the client
+   */
+  clearContext(clientName: string): Promise<void>;
+  async clearContext(nameOrUndefined?: string): Promise<void> {
+    const clientName = stringOrUndefined(nameOrUndefined);
+    if (clientName) {
+      const provider = this._clientProviders.get(clientName);
+      if (provider) {
+        const oldContext = this.getContext(clientName);
+        this._namedProviderContext.delete(clientName);
+        const newContext = this.getContext();
+        await this.runProviderContextChangeHandler(provider, oldContext, newContext);
+      } else {
+        this._namedProviderContext.delete(clientName);
+      }
+    } else {
+      return this.setContext({});
+    }
+  }
+
+  /**
+   * Resets the global evaluation context and removes the evaluation context for
+   * all named clients.
+   */
+  async clearContexts(): Promise<void> {
+    // Default context must be cleared first to avoid calling the onContextChange
+    // handler multiple times for named clients.
+    await this.clearContext();
+
+    // Use allSettled so a promise rejection doesn't affect others
+    await Promise.allSettled(Array.from(this._clientProviders.keys()).map((name) => this.clearContext(name)));
+  }
+
   /**
    * A factory function for creating new named OpenFeature clients. Clients can contain
    * their own state (e.g. logger, hook, context). Multiple clients can be used
@@ -84,8 +184,21 @@ export class OpenFeatureAPI extends OpenFeatureCommonAPI<Provider, Hook> impleme
    * Clears all registered providers and resets the default provider.
    * @returns {Promise<void>}
    */
-  clearProviders(): Promise<void> {
-    return super.clearProvidersAndSetDefault(NOOP_PROVIDER);
+  async clearProviders(): Promise<void> {
+    await super.clearProvidersAndSetDefault(NOOP_PROVIDER);
+    this._namedProviderContext.clear();
+  }
+
+  private async runProviderContextChangeHandler(
+    provider: Provider,
+    oldContext: EvaluationContext,
+    newContext: EvaluationContext,
+  ): Promise<void> {
+    try {
+      return await provider.onContextChange?.(oldContext, newContext);
+    } catch (err) {
+      this._logger?.error(`Error running ${provider.metadata.name}'s context change handler:`, err);
+    }
   }
 }
 


Test patch:
diff --git a/packages/client/test/evaluation-context.spec.ts b/packages/client/test/evaluation-context.spec.ts
index 79e91198..823bd123 100644
--- a/packages/client/test/evaluation-context.spec.ts
+++ b/packages/client/test/evaluation-context.spec.ts
@@ -30,12 +30,75 @@ class MockProvider implements Provider {
 }
 
 describe('Evaluation Context', () => {
+  afterEach(async () => {
+    await OpenFeature.clearContexts();
+  });
+
   describe('Requirement 3.2.2', () => {
-    it('the API MUST have a method for setting the global evaluation context', () => {
+    it('the API MUST have a method for setting the global evaluation context', async () => {
       const context: EvaluationContext = { property1: false };
-      OpenFeature.setContext(context);
+      await OpenFeature.setContext(context);
       expect(OpenFeature.getContext()).toEqual(context);
     });
+
+    it('the API MUST have a method for setting evaluation context for a named client', async () => {
+      const context: EvaluationContext = { property1: false };
+      const clientName = 'valid';
+      await OpenFeature.setContext(clientName, context);
+      expect(OpenFeature.getContext(clientName)).toEqual(context);
+    });
+
+    it('the API MUST return the default context if not match is found', async () => {
+      const defaultContext: EvaluationContext = { name: 'test' };
+      const nameContext: EvaluationContext = { property1: false };
+      await OpenFeature.setContext(defaultContext);
+      await OpenFeature.setContext('test', nameContext);
+      expect(OpenFeature.getContext('invalid')).toEqual(defaultContext);
+    });
+
+    describe('Context Management', () => {
+      it('should reset global context', async () => {
+        const globalContext: EvaluationContext = { scope: 'global' };
+        await OpenFeature.setContext(globalContext);
+        expect(OpenFeature.getContext()).toEqual(globalContext);
+        await OpenFeature.clearContext();
+        expect(OpenFeature.getContext()).toEqual({});
+      });
+
+      it('should remove context from a name provider', async () => {
+        const globalContext: EvaluationContext = { scope: 'global' };
+        const testContext: EvaluationContext = { scope: 'test' };
+        const clientName = 'test';
+        await OpenFeature.setContext(globalContext);
+        await OpenFeature.setContext(clientName, testContext);
+        expect(OpenFeature.getContext(clientName)).toEqual(testContext);
+        await OpenFeature.clearContext(clientName);
+        expect(OpenFeature.getContext(clientName)).toEqual(globalContext);
+      });
+
+      it('should only call a providers onContextChange once when clearing context', async () => {
+        const globalContext: EvaluationContext = { scope: 'global' };
+        const testContext: EvaluationContext = { scope: 'test' };
+        const clientName = 'test';
+        await OpenFeature.setContext(globalContext);
+        await OpenFeature.setContext(clientName, testContext);
+
+        const defaultProvider = new MockProvider();
+        const provider1 = new MockProvider();
+
+        OpenFeature.setProvider(defaultProvider);
+        OpenFeature.setProvider(clientName, provider1);
+
+        // Spy on context changed handlers of all providers
+        const contextChangedSpies = [defaultProvider, provider1].map((provider) =>
+          jest.spyOn(provider, 'onContextChange'),
+        );
+
+        await OpenFeature.clearContexts();
+
+        contextChangedSpies.forEach((spy) => expect(spy).toHaveBeenCalledTimes(1));
+      });
+    });
   });
 
   describe('Requirement 3.2.4', () => {
@@ -55,15 +118,50 @@ describe('Evaluation Context', () => {
         OpenFeature.setProvider('client2', provider2);
 
         // Spy on context changed handlers of all providers
-        const contextChangedSpys = [defaultProvider, provider1, provider2].map((provider) =>
-          jest.spyOn(provider, 'onContextChange')
+        const contextChangedSpies = [defaultProvider, provider1, provider2].map((provider) =>
+          jest.spyOn(provider, 'onContextChange'),
         );
 
         // Change context
         const newContext: EvaluationContext = { property1: true, property2: 'prop2' };
         await OpenFeature.setContext(newContext);
 
-        contextChangedSpys.forEach((spy) => expect(spy).toHaveBeenCalledWith(context, newContext));
+        contextChangedSpies.forEach((spy) => expect(spy).toHaveBeenCalledWith(context, newContext));
+      });
+
+      it('on only the providers using the default context', async () => {
+        // Set initial context
+        const context: EvaluationContext = { property1: false };
+        await OpenFeature.setContext(context);
+
+        // Set some providers
+        const defaultProvider = new MockProvider();
+        const provider1 = new MockProvider();
+        const provider2 = new MockProvider();
+
+        const client1 = 'client1';
+        const client2 = 'client2';
+
+        OpenFeature.setProvider(defaultProvider);
+        OpenFeature.setProvider(client1, provider1);
+        OpenFeature.setProvider(client2, provider2);
+
+        // Set context for client1
+        await OpenFeature.setContext(client1, { property1: 'test' });
+
+        // Spy on context changed handlers of all providers
+        const contextShouldChangeSpies = [defaultProvider, provider2].map((provider) =>
+          jest.spyOn(provider, 'onContextChange'),
+        );
+
+        const contextShouldntChangeSpies = jest.spyOn(provider1, 'onContextChange');
+
+        // Change context
+        const newContext: EvaluationContext = { property1: true, property2: 'prop2' };
+        await OpenFeature.setContext(newContext);
+
+        contextShouldChangeSpies.forEach((spy) => expect(spy).toHaveBeenCalledWith(context, newContext));
+        expect(contextShouldntChangeSpies).not.toHaveBeenCalled();
       });
 
       it('on all registered providers even if one fails', async () => {
@@ -81,18 +179,18 @@ describe('Evaluation Context', () => {
         OpenFeature.setProvider('client2', provider2);
 
         // Spy on context changed handlers of all providers
-        const contextChangedSpys = [defaultProvider, provider1, provider2].map((provider) =>
-          jest.spyOn(provider, 'onContextChange')
+        const contextChangedSpies = [defaultProvider, provider1, provider2].map((provider) =>
+          jest.spyOn(provider, 'onContextChange'),
         );
 
         // Let first handler fail
-        contextChangedSpys[0].mockImplementation(() => Promise.reject(new Error('Error')));
+        contextChangedSpies[0].mockImplementation(() => Promise.reject(new Error('Error')));
 
         // Change context
         const newContext: EvaluationContext = { property1: true, property2: 'prop2' };
         await OpenFeature.setContext(newContext);
 
-        contextChangedSpys.forEach((spy) => expect(spy).toHaveBeenCalledWith(context, newContext));
+        contextChangedSpies.forEach((spy) => expect(spy).toHaveBeenCalledWith(context, newContext));
       });
     });
   });