From 41f262177ca0a889c9fbee0e3f597eb37840b5b8 Mon Sep 17 00:00:00 2001 From: Mike Kitzman Date: Thu, 13 Nov 2025 08:08:04 -0600 Subject: [PATCH 1/2] fix: multi-provider hook context management Fixing an issue with the MultiProvider where hook contexts and hints were being lost due to copies of the context data being created in the OpenFeature sdk evaluation. Since key evaluation of Maps using objects is done by reference, the lookup of the context during evaluation was failing, leading to errors. Signed-off-by: Mike Kitzman --- .../src/provider/multi-provider/multi-provider.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/server/src/provider/multi-provider/multi-provider.ts b/packages/server/src/provider/multi-provider/multi-provider.ts index 998c9149c..3452cbbee 100644 --- a/packages/server/src/provider/multi-provider/multi-provider.ts +++ b/packages/server/src/provider/multi-provider/multi-provider.ts @@ -30,9 +30,8 @@ export class MultiProvider implements Provider { public readonly events = new OpenFeatureEventEmitter(); - private hookContexts: WeakMap = new WeakMap(); - private hookHints: WeakMap = new WeakMap(); - + private hookContents: Array<[HookContext, HookHints]> = []; + metadata: ProviderMetadata; providerEntries: RegisteredProvider[] = []; @@ -140,9 +139,8 @@ export class MultiProvider implements Provider { defaultValue: T, context: EvaluationContext, ): Promise> { - const hookContext = this.hookContexts.get(context); - const hookHints = this.hookHints.get(context); - + const [hookContext, hookHints] = this.hookContents.shift() ?? []; + if (!hookContext || !hookHints) { throw new GeneralError('Hook context not available for evaluation'); } @@ -299,8 +297,7 @@ export class MultiProvider implements Provider { return [ { before: async (hookContext: BeforeHookContext, hints: HookHints): Promise => { - this.hookContexts.set(hookContext.context, hookContext); - this.hookHints.set(hookContext.context, hints ?? {}); + this.hookContents.push([hookContext, hints ?? {}]); return hookContext.context; }, }, From c5ecbcfc32601d2f0fc42e355815df2727b1ad26 Mon Sep 17 00:00:00 2001 From: Mike Kitzman Date: Tue, 18 Nov 2025 19:09:10 -0600 Subject: [PATCH 2/2] fix: open-feature-client context management When accumulating the context in the OpenFeature client in the before hooks, the context was being re-assigned instead of merged. This change ensures that the context is merged correctly, preserving object reference. Signed-off-by: Mike Kitzman --- .../client/internal/open-feature-client.ts | 7 ++-- .../provider/multi-provider/multi-provider.ts | 13 ++++--- packages/server/test/client.spec.ts | 34 +++++++++++++++++++ 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/packages/server/src/client/internal/open-feature-client.ts b/packages/server/src/client/internal/open-feature-client.ts index 01120f693..7dcb13ef8 100644 --- a/packages/server/src/client/internal/open-feature-client.ts +++ b/packages/server/src/client/internal/open-feature-client.ts @@ -332,7 +332,7 @@ export class OpenFeatureClient implements Client { mergedContext: EvaluationContext, options: FlagEvaluationOptions, ) { - let accumulatedContext = mergedContext; + const accumulatedContext = mergedContext; for (const [index, hook] of hooks.entries()) { const hookContextIndex = hooks.length - 1 - index; // reverse index for before hooks @@ -343,10 +343,7 @@ export class OpenFeatureClient implements Client { const hookResult = await hook?.before?.(hookContext, Object.freeze(options.hookHints)); if (hookResult) { - accumulatedContext = { - ...accumulatedContext, - ...hookResult, - }; + Object.assign(accumulatedContext, hookResult); for (let i = 0; i < hooks.length; i++) { Object.assign(hookContexts[hookContextIndex].context, accumulatedContext); diff --git a/packages/server/src/provider/multi-provider/multi-provider.ts b/packages/server/src/provider/multi-provider/multi-provider.ts index 3452cbbee..998c9149c 100644 --- a/packages/server/src/provider/multi-provider/multi-provider.ts +++ b/packages/server/src/provider/multi-provider/multi-provider.ts @@ -30,8 +30,9 @@ export class MultiProvider implements Provider { public readonly events = new OpenFeatureEventEmitter(); - private hookContents: Array<[HookContext, HookHints]> = []; - + private hookContexts: WeakMap = new WeakMap(); + private hookHints: WeakMap = new WeakMap(); + metadata: ProviderMetadata; providerEntries: RegisteredProvider[] = []; @@ -139,8 +140,9 @@ export class MultiProvider implements Provider { defaultValue: T, context: EvaluationContext, ): Promise> { - const [hookContext, hookHints] = this.hookContents.shift() ?? []; - + const hookContext = this.hookContexts.get(context); + const hookHints = this.hookHints.get(context); + if (!hookContext || !hookHints) { throw new GeneralError('Hook context not available for evaluation'); } @@ -297,7 +299,8 @@ export class MultiProvider implements Provider { return [ { before: async (hookContext: BeforeHookContext, hints: HookHints): Promise => { - this.hookContents.push([hookContext, hints ?? {}]); + this.hookContexts.set(hookContext.context, hookContext); + this.hookHints.set(hookContext.context, hints ?? {}); return hookContext.context; }, }, diff --git a/packages/server/test/client.spec.ts b/packages/server/test/client.spec.ts index b1db27412..7dac9335b 100644 --- a/packages/server/test/client.spec.ts +++ b/packages/server/test/client.spec.ts @@ -811,6 +811,40 @@ describe('OpenFeatureClient', () => { client.setContext({ [KEY]: VAL }); expect(client.getContext()[KEY]).toEqual(VAL); }); + + it('context object is reference stable between hook and evaluation calls', async () => { + let hookContextRef; + const contextMap = new WeakMap(); + const contextStabilityProvider = { + metadata: { + name: 'evaluation-context', + }, + hooks: [ + { + before: jest.fn((hookContext: HookContext) => { + contextMap.set(hookContext.context, hookContext); + return hookContext.context; + }) + } + ], + resolveBooleanEvaluation: jest.fn((_flagKey, _defaultValue, context): Promise> => { + // We expect that the context object reference is the same as that captured in the hook + hookContextRef = contextMap.get(context); + return Promise.resolve({ + value: true, + }); + }), + } as unknown as Provider; + + await OpenFeature.setProviderAndWait(contextStabilityProvider); + const client = OpenFeature.getClient(); + + const context = { data: 1, value: '2' }; + await client.getBooleanValue('some-other-flag', false, context); + expect(contextStabilityProvider.resolveBooleanEvaluation).toHaveBeenCalled(); + expect(contextStabilityProvider.hooks?.[0].before).toHaveBeenCalled(); + expect(hookContextRef).toBeDefined(); + }); }); });