From 40f0f44c279ae46ea4ef021d2cd55d4f4c6bd68e Mon Sep 17 00:00:00 2001 From: Blake Date: Thu, 23 Jul 2026 20:55:19 -0400 Subject: [PATCH 01/14] feat(openfeature): expose dynamic offline rules --- .../flags/FlagsSourceToggle.tsx | 41 ++++-- .../flags/flagsProvider.ts | 24 +++- .../flags/sampleOfflineConfiguration.ts | 90 ++++++++----- example/src/components/FlagsSourceToggle.tsx | 44 ++++++- example/src/flags/flagsProvider.ts | 30 ++++- .../src/flags/sampleOfflineConfiguration.ts | 94 ++++++++----- packages/react-native-openfeature/README.md | 124 +++++++++++++----- .../offlineProvider.integration.test.ts | 85 ++++++++++++ .../src/__tests__/offlineProvider.test.ts | 19 ++- .../src/coreProvider.ts | 59 ++++++--- .../src/offlineProvider.ts | 20 ++- 11 files changed, 487 insertions(+), 143 deletions(-) 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 fbe53c58c..c07d19e7b 100644 --- a/example-new-architecture/flags/flagsProvider.ts +++ b/example-new-architecture/flags/flagsProvider.ts @@ -6,7 +6,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'; @@ -37,6 +40,7 @@ export const setFlagsProvider = async (source: FlagsSource): Promise => { }); provider.setConfiguration(configuration); await OpenFeature.setProviderAndWait(provider); + await setOfflineExampleContext(true); return; } @@ -44,3 +48,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..d632c631d 100644 --- a/example-new-architecture/flags/sampleOfflineConfiguration.ts +++ b/example-new-architecture/flags/sampleOfflineConfiguration.ts @@ -1,48 +1,74 @@ // 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 JSON rules fixture with the published + // portable wire fixture when flagging-core publishes the final format. 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, + extraLogging: { + source: 'dynamic-offline-example', + }, + 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 7228345b4..0906601c2 100644 --- a/example/src/flags/flagsProvider.ts +++ b/example/src/flags/flagsProvider.ts @@ -6,8 +6,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'; @@ -24,10 +26,7 @@ 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 configuration = configurationFromString( buildSampleWire(offlineContext) @@ -43,6 +42,7 @@ export const setFlagsProvider = async ( }); provider.setConfiguration(configuration); await OpenFeature.setProviderAndWait(provider); + await setOfflineExampleContext(true); return; } @@ -50,3 +50,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..e148ef495 100644 --- a/example/src/flags/sampleOfflineConfiguration.ts +++ b/example/src/flags/sampleOfflineConfiguration.ts @@ -1,48 +1,76 @@ // 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 JSON rules fixture with the published + // portable wire fixture when flagging-core publishes the final format. 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, + extraLogging: { + source: 'dynamic-offline-example' + }, + 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 285cae248..bc5cf1085 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'; @@ -154,6 +155,14 @@ const isNewFeatureEnabled = client.getBooleanValue( ); ``` +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. + 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 @@ -170,31 +179,76 @@ different from a missing targeting key. A context-agnostic precomputed configura 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.** 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. - -`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 -> `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). +#### 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(domain); + +await OpenFeature.setContext(domain, { + targetingKey: 'user-a', + country: 'US' +}); +const valueForUserA = client.getBooleanValue('new-feature', false); + +await OpenFeature.setContext(domain, { + targetingKey: 'user-b', + country: 'CA' +}); +const valueForUserB = client.getBooleanValue('new-feature', false); +``` + +#### Precomputed offline configuration + +A precomputed configuration is one snapshot for one context. +The effective OpenFeature context must match the embedded context. +Use `getPrecomputedContext` to obtain a supported copy and set it explicitly. + +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. +Set the helper context on that domain before provider registration. +An OpenFeature domain with no context of its own inherits the global context. + +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. + +Set an explicit domain context when the domain must not inherit global context changes. +`OpenFeature.clearContext(domain)` removes the domain context and restores global inheritance. +If the inherited global context cannot use the configuration, the provider enters `ERROR`. +A global `clearContext()` supplies `{}`; it does not restore the embedded context. + +#### 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 08ff5b726..bbe70820e 100644 --- a/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts @@ -55,6 +55,61 @@ const wireFor = (context?: EvaluationContext): 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, + extraLogging: { source: 'dynamic-offline' }, + shards: [ + { + salt: 'test-salt', + ranges: [{ start: 0, end: 100 }], + totalShards: 100 + } + ] + } + ], + doLog: false + } + ] + } + } +}); + +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; @@ -82,6 +137,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('uses the helper context to start READY with a precomputed configuration', async () => { @@ -111,6 +167,35 @@ describe('DatadogOfflineOpenFeatureProvider (integration, real FlagsClient + Ope ).not.toHaveBeenCalled(); }); + 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('starts in ERROR when a context-specific configuration has no OpenFeature context', 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 78c5f096d..b9c0c7a27 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, @@ -244,15 +250,22 @@ 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 + false, + 'boolean', + { + targetingKey: 'user-1', + attributes: { country: 'US' } + }, + console ); + expect(mockFlagsClient.getBooleanDetails).not.toHaveBeenCalled(); }); }); diff --git a/packages/react-native-openfeature/src/coreProvider.ts b/packages/react-native-openfeature/src/coreProvider.ts index e17f3aa80..1394a8a96 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, toDdContext } 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', + toDdContext(_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', + toDdContext(_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', + toDdContext(_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', + toDdContext(_context), + _logger + ) + : this.flagsClient.getObjectDetails(flagKey, defaultValue); return toFlagResolution(details); } } diff --git a/packages/react-native-openfeature/src/offlineProvider.ts b/packages/react-native-openfeature/src/offlineProvider.ts index f0aec033b..d3abe1867 100644 --- a/packages/react-native-openfeature/src/offlineProvider.ts +++ b/packages/react-native-openfeature/src/offlineProvider.ts @@ -51,13 +51,17 @@ const OF_ERROR_CODE: Record = { * Instead of fetching on `initialize`/`onContextChange`, it evaluates against a configuration * 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 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: + * 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`. 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. + * + * 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 @@ -87,6 +91,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 From 588550431943fee13a85a3093b3b935ea778ebb1 Mon Sep 17 00:00:00 2001 From: Blake Date: Mon, 27 Jul 2026 16:28:43 -0400 Subject: [PATCH 02/14] fix(openfeature): preserve missing targeting keys --- .../flags/sampleOfflineConfiguration.ts | 7 +- .../src/flags/sampleOfflineConfiguration.ts | 7 +- packages/react-native-openfeature/README.md | 4 + .../offlineProvider.integration.test.ts | 21 ++++- .../src/__tests__/offlineProvider.test.ts | 90 +++++++++++++++++++ .../src/coreProvider.ts | 78 ++++++++-------- 6 files changed, 155 insertions(+), 52 deletions(-) diff --git a/example-new-architecture/flags/sampleOfflineConfiguration.ts b/example-new-architecture/flags/sampleOfflineConfiguration.ts index d632c631d..b8f94039e 100644 --- a/example-new-architecture/flags/sampleOfflineConfiguration.ts +++ b/example-new-architecture/flags/sampleOfflineConfiguration.ts @@ -22,8 +22,8 @@ export const buildSampleWire = (): string => JSON.stringify({ version: 1, rulesBased: { - // TODO(FFL-2837): Replace this JSON rules fixture with the published - // portable wire fixture when flagging-core publishes the final format. + // TODO(FFL-2837): Replace this legacy rulesBased JSON fixture with + // a protobuf rules wire after flagging-core publishes PR #344. response: JSON.stringify({ createdAt: '2026-07-23T12:00:00.000Z', format: 'SERVER', @@ -54,9 +54,6 @@ export const buildSampleWire = (): string => { variationKey: 'enabled', serialId: 1, - extraLogging: { - source: 'dynamic-offline-example', - }, shards: [ { salt: 'offline-example-salt', diff --git a/example/src/flags/sampleOfflineConfiguration.ts b/example/src/flags/sampleOfflineConfiguration.ts index e148ef495..72148f443 100644 --- a/example/src/flags/sampleOfflineConfiguration.ts +++ b/example/src/flags/sampleOfflineConfiguration.ts @@ -22,8 +22,8 @@ export const buildSampleWire = (): string => JSON.stringify({ version: 1, rulesBased: { - // TODO(FFL-2837): Replace this JSON rules fixture with the published - // portable wire fixture when flagging-core publishes the final format. + // TODO(FFL-2837): Replace this legacy rulesBased JSON fixture with + // a protobuf rules wire after flagging-core publishes PR #344. response: JSON.stringify({ createdAt: '2026-07-23T12:00:00.000Z', format: 'SERVER', @@ -54,9 +54,6 @@ export const buildSampleWire = (): string => { variationKey: 'enabled', serialId: 1, - extraLogging: { - source: 'dynamic-offline-example' - }, shards: [ { salt: 'offline-example-salt', diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index bc5cf1085..1cd8ea8ed 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -155,6 +155,10 @@ const isNewFeatureEnabled = client.getBooleanValue( ); ``` +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. + 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. 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 bbe70820e..750735974 100644 --- a/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts @@ -85,7 +85,6 @@ const rulesResponseFor = (flagKey: string) => ({ { variationKey: 'enabled', serialId: 7, - extraLogging: { source: 'dynamic-offline' }, shards: [ { salt: 'test-salt', @@ -102,6 +101,8 @@ const rulesResponseFor = (flagKey: string) => ({ } }); +// 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, @@ -196,6 +197,24 @@ describe('DatadogOfflineOpenFeatureProvider (integration, real FlagsClient + Ope 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('starts in ERROR when a context-specific configuration has no OpenFeature context', 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 b9c0c7a27..9706ae31b 100644 --- a/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts @@ -90,6 +90,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( @@ -268,4 +280,82 @@ describe('DatadogOfflineOpenFeatureProvider', () => { ); expect(mockFlagsClient.getBooleanDetails).not.toHaveBeenCalled(); }); + + it('passes an empty effective context through per-resolution evaluation', () => { + const provider = new DatadogOfflineOpenFeatureProvider(); + // eslint-disable-next-line no-console + const logger = console as never; + + provider.resolveBooleanEvaluation('flag', false, {}, logger); + + expect(mockFlagsClient.getDetailsForContext).toHaveBeenCalledWith( + 'flag', + false, + 'boolean', + { attributes: {} }, + logger + ); + expect(mockFlagsClient.getBooleanDetails).not.toHaveBeenCalled(); + }); + + it('preserves a missing targeting key for per-resolution evaluation', () => { + const provider = new DatadogOfflineOpenFeatureProvider(); + + provider.resolveBooleanEvaluation( + 'flag', + 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 1394a8a96..9426d2dbc 100644 --- a/packages/react-native-openfeature/src/coreProvider.ts +++ b/packages/react-native-openfeature/src/coreProvider.ts @@ -19,7 +19,7 @@ import type { ProviderEvents } from '@openfeature/web-sdk'; -import { isEmptyContext, toDdContext } from './mappers'; +import { toDdContextPreservingTargetingKey } from './mappers'; export interface DatadogOpenFeatureProviderOptions { /** @@ -67,16 +67,15 @@ export abstract class DatadogCoreOpenFeatureProvider implements Provider { _context: OFEvaluationContext, _logger: Logger ): ResolutionDetails { - const details = - this.useResolutionContext && !isEmptyContext(_context) - ? this.flagsClient.getDetailsForContext( - flagKey, - defaultValue, - 'boolean', - toDdContext(_context), - _logger - ) - : this.flagsClient.getBooleanDetails(flagKey, defaultValue); + const details = this.useResolutionContext + ? this.flagsClient.getDetailsForContext( + flagKey, + defaultValue, + 'boolean', + toDdContextPreservingTargetingKey(_context), + _logger + ) + : this.flagsClient.getBooleanDetails(flagKey, defaultValue); return toFlagResolution(details); } @@ -86,16 +85,15 @@ export abstract class DatadogCoreOpenFeatureProvider implements Provider { _context: OFEvaluationContext, _logger: Logger ): ResolutionDetails { - const details = - this.useResolutionContext && !isEmptyContext(_context) - ? this.flagsClient.getDetailsForContext( - flagKey, - defaultValue, - 'string', - toDdContext(_context), - _logger - ) - : this.flagsClient.getStringDetails(flagKey, defaultValue); + const details = this.useResolutionContext + ? this.flagsClient.getDetailsForContext( + flagKey, + defaultValue, + 'string', + toDdContextPreservingTargetingKey(_context), + _logger + ) + : this.flagsClient.getStringDetails(flagKey, defaultValue); return toFlagResolution(details); } @@ -105,16 +103,15 @@ export abstract class DatadogCoreOpenFeatureProvider implements Provider { _context: OFEvaluationContext, _logger: Logger ): ResolutionDetails { - const details = - this.useResolutionContext && !isEmptyContext(_context) - ? this.flagsClient.getDetailsForContext( - flagKey, - defaultValue, - 'number', - toDdContext(_context), - _logger - ) - : this.flagsClient.getNumberDetails(flagKey, defaultValue); + const details = this.useResolutionContext + ? this.flagsClient.getDetailsForContext( + flagKey, + defaultValue, + 'number', + toDdContextPreservingTargetingKey(_context), + _logger + ) + : this.flagsClient.getNumberDetails(flagKey, defaultValue); return toFlagResolution(details); } @@ -129,16 +126,15 @@ 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.useResolutionContext && !isEmptyContext(_context) - ? this.flagsClient.getDetailsForContext( - flagKey, - defaultValue, - 'object', - toDdContext(_context), - _logger - ) - : this.flagsClient.getObjectDetails(flagKey, defaultValue); + const details = this.useResolutionContext + ? this.flagsClient.getDetailsForContext( + flagKey, + defaultValue, + 'object', + toDdContextPreservingTargetingKey(_context), + _logger + ) + : this.flagsClient.getObjectDetails(flagKey, defaultValue); return toFlagResolution(details); } } From 4012749db0b2e588db491e3f929a21384b117f29 Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 28 Jul 2026 16:00:31 -0400 Subject: [PATCH 03/14] fix(openfeature): align offline resolution with upstream --- .../flags/sampleOfflineConfiguration.ts | 5 +++-- example/src/flags/sampleOfflineConfiguration.ts | 5 +++-- packages/react-native-openfeature/README.md | 3 +++ .../src/__tests__/offlineProvider.integration.test.ts | 6 ++++-- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/example-new-architecture/flags/sampleOfflineConfiguration.ts b/example-new-architecture/flags/sampleOfflineConfiguration.ts index b8f94039e..aac670339 100644 --- a/example-new-architecture/flags/sampleOfflineConfiguration.ts +++ b/example-new-architecture/flags/sampleOfflineConfiguration.ts @@ -21,9 +21,10 @@ export const DYNAMIC_OFFLINE_CONTEXTS = { export const buildSampleWire = (): string => JSON.stringify({ version: 1, + // TODO(FFL-2837): Replace this complete legacy `rulesBased` JSON branch + // with a version 1 `rules.response` base64 fixture after a published + // flagging-core release contains DataDog/openfeature-js-client#344. rulesBased: { - // TODO(FFL-2837): Replace this legacy rulesBased JSON fixture with - // a protobuf rules wire after flagging-core publishes PR #344. response: JSON.stringify({ createdAt: '2026-07-23T12:00:00.000Z', format: 'SERVER', diff --git a/example/src/flags/sampleOfflineConfiguration.ts b/example/src/flags/sampleOfflineConfiguration.ts index 72148f443..4d5559cfc 100644 --- a/example/src/flags/sampleOfflineConfiguration.ts +++ b/example/src/flags/sampleOfflineConfiguration.ts @@ -21,9 +21,10 @@ export const DYNAMIC_OFFLINE_CONTEXTS = { export const buildSampleWire = (): string => JSON.stringify({ version: 1, + // TODO(FFL-2837): Replace this complete legacy `rulesBased` JSON branch + // with a version 1 `rules.response` base64 fixture after a published + // flagging-core release contains DataDog/openfeature-js-client#344. rulesBased: { - // TODO(FFL-2837): Replace this legacy rulesBased JSON fixture with - // a protobuf rules wire after flagging-core publishes PR #344. response: JSON.stringify({ createdAt: '2026-07-23T12:00:00.000Z', format: 'SERVER', diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index 1cd8ea8ed..0ddf0589f 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -251,6 +251,9 @@ 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. +Only load a rules configuration from a trusted source. +The rules evaluator uses JavaScript regular expressions without an execution limit. +A hostile expression can block the JavaScript thread. This provider requires `@openfeature/web-sdk` `^1.8.0`. 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 750735974..a6b0972e3 100644 --- a/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts @@ -101,8 +101,10 @@ const rulesResponseFor = (flagKey: string) => ({ } }); -// TODO(FFL-2837): Replace this legacy rulesBased JSON wire with a canonical -// protobuf rules wire after a flagging-core release contains upstream PR #344. +// TODO(FFL-2837): Replace this legacy `rulesBased` JSON helper with a canonical +// version 1 `rules.response` base64 fixture after a published flagging-core +// release contains DataDog/openfeature-js-client#344. Reuse the fixture for +// packed-package Metro, Hermes, and JSC checks. const rulesWireFor = (flagKey: string): string => JSON.stringify({ version: 1, From 3a61fb7a28ef37d4ffea1e9b9ebb389cdb3e410f Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 28 Jul 2026 18:39:59 -0400 Subject: [PATCH 04/14] docs(openfeature): define portable wire ownership --- example-new-architecture/flags/flagsProvider.ts | 5 +++-- .../flags/sampleOfflineConfiguration.ts | 11 +++++++---- example/src/flags/flagsProvider.ts | 5 +++-- example/src/flags/sampleOfflineConfiguration.ts | 11 +++++++---- packages/react-native-openfeature/README.md | 7 +++++++ .../src/__tests__/offlineProvider.integration.test.ts | 10 ++++++---- .../react-native-openfeature/src/offlineProvider.ts | 7 +++++-- 7 files changed, 38 insertions(+), 18 deletions(-) diff --git a/example-new-architecture/flags/flagsProvider.ts b/example-new-architecture/flags/flagsProvider.ts index c07d19e7b..f93c113db 100644 --- a/example-new-architecture/flags/flagsProvider.ts +++ b/example-new-architecture/flags/flagsProvider.ts @@ -16,8 +16,9 @@ export type FlagsSource = 'online' | 'offline'; /** * Select which OpenFeature provider backs flag evaluations, and (re)set it at runtime. * - * - `offline`: loads a bundled `ConfigurationWire` into `DatadogOfflineOpenFeatureProvider` - * **before** setting it, so flags resolve immediately with no network request. + * - `offline`: loads a complete bundled portable `ConfigurationWire` into + * `DatadogOfflineOpenFeatureProvider` **before** setting it, so flags resolve immediately + * with no network request. The provider does not fetch a UFC response or build this wire. * - `online`: the standard `DatadogOpenFeatureProvider`, which fetches assignments from the CDN. * * The two providers use distinct `clientName`s so each is backed by its own `FlagsClient`. diff --git a/example-new-architecture/flags/sampleOfflineConfiguration.ts b/example-new-architecture/flags/sampleOfflineConfiguration.ts index aac670339..28baef55b 100644 --- a/example-new-architecture/flags/sampleOfflineConfiguration.ts +++ b/example-new-architecture/flags/sampleOfflineConfiguration.ts @@ -13,17 +13,20 @@ export const DYNAMIC_OFFLINE_CONTEXTS = { }; /** - * Build a bundled rules `ConfigurationWire` string. + * Build a complete bundled portable rules `ConfigurationWire` string. * * The example is fully offline. It evaluates the same rules for each new - * OpenFeature context. It does not fetch assignments. + * OpenFeature context. It does not fetch a UFC response or build a wire at runtime. */ export const buildSampleWire = (): string => JSON.stringify({ version: 1, // TODO(FFL-2837): Replace this complete legacy `rulesBased` JSON branch - // with a version 1 `rules.response` base64 fixture after a published - // flagging-core release contains DataDog/openfeature-js-client#344. + // after a published flagging-core release contains + // DataDog/openfeature-js-client#344. Reuse the production-derived client + // fixture from the integration test: one base64 encoding of the canonical + // dd-source#34959 protobuf bytes in a version 1 `rules.response` envelope. + // Do not use raw protobuf or the legacy service JSON response here. rulesBased: { response: JSON.stringify({ createdAt: '2026-07-23T12:00:00.000Z', diff --git a/example/src/flags/flagsProvider.ts b/example/src/flags/flagsProvider.ts index 0906601c2..b27deb679 100644 --- a/example/src/flags/flagsProvider.ts +++ b/example/src/flags/flagsProvider.ts @@ -16,8 +16,9 @@ export type FlagsSource = 'online' | 'offline'; /** * Select which OpenFeature provider backs flag evaluations, and (re)set it at runtime. * - * - `offline`: loads a bundled `ConfigurationWire` into `DatadogOfflineOpenFeatureProvider` - * **before** setting it, so flags resolve immediately with no network request. + * - `offline`: loads a complete bundled portable `ConfigurationWire` into + * `DatadogOfflineOpenFeatureProvider` **before** setting it, so flags resolve immediately + * with no network request. The provider does not fetch a UFC response or build this wire. * - `online`: the standard `DatadogOpenFeatureProvider`, which fetches assignments from the CDN. * * The two providers use distinct `clientName`s so each is backed by its own `FlagsClient`. diff --git a/example/src/flags/sampleOfflineConfiguration.ts b/example/src/flags/sampleOfflineConfiguration.ts index 4d5559cfc..268d27fe9 100644 --- a/example/src/flags/sampleOfflineConfiguration.ts +++ b/example/src/flags/sampleOfflineConfiguration.ts @@ -13,17 +13,20 @@ export const DYNAMIC_OFFLINE_CONTEXTS = { }; /** - * Build a bundled rules `ConfigurationWire` string. + * Build a complete bundled portable rules `ConfigurationWire` string. * * The example is fully offline. It evaluates the same rules for each new - * OpenFeature context. It does not fetch assignments. + * OpenFeature context. It does not fetch a UFC response or build a wire at runtime. */ export const buildSampleWire = (): string => JSON.stringify({ version: 1, // TODO(FFL-2837): Replace this complete legacy `rulesBased` JSON branch - // with a version 1 `rules.response` base64 fixture after a published - // flagging-core release contains DataDog/openfeature-js-client#344. + // after a published flagging-core release contains + // DataDog/openfeature-js-client#344. Reuse the production-derived client + // fixture from the integration test: one base64 encoding of the canonical + // dd-source#34959 protobuf bytes in a version 1 `rules.response` envelope. + // Do not use raw protobuf or the legacy service JSON response here. rulesBased: { response: JSON.stringify({ createdAt: '2026-07-23T12:00:00.000Z', diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index 0ddf0589f..aab57b32e 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -155,6 +155,13 @@ const isNewFeatureEnabled = client.getBooleanValue( ); ``` +`wire` must be the complete version `1` portable JSON envelope. +For rules, `rules.response` contains one base64 encoding of the raw UFC protobuf bytes. +Do not pass raw protobuf bytes to `configurationFromString`. +Do not put the UFC service JSON response in `rules.response`. +The provider does not fetch the UFC endpoint or build the portable envelope. +The customer or configuration distribution layer must supply that envelope. + 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. 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 a6b0972e3..0aa05252c 100644 --- a/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts @@ -101,10 +101,12 @@ const rulesResponseFor = (flagKey: string) => ({ } }); -// TODO(FFL-2837): Replace this legacy `rulesBased` JSON helper with a canonical -// version 1 `rules.response` base64 fixture after a published flagging-core -// release contains DataDog/openfeature-js-client#344. Reuse the fixture for -// packed-package Metro, Hermes, and JSC checks. +// TODO(FFL-2837): Replace this legacy `rulesBased` JSON helper after a published +// flagging-core release contains DataDog/openfeature-js-client#344. Use canonical +// raw protobuf bytes produced from the dd-source#34959 client-distribution path. +// Put one base64 encoding of those bytes in a version 1 `rules.response` envelope, +// verify that decoding returns the original bytes, and record the source revision. +// Reuse that portable-wire fixture for examples, Metro, Hermes, and JSC checks. const rulesWireFor = (flagKey: string): string => JSON.stringify({ version: 1, diff --git a/packages/react-native-openfeature/src/offlineProvider.ts b/packages/react-native-openfeature/src/offlineProvider.ts index d3abe1867..b8510ed8c 100644 --- a/packages/react-native-openfeature/src/offlineProvider.ts +++ b/packages/react-native-openfeature/src/offlineProvider.ts @@ -50,6 +50,8 @@ const OF_ERROR_CODE: Record = { * 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}. + * Supply a configuration parsed from the complete portable JSON envelope. The provider does not + * accept a raw UFC protobuf response and does not build the envelope. * * A rules configuration evaluates each new context locally. Call `OpenFeature.setContext` to * change the subject. The provider does not fetch after this call. @@ -129,8 +131,9 @@ export class DatadogOfflineOpenFeatureProvider extends DatadogCoreOpenFeaturePro /** * Load a configuration into the provider for offline evaluation. * - * @param configuration A configuration parsed from a `ConfigurationWire` string via - * `configurationFromString`. + * @param configuration A configuration parsed from a complete portable + * `FlagsConfigurationWire` JSON envelope via `configurationFromString`. The provider does not + * fetch a UFC service response or construct this envelope. */ setConfiguration(configuration: ParsedFlagsConfiguration): void { const result = this.flagsClient.setConfiguration(configuration); From ae90938501d078a062bcaef44257e27e761e3d21 Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 28 Jul 2026 20:28:16 -0400 Subject: [PATCH 05/14] docs(openfeature): align parser fixture contract --- example-new-architecture/flags/sampleOfflineConfiguration.ts | 3 ++- example/src/flags/sampleOfflineConfiguration.ts | 3 ++- packages/react-native-openfeature/README.md | 2 ++ .../src/__tests__/offlineProvider.integration.test.ts | 5 ++++- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/example-new-architecture/flags/sampleOfflineConfiguration.ts b/example-new-architecture/flags/sampleOfflineConfiguration.ts index 28baef55b..5f8174ac8 100644 --- a/example-new-architecture/flags/sampleOfflineConfiguration.ts +++ b/example-new-architecture/flags/sampleOfflineConfiguration.ts @@ -26,7 +26,8 @@ export const buildSampleWire = (): string => // DataDog/openfeature-js-client#344. Reuse the production-derived client // fixture from the integration test: one base64 encoding of the canonical // dd-source#34959 protobuf bytes in a version 1 `rules.response` envelope. - // Do not use raw protobuf or the legacy service JSON response here. + // Let the upstream configuration subpath decode it. Do not use raw protobuf, + // the legacy service JSON response, or a local strict base64 validator here. rulesBased: { response: JSON.stringify({ createdAt: '2026-07-23T12:00:00.000Z', diff --git a/example/src/flags/sampleOfflineConfiguration.ts b/example/src/flags/sampleOfflineConfiguration.ts index 268d27fe9..756fc13f2 100644 --- a/example/src/flags/sampleOfflineConfiguration.ts +++ b/example/src/flags/sampleOfflineConfiguration.ts @@ -26,7 +26,8 @@ export const buildSampleWire = (): string => // DataDog/openfeature-js-client#344. Reuse the production-derived client // fixture from the integration test: one base64 encoding of the canonical // dd-source#34959 protobuf bytes in a version 1 `rules.response` envelope. - // Do not use raw protobuf or the legacy service JSON response here. + // Let the upstream configuration subpath decode it. Do not use raw protobuf, + // the legacy service JSON response, or a local strict base64 validator here. rulesBased: { response: JSON.stringify({ createdAt: '2026-07-23T12:00:00.000Z', diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index aab57b32e..da846efa4 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -157,6 +157,8 @@ const isNewFeatureEnabled = client.getBooleanValue( `wire` must be the complete version `1` portable JSON envelope. For rules, `rules.response` contains one base64 encoding of the raw UFC protobuf bytes. +Use standard base64. +The SDK delegates decoding to flagging-core and does not add a second stricter base64 validator. Do not pass raw protobuf bytes to `configurationFromString`. Do not put the UFC service JSON response in `rules.response`. The provider does not fetch the UFC endpoint or build the portable envelope. 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 0aa05252c..cccdaf942 100644 --- a/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts @@ -106,7 +106,10 @@ const rulesResponseFor = (flagKey: string) => ({ // raw protobuf bytes produced from the dd-source#34959 client-distribution path. // Put one base64 encoding of those bytes in a version 1 `rules.response` envelope, // verify that decoding returns the original bytes, and record the source revision. -// Reuse that portable-wire fixture for examples, Metro, Hermes, and JSC checks. +// Use the upstream `@datadog/flagging-core/configuration` parser. Do not copy the +// strict base64 validator removed by PR #344. Reuse the portable-wire fixture for +// examples, Metro, Hermes, and JSC checks. Also confirm that the default flagging-core +// entry point excludes Protobuf-ES and measure whether the React Native root includes it. const rulesWireFor = (flagKey: string): string => JSON.stringify({ version: 1, From 18050787a3aadfadc80530c8725f3f38fb499710 Mon Sep 17 00:00:00 2001 From: Blake Date: Wed, 29 Jul 2026 10:23:45 -0400 Subject: [PATCH 06/14] test(openfeature): preserve rules parse errors --- .../flags/sampleOfflineConfiguration.ts | 2 + .../src/flags/sampleOfflineConfiguration.ts | 2 + packages/react-native-openfeature/README.md | 6 +++ .../offlineProvider.integration.test.ts | 43 ++++++++++++++++++- 4 files changed, 51 insertions(+), 2 deletions(-) diff --git a/example-new-architecture/flags/sampleOfflineConfiguration.ts b/example-new-architecture/flags/sampleOfflineConfiguration.ts index 5f8174ac8..899ff4935 100644 --- a/example-new-architecture/flags/sampleOfflineConfiguration.ts +++ b/example-new-architecture/flags/sampleOfflineConfiguration.ts @@ -28,6 +28,8 @@ export const buildSampleWire = (): string => // dd-source#34959 protobuf bytes in a version 1 `rules.response` envelope. // Let the upstream configuration subpath decode it. Do not use raw protobuf, // the legacy service JSON response, or a local strict base64 validator here. + // Pin PR #344 at or after `be0d886` so invalid flags return `PARSE_ERROR` + // and unknown protobuf fields do not reject supported known data. rulesBased: { response: JSON.stringify({ createdAt: '2026-07-23T12:00:00.000Z', diff --git a/example/src/flags/sampleOfflineConfiguration.ts b/example/src/flags/sampleOfflineConfiguration.ts index 756fc13f2..0bb9390ef 100644 --- a/example/src/flags/sampleOfflineConfiguration.ts +++ b/example/src/flags/sampleOfflineConfiguration.ts @@ -28,6 +28,8 @@ export const buildSampleWire = (): string => // dd-source#34959 protobuf bytes in a version 1 `rules.response` envelope. // Let the upstream configuration subpath decode it. Do not use raw protobuf, // the legacy service JSON response, or a local strict base64 validator here. + // Pin PR #344 at or after `be0d886` so invalid flags return `PARSE_ERROR` + // and unknown protobuf fields do not reject supported known data. rulesBased: { response: JSON.stringify({ createdAt: '2026-07-23T12:00:00.000Z', diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index da846efa4..f0d9a6652 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -164,6 +164,12 @@ Do not put the UFC service JSON response in `rules.response`. The provider does not fetch the UFC endpoint or build the portable envelope. The customer or configuration distribution layer must supply that envelope. +Flagging-core keeps an invalid or unsupported rules flag in the parsed configuration. +An evaluation of that flag returns `PARSE_ERROR` and the validation message. +The SDK does not track that error result. +Other valid rules flags remain usable. +Unknown protobuf fields do not reject supported known data. + 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. 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 cccdaf942..4217e03a9 100644 --- a/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts @@ -110,11 +110,16 @@ const rulesResponseFor = (flagKey: string) => ({ // strict base64 validator removed by PR #344. Reuse the portable-wire fixture for // examples, Metro, Hermes, and JSC checks. Also confirm that the default flagging-core // entry point excludes Protobuf-ES and measure whether the React Native root includes it. -const rulesWireFor = (flagKey: string): string => +// Pin PR #344 at or after `be0d886`: invalid flags must return `PARSE_ERROR` with +// their message, and unknown protobuf fields must not reject supported known data. +const rulesWireFor = ( + flagKey: string, + response = rulesResponseFor(flagKey) +): string => JSON.stringify({ version: 1, rulesBased: { - response: JSON.stringify(rulesResponseFor(flagKey)) + response: JSON.stringify(response) } }); @@ -222,6 +227,40 @@ describe('DatadogOfflineOpenFeatureProvider (integration, real FlagsClient + Ope expect(details.errorCode).toBe(ErrorCode.TARGETING_KEY_MISSING); }); + it('preserves an invalid flag PARSE_ERROR and does not track it', async () => { + const { domain, clientName } = freshNames(); + const response = rulesResponseFor('invalid-feature'); + const condition = + response.flags['invalid-feature'].allocations[0].rules?.[0] + .conditions[0]; + if (!condition) { + throw new Error('The fixture has no condition.'); + } + (condition as { operator: string }).operator = 'FUTURE_OPERATOR'; + + const provider = new DatadogOfflineOpenFeatureProvider({ clientName }); + provider.setConfiguration( + configurationFromString(rulesWireFor('invalid-feature', response)) + ); + await OpenFeature.setProviderAndWait(domain, provider); + + const details = OpenFeature.getClient(domain).getBooleanDetails( + 'invalid-feature', + false + ); + expect(details).toMatchObject({ + value: false, + reason: 'ERROR', + errorCode: ErrorCode.PARSE_ERROR, + errorMessage: expect.stringContaining('FUTURE_OPERATOR') + }); + + const nativeFlags = jest.requireMock( + '../../../core/src/specs/NativeDdFlags' + ).default; + expect(nativeFlags.trackEvaluation).not.toHaveBeenCalled(); + }); + it('starts in ERROR when a context-specific configuration has no OpenFeature context', async () => { const { domain, clientName } = freshNames(); const provider = new DatadogOfflineOpenFeatureProvider({ clientName }); From 88282059f1f1b7c4dc6bb91f9e9c1d807191a50f Mon Sep 17 00:00:00 2001 From: Blake Date: Wed, 29 Jul 2026 12:25:50 -0400 Subject: [PATCH 07/14] test(openfeature): reject unsafe rules integers --- .../flags/sampleOfflineConfiguration.ts | 7 ++-- .../src/flags/sampleOfflineConfiguration.ts | 7 ++-- packages/react-native-openfeature/README.md | 4 +++ .../offlineProvider.integration.test.ts | 32 ++++++++++--------- 4 files changed, 29 insertions(+), 21 deletions(-) diff --git a/example-new-architecture/flags/sampleOfflineConfiguration.ts b/example-new-architecture/flags/sampleOfflineConfiguration.ts index 899ff4935..d4beb1748 100644 --- a/example-new-architecture/flags/sampleOfflineConfiguration.ts +++ b/example-new-architecture/flags/sampleOfflineConfiguration.ts @@ -23,13 +23,14 @@ export const buildSampleWire = (): string => version: 1, // TODO(FFL-2837): Replace this complete legacy `rulesBased` JSON branch // after a published flagging-core release contains - // DataDog/openfeature-js-client#344. Reuse the production-derived client + // DataDog/openfeature-js-client#344 through `4f6f40c`. Reuse the + // production-derived client // fixture from the integration test: one base64 encoding of the canonical // dd-source#34959 protobuf bytes in a version 1 `rules.response` envelope. // Let the upstream configuration subpath decode it. Do not use raw protobuf, // the legacy service JSON response, or a local strict base64 validator here. - // Pin PR #344 at or after `be0d886` so invalid flags return `PARSE_ERROR` - // and unknown protobuf fields do not reject supported known data. + // The final fixture must preserve protobuf integers as `bigint`, return + // `PARSE_ERROR` for unsafe number conversion, and tolerate unknown fields. rulesBased: { response: JSON.stringify({ createdAt: '2026-07-23T12:00:00.000Z', diff --git a/example/src/flags/sampleOfflineConfiguration.ts b/example/src/flags/sampleOfflineConfiguration.ts index 0bb9390ef..586ae1b11 100644 --- a/example/src/flags/sampleOfflineConfiguration.ts +++ b/example/src/flags/sampleOfflineConfiguration.ts @@ -23,13 +23,14 @@ export const buildSampleWire = (): string => version: 1, // TODO(FFL-2837): Replace this complete legacy `rulesBased` JSON branch // after a published flagging-core release contains - // DataDog/openfeature-js-client#344. Reuse the production-derived client + // DataDog/openfeature-js-client#344 through `4f6f40c`. Reuse the + // production-derived client // fixture from the integration test: one base64 encoding of the canonical // dd-source#34959 protobuf bytes in a version 1 `rules.response` envelope. // Let the upstream configuration subpath decode it. Do not use raw protobuf, // the legacy service JSON response, or a local strict base64 validator here. - // Pin PR #344 at or after `be0d886` so invalid flags return `PARSE_ERROR` - // and unknown protobuf fields do not reject supported known data. + // The final fixture must preserve protobuf integers as `bigint`, return + // `PARSE_ERROR` for unsafe number conversion, and tolerate unknown fields. rulesBased: { response: JSON.stringify({ createdAt: '2026-07-23T12:00:00.000Z', diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index f0d9a6652..647ad0fdb 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -169,6 +169,10 @@ An evaluation of that flag returns `PARSE_ERROR` and the validation message. The SDK does not track that error result. Other valid rules flags remain usable. Unknown protobuf fields do not reject supported known data. +Protobuf integer values stay as `bigint` in the parsed rules object. +Safe integers evaluate as OpenFeature numbers. +An integer outside the JavaScript safe range returns `PARSE_ERROR`. +The SDK does not return a rounded or imprecise value. Keep the original wire when it contains rules. Do not use `configurationToString` to recreate a rules wire. 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 4217e03a9..0574f3f2f 100644 --- a/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts @@ -102,7 +102,8 @@ const rulesResponseFor = (flagKey: string) => ({ }); // TODO(FFL-2837): Replace this legacy `rulesBased` JSON helper after a published -// flagging-core release contains DataDog/openfeature-js-client#344. Use canonical +// flagging-core release contains DataDog/openfeature-js-client#344 through +// `4f6f40c`. Use canonical // raw protobuf bytes produced from the dd-source#34959 client-distribution path. // Put one base64 encoding of those bytes in a version 1 `rules.response` envelope, // verify that decoding returns the original bytes, and record the source revision. @@ -110,8 +111,9 @@ const rulesResponseFor = (flagKey: string) => ({ // strict base64 validator removed by PR #344. Reuse the portable-wire fixture for // examples, Metro, Hermes, and JSC checks. Also confirm that the default flagging-core // entry point excludes Protobuf-ES and measure whether the React Native root includes it. -// Pin PR #344 at or after `be0d886`: invalid flags must return `PARSE_ERROR` with -// their message, and unknown protobuf fields must not reject supported known data. +// The fixture must prove that unknown fields preserve supported known data and +// that an out-of-range `int64` stays a `bigint` before evaluation returns +// `PARSE_ERROR`. Run the same fixture in the supported Hermes and JSC versions. const rulesWireFor = ( flagKey: string, response = rulesResponseFor(flagKey) @@ -227,16 +229,15 @@ describe('DatadogOfflineOpenFeatureProvider (integration, real FlagsClient + Ope expect(details.errorCode).toBe(ErrorCode.TARGETING_KEY_MISSING); }); - it('preserves an invalid flag PARSE_ERROR and does not track it', async () => { + it('preserves an unsafe-integer PARSE_ERROR and does not track it', async () => { const { domain, clientName } = freshNames(); const response = rulesResponseFor('invalid-feature'); - const condition = - response.flags['invalid-feature'].allocations[0].rules?.[0] - .conditions[0]; - if (!condition) { - throw new Error('The fixture has no condition.'); - } - (condition as { operator: string }).operator = 'FUTURE_OPERATOR'; + const flag = (response.flags['invalid-feature'] as unknown) as { + variationType: string; + variations: { enabled: { value: unknown } }; + }; + flag.variationType = 'INTEGER'; + flag.variations.enabled.value = Number.MAX_SAFE_INTEGER + 1; const provider = new DatadogOfflineOpenFeatureProvider({ clientName }); provider.setConfiguration( @@ -244,15 +245,16 @@ describe('DatadogOfflineOpenFeatureProvider (integration, real FlagsClient + Ope ); await OpenFeature.setProviderAndWait(domain, provider); - const details = OpenFeature.getClient(domain).getBooleanDetails( + const details = OpenFeature.getClient(domain).getNumberDetails( 'invalid-feature', - false + 0 ); expect(details).toMatchObject({ - value: false, + value: 0, reason: 'ERROR', errorCode: ErrorCode.PARSE_ERROR, - errorMessage: expect.stringContaining('FUTURE_OPERATOR') + errorMessage: + 'Integer variation value cannot be represented safely as a JavaScript number' }); const nativeFlags = jest.requireMock( From f9fb1f2bee31fadeee09a51685c04bec32ba0ca8 Mon Sep 17 00:00:00 2001 From: Blake Date: Thu, 30 Jul 2026 08:40:31 -0400 Subject: [PATCH 08/14] docs(openfeature): refresh upstream fixture TODOs --- .../flags/sampleOfflineConfiguration.ts | 6 ++++-- example/src/flags/sampleOfflineConfiguration.ts | 6 ++++-- .../src/__tests__/offlineProvider.integration.test.ts | 7 ++++--- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/example-new-architecture/flags/sampleOfflineConfiguration.ts b/example-new-architecture/flags/sampleOfflineConfiguration.ts index d4beb1748..639de96de 100644 --- a/example-new-architecture/flags/sampleOfflineConfiguration.ts +++ b/example-new-architecture/flags/sampleOfflineConfiguration.ts @@ -23,14 +23,16 @@ export const buildSampleWire = (): string => version: 1, // TODO(FFL-2837): Replace this complete legacy `rulesBased` JSON branch // after a published flagging-core release contains - // DataDog/openfeature-js-client#344 through `4f6f40c`. Reuse the + // DataDog/openfeature-js-client#344 through `41dff20` and restores + // 32-byte SHA digest validation. Reuse the // production-derived client // fixture from the integration test: one base64 encoding of the canonical // dd-source#34959 protobuf bytes in a version 1 `rules.response` envelope. // Let the upstream configuration subpath decode it. Do not use raw protobuf, // the legacy service JSON response, or a local strict base64 validator here. // The final fixture must preserve protobuf integers as `bigint`, return - // `PARSE_ERROR` for unsafe number conversion, and tolerate unknown fields. + // `PARSE_ERROR` for unsafe number conversion, tolerate unknown fields, + // and round-trip through `configurationToString`. rulesBased: { response: JSON.stringify({ createdAt: '2026-07-23T12:00:00.000Z', diff --git a/example/src/flags/sampleOfflineConfiguration.ts b/example/src/flags/sampleOfflineConfiguration.ts index 586ae1b11..b76ff2558 100644 --- a/example/src/flags/sampleOfflineConfiguration.ts +++ b/example/src/flags/sampleOfflineConfiguration.ts @@ -23,14 +23,16 @@ export const buildSampleWire = (): string => version: 1, // TODO(FFL-2837): Replace this complete legacy `rulesBased` JSON branch // after a published flagging-core release contains - // DataDog/openfeature-js-client#344 through `4f6f40c`. Reuse the + // DataDog/openfeature-js-client#344 through `41dff20` and restores + // 32-byte SHA digest validation. Reuse the // production-derived client // fixture from the integration test: one base64 encoding of the canonical // dd-source#34959 protobuf bytes in a version 1 `rules.response` envelope. // Let the upstream configuration subpath decode it. Do not use raw protobuf, // the legacy service JSON response, or a local strict base64 validator here. // The final fixture must preserve protobuf integers as `bigint`, return - // `PARSE_ERROR` for unsafe number conversion, and tolerate unknown fields. + // `PARSE_ERROR` for unsafe number conversion, tolerate unknown fields, + // and round-trip through `configurationToString`. rulesBased: { response: JSON.stringify({ createdAt: '2026-07-23T12:00:00.000Z', 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 0574f3f2f..720d58428 100644 --- a/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts @@ -103,8 +103,8 @@ const rulesResponseFor = (flagKey: string) => ({ // TODO(FFL-2837): Replace this legacy `rulesBased` JSON helper after a published // flagging-core release contains DataDog/openfeature-js-client#344 through -// `4f6f40c`. Use canonical -// raw protobuf bytes produced from the dd-source#34959 client-distribution path. +// `41dff20` and restores 32-byte SHA digest validation. Use canonical raw +// protobuf bytes produced from the dd-source#34959 client-distribution path. // Put one base64 encoding of those bytes in a version 1 `rules.response` envelope, // verify that decoding returns the original bytes, and record the source revision. // Use the upstream `@datadog/flagging-core/configuration` parser. Do not copy the @@ -113,7 +113,8 @@ const rulesResponseFor = (flagKey: string) => ({ // entry point excludes Protobuf-ES and measure whether the React Native root includes it. // The fixture must prove that unknown fields preserve supported known data and // that an out-of-range `int64` stays a `bigint` before evaluation returns -// `PARSE_ERROR`. Run the same fixture in the supported Hermes and JSC versions. +// `PARSE_ERROR`. Round-trip it through `configurationToString`. Run the same +// fixture in the supported Hermes and JSC versions. const rulesWireFor = ( flagKey: string, response = rulesResponseFor(flagKey) From 267c19e9a79780c8434f361b0c45b10f51ae5eeb Mon Sep 17 00:00:00 2001 From: Blake Date: Fri, 31 Jul 2026 09:55:08 -0400 Subject: [PATCH 09/14] docs(openfeature): clarify integer runtime fixtures --- .../flags/sampleOfflineConfiguration.ts | 8 +++++--- example/src/flags/sampleOfflineConfiguration.ts | 8 +++++--- .../__tests__/offlineProvider.integration.test.ts | 14 ++++++++++---- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/example-new-architecture/flags/sampleOfflineConfiguration.ts b/example-new-architecture/flags/sampleOfflineConfiguration.ts index 639de96de..4f53f335a 100644 --- a/example-new-architecture/flags/sampleOfflineConfiguration.ts +++ b/example-new-architecture/flags/sampleOfflineConfiguration.ts @@ -23,8 +23,9 @@ export const buildSampleWire = (): string => version: 1, // TODO(FFL-2837): Replace this complete legacy `rulesBased` JSON branch // after a published flagging-core release contains - // DataDog/openfeature-js-client#344 through `41dff20` and restores - // 32-byte SHA digest validation. Reuse the + // DataDog/openfeature-js-client#344 through `41dff20`, restores + // 32-byte SHA digest validation, and finalizes the runtime contract for + // integer and shard evaluation without global `BigInt`. Reuse the // production-derived client // fixture from the integration test: one base64 encoding of the canonical // dd-source#34959 protobuf bytes in a version 1 `rules.response` envelope. @@ -32,7 +33,8 @@ export const buildSampleWire = (): string => // the legacy service JSON response, or a local strict base64 validator here. // The final fixture must preserve protobuf integers as `bigint`, return // `PARSE_ERROR` for unsafe number conversion, tolerate unknown fields, - // and round-trip through `configurationToString`. + // preserve them through `configurationToString`, and follow the final + // global-`BigInt` runtime requirement. rulesBased: { response: JSON.stringify({ createdAt: '2026-07-23T12:00:00.000Z', diff --git a/example/src/flags/sampleOfflineConfiguration.ts b/example/src/flags/sampleOfflineConfiguration.ts index b76ff2558..12ca564d9 100644 --- a/example/src/flags/sampleOfflineConfiguration.ts +++ b/example/src/flags/sampleOfflineConfiguration.ts @@ -23,8 +23,9 @@ export const buildSampleWire = (): string => version: 1, // TODO(FFL-2837): Replace this complete legacy `rulesBased` JSON branch // after a published flagging-core release contains - // DataDog/openfeature-js-client#344 through `41dff20` and restores - // 32-byte SHA digest validation. Reuse the + // DataDog/openfeature-js-client#344 through `41dff20`, restores + // 32-byte SHA digest validation, and finalizes the runtime contract for + // integer and shard evaluation without global `BigInt`. Reuse the // production-derived client // fixture from the integration test: one base64 encoding of the canonical // dd-source#34959 protobuf bytes in a version 1 `rules.response` envelope. @@ -32,7 +33,8 @@ export const buildSampleWire = (): string => // the legacy service JSON response, or a local strict base64 validator here. // The final fixture must preserve protobuf integers as `bigint`, return // `PARSE_ERROR` for unsafe number conversion, tolerate unknown fields, - // and round-trip through `configurationToString`. + // preserve them through `configurationToString`, and follow the final + // global-`BigInt` runtime requirement. rulesBased: { response: JSON.stringify({ createdAt: '2026-07-23T12:00:00.000Z', 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 720d58428..702684f27 100644 --- a/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts @@ -103,8 +103,11 @@ const rulesResponseFor = (flagKey: string) => ({ // TODO(FFL-2837): Replace this legacy `rulesBased` JSON helper after a published // flagging-core release contains DataDog/openfeature-js-client#344 through -// `41dff20` and restores 32-byte SHA digest validation. Use canonical raw -// protobuf bytes produced from the dd-source#34959 client-distribution path. +// `41dff20`, restores 32-byte SHA digest validation, and either supports integer +// and shard evaluation without global `BigInt` or declares `BigInt` as a runtime +// requirement. The `41dff20` smoke test covers only a static boolean without +// `BigInt`. Use canonical raw protobuf bytes produced from the dd-source#34959 +// client-distribution path. // Put one base64 encoding of those bytes in a version 1 `rules.response` envelope, // verify that decoding returns the original bytes, and record the source revision. // Use the upstream `@datadog/flagging-core/configuration` parser. Do not copy the @@ -113,8 +116,11 @@ const rulesResponseFor = (flagKey: string) => ({ // entry point excludes Protobuf-ES and measure whether the React Native root includes it. // The fixture must prove that unknown fields preserve supported known data and // that an out-of-range `int64` stays a `bigint` before evaluation returns -// `PARSE_ERROR`. Round-trip it through `configurationToString`. Run the same -// fixture in the supported Hermes and JSC versions. +// `PARSE_ERROR`. Round-trip it through `configurationToString` and prove that +// unknown fields survive serialization. Run safe and unsafe integer variations, +// shard counts, and shard ranges without global `BigInt`; invalid data must return +// `PARSE_ERROR`, not `GENERAL`. Run the same fixture in the supported Hermes and +// JSC versions. const rulesWireFor = ( flagKey: string, response = rulesResponseFor(flagKey) From 92388af89e9027723d9ba104100ada3d7a476020 Mon Sep 17 00:00:00 2001 From: Blake Date: Mon, 3 Aug 2026 09:39:40 -0400 Subject: [PATCH 10/14] docs(openfeature): refresh portable fixture contract --- .../flags/sampleOfflineConfiguration.ts | 11 +++++++---- .../src/flags/sampleOfflineConfiguration.ts | 11 +++++++---- .../offlineProvider.integration.test.ts | 18 +++++++++++------- 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/example-new-architecture/flags/sampleOfflineConfiguration.ts b/example-new-architecture/flags/sampleOfflineConfiguration.ts index 4f53f335a..bac95b796 100644 --- a/example-new-architecture/flags/sampleOfflineConfiguration.ts +++ b/example-new-architecture/flags/sampleOfflineConfiguration.ts @@ -23,17 +23,20 @@ export const buildSampleWire = (): string => version: 1, // TODO(FFL-2837): Replace this complete legacy `rulesBased` JSON branch // after a published flagging-core release contains - // DataDog/openfeature-js-client#344 through `41dff20`, restores + // DataDog/openfeature-js-client#344 through `9f794c7`, restores // 32-byte SHA digest validation, and finalizes the runtime contract for // integer and shard evaluation without global `BigInt`. Reuse the // production-derived client // fixture from the integration test: one base64 encoding of the canonical // dd-source#34959 protobuf bytes in a version 1 `rules.response` envelope. - // Let the upstream configuration subpath decode it. Do not use raw protobuf, - // the legacy service JSON response, or a local strict base64 validator here. + // Record dd-source#40304 commit `071c4ad` as its schema revision. Let the + // upstream configuration subpath decode it. Do not use the protobuf-free + // precomputed subpath, raw protobuf, the legacy service JSON response, or a + // local strict base64 validator here. // The final fixture must preserve protobuf integers as `bigint`, return // `PARSE_ERROR` for unsafe number conversion, tolerate unknown fields, - // preserve them through `configurationToString`, and follow the final + // preserve them through `configurationToString`, return flag-scoped + // `PARSE_ERROR` for an unsupported feature level, and follow the final // global-`BigInt` runtime requirement. rulesBased: { response: JSON.stringify({ diff --git a/example/src/flags/sampleOfflineConfiguration.ts b/example/src/flags/sampleOfflineConfiguration.ts index 12ca564d9..96672ef5c 100644 --- a/example/src/flags/sampleOfflineConfiguration.ts +++ b/example/src/flags/sampleOfflineConfiguration.ts @@ -23,17 +23,20 @@ export const buildSampleWire = (): string => version: 1, // TODO(FFL-2837): Replace this complete legacy `rulesBased` JSON branch // after a published flagging-core release contains - // DataDog/openfeature-js-client#344 through `41dff20`, restores + // DataDog/openfeature-js-client#344 through `9f794c7`, restores // 32-byte SHA digest validation, and finalizes the runtime contract for // integer and shard evaluation without global `BigInt`. Reuse the // production-derived client // fixture from the integration test: one base64 encoding of the canonical // dd-source#34959 protobuf bytes in a version 1 `rules.response` envelope. - // Let the upstream configuration subpath decode it. Do not use raw protobuf, - // the legacy service JSON response, or a local strict base64 validator here. + // Record dd-source#40304 commit `071c4ad` as its schema revision. Let the + // upstream configuration subpath decode it. Do not use the protobuf-free + // precomputed subpath, raw protobuf, the legacy service JSON response, or a + // local strict base64 validator here. // The final fixture must preserve protobuf integers as `bigint`, return // `PARSE_ERROR` for unsafe number conversion, tolerate unknown fields, - // preserve them through `configurationToString`, and follow the final + // preserve them through `configurationToString`, return flag-scoped + // `PARSE_ERROR` for an unsupported feature level, and follow the final // global-`BigInt` runtime requirement. rulesBased: { response: JSON.stringify({ 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 702684f27..16a366284 100644 --- a/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts @@ -103,24 +103,28 @@ const rulesResponseFor = (flagKey: string) => ({ // TODO(FFL-2837): Replace this legacy `rulesBased` JSON helper after a published // flagging-core release contains DataDog/openfeature-js-client#344 through -// `41dff20`, restores 32-byte SHA digest validation, and either supports integer +// `9f794c7`, restores 32-byte SHA digest validation, and either supports integer // and shard evaluation without global `BigInt` or declares `BigInt` as a runtime -// requirement. The `41dff20` smoke test covers only a static boolean without +// requirement. The `9f794c7` smoke test covers only a static boolean without // `BigInt`. Use canonical raw protobuf bytes produced from the dd-source#34959 -// client-distribution path. +// client-distribution path. Record dd-source#40304 commit `071c4ad` as the schema +// revision and dd-source#34959 as the service producer path. // Put one base64 encoding of those bytes in a version 1 `rules.response` envelope, // verify that decoding returns the original bytes, and record the source revision. // Use the upstream `@datadog/flagging-core/configuration` parser. Do not copy the -// strict base64 validator removed by PR #344. Reuse the portable-wire fixture for -// examples, Metro, Hermes, and JSC checks. Also confirm that the default flagging-core -// entry point excludes Protobuf-ES and measure whether the React Native root includes it. +// strict base64 validator removed by PR #344. Do not use the new +// `@datadog/flagging-core/precomputed` subpath for this rules wire. Reuse the +// portable-wire fixture for examples, Metro, Hermes, and JSC checks. Confirm that +// the default flagging-core and precomputed entry points exclude Protobuf-ES and +// measure whether the React Native root includes it. // The fixture must prove that unknown fields preserve supported known data and // that an out-of-range `int64` stays a `bigint` before evaluation returns // `PARSE_ERROR`. Round-trip it through `configurationToString` and prove that // unknown fields survive serialization. Run safe and unsafe integer variations, // shard counts, and shard ranges without global `BigInt`; invalid data must return // `PARSE_ERROR`, not `GENERAL`. Run the same fixture in the supported Hermes and -// JSC versions. +// JSC versions. Also require flag-scoped `PARSE_ERROR`, not `FLAG_NOT_FOUND`, for +// an unsupported minimum feature level. const rulesWireFor = ( flagKey: string, response = rulesResponseFor(flagKey) From 0ac9aedc723afb99b3f7cddc7212365fde194495 Mon Sep 17 00:00:00 2001 From: Blake Date: Mon, 3 Aug 2026 16:26:17 -0400 Subject: [PATCH 11/14] test(openfeature): verify recovery event order --- .../src/__tests__/offlineProvider.integration.test.ts | 2 ++ .../src/__tests__/offlineProvider.test.ts | 4 ++++ 2 files changed, 6 insertions(+) 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 16a366284..c92567425 100644 --- a/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts @@ -109,6 +109,8 @@ const rulesResponseFor = (flagKey: string) => ({ // `BigInt`. Use canonical raw protobuf bytes produced from the dd-source#34959 // client-distribution path. Record dd-source#40304 commit `071c4ad` as the schema // revision and dd-source#34959 as the service producer path. +// PR #336 through `33113d2` does not change this wire contract. Keep the existing +// React Native provider name and its Ready-before-ConfigurationChanged recovery order. // Put one base64 encoding of those bytes in a version 1 `rules.response` envelope, // verify that decoding returns the original bytes, and record the source revision. // Use the upstream `@datadog/flagging-core/configuration` parser. Do not copy the diff --git a/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts b/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts index 9706ae31b..743512cef 100644 --- a/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts @@ -240,6 +240,10 @@ describe('DatadogOfflineOpenFeatureProvider', () => { expect(emitSpy).toHaveBeenCalledWith( ProviderEvents.ConfigurationChanged ); + expect(emitSpy.mock.calls.slice(-2).map(([event]) => event)).toEqual([ + ProviderEvents.Ready, + ProviderEvents.ConfigurationChanged + ]); }); it('rejects initialize when a config was loaded (pre-registration) and is invalid', async () => { From 2c1bba57c80586d2a04359dca2f59162d194c2e1 Mon Sep 17 00:00:00 2001 From: Blake Date: Fri, 7 Aug 2026 07:51:58 -0400 Subject: [PATCH 12/14] fix(openfeature): surface configuration parse errors --- .../flags/sampleOfflineConfiguration.ts | 10 +++--- .../src/flags/sampleOfflineConfiguration.ts | 10 +++--- packages/react-native-openfeature/README.md | 10 +++--- .../offlineProvider.integration.test.ts | 32 ++++++++++++++---- .../src/__tests__/offlineProvider.test.ts | 33 ++++++++++++++++--- .../src/offlineProvider.ts | 6 ++++ 6 files changed, 77 insertions(+), 24 deletions(-) diff --git a/example-new-architecture/flags/sampleOfflineConfiguration.ts b/example-new-architecture/flags/sampleOfflineConfiguration.ts index bac95b796..9906ed20d 100644 --- a/example-new-architecture/flags/sampleOfflineConfiguration.ts +++ b/example-new-architecture/flags/sampleOfflineConfiguration.ts @@ -23,9 +23,9 @@ export const buildSampleWire = (): string => version: 1, // TODO(FFL-2837): Replace this complete legacy `rulesBased` JSON branch // after a published flagging-core release contains - // DataDog/openfeature-js-client#344 through `9f794c7`, restores - // 32-byte SHA digest validation, and finalizes the runtime contract for - // integer and shard evaluation without global `BigInt`. Reuse the + // DataDog/openfeature-js-client#344 through `82bfc2e` and restores + // 32-byte SHA digest validation. Safe integer conversion no longer calls + // global `BigInt`; retain tests for unsafe integers and shard values. Reuse the // production-derived client // fixture from the integration test: one base64 encoding of the canonical // dd-source#34959 protobuf bytes in a version 1 `rules.response` envelope. @@ -36,8 +36,8 @@ export const buildSampleWire = (): string => // The final fixture must preserve protobuf integers as `bigint`, return // `PARSE_ERROR` for unsafe number conversion, tolerate unknown fields, // preserve them through `configurationToString`, return flag-scoped - // `PARSE_ERROR` for an unsupported feature level, and follow the final - // global-`BigInt` runtime requirement. + // `PARSE_ERROR` for an unsupported feature level, and work without the + // global `BigInt` function. rulesBased: { response: JSON.stringify({ createdAt: '2026-07-23T12:00:00.000Z', diff --git a/example/src/flags/sampleOfflineConfiguration.ts b/example/src/flags/sampleOfflineConfiguration.ts index 96672ef5c..82078925e 100644 --- a/example/src/flags/sampleOfflineConfiguration.ts +++ b/example/src/flags/sampleOfflineConfiguration.ts @@ -23,9 +23,9 @@ export const buildSampleWire = (): string => version: 1, // TODO(FFL-2837): Replace this complete legacy `rulesBased` JSON branch // after a published flagging-core release contains - // DataDog/openfeature-js-client#344 through `9f794c7`, restores - // 32-byte SHA digest validation, and finalizes the runtime contract for - // integer and shard evaluation without global `BigInt`. Reuse the + // DataDog/openfeature-js-client#344 through `82bfc2e` and restores + // 32-byte SHA digest validation. Safe integer conversion no longer calls + // global `BigInt`; retain tests for unsafe integers and shard values. Reuse the // production-derived client // fixture from the integration test: one base64 encoding of the canonical // dd-source#34959 protobuf bytes in a version 1 `rules.response` envelope. @@ -36,8 +36,8 @@ export const buildSampleWire = (): string => // The final fixture must preserve protobuf integers as `bigint`, return // `PARSE_ERROR` for unsafe number conversion, tolerate unknown fields, // preserve them through `configurationToString`, return flag-scoped - // `PARSE_ERROR` for an unsupported feature level, and follow the final - // global-`BigInt` runtime requirement. + // `PARSE_ERROR` for an unsupported feature level, and work without the + // global `BigInt` function. rulesBased: { response: JSON.stringify({ createdAt: '2026-07-23T12:00:00.000Z', diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index 647ad0fdb..03e8c093b 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -174,12 +174,14 @@ Safe integers evaluate as OpenFeature numbers. An integer outside the JavaScript safe range returns `PARSE_ERROR`. The SDK does not return a rounded or imprecise value. -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. +`configurationToString` serializes precomputed and rules configurations. +It preserves unknown protobuf fields in the rules response. +Keep the original wire until the published flagging-core dependency provides this contract. Load the configuration before you set the provider. -The provider starts in `ERROR` when it has no usable configuration. +The provider starts in `ERROR` with `PROVIDER_NOT_READY` when no configuration was supplied. +A supplied but unusable configuration reports `PARSE_ERROR`. +A valid matching precomputed branch or valid rules branch remains usable when its sibling branch is invalid. A later valid configuration can recover the provider. Do not call the non-waiting `OpenFeature.setProvider` and then call `setConfiguration`. 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 c92567425..4f3f0998e 100644 --- a/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts @@ -103,14 +103,16 @@ const rulesResponseFor = (flagKey: string) => ({ // TODO(FFL-2837): Replace this legacy `rulesBased` JSON helper after a published // flagging-core release contains DataDog/openfeature-js-client#344 through -// `9f794c7`, restores 32-byte SHA digest validation, and either supports integer -// and shard evaluation without global `BigInt` or declares `BigInt` as a runtime -// requirement. The `9f794c7` smoke test covers only a static boolean without -// `BigInt`. Use canonical raw protobuf bytes produced from the dd-source#34959 +// `82bfc2e` and restores 32-byte SHA digest validation. Safe integer conversion +// no longer calls global `BigInt`. The `82bfc2e` smoke test covers a static +// boolean and a safe integer, but not unsafe integers or shard values. Use +// canonical raw protobuf bytes produced from the dd-source#34959 // client-distribution path. Record dd-source#40304 commit `071c4ad` as the schema // revision and dd-source#34959 as the service producer path. -// PR #336 through `33113d2` does not change this wire contract. Keep the existing -// React Native provider name and its Ready-before-ConfigurationChanged recovery order. +// PR #336 through `4d0f24e` does not change this wire contract. It defines +// valid-sibling and parse-error precedence and the `{ message, errorCode? }` +// provider error event. Keep the existing React Native provider name and its +// Ready-before-ConfigurationChanged recovery order. // Put one base64 encoding of those bytes in a version 1 `rules.response` envelope, // verify that decoding returns the original bytes, and record the source revision. // Use the upstream `@datadog/flagging-core/configuration` parser. Do not copy the @@ -224,6 +226,24 @@ describe('DatadogOfflineOpenFeatureProvider (integration, real FlagsClient + Ope expect(nativeFlags.setEvaluationContext).not.toHaveBeenCalled(); }); + it('reports PARSE_ERROR for a supplied unusable configuration', async () => { + const { domain, clientName } = freshNames(); + const provider = new DatadogOfflineOpenFeatureProvider({ clientName }); + provider.setConfiguration(configurationFromString('not json')); + + await expect( + OpenFeature.setProviderAndWait(domain, provider) + ).rejects.toMatchObject({ code: ErrorCode.PARSE_ERROR }); + + const client = OpenFeature.getClient(domain); + expect(client.providerStatus).toBe(ProviderStatus.ERROR); + expect(client.getBooleanDetails('new-feature', false)).toMatchObject({ + value: false, + errorCode: ErrorCode.PARSE_ERROR, + errorMessage: 'Invalid flags configuration wire format' + }); + }); + it('does not synthesize an empty targeting key when a shard requires 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 743512cef..2c88884d1 100644 --- a/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts @@ -8,6 +8,7 @@ import { ErrorCode, GeneralError, InvalidContextError, + ParseError, ProviderEvents, ProviderNotReadyError } from '@openfeature/web-sdk'; @@ -17,6 +18,7 @@ import { DatadogOfflineOpenFeatureProvider } from '../offlineProvider'; const READY = { status: 'ready' as const }; const mismatch = { status: 'error' as const, errorCode: 'INVALID_CONTEXT' }; const notReady = { status: 'error' as const, errorCode: 'PROVIDER_NOT_READY' }; +const parseError = { status: 'error' as const, errorCode: 'PARSE_ERROR' }; const generalError = { status: 'error' as const, errorCode: 'GENERAL' }; const mockFlagsClient = { @@ -206,22 +208,36 @@ describe('DatadogOfflineOpenFeatureProvider', () => { expect(emitSpy).not.toHaveBeenCalledWith(ProviderEvents.Ready); }); - it('emits PROVIDER_ERROR with a top-level errorCode on an invalid configuration', () => { + it('emits PROVIDER_ERROR with a top-level parse error code on an invalid configuration', () => { const provider = new DatadogOfflineOpenFeatureProvider(); const emitSpy = jest.spyOn(provider.events, 'emit'); - mockFlagsClient.setConfiguration.mockReturnValueOnce(generalError); + mockFlagsClient.setConfiguration.mockReturnValueOnce(parseError); provider.setConfiguration({} as never); expect(emitSpy).toHaveBeenCalledWith( ProviderEvents.Error, expect.objectContaining({ message: expect.any(String), - errorCode: ErrorCode.GENERAL + errorCode: ErrorCode.PARSE_ERROR }) ); }); + it('preserves a general code for an unexpected configuration error', () => { + const provider = new DatadogOfflineOpenFeatureProvider(); + const emitSpy = jest.spyOn(provider.events, 'emit'); + + mockFlagsClient.setConfiguration.mockReturnValueOnce(generalError); + provider.setConfiguration({} as never); + + expect(emitSpy).toHaveBeenCalledWith(ProviderEvents.Error, { + message: + 'The Datadog offline provider cannot serve the loaded configuration for the current context.', + errorCode: ErrorCode.GENERAL + }); + }); + it('recovers on a later valid configuration, emitting READY then CONFIGURATION_CHANGED', () => { const provider = new DatadogOfflineOpenFeatureProvider(); const emitSpy = jest.spyOn(provider.events, 'emit'); @@ -248,15 +264,24 @@ describe('DatadogOfflineOpenFeatureProvider', () => { it('rejects initialize when a config was loaded (pre-registration) and is invalid', async () => { const provider = new DatadogOfflineOpenFeatureProvider(); - mockFlagsClient.setConfiguration.mockReturnValueOnce(generalError); + mockFlagsClient.setConfiguration.mockReturnValueOnce(parseError); provider.setConfiguration({} as never); // 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.setEvaluationContextWithoutFetching.mockReturnValueOnce( + parseError + ); + await expect(provider.initialize({})).rejects.toThrow(ParseError); + }); + + it('maps unexpected initialize errors to GeneralError', async () => { + const provider = new DatadogOfflineOpenFeatureProvider(); mockFlagsClient.setEvaluationContextWithoutFetching.mockReturnValueOnce( generalError ); + await expect(provider.initialize({})).rejects.toThrow(GeneralError); }); diff --git a/packages/react-native-openfeature/src/offlineProvider.ts b/packages/react-native-openfeature/src/offlineProvider.ts index b8510ed8c..6329beffc 100644 --- a/packages/react-native-openfeature/src/offlineProvider.ts +++ b/packages/react-native-openfeature/src/offlineProvider.ts @@ -12,6 +12,7 @@ import { ErrorCode, GeneralError, InvalidContextError, + ParseError, ProviderEvents, ProviderNotReadyError } from '@openfeature/web-sdk'; @@ -40,6 +41,7 @@ type ProviderErrorEvent = { message: string; errorCode: ErrorCode }; const OF_ERROR_CODE: Record = { INVALID_CONTEXT: ErrorCode.INVALID_CONTEXT, PROVIDER_NOT_READY: ErrorCode.PROVIDER_NOT_READY, + PARSE_ERROR: ErrorCode.PARSE_ERROR, GENERAL: ErrorCode.GENERAL }; @@ -186,6 +188,10 @@ export class DatadogOfflineOpenFeatureProvider extends DatadogCoreOpenFeaturePro return new ProviderNotReadyError( 'The Datadog offline provider has no configuration loaded. Provide one via setConfiguration.' ); + case 'PARSE_ERROR': + return new ParseError( + 'The Datadog offline provider cannot parse the loaded configuration.' + ); default: return new GeneralError( 'The Datadog offline provider cannot serve the loaded configuration.' From 838e3521513575ee88f457ca0fa90b9041e9721d Mon Sep 17 00:00:00 2001 From: Blake Date: Mon, 10 Aug 2026 14:47:50 -0400 Subject: [PATCH 13/14] docs(openfeature): refresh upstream fixture TODOs --- .../flags/sampleOfflineConfiguration.ts | 8 +++++--- example/src/flags/sampleOfflineConfiguration.ts | 8 +++++--- .../src/__tests__/offlineProvider.integration.test.ts | 10 ++++++---- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/example-new-architecture/flags/sampleOfflineConfiguration.ts b/example-new-architecture/flags/sampleOfflineConfiguration.ts index 9906ed20d..17d15a286 100644 --- a/example-new-architecture/flags/sampleOfflineConfiguration.ts +++ b/example-new-architecture/flags/sampleOfflineConfiguration.ts @@ -23,10 +23,12 @@ export const buildSampleWire = (): string => version: 1, // TODO(FFL-2837): Replace this complete legacy `rulesBased` JSON branch // after a published flagging-core release contains - // DataDog/openfeature-js-client#344 through `82bfc2e` and restores + // DataDog/openfeature-js-client#344 through `939da97` and restores // 32-byte SHA digest validation. Safe integer conversion no longer calls - // global `BigInt`; retain tests for unsafe integers and shard values. Reuse the - // production-derived client + // global `BigInt`; retain tests for unsafe integers and shard values. + // Commits `ab22ad0` and `939da97` only refresh generated Node-server + // declarations and isolate browser provider tests. They do not change the + // portable wire or runtime contract. Reuse the production-derived client // fixture from the integration test: one base64 encoding of the canonical // dd-source#34959 protobuf bytes in a version 1 `rules.response` envelope. // Record dd-source#40304 commit `071c4ad` as its schema revision. Let the diff --git a/example/src/flags/sampleOfflineConfiguration.ts b/example/src/flags/sampleOfflineConfiguration.ts index 82078925e..53da54a44 100644 --- a/example/src/flags/sampleOfflineConfiguration.ts +++ b/example/src/flags/sampleOfflineConfiguration.ts @@ -23,10 +23,12 @@ export const buildSampleWire = (): string => version: 1, // TODO(FFL-2837): Replace this complete legacy `rulesBased` JSON branch // after a published flagging-core release contains - // DataDog/openfeature-js-client#344 through `82bfc2e` and restores + // DataDog/openfeature-js-client#344 through `939da97` and restores // 32-byte SHA digest validation. Safe integer conversion no longer calls - // global `BigInt`; retain tests for unsafe integers and shard values. Reuse the - // production-derived client + // global `BigInt`; retain tests for unsafe integers and shard values. + // Commits `ab22ad0` and `939da97` only refresh generated Node-server + // declarations and isolate browser provider tests. They do not change the + // portable wire or runtime contract. Reuse the production-derived client // fixture from the integration test: one base64 encoding of the canonical // dd-source#34959 protobuf bytes in a version 1 `rules.response` envelope. // Record dd-source#40304 commit `071c4ad` as its schema revision. Let the 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 4f3f0998e..de4670bfa 100644 --- a/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts @@ -103,13 +103,15 @@ const rulesResponseFor = (flagKey: string) => ({ // TODO(FFL-2837): Replace this legacy `rulesBased` JSON helper after a published // flagging-core release contains DataDog/openfeature-js-client#344 through -// `82bfc2e` and restores 32-byte SHA digest validation. Safe integer conversion -// no longer calls global `BigInt`. The `82bfc2e` smoke test covers a static -// boolean and a safe integer, but not unsafe integers or shard values. Use +// `939da97` and restores 32-byte SHA digest validation. Safe integer conversion +// no longer calls global `BigInt`. The upstream tests cover a static boolean and +// a safe integer, but not unsafe integers or shard values. Commits `ab22ad0` and +// `939da97` only refresh generated Node-server declarations and isolate browser +// provider tests; they do not change this wire or runtime contract. Use // canonical raw protobuf bytes produced from the dd-source#34959 // client-distribution path. Record dd-source#40304 commit `071c4ad` as the schema // revision and dd-source#34959 as the service producer path. -// PR #336 through `4d0f24e` does not change this wire contract. It defines +// PR #336 through `6d3d6a4` does not change this wire contract. It defines // valid-sibling and parse-error precedence and the `{ message, errorCode? }` // provider error event. Keep the existing React Native provider name and its // Ready-before-ConfigurationChanged recovery order. From c7fee2440d323305ca78096716caa3985d3da3fd Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 11 Aug 2026 16:05:35 -0400 Subject: [PATCH 14/14] docs(openfeature): refresh rebased fixture TODOs --- .../flags/sampleOfflineConfiguration.ts | 9 +++++---- example/src/flags/sampleOfflineConfiguration.ts | 9 +++++---- .../__tests__/offlineProvider.integration.test.ts | 12 +++++++----- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/example-new-architecture/flags/sampleOfflineConfiguration.ts b/example-new-architecture/flags/sampleOfflineConfiguration.ts index 17d15a286..4c91c2f3e 100644 --- a/example-new-architecture/flags/sampleOfflineConfiguration.ts +++ b/example-new-architecture/flags/sampleOfflineConfiguration.ts @@ -23,12 +23,13 @@ export const buildSampleWire = (): string => version: 1, // TODO(FFL-2837): Replace this complete legacy `rulesBased` JSON branch // after a published flagging-core release contains - // DataDog/openfeature-js-client#344 through `939da97` and restores + // DataDog/openfeature-js-client#344 through `03cde21` and restores // 32-byte SHA digest validation. Safe integer conversion no longer calls // global `BigInt`; retain tests for unsafe integers and shard values. - // Commits `ab22ad0` and `939da97` only refresh generated Node-server - // declarations and isolate browser provider tests. They do not change the - // portable wire or runtime contract. Reuse the production-derived client + // The `03cde21` tree is identical to the previous `939da97` tree. Commits + // `1db13d4` and `03cde21` only refresh generated Node-server declarations + // and isolate browser provider tests. They do not change the portable wire + // or runtime contract. Reuse the production-derived client // fixture from the integration test: one base64 encoding of the canonical // dd-source#34959 protobuf bytes in a version 1 `rules.response` envelope. // Record dd-source#40304 commit `071c4ad` as its schema revision. Let the diff --git a/example/src/flags/sampleOfflineConfiguration.ts b/example/src/flags/sampleOfflineConfiguration.ts index 53da54a44..375205f22 100644 --- a/example/src/flags/sampleOfflineConfiguration.ts +++ b/example/src/flags/sampleOfflineConfiguration.ts @@ -23,12 +23,13 @@ export const buildSampleWire = (): string => version: 1, // TODO(FFL-2837): Replace this complete legacy `rulesBased` JSON branch // after a published flagging-core release contains - // DataDog/openfeature-js-client#344 through `939da97` and restores + // DataDog/openfeature-js-client#344 through `03cde21` and restores // 32-byte SHA digest validation. Safe integer conversion no longer calls // global `BigInt`; retain tests for unsafe integers and shard values. - // Commits `ab22ad0` and `939da97` only refresh generated Node-server - // declarations and isolate browser provider tests. They do not change the - // portable wire or runtime contract. Reuse the production-derived client + // The `03cde21` tree is identical to the previous `939da97` tree. Commits + // `1db13d4` and `03cde21` only refresh generated Node-server declarations + // and isolate browser provider tests. They do not change the portable wire + // or runtime contract. Reuse the production-derived client // fixture from the integration test: one base64 encoding of the canonical // dd-source#34959 protobuf bytes in a version 1 `rules.response` envelope. // Record dd-source#40304 commit `071c4ad` as its schema revision. Let the 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 de4670bfa..1e4794ead 100644 --- a/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts @@ -103,15 +103,17 @@ const rulesResponseFor = (flagKey: string) => ({ // TODO(FFL-2837): Replace this legacy `rulesBased` JSON helper after a published // flagging-core release contains DataDog/openfeature-js-client#344 through -// `939da97` and restores 32-byte SHA digest validation. Safe integer conversion +// `03cde21` and restores 32-byte SHA digest validation. Safe integer conversion // no longer calls global `BigInt`. The upstream tests cover a static boolean and -// a safe integer, but not unsafe integers or shard values. Commits `ab22ad0` and -// `939da97` only refresh generated Node-server declarations and isolate browser -// provider tests; they do not change this wire or runtime contract. Use +// a safe integer, but not unsafe integers or shard values. The `03cde21` tree is +// identical to the previous `939da97` tree. Commits `1db13d4` and `03cde21` only +// refresh generated Node-server declarations and isolate browser provider tests; +// they do not change this wire or runtime contract. Use // canonical raw protobuf bytes produced from the dd-source#34959 // client-distribution path. Record dd-source#40304 commit `071c4ad` as the schema // revision and dd-source#34959 as the service producer path. -// PR #336 through `6d3d6a4` does not change this wire contract. It defines +// PR #336 through `772167b` does not change this wire contract. Its tree is +// identical to the previous `6d3d6a4` tree. It defines // valid-sibling and parse-error precedence and the `{ message, errorCode? }` // provider error event. Keep the existing React Native provider name and its // Ready-before-ConfigurationChanged recovery order.