diff --git a/.prettierignore b/.prettierignore index 0d054923e..a9c29a7d9 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,5 @@ dist/ node_modules/ +test-harness/ package-lock.json CHANGELOG.md diff --git a/packages/react/README.md b/packages/react/README.md index d8e1fc434..14d498e7a 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -50,6 +50,8 @@ 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) + - [FeatureFlag Component](#featureflag-component) - [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) @@ -166,6 +168,68 @@ import { useBooleanFlagDetails } from '@openfeature/react-sdk'; const { value, variant, reason, flagMetadata } = 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 */} + !!expected && actual.value.includes(expected)} + > + + + + {/* Function as children for accessing flag details */} + + {({ value, reason }) => ( + + value is {value}, reason is {reason?.toString()} + + )} + + + ); +} +``` + +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 +- **`matchValue`** (required, except for boolean flags): Value to match against the flag value. By default, an optimized deep-comparison function is used. +- **`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`: @@ -308,8 +372,8 @@ The [OpenFeature debounce hook](https://github.com/open-feature/js-sdk-contrib/t ### 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 ``. @@ -330,6 +394,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..d318f3e46 --- /dev/null +++ b/packages/react/src/declarative/FeatureFlag.tsx @@ -0,0 +1,124 @@ +import React from 'react'; +import { useFlag } from '../evaluation'; +import type { FlagQuery } from '../query'; +import type { FlagValue, EvaluationDetails } from '@openfeature/core'; +import { isEqual } from '../internal'; + +/** + * 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 isEqual(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 predicate function for custom matching logic. + * If provided, this function will be used instead of the default equality check. + * @param matchValue The value to match (matchValue prop) + * @param details The evaluation details + * @returns true if the condition is met, false otherwise + */ + predicate?: (matchValue: T | undefined, details: EvaluationDetails) => boolean; + + /** + * 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); +} + +/** + * Configuration for matching flag values. + * For boolean flags, `match` is optional (defaults to checking truthiness). + * For non-boolean flags (string, number, object), `match` is required to determine when to render. + */ +type FeatureFlagMatchConfig = { + /** + * Default value to use when the feature flag is not found. + */ + defaultValue: T; +} & (T extends boolean + ? { + /** + * Optional value to match against the feature flag value. + */ + matchValue?: T | undefined; + } + : { + /** + * Value to match against the feature flag value. + * Required for non-boolean flags to determine when children should render. + * By default, strict equality is used for comparison. + */ + matchValue: T; + }); + +type FeatureFlagComponentProps = FeatureFlagProps & FeatureFlagMatchConfig; + +/** + * @experimental This API is experimental, and is subject to change. + * FeatureFlag component that conditionally renders its children based on the evaluation of a feature flag. + * @param {FeatureFlagComponentProps} 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, + matchValue, + predicate, + defaultValue, + children, + fallback = null, +}: FeatureFlagComponentProps): 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(matchValue as T, details.details as EvaluationDetails); + } else if (matchValue !== undefined) { + // Default behavior: check if match value equals flag value + shouldRender = equals(matchValue, details.details as EvaluationDetails); + } else if (details.type === 'boolean') { + // If no match value is provided, render if flag is truthy + shouldRender = Boolean(details.value); + } else { + shouldRender = false; + } + + 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..f5c536a96 --- /dev/null +++ b/packages/react/test/declarative.spec.tsx @@ -0,0 +1,237 @@ +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 not show a non-boolean feature flag without match', () => { + render( + + {/* @ts-expect-error-next-line /* Intentional to test missing match prop */} + + + + , + ); + + expect(screen.queryByText(childText)).not.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(); + }); + }); +});