Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
ViewContainerRef,
inject,
} from '@angular/core';
import { withFrameworkMetadata } from '@openfeature/core';
import {
BooleanFlagKey,
Client,
Expand Down Expand Up @@ -132,7 +133,7 @@ export abstract class FeatureFlagDirective<T extends FlagValue> implements OnIni
if (this._client) {
this.disposeClient(this._client);
}
this._client = OpenFeature.getClient(this._featureFlagDomain);
this._client = withFrameworkMetadata(OpenFeature.getClient(this._featureFlagDomain), 'angular');

const baseHandler = () => {
const result = this.getFlagDetails(this._featureFlagKey, this._featureFlagDefault);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { AsyncPipe } from '@angular/common';
import { TestingProvider } from '../test/test.utils';
import { OpenFeatureModule } from './open-feature.module';
import { toSignal } from '@angular/core/rxjs-interop';
import { vi } from 'vitest';

const FLAG_KEY = 'thumbs';

Expand Down Expand Up @@ -114,6 +115,7 @@ describe('FeatureFlagService', () => {

afterEach(async () => {
await OpenFeature.close();
OpenFeature.clearHooks();
await OpenFeature.setContext({});
currentTestComponentFixture?.destroy();
currentContextChangeDisabledComponentFixture?.destroy();
Expand All @@ -129,6 +131,26 @@ describe('FeatureFlagService', () => {
expect(observableValue?.textContent).toBe('👍');
});

it('should surface angular metadata in hook contexts', async () => {
const hook = { before: vi.fn() };

OpenFeature.addHooks(hook);
await createTestingModule();
service = TestBed.inject(FeatureFlagService);

await firstValueFrom(service.getBooleanDetails(FLAG_KEY, false));

expect(hook.before).toHaveBeenCalledWith(
expect.objectContaining({
clientMetadata: expect.objectContaining({
sdk: 'web',
framework: 'angular',
}),
}),
undefined,
);
});

it('should render updated value after delay', async () => {
const delay = 50;
await createTestingModule({ providerInitDelay: delay });
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { withFrameworkMetadata } from '@openfeature/core';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import {
Expand Down Expand Up @@ -217,7 +218,7 @@ export class FeatureFlagService {
domain: string | undefined,
options?: AngularFlagEvaluationOptions,
): Observable<EvaluationDetails<T>> {
const client = domain ? OpenFeature.getClient(domain) : OpenFeature.getClient();
const client = withFrameworkMetadata(domain ? OpenFeature.getClient(domain) : OpenFeature.getClient(), 'angular');

return new Observable<EvaluationDetails<T>>((subscriber) => {
let currentResult: EvaluationDetails<T> | undefined = undefined;
Expand Down
5 changes: 3 additions & 2 deletions packages/nest/src/open-feature.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
EventHandler,
Logger,
} from '@openfeature/server-sdk';
import { withFrameworkMetadata } from '@openfeature/core';
import { OpenFeature, AsyncLocalStorageTransactionContextPropagator } from '@openfeature/server-sdk';
import type { ContextFactory } from './context-factory';
import { ContextFactoryToken } from './context-factory';
Expand Down Expand Up @@ -45,7 +46,7 @@ export class OpenFeatureModule {
const clientValueProviders: NestFactoryProvider<Client>[] = [
{
provide: getOpenFeatureClientToken(),
useFactory: () => OpenFeature.getClient(),
useFactory: () => withFrameworkMetadata(OpenFeature.getClient(), 'nest'),
},
];

Expand All @@ -58,7 +59,7 @@ export class OpenFeatureModule {
OpenFeature.setProvider(domain, provider);
clientValueProviders.push({
provide: getOpenFeatureClientToken(domain),
useFactory: () => OpenFeature.getClient(domain),
useFactory: () => withFrameworkMetadata(OpenFeature.getClient(domain), 'nest'),
});
});
}
Expand Down
6 changes: 5 additions & 1 deletion packages/nest/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { withFrameworkMetadata } from '@openfeature/core';
import type { Client, EvaluationContext } from '@openfeature/server-sdk';
import { OpenFeature } from '@openfeature/server-sdk';

Expand All @@ -8,5 +9,8 @@ import { OpenFeature } from '@openfeature/server-sdk';
* @returns {Client} The OpenFeature client.
*/
export function getClientForEvaluation(domain?: string, context?: EvaluationContext) {
return domain ? OpenFeature.getClient(domain, context) : OpenFeature.getClient(context);
return withFrameworkMetadata(
domain ? OpenFeature.getClient(domain, context) : OpenFeature.getClient(context),
'nest',
);
}
41 changes: 40 additions & 1 deletion packages/nest/test/open-feature.module.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Test } from '@nestjs/testing';
import { getOpenFeatureClientToken, OpenFeatureModule, ServerProviderEvents } from '../src';
import type { Client } from '@openfeature/server-sdk';
import { OpenFeature } from '@openfeature/server-sdk';
import { getOpenFeatureDefaultTestModule } from './fixtures';
import { defaultProvider, getOpenFeatureDefaultTestModule } from './fixtures';

describe('OpenFeatureModule', () => {
let moduleRef: TestingModule;
Expand Down Expand Up @@ -49,6 +49,45 @@ describe('OpenFeatureModule', () => {
expect(client).toBeDefined();
expect(await client.getStringValue('testStringFlag', '')).toEqual('expected-string-value-scoped');
});

it('should expose nest framework metadata on injected clients', () => {
const defaultClient = moduleRef.get<Client>(getOpenFeatureClientToken());
const scopedClient = moduleRef.get<Client>(getOpenFeatureClientToken('domainScopedClient'));

expect(defaultClient.metadata).toMatchObject({
sdk: 'server',
framework: 'nest',
});
expect(scopedClient.metadata).toMatchObject({
sdk: 'server',
framework: 'nest',
});
});

it('should surface nest metadata in hook contexts', async () => {
const hook = { before: jest.fn() };
const hookModuleRef = await Test.createTestingModule({
imports: [OpenFeatureModule.forRoot({ defaultProvider, hooks: [hook] })],
}).compile();

try {
const client = hookModuleRef.get<Client>(getOpenFeatureClientToken());
await client.getBooleanValue('testBooleanFlag', false);

expect(hook.before).toHaveBeenCalledWith(
expect.objectContaining({
clientMetadata: expect.objectContaining({
sdk: 'server',
framework: 'nest',
}),
}),
undefined,
);
} finally {
await hookModuleRef.close();
OpenFeature.clearHooks();
}
});
});

describe('handlers', () => {
Expand Down
6 changes: 5 additions & 1 deletion packages/react/src/provider/provider.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { withFrameworkMetadata } from '@openfeature/core';
import type { Client } from '@openfeature/web-sdk';
import { OpenFeature } from '@openfeature/web-sdk';
import * as React from 'react';
Expand Down Expand Up @@ -32,7 +33,10 @@ type ProviderProps = {
* @returns {OpenFeatureProvider} context provider
*/
export function OpenFeatureProvider({ client, domain, children, ...options }: ProviderProps) {
const stableClient = React.useMemo(() => client || OpenFeature.getClient(domain), [client, domain]);
const stableClient = React.useMemo(
() => withFrameworkMetadata(client || OpenFeature.getClient(domain), 'react'),
[client, domain],
);
Comment on lines +36 to +39
Copy link
Copy Markdown
Member

@toddbaert toddbaert Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thing I want to make sure we've considered: since @openfeature/web-sdk is a peer dependency of the React (and Angular) SDKs, it's possible for users to import OpenFeature.getClient() directly from @openfeature/web-sdk in a React app, bypassing OpenFeatureProvider (this isn't a problem, we re-export everything, so until now these have always been exactly the same objects and imports, even in terms of instanceof checks, etc - they are literally nominatively the same classes, etc.

However, with this change, there's a difference - in the case above,, sdk and paradigm would be populated, but framework would be undefined.

I think that's arguably correct; if you're not going through the framework wrapper, the metadata should reflect that. But worth calling out since a hook or provider branching on framework === 'react' could get inconsistent results depending on how the client was obtained.

WDYT? Worth a note in the docs/JSDoc, or is this fine as-is?

(Note this impacts both implementations)


return <Context.Provider value={{ client: stableClient, options }}>{children}</Context.Provider>;
}
51 changes: 49 additions & 2 deletions packages/react/test/provider.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,14 @@ describe('OpenFeatureProvider', () => {

beforeEach(async () => {
await OpenFeature.clearContexts();
OpenFeature.clearHooks();
});

describe('useOpenFeatureClient', () => {
const DOMAIN = 'useOpenFeatureClient';

describe('client specified', () => {
it('should return client from provider', () => {
it('should return a react-aware client from provider', () => {
const client = OpenFeature.getClient(DOMAIN);

const wrapper = ({ children }: Parameters<typeof OpenFeatureProvider>[0]) => (
Expand All @@ -68,7 +69,12 @@ describe('OpenFeatureProvider', () => {

const { result } = renderHook(() => useOpenFeatureClient(), { wrapper });

expect(result.current).toEqual(client);
expect(result.current).not.toBe(client);
expect(result.current.metadata).toMatchObject({
domain: DOMAIN,
sdk: 'web',
framework: 'react',
});
});
});

Expand All @@ -81,6 +87,8 @@ describe('OpenFeatureProvider', () => {
const { result } = renderHook(() => useOpenFeatureClient(), { wrapper });

expect(result.current.metadata.domain).toEqual(DOMAIN);
expect(result.current.metadata.sdk).toEqual('web');
expect(result.current.metadata.framework).toEqual('react');
});

it('should return a stable client across renders', () => {
Expand All @@ -96,6 +104,45 @@ describe('OpenFeatureProvider', () => {

expect(firstClient).toBe(secondClient);
});

it('should surface react metadata in hook contexts', async () => {
const hook = { before: jest.fn() };

OpenFeature.setProvider(
DOMAIN,
new InMemoryProvider({
greeting: {
disabled: false,
variants: { default: 'hello' },
defaultVariant: 'default',
},
}),
);

const wrapper = ({ children }: Parameters<typeof OpenFeatureProvider>[0]) => (
<OpenFeatureProvider domain={DOMAIN}>{children}</OpenFeatureProvider>
);

const { result } = renderHook(
() =>
useStringFlagValue('greeting', 'fallback', {
hooks: [hook],
}),
{ wrapper },
);

await waitFor(() => expect(result.current).toEqual('hello'));
expect(hook.before).toHaveBeenCalledWith(
expect.objectContaining({
clientMetadata: expect.objectContaining({
domain: DOMAIN,
sdk: 'web',
framework: 'react',
}),
}),
undefined,
);
});
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export class OpenFeatureClient implements Client {
name: this.options.domain ?? this.options.name,
domain: this.options.domain ?? this.options.name,
version: this.options.version,
sdk: 'server',
providerMetadata: this.providerAccessor().metadata,
};
}
Expand Down
4 changes: 4 additions & 0 deletions packages/server/test/client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,10 @@ describe('OpenFeatureClient', () => {
it('should have metadata accessor with domain', () => {
expect(client.metadata.domain).toEqual(domain);
});

it('should expose the server sdk family in metadata', () => {
expect(client.metadata.sdk).toEqual('server');
});
});

describe('Requirement 1.3.1, 1.3.2.1', () => {
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,7 @@ export interface ClientMetadata {
readonly name?: string;
readonly domain?: string;
readonly version?: string;
readonly sdk?: 'web' | 'server';
readonly framework?: 'react' | 'angular' | 'nest';
readonly providerMetadata: ProviderMetadata;
}
35 changes: 35 additions & 0 deletions packages/shared/src/client/framework-metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { ClientMetadata } from './client';

/**
* Wraps a client so framework metadata is visible through `metadata` and `this.metadata`.
* @template T
* @param {T} client client to wrap
* @param {NonNullable<ClientMetadata['framework']>} framework framework metadata to expose
* @returns {T} framework-aware client proxy
*/
export function withFrameworkMetadata<T extends object>(
Comment thread
jonathannorris marked this conversation as resolved.
Outdated
client: T,
framework: NonNullable<ClientMetadata['framework']>,
): T {
return new Proxy(client, {
get(target, property, receiver) {
if (property === 'metadata') {
return {
...(Reflect.get(target, property, receiver) ?? {}),
framework,
};
}

const value = Reflect.get(target, property, receiver);

if (typeof value !== 'function') {
return value;
}

return (...args: unknown[]) => {
const result = value.apply(receiver, args);
return result === target ? receiver : result;
};
},
});
}
1 change: 1 addition & 0 deletions packages/shared/src/client/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './client';
export * from './framework-metadata';
1 change: 1 addition & 0 deletions packages/web/src/client/internal/open-feature-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export class OpenFeatureClient implements Client {
name: this.options.domain ?? this.options.name,
domain: this.options.domain ?? this.options.name,
version: this.options.version,
sdk: 'web',
providerMetadata: this.providerAccessor().metadata,
};
}
Expand Down
4 changes: 4 additions & 0 deletions packages/web/test/client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,10 @@ describe('OpenFeatureClient', () => {
it('should have metadata accessor with domain', () => {
expect(client.metadata.domain).toEqual(domain);
});

it('should expose the web sdk family in metadata', () => {
expect(client.metadata.sdk).toEqual('web');
});
});

describe('Requirement 1.3.1, 1.3.2.1', () => {
Expand Down
Loading