Skip to content

Commit 78516f4

Browse files
authored
feat: re-render if flagsChanged is falsy (#1095)
Adds an improvement to the React SDK which supports re-renders if the [flags changed](https://open-feature.github.io/js-sdk/types/_openfeature_server_sdk.ConfigChangeEvent.html) array from a provider event is falsy. Since some providers have no knowledge of flags which are changed, this allows them to support dynamic re-rendering by not defining this property. If the prop is null/undefined, we diff all flags... If the property is explicitly set to an empty array, that means no flags have changed and the React SDK skips all diff checks. Signed-off-by: Todd Baert <[email protected]>
1 parent 5ece80e commit 78516f4

File tree

3 files changed

+64
-12
lines changed

3 files changed

+64
-12
lines changed

packages/react/README.md

+5-2
Original file line numberDiff line numberDiff line change
@@ -233,14 +233,17 @@ function Page() {
233233
}
234234
```
235235

236-
Note that if your provider doesn't support updates, this configuration has no impact.
236+
If your provider doesn't support updates, this configuration has no impact.
237+
238+
> [!NOTE]
239+
> If your provider includes a list of [flags changed](https://open-feature.github.io/js-sdk/types/_openfeature_server_sdk.ConfigChangeEvent.html) in its `PROVIDER_CONFIGURATION_CHANGED` event, that list of flags is used to decide which flag evaluation hooks should re-run by diffing the latest value of these flags with the previous render.
240+
> If your provider event does not the include the `flags changed` list, then the SDK diffs all flags with the previous render to determine which hooks should re-run.
237241
238242
#### Suspense Support
239243

240244
> [!NOTE]
241245
> React suspense is an experimental feature and is subject to change in future versions.
242246
243-
244247
Frequently, providers need to perform some initial startup tasks.
245248
It may be desirable not to display components with feature flags until this is complete or when the context changes.
246249
Built-in [suspense](https://react.dev/reference/react/Suspense) support makes this easy.

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

+2-1
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,8 @@ export function useObjectFlagDetails<T extends JsonValue = JsonValue>(
267267

268268
// determines if a flag should be re-evaluated based on a list of changed flags
269269
function shouldEvaluateFlag(flagKey: string, flagsChanged?: string[]): boolean {
270-
return !!flagsChanged && flagsChanged.includes(flagKey);
270+
// if flagsChange is missing entirely, we don't know what to re-render
271+
return !flagsChanged || flagsChanged.includes(flagKey);
271272
}
272273

273274
function attachHandlersAndResolve<T extends FlagValue>(

packages/react/test/evaluation.spec.tsx

+57-9
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
1+
import { jest } from '@jest/globals';
2+
import type { ProviderEmittableEvents } from '@openfeature/web-sdk';
3+
import { ClientProviderEvents } from '@openfeature/web-sdk';
4+
import type { FlagConfiguration } from '@openfeature/web-sdk/src/provider/in-memory-provider/flag-configuration';
15
import '@testing-library/jest-dom'; // see: https://testing-library.com/docs/react-testing-library/setup
26
import { act, render, renderHook, screen, waitFor } from '@testing-library/react';
37
import * as React from 'react';
8+
import { startTransition, useState } from 'react';
49
import type {
510
EvaluationContext,
611
EvaluationDetails,
7-
Hook} from '../src/';
12+
EventContext,
13+
Hook
14+
} from '../src/';
815
import {
916
ErrorCode,
1017
InMemoryProvider,
@@ -20,12 +27,20 @@ import {
2027
useObjectFlagValue,
2128
useStringFlagDetails,
2229
useStringFlagValue,
23-
useSuspenseFlag,
30+
useSuspenseFlag
2431
} from '../src/';
25-
import { TestingProvider } from './test.utils';
2632
import { HookFlagQuery } from '../src/evaluation/hook-flag-query';
27-
import { startTransition, useState } from 'react';
28-
import { jest } from '@jest/globals';
33+
import { TestingProvider } from './test.utils';
34+
35+
// custom provider to have better control over the emitted events
36+
class CustomEventInMemoryProvider extends InMemoryProvider {
37+
38+
putConfigurationWithCustomEvent(flagConfiguration: FlagConfiguration, event: ProviderEmittableEvents, eventContext: EventContext) {
39+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
40+
this['_flagConfiguration'] = { ...flagConfiguration }; // private access hack
41+
this.events.emit(event, eventContext);
42+
}
43+
}
2944

3045
describe('evaluation', () => {
3146
const EVALUATION = 'evaluation';
@@ -262,7 +277,7 @@ describe('evaluation', () => {
262277

263278
describe('re-render', () => {
264279
const RERENDER_DOMAIN = 'rerender';
265-
const rerenderProvider = new InMemoryProvider(FLAG_CONFIG);
280+
const rerenderProvider = new CustomEventInMemoryProvider(FLAG_CONFIG);
266281

267282
function TestComponentFactory() {
268283
let renderCount = 0;
@@ -370,7 +385,7 @@ describe('evaluation', () => {
370385
expect(screen.queryByTestId('render-count')).toHaveTextContent('2');
371386
});
372387

373-
it('should not render on flag change because the provider did not include changed flags in the change event', async () => {
388+
it('should not render on flag change when the provider change event has empty flagsChanged', async () => {
374389
const TestComponent = TestComponentFactory();
375390
render(
376391
<OpenFeatureProvider domain={RERENDER_DOMAIN}>
@@ -380,14 +395,46 @@ describe('evaluation', () => {
380395

381396
expect(screen.queryByTestId('render-count')).toHaveTextContent('1');
382397
await act(async () => {
383-
await rerenderProvider.putConfiguration({
398+
await rerenderProvider.putConfigurationWithCustomEvent({
384399
...FLAG_CONFIG,
385-
});
400+
[BOOL_FLAG_KEY]: {
401+
...FLAG_CONFIG[BOOL_FLAG_KEY],
402+
// Change the default; this should be ignored and not cause a re-render because flagsChanged is empty
403+
defaultVariant: 'off',
404+
},
405+
// if the flagsChanged is empty, we know nothing has changed, so we don't bother diffing
406+
}, ClientProviderEvents.ConfigurationChanged, { flagsChanged: [] });
407+
386408
});
387409

388410
expect(screen.queryByTestId('render-count')).toHaveTextContent('1');
389411
});
390412

413+
it('should re-render on flag change because the provider change event has falsy flagsChanged', async () => {
414+
const TestComponent = TestComponentFactory();
415+
render(
416+
<OpenFeatureProvider domain={RERENDER_DOMAIN}>
417+
<TestComponent></TestComponent>
418+
</OpenFeatureProvider>,
419+
);
420+
421+
expect(screen.queryByTestId('render-count')).toHaveTextContent('1');
422+
await act(async () => {
423+
await rerenderProvider.putConfigurationWithCustomEvent({
424+
...FLAG_CONFIG,
425+
[BOOL_FLAG_KEY]: {
426+
...FLAG_CONFIG[BOOL_FLAG_KEY],
427+
// Change the default variant to trigger a rerender since not only do we check flagsChanged, but we also diff the value
428+
defaultVariant: 'off',
429+
},
430+
// if the flagsChanged is falsy, we don't know what flags changed - so we attempt to diff everything
431+
}, ClientProviderEvents.ConfigurationChanged, { flagsChanged: undefined });
432+
433+
});
434+
435+
expect(screen.queryByTestId('render-count')).toHaveTextContent('2');
436+
});
437+
391438
it('should not rerender on flag change because the evaluated values did not change', async () => {
392439
const TestComponent = TestComponentFactory();
393440
render(
@@ -1105,3 +1152,4 @@ describe('evaluation', () => {
11051152
});
11061153
});
11071154
});
1155+

0 commit comments

Comments
 (0)