diff --git a/package-lock.json b/package-lock.json index e0a06a628..c66d2fd15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23779,7 +23779,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "dev": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -24703,7 +24702,7 @@ }, "packages/shared": { "name": "@openfeature/core", - "version": "1.8.0", + "version": "1.8.1", "license": "Apache-2.0", "devDependencies": {} }, diff --git a/packages/react/README.md b/packages/react/README.md index c5f326407..1eed7b547 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -50,6 +50,7 @@ In addition to the feature provided by the [web sdk](https://openfeature.dev/doc - [Usage](#usage) - [OpenFeatureProvider context provider](#openfeatureprovider-context-provider) - [Evaluation hooks](#evaluation-hooks) + - [Declarative components](#declarative-components) - [Multiple Providers and Domains](#multiple-providers-and-domains) - [Re-rendering with Context Changes](#re-rendering-with-context-changes) - [Re-rendering with Flag Configuration Changes](#re-rendering-with-flag-configuration-changes) @@ -170,6 +171,72 @@ const { } = useBooleanFlagDetails('new-message', false); ``` +#### Declarative components + +The React SDK includes declarative components for feature flagging that provide a more JSX-native approach to conditional rendering. + +##### FeatureFlag Component + +The `FeatureFlag` component conditionally renders its children based on feature flag evaluation: + +```tsx +import { FeatureFlag } from '@openfeature/react-sdk'; + +function App() { + return ( + + {/* Basic usage - renders children when flag is truthy */} + + + + + {/* Match specific values */} + + + + + {/* Boolean flag with fallback */} + } + > + + + + {/* Custom predicate function for complex matching */} + actual.value.includes('beta')} + > + + + + {/* Function as children for accessing flag details */} + + {(flagDetails) => ( + + )} + + + ); +} +``` + +The `FeatureFlag` component supports the following props: + +- **`flagKey`** (required): The feature flag key to evaluate +- **`defaultValue`** (required): Default value when the flag is not available +- **`match`** (optional): Value to match against the flag value. By default, strict equality (===) is used for comparison +- **`predicate`** (optional): Custom function for matching logic that receives the expected value and evaluation details +- **`children`**: Content to render when condition is met (can be JSX or a function receiving flag details) +- **`fallback`** (optional): Content to render when condition is not met + #### Multiple Providers and Domains Multiple providers can be used by passing a `domain` to the `OpenFeatureProvider`: @@ -306,8 +373,8 @@ function MyComponent() { ### Testing The React SDK includes a built-in context provider for testing. -This allows you to easily test components that use evaluation hooks, such as `useFlag`. -If you try to test a component (in this case, `MyComponent`) which uses an evaluation hook, you might see an error message like: +This allows you to easily test components that use evaluation hooks (such as `useFlag`) or declarative components (such as `FeatureFlag`). +If you try to test a component (in this case, `MyComponent`) which uses feature flags, you might see an error message like: > No OpenFeature client available - components using OpenFeature must be wrapped with an ``. @@ -328,6 +395,16 @@ If you'd like to control the values returned by the evaluation hooks, you can pa + +// testing declarative FeatureFlag components + + + + + + + + ``` Additionally, you can pass an artificial delay for the provider startup to test your suspense boundaries or loaders/spinners impacted by feature flags: diff --git a/packages/react/src/declarative/FeatureFlag.tsx b/packages/react/src/declarative/FeatureFlag.tsx new file mode 100644 index 000000000..f1cd967b9 --- /dev/null +++ b/packages/react/src/declarative/FeatureFlag.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { useFlag } from '../evaluation'; +import type { FlagQuery } from '../query'; +import type { FlagValue, EvaluationDetails } from '@openfeature/core'; + +/** + * Default predicate function that checks if the expected value equals the actual flag value. + * @param {T} expected The expected value to match against + * @param {EvaluationDetails} actual The evaluation details containing the actual flag value + * @returns {boolean} true if the values match, false otherwise + */ +function equals(expected: T, actual: EvaluationDetails): boolean { + return expected === actual.value; +} + +/** + * Props for the FeatureFlag component that conditionally renders content based on feature flag state. + * @interface FeatureFlagProps + */ +interface FeatureFlagProps { + /** + * The key of the feature flag to evaluate. + */ + flagKey: string; + + /** + * Optional value to match against the feature flag value. + * If provided, the component will only render children when the flag value matches this value. + * By default, strict equality (===) is used for comparison. + * If a boolean, it will check if the flag is enabled (true) or disabled (false). + * If a string, it will check if the flag variant equals this string. + */ + match?: T; + + /** + * Optional predicate function for custom matching logic. + * If provided, this function will be used instead of the default equality check. + * @param expected The expected value (from match prop) + * @param actual The evaluation details + * @returns true if the condition is met, false otherwise + */ + predicate?: (expected: T | undefined, actual: EvaluationDetails) => boolean; + + /** + * Default value to use when the feature flag is not found. + */ + defaultValue: T; + + /** + * Content to render when the feature flag condition is met. + * Can be a React node or a function that receives flag query details and returns a React node. + */ + children: React.ReactNode | ((details: FlagQuery) => React.ReactNode); + + /** + * Optional content to render when the feature flag condition is not met. + * Can be a React node or a function that receives evaluation details and returns a React node. + */ + fallback?: React.ReactNode | ((details: EvaluationDetails) => React.ReactNode); +} + +/** + * FeatureFlag component that conditionally renders its children based on the evaluation of a feature flag. + * @param {FeatureFlagProps} props The properties for the FeatureFlag component. + * @returns {React.ReactElement | null} The rendered component or null if the feature is not enabled. + */ +export function FeatureFlag({ + flagKey, + match, + predicate, + defaultValue, + children, + fallback = null, +}: FeatureFlagProps): React.ReactElement | null { + const details = useFlag(flagKey, defaultValue, { + updateOnContextChanged: true, + }); + + // If the flag evaluation failed, we render the fallback + if (details.reason === 'ERROR') { + const fallbackNode: React.ReactNode = typeof fallback === 'function' ? fallback(details.details as EvaluationDetails) : fallback; + return <>{fallbackNode}; + } + + // Use custom predicate if provided, otherwise use default matching logic + let shouldRender = false; + if (predicate) { + shouldRender = predicate(match, details.details as EvaluationDetails); + } else if (match !== undefined) { + // Default behavior: check if match value equals flag value + shouldRender = equals(match, details.details as EvaluationDetails); + } else { + // If no match value is provided, render if flag is truthy + shouldRender = Boolean(details.value); + } + + if (shouldRender) { + const childNode: React.ReactNode = typeof children === 'function' ? children(details as FlagQuery) : children; + return <>{childNode}; + } + + const fallbackNode: React.ReactNode = typeof fallback === 'function' ? fallback(details.details as EvaluationDetails) : fallback; + return <>{fallbackNode}; +} diff --git a/packages/react/src/declarative/index.ts b/packages/react/src/declarative/index.ts new file mode 100644 index 000000000..5baeee7ca --- /dev/null +++ b/packages/react/src/declarative/index.ts @@ -0,0 +1 @@ +export * from './FeatureFlag'; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index e08a7ae63..9859e26d4 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,3 +1,4 @@ +export * from './declarative'; export * from './evaluation'; export * from './query'; export * from './provider'; diff --git a/packages/react/test/declarative.spec.tsx b/packages/react/test/declarative.spec.tsx new file mode 100644 index 000000000..fcc803631 --- /dev/null +++ b/packages/react/test/declarative.spec.tsx @@ -0,0 +1,209 @@ +import React from 'react'; +import '@testing-library/jest-dom'; // see: https://testing-library.com/docs/react-testing-library/setup +import { render, screen } from '@testing-library/react'; +import { FeatureFlag } from '../src/declarative/FeatureFlag'; // Assuming Feature.tsx is in the same directory or adjust path +import { InMemoryProvider, OpenFeature, OpenFeatureProvider } from '../src'; +import type { EvaluationDetails } from '@openfeature/core'; + +describe('Feature Component', () => { + const EVALUATION = 'evaluation'; + const MISSING_FLAG_KEY = 'missing-flag'; + const BOOL_FLAG_KEY = 'boolean-flag'; + const BOOL_FLAG_NEGATE_KEY = 'boolean-flag-negate'; + const BOOL_FLAG_VARIANT = 'on'; + const BOOL_FLAG_VALUE = true; + const STRING_FLAG_KEY = 'string-flag'; + const STRING_FLAG_VARIANT = 'greeting'; + const STRING_FLAG_VALUE = 'hi'; + + const FLAG_CONFIG: ConstructorParameters[0] = { + [BOOL_FLAG_KEY]: { + disabled: false, + variants: { + [BOOL_FLAG_VARIANT]: BOOL_FLAG_VALUE, + off: false, + }, + defaultVariant: BOOL_FLAG_VARIANT, + }, + [BOOL_FLAG_NEGATE_KEY]: { + disabled: false, + variants: { + [BOOL_FLAG_VARIANT]: BOOL_FLAG_VALUE, + off: false, + }, + defaultVariant: 'off', + }, + [STRING_FLAG_KEY]: { + disabled: false, + variants: { + [STRING_FLAG_VARIANT]: STRING_FLAG_VALUE, + parting: 'bye', + }, + defaultVariant: STRING_FLAG_VARIANT, + } + }; + + const makeProvider = () => { + return new InMemoryProvider(FLAG_CONFIG); + }; + + OpenFeature.setProvider(EVALUATION, makeProvider()); + + const childText = 'Feature is active'; + const ChildComponent = () =>
{childText}
; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('', () => { + it('should not show the feature component if the flag is not enabled', () => { + render( + + + + + , + ); + + expect(screen.queryByText(childText)).toBeInTheDocument(); + }); + + it('should fallback when provided', () => { + render( + + Fallback}> + + + , + ); + + expect(screen.queryByText('Fallback')).toBeInTheDocument(); + + screen.debug(); + }); + + it('should handle showing multivariate flags with string match', () => { + render( + + + + + , + ); + + expect(screen.queryByText(childText)).toBeInTheDocument(); + }); + + it('should support custom predicate function', () => { + const customPredicate = (expected: boolean | undefined, actual: { value: boolean }) => { + // Custom logic: render if flag is NOT the expected value (negation) + return expected !== undefined ? actual.value !== expected : !actual.value; + }; + + render( + + + + + , + ); + + expect(screen.queryByText(childText)).toBeInTheDocument(); + }); + + it('should render children when no match is provided and flag is truthy', () => { + render( + + + + + , + ); + + expect(screen.queryByText(childText)).toBeInTheDocument(); + }); + + it('should not render children when no match is provided and flag is falsy', () => { + render( + + + + + , + ); + + expect(screen.queryByText(childText)).not.toBeInTheDocument(); + }); + + it('should support function-based fallback with EvaluationDetails', () => { + const fallbackFunction = jest.fn((details: EvaluationDetails) =>
Fallback: {details.flagKey}
); + + render( + + + + + , + ); + + expect(fallbackFunction).toHaveBeenCalled(); + expect(fallbackFunction).toHaveBeenCalledWith(expect.objectContaining({ + flagKey: MISSING_FLAG_KEY + })); + expect(screen.queryByText(`Fallback: ${MISSING_FLAG_KEY}`)).toBeInTheDocument(); + }); + + it('should pass correct EvaluationDetails to function-based fallback', () => { + const fallbackFunction = jest.fn((details: EvaluationDetails) => { + return
Flag: {details.flagKey}, Value: {String(details.value)}, Reason: {details.reason}
; + }); + + render( + + + + + , + ); + + expect(fallbackFunction).toHaveBeenCalledWith(expect.objectContaining({ + flagKey: MISSING_FLAG_KEY, + value: false, + reason: expect.any(String) + })); + }); + + it('should support function-based fallback for error conditions', () => { + // Create a provider that will cause an error + const errorProvider = new InMemoryProvider({}); + OpenFeature.setProvider('error-test', errorProvider); + + const fallbackFunction = jest.fn((details: EvaluationDetails) =>
Error fallback: {details.reason}
); + + render( + + + + + , + ); + + expect(fallbackFunction).toHaveBeenCalled(); + expect(screen.queryByText(childText)).not.toBeInTheDocument(); + }); + + it('should render static fallback when fallback is not a function', () => { + render( + + Static fallback}> + + + , + ); + + expect(screen.queryByText('Static fallback')).toBeInTheDocument(); + expect(screen.queryByText(childText)).not.toBeInTheDocument(); + }); + }); +});