Skip to content

Commit 539e741

Browse files
feat!: options inheritance, useWhenProviderReady, suspend by default (#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]>
1 parent 37c50b7 commit 539e741

File tree

10 files changed

+306
-149
lines changed

10 files changed

+306
-149
lines changed

packages/react/README.md

Lines changed: 57 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,13 @@ In addition to the feature provided by the [web sdk](https://openfeature.dev/doc
5050
- [yarn](#yarn)
5151
- [Required peer dependencies](#required-peer-dependencies)
5252
- [Usage](#usage)
53-
- [Multiple Providers and Domains](#multiple-providers-and-domains)
54-
- [Re-rendering with Context Changes](#re-rendering-with-context-changes)
55-
- [Re-rendering with Flag Configuration Changes](#re-rendering-with-flag-configuration-changes)
56-
- [Suspense Support](#suspense-support)
53+
- [OpenFeatureProvider context provider](#openfeatureprovider-context-provider)
54+
- [Evaluation hooks](#evaluation-hooks)
55+
- [Multiple Providers and Domains](#multiple-providers-and-domains)
56+
- [Re-rendering with Context Changes](#re-rendering-with-context-changes)
57+
- [Re-rendering with Flag Configuration Changes](#re-rendering-with-flag-configuration-changes)
58+
- [Suspense Support](#suspense-support)
59+
- [FAQ and troubleshooting](#faq-and-troubleshooting)
5760
- [Resources](#resources)
5861

5962
## Quick start
@@ -87,7 +90,9 @@ The following list contains the peer dependencies of `@openfeature/react-sdk` wi
8790

8891
### Usage
8992

90-
The `OpenFeatureProvider` represents a scope for feature flag evaluations within a React application.
93+
#### OpenFeatureProvider context provider
94+
95+
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.
9196
It binds an OpenFeature client to all evaluations within child components, and allows the use of evaluation hooks.
9297
The example below shows how to use the `OpenFeatureProvider` with OpenFeature's `InMemoryProvider`.
9398

@@ -123,9 +128,15 @@ function App() {
123128
</OpenFeatureProvider>
124129
);
125130
}
131+
```
132+
133+
#### Evaluation hooks
126134

135+
Within the provider, you can use the various evaluation hooks to evaluate flags.
136+
137+
```tsx
127138
function Page() {
128-
// Use the "query-style" flag evaluation hook.
139+
// Use the "query-style" flag evaluation hook, specifying a flag-key and a default value.
129140
const { value: showNewMessage } = useFlag('new-message', true);
130141
return (
131142
<div className="App">
@@ -135,9 +146,8 @@ function Page() {
135146
</div>
136147
)
137148
}
138-
139-
export default App;
140149
```
150+
141151
You can use the strongly-typed flag value and flag evaluation detail hooks as well, if you prefer.
142152

143153
```tsx
@@ -159,8 +169,7 @@ const {
159169
} = useBooleanFlagDetails('new-message', false);
160170
```
161171

162-
### Multiple Providers and Domains
163-
172+
#### Multiple Providers and Domains
164173

165174
Multiple providers can be used by passing a `domain` to the `OpenFeatureProvider`:
166175

@@ -183,11 +192,11 @@ OpenFeature.getClient('my-domain');
183192

184193
For more information about `domains`, refer to the [web SDK](https://github.com/open-feature/js-sdk/blob/main/packages/client/README.md).
185194

186-
### Re-rendering with Context Changes
195+
#### Re-rendering with Context Changes
187196

188197
By default, if the OpenFeature [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context) is modified, components will be re-rendered.
189198
This is useful in cases where flag values are dependant on user-attributes or other application state (user logged in, items in card, etc).
190-
You can disable this feature in the hook options:
199+
You can disable this feature in the hook options (or in the [OpenFeatureProvider](#openfeatureprovider-context-provider)):
191200

192201
```tsx
193202
function Page() {
@@ -200,11 +209,11 @@ function Page() {
200209

201210
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).
202211

203-
### Re-rendering with Flag Configuration Changes
212+
#### Re-rendering with Flag Configuration Changes
204213

205214
By default, if the underlying provider emits a `ConfigurationChanged` event, components will be re-rendered.
206215
This is useful if you want your UI to immediately reflect changes in the backend flag configuration.
207-
You can disable this feature in the hook options:
216+
You can disable this feature in the hook options (or in the [OpenFeatureProvider](#openfeatureprovider-context-provider)):
208217

209218
```tsx
210219
function Page() {
@@ -217,11 +226,11 @@ function Page() {
217226

218227
Note that if your provider doesn't support updates, this configuration has no impact.
219228

220-
### Suspense Support
229+
#### Suspense Support
221230

222231
Frequently, providers need to perform some initial startup tasks.
223-
It may be desireable not to display components with feature flags until this is complete.
224-
Built-in [suspense](https://react.dev/reference/react/Suspense) support makes this easy:
232+
It may be desireable not to display components with feature flags until this is complete, or when the context changes.
233+
Built-in [suspense](https://react.dev/reference/react/Suspense) support makes this easy.
225234

226235
```tsx
227236
function Content() {
@@ -252,6 +261,37 @@ function Fallback() {
252261
// component to render before READY.
253262
return <p>Waiting for provider to be ready...</p>;
254263
}
264+
265+
```
266+
267+
This can be disabled in the hook options (or in the [OpenFeatureProvider](#openfeatureprovider-context-provider)).
268+
269+
## FAQ and troubleshooting
270+
271+
> I get an error that says something like: `A React component suspended while rendering, but no fallback UI was specified.`
272+
273+
The OpenFeature React SDK features built-in [suspense support](#suspense-support).
274+
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!
275+
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.
276+
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).
277+
278+
> I get odd rendering issues, or errors when components mount, if I use the suspense features.
279+
280+
In React 16/17's "Legacy Suspense", when a component suspends, its sibling components initially mount and then are hidden.
281+
This can cause surprising effects and inconsistencies if sibling components are rendered while the provider is still getting ready.
282+
To fix this, you can upgrade to React 18, which uses "Concurrent Suspense", in which siblings are not mounted until their suspended sibling resolves.
283+
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.
284+
285+
> I am using multiple `OpenFeatureProvider` contexts, but they are sharing the same provider or evaluation context. Why?
286+
287+
The `OpenFeatureProvider` binds a `client` to all child components, but the provider and context associated with that client is controlled by the `domain` parameter.
288+
This is consistent with all OpenFeature SDKs.
289+
To scope an OpenFeatureProvider to a particular provider/context set the `domain` parameter on your `OpenFeatureProvider`:
290+
291+
```tsx
292+
<OpenFeatureProvider domain={'my-domain'}>
293+
<Page></Page>
294+
</OpenFeatureProvider>
255295
```
256296

257297
## Resources
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { FlagEvaluationOptions } from '@openfeature/web-sdk';
2+
3+
export type ReactFlagEvaluationOptions = ({
4+
/**
5+
* Enable or disable all suspense functionality.
6+
* Cannot be used in conjunction with `suspendUntilReady` and `suspendWhileReconciling` options.
7+
*/
8+
suspend?: boolean;
9+
suspendUntilReady?: never;
10+
suspendWhileReconciling?: never;
11+
} | {
12+
/**
13+
* Suspend flag evaluations while the provider is not ready.
14+
* Set to false if you don't want to show suspense fallbacks until the provider is initialized.
15+
* Defaults to true.
16+
* Cannot be used in conjunction with `suspend` option.
17+
*/
18+
suspendUntilReady?: boolean;
19+
/**
20+
* Suspend flag evaluations while the provider's context is being reconciled.
21+
* Set to true if you want to show suspense fallbacks while flags are re-evaluated after context changes.
22+
* Defaults to true.
23+
* Cannot be used in conjunction with `suspend` option.
24+
*/
25+
suspendWhileReconciling?: boolean;
26+
suspend?: never;
27+
}) & {
28+
/**
29+
* Update the component if the provider emits a ConfigurationChanged event.
30+
* Set to false to prevent components from re-rendering when flag value changes
31+
* are received by the associated provider.
32+
* Defaults to true.
33+
*/
34+
updateOnConfigurationChanged?: boolean;
35+
/**
36+
* Update the component when the OpenFeature context changes.
37+
* Set to false to prevent components from re-rendering when attributes which
38+
* may be factors in flag evaluation change.
39+
* Defaults to true.
40+
*/
41+
updateOnContextChanged?: boolean;
42+
} & FlagEvaluationOptions;
43+
44+
export type NormalizedOptions = Omit<ReactFlagEvaluationOptions, 'suspend'>;
45+
46+
/**
47+
* Default options.
48+
* DO NOT EXPORT PUBLICLY
49+
* @internal
50+
*/
51+
export const DEFAULT_OPTIONS: ReactFlagEvaluationOptions = {
52+
updateOnContextChanged: true,
53+
updateOnConfigurationChanged: true,
54+
suspendUntilReady: true,
55+
suspendWhileReconciling: true,
56+
};
57+
58+
/**
59+
* Returns normalization options (all `undefined` fields removed, and `suspend` decomposed to `suspendUntilReady` and `suspendWhileReconciling`).
60+
* DO NOT EXPORT PUBLICLY
61+
* @internal
62+
* @param {ReactFlagEvaluationOptions} options options to normalize
63+
* @returns {NormalizedOptions} normalized options
64+
*/
65+
export const normalizeOptions: (options?: ReactFlagEvaluationOptions) => NormalizedOptions = (options?: ReactFlagEvaluationOptions) => {
66+
const defaultOptionsIfMissing = !options ? {} : options;
67+
// fall-back the suspense options
68+
const suspendUntilReady = 'suspendUntilReady' in defaultOptionsIfMissing ? defaultOptionsIfMissing.suspendUntilReady : defaultOptionsIfMissing.suspend;
69+
const suspendWhileReconciling = 'suspendWhileReconciling' in defaultOptionsIfMissing ? defaultOptionsIfMissing.suspendWhileReconciling : defaultOptionsIfMissing.suspend;
70+
return {
71+
updateOnContextChanged: defaultOptionsIfMissing.updateOnContextChanged,
72+
updateOnConfigurationChanged: defaultOptionsIfMissing.updateOnConfigurationChanged,
73+
// only return these if properly set (no undefined to allow overriding with spread)
74+
...(typeof suspendUntilReady === 'boolean' && {suspendUntilReady}),
75+
...(typeof suspendWhileReconciling === 'boolean' && {suspendWhileReconciling}),
76+
};
77+
};
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { Client, ProviderEvents } from '@openfeature/web-sdk';
2+
import { Dispatch, SetStateAction } from 'react';
3+
4+
enum SuspendState {
5+
Pending,
6+
Success,
7+
Error,
8+
}
9+
10+
/**
11+
* Suspend function. If this runs, components using the calling hook will be suspended.
12+
* DO NOT EXPORT PUBLICLY
13+
* @internal
14+
* @param {Client} client the OpenFeature client
15+
* @param {Function} updateState the state update function
16+
* @param {ProviderEvents[]} resumeEvents list of events which will resume the suspend
17+
*/
18+
export function suspend(
19+
client: Client,
20+
updateState: Dispatch<SetStateAction<object | undefined>>,
21+
...resumeEvents: ProviderEvents[]
22+
) {
23+
let suspendResolver: () => void;
24+
25+
const suspendPromise = new Promise<void>((resolve) => {
26+
suspendResolver = () => {
27+
resolve();
28+
resumeEvents.forEach((e) => {
29+
client.removeHandler(e, suspendResolver); // remove handlers once they've run
30+
});
31+
client.removeHandler(ProviderEvents.Error, suspendResolver);
32+
};
33+
resumeEvents.forEach((e) => {
34+
client.addHandler(e, suspendResolver);
35+
});
36+
client.addHandler(ProviderEvents.Error, suspendResolver); // we never want to throw, resolve with errors - we may make this configurable later
37+
});
38+
updateState(suspenseWrapper(suspendPromise));
39+
}
40+
41+
/**
42+
* Promise wrapper that throws unresolved promises to support React suspense.
43+
* DO NOT EXPORT PUBLICLY
44+
* @internal
45+
* @param {Promise<T>} promise to wrap
46+
* @template T flag type
47+
* @returns {Function} suspense-compliant lambda
48+
*/
49+
export function suspenseWrapper<T>(promise: Promise<T>) {
50+
let status: SuspendState = SuspendState.Pending;
51+
let result: T;
52+
53+
const suspended = promise
54+
.then((value) => {
55+
status = SuspendState.Success;
56+
result = value;
57+
})
58+
.catch((error) => {
59+
status = SuspendState.Error;
60+
result = error;
61+
});
62+
63+
return () => {
64+
switch (status) {
65+
case SuspendState.Pending:
66+
throw suspended;
67+
case SuspendState.Success:
68+
return result;
69+
case SuspendState.Error:
70+
throw result;
71+
default:
72+
throw new Error('Suspending promise is in an unknown state.');
73+
}
74+
};
75+
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export * from './use-feature-flag';
1+
export * from './use-feature-flag';

0 commit comments

Comments
 (0)