Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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 @@ -5,6 +5,7 @@ import { By } from '@angular/platform-browser';
import { Client, ClientProviderEvents, FlagValue, InMemoryProvider, OpenFeature } from '@openfeature/web-sdk';
import { TestingProvider } from '../test/test.utils';
import { v4 } from 'uuid';
import { vi } from 'vitest';
import {
BooleanFeatureFlagDirective,
FeatureFlagDirectiveContext,
Expand Down Expand Up @@ -182,6 +183,15 @@ class TestComponent {
protected readonly JSON = JSON;
}

@Component({
standalone: true,
imports: [BooleanFeatureFlagDirective],
template: `<div *booleanFeatureFlag="'test-flag'; default: true; domain: domain">Flag On</div>`,
})
class SingleDirectiveTestComponent {
@Input() domain: string;
}

describe('FeatureFlagDirectiveContext', () => {
it('should initialize $implicit and evaluationDetails from EvaluationDetails', () => {
const mockDetails = {
Expand Down Expand Up @@ -361,6 +371,42 @@ describe('FeatureFlagDirective', () => {
});

describe('complex case', () => {
it('should only initialize the client once when the domain is set before init', async () => {
const domain = v4();
const provider = new TestingProvider(
{
'test-flag': {
variants: { default: true },
defaultVariant: 'default',
disabled: false,
},
},
0,
);
const getClientSpy = vi.spyOn(OpenFeature, 'getClient');

try {
const fixture = TestBed.configureTestingModule({
imports: [
OpenFeatureModule.forRoot({
provider: new InMemoryProvider(),
domainBoundProviders: { [domain]: provider },
}),
SingleDirectiveTestComponent,
],
}).createComponent(SingleDirectiveTestComponent);

fixture.componentRef.setInput('domain', domain);
fixture.detectChanges();
await fixture.whenStable();

expect(getClientSpy).toHaveBeenCalledTimes(1);
expect(getClientSpy).toHaveBeenCalledWith(domain);
} finally {
getClientSpy.mockRestore();
}
});

it('should use initializing, then, else and reconciling in one go', async () => {
const { fixture, provider, client, domain } = await createTestingModule({
flagConfiguration: {
Expand Down
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 @@ -114,7 +115,9 @@ export abstract class FeatureFlagDirective<T extends FlagValue> implements OnIni
}

ngOnInit(): void {
this.initClient();
if (!this._client) {
this.initClient();
}
}

ngOnChanges(): void {
Expand All @@ -132,7 +135,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,27 @@ 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: 'js-web',
paradigm: 'client',
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',
);
}
44 changes: 43 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,48 @@ 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: 'js-server',
paradigm: 'server',
framework: 'nest',
});
expect(scopedClient.metadata).toMatchObject({
sdk: 'js-server',
paradigm: '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: 'js-server',
paradigm: '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>;
}
54 changes: 52 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,13 @@ 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: 'js-web',
paradigm: 'client',
framework: 'react',
});
});
});

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

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

it('should return a stable client across renders', () => {
Expand All @@ -96,6 +106,46 @@ 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: 'js-web',
paradigm: 'client',
framework: 'react',
}),
}),
undefined,
);
});
});
});

Expand Down
6 changes: 3 additions & 3 deletions packages/server/src/client/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ClientMetadata, EvaluationLifeCycle, Eventing, ManageContext, ManageLogger } from '@openfeature/core';
import type { EvaluationLifeCycle, Eventing, ManageContext, ManageLogger, MetadataClient } from '@openfeature/core';
import type { Features } from '../evaluation';
import type { ProviderStatus } from '../provider';
import type { ProviderEvents } from '../events';
Expand All @@ -11,8 +11,8 @@ export interface Client
ManageContext<Client>,
ManageLogger<Client>,
Tracking,
Eventing<ProviderEvents> {
readonly metadata: ClientMetadata;
Eventing<ProviderEvents>,
MetadataClient {
/**
* Returns the status of the associated provider.
*/
Expand Down
2 changes: 2 additions & 0 deletions packages/server/src/client/internal/open-feature-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ export class OpenFeatureClient implements Client {
name: this.options.domain ?? this.options.name,
domain: this.options.domain ?? this.options.name,
version: this.options.version,
sdk: 'js-server',
paradigm: 'server',
providerMetadata: this.providerAccessor().metadata,
};
}
Expand Down
5 changes: 5 additions & 0 deletions packages/server/test/client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,11 @@ describe('OpenFeatureClient', () => {
it('should have metadata accessor with domain', () => {
expect(client.metadata.domain).toEqual(domain);
});

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

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