Skip to content

Commit 8a3ce44

Browse files
Merge branch 'main' into feat-migrate-multi-provider
2 parents 6f54fbd + ee23639 commit 8a3ce44

File tree

8 files changed

+160
-24
lines changed

8 files changed

+160
-24
lines changed

.release-please-manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"packages/nest": "0.2.5",
3-
"packages/react": "1.0.0",
3+
"packages/react": "1.0.1",
44
"packages/web": "1.6.1",
55
"packages/server": "1.19.0",
66
"packages/shared": "1.9.0",

packages/react/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## [1.0.1](https://github.com/open-feature/js-sdk/compare/react-sdk-v1.0.0...react-sdk-v1.0.1) (2025-08-18)
4+
5+
6+
### 🐛 Bug Fixes
7+
8+
* **react:** re-evaluate flags on re-render to detect silent provider … ([#1226](https://github.com/open-feature/js-sdk/issues/1226)) ([3105595](https://github.com/open-feature/js-sdk/commit/31055959265a53f52102590f54fa3168811ec678))
9+
310
## [1.0.0](https://github.com/open-feature/js-sdk/compare/react-sdk-v0.4.11...react-sdk-v1.0.0) (2025-04-14)
411

512

packages/react/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@
1616
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
1717
</a>
1818
<!-- x-release-please-start-version -->
19-
<a href="https://github.com/open-feature/js-sdk/releases/tag/react-sdk-v1.0.0">
20-
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v1.0.0&color=blue&style=for-the-badge" />
19+
<a href="https://github.com/open-feature/js-sdk/releases/tag/react-sdk-v1.0.1">
20+
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v1.0.1&color=blue&style=for-the-badge" />
2121
</a>
2222
<!-- x-release-please-end -->
2323
<br/>

packages/react/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@openfeature/react-sdk",
3-
"version": "1.0.0",
3+
"version": "1.0.1",
44
"description": "OpenFeature React SDK",
55
"main": "./dist/cjs/index.js",
66
"files": [

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

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type {
88
JsonValue,
99
} from '@openfeature/web-sdk';
1010
import { ProviderEvents, ProviderStatus } from '@openfeature/web-sdk';
11-
import { useEffect, useRef, useState } from 'react';
11+
import { useCallback, useEffect, useRef, useState } from 'react';
1212
import {
1313
DEFAULT_OPTIONS,
1414
isEqual,
@@ -287,8 +287,7 @@ function attachHandlersAndResolve<T extends FlagValue>(
287287
const client = useOpenFeatureClient();
288288
const status = useOpenFeatureClientStatus();
289289
const provider = useOpenFeatureProvider();
290-
291-
const controller = new AbortController();
290+
const isFirstRender = useRef(true);
292291

293292
if (defaultedOptions.suspendUntilReady && status === ProviderStatus.NOT_READY) {
294293
suspendUntilInitialized(provider, client);
@@ -298,17 +297,30 @@ function attachHandlersAndResolve<T extends FlagValue>(
298297
suspendUntilReconciled(client);
299298
}
300299

301-
const [evaluationDetails, setEvaluationDetails] = useState<EvaluationDetails<T>>(
300+
const [evaluationDetails, setEvaluationDetails] = useState<EvaluationDetails<T>>(() =>
302301
resolver(client).call(client, flagKey, defaultValue, options),
303302
);
303+
304+
// Re-evaluate when dependencies change (handles prop changes like flagKey), or if during a re-render, we have detected a change in the evaluated value
305+
useEffect(() => {
306+
if (isFirstRender.current) {
307+
isFirstRender.current = false;
308+
return;
309+
}
310+
311+
const newDetails = resolver(client).call(client, flagKey, defaultValue, options);
312+
if (!isEqual(newDetails.value, evaluationDetails.value)) {
313+
setEvaluationDetails(newDetails);
314+
}
315+
}, [client, flagKey, defaultValue, options, resolver, evaluationDetails]);
304316

305317
// Maintain a mutable reference to the evaluation details to have a up-to-date reference in the handlers.
306318
const evaluationDetailsRef = useRef<EvaluationDetails<T>>(evaluationDetails);
307319
useEffect(() => {
308320
evaluationDetailsRef.current = evaluationDetails;
309321
}, [evaluationDetails]);
310322

311-
const updateEvaluationDetailsCallback = () => {
323+
const updateEvaluationDetailsCallback = useCallback(() => {
312324
const updatedEvaluationDetails = resolver(client).call(client, flagKey, defaultValue, options);
313325

314326
/**
@@ -319,15 +331,19 @@ function attachHandlersAndResolve<T extends FlagValue>(
319331
if (!isEqual(updatedEvaluationDetails.value, evaluationDetailsRef.current.value)) {
320332
setEvaluationDetails(updatedEvaluationDetails);
321333
}
322-
};
334+
}, [client, flagKey, defaultValue, options, resolver]);
323335

324-
const configurationChangeCallback: EventHandler<ClientProviderEvents.ConfigurationChanged> = (eventDetails) => {
325-
if (shouldEvaluateFlag(flagKey, eventDetails?.flagsChanged)) {
326-
updateEvaluationDetailsCallback();
327-
}
328-
};
336+
const configurationChangeCallback = useCallback<EventHandler<ClientProviderEvents.ConfigurationChanged>>(
337+
(eventDetails) => {
338+
if (shouldEvaluateFlag(flagKey, eventDetails?.flagsChanged)) {
339+
updateEvaluationDetailsCallback();
340+
}
341+
},
342+
[flagKey, updateEvaluationDetailsCallback],
343+
);
329344

330345
useEffect(() => {
346+
const controller = new AbortController();
331347
if (status === ProviderStatus.NOT_READY) {
332348
// update when the provider is ready
333349
client.addHandler(ProviderEvents.Ready, updateEvaluationDetailsCallback, { signal: controller.signal });
@@ -348,7 +364,14 @@ function attachHandlersAndResolve<T extends FlagValue>(
348364
// cleanup the handlers
349365
controller.abort();
350366
};
351-
}, []);
367+
}, [
368+
client,
369+
status,
370+
defaultedOptions.updateOnContextChanged,
371+
defaultedOptions.updateOnConfigurationChanged,
372+
updateEvaluationDetailsCallback,
373+
configurationChangeCallback,
374+
]);
352375

353376
return evaluationDetails;
354377
}

packages/react/test/evaluation.spec.tsx

Lines changed: 113 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -924,17 +924,124 @@ describe('evaluation', () => {
924924
OpenFeature.setContext(SUSPEND_OFF, { user: TARGETED_USER });
925925
});
926926

927-
// expect to see static value until we reconcile
928-
await waitFor(() => expect(screen.queryByText(STATIC_FLAG_VALUE_A)).toBeInTheDocument(), {
929-
timeout: DELAY / 2,
930-
});
931-
932-
// make sure we updated after reconciling
927+
// With the fix for useState initialization, the hook now immediately
928+
// reflects provider state changes. This is intentional to handle cases
929+
// where providers don't emit proper events.
930+
// The value updates immediately to the targeted value.
933931
await waitFor(() => expect(screen.queryByText(TARGETED_FLAG_VALUE)).toBeInTheDocument(), {
934932
timeout: DELAY * 2,
935933
});
936934
});
937935
});
936+
937+
describe('re-render behavior when flag values change without provider events', ()=> {
938+
it('should reflect provider state changes on re-render even without provider events', async () => {
939+
let providerValue = 'initial-value';
940+
941+
class SilentUpdateProvider extends InMemoryProvider {
942+
resolveBooleanEvaluation() {
943+
return {
944+
value: true,
945+
variant: 'on',
946+
reason: StandardResolutionReasons.STATIC,
947+
};
948+
}
949+
950+
resolveStringEvaluation() {
951+
return {
952+
value: providerValue,
953+
variant: providerValue,
954+
reason: StandardResolutionReasons.STATIC,
955+
};
956+
}
957+
}
958+
959+
const provider = new SilentUpdateProvider({});
960+
await OpenFeature.setProviderAndWait('test', provider);
961+
962+
// The triggerRender prop forces a re-render
963+
const TestComponent = ({ triggerRender }: { triggerRender: number }) => {
964+
const { value } = useFlag('test-flag', 'default');
965+
return <div data-testid="flag-value" data-render-count={triggerRender}>{value}</div>;
966+
};
967+
968+
const WrapperComponent = () => {
969+
const [renderCount, setRenderCount] = useState(0);
970+
return (
971+
<>
972+
<button onClick={() => setRenderCount(c => c + 1)}>Force Re-render</button>
973+
<TestComponent triggerRender={renderCount} />
974+
</>
975+
);
976+
};
977+
978+
const { getByText } = render(
979+
<OpenFeatureProvider client={OpenFeature.getClient('test')}>
980+
<WrapperComponent />
981+
</OpenFeatureProvider>
982+
);
983+
984+
// Initial value should be rendered
985+
await waitFor(() => {
986+
expect(screen.getByTestId('flag-value')).toHaveTextContent('initial-value');
987+
});
988+
989+
// Change the provider's internal state (without emitting events)
990+
providerValue = 'updated-value';
991+
992+
// Force a re-render of the component
993+
act(() => {
994+
getByText('Force Re-render').click();
995+
});
996+
997+
await waitFor(() => {
998+
expect(screen.getByTestId('flag-value')).toHaveTextContent('updated-value');
999+
});
1000+
});
1001+
1002+
it('should update flag value when flag key prop changes without provider events', async () => {
1003+
const provider = new InMemoryProvider({
1004+
'flag-a': {
1005+
disabled: false,
1006+
variants: { on: 'value-a' },
1007+
defaultVariant: 'on',
1008+
},
1009+
'flag-b': {
1010+
disabled: false,
1011+
variants: { on: 'value-b' },
1012+
defaultVariant: 'on',
1013+
},
1014+
});
1015+
1016+
await OpenFeature.setProviderAndWait(EVALUATION, provider);
1017+
1018+
const TestComponent = ({ flagKey }: { flagKey: string }) => {
1019+
const { value } = useFlag(flagKey, 'default');
1020+
return <div data-testid="flag-value">{value}</div>;
1021+
};
1022+
1023+
const { rerender } = render(
1024+
<OpenFeatureProvider client={OpenFeature.getClient(EVALUATION)}>
1025+
<TestComponent flagKey="flag-a" />
1026+
</OpenFeatureProvider>
1027+
);
1028+
1029+
await waitFor(() => {
1030+
expect(screen.getByTestId('flag-value')).toHaveTextContent('value-a');
1031+
});
1032+
1033+
// Change to flag-b (without any provider events)
1034+
rerender(
1035+
<OpenFeatureProvider client={OpenFeature.getClient(EVALUATION)}>
1036+
<TestComponent flagKey="flag-b" />
1037+
</OpenFeatureProvider>
1038+
);
1039+
1040+
await waitFor(() => {
1041+
expect(screen.getByTestId('flag-value')).toHaveTextContent('value-b');
1042+
});
1043+
});
1044+
});
9381045
});
9391046

9401047
describe('context, hooks and options', () => {

packages/react/test/provider.spec.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,7 @@ describe('OpenFeatureProvider', () => {
288288
{ timeout: DELAY * 4 },
289289
);
290290

291-
expect(screen.getByText('Will says hi')).toBeInTheDocument();
291+
expect(screen.getByText('Will says aloha')).toBeInTheDocument();
292292
});
293293
});
294294
});

release-please-config.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
"versioning": "default"
2121
},
2222
"packages/react": {
23-
"release-as": "1.0.0",
2423
"release-type": "node",
2524
"prerelease": false,
2625
"bump-minor-pre-major": true,

0 commit comments

Comments
 (0)