Skip to content

Commit

Permalink
feat!: options inheritance, useWhenProviderReady, suspend by default (#…
Browse files Browse the repository at this point in the history
…900)

* suspends while reconciling by default
* adds the ability to configure options at the context-provider level
* adds new `useWhenProviderReady` hook which force-suspend components
until the provider is ready (especially good for React 17 shortcomings
* updates README with FAQ/troubleshooting and more

---------

Signed-off-by: Todd Baert <[email protected]>
Co-authored-by: Lukas Reining <[email protected]>
  • Loading branch information
toddbaert and lukas-reining authored Apr 8, 2024
1 parent 37c50b7 commit 539e741
Show file tree
Hide file tree
Showing 10 changed files with 306 additions and 149 deletions.
74 changes: 57 additions & 17 deletions packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`.

Expand Down Expand Up @@ -123,9 +128,15 @@ function App() {
</OpenFeatureProvider>
);
}
```

#### 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 (
<div className="App">
Expand All @@ -135,9 +146,8 @@ function Page() {
</div>
)
}

export default App;
```

You can use the strongly-typed flag value and flag evaluation detail hooks as well, if you prefer.

```tsx
Expand All @@ -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`:

Expand All @@ -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() {
Expand All @@ -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() {
Expand All @@ -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() {
Expand Down Expand Up @@ -252,6 +261,37 @@ function Fallback() {
// component to render before READY.
return <p>Waiting for provider to be ready...</p>;
}

```

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
<OpenFeatureProvider domain={'my-domain'}>
<Page></Page>
</OpenFeatureProvider>
```

## Resources
Expand Down
77 changes: 77 additions & 0 deletions packages/react/src/common/options.ts
Original file line number Diff line number Diff line change
@@ -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<ReactFlagEvaluationOptions, 'suspend'>;

/**
* 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}),
};
};
75 changes: 75 additions & 0 deletions packages/react/src/common/suspend.ts
Original file line number Diff line number Diff line change
@@ -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<SetStateAction<object | undefined>>,
...resumeEvents: ProviderEvents[]
) {
let suspendResolver: () => void;

const suspendPromise = new Promise<void>((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<T>} promise to wrap
* @template T flag type
* @returns {Function} suspense-compliant lambda
*/
export function suspenseWrapper<T>(promise: Promise<T>) {
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.');
}
};
}
2 changes: 1 addition & 1 deletion packages/react/src/evaluation/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export * from './use-feature-flag';
export * from './use-feature-flag';
Loading

0 comments on commit 539e741

Please sign in to comment.