Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 67 additions & 36 deletions src/hooks/useDecide.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@

import { vi, describe, it, expect, beforeEach } from 'vitest';
import React from 'react';
import { act } from '@testing-library/react';
import { act, waitFor } from '@testing-library/react';
import { renderHook } from '@testing-library/react';
import { OptimizelyContext, ProviderStateStore } from '../provider/index';
import { OptimizelyContext, ProviderStateStore, OptimizelyProvider } from '../provider/index';
import { REACT_CLIENT_META } from '../client/index';
import { useDecide } from './useDecide';
import type {
OptimizelyUserContext,
Expand Down Expand Up @@ -68,6 +69,49 @@ function createMockClient(hasConfig = false): Client {
} as unknown as Client;
}

/**
* Creates a mock client with notification center support and wraps it in OptimizelyProvider.
* Used for integration-style tests that need the full Provider lifecycle.
*/
function createProviderWrapper(mockUserContext: OptimizelyUserContext) {
let configUpdateCallback: (() => void) | undefined;

const client = {
getOptimizelyConfig: vi.fn().mockReturnValue({ revision: '1' }),
createUserContext: vi.fn().mockReturnValue(mockUserContext),
onReady: vi.fn().mockResolvedValue(undefined),
isOdpIntegrated: vi.fn().mockReturnValue(false),
notificationCenter: {
addNotificationListener: vi.fn().mockImplementation((type: string, cb: () => void) => {
if (type === 'OPTIMIZELY_CONFIG_UPDATE') {
configUpdateCallback = cb;
}
return 1;
}),
removeNotificationListener: vi.fn(),
},
} as unknown as Client;

(client as unknown as Record<symbol, unknown>)[REACT_CLIENT_META] = {
hasOdpManager: false,
hasVuidManager: false,
};

function Wrapper({ children }: { children: React.ReactNode }) {
return (
<OptimizelyProvider client={client} user={{ id: 'user-1' }}>
{children}
</OptimizelyProvider>
);
}

return {
wrapper: Wrapper,
client,
fireConfigUpdate: () => configUpdateCallback?.(),
};
}

function createWrapper(store: ProviderStateStore, client: Client) {
const contextValue: OptimizelyContextValue = { store, client };

Expand Down Expand Up @@ -177,25 +221,6 @@ describe('useDecide', () => {
expect(result.current.decision).toBe(MOCK_DECISION);
});

it('should re-evaluate when setClientReady fire', async () => {
const mockUserContext = createMockUserContext();
store.setUserContext(mockUserContext);
// Client has no config yet
const wrapper = createWrapper(store, mockClient);
const { result } = renderHook(() => useDecide('flag_1'), { wrapper });

expect(result.current.isLoading).toBe(true);

// Simulate config becoming available when onReady resolves
(mockClient.getOptimizelyConfig as ReturnType<typeof vi.fn>).mockReturnValue({ revision: '1' });
await act(async () => {
store.setClientReady(true);
});

expect(result.current.isLoading).toBe(false);
expect(result.current.decision).toBe(MOCK_DECISION);
});

it('should return error from store with isLoading: false', async () => {
const wrapper = createWrapper(store, mockClient);
const { result } = renderHook(() => useDecide('flag_1'), { wrapper });
Expand Down Expand Up @@ -319,31 +344,37 @@ describe('useDecide', () => {
expect(result.current.decision).toBeNull();
});

it('should re-call decide() when setClientReady fires after sync decision was already served', async () => {
// Sync datafile scenario: config + userContext available before onReady
mockClient = createMockClient(true);
it('should re-evaluate decision when OPTIMIZELY_CONFIG_UPDATE fires from the client', async () => {
const mockUserContext = createMockUserContext();
store.setUserContext(mockUserContext);
const { wrapper, fireConfigUpdate } = createProviderWrapper(mockUserContext);

const wrapper = createWrapper(store, mockClient);
const { result } = renderHook(() => useDecide('flag_1'), { wrapper });

// Decision already served
expect(result.current.isLoading).toBe(false);
// Wait for Provider's onReady + UserContextManager + queueMicrotask chain to complete
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});

expect(result.current.decision).toBe(MOCK_DECISION);
expect(mockUserContext.decide).toHaveBeenCalledTimes(1);

// onReady() resolves → setClientReady(true) fires → store state changes →
// useSyncExternalStore re-renders → useMemo recomputes → decide() called again.
// This is a redundant call since config + userContext haven't changed,
// but it's a one-time cost per flag per page load.
const callCountBeforeUpdate = (mockUserContext.decide as ReturnType<typeof vi.fn>).mock.calls.length;

// Simulate a new datafile with a different decision
const updatedDecision: OptimizelyDecision = {
...MOCK_DECISION,
variationKey: 'variation_2',
variables: { color: 'blue' },
};
(mockUserContext.decide as ReturnType<typeof vi.fn>).mockReturnValue(updatedDecision);

// Fire the config update notification (as the SDK would on datafile poll)
await act(async () => {
store.setClientReady(true);
fireConfigUpdate();
});

expect(mockUserContext.decide).toHaveBeenCalledTimes(2);
expect(mockUserContext.decide).toHaveBeenCalledTimes(callCountBeforeUpdate + 1);
expect(result.current.decision).toBe(updatedDecision);
expect(result.current.isLoading).toBe(false);
expect(result.current.decision).toBe(MOCK_DECISION);
});

describe('forced decision reactivity', () => {
Expand Down
4 changes: 2 additions & 2 deletions src/hooks/useOptimizelyClient.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,12 @@ describe('useOptimizelyClient', () => {

// Trigger store state changes that should NOT cause useOptimizelyClient to re-render
act(() => {
store.setClientReady(true);
store.setError(new Error('test'));
});
expect(capturedRenderCount).toBe(initialRenderCount);

act(() => {
store.setError(new Error('test'));
store.refresh();
});
expect(capturedRenderCount).toBe(initialRenderCount);
});
Expand Down
4 changes: 2 additions & 2 deletions src/hooks/useOptimizelyUserContext.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -206,11 +206,11 @@ describe('useOptimizelyUserContext', () => {

const initialRenderCount = capturedRenderCount;

// Changing isClientReady triggers a store notification,
// Triggering a store notification via setState,
// but since the derived result hasn't changed, useMemo returns
// the same reference and React bails out
act(() => {
store.setClientReady(true);
store.refresh();
});

expect(capturedRenderCount).toBe(initialRenderCount);
Expand Down
106 changes: 93 additions & 13 deletions src/provider/OptimizelyProvider.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@ function createMockClient(
createUserContext: vi.fn().mockReturnValue(mockUserContext),
close: vi.fn(),
getOptimizelyConfig: vi.fn(),
notificationCenter: {} as OptimizelyClient['notificationCenter'],
notificationCenter: {
addNotificationListener: vi.fn().mockReturnValue(1),
removeNotificationListener: vi.fn(),
} as unknown as OptimizelyClient['notificationCenter'],
sendOdpEvent: vi.fn(),
isOdpIntegrated: vi.fn().mockReturnValue(false),
...overrides,
Expand Down Expand Up @@ -151,7 +154,7 @@ describe('OptimizelyProvider', () => {
expect(mockClient.onReady).toHaveBeenCalledWith({ timeout: 5000 });
});

it('should set isClientReady to true when onReady succeeds', async () => {
it('should not set error when onReady succeeds', async () => {
const mockClient = createMockClient({
onReady: vi.fn().mockResolvedValue(undefined),
});
Expand All @@ -165,13 +168,12 @@ describe('OptimizelyProvider', () => {

await waitFor(() => {
expect(capturedContext).not.toBeNull();
expect(capturedContext!.store.getState().isClientReady).toBe(true);
});

expect(capturedContext!.store.getState().error).toBeNull();
});

it('should set isClientReady to false and set error when onReady rejects', async () => {
it('should set error when onReady rejects', async () => {
const testError = new Error('Client initialization failed');
const mockClient = createMockClient({
onReady: vi.fn().mockRejectedValue(testError),
Expand All @@ -188,9 +190,6 @@ describe('OptimizelyProvider', () => {
expect(capturedContext).not.toBeNull();
expect(capturedContext!.store.getState().error).toBe(testError);
});

// Client is NOT ready when onReady rejects
expect(capturedContext!.store.getState().isClientReady).toBe(false);
});

it('should set error when onReady times out (rejects)', async () => {
Expand All @@ -210,8 +209,6 @@ describe('OptimizelyProvider', () => {
expect(capturedContext).not.toBeNull();
expect(capturedContext!.store.getState().error).toBe(timeoutError);
});

expect(capturedContext!.store.getState().isClientReady).toBe(false);
});
});

Expand Down Expand Up @@ -243,15 +240,13 @@ describe('OptimizelyProvider', () => {

await waitFor(() => {
expect(capturedContext).not.toBeNull();
expect(capturedContext!.store.getState().isClientReady).toBe(true);
});

const store = capturedContext!.store;

unmount();

// Store should be reset
expect(store.getState().isClientReady).toBe(false);
expect(store.getState().userContext).toBeNull();
expect(store.getState().error).toBeNull();
});
Expand Down Expand Up @@ -569,8 +564,8 @@ describe('OptimizelyProvider', () => {
resolveOnReady!();
});

// Store was reset on unmount, and onReady resolution should not set isClientReady
expect(store.getState().isClientReady).toBe(false);
// Store was reset on unmount, onReady resolution should not affect store
expect(store.getState().error).toBeNull();
});

it('should call onReady again when client changes', async () => {
Expand Down Expand Up @@ -638,6 +633,91 @@ describe('OptimizelyProvider', () => {
});
});

describe('config update subscription', () => {
it('should subscribe to OPTIMIZELY_CONFIG_UPDATE on mount', () => {
const mockClient = createMockClient();

render(
<OptimizelyProvider client={mockClient}>
<div>Child</div>
</OptimizelyProvider>
);

expect(mockClient.notificationCenter.addNotificationListener).toHaveBeenCalledWith(
'OPTIMIZELY_CONFIG_UPDATE',
expect.any(Function)
);
});

it('should remove notification listener on unmount', () => {
const mockClient = createMockClient();

const { unmount } = render(
<OptimizelyProvider client={mockClient}>
<div>Child</div>
</OptimizelyProvider>
);

unmount();

expect(mockClient.notificationCenter.removeNotificationListener).toHaveBeenCalledWith(1);
});

it('should trigger store state change when config update fires', async () => {
const mockClient = createMockClient();
let capturedContext: OptimizelyContextValue | null = null;

render(
<OptimizelyProvider client={mockClient}>
<ContextConsumer onContext={(ctx) => (capturedContext = ctx)} />
</OptimizelyProvider>
);

await waitFor(() => {
expect(capturedContext).not.toBeNull();
});

const stateBefore = capturedContext!.store.getState();

// Get the callback that was registered and invoke it
const configUpdateCallback = (
mockClient.notificationCenter.addNotificationListener as ReturnType<typeof vi.fn>
).mock.calls.find((call: unknown[]) => call[0] === 'OPTIMIZELY_CONFIG_UPDATE')![1];

await act(() => {
configUpdateCallback();
});

const stateAfter = capturedContext!.store.getState();

// State should be a new reference (triggers useSyncExternalStore subscribers)
expect(stateBefore).not.toBe(stateAfter);
});

it('should re-subscribe when client changes', () => {
const mockClient1 = createMockClient();
const mockClient2 = createMockClient();

const { rerender } = render(
<OptimizelyProvider client={mockClient1}>
<div>Child</div>
</OptimizelyProvider>
);

expect(mockClient1.notificationCenter.addNotificationListener).toHaveBeenCalledTimes(1);

rerender(
<OptimizelyProvider client={mockClient2}>
<div>Child</div>
</OptimizelyProvider>
);

// Old listener cleaned up, new one registered
expect(mockClient1.notificationCenter.removeNotificationListener).toHaveBeenCalledWith(1);
expect(mockClient2.notificationCenter.addNotificationListener).toHaveBeenCalledTimes(1);
});
});

describe('context reference identity', () => {
it('should change context value reference when client changes', async () => {
const mockClient1 = createMockClient();
Expand Down
Loading
Loading