From 68b0f26ccfedc9b1fac32147672f36aa1eb911b2 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Thu, 11 Jan 2024 15:10:22 -0500 Subject: [PATCH] feat: suspense support, client scoping, context-sensitive re-rendering (#698) Adds a few react features: - `` support: components using feature flags will trigger suspense for easy loaders/spinners - Ability to specify name for provider scope: `` > [!IMPORTANT] > Please see [here](https://github.com/open-feature/react-test-app/pull/2) for the latest changes to the demo application using these features (note that we won't be able to merge these demos until this is released). > [!IMPORTANT] > Also note I've added no tests, which is certainly not my MO. I will add them to this PR once there's an agreement on this behavior and implementation. gif from the demo app: ![demo](https://github.com/open-feature/js-sdk/assets/25272906/73007cff-0d0c-44e8-a34c-b99412556206) :warning: I want to add another feature here to support re-rendering on context changes. That requires [this](https://github.com/open-feature/js-sdk/pull/731) to be merged. --------- Signed-off-by: Todd Baert Co-authored-by: Michael Beemer --- .eslintrc.json | 6 ++ packages/client/src/client/client.ts | 2 +- packages/react/README.md | 124 ++++++++++++++++++++-- packages/react/package.json | 2 +- packages/react/src/provider.tsx | 31 +++++- packages/react/src/use-feature-flag.ts | 136 +++++++++++++++++++++++-- packages/react/tsconfig.json | 5 +- 7 files changed, 280 insertions(+), 26 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index 7df15a664..4e9a1c2af 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -21,6 +21,12 @@ "jsdoc" ], "rules": { + "jsdoc/require-jsdoc": [ + "warn", + { + "publicOnly": true + } + ], "jsdoc/check-tag-names": [ "warn", { diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 2eec5c8e1..4c8905b53 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -7,4 +7,4 @@ export interface Client extends EvaluationLifeCycle, Features, ManageLog * Returns the status of the associated provider. */ readonly providerStatus: ProviderStatus; -} \ No newline at end of file +} diff --git a/packages/react/README.md b/packages/react/README.md index b04c0561e..bda8a64dc 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -26,22 +26,34 @@ 🧪 This SDK is experimental. +## Basic Usage -Here's a basic example of how to use the current API with flagd: +Here's a basic example of how to use the current API with the in-memory provider: -```js +```tsx import logo from './logo.svg'; import './App.css'; import { OpenFeatureProvider, useFeatureFlag, OpenFeature } from '@openfeature/react-sdk'; import { FlagdWebProvider } from '@openfeature/flagd-web-provider'; -const provider = new FlagdWebProvider({ - host: 'localhost', - port: 8013, - tls: false, - maxRetries: 0, -}); -OpenFeature.setProvider(provider) +const flagConfig = { + 'new-message': { + disabled: false, + variants: { + on: true, + off: false, + }, + defaultVariant: "on", + contextEvaluator: (context: EvaluationContext) => { + if (context.silly) { + return 'on'; + } + return 'off' + } + }, +}; + +OpenFeature.setProvider(new InMemoryProvider(flagConfig)); function App() { return ( @@ -52,7 +64,7 @@ function App() { } function Page() { - const booleanFlag = useFeatureFlag('new-welcome-message', false); + const booleanFlag = useFeatureFlag('new-message', false); return (
@@ -65,3 +77,95 @@ function Page() { export default App; ``` + +### Multiple Providers and Scoping + +Multiple providers and scoped clients can be configured by passing a `clientName` to the `OpenFeatureProvider`: + +```tsx +// Flags within this scope will use the a client/provider associated with `myClient`, +function App() { + return ( + + + + ); +} +``` + +This is analogous to: + +```ts +OpenFeature.getClient('myClient'); +``` + +### 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 `useFeatureFlag` hook options: + +```tsx +function Page() { + const booleanFlag = useFeatureFlag('new-message', false, { updateOnContextChanged: false }); + return ( + + ) +} +``` + +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 + +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 `useFeatureFlag` hook options: + +```tsx +function Page() { + const booleanFlag = useFeatureFlag('new-message', false, { updateOnConfigurationChanged: false }); + return ( + + ) +} +``` + +Note that if your provider doesn't support updates, this configuration has no impact. + +### 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: + +```tsx +function Content() { + // cause the "fallback" to be displayed if the component uses feature flags and the provider is not ready + return ( + }> + + + ); +} + +function Message() { + // component to render after READY. + const { value: showNewMessage } = useFeatureFlag('new-message', false); + + return ( + <> + {showNewMessage ? ( +

Welcome to this OpenFeature-enabled React app!

+ ) : ( +

Welcome to this plain old React app!

+ )} + + ); +} + +function Fallback() { + // component to render before READY. + return

Waiting for provider to be ready...

; +} +``` \ No newline at end of file diff --git a/packages/react/package.json b/packages/react/package.json index 0fc9591df..56e29d727 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -46,7 +46,7 @@ }, "homepage": "https://github.com/open-feature/js-sdk#readme", "peerDependencies": { - "@openfeature/web-sdk": ">=0.4.0", + "@openfeature/web-sdk": ">=0.4.10", "react": ">=16.8.0" }, "devDependencies": { diff --git a/packages/react/src/provider.tsx b/packages/react/src/provider.tsx index 196903418..3fe406e45 100644 --- a/packages/react/src/provider.tsx +++ b/packages/react/src/provider.tsx @@ -1,16 +1,39 @@ import * as React from 'react'; import { Client, OpenFeature } from '@openfeature/web-sdk'; +type ClientOrClientName = + | { + /** + * The name of the client. + * @see OpenFeature.setProvider() and overloads. + */ + clientName: string; + /** + * OpenFeature client to use. + */ + client?: never; + } + | { + /** + * OpenFeature client to use. + */ + client: Client; + /** + * The name of the client. + * @see OpenFeature.setProvider() and overloads. + */ + clientName?: never; + }; + type ProviderProps = { - client?: Client; children?: React.ReactNode; -}; +} & ClientOrClientName; const Context = React.createContext(undefined); -export const OpenFeatureProvider = ({ client, children }: ProviderProps) => { +export const OpenFeatureProvider = ({ client, clientName, children }: ProviderProps) => { if (!client) { - client = OpenFeature.getClient(); + client = OpenFeature.getClient(clientName); } return {children}; diff --git a/packages/react/src/use-feature-flag.ts b/packages/react/src/use-feature-flag.ts index cf5c7459b..0f95f50bf 100644 --- a/packages/react/src/use-feature-flag.ts +++ b/packages/react/src/use-feature-flag.ts @@ -1,21 +1,81 @@ -import { Client, EvaluationDetails, FlagValue, ProviderEvents } from '@openfeature/web-sdk'; -import { useEffect, useState } from 'react'; +import { Client, EvaluationDetails, FlagEvaluationOptions, FlagValue, ProviderEvents, ProviderStatus } from '@openfeature/web-sdk'; +import { Dispatch, SetStateAction, useEffect, useState } from 'react'; import { useOpenFeatureClient } from './provider'; -export function useFeatureFlag(flagKey: string, defaultValue: T): EvaluationDetails { - const [, setForceUpdateState] = useState({}); +type ReactFlagEvaluationOptions = { + /** + * Suspend flag evaluations while the provider is not ready. + * Set to false if you don't want to use React Suspense API. + * Defaults to true. + */ + suspend?: 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, + suspend: true, +}; + +enum SuspendState { + Pending, + Success, + Error +} + +/** + * Evaluates a feature flag, returning evaluation details. + * @param {string}flagKey the flag identifier + * @param {T} defaultValue the default value + * @param {ReactFlagEvaluationOptions} options options for this evaluation + * @template T flag type + * @returns { EvaluationDetails} a EvaluationDetails object for this evaluation + */ +export function useFeatureFlag(flagKey: string, defaultValue: T, options?: ReactFlagEvaluationOptions): EvaluationDetails { + const defaultedOptions = { ...DEFAULT_OPTIONS, ...options }; + const [, updateState] = useState(); + const forceUpdate = () => { + updateState({}); + }; const client = useOpenFeatureClient(); useEffect(() => { - const forceUpdate = () => setForceUpdateState({}); - // adding handlers here means that an update is triggered, which leads to the change directly reflecting in the UI - client.addHandler(ProviderEvents.Ready, forceUpdate); - client.addHandler(ProviderEvents.ConfigurationChanged, forceUpdate); + if (client.providerStatus !== ProviderStatus.READY) { + // update when the provider is ready + client.addHandler(ProviderEvents.Ready, forceUpdate); + if (defaultedOptions.suspend) { + suspend(client, updateState); + } + } + + if (defaultedOptions.updateOnContextChanged) { + // update when the context changes + client.addHandler(ProviderEvents.ContextChanged, forceUpdate); + } + + if (defaultedOptions.updateOnConfigurationChanged) { + // update when the provider configuration changes + client.addHandler(ProviderEvents.ConfigurationChanged, forceUpdate); + } return () => { - // be sure to cleanup the handlers + // cleanup the handlers (we can do this unconditionally with no impact) client.removeHandler(ProviderEvents.Ready, forceUpdate); + client.removeHandler(ProviderEvents.ContextChanged, forceUpdate); client.removeHandler(ProviderEvents.ConfigurationChanged, forceUpdate); }; }, [client]); @@ -34,3 +94,61 @@ function getFlag(client: Client, flagKey: string, defaultVa return client.getObjectDetails(flagKey, defaultValue) as EvaluationDetails; } } + +/** + * 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 + */ +function suspend(client: Client, updateState: Dispatch>) { + let suspendResolver: () => void; + let suspendRejecter: () => void; + const suspendPromise = new Promise((resolve) => { + suspendResolver = () => { + resolve(); + client.removeHandler(ProviderEvents.Ready, suspendResolver); // remove handler once it's run + }; + suspendRejecter = () => { + resolve(); // we still resolve here, since we don't want to throw errors + client.removeHandler(ProviderEvents.Error, suspendRejecter); // remove handler once it's run + }; + client.addHandler(ProviderEvents.Ready, suspendResolver); + client.addHandler(ProviderEvents.Error, suspendRejecter); + }); + 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; + }, + (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.'); + } + }; +}; \ No newline at end of file diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json index f3c6813bd..42ff0cccf 100644 --- a/packages/react/tsconfig.json +++ b/packages/react/tsconfig.json @@ -29,7 +29,10 @@ // "rootDir": "./", /* Specify the root folder within your source files. */ "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ - // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + "paths": { + "@openfeature/core": [ "../shared/src" ], + "@openfeature/web-sdk": [ "../client/src" ] + }, /* Specify a set of entries that re-map imports to additional lookup locations. */ // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ // "types": [], /* Specify type package names to be included without being referenced in a source file. */