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..b52f30a68 100644 --- a/packages/react/src/internal/is-equal.ts +++ b/packages/react/src/internal/is-equal.ts @@ -1,13 +1,14 @@ -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 + * + * 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 */ -export function isEqual(value: FlagValue, other: FlagValue): boolean { +export function isEqual(value: unknown, other: unknown): boolean { if (value === other) { return true; } @@ -16,7 +17,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);