Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: suspense support, client scoping, context-sensitive re-rendering #698

Merged
merged 10 commits into from
Jan 11, 2024
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
}
}
122 changes: 112 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,93 @@ 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.
toddbaert marked this conversation as resolved.
Show resolved Hide resolved
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>
)
}
```

### 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 realtime updates, this configuration has no impact.
toddbaert marked this conversation as resolved.
Show resolved Hide resolved

### 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>;
}
```
2 changes: 1 addition & 1 deletion packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": ">=18.0.0"
},
"devDependencies": {
Expand Down
12 changes: 10 additions & 2 deletions packages/react/src/provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,23 @@ import * as React from 'react';
import { Client, OpenFeature } from '@openfeature/web-sdk';

type ProviderProps = {
/**
* OpenFeature client to use.
*/
client?: Client;
/**
* The name of the client.
* @see OpenFeature.setProvider() and overloads.
*/
clientName?: string,
toddbaert marked this conversation as resolved.
Show resolved Hide resolved
children?: React.ReactNode;
};

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 = {
toddbaert marked this conversation as resolved.
Show resolved Hide resolved
/**
* 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" ]
toddbaert marked this conversation as resolved.
Show resolved Hide resolved
}, /* 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