diff --git a/packages/react/README.md b/packages/react/README.md index 915b511b8..4ee796083 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -50,10 +50,13 @@ In addition to the feature provided by the [web sdk](https://openfeature.dev/doc - [yarn](#yarn) - [Required peer dependencies](#required-peer-dependencies) - [Usage](#usage) - - [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) - - [Suspense Support](#suspense-support) + - [OpenFeatureProvider context provider](#openfeatureprovider-context-provider) + - [Evaluation hooks](#evaluation-hooks) + - [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) + - [Suspense Support](#suspense-support) +- [FAQ and troubleshooting](#faq-and-troubleshooting) - [Resources](#resources) ## Quick start @@ -87,7 +90,9 @@ The following list contains the peer dependencies of `@openfeature/react-sdk` wi ### Usage -The `OpenFeatureProvider` represents a scope for feature flag evaluations within a React application. +#### OpenFeatureProvider context provider + +The `OpenFeatureProvider` is a [React context provider](https://react.dev/reference/react/createContext#provider) which represents a scope for feature flag evaluations within a React application. It binds an OpenFeature client to all evaluations within child components, and allows the use of evaluation hooks. The example below shows how to use the `OpenFeatureProvider` with OpenFeature's `InMemoryProvider`. @@ -123,9 +128,15 @@ function App() { ); } +``` + +#### Evaluation hooks +Within the provider, you can use the various evaluation hooks to evaluate flags. + +```tsx function Page() { - // Use the "query-style" flag evaluation hook. + // Use the "query-style" flag evaluation hook, specifying a flag-key and a default value. const { value: showNewMessage } = useFlag('new-message', true); return (
@@ -135,9 +146,8 @@ function Page() {
) } - -export default App; ``` + You can use the strongly-typed flag value and flag evaluation detail hooks as well, if you prefer. ```tsx @@ -159,8 +169,7 @@ const { } = useBooleanFlagDetails('new-message', false); ``` -### Multiple Providers and Domains - +#### Multiple Providers and Domains Multiple providers can be used by passing a `domain` to the `OpenFeatureProvider`: @@ -183,11 +192,11 @@ OpenFeature.getClient('my-domain'); For more information about `domains`, refer to the [web SDK](https://github.com/open-feature/js-sdk/blob/main/packages/client/README.md). -### Re-rendering with Context Changes +#### Re-rendering with Context Changes By default, if the OpenFeature [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context) is modified, components will be re-rendered. This is useful in cases where flag values are dependant on user-attributes or other application state (user logged in, items in card, etc). -You can disable this feature in the hook options: +You can disable this feature in the hook options (or in the [OpenFeatureProvider](#openfeatureprovider-context-provider)): ```tsx function Page() { @@ -200,11 +209,11 @@ function Page() { For more information about how evaluation context works in the React SDK, see the documentation on OpenFeature's [static context SDK paradigm](https://openfeature.dev/specification/glossary/#static-context-paradigm). -### Re-rendering with Flag Configuration Changes +#### Re-rendering with Flag Configuration Changes By default, if the underlying provider emits a `ConfigurationChanged` event, components will be re-rendered. This is useful if you want your UI to immediately reflect changes in the backend flag configuration. -You can disable this feature in the hook options: +You can disable this feature in the hook options (or in the [OpenFeatureProvider](#openfeatureprovider-context-provider)): ```tsx function Page() { @@ -217,11 +226,11 @@ function Page() { Note that if your provider doesn't support updates, this configuration has no impact. -### Suspense Support +#### Suspense Support Frequently, providers need to perform some initial startup tasks. -It may be desireable not to display components with feature flags until this is complete. -Built-in [suspense](https://react.dev/reference/react/Suspense) support makes this easy: +It may be desireable not to display components with feature flags until this is complete, or when the context changes. +Built-in [suspense](https://react.dev/reference/react/Suspense) support makes this easy. ```tsx function Content() { @@ -252,6 +261,37 @@ function Fallback() { // component to render before READY. return

Waiting for provider to be ready...

; } + +``` + +This can be disabled in the hook options (or in the [OpenFeatureProvider](#openfeatureprovider-context-provider)). + +## FAQ and troubleshooting + +> I get an error that says something like: `A React component suspended while rendering, but no fallback UI was specified.` + +The OpenFeature React SDK features built-in [suspense support](#suspense-support). +This means that it will render your loading fallback automatically while the your provider starts up, and during context reconciliation for any of your components using feature flags! +However, you will see this error if you neglect to create a suspense boundary around any components using feature flags; add a suspense boundary to resolve this issue. +Alternatively, you can disable this feature by setting `suspendWhileReconciling=false` and `suspendUntilReady=false` in the [evaluation hooks](#evaluation-hooks) or the [OpenFeatureProvider](#openfeatureprovider-context-provider) (which applies to all evaluation hooks in child components). + +> I get odd rendering issues, or errors when components mount, if I use the suspense features. + +In React 16/17's "Legacy Suspense", when a component suspends, its sibling components initially mount and then are hidden. +This can cause surprising effects and inconsistencies if sibling components are rendered while the provider is still getting ready. +To fix this, you can upgrade to React 18, which uses "Concurrent Suspense", in which siblings are not mounted until their suspended sibling resolves. +Alternatively, if you cannot upgrade to React 18, you can use the `useWhenProviderReady` utility hook in any sibling components to prevent them from mounting until the provider is ready. + +> I am using multiple `OpenFeatureProvider` contexts, but they are sharing the same provider or evaluation context. Why? + +The `OpenFeatureProvider` binds a `client` to all child components, but the provider and context associated with that client is controlled by the `domain` parameter. +This is consistent with all OpenFeature SDKs. +To scope an OpenFeatureProvider to a particular provider/context set the `domain` parameter on your `OpenFeatureProvider`: + +```tsx + + + ``` ## Resources diff --git a/packages/react/src/common/options.ts b/packages/react/src/common/options.ts new file mode 100644 index 000000000..d950c7e4a --- /dev/null +++ b/packages/react/src/common/options.ts @@ -0,0 +1,77 @@ +import { FlagEvaluationOptions } from '@openfeature/web-sdk'; + +export type ReactFlagEvaluationOptions = ({ + /** + * Enable or disable all suspense functionality. + * Cannot be used in conjunction with `suspendUntilReady` and `suspendWhileReconciling` options. + */ + suspend?: boolean; + suspendUntilReady?: never; + suspendWhileReconciling?: never; +} | { + /** + * Suspend flag evaluations while the provider is not ready. + * Set to false if you don't want to show suspense fallbacks until the provider is initialized. + * Defaults to true. + * Cannot be used in conjunction with `suspend` option. + */ + suspendUntilReady?: boolean; + /** + * Suspend flag evaluations while the provider's context is being reconciled. + * Set to true if you want to show suspense fallbacks while flags are re-evaluated after context changes. + * Defaults to true. + * Cannot be used in conjunction with `suspend` option. + */ + suspendWhileReconciling?: boolean; + suspend?: never; +}) & { + /** + * Update the component if the provider emits a ConfigurationChanged event. + * Set to false to prevent components from re-rendering when flag value changes + * are received by the associated provider. + * Defaults to true. + */ + updateOnConfigurationChanged?: boolean; + /** + * Update the component when the OpenFeature context changes. + * Set to false to prevent components from re-rendering when attributes which + * may be factors in flag evaluation change. + * Defaults to true. + */ + updateOnContextChanged?: boolean; +} & FlagEvaluationOptions; + +export type NormalizedOptions = Omit; + +/** + * Default options. + * DO NOT EXPORT PUBLICLY + * @internal + */ +export const DEFAULT_OPTIONS: ReactFlagEvaluationOptions = { + updateOnContextChanged: true, + updateOnConfigurationChanged: true, + suspendUntilReady: true, + suspendWhileReconciling: true, +}; + +/** + * Returns normalization options (all `undefined` fields removed, and `suspend` decomposed to `suspendUntilReady` and `suspendWhileReconciling`). + * DO NOT EXPORT PUBLICLY + * @internal + * @param {ReactFlagEvaluationOptions} options options to normalize + * @returns {NormalizedOptions} normalized options + */ +export const normalizeOptions: (options?: ReactFlagEvaluationOptions) => NormalizedOptions = (options?: ReactFlagEvaluationOptions) => { + const defaultOptionsIfMissing = !options ? {} : options; + // fall-back the suspense options + const suspendUntilReady = 'suspendUntilReady' in defaultOptionsIfMissing ? defaultOptionsIfMissing.suspendUntilReady : defaultOptionsIfMissing.suspend; + const suspendWhileReconciling = 'suspendWhileReconciling' in defaultOptionsIfMissing ? defaultOptionsIfMissing.suspendWhileReconciling : defaultOptionsIfMissing.suspend; + return { + updateOnContextChanged: defaultOptionsIfMissing.updateOnContextChanged, + updateOnConfigurationChanged: defaultOptionsIfMissing.updateOnConfigurationChanged, + // only return these if properly set (no undefined to allow overriding with spread) + ...(typeof suspendUntilReady === 'boolean' && {suspendUntilReady}), + ...(typeof suspendWhileReconciling === 'boolean' && {suspendWhileReconciling}), + }; +}; diff --git a/packages/react/src/common/suspend.ts b/packages/react/src/common/suspend.ts new file mode 100644 index 000000000..c598af0b3 --- /dev/null +++ b/packages/react/src/common/suspend.ts @@ -0,0 +1,75 @@ +import { Client, ProviderEvents } from '@openfeature/web-sdk'; +import { Dispatch, SetStateAction } from 'react'; + +enum SuspendState { + Pending, + Success, + Error, +} + +/** + * Suspend function. If this runs, components using the calling hook will be suspended. + * DO NOT EXPORT PUBLICLY + * @internal + * @param {Client} client the OpenFeature client + * @param {Function} updateState the state update function + * @param {ProviderEvents[]} resumeEvents list of events which will resume the suspend + */ +export function suspend( + client: Client, + updateState: Dispatch>, + ...resumeEvents: ProviderEvents[] +) { + let suspendResolver: () => void; + + const suspendPromise = new Promise((resolve) => { + suspendResolver = () => { + resolve(); + resumeEvents.forEach((e) => { + client.removeHandler(e, suspendResolver); // remove handlers once they've run + }); + client.removeHandler(ProviderEvents.Error, suspendResolver); + }; + resumeEvents.forEach((e) => { + client.addHandler(e, suspendResolver); + }); + client.addHandler(ProviderEvents.Error, suspendResolver); // we never want to throw, resolve with errors - we may make this configurable later + }); + updateState(suspenseWrapper(suspendPromise)); +} + +/** + * Promise wrapper that throws unresolved promises to support React suspense. + * DO NOT EXPORT PUBLICLY + * @internal + * @param {Promise} promise to wrap + * @template T flag type + * @returns {Function} suspense-compliant lambda + */ +export function suspenseWrapper(promise: Promise) { + let status: SuspendState = SuspendState.Pending; + let result: T; + + const suspended = promise + .then((value) => { + status = SuspendState.Success; + result = value; + }) + .catch((error) => { + status = SuspendState.Error; + result = error; + }); + + return () => { + switch (status) { + case SuspendState.Pending: + throw suspended; + case SuspendState.Success: + return result; + case SuspendState.Error: + throw result; + default: + throw new Error('Suspending promise is in an unknown state.'); + } + }; +} diff --git a/packages/react/src/evaluation/index.ts b/packages/react/src/evaluation/index.ts index 5868641fa..9a5edb703 100644 --- a/packages/react/src/evaluation/index.ts +++ b/packages/react/src/evaluation/index.ts @@ -1 +1 @@ -export * from './use-feature-flag'; \ No newline at end of file +export * from './use-feature-flag'; diff --git a/packages/react/src/evaluation/use-feature-flag.ts b/packages/react/src/evaluation/use-feature-flag.ts index 03e8e393d..028b26310 100644 --- a/packages/react/src/evaluation/use-feature-flag.ts +++ b/packages/react/src/evaluation/use-feature-flag.ts @@ -1,59 +1,19 @@ import { Client, EvaluationDetails, - FlagEvaluationOptions, FlagValue, JsonValue, ProviderEvents, ProviderStatus, - StandardResolutionReasons, + StandardResolutionReasons } from '@openfeature/web-sdk'; -import { Dispatch, SetStateAction, useEffect, useState } from 'react'; -import { useOpenFeatureClient } from '../provider'; +import { useEffect, useState } from 'react'; +import { DEFAULT_OPTIONS, ReactFlagEvaluationOptions, normalizeOptions } from '../common/options'; +import { suspend } from '../common/suspend'; +import { useProviderOptions } from '../provider/context'; +import { useOpenFeatureClient } from '../provider/use-open-feature-client'; import { FlagQuery } from '../query'; -type ReactFlagEvaluationOptions = { - /** - * Suspend flag evaluations while the provider is not ready. - * Set to false if you don't want to show suspense fallbacks until the provider is initialized. - * Defaults to true. - */ - suspendUntilReady?: boolean; - /** - * Suspend flag evaluations while the provider's context is being reconciled. - * Set to true if you want to show suspense fallbacks while flags are re-evaluated after context changes. - * Defaults to false. - */ - suspendWhileReconciling?: boolean; - /** - * Update the component if the provider emits a ConfigurationChanged event. - * Set to false to prevent components from re-rendering when flag value changes - * are received by the associated provider. - * Defaults to true. - */ - updateOnConfigurationChanged?: boolean; - /** - * Update the component when the OpenFeature context changes. - * Set to false to prevent components from re-rendering when attributes which - * may be factors in flag evaluation change. - * Defaults to true. - */ - updateOnContextChanged?: boolean; -} & FlagEvaluationOptions; - -const DEFAULT_OPTIONS: ReactFlagEvaluationOptions = { - updateOnContextChanged: true, - updateOnConfigurationChanged: true, - suspendUntilReady: true, - suspendWhileReconciling: false, -}; - -enum SuspendState { - Pending, - Success, - Error, -} - // This type is a bit wild-looking, but I think we need it. // We have to use the conditional, because otherwise useFlag('key', false) would return false, not boolean (too constrained). // We have a duplicate for the hook return below, this one is just used for casting because the name isn't as clear @@ -284,7 +244,8 @@ function attachHandlersAndResolve( resolver: (client: Client) => (flagKey: string, defaultValue: T) => EvaluationDetails, options?: ReactFlagEvaluationOptions, ): EvaluationDetails { - const defaultedOptions = { ...DEFAULT_OPTIONS, ...options }; + // highest priority > evaluation hook options > provider options > default options > lowest priority + const defaultedOptions = { ...DEFAULT_OPTIONS, ...useProviderOptions(), ...normalizeOptions(options)}; const [, updateState] = useState(); const client = useOpenFeatureClient(); const forceUpdate = () => { @@ -338,69 +299,6 @@ function attachHandlersAndResolve( return resolver(client).call(client, flagKey, defaultValue); } -/** - * Suspend function. If this runs, components using the calling hook will be suspended. - * @param {Client} client the OpenFeature client - * @param {Function} updateState the state update function - * @param {ProviderEvents[]} resumeEvents list of events which will resume the suspend - */ -function suspend( - client: Client, - updateState: Dispatch>, - ...resumeEvents: ProviderEvents[] -) { - let suspendResolver: () => void; - - const suspendPromise = new Promise((resolve) => { - suspendResolver = () => { - resolve(); - resumeEvents.forEach((e) => { - client.removeHandler(e, suspendResolver); // remove handlers once they've run - }); - client.removeHandler(ProviderEvents.Error, suspendResolver); - }; - resumeEvents.forEach((e) => { - client.addHandler(e, suspendResolver); - }); - client.addHandler(ProviderEvents.Error, suspendResolver); // we never want to throw, resolve with errors - we may make this configurable later - }); - updateState(suspenseWrapper(suspendPromise)); -} - -/** - * Promise wrapper that throws unresolved promises to support React suspense. - * @param {Promise} promise to wrap - * @template T flag type - * @returns {Function} suspense-compliant lambda - */ -function suspenseWrapper(promise: Promise) { - let status: SuspendState = SuspendState.Pending; - let result: T; - - const suspended = promise - .then((value) => { - status = SuspendState.Success; - result = value; - }) - .catch((error) => { - status = SuspendState.Error; - result = error; - }); - - return () => { - switch (status) { - case SuspendState.Pending: - throw suspended; - case SuspendState.Success: - return result; - case SuspendState.Error: - throw result; - default: - throw new Error('Suspending promise is in an unknown state.'); - } - }; -} - // FlagQuery implementation, do not export class HookFlagQuery implements FlagQuery { constructor(private _details: EvaluationDetails) {} diff --git a/packages/react/src/provider/context.ts b/packages/react/src/provider/context.ts new file mode 100644 index 000000000..ff4f215d3 --- /dev/null +++ b/packages/react/src/provider/context.ts @@ -0,0 +1,21 @@ +import { Client } from '@openfeature/web-sdk'; +import React from 'react'; +import { NormalizedOptions, ReactFlagEvaluationOptions, normalizeOptions } from '../common/options'; + +/** + * The underlying React context. + * DO NOT EXPORT PUBLICLY + * @internal + */ +export const Context = React.createContext<{ client: Client; options: ReactFlagEvaluationOptions } | undefined>(undefined); + +/** + * Get a normalized copy of the options used for this OpenFeatureProvider, see {@link normalizeOptions}. + * DO NOT EXPORT PUBLICLY + * @internal + * @returns {NormalizedOptions} normalized options the defaulted options, not defaulted or normalized. + */ +export function useProviderOptions(): NormalizedOptions { + const { options } = React.useContext(Context) || {}; + return normalizeOptions(options); +} diff --git a/packages/react/src/provider/index.ts b/packages/react/src/provider/index.ts index 0a8c7e17e..5c148dbe6 100644 --- a/packages/react/src/provider/index.ts +++ b/packages/react/src/provider/index.ts @@ -1 +1,3 @@ -export * from './provider'; \ No newline at end of file +export * from './provider'; +export * from './use-open-feature-client'; +export * from './use-when-provider-ready'; diff --git a/packages/react/src/provider/provider.tsx b/packages/react/src/provider/provider.tsx index 8010da8bd..e3fb928a3 100644 --- a/packages/react/src/provider/provider.tsx +++ b/packages/react/src/provider/provider.tsx @@ -1,5 +1,7 @@ -import * as React from 'react'; import { Client, OpenFeature } from '@openfeature/web-sdk'; +import * as React from 'react'; +import { ReactFlagEvaluationOptions } from '../common/options'; +import { Context } from './context'; type ClientOrDomain = | { @@ -20,26 +22,18 @@ type ClientOrDomain = type ProviderProps = { children?: React.ReactNode; -} & ClientOrDomain; - -const Context = React.createContext(undefined); - -export const OpenFeatureProvider = ({ client, domain, children }: ProviderProps) => { +} & ClientOrDomain & + ReactFlagEvaluationOptions; + + /** + * Provides a scope for evaluating feature flags by binding a client to all child components. + * @param {ProviderProps} properties props for the context provider + * @returns {OpenFeatureProvider} context provider + */ +export function OpenFeatureProvider({ client, domain, children, ...options }: ProviderProps) { if (!client) { client = OpenFeature.getClient(domain); } - return {children}; -}; - -export const useOpenFeatureClient = () => { - const client = React.useContext(Context); - - if (!client) { - throw new Error( - 'No OpenFeature client available - components using OpenFeature must be wrapped with an ' - ); - } - - return client; -}; + return {children}; +} diff --git a/packages/react/src/provider/use-open-feature-client.ts b/packages/react/src/provider/use-open-feature-client.ts new file mode 100644 index 000000000..bce7bbece --- /dev/null +++ b/packages/react/src/provider/use-open-feature-client.ts @@ -0,0 +1,20 @@ +import React from 'react'; +import { Context } from './context'; +import { Client } from '@openfeature/web-sdk'; + +/** + * Get the {@link Client} instance for this OpenFeatureProvider context. + * Note that the provider to which this is bound is determined by the OpenFeatureProvider's domain. + * @returns {Client} client for this scope + */ +export function useOpenFeatureClient(): Client { + const { client } = React.useContext(Context) || {}; + + if (!client) { + throw new Error( + 'No OpenFeature client available - components using OpenFeature must be wrapped with an ', + ); + } + + return client; +} diff --git a/packages/react/src/provider/use-when-provider-ready.ts b/packages/react/src/provider/use-when-provider-ready.ts new file mode 100644 index 000000000..7d68ac728 --- /dev/null +++ b/packages/react/src/provider/use-when-provider-ready.ts @@ -0,0 +1,30 @@ +import { ProviderEvents, ProviderStatus } from '@openfeature/web-sdk'; +import { useEffect, useState } from 'react'; +import { DEFAULT_OPTIONS, ReactFlagEvaluationOptions, normalizeOptions } from '../common/options'; +import { suspend } from '../common/suspend'; +import { useProviderOptions } from './context'; +import { useOpenFeatureClient } from './use-open-feature-client'; + +type Options = Pick; + +/** + * Utility hook that triggers suspense until the provider is {@link ProviderStatus.READY}, without evaluating any flags. + * Especially useful for React v16/17 "Legacy Suspense", in which siblings to suspending components are + * initially mounted and then hidden (see: https://github.com/reactwg/react-18/discussions/7). + * @param {Options} options options for suspense + * @returns {boolean} boolean indicating if provider is {@link ProviderStatus.READY}, useful if suspense is disabled and you want to handle loaders on your own + */ +export function useWhenProviderReady(options?: Options): boolean { + const [, updateState] = useState(); + const client = useOpenFeatureClient(); + // highest priority > evaluation hook options > provider options > default options > lowest priority + const defaultedOptions = { ...DEFAULT_OPTIONS, ...useProviderOptions(), ...normalizeOptions(options)}; + + useEffect(() => { + if (defaultedOptions.suspendUntilReady && client.providerStatus === ProviderStatus.NOT_READY) { + suspend(client, updateState, ProviderEvents.Ready); + } + }, []); + + return client.providerStatus === ProviderStatus.READY; +}