diff --git a/example-new-architecture/flags/FlagsSourceToggle.tsx b/example-new-architecture/flags/FlagsSourceToggle.tsx
index 5920c0da5..5025dbfec 100644
--- a/example-new-architecture/flags/FlagsSourceToggle.tsx
+++ b/example-new-architecture/flags/FlagsSourceToggle.tsx
@@ -1,13 +1,7 @@
import React, {useState} from 'react';
-import {
- View,
- Text,
- Switch,
- ActivityIndicator,
- StyleSheet,
-} from 'react-native';
+import {View, Text, Switch, ActivityIndicator, StyleSheet} from 'react-native';
-import {setFlagsProvider} from './flagsProvider';
+import {setFlagsProvider, setOfflineExampleContext} from './flagsProvider';
import type {FlagsSource} from './flagsProvider';
/**
@@ -20,6 +14,7 @@ export const FlagsSourceToggle = ({
initialSource?: FlagsSource;
}) => {
const [offline, setOffline] = useState(initialSource === 'offline');
+ const [included, setIncluded] = useState(true);
const [busy, setBusy] = useState(false);
const onToggle = async (nextOffline: boolean) => {
@@ -27,6 +22,19 @@ export const FlagsSourceToggle = ({
try {
await setFlagsProvider(nextOffline ? 'offline' : 'online');
setOffline(nextOffline);
+ if (nextOffline) {
+ setIncluded(true);
+ }
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const onAudienceToggle = async (nextIncluded: boolean) => {
+ setBusy(true);
+ try {
+ await setOfflineExampleContext(nextIncluded);
+ setIncluded(nextIncluded);
} finally {
setBusy(false);
}
@@ -43,6 +51,19 @@ export const FlagsSourceToggle = ({
onValueChange={onToggle}
disabled={busy}
/>
+ {offline ? (
+ <>
+
+ Rules match: {included ? 'yes' : 'no'}
+
+
+ >
+ ) : null}
{busy ? : null}
);
@@ -60,4 +81,8 @@ const styles = StyleSheet.create({
spinner: {
marginLeft: 10,
},
+ audienceLabel: {
+ marginLeft: 16,
+ marginRight: 10,
+ },
});
diff --git a/example-new-architecture/flags/flagsProvider.ts b/example-new-architecture/flags/flagsProvider.ts
index 1d4a4dab6..5253b8a27 100644
--- a/example-new-architecture/flags/flagsProvider.ts
+++ b/example-new-architecture/flags/flagsProvider.ts
@@ -5,7 +5,10 @@ import {
} from '@datadog/mobile-react-native-openfeature';
import {OpenFeature} from '@openfeature/react-sdk';
-import {buildSampleWire} from './sampleOfflineConfiguration';
+import {
+ buildSampleWire,
+ DYNAMIC_OFFLINE_CONTEXTS,
+} from './sampleOfflineConfiguration';
export type FlagsSource = 'online' | 'offline';
@@ -27,10 +30,9 @@ export const setFlagsProvider = async (source: FlagsSource): Promise => {
const provider = new DatadogOfflineOpenFeatureProvider({
clientName: 'offline',
});
- provider.setConfiguration(
- configurationFromString(buildSampleWire()),
- );
+ provider.setConfiguration(configurationFromString(buildSampleWire()));
await OpenFeature.setProviderAndWait(provider);
+ await setOfflineExampleContext(true);
return;
}
@@ -38,3 +40,21 @@ export const setFlagsProvider = async (source: FlagsSource): Promise => {
new DatadogOpenFeatureProvider({clientName: 'online'}),
);
};
+
+/**
+ * Change the dynamic offline subject without a network request.
+ *
+ * Try both calls:
+ *
+ * `await setOfflineExampleContext(true);`
+ * `await setOfflineExampleContext(false);`
+ */
+export const setOfflineExampleContext = async (
+ included: boolean,
+): Promise => {
+ await OpenFeature.setContext(
+ included
+ ? DYNAMIC_OFFLINE_CONTEXTS.included
+ : DYNAMIC_OFFLINE_CONTEXTS.excluded,
+ );
+};
diff --git a/example-new-architecture/flags/sampleOfflineConfiguration.ts b/example-new-architecture/flags/sampleOfflineConfiguration.ts
index 4a7a20594..b8f94039e 100644
--- a/example-new-architecture/flags/sampleOfflineConfiguration.ts
+++ b/example-new-architecture/flags/sampleOfflineConfiguration.ts
@@ -1,48 +1,71 @@
// The flag key shared with the online example, so the UI is comparable across providers.
export const OFFLINE_FLAG_KEY = 'rn-sdk-test-boolean-flag';
-export type OfflineWireContext = {targetingKey?: string} & Record<
- string,
- string | number | boolean
->;
-
-// The evaluation context the bundled configuration is precomputed for. Because the wire
-// carries its own context, the app does not need to call `OpenFeature.setContext` for the
-// offline flow.
-export const DEFAULT_OFFLINE_CONTEXT: OfflineWireContext = {
- targetingKey: 'example-offline-user',
+export const DYNAMIC_OFFLINE_CONTEXTS = {
+ included: {
+ targetingKey: 'example-offline-user-a',
+ country: 'US',
+ },
+ excluded: {
+ targetingKey: 'example-offline-user-b',
+ country: 'CA',
+ },
};
/**
- * Build a bundled `ConfigurationWire` v1 string for the offline example.
+ * Build a bundled rules `ConfigurationWire` string.
*
- * Mirrors the shape the Datadog Flags CDN returns, but is bundled with the app so the demo
- * is fully offline — it never hits the network. Flip `variationValue` to `false` to confirm
- * the flag's fallback renders.
+ * The example is fully offline. It evaluates the same rules for each new
+ * OpenFeature context. It does not fetch assignments.
*/
-export const buildSampleWire = (
- context: OfflineWireContext = DEFAULT_OFFLINE_CONTEXT,
- variationValue = true,
-): string =>
+export const buildSampleWire = (): string =>
JSON.stringify({
version: 1,
- precomputed: {
- context,
+ rulesBased: {
+ // TODO(FFL-2837): Replace this legacy rulesBased JSON fixture with
+ // a protobuf rules wire after flagging-core publishes PR #344.
response: JSON.stringify({
- data: {
- attributes: {
- obfuscated: false,
- flags: {
- [OFFLINE_FLAG_KEY]: {
- variationType: 'boolean',
- variationValue,
- variationKey: String(variationValue),
- allocationKey: 'offline-example-alloc',
- reason: 'STATIC',
+ createdAt: '2026-07-23T12:00:00.000Z',
+ format: 'SERVER',
+ environment: {name: 'example'},
+ flags: {
+ [OFFLINE_FLAG_KEY]: {
+ key: OFFLINE_FLAG_KEY,
+ enabled: true,
+ variationType: 'BOOLEAN',
+ variations: {
+ enabled: {key: 'enabled', value: true},
+ },
+ allocations: [
+ {
+ key: 'offline-example-alloc',
+ rules: [
+ {
+ conditions: [
+ {
+ operator: 'ONE_OF',
+ attribute: 'country',
+ value: ['US'],
+ },
+ ],
+ },
+ ],
+ splits: [
+ {
+ variationKey: 'enabled',
+ serialId: 1,
+ shards: [
+ {
+ salt: 'offline-example-salt',
+ ranges: [{start: 0, end: 100}],
+ totalShards: 100,
+ },
+ ],
+ },
+ ],
doLog: true,
- extraLogging: {},
},
- },
+ ],
},
},
}),
diff --git a/example/src/components/FlagsSourceToggle.tsx b/example/src/components/FlagsSourceToggle.tsx
index 40f796b77..09cb64474 100644
--- a/example/src/components/FlagsSourceToggle.tsx
+++ b/example/src/components/FlagsSourceToggle.tsx
@@ -1,7 +1,16 @@
import React, { useState } from 'react';
-import { View, Text, Switch, ActivityIndicator, StyleSheet } from 'react-native';
+import {
+ View,
+ Text,
+ Switch,
+ ActivityIndicator,
+ StyleSheet
+} from 'react-native';
-import { setFlagsProvider } from '../flags/flagsProvider';
+import {
+ setFlagsProvider,
+ setOfflineExampleContext
+} from '../flags/flagsProvider';
import type { FlagsSource } from '../flags/flagsProvider';
/**
@@ -14,6 +23,7 @@ export const FlagsSourceToggle = ({
initialSource?: FlagsSource;
}) => {
const [offline, setOffline] = useState(initialSource === 'offline');
+ const [included, setIncluded] = useState(true);
const [busy, setBusy] = useState(false);
const onToggle = async (nextOffline: boolean) => {
@@ -21,6 +31,19 @@ export const FlagsSourceToggle = ({
try {
await setFlagsProvider(nextOffline ? 'offline' : 'online');
setOffline(nextOffline);
+ if (nextOffline) {
+ setIncluded(true);
+ }
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const onAudienceToggle = async (nextIncluded: boolean) => {
+ setBusy(true);
+ try {
+ await setOfflineExampleContext(nextIncluded);
+ setIncluded(nextIncluded);
} finally {
setBusy(false);
}
@@ -37,6 +60,19 @@ export const FlagsSourceToggle = ({
onValueChange={onToggle}
disabled={busy}
/>
+ {offline ? (
+ <>
+
+ Rules match: {included ? 'yes' : 'no'}
+
+
+ >
+ ) : null}
{busy ? : null}
);
@@ -53,5 +89,9 @@ const styles = StyleSheet.create({
},
spinner: {
marginLeft: 10
+ },
+ audienceLabel: {
+ marginLeft: 16,
+ marginRight: 10
}
});
diff --git a/example/src/flags/flagsProvider.ts b/example/src/flags/flagsProvider.ts
index 70be0801d..0665e2231 100644
--- a/example/src/flags/flagsProvider.ts
+++ b/example/src/flags/flagsProvider.ts
@@ -5,8 +5,10 @@ import {
} from '@datadog/mobile-react-native-openfeature';
import { OpenFeature } from '@openfeature/react-sdk';
-import { buildSampleWire } from './sampleOfflineConfiguration';
-import type { OfflineWireContext } from './sampleOfflineConfiguration';
+import {
+ buildSampleWire,
+ DYNAMIC_OFFLINE_CONTEXTS
+} from './sampleOfflineConfiguration';
export type FlagsSource = 'online' | 'offline';
@@ -23,18 +25,14 @@ export type FlagsSource = 'online' | 'offline';
*
* `DdFlags.enable()` must have been called once before this (it enables the native feature).
*/
-export const setFlagsProvider = async (
- source: FlagsSource,
- offlineContext?: OfflineWireContext
-): Promise => {
+export const setFlagsProvider = async (source: FlagsSource): Promise => {
if (source === 'offline') {
const provider = new DatadogOfflineOpenFeatureProvider({
clientName: 'offline'
});
- provider.setConfiguration(
- configurationFromString(buildSampleWire(offlineContext))
- );
+ provider.setConfiguration(configurationFromString(buildSampleWire()));
await OpenFeature.setProviderAndWait(provider);
+ await setOfflineExampleContext(true);
return;
}
@@ -42,3 +40,21 @@ export const setFlagsProvider = async (
new DatadogOpenFeatureProvider({ clientName: 'online' })
);
};
+
+/**
+ * Change the dynamic offline subject without a network request.
+ *
+ * Try both calls:
+ *
+ * `await setOfflineExampleContext(true);`
+ * `await setOfflineExampleContext(false);`
+ */
+export const setOfflineExampleContext = async (
+ included: boolean
+): Promise => {
+ await OpenFeature.setContext(
+ included
+ ? DYNAMIC_OFFLINE_CONTEXTS.included
+ : DYNAMIC_OFFLINE_CONTEXTS.excluded
+ );
+};
diff --git a/example/src/flags/sampleOfflineConfiguration.ts b/example/src/flags/sampleOfflineConfiguration.ts
index e62b2b7ee..72148f443 100644
--- a/example/src/flags/sampleOfflineConfiguration.ts
+++ b/example/src/flags/sampleOfflineConfiguration.ts
@@ -1,48 +1,73 @@
// The flag key shared with the online example, so the UI is comparable across providers.
export const OFFLINE_FLAG_KEY = 'rn-sdk-test-boolean-flag';
-export type OfflineWireContext = { targetingKey?: string } & Record<
- string,
- string | number | boolean
->;
-
-// The evaluation context the bundled configuration is precomputed for. Because the wire
-// carries its own context, the app does not need to call `OpenFeature.setContext` for the
-// offline flow.
-export const DEFAULT_OFFLINE_CONTEXT: OfflineWireContext = {
- targetingKey: 'example-offline-user'
+export const DYNAMIC_OFFLINE_CONTEXTS = {
+ included: {
+ targetingKey: 'example-offline-user-a',
+ country: 'US'
+ },
+ excluded: {
+ targetingKey: 'example-offline-user-b',
+ country: 'CA'
+ }
};
/**
- * Build a bundled `ConfigurationWire` v1 string for the offline example.
+ * Build a bundled rules `ConfigurationWire` string.
*
- * Mirrors the shape the Datadog Flags CDN returns, but is bundled with the app so the demo
- * is fully offline — it never hits the network. Flip `variationValue` to `false` to confirm
- * the flag's fallback renders.
+ * The example is fully offline. It evaluates the same rules for each new
+ * OpenFeature context. It does not fetch assignments.
*/
-export const buildSampleWire = (
- context: OfflineWireContext = DEFAULT_OFFLINE_CONTEXT,
- variationValue = true
-): string =>
+export const buildSampleWire = (): string =>
JSON.stringify({
version: 1,
- precomputed: {
- context,
+ rulesBased: {
+ // TODO(FFL-2837): Replace this legacy rulesBased JSON fixture with
+ // a protobuf rules wire after flagging-core publishes PR #344.
response: JSON.stringify({
- data: {
- attributes: {
- obfuscated: false,
- flags: {
- [OFFLINE_FLAG_KEY]: {
- variationType: 'boolean',
- variationValue,
- variationKey: String(variationValue),
- allocationKey: 'offline-example-alloc',
- reason: 'STATIC',
- doLog: true,
- extraLogging: {}
+ createdAt: '2026-07-23T12:00:00.000Z',
+ format: 'SERVER',
+ environment: { name: 'example' },
+ flags: {
+ [OFFLINE_FLAG_KEY]: {
+ key: OFFLINE_FLAG_KEY,
+ enabled: true,
+ variationType: 'BOOLEAN',
+ variations: {
+ enabled: { key: 'enabled', value: true }
+ },
+ allocations: [
+ {
+ key: 'offline-example-alloc',
+ rules: [
+ {
+ conditions: [
+ {
+ operator: 'ONE_OF',
+ attribute: 'country',
+ value: ['US']
+ }
+ ]
+ }
+ ],
+ splits: [
+ {
+ variationKey: 'enabled',
+ serialId: 1,
+ shards: [
+ {
+ salt: 'offline-example-salt',
+ ranges: [
+ { start: 0, end: 100 }
+ ],
+ totalShards: 100
+ }
+ ]
+ }
+ ],
+ doLog: true
}
- }
+ ]
}
}
})
diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md
index 5fd9cca9a..155c3f364 100644
--- a/packages/react-native-openfeature/README.md
+++ b/packages/react-native-openfeature/README.md
@@ -29,15 +29,18 @@ yarn add @datadog/mobile-react-native @datadog/mobile-react-native-openfeature @
Use the following example code snippet to initialize the Datadog SDK, enable the Feature Flags feature, and set up the OpenFeature provider.
```tsx
-import { CoreConfiguration, DatadogProvider, DdFlags } from '@datadog/mobile-react-native';
+import {
+ CoreConfiguration,
+ DatadogProvider,
+ DdFlags
+} from '@datadog/mobile-react-native';
import { DatadogOpenFeatureProvider } from '@datadog/mobile-react-native-openfeature';
import { OpenFeature } from '@openfeature/react-sdk';
(async () => {
// Follow the core Datadog SDK initialization guide.
- const config = new CoreConfiguration(
- // ...
- );
+ const config = new CoreConfiguration();
+ // ...
await DdSdkReactNative.initialize(config);
// Enable Datadog Flags feature after the core SDK has been initialized.
@@ -60,7 +63,7 @@ import { OpenFeature } from '@openfeature/react-sdk';
}}
>
{/* ... */}
-
+;
```
After completing this setup, your app is ready for flag evaluation with OpenFeature.
@@ -114,11 +117,9 @@ export default AppWithProviders;
### Offline initialization
-If you fetch a flag configuration yourself (for example a precomputed-assignments payload
-cached on disk, delivered via your own service, or bundled with the app), use
-`DatadogOfflineOpenFeatureProvider` instead of `DatadogOpenFeatureProvider`. It evaluates flags
-and reports exposures exactly like the online provider, but **never fetches configuration from
-the network** — you supply it with `setConfiguration`.
+Use `DatadogOfflineOpenFeatureProvider` when your application supplies the flag configuration.
+The provider does not fetch a configuration.
+It evaluates flags and reports evaluations through the normal Datadog path.
```tsx
import { DdFlags } from '@datadog/mobile-react-native';
@@ -131,63 +132,86 @@ import { OpenFeature } from '@openfeature/react-sdk';
await DdFlags.enable();
const provider = new DatadogOfflineOpenFeatureProvider();
-
-// `wire` is a ConfigurationWire string you fetched yourself.
provider.setConfiguration(configurationFromString(wire));
-
-// Set the provider after loading the configuration so it is ready with real flag values.
await OpenFeature.setProviderAndWait(provider);
+```
+
+Keep the original wire when it contains rules.
+Do not use `configurationToString` to recreate a rules wire.
+The parsed rules object does not contain the original protobuf payload.
-// Evaluate flags — no network request is made.
+Load the configuration before you set the provider.
+The provider starts in `ERROR` when it has no usable configuration.
+A later valid configuration can recover the provider.
+
+Do not call the non-waiting `OpenFeature.setProvider` and then call `setConfiguration`.
+The pending initialization can finish after the configuration load.
+Use the order in the example.
+
+#### Rules-based offline configuration
+
+A rules configuration can evaluate more than one context.
+Call `OpenFeature.setContext` when the subject changes.
+The provider evaluates the new context locally.
+It does not make a native configuration request.
+
+```tsx
const client = OpenFeature.getClient();
-const isNewFeatureEnabled = client.getBooleanValue('new-feature-enabled', false);
+
+await OpenFeature.setContext({
+ targetingKey: 'user-a',
+ country: 'US'
+});
+const valueForUserA = client.getBooleanValue('new-feature', false);
+
+await OpenFeature.setContext({
+ targetingKey: 'user-b',
+ country: 'CA'
+});
+const valueForUserB = client.getBooleanValue('new-feature', 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.
-
-> **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.
-
-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`.
-- **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`.
-
-> **Note (startup order):** Load the configuration with `setConfiguration` _before_
-> `setProviderAndWait`, as shown above. If you register the provider before any successful
-> `setConfiguration`, it initializes to the `ERROR` state (there is nothing it can evaluate); loading a
-> valid configuration afterwards recovers it to `READY`. **Do not use the non-awaiting
-> `OpenFeature.setProvider(provider)` immediately followed by `setConfiguration`** — that ordering
-> races (the pending initialization can **settle (reject)** after the recovery and overwrite the
-> status back to `ERROR`). Configure first, or `await OpenFeature.setProviderAndWait(...)`.
-
-This provider relies on the OpenFeature static-context lifecycle — the SDK owns the
-`PROVIDER_RECONCILING`/`PROVIDER_CONTEXT_CHANGED` events on a context change — and requires
-`@openfeature/web-sdk` `^1.8.0` (the version it is developed and verified against).
+#### Precomputed offline configuration
+
+A precomputed configuration is one snapshot for one context.
+The provider adopts the embedded context when no external context exists.
+
+Do not set a different context for a precomputed-only configuration.
+The provider cannot fetch a new snapshot.
+It enters `ERROR` and returns coded defaults with `INVALID_CONTEXT`.
+
+An empty string is a real targeting key.
+It is not the same as an absent context.
+Use `clearContext` to remove a domain context.
+Remember that a cleared domain can inherit a non-empty global context.
+
+#### Configuration with both branches
+
+A configuration can contain precomputed data and rules data.
+The provider uses this order for each resolution:
+
+1. Use precomputed data when its context matches.
+2. Otherwise, use valid rules data.
+3. Otherwise, return the applicable configuration error.
+
+#### Domains and client names
+
+Use a dedicated OpenFeature domain for an offline provider.
+Use a unique Datadog `clientName` for each online or offline provider.
+Providers with the same client name share one `FlagsClient`.
+An online request on that client removes the offline configuration.
+
+An OpenFeature domain with no context can inherit the global context.
+Set an explicit domain context when the domain must not inherit global context changes.
+
+#### Rules configuration security
+
+Treat a client rules configuration as public data.
+Do not put secrets in flag names, variant values, attributes, regular expressions, salts, or metadata.
+Salted hashes do not make low-entropy values confidential.
+An attacker can test likely values offline.
+
+This provider requires `@openfeature/web-sdk` `^1.8.0`.
[1]: https://openfeature.dev/docs/reference/sdks/client/web/react/
[2]: https://docs.datadoghq.com/getting_started/feature_flags/
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..76bb1277f 100644
--- a/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts
+++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts
@@ -53,6 +53,62 @@ const wireFor = (targetingKey: string): string =>
}
});
+const rulesResponseFor = (flagKey: string) => ({
+ createdAt: '2026-07-23T12:00:00.000Z',
+ format: 'SERVER',
+ environment: { name: 'test' },
+ flags: {
+ [flagKey]: {
+ key: flagKey,
+ enabled: true,
+ variationType: 'BOOLEAN',
+ variations: {
+ enabled: { key: 'enabled', value: true }
+ },
+ allocations: [
+ {
+ key: 'rules-allocation',
+ rules: [
+ {
+ conditions: [
+ {
+ operator: 'ONE_OF',
+ attribute: 'country',
+ value: ['US']
+ }
+ ]
+ }
+ ],
+ splits: [
+ {
+ variationKey: 'enabled',
+ serialId: 7,
+ shards: [
+ {
+ salt: 'test-salt',
+ ranges: [{ start: 0, end: 100 }],
+ totalShards: 100
+ }
+ ]
+ }
+ ],
+ doLog: false
+ }
+ ]
+ }
+ }
+});
+
+// TODO(FFL-2837): Replace this legacy rulesBased JSON wire with a canonical
+// protobuf rules wire after a flagging-core release contains upstream PR #344.
+const rulesWireFor = (flagKey: string): string =>
+ JSON.stringify({
+ version: 1,
+ rulesBased: {
+ response: JSON.stringify(rulesResponseFor(flagKey))
+ }
+ });
+
// A unique OpenFeature domain + Datadog clientName per test keeps providers isolated (separate
// domains otherwise share the same underlying FlagsClient).
let seq = 0;
@@ -66,6 +122,7 @@ describe('DatadogOfflineOpenFeatureProvider (integration, real FlagsClient + Ope
await OpenFeature.clearProviders();
// Reset the global context so a context set by one test does not leak into the next.
await OpenFeature.clearContext();
+ jest.clearAllMocks();
});
it('is READY when a matching configuration is loaded before registration', async () => {
@@ -80,6 +137,53 @@ describe('DatadogOfflineOpenFeatureProvider (integration, real FlagsClient + Ope
expect(client.getBooleanValue('new-feature', false)).toBe(true);
});
+ it('evaluates rules for each new context without a fetch', async () => {
+ const { domain, clientName } = freshNames();
+ const provider = new DatadogOfflineOpenFeatureProvider({ clientName });
+ provider.setConfiguration(
+ configurationFromString(rulesWireFor('dynamic-feature'))
+ );
+ await OpenFeature.setProviderAndWait(domain, provider);
+
+ const client = OpenFeature.getClient(domain);
+ await OpenFeature.setContext(domain, {
+ targetingKey: 'user-1',
+ country: 'US'
+ });
+ expect(client.providerStatus).toBe(ProviderStatus.READY);
+ expect(client.getBooleanValue('dynamic-feature', false)).toBe(true);
+
+ await OpenFeature.setContext(domain, {
+ targetingKey: 'user-2',
+ country: 'CA'
+ });
+ expect(client.providerStatus).toBe(ProviderStatus.READY);
+ expect(client.getBooleanValue('dynamic-feature', false)).toBe(false);
+
+ const nativeFlags = jest.requireMock(
+ '../../../core/src/specs/NativeDdFlags'
+ ).default;
+ expect(nativeFlags.setEvaluationContext).not.toHaveBeenCalled();
+ });
+
+ it('does not synthesize an empty targeting key when a shard requires one', async () => {
+ const { domain, clientName } = freshNames();
+ const provider = new DatadogOfflineOpenFeatureProvider({ clientName });
+ provider.setConfiguration(
+ configurationFromString(rulesWireFor('dynamic-feature'))
+ );
+ await OpenFeature.setProviderAndWait(domain, provider);
+
+ await OpenFeature.setContext(domain, { country: 'US' });
+
+ const details = OpenFeature.getClient(domain).getBooleanDetails(
+ 'dynamic-feature',
+ false
+ );
+ expect(details.value).toBe(false);
+ expect(details.errorCode).toBe(ErrorCode.TARGETING_KEY_MISSING);
+ });
+
it('enters ERROR and serves defaults on a mismatching setContext, then recovers on a matching one', async () => {
const { domain, clientName } = freshNames();
const provider = new DatadogOfflineOpenFeatureProvider({ clientName });
diff --git a/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts b/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts
index 7a60b331e..899228ccc 100644
--- a/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts
+++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts
@@ -24,6 +24,12 @@ const mockFlagsClient = {
setEvaluationContextWithoutFetching: jest.fn(() => READY),
resetEvaluationContextWithoutFetching: jest.fn(() => READY),
setEvaluationContext: jest.fn(() => Promise.resolve()),
+ getDetailsForContext: jest.fn(() => ({
+ key: 'flag',
+ value: true,
+ reason: 'TARGETING_MATCH',
+ variant: 'true'
+ })),
getBooleanDetails: jest.fn(() => ({
key: 'flag',
value: true,
@@ -86,6 +92,18 @@ describe('DatadogOfflineOpenFeatureProvider', () => {
expect(mockFlagsClient.setEvaluationContext).not.toHaveBeenCalled();
});
+ it('does not replace a missing targeting key in an attributes-only context', () => {
+ const provider = new DatadogOfflineOpenFeatureProvider();
+
+ provider.onContextChange({}, { country: 'US' });
+
+ const context =
+ mockFlagsClient.setEvaluationContextWithoutFetching.mock
+ .calls[0][0];
+ expect(context).toEqual({ attributes: { country: 'US' } });
+ expect(context).not.toHaveProperty('targetingKey');
+ });
+
it('rejects initialize when the initial context does not match', async () => {
const provider = new DatadogOfflineOpenFeatureProvider();
mockFlagsClient.setEvaluationContextWithoutFetching.mockReturnValueOnce(
@@ -238,15 +256,83 @@ describe('DatadogOfflineOpenFeatureProvider', () => {
const result = provider.resolveBooleanEvaluation(
'flag',
false,
- {},
+ { targetingKey: 'user-1', country: 'US' },
// eslint-disable-next-line no-console
console as never
);
expect(result.value).toBe(true);
- expect(mockFlagsClient.getBooleanDetails).toHaveBeenCalledWith(
+ expect(mockFlagsClient.getDetailsForContext).toHaveBeenCalledWith(
+ 'flag',
+ false,
+ 'boolean',
+ {
+ targetingKey: 'user-1',
+ attributes: { country: 'US' }
+ },
+ console
+ );
+ expect(mockFlagsClient.getBooleanDetails).not.toHaveBeenCalled();
+ });
+
+ it('preserves a missing targeting key for per-resolution evaluation', () => {
+ const provider = new DatadogOfflineOpenFeatureProvider();
+
+ provider.resolveBooleanEvaluation(
'flag',
- false
+ false,
+ { country: 'US' },
+ // eslint-disable-next-line no-console
+ console as never
+ );
+
+ const context = mockFlagsClient.getDetailsForContext.mock.calls[0][3];
+ expect(context).toEqual({ attributes: { country: 'US' } });
+ expect(context).not.toHaveProperty('targetingKey');
+ });
+
+ it('passes the effective context and logger through every resolver', () => {
+ const provider = new DatadogOfflineOpenFeatureProvider();
+ const context = { targetingKey: 'user-1', country: 'US' };
+ // eslint-disable-next-line no-console
+ const logger = console as never;
+
+ provider.resolveStringEvaluation(
+ 'string-flag',
+ 'default',
+ context,
+ logger
+ );
+ provider.resolveNumberEvaluation('number-flag', 0, context, logger);
+ provider.resolveObjectEvaluation('object-flag', {}, context, logger);
+
+ const ddContext = {
+ targetingKey: 'user-1',
+ attributes: { country: 'US' }
+ };
+ expect(mockFlagsClient.getDetailsForContext).toHaveBeenNthCalledWith(
+ 1,
+ 'string-flag',
+ 'default',
+ 'string',
+ ddContext,
+ logger
+ );
+ expect(mockFlagsClient.getDetailsForContext).toHaveBeenNthCalledWith(
+ 2,
+ 'number-flag',
+ 0,
+ 'number',
+ ddContext,
+ logger
+ );
+ expect(mockFlagsClient.getDetailsForContext).toHaveBeenNthCalledWith(
+ 3,
+ 'object-flag',
+ {},
+ 'object',
+ ddContext,
+ logger
);
});
});
diff --git a/packages/react-native-openfeature/src/coreProvider.ts b/packages/react-native-openfeature/src/coreProvider.ts
index e17f3aa80..579a115c4 100644
--- a/packages/react-native-openfeature/src/coreProvider.ts
+++ b/packages/react-native-openfeature/src/coreProvider.ts
@@ -19,6 +19,8 @@ import type {
ProviderEvents
} from '@openfeature/web-sdk';
+import { isEmptyContext, toDdContextPreservingTargetingKey } from './mappers';
+
export interface DatadogOpenFeatureProviderOptions {
/**
* The name of the Datadog Flags client to use.
@@ -45,6 +47,7 @@ export abstract class DatadogCoreOpenFeatureProvider implements Provider {
private options: DatadogOpenFeatureProviderOptions;
protected flagsClient: FlagsClient;
+ protected readonly useResolutionContext: boolean = false;
readonly events: ProviderEventEmitter = new OpenFeatureEventEmitter();
@@ -64,10 +67,16 @@ export abstract class DatadogCoreOpenFeatureProvider implements Provider {
_context: OFEvaluationContext,
_logger: Logger
): ResolutionDetails {
- const details = this.flagsClient.getBooleanDetails(
- flagKey,
- defaultValue
- );
+ const details =
+ this.useResolutionContext && !isEmptyContext(_context)
+ ? this.flagsClient.getDetailsForContext(
+ flagKey,
+ defaultValue,
+ 'boolean',
+ toDdContextPreservingTargetingKey(_context),
+ _logger
+ )
+ : this.flagsClient.getBooleanDetails(flagKey, defaultValue);
return toFlagResolution(details);
}
@@ -77,10 +86,16 @@ export abstract class DatadogCoreOpenFeatureProvider implements Provider {
_context: OFEvaluationContext,
_logger: Logger
): ResolutionDetails {
- const details = this.flagsClient.getStringDetails(
- flagKey,
- defaultValue
- );
+ const details =
+ this.useResolutionContext && !isEmptyContext(_context)
+ ? this.flagsClient.getDetailsForContext(
+ flagKey,
+ defaultValue,
+ 'string',
+ toDdContextPreservingTargetingKey(_context),
+ _logger
+ )
+ : this.flagsClient.getStringDetails(flagKey, defaultValue);
return toFlagResolution(details);
}
@@ -90,10 +105,16 @@ export abstract class DatadogCoreOpenFeatureProvider implements Provider {
_context: OFEvaluationContext,
_logger: Logger
): ResolutionDetails {
- const details = this.flagsClient.getNumberDetails(
- flagKey,
- defaultValue
- );
+ const details =
+ this.useResolutionContext && !isEmptyContext(_context)
+ ? this.flagsClient.getDetailsForContext(
+ flagKey,
+ defaultValue,
+ 'number',
+ toDdContextPreservingTargetingKey(_context),
+ _logger
+ )
+ : this.flagsClient.getNumberDetails(flagKey, defaultValue);
return toFlagResolution(details);
}
@@ -108,10 +129,16 @@ export abstract class DatadogCoreOpenFeatureProvider implements Provider {
// Thus, the user should always expect the returned value to be an object instead of any arbitrary JSON value.
// Also, the user is responsible for providing a proper `defaultValue` that's an object.
- const details = this.flagsClient.getObjectDetails(
- flagKey,
- defaultValue
- );
+ const details =
+ this.useResolutionContext && !isEmptyContext(_context)
+ ? this.flagsClient.getDetailsForContext(
+ flagKey,
+ defaultValue,
+ 'object',
+ toDdContextPreservingTargetingKey(_context),
+ _logger
+ )
+ : this.flagsClient.getObjectDetails(flagKey, defaultValue);
return toFlagResolution(details);
}
}
diff --git a/packages/react-native-openfeature/src/mappers.ts b/packages/react-native-openfeature/src/mappers.ts
index ecc7d03b8..5fb866545 100644
--- a/packages/react-native-openfeature/src/mappers.ts
+++ b/packages/react-native-openfeature/src/mappers.ts
@@ -30,6 +30,25 @@ export const toDdContext = (
};
};
+/**
+ * Convert an OpenFeature context for offline evaluation without inventing a
+ * targeting key. Rules distinguish a missing key from an empty key.
+ */
+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;
+};
+
/**
* 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).
diff --git a/packages/react-native-openfeature/src/offlineProvider.ts b/packages/react-native-openfeature/src/offlineProvider.ts
index aa4f868a7..833ebeaa3 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 { isEmptyContext, 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,17 +49,17 @@ 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:
+ * A rules configuration evaluates each new context locally. Call `OpenFeature.setContext` to
+ * change the subject. The provider does not fetch after this call.
+ *
+ * A precomputed configuration is a single-context snapshot. A different runtime context cannot
+ * use that snapshot. If no rules fallback exists, the provider enters the OpenFeature `ERROR`
+ * state and evaluations return coded defaults with `INVALID_CONTEXT`.
+ *
+ * A configuration can contain both branches. Matching precomputed data has priority. Rules data
+ * is the fallback for a different context. Load the configuration before you set the provider:
*
* @example
* ```ts
@@ -82,6 +82,8 @@ export class DatadogOfflineOpenFeatureProvider extends DatadogCoreOpenFeaturePro
name: 'datadog-react-native-offline'
};
+ protected readonly useResolutionContext = true;
+
// Whether the provider is currently in an error state, so a successful `setConfiguration` must
// emit `PROVIDER_READY` to recover (a bare `CONFIGURATION_CHANGED` would not clear `ERROR`).
// It may be set before the provider is registered, so it does not necessarily mirror
@@ -152,7 +154,7 @@ export class DatadogOfflineOpenFeatureProvider extends DatadogCoreOpenFeaturePro
const result = isEmptyContext(context)
? this.flagsClient.resetEvaluationContextWithoutFetching()
: this.flagsClient.setEvaluationContextWithoutFetching(
- toDdContext(context)
+ toDdContextPreservingTargetingKey(context)
);
this.configurationInError = result.status === 'error';