Skip to content

Commit 8f01ead

Browse files
authored
feat: suspense support, client scoping, context-sensitive re-rendering (#759)
Adds a few react features: - `<Suspense />` support: components using feature flags will trigger suspense for easy loaders/spinners - Ability to specify name for provider scope: `<OpenFeatureProvider name="my-provider">` - re-render on context change This is an exact re-merge of: #698, without a web-sdk whitespace change that created a bunch of bad release notes. Signed-off-by: Todd Baert <[email protected]>
1 parent 2c864e4 commit 8f01ead

File tree

6 files changed

+279
-25
lines changed

6 files changed

+279
-25
lines changed

.eslintrc.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@
2121
"jsdoc"
2222
],
2323
"rules": {
24+
"jsdoc/require-jsdoc": [
25+
"warn",
26+
{
27+
"publicOnly": true
28+
}
29+
],
2430
"jsdoc/check-tag-names": [
2531
"warn",
2632
{

packages/react/README.md

Lines changed: 114 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,22 +26,34 @@
2626

2727
🧪 This SDK is experimental.
2828

29+
## Basic Usage
2930

30-
Here's a basic example of how to use the current API with flagd:
31+
Here's a basic example of how to use the current API with the in-memory provider:
3132

32-
```js
33+
```tsx
3334
import logo from './logo.svg';
3435
import './App.css';
3536
import { OpenFeatureProvider, useFeatureFlag, OpenFeature } from '@openfeature/react-sdk';
3637
import { FlagdWebProvider } from '@openfeature/flagd-web-provider';
3738

38-
const provider = new FlagdWebProvider({
39-
host: 'localhost',
40-
port: 8013,
41-
tls: false,
42-
maxRetries: 0,
43-
});
44-
OpenFeature.setProvider(provider)
39+
const flagConfig = {
40+
'new-message': {
41+
disabled: false,
42+
variants: {
43+
on: true,
44+
off: false,
45+
},
46+
defaultVariant: "on",
47+
contextEvaluator: (context: EvaluationContext) => {
48+
if (context.silly) {
49+
return 'on';
50+
}
51+
return 'off'
52+
}
53+
},
54+
};
55+
56+
OpenFeature.setProvider(new InMemoryProvider(flagConfig));
4557

4658
function App() {
4759
return (
@@ -52,7 +64,7 @@ function App() {
5264
}
5365

5466
function Page() {
55-
const booleanFlag = useFeatureFlag('new-welcome-message', false);
67+
const booleanFlag = useFeatureFlag('new-message', false);
5668
return (
5769
<div className="App">
5870
<header className="App-header">
@@ -65,3 +77,95 @@ function Page() {
6577

6678
export default App;
6779
```
80+
81+
### Multiple Providers and Scoping
82+
83+
Multiple providers and scoped clients can be configured by passing a `clientName` to the `OpenFeatureProvider`:
84+
85+
```tsx
86+
// Flags within this scope will use the a client/provider associated with `myClient`,
87+
function App() {
88+
return (
89+
<OpenFeatureProvider clientName={'myClient'}>
90+
<Page></Page>
91+
</OpenFeatureProvider>
92+
);
93+
}
94+
```
95+
96+
This is analogous to:
97+
98+
```ts
99+
OpenFeature.getClient('myClient');
100+
```
101+
102+
### Re-rendering with Context Changes
103+
104+
By default, if the OpenFeature [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context) is modified, components will be re-rendered.
105+
This is useful in cases where flag values are dependant on user-attributes or other application state (user logged in, items in card, etc).
106+
You can disable this feature in the `useFeatureFlag` hook options:
107+
108+
```tsx
109+
function Page() {
110+
const booleanFlag = useFeatureFlag('new-message', false, { updateOnContextChanged: false });
111+
return (
112+
<MyComponents></MyComponents>
113+
)
114+
}
115+
```
116+
117+
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).
118+
119+
### Re-rendering with Flag Configuration Changes
120+
121+
By default, if the underlying provider emits a `ConfigurationChanged` event, components will be re-rendered.
122+
This is useful if you want your UI to immediately reflect changes in the backend flag configuration.
123+
You can disable this feature in the `useFeatureFlag` hook options:
124+
125+
```tsx
126+
function Page() {
127+
const booleanFlag = useFeatureFlag('new-message', false, { updateOnConfigurationChanged: false });
128+
return (
129+
<MyComponents></MyComponents>
130+
)
131+
}
132+
```
133+
134+
Note that if your provider doesn't support updates, this configuration has no impact.
135+
136+
### Suspense Support
137+
138+
Frequently, providers need to perform some initial startup tasks.
139+
It may be desireable not to display components with feature flags until this is complete.
140+
Built-in [suspense](https://react.dev/reference/react/Suspense) support makes this easy:
141+
142+
```tsx
143+
function Content() {
144+
// cause the "fallback" to be displayed if the component uses feature flags and the provider is not ready
145+
return (
146+
<Suspense fallback={<Fallback />}>
147+
<Message />
148+
</Suspense>
149+
);
150+
}
151+
152+
function Message() {
153+
// component to render after READY.
154+
const { value: showNewMessage } = useFeatureFlag('new-message', false);
155+
156+
return (
157+
<>
158+
{showNewMessage ? (
159+
<p>Welcome to this OpenFeature-enabled React app!</p>
160+
) : (
161+
<p>Welcome to this plain old React app!</p>
162+
)}
163+
</>
164+
);
165+
}
166+
167+
function Fallback() {
168+
// component to render before READY.
169+
return <p>Waiting for provider to be ready...</p>;
170+
}
171+
```

packages/react/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
},
4747
"homepage": "https://github.com/open-feature/js-sdk#readme",
4848
"peerDependencies": {
49-
"@openfeature/web-sdk": ">=0.4.0",
49+
"@openfeature/web-sdk": ">=0.4.10",
5050
"react": ">=16.8.0"
5151
},
5252
"devDependencies": {

packages/react/src/provider.tsx

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,39 @@
11
import * as React from 'react';
22
import { Client, OpenFeature } from '@openfeature/web-sdk';
33

4+
type ClientOrClientName =
5+
| {
6+
/**
7+
* The name of the client.
8+
* @see OpenFeature.setProvider() and overloads.
9+
*/
10+
clientName: string;
11+
/**
12+
* OpenFeature client to use.
13+
*/
14+
client?: never;
15+
}
16+
| {
17+
/**
18+
* OpenFeature client to use.
19+
*/
20+
client: Client;
21+
/**
22+
* The name of the client.
23+
* @see OpenFeature.setProvider() and overloads.
24+
*/
25+
clientName?: never;
26+
};
27+
428
type ProviderProps = {
5-
client?: Client;
629
children?: React.ReactNode;
7-
};
30+
} & ClientOrClientName;
831

932
const Context = React.createContext<Client | undefined>(undefined);
1033

11-
export const OpenFeatureProvider = ({ client, children }: ProviderProps) => {
34+
export const OpenFeatureProvider = ({ client, clientName, children }: ProviderProps) => {
1235
if (!client) {
13-
client = OpenFeature.getClient();
36+
client = OpenFeature.getClient(clientName);
1437
}
1538

1639
return <Context.Provider value={client}>{children}</Context.Provider>;

packages/react/src/use-feature-flag.ts

Lines changed: 127 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,81 @@
1-
import { Client, EvaluationDetails, FlagValue, ProviderEvents } from '@openfeature/web-sdk';
2-
import { useEffect, useState } from 'react';
1+
import { Client, EvaluationDetails, FlagEvaluationOptions, FlagValue, ProviderEvents, ProviderStatus } from '@openfeature/web-sdk';
2+
import { Dispatch, SetStateAction, useEffect, useState } from 'react';
33
import { useOpenFeatureClient } from './provider';
44

5-
export function useFeatureFlag<T extends FlagValue>(flagKey: string, defaultValue: T): EvaluationDetails<T> {
6-
const [, setForceUpdateState] = useState({});
5+
type ReactFlagEvaluationOptions = {
6+
/**
7+
* Suspend flag evaluations while the provider is not ready.
8+
* Set to false if you don't want to use React Suspense API.
9+
* Defaults to true.
10+
*/
11+
suspend?: boolean,
12+
/**
13+
* Update the component if the provider emits a ConfigurationChanged event.
14+
* Set to false to prevent components from re-rendering when flag value changes
15+
* are received by the associated provider.
16+
* Defaults to true.
17+
*/
18+
updateOnConfigurationChanged?: boolean,
19+
/**
20+
* Update the component when the OpenFeature context changes.
21+
* Set to false to prevent components from re-rendering when attributes which
22+
* may be factors in flag evaluation change.
23+
* Defaults to true.
24+
*/
25+
updateOnContextChanged?: boolean,
26+
} & FlagEvaluationOptions;
727

28+
const DEFAULT_OPTIONS: ReactFlagEvaluationOptions = {
29+
updateOnContextChanged: true,
30+
updateOnConfigurationChanged: true,
31+
suspend: true,
32+
};
33+
34+
enum SuspendState {
35+
Pending,
36+
Success,
37+
Error
38+
}
39+
40+
/**
41+
* Evaluates a feature flag, returning evaluation details.
42+
* @param {string}flagKey the flag identifier
43+
* @param {T} defaultValue the default value
44+
* @param {ReactFlagEvaluationOptions} options options for this evaluation
45+
* @template T flag type
46+
* @returns { EvaluationDetails<T>} a EvaluationDetails object for this evaluation
47+
*/
48+
export function useFeatureFlag<T extends FlagValue>(flagKey: string, defaultValue: T, options?: ReactFlagEvaluationOptions): EvaluationDetails<T> {
49+
const defaultedOptions = { ...DEFAULT_OPTIONS, ...options };
50+
const [, updateState] = useState<object | undefined>();
51+
const forceUpdate = () => {
52+
updateState({});
53+
};
854
const client = useOpenFeatureClient();
955

1056
useEffect(() => {
11-
const forceUpdate = () => setForceUpdateState({});
1257

13-
// adding handlers here means that an update is triggered, which leads to the change directly reflecting in the UI
14-
client.addHandler(ProviderEvents.Ready, forceUpdate);
15-
client.addHandler(ProviderEvents.ConfigurationChanged, forceUpdate);
58+
if (client.providerStatus !== ProviderStatus.READY) {
59+
// update when the provider is ready
60+
client.addHandler(ProviderEvents.Ready, forceUpdate);
61+
if (defaultedOptions.suspend) {
62+
suspend(client, updateState);
63+
}
64+
}
65+
66+
if (defaultedOptions.updateOnContextChanged) {
67+
// update when the context changes
68+
client.addHandler(ProviderEvents.ContextChanged, forceUpdate);
69+
}
70+
71+
if (defaultedOptions.updateOnConfigurationChanged) {
72+
// update when the provider configuration changes
73+
client.addHandler(ProviderEvents.ConfigurationChanged, forceUpdate);
74+
}
1675
return () => {
17-
// be sure to cleanup the handlers
76+
// cleanup the handlers (we can do this unconditionally with no impact)
1877
client.removeHandler(ProviderEvents.Ready, forceUpdate);
78+
client.removeHandler(ProviderEvents.ContextChanged, forceUpdate);
1979
client.removeHandler(ProviderEvents.ConfigurationChanged, forceUpdate);
2080
};
2181
}, [client]);
@@ -34,3 +94,61 @@ function getFlag<T extends FlagValue>(client: Client, flagKey: string, defaultVa
3494
return client.getObjectDetails(flagKey, defaultValue) as EvaluationDetails<T>;
3595
}
3696
}
97+
98+
/**
99+
* Suspend function. If this runs, components using the calling hook will be suspended.
100+
* @param {Client} client the OpenFeature client
101+
* @param {Function} updateState the state update function
102+
*/
103+
function suspend(client: Client, updateState: Dispatch<SetStateAction<object | undefined>>) {
104+
let suspendResolver: () => void;
105+
let suspendRejecter: () => void;
106+
const suspendPromise = new Promise<void>((resolve) => {
107+
suspendResolver = () => {
108+
resolve();
109+
client.removeHandler(ProviderEvents.Ready, suspendResolver); // remove handler once it's run
110+
};
111+
suspendRejecter = () => {
112+
resolve(); // we still resolve here, since we don't want to throw errors
113+
client.removeHandler(ProviderEvents.Error, suspendRejecter); // remove handler once it's run
114+
};
115+
client.addHandler(ProviderEvents.Ready, suspendResolver);
116+
client.addHandler(ProviderEvents.Error, suspendRejecter);
117+
});
118+
updateState(suspenseWrapper(suspendPromise));
119+
}
120+
121+
/**
122+
* Promise wrapper that throws unresolved promises to support React suspense.
123+
* @param {Promise<T>} promise to wrap
124+
* @template T flag type
125+
* @returns {Function} suspense-compliant lambda
126+
*/
127+
function suspenseWrapper <T>(promise: Promise<T>) {
128+
let status: SuspendState = SuspendState.Pending;
129+
let result: T;
130+
131+
const suspended = promise.then(
132+
(value) => {
133+
status = SuspendState.Success;
134+
result = value;
135+
},
136+
(error) => {
137+
status = SuspendState.Error;
138+
result = error;
139+
}
140+
);
141+
142+
return () => {
143+
switch (status) {
144+
case SuspendState.Pending:
145+
throw suspended;
146+
case SuspendState.Success:
147+
return result;
148+
case SuspendState.Error:
149+
throw result;
150+
default:
151+
throw new Error('Suspending promise is in an unknown state.');
152+
}
153+
};
154+
};

packages/react/tsconfig.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,10 @@
2929
// "rootDir": "./", /* Specify the root folder within your source files. */
3030
"moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
3131
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
32-
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
32+
"paths": {
33+
"@openfeature/core": [ "../shared/src" ],
34+
"@openfeature/web-sdk": [ "../client/src" ]
35+
}, /* Specify a set of entries that re-map imports to additional lookup locations. */
3336
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
3437
// "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */
3538
// "types": [], /* Specify type package names to be included without being referenced in a source file. */

0 commit comments

Comments
 (0)