diff --git a/example-new-architecture/App.tsx b/example-new-architecture/App.tsx index f753110f7..9f2a7bb6a 100644 --- a/example-new-architecture/App.tsx +++ b/example-new-architecture/App.tsx @@ -95,10 +95,9 @@ import {APPLICATION_ID, CLIENT_TOKEN, ENVIRONMENT} from './ddCredentials'; })(); function AppWithProviders() { - // No OpenFeature.setContext here on purpose: the offline precomputed configuration is a - // single-subject snapshot served against the context it was computed for (see the wire's - // embedded context in flags/). Setting a different runtime context would put the provider into - // the OpenFeature ERROR state and fall back to coded defaults. + // setFlagsProvider gets a supported copy of the precomputed context and sets it on OpenFeature + // before provider registration. A later different context puts the offline provider into ERROR + // and evaluations use their coded defaults. return ( => { if (source === 'offline') { + const configuration = configurationFromString(buildSampleWire()); + const context = getPrecomputedContext(configuration); + + if (context !== undefined) { + await OpenFeature.setContext(context); + } + const provider = new DatadogOfflineOpenFeatureProvider({ clientName: 'offline', }); - provider.setConfiguration( - configurationFromString(buildSampleWire()), - ); + provider.setConfiguration(configuration); await OpenFeature.setProviderAndWait(provider); return; } diff --git a/example/src/flags/flagsProvider.ts b/example/src/flags/flagsProvider.ts index 70be0801d..7228345b4 100644 --- a/example/src/flags/flagsProvider.ts +++ b/example/src/flags/flagsProvider.ts @@ -1,7 +1,8 @@ import { DatadogOpenFeatureProvider, DatadogOfflineOpenFeatureProvider, - configurationFromString + configurationFromString, + getPrecomputedContext } from '@datadog/mobile-react-native-openfeature'; import { OpenFeature } from '@openfeature/react-sdk'; @@ -28,12 +29,19 @@ export const setFlagsProvider = async ( offlineContext?: OfflineWireContext ): Promise => { if (source === 'offline') { + const configuration = configurationFromString( + buildSampleWire(offlineContext) + ); + const context = getPrecomputedContext(configuration); + + if (context !== undefined) { + await OpenFeature.setContext(context); + } + const provider = new DatadogOfflineOpenFeatureProvider({ clientName: 'offline' }); - provider.setConfiguration( - configurationFromString(buildSampleWire(offlineContext)) - ); + provider.setConfiguration(configuration); await OpenFeature.setProviderAndWait(provider); return; } diff --git a/packages/core/src/flags/FlagsClient.ts b/packages/core/src/flags/FlagsClient.ts index 059493dde..869806ebf 100644 --- a/packages/core/src/flags/FlagsClient.ts +++ b/packages/core/src/flags/FlagsClient.ts @@ -205,11 +205,10 @@ export class FlagsClient { /** * Clear any externally-set evaluation context and reconcile. * - * This is the offline counterpart to clearing/omitting an OpenFeature context: it drops the - * external override so a loaded precomputed configuration is served against **its embedded - * context** again. Clearing the override (rather than skipping) matters so that a - * configuration loaded *after* a clear is not judged against a stale override. With no - * configuration loaded the result is `PROVIDER_NOT_READY`. + * This is an explicit low-level Datadog reset operation. It drops the external override so a + * loaded precomputed configuration is served against **its embedded context** again. It does + * not represent OpenFeature `clearContext()`, which supplies the resulting effective context + * to a provider. With no configuration loaded the result is `PROVIDER_NOT_READY`. */ resetEvaluationContextWithoutFetching = (): ConfigurationResult => { this.externalContext = undefined; diff --git a/packages/core/src/flags/__tests__/FlagsClient.test.ts b/packages/core/src/flags/__tests__/FlagsClient.test.ts index 7df650bf9..bfd5a224f 100644 --- a/packages/core/src/flags/__tests__/FlagsClient.test.ts +++ b/packages/core/src/flags/__tests__/FlagsClient.test.ts @@ -774,6 +774,33 @@ describe('FlagsClient', () => { ).not.toHaveBeenCalled(); }); + it('stores an empty context as an explicit override', () => { + const flagsClient = DdFlags.getClient(); + flagsClient.setConfiguration( + buildConfig(offlineFlags, { targetingKey: 'user-1' }) + ); + + const result = flagsClient.setEvaluationContextWithoutFetching({ + attributes: {} + } as never); + + expect(result).toEqual({ + status: 'error', + errorCode: 'INVALID_CONTEXT' + }); + + // Reloading the snapshot reconciles against the stored empty override. It does not + // silently restore the snapshot's embedded user-1 context. + expect( + flagsClient.setConfiguration( + buildConfig(offlineFlags, { targetingKey: 'user-1' }) + ) + ).toEqual({ + status: 'error', + errorCode: 'INVALID_CONTEXT' + }); + }); + it('recovers to ready when a matching context is set after a mismatch', () => { const flagsClient = DdFlags.getClient(); flagsClient.setConfiguration( diff --git a/packages/core/src/flags/configuration/__tests__/context.test.ts b/packages/core/src/flags/configuration/__tests__/context.test.ts index ec0155bad..e9654c4a4 100644 --- a/packages/core/src/flags/configuration/__tests__/context.test.ts +++ b/packages/core/src/flags/configuration/__tests__/context.test.ts @@ -28,9 +28,9 @@ describe('normalizeWireContext', () => { }); }); - it('defaults a missing targeting key to an empty string', () => { + it('preserves a missing targeting key', () => { expect(normalizeWireContext({ country: 'US' })).toEqual({ - targetingKey: '', + targetingKey: undefined, attributes: { country: 'US' } }); }); @@ -80,6 +80,14 @@ describe('contextMatchesConfiguration', () => { ).toBe(true); }); + it('matches empty contexts without inventing a targeting key', () => { + expect( + contextMatchesConfiguration({}, { + attributes: {} + } as EvaluationContext) + ).toBe(true); + }); + it('does not match a different targeting key', () => { expect( contextMatchesConfiguration( diff --git a/packages/core/src/flags/configuration/context.ts b/packages/core/src/flags/configuration/context.ts index 436ad96b5..1810d937c 100644 --- a/packages/core/src/flags/configuration/context.ts +++ b/packages/core/src/flags/configuration/context.ts @@ -19,14 +19,18 @@ export const normalizeWireContext = ( wireContext: WireEvaluationContext ): EvaluationContext => { const { targetingKey, ...attributes } = wireContext; - - return processEvaluationContext({ - // The wire is untrusted, so a non-string targetingKey is treated as absent. - targetingKey: typeof targetingKey === 'string' ? targetingKey : '', - // `processEvaluationContext` drops non-primitive attributes; casting here mirrors - // how the active context's attributes are typed before that same processing. + const context = { + // `processEvaluationContext` removes unsupported nested values from the attributes. attributes: attributes as Record - }); + } as EvaluationContext; + + // The wire is untrusted. Preserve a string (including an empty string), but do not invent a + // targeting key when it is absent or invalid. + if (typeof targetingKey === 'string') { + context.targetingKey = targetingKey; + } + + return processEvaluationContext(context); }; /** diff --git a/packages/core/src/flags/configuration/index.ts b/packages/core/src/flags/configuration/index.ts index 8078428e2..f0fb1fa46 100644 --- a/packages/core/src/flags/configuration/index.ts +++ b/packages/core/src/flags/configuration/index.ts @@ -10,6 +10,11 @@ // the decoder and other helpers stay internal to this boundary. Keeping the surface contained // here makes a future "port -> depend on a shared core" swap easier. +// TODO(FFL-2837): Re-export `getPrecomputedContext` from +// `@datadog/flagging-core/configuration` here after a flagging-core release contains +// DataDog/openfeature-js-client#353 through `499c31b`. Also expose it from the +// public React Native SDK entry point for the OpenFeature package to consume. + export { configurationFromString, configurationToString } from './wire'; export { decodePrecomputedFlags, diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index 5fd9cca9a..285cae248 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -124,58 +124,65 @@ the network** — you supply it with `setConfiguration`. import { DdFlags } from '@datadog/mobile-react-native'; import { DatadogOfflineOpenFeatureProvider, - configurationFromString + configurationFromString, + getPrecomputedContext } from '@datadog/mobile-react-native-openfeature'; import { OpenFeature } from '@openfeature/react-sdk'; await DdFlags.enable(); -const provider = new DatadogOfflineOpenFeatureProvider(); +const domain = 'offline-flags'; +const configuration = configurationFromString(wire); +const context = getPrecomputedContext(configuration); + +// A context-specific precomputed configuration must use its matching OpenFeature context. +if (context !== undefined) { + await OpenFeature.setContext(domain, context); +} -// `wire` is a ConfigurationWire string you fetched yourself. -provider.setConfiguration(configurationFromString(wire)); +const provider = new DatadogOfflineOpenFeatureProvider(); +provider.setConfiguration(configuration); // Set the provider after loading the configuration so it is ready with real flag values. -await OpenFeature.setProviderAndWait(provider); +await OpenFeature.setProviderAndWait(domain, provider); // Evaluate flags — no network request is made. -const client = OpenFeature.getClient(); -const isNewFeatureEnabled = client.getBooleanValue('new-feature-enabled', false); +const client = OpenFeature.getClient(domain); +const isNewFeatureEnabled = client.getBooleanValue( + 'new-feature-enabled', + false +); ``` -The configuration carries the evaluation context it was computed for, and the provider adopts it -automatically. A precomputed configuration is a **single-subject snapshot**: it can only be served -against the context it was computed for. Per-context evaluation is a future (rules-based) capability. +A context-specific precomputed configuration is a **single-subject snapshot**. The effective +OpenFeature context must match the context that was used to compute the snapshot. Use +`getPrecomputedContext(configuration)` to get a detached copy through a supported API. Do not inspect +the parsed configuration or wire format. The helper does not call OpenFeature and does not change +provider state. + +If a context-specific snapshot does not match the effective context, the provider enters the +OpenFeature **`ERROR`** state. Evaluations return their coded default values with +`errorCode: INVALID_CONTEXT`. Set the matching context to recover the provider to `READY`. -> **Warning:** Do **not** call `OpenFeature.setContext` with a _different_ context for the offline -> precomputed flow. A runtime context that does not match the configuration's embedded context -> (compared after the SDK's context normalization, not raw deep-equality) cannot be served (offline -> never fetches), so the provider enters the OpenFeature **`ERROR`** state and evaluations fall back -> to your **coded default values** (evaluation `errorCode: INVALID_CONTEXT`). The provider recovers to -> `READY` once the effective context is empty or matches the snapshot again. Note that a blank -> `{ targetingKey: '' }` is **not** "empty" — an empty string is a real (anonymous) targeting key, a -> distinct subject that must match the snapshot; use `clearContext()` (or omit context) to fall back -> to the embedded context. +An empty context (`{}`) is a real OpenFeature context. It does not restore the context in the +configuration. An empty targeting key (`{ targetingKey: '' }`) is also a real context and is +different from a missing targeting key. A context-agnostic precomputed configuration has no +embedded context. `getPrecomputedContext` returns `undefined` for that configuration, and it can be +used with any effective context. Recommended setup for a hybrid app that also uses other OpenFeature providers, hooks, or domains: -- **Bind the offline provider to a dedicated OpenFeature domain, and give that domain an explicit - empty context** at registration (`OpenFeature.setContext(domain, {})`) — which this provider reads - as "no override, use the embedded context". A domain with no context of its own **inherits the - global context**, so a global `OpenFeature.setContext` (or a mismatching global context) would - otherwise reach the provider and force it into `ERROR`. +- **Bind the offline provider to a dedicated OpenFeature domain.** Set the helper context on that + domain before provider registration. A domain with no context of its own inherits the global + context. - **Use a unique Datadog `clientName`** (`new DatadogOfflineOpenFeatureProvider({ clientName })`): separate OpenFeature domains otherwise share the same underlying `DdFlags.getClient('default')`, and an online provider on that shared client would discard the offline configuration. -Because you do not set an OpenFeature context, note the **context split**: OpenFeature hooks observe -the OpenFeature evaluation context (`{}` when unset), while Datadog exposure tracking attributes -evaluations to the configuration's embedded context. - -> **Note (recovery caveat):** "clearing context recovers" holds only when the resulting *effective* -> context is empty or matches the snapshot. `OpenFeature.clearContext(domain)` removes the domain -> context and **falls back to the global context** — if that global context is non-empty and does not -> match, the provider stays in `ERROR`. +`OpenFeature.clearContext(domain)` removes the domain context and uses the global context. If the +global context is empty or does not match a context-specific snapshot, the provider enters `ERROR`. +Call `OpenFeature.setContext(domain, matchingContext)` to recover. A global `clearContext()` supplies +`{}` to the provider; it does not restore the context in the configuration. > **Note (startup order):** Load the configuration with `setConfiguration` _before_ > `setProviderAndWait`, as shown above. If you register the provider before any successful diff --git a/packages/react-native-openfeature/src/__tests__/configuration.test.ts b/packages/react-native-openfeature/src/__tests__/configuration.test.ts new file mode 100644 index 000000000..00565c195 --- /dev/null +++ b/packages/react-native-openfeature/src/__tests__/configuration.test.ts @@ -0,0 +1,119 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import type { ParsedFlagsConfiguration } from '@datadog/mobile-react-native'; +import type { EvaluationContext } from '@openfeature/web-sdk'; + +import { getPrecomputedContext } from '../configuration'; + +const configurationWithContext = ( + context?: EvaluationContext +): ParsedFlagsConfiguration => { + return { + precomputed: { + response: { + data: { + attributes: { + createdAt: '2026-08-05T00:00:00.000Z', + flags: {} + } + } + }, + ...(context === undefined ? {} : { context }) + } + }; +}; + +describe('getPrecomputedContext', () => { + it('returns the context from a precomputed configuration', () => { + const configuration = configurationWithContext({ + targetingKey: 'user-1', + country: 'US' + }); + + expect(getPrecomputedContext(configuration)).toEqual({ + targetingKey: 'user-1', + country: 'US' + }); + }); + + it('preserves an explicit empty context and an empty targeting key', () => { + expect(getPrecomputedContext(configurationWithContext({}))).toEqual({}); + expect( + getPrecomputedContext( + configurationWithContext({ targetingKey: '' }) + ) + ).toEqual({ targetingKey: '' }); + }); + + it('returns a deep copy of the context', () => { + const date = new Date('2026-08-05T00:00:00.000Z'); + const configuration = configurationWithContext({ + targetingKey: 'user-1', + profile: { + groups: ['beta', { name: 'mobile' }], + enrolledAt: date + } + }); + + const first = getPrecomputedContext(configuration) as EvaluationContext; + const firstProfile = first.profile as { + groups: Array; + enrolledAt: Date; + }; + firstProfile.groups[1] = { name: 'changed' }; + firstProfile.enrolledAt.setUTCFullYear(2030); + + const second = getPrecomputedContext(configuration); + expect(second).toEqual({ + targetingKey: 'user-1', + profile: { + groups: ['beta', { name: 'mobile' }], + enrolledAt: date + } + }); + expect(second).not.toBe(first); + expect((second?.profile as { groups: unknown[] }).groups).not.toBe( + firstProfile.groups + ); + expect((second?.profile as { enrolledAt: Date }).enrolledAt).not.toBe( + date + ); + }); + + it.each([ + ['an empty configuration', {}], + ['a rules-only configuration', { rulesBased: { response: {} } }], + [ + 'an invalid precomputed branch with valid rules', + { + precomputedError: new Error('invalid precomputed branch'), + rulesBased: { response: {} } + } + ] + ])('returns undefined for %s', (_name, configuration) => { + expect( + getPrecomputedContext(configuration as ParsedFlagsConfiguration) + ).toBeUndefined(); + }); + + it('returns undefined for context-agnostic precomputed configuration', () => { + expect( + getPrecomputedContext(configurationWithContext()) + ).toBeUndefined(); + }); + + it('returns the precomputed context from a mixed configuration', () => { + const configuration = { + ...configurationWithContext({ targetingKey: 'user-1' }), + rulesBased: { response: {} } + } as ParsedFlagsConfiguration; + + expect(getPrecomputedContext(configuration)).toEqual({ + targetingKey: 'user-1' + }); + }); +}); diff --git a/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts b/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts index 6c5d2d4d2..08ff5b726 100644 --- a/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts @@ -13,7 +13,9 @@ import { configurationFromString } from '@datadog/mobile-react-native'; import { ErrorCode, OpenFeature, ProviderStatus } from '@openfeature/web-sdk'; +import type { EvaluationContext } from '@openfeature/web-sdk'; +import { getPrecomputedContext } from '../configuration'; import { DatadogOfflineOpenFeatureProvider } from '../offlineProvider'; // Stub the native flags TurboModule (TurboModuleRegistry.get returns null under jest), so @@ -27,7 +29,7 @@ jest.mock('../../../core/src/specs/NativeDdFlags', () => ({ } })); -const wireFor = (targetingKey: string): string => +const wireFor = (context?: EvaluationContext): string => JSON.stringify({ version: 1, precomputed: { @@ -49,7 +51,7 @@ const wireFor = (targetingKey: string): string => } } }), - context: { targetingKey } + ...(context === undefined ? {} : { context }) } }); @@ -61,6 +63,20 @@ const freshNames = () => { return { domain: `offline-int-${seq}`, clientName: `offline-int-${seq}` }; }; +const requiredPrecomputedContext = ( + configuration: ReturnType +): EvaluationContext => { + const context = getPrecomputedContext(configuration); + + if (context === undefined) { + throw new Error( + 'Expected a context-specific precomputed configuration.' + ); + } + + return context; +}; + describe('DatadogOfflineOpenFeatureProvider (integration, real FlagsClient + OpenFeature)', () => { afterEach(async () => { await OpenFeature.clearProviders(); @@ -68,49 +84,95 @@ describe('DatadogOfflineOpenFeatureProvider (integration, real FlagsClient + Ope await OpenFeature.clearContext(); }); - it('is READY when a matching configuration is loaded before registration', async () => { + it('uses the helper context to start READY with a precomputed configuration', async () => { const { domain, clientName } = freshNames(); + const configuration = configurationFromString( + wireFor({ targetingKey: 'user-123' }) + ); + const context = getPrecomputedContext(configuration); + + expect(context).toEqual({ targetingKey: 'user-123' }); + await OpenFeature.setContext( + domain, + requiredPrecomputedContext(configuration) + ); + const provider = new DatadogOfflineOpenFeatureProvider({ clientName }); - provider.setConfiguration(configurationFromString(wireFor('user-123'))); + provider.setConfiguration(configuration); await OpenFeature.setProviderAndWait(domain, provider); const client = OpenFeature.getClient(domain); expect(client.providerStatus).toBe(ProviderStatus.READY); expect(client.getBooleanValue('new-feature', false)).toBe(true); + expect( + jest.requireMock('../../../core/src/specs/NativeDdFlags').default + .setEvaluationContext + ).not.toHaveBeenCalled(); }); - it('enters ERROR and serves defaults on a mismatching setContext, then recovers on a matching one', async () => { + it('starts in ERROR when a context-specific configuration has no OpenFeature context', async () => { const { domain, clientName } = freshNames(); const provider = new DatadogOfflineOpenFeatureProvider({ clientName }); - provider.setConfiguration(configurationFromString(wireFor('user-123'))); + provider.setConfiguration( + configurationFromString(wireFor({ targetingKey: 'user-123' })) + ); + + await expect( + OpenFeature.setProviderAndWait(domain, provider) + ).rejects.toThrow(); + + const client = OpenFeature.getClient(domain); + expect(client.providerStatus).toBe(ProviderStatus.ERROR); + expect(client.getBooleanDetails('new-feature', false)).toMatchObject({ + value: false, + errorCode: ErrorCode.INVALID_CONTEXT + }); + }); + + it('enters ERROR on a mismatching context and recovers on a matching context', async () => { + const { domain, clientName } = freshNames(); + const configuration = configurationFromString( + wireFor({ targetingKey: 'user-123' }) + ); + await OpenFeature.setContext( + domain, + requiredPrecomputedContext(configuration) + ); + const provider = new DatadogOfflineOpenFeatureProvider({ clientName }); + provider.setConfiguration(configuration); await OpenFeature.setProviderAndWait(domain, provider); - // A runtime context that does not match the snapshot cannot be served. await OpenFeature.setContext(domain, { targetingKey: 'someone-else' }); const client = OpenFeature.getClient(domain); expect(client.providerStatus).toBe(ProviderStatus.ERROR); - // Serving the coded default, with the precise error code. const details = client.getBooleanDetails('new-feature', false); expect(details.value).toBe(false); expect(details.errorCode).toBe(ErrorCode.INVALID_CONTEXT); - // Setting the matching context again recovers automatically and serves the retained value. await OpenFeature.setContext(domain, { targetingKey: 'user-123' }); expect(client.providerStatus).toBe(ProviderStatus.READY); expect(client.getBooleanValue('new-feature', false)).toBe(true); }); - it('errors on an explicit empty-string targeting key (a real anonymous subject, not "cleared")', async () => { + it.each([ + ['an empty context', {}], + ['an empty targeting key', { targetingKey: '' }] + ])('treats %s as a real context', async (_label, nextContext) => { const { domain, clientName } = freshNames(); + const configuration = configurationFromString( + wireFor({ targetingKey: 'user-123' }) + ); + await OpenFeature.setContext( + domain, + requiredPrecomputedContext(configuration) + ); const provider = new DatadogOfflineOpenFeatureProvider({ clientName }); - provider.setConfiguration(configurationFromString(wireFor('user-123'))); + provider.setConfiguration(configuration); await OpenFeature.setProviderAndWait(domain, provider); - // `{ targetingKey: '' }` is an anonymous subject, distinct from the user-123 snapshot — not - // the same as clearing context. It must error rather than silently serve user-123's flags. - await OpenFeature.setContext(domain, { targetingKey: '' }); + await OpenFeature.setContext(domain, nextContext); const client = OpenFeature.getClient(domain); expect(client.providerStatus).toBe(ProviderStatus.ERROR); @@ -119,47 +181,67 @@ describe('DatadogOfflineOpenFeatureProvider (integration, real FlagsClient + Ope ); }); - it('stays READY when the context is cleared (empty = re-adopt embedded)', async () => { - const { domain, clientName } = freshNames(); - const provider = new DatadogOfflineOpenFeatureProvider({ clientName }); - provider.setConfiguration(configurationFromString(wireFor('user-123'))); - await OpenFeature.setProviderAndWait(domain, provider); + it.each([ + ['context-agnostic', undefined, undefined], + ['explicitly empty', {}, {}] + ])( + 'starts READY with a %s precomputed context', + async (_label, wireContext, expectedHelperContext) => { + const { domain, clientName } = freshNames(); + const configuration = configurationFromString( + wireFor(wireContext as EvaluationContext | undefined) + ); + expect(getPrecomputedContext(configuration)).toEqual( + expectedHelperContext + ); - await OpenFeature.setContext(domain, { targetingKey: 'user-123' }); - expect(OpenFeature.getClient(domain).providerStatus).toBe( - ProviderStatus.READY - ); + const provider = new DatadogOfflineOpenFeatureProvider({ + clientName + }); + provider.setConfiguration(configuration); + await OpenFeature.setProviderAndWait(domain, provider); - // clearContext(domain) falls back to the empty global context; empty means "no override", so - // the embedded context is re-adopted and the provider stays READY. - await OpenFeature.clearContext(domain); - expect(OpenFeature.getClient(domain).providerStatus).toBe( - ProviderStatus.READY - ); - }); + expect(OpenFeature.getClient(domain).providerStatus).toBe( + ProviderStatus.READY + ); + } + ); - it('starts in ERROR when registered before any configuration, then recovers via setConfiguration', async () => { + it('recovers a provider-first setup after the application sets the helper context', async () => { const { domain, clientName } = freshNames(); const provider = new DatadogOfflineOpenFeatureProvider({ clientName }); - // Provider-first: initialize rejects (no usable configuration), so registration surfaces - // an error and the provider is ERROR rather than a misleading READY. await expect( OpenFeature.setProviderAndWait(domain, provider) ).rejects.toThrow(); const client = OpenFeature.getClient(domain); expect(client.providerStatus).toBe(ProviderStatus.ERROR); - // Loading a valid configuration recovers via the emitted PROVIDER_READY. - provider.setConfiguration(configurationFromString(wireFor('user-123'))); + const configuration = configurationFromString( + wireFor({ targetingKey: 'user-123' }) + ); + provider.setConfiguration(configuration); + expect(client.providerStatus).toBe(ProviderStatus.ERROR); + + await OpenFeature.setContext( + domain, + requiredPrecomputedContext(configuration) + ); expect(client.providerStatus).toBe(ProviderStatus.READY); expect(client.getBooleanValue('new-feature', false)).toBe(true); }); it('recovers via setConfiguration when a config matching the current context is loaded', async () => { const { domain, clientName } = freshNames(); + const configuration = configurationFromString( + wireFor({ targetingKey: 'user-123' }) + ); + await OpenFeature.setContext( + domain, + requiredPrecomputedContext(configuration) + ); const provider = new DatadogOfflineOpenFeatureProvider({ clientName }); - provider.setConfiguration(configurationFromString(wireFor('user-123'))); + provider.setConfiguration(configuration); await OpenFeature.setProviderAndWait(domain, provider); await OpenFeature.setContext(domain, { targetingKey: 'someone-else' }); @@ -169,26 +251,22 @@ describe('DatadogOfflineOpenFeatureProvider (integration, real FlagsClient + Ope // Load a configuration computed for the now-current context: it reconciles to ready and // setConfiguration emits PROVIDER_READY. provider.setConfiguration( - configurationFromString(wireFor('someone-else')) + configurationFromString(wireFor({ targetingKey: 'someone-else' })) ); expect(client.providerStatus).toBe(ProviderStatus.READY); }); describe('domain / global-context isolation', () => { - it('stays READY with the documented setup: global mismatch set, explicit empty domain context, then register', async () => { + it('inherits a matching global context when the domain has no context', async () => { const { domain, clientName } = freshNames(); const provider = new DatadogOfflineOpenFeatureProvider({ clientName }); provider.setConfiguration( - configurationFromString(wireFor('user-123')) + configurationFromString(wireFor({ targetingKey: 'user-123' })) ); - // The documented order: a mismatching GLOBAL context is already in place, the dedicated - // domain is given an explicit empty context (isolating it), and only then is the provider - // registered. It initializes against the empty domain context → embedded → READY. - await OpenFeature.setContext({ targetingKey: 'global-user' }); - await OpenFeature.setContext(domain, {}); + await OpenFeature.setContext({ targetingKey: 'user-123' }); await OpenFeature.setProviderAndWait(domain, provider); expect(OpenFeature.getClient(domain).providerStatus).toBe( @@ -196,45 +274,44 @@ describe('DatadogOfflineOpenFeatureProvider (integration, real FlagsClient + Ope ); }); - it('inherits a mismatching global context when the domain has none, entering ERROR', async () => { + it('enters ERROR when a cleared domain inherits an empty global context', async () => { const { domain, clientName } = freshNames(); + const configuration = configurationFromString( + wireFor({ targetingKey: 'user-123' }) + ); + await OpenFeature.setContext( + domain, + requiredPrecomputedContext(configuration) + ); const provider = new DatadogOfflineOpenFeatureProvider({ clientName }); - provider.setConfiguration( - configurationFromString(wireFor('user-123')) - ); + provider.setConfiguration(configuration); + await OpenFeature.setProviderAndWait(domain, provider); - // A global (non-domain) context is set; the domain has no context of its own, so it - // inherits the global one at registration → mismatch → ERROR. - await OpenFeature.setContext({ targetingKey: 'global-user' }); - await expect( - OpenFeature.setProviderAndWait(domain, provider) - ).rejects.toThrow(); + await OpenFeature.clearContext(domain); expect(OpenFeature.getClient(domain).providerStatus).toBe( ProviderStatus.ERROR ); }); - it('ignores later global context changes once the domain has an explicit empty context', async () => { + it('stays READY when a cleared domain inherits a matching global context', async () => { const { domain, clientName } = freshNames(); + const configuration = configurationFromString( + wireFor({ targetingKey: 'user-123' }) + ); + await OpenFeature.setContext({ targetingKey: 'user-123' }); + await OpenFeature.setContext(domain, { + targetingKey: 'user-123' + }); const provider = new DatadogOfflineOpenFeatureProvider({ clientName }); - provider.setConfiguration( - configurationFromString(wireFor('user-123')) - ); + provider.setConfiguration(configuration); await OpenFeature.setProviderAndWait(domain, provider); - // Give the domain its own (empty) context: this provider reads it as "no override". - await OpenFeature.setContext(domain, {}); - expect(OpenFeature.getClient(domain).providerStatus).toBe( - ProviderStatus.READY - ); - - // A later mismatching GLOBAL context does not reach a domain that has its own context. - await OpenFeature.setContext({ targetingKey: 'global-mismatch' }); + await OpenFeature.clearContext(domain); expect(OpenFeature.getClient(domain).providerStatus).toBe( ProviderStatus.READY ); @@ -246,22 +323,20 @@ describe('DatadogOfflineOpenFeatureProvider (integration, real FlagsClient + Ope clientName }); provider.setConfiguration( - configurationFromString(wireFor('user-123')) + configurationFromString(wireFor({ targetingKey: 'user-123' })) ); - await OpenFeature.setProviderAndWait(domain, provider); await OpenFeature.setContext(domain, { targetingKey: 'user-123' }); + await OpenFeature.setProviderAndWait(domain, provider); expect(OpenFeature.getClient(domain).providerStatus).toBe( ProviderStatus.READY ); - // A mismatching global context does not reach the domain while it has its own context. await OpenFeature.setContext({ targetingKey: 'global-mismatch' }); expect(OpenFeature.getClient(domain).providerStatus).toBe( ProviderStatus.READY ); - // Clearing the domain context falls back to the (mismatching) global context → ERROR. await OpenFeature.clearContext(domain); expect(OpenFeature.getClient(domain).providerStatus).toBe( ProviderStatus.ERROR diff --git a/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts b/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts index 7a60b331e..78c5f096d 100644 --- a/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts @@ -57,18 +57,16 @@ describe('DatadogOfflineOpenFeatureProvider', () => { ); }); - it('re-adopts the embedded context on an empty initialize context', async () => { + it('records an empty initialize context without fetching', async () => { const provider = new DatadogOfflineOpenFeatureProvider(); await provider.initialize({}); - // An empty context means "no external override": reset to the embedded context rather - // than setting an (empty) override. - expect( - mockFlagsClient.resetEvaluationContextWithoutFetching - ).toHaveBeenCalled(); expect( mockFlagsClient.setEvaluationContextWithoutFetching + ).toHaveBeenCalledWith({ attributes: {} }); + expect( + mockFlagsClient.resetEvaluationContextWithoutFetching ).not.toHaveBeenCalled(); expect(mockFlagsClient.setEvaluationContext).not.toHaveBeenCalled(); }); @@ -99,7 +97,7 @@ describe('DatadogOfflineOpenFeatureProvider', () => { it('rejects initialize when no configuration is loaded (provider-first)', async () => { const provider = new DatadogOfflineOpenFeatureProvider(); - mockFlagsClient.resetEvaluationContextWithoutFetching.mockReturnValueOnce( + mockFlagsClient.setEvaluationContextWithoutFetching.mockReturnValueOnce( notReady ); @@ -140,32 +138,40 @@ describe('DatadogOfflineOpenFeatureProvider', () => { ).toThrow(InvalidContextError); }); - it('re-adopts the embedded context on clearContext / empty context change', () => { + it('treats a cleared or empty context as the effective context', () => { const provider = new DatadogOfflineOpenFeatureProvider(); provider.onContextChange({ targetingKey: 'user-1' }, {}); - // Clearing context is not a mismatch: it re-adopts the embedded context and does not throw. - expect( - mockFlagsClient.resetEvaluationContextWithoutFetching - ).toHaveBeenCalled(); expect( mockFlagsClient.setEvaluationContextWithoutFetching + ).toHaveBeenCalledWith({ attributes: {} }); + expect( + mockFlagsClient.resetEvaluationContextWithoutFetching ).not.toHaveBeenCalled(); }); - it('treats a context with only an undefined targetingKey as empty', () => { + it('does not invent a targeting key when it is undefined', () => { const provider = new DatadogOfflineOpenFeatureProvider(); provider.onContextChange({}, { targetingKey: undefined }); - // `{ targetingKey: undefined }` carries no information: reset to the embedded context. + expect( + mockFlagsClient.setEvaluationContextWithoutFetching + ).toHaveBeenCalledWith({ attributes: {} }); expect( mockFlagsClient.resetEvaluationContextWithoutFetching - ).toHaveBeenCalled(); + ).not.toHaveBeenCalled(); + }); + + it('preserves an explicit empty targeting key', () => { + const provider = new DatadogOfflineOpenFeatureProvider(); + + provider.onContextChange({}, { targetingKey: '' }); + expect( mockFlagsClient.setEvaluationContextWithoutFetching - ).not.toHaveBeenCalled(); + ).toHaveBeenCalledWith({ targetingKey: '', attributes: {} }); }); it('delegates setConfiguration to the client and emits CONFIGURATION_CHANGED', () => { @@ -226,7 +232,7 @@ describe('DatadogOfflineOpenFeatureProvider', () => { // A pre-registration setConfiguration error had no listeners, and the empty initialize // context reconciles to the same error, so initialize rejects -> the Web SDK starts the // provider in ERROR rather than a misleading READY. - mockFlagsClient.resetEvaluationContextWithoutFetching.mockReturnValueOnce( + mockFlagsClient.setEvaluationContextWithoutFetching.mockReturnValueOnce( generalError ); await expect(provider.initialize({})).rejects.toThrow(GeneralError); diff --git a/packages/react-native-openfeature/src/configuration.ts b/packages/react-native-openfeature/src/configuration.ts new file mode 100644 index 000000000..19f9a9c70 --- /dev/null +++ b/packages/react-native-openfeature/src/configuration.ts @@ -0,0 +1,71 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import type { ParsedFlagsConfiguration } from '@datadog/mobile-react-native'; +import type { + EvaluationContext, + EvaluationContextValue +} from '@openfeature/web-sdk'; + +// TODO(FFL-2837): Delete this local helper and its clone-semantics tests after a +// flagging-core release contains DataDog/openfeature-js-client#353 through +// `499c31b` and `@datadog/mobile-react-native` re-exports the upstream helper. +// Import and re-export `getPrecomputedContext` from the React Native SDK instead. +// Raise the React Native SDK peer and development dependency minimums to the first +// release that exports it. Replace these semantic tests with one package-root +// forwarding test. Keep the provider and bootstrap integration tests. +const cloneContextValue = ( + value: EvaluationContextValue +): EvaluationContextValue => { + if (value instanceof Date) { + return new Date(value.getTime()); + } + + if (Array.isArray(value)) { + return value.map(cloneContextValue); + } + + if (value !== null && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, nestedValue]) => [ + key, + cloneContextValue(nestedValue) + ]) + ); + } + + return value; +}; + +const cloneEvaluationContext = ( + context: EvaluationContext +): EvaluationContext => { + return Object.fromEntries( + Object.entries(context).map(([key, value]) => [ + key, + cloneContextValue(value) + ]) + ); +}; + +/** + * Return the evaluation context from a precomputed configuration. + * + * The returned context is a detached copy. Setting it as the OpenFeature context is an explicit + * application operation; this function does not modify OpenFeature or provider state. It returns + * `undefined` when the configuration has no context-specific precomputed branch. + */ +export const getPrecomputedContext = ( + configuration: ParsedFlagsConfiguration +): EvaluationContext | undefined => { + const context = configuration.precomputed?.context; + + if (context === undefined) { + return undefined; + } + + return cloneEvaluationContext(context); +}; diff --git a/packages/react-native-openfeature/src/index.ts b/packages/react-native-openfeature/src/index.ts index 55c571c9e..c9614768e 100644 --- a/packages/react-native-openfeature/src/index.ts +++ b/packages/react-native-openfeature/src/index.ts @@ -6,6 +6,7 @@ import { configurationFromString } from '@datadog/mobile-react-native'; +import { getPrecomputedContext } from './configuration'; import { DatadogOfflineOpenFeatureProvider } from './offlineProvider'; import { DatadogOpenFeatureProvider } from './provider'; import type { DatadogOpenFeatureProviderOptions } from './provider'; @@ -13,6 +14,7 @@ import type { DatadogOpenFeatureProviderOptions } from './provider'; export { DatadogOpenFeatureProvider, DatadogOfflineOpenFeatureProvider, - configurationFromString + configurationFromString, + getPrecomputedContext }; export type { DatadogOpenFeatureProviderOptions }; diff --git a/packages/react-native-openfeature/src/mappers.ts b/packages/react-native-openfeature/src/mappers.ts index ecc7d03b8..80651ed9d 100644 --- a/packages/react-native-openfeature/src/mappers.ts +++ b/packages/react-native-openfeature/src/mappers.ts @@ -31,17 +31,20 @@ export const toDdContext = ( }; /** - * Whether an OpenFeature evaluation context carries no information — no targeting key and no - * attributes with a defined value (so `{}` and `{ targetingKey: undefined }` are both empty). - * Used by the offline provider to avoid overwriting a configuration's embedded context with an - * empty context stamped by the OpenFeature lifecycle. - * - * Note: an explicit `targetingKey: ''` is **not** empty. An empty string is a real (anonymous) - * targeting key — a distinct subject — not the absence of a context. Only a genuinely absent - * context (`{}` / `clearContext()`) re-adopts the configuration's embedded context; `{ targetingKey: - * '' }` is reconciled as a real context, so it must match the precomputed snapshot or the provider - * enters `ERROR` (serving coded defaults) rather than silently serving another subject's flags. + * Convert an OpenFeature context for offline evaluation without inventing a targeting key. + * Rules distinguish a missing targeting key from an empty targeting key. */ -export const isEmptyContext = (context: OFEvaluationContext): boolean => { - return Object.values(context).every(value => value === undefined); +export const toDdContextPreservingTargetingKey = ( + context: OFEvaluationContext +): DdEvaluationContext => { + const { targetingKey, ...attributes } = context; + const ddContext = { + attributes: attributes as Record + } as DdEvaluationContext; + + if (targetingKey !== undefined) { + ddContext.targetingKey = targetingKey; + } + + return ddContext; }; diff --git a/packages/react-native-openfeature/src/offlineProvider.ts b/packages/react-native-openfeature/src/offlineProvider.ts index aa4f868a7..f0aec033b 100644 --- a/packages/react-native-openfeature/src/offlineProvider.ts +++ b/packages/react-native-openfeature/src/offlineProvider.ts @@ -22,7 +22,7 @@ import type { } from '@openfeature/web-sdk'; import { DatadogCoreOpenFeatureProvider } from './coreProvider'; -import { isEmptyContext, toDdContext } from './mappers'; +import { toDdContextPreservingTargetingKey } from './mappers'; // The outcome of a `FlagsClient` reconcile. Derived from the client so the provider maps it to // OpenFeature transitions; not part of the package's public API. @@ -49,28 +49,33 @@ const OF_ERROR_CODE: Record = { * It behaves like the online `DatadogOpenFeatureProvider` — same flag evaluation and * exposure/RUM tracking — **except it never fetches configuration from the network**. * Instead of fetching on `initialize`/`onContextChange`, it evaluates against a configuration - * supplied via {@link DatadogOfflineOpenFeatureProvider.setConfiguration}. A precomputed - * configuration carries the evaluation context it was computed for, so you should **not** call - * `OpenFeature.setContext` for the offline precomputed flow — see the class remarks. + * supplied via {@link DatadogOfflineOpenFeatureProvider.setConfiguration}. * * A runtime context that does not match the configuration's embedded context (compared after * normalization) cannot be served (offline never fetches), so it puts the provider into the * OpenFeature `ERROR` state and evaluations fall back to your coded defaults (`INVALID_CONTEXT`). - * An empty *effective* context re-adopts the embedded context and recovers — but note that - * `clearContext(domain)` falls back to the global context, which may itself be non-empty and - * mismatching (and would keep the provider in `ERROR`). Load the configuration before setting the - * provider so it is ready with real flag values from the start: + * An empty context is a real context. It does not select the embedded context. Use + * `getPrecomputedContext` to get a supported copy of the embedded context, and set it on + * OpenFeature before provider registration. Load the configuration before setting the provider so + * it is ready with real flag values from the start: * * @example * ```ts * import { OpenFeature } from '@openfeature/web-sdk'; * import { * DatadogOfflineOpenFeatureProvider, - * configurationFromString + * configurationFromString, + * getPrecomputedContext * } from '@datadog/mobile-react-native-openfeature'; * + * const configuration = configurationFromString(wire); + * const context = getPrecomputedContext(configuration); + * if (context !== undefined) { + * await OpenFeature.setContext(context); + * } + * * const provider = new DatadogOfflineOpenFeatureProvider(); - * provider.setConfiguration(configurationFromString(wire)); // no network + * provider.setConfiguration(configuration); // no network * await OpenFeature.setProviderAndWait(provider); * * const client = OpenFeature.getClient(); @@ -145,15 +150,11 @@ export class DatadogOfflineOpenFeatureProvider extends DatadogCoreOpenFeaturePro } private applyContext(context: OFEvaluationContext): ConfigurationResult { - // An empty context means "no external override": clear it so a loaded precomputed - // configuration is served against its embedded context. Order-independent — the synthetic - // `initialize({})`, `setContext({})`, and `clearContext()` all re-adopt the embedded - // context rather than being treated as a mismatch. - const result = isEmptyContext(context) - ? this.flagsClient.resetEvaluationContextWithoutFetching() - : this.flagsClient.setEvaluationContextWithoutFetching( - toDdContext(context) - ); + // OpenFeature gives the provider only the effective context. It uses `{}` for an unset or + // cleared global context, so the provider must treat `{}` as the real effective context. + const result = this.flagsClient.setEvaluationContextWithoutFetching( + toDdContextPreservingTargetingKey(context) + ); this.configurationInError = result.status === 'error';