From d94fb746e2264bf5bf7e8666db045fc42903882e Mon Sep 17 00:00:00 2001 From: Michael Beemer Date: Fri, 21 Nov 2025 10:13:19 -0500 Subject: [PATCH 1/2] fix(react): compare full EvaluationDetails to prevent stale data Signed-off-by: Michael Beemer --- .../react/src/evaluation/use-feature-flag.ts | 10 +-- packages/react/src/internal/is-equal.ts | 10 +-- packages/react/test/evaluation.spec.tsx | 73 +++++++++++++++++++ 3 files changed, 81 insertions(+), 12 deletions(-) diff --git a/packages/react/src/evaluation/use-feature-flag.ts b/packages/react/src/evaluation/use-feature-flag.ts index bf1d3db74..a34642062 100644 --- a/packages/react/src/evaluation/use-feature-flag.ts +++ b/packages/react/src/evaluation/use-feature-flag.ts @@ -307,9 +307,9 @@ function attachHandlersAndResolve( isFirstRender.current = false; return; } - + const newDetails = resolver(client).call(client, flagKey, defaultValue, options); - if (!isEqual(newDetails.value, evaluationDetails.value)) { + if (!isEqual(newDetails, evaluationDetails)) { setEvaluationDetails(newDetails); } }, [client, flagKey, defaultValue, options, resolver, evaluationDetails]); @@ -324,11 +324,9 @@ function attachHandlersAndResolve( const updatedEvaluationDetails = resolver(client).call(client, flagKey, defaultValue, options); /** - * Avoid re-rendering if the value hasn't changed. We could expose a means - * to define a custom comparison function if users require a more - * sophisticated comparison in the future. + * Avoid re-rendering if the evaluation details haven't changed. */ - if (!isEqual(updatedEvaluationDetails.value, evaluationDetailsRef.current.value)) { + if (!isEqual(updatedEvaluationDetails, evaluationDetailsRef.current)) { setEvaluationDetails(updatedEvaluationDetails); } }, [client, flagKey, defaultValue, options, resolver]); diff --git a/packages/react/src/internal/is-equal.ts b/packages/react/src/internal/is-equal.ts index 25016851d..74bb89a6a 100644 --- a/packages/react/src/internal/is-equal.ts +++ b/packages/react/src/internal/is-equal.ts @@ -1,13 +1,11 @@ -import { type FlagValue } from '@openfeature/web-sdk'; - /** * Deeply compare two values to determine if they are equal. * Supports primitives and serializable objects. - * @param {FlagValue} value First value to compare - * @param {FlagValue} other Second value to compare + * @param {unknown} value First value to compare + * @param {unknown} other Second value to compare * @returns {boolean} True if the values are equal */ -export function isEqual(value: FlagValue, other: FlagValue): boolean { +export function isEqual(value: unknown, other: unknown): boolean { if (value === other) { return true; } @@ -16,7 +14,7 @@ export function isEqual(value: FlagValue, other: FlagValue): boolean { return false; } - if (typeof value === 'object' && value !== null && other !== null) { + if (typeof value === 'object' && value !== null && typeof other === 'object' && other !== null) { const valueKeys = Object.keys(value); const otherKeys = Object.keys(other); diff --git a/packages/react/test/evaluation.spec.tsx b/packages/react/test/evaluation.spec.tsx index 0176d02cb..ff6c41e91 100644 --- a/packages/react/test/evaluation.spec.tsx +++ b/packages/react/test/evaluation.spec.tsx @@ -585,6 +585,79 @@ describe('evaluation', () => { return new TestingProvider(CONFIG, DELAY); // delay init by 100ms }; + describe('provider ready event updates', () => { + it('should update EvaluationDetails when provider becomes ready (including reason)', async () => { + const PROVIDER_READY_DOMAIN = 'provider-ready-test'; + const TEST_FLAG_KEY = 'test-flag'; + const FLAG_VALUE = true; + + // Create a provider that will delay initialization + const provider = new TestingProvider( + { + [TEST_FLAG_KEY]: { + disabled: false, + variants: { + on: FLAG_VALUE, + off: false, + }, + defaultVariant: 'on', + }, + }, + DELAY, + ); + + OpenFeature.setProvider(PROVIDER_READY_DOMAIN, provider); + + let capturedDetails: EvaluationDetails | undefined; + let renderCount = 0; + + function TestComponent() { + renderCount++; + const details = useBooleanFlagDetails(TEST_FLAG_KEY, FLAG_VALUE); + capturedDetails = details; + + return ( +
+
{String(details.value)}
+
{details.reason}
+
{renderCount}
+
+ ); + } + + render( + + + , + ); + + // Initial render - provider is NOT_READY, should use default value with ERROR reason + expect(capturedDetails?.value).toBe(FLAG_VALUE); + expect(capturedDetails?.reason).toBe(StandardResolutionReasons.ERROR); + expect(capturedDetails?.errorCode).toBe(ErrorCode.PROVIDER_NOT_READY); + expect(screen.getByTestId('reason')).toHaveTextContent(StandardResolutionReasons.ERROR); + + const initialRenderCount = renderCount; + + // Wait for provider to become ready and component to re-render + await waitFor( + () => { + expect(capturedDetails?.reason).toBe(StandardResolutionReasons.STATIC); + }, + { timeout: DELAY * 2 }, + ); + + // After provider is ready, same value but different reason and no error + expect(capturedDetails?.value).toBe(FLAG_VALUE); + expect(capturedDetails?.errorCode).toBeUndefined(); + expect(screen.getByTestId('value')).toHaveTextContent(String(FLAG_VALUE)); + expect(screen.getByTestId('reason')).toHaveTextContent(StandardResolutionReasons.STATIC); + + // Verify that a re-render occurred + expect(renderCount).toBeGreaterThan(initialRenderCount); + }); + }); + describe('when using the noop provider', () => { function TestComponent() { const { value } = useSuspenseFlag(SUSPENSE_FLAG_KEY, DEFAULT); From 6401e7e80b3f8e66b11e0e6d92b09c8366a346aa Mon Sep 17 00:00:00 2001 From: Michael Beemer Date: Fri, 21 Nov 2025 10:25:54 -0500 Subject: [PATCH 2/2] updated the jsdoc to explicty state known limitations Signed-off-by: Michael Beemer --- packages/react/src/internal/is-equal.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/react/src/internal/is-equal.ts b/packages/react/src/internal/is-equal.ts index 74bb89a6a..b52f30a68 100644 --- a/packages/react/src/internal/is-equal.ts +++ b/packages/react/src/internal/is-equal.ts @@ -1,6 +1,9 @@ /** * Deeply compare two values to determine if they are equal. * Supports primitives and serializable objects. + * + * Note: Does not handle Date, RegExp, Map, Set, or circular references. + * Suitable for comparing EvaluationDetails and other JSON-serializable data. * @param {unknown} value First value to compare * @param {unknown} other Second value to compare * @returns {boolean} True if the values are equal