Skip to content

Commit

Permalink
feat: react suspense support, named clients
Browse files Browse the repository at this point in the history
Signed-off-by: Todd Baert <[email protected]>
  • Loading branch information
toddbaert committed Jan 11, 2024
1 parent 58cac6c commit 69b531e
Show file tree
Hide file tree
Showing 7 changed files with 281 additions and 27 deletions.
6 changes: 6 additions & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@
"jsdoc"
],
"rules": {
"jsdoc/require-jsdoc": [
"warn",
{
"publicOnly": true
}
],
"jsdoc/check-tag-names": [
"warn",
{
Expand Down
2 changes: 1 addition & 1 deletion packages/client/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@ export interface Client extends EvaluationLifeCycle<Client>, Features, ManageLog
* Returns the status of the associated provider.
*/
readonly providerStatus: ProviderStatus;
}
}
124 changes: 114 additions & 10 deletions packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -52,7 +64,7 @@ function App() {
}

function Page() {
const booleanFlag = useFeatureFlag('new-welcome-message', false);
const booleanFlag = useFeatureFlag('new-message', false);
return (
<div className="App">
<header className="App-header">
Expand All @@ -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 (
<OpenFeatureProvider clientName={'myClient'}>
<Page></Page>
</OpenFeatureProvider>
);
}
```

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 (
<MyComponents></MyComponents>
)
}
```

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 (
<MyComponents></MyComponents>
)
}
```

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 (
<Suspense fallback={<Fallback />}>
<Message />
</Suspense>
);
}

function Message() {
// component to render after READY.
const { value: showNewMessage } = useFeatureFlag('new-message', false);

return (
<>
{showNewMessage ? (
<p>Welcome to this OpenFeature-enabled React app!</p>
) : (
<p>Welcome to this plain old React app!</p>
)}
</>
);
}

function Fallback() {
// component to render before READY.
return <p>Waiting for provider to be ready...</p>;
}
```
4 changes: 2 additions & 2 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@
},
"homepage": "https://github.com/open-feature/js-sdk#readme",
"peerDependencies": {
"@openfeature/web-sdk": ">=0.4.0",
"react": ">=18.0.0"
"@openfeature/web-sdk": ">=0.4.10",
"react": ">=16.8.0"
},
"devDependencies": {
"@openfeature/core": "*",
Expand Down
31 changes: 27 additions & 4 deletions packages/react/src/provider.tsx
Original file line number Diff line number Diff line change
@@ -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<Client | undefined>(undefined);

export const OpenFeatureProvider = ({ client, children }: ProviderProps) => {
export const OpenFeatureProvider = ({ client, clientName, children }: ProviderProps) => {
if (!client) {
client = OpenFeature.getClient();
client = OpenFeature.getClient(clientName);
}

return <Context.Provider value={client}>{children}</Context.Provider>;
Expand Down
136 changes: 127 additions & 9 deletions packages/react/src/use-feature-flag.ts
Original file line number Diff line number Diff line change
@@ -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<T extends FlagValue>(flagKey: string, defaultValue: T): EvaluationDetails<T> {
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<T>} a EvaluationDetails object for this evaluation
*/
export function useFeatureFlag<T extends FlagValue>(flagKey: string, defaultValue: T, options?: ReactFlagEvaluationOptions): EvaluationDetails<T> {
const defaultedOptions = { ...DEFAULT_OPTIONS, ...options };
const [, updateState] = useState<object | undefined>();
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]);
Expand All @@ -34,3 +94,61 @@ function getFlag<T extends FlagValue>(client: Client, flagKey: string, defaultVa
return client.getObjectDetails(flagKey, defaultValue) as EvaluationDetails<T>;
}
}

/**
* 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<SetStateAction<object | undefined>>) {
let suspendResolver: () => void;
let suspendRejecter: () => void;
const suspendPromise = new Promise<void>((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<T>} promise to wrap
* @template T flag type
* @returns {Function} suspense-compliant lambda
*/
function suspenseWrapper <T>(promise: Promise<T>) {
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.');
}
};
};
5 changes: 4 additions & 1 deletion packages/react/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down

0 comments on commit 69b531e

Please sign in to comment.