Skip to content
Merged
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
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,39 @@ may be beneficial to immediately render the application and handle display of lo
The React Client SDK's asynchronous mode allows this by passing the optional `async` prop when connecting with the
`FFContextProvider`.


## On Flag Not Found
The `onFlagNotFound` option allows you to handle situations where a default variation is returned.
It includes the flag, variation, and whether the SDK was still initializing (`loading)` when the default was served.

This can happen when:

1. Using `async` mode without `cache` or `initialEvaluations` and where the SDK is still initializing.
2. The flag identifier is incorrect (e.g., due to a typo).
3. The wrong API key is being used, and the expected flags are not available for that project.

```typescript jsx
<FFContextProvider
apiKey="YOUR_API_KEY"
target={{
identifier: 'reactclientsdk',
name: 'ReactClientSDK'
}}
onFlagNotFound={(flagNotFoundPayload, loading) => {
if (loading) {
console.debug(`Flag "${flagNotFound.flag}" not found because the SDK is still initializing. Returned default: ${flagNotFound.defaultVariation}`);
} else {
console.warn(`Flag "${flagNotFound.flag}" not found. Returned default: ${flagNotFound.defaultVariation}`);
}
}}
>
<MyApp />
</FFContextProvider>

```

By using the `onFlagNotFound` prop, your application can be notified whenever a flag is missing and the default variation has been returned.

## Caching evaluations

In practice flags rarely change and so it can be useful to cache the last received evaluations from the server to allow
Expand Down
18 changes: 9 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@harnessio/ff-react-client-sdk",
"version": "1.13.0",
"version": "1.14.0",
"author": "Harness",
"license": "Apache-2.0",
"module": "dist/esm/index.js",
Expand All @@ -21,7 +21,7 @@
"react": ">=16.7.0"
},
"dependencies": {
"@harnessio/ff-javascript-client-sdk": "^1.28.0",
"@harnessio/ff-javascript-client-sdk": "^1.29.0",
"lodash.omit": "^4.5.0"
},
"devDependencies": {
Expand Down
208 changes: 208 additions & 0 deletions src/context/FFContext.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import { render, waitFor } from '@testing-library/react'
import { FFContextProvider, FFContextProviderProps } from './FFContext'
import { Event, initialize } from '@harnessio/ff-javascript-client-sdk'

// Mock the FF JS SDK
jest.mock('@harnessio/ff-javascript-client-sdk', () => ({
initialize: jest.fn(),
// Mock these so we can manually invoke the callback functions in the tests
Event: {
READY: 'ready',
ERROR_DEFAULT_VARIATION_RETURNED: 'default variation returned',
ERROR_AUTH: 'auth error'
}
}))

const mockInitialize = initialize as jest.Mock

describe('FFContextProvider', () => {
beforeEach(() => {
jest.clearAllMocks()
})

test('it renders fallback content while loading', async () => {
mockInitialize.mockReturnValueOnce({
on: jest.fn(),
off: jest.fn(),
close: jest.fn()
})

const { getByText } = render(
<FFContextProvider
apiKey="test-api-key"
target={{ identifier: 'test-target' }}
>
<p>Loaded</p>
</FFContextProvider>
)

// Initially, it should show the fallback (loading state)
expect(getByText('Loading...')).toBeInTheDocument()
})

test('it updates loading state after SDK is ready', async () => {
const mockOn = jest.fn()

mockInitialize.mockReturnValueOnce({
on: mockOn,
off: jest.fn(),
close: jest.fn()
})

const { getByText, queryByText } = render(
<FFContextProvider
apiKey="test-api-key"
target={{ identifier: 'test-target' }}
>
<p>Loaded</p>
</FFContextProvider>
)

// Simulate the SDK's "ready" event
const readyCallback = mockOn.mock.calls.find(
(call) => call[0] === Event.READY
)?.[1]
readyCallback?.({})

// Wait for the component to update after the SDK is ready
await waitFor(() =>
expect(queryByText('Loading...')).not.toBeInTheDocument()
)
expect(getByText('Loaded')).toBeInTheDocument()
})

test('it triggers onFlagNotFound callback when flag is missing with async disabled', async () => {
const mockOn = jest.fn()
const mockOnFlagNotFound = jest.fn()

mockInitialize.mockReturnValueOnce({
on: mockOn,
off: jest.fn(),
close: jest.fn()
})

render(
<FFContextProvider
apiKey="test-api-key"
target={{ identifier: 'test-target' }}
onFlagNotFound={mockOnFlagNotFound}
async={false}
>
<p>Loaded</p>
</FFContextProvider>
)

// SDK initialises successfully and emits the ready event
const readyCallback = mockOn.mock.calls.find(
(call) => call[0] === Event.READY
)?.[1]
readyCallback?.({})

// Flag can't be found after initialising
const flagNotFoundCallback = mockOn.mock.calls.find(
(call) => call[0] === Event.ERROR_DEFAULT_VARIATION_RETURNED
)?.[1]
flagNotFoundCallback?.({
flag: 'missingFlag',
defaultVariation: 'defaultValue'
})

// Verify the onFlagNotFound callback was called after initialization
await waitFor(() =>
expect(mockOnFlagNotFound).toHaveBeenCalledWith(
{ flag: 'missingFlag', defaultVariation: 'defaultValue' },
false
)
)
})

test('it triggers onFlagNotFound callback when flag is missing with async enabled', async () => {
const mockOn = jest.fn()
const mockOnFlagNotFound = jest.fn()

mockInitialize.mockReturnValueOnce({
on: mockOn,
off: jest.fn(),
close: jest.fn()
})

render(
<FFContextProvider
apiKey="test-api-key"
target={{ identifier: 'test-target' }}
onFlagNotFound={mockOnFlagNotFound}
async={true}
>
<p>Loaded</p>
</FFContextProvider>
)

// Flag can't be found as not initialised
const flagNotFoundCallback = mockOn.mock.calls.find(
(call) => call[0] === Event.ERROR_DEFAULT_VARIATION_RETURNED
)?.[1]
flagNotFoundCallback?.({
flag: 'missingFlag',
defaultVariation: 'defaultValue'
})

// Verify the onFlagNotFound callback was called immediately and with loading as true
expect(mockOnFlagNotFound).toHaveBeenCalledWith(
{ flag: 'missingFlag', defaultVariation: 'defaultValue' },
true
)

// SDK initialises successfully,
const readyCallback = mockOn.mock.calls.find(
(call) => call[0] === Event.READY
)?.[1]
readyCallback?.({})

// Flag still not found after successful init
flagNotFoundCallback?.({
flag: 'missingFlag',
defaultVariation: 'defaultValue'
})

// Verify onFlagNotFound called again, this time with loading as false
await waitFor(() =>
expect(mockOnFlagNotFound).toHaveBeenCalledWith(
{ flag: 'missingFlag', defaultVariation: 'defaultValue' },
false
)
)
})

test('it triggers onError callback when a network error occurs', async () => {
const mockOn = jest.fn()
const mockOnError = jest.fn()

mockInitialize.mockReturnValueOnce({
on: mockOn,
off: jest.fn(),
close: jest.fn()
})

render(
<FFContextProvider
apiKey="test-api-key"
target={{ identifier: 'test-target' }}
onError={mockOnError}
>
<p>Loaded</p>
</FFContextProvider>
)

// SDK fails to init and emits the error event
const authErrorCallback = mockOn.mock.calls.find(
(call) => call[0] === Event.ERROR_AUTH
)?.[1]
authErrorCallback?.('Auth error message')

// Verify the onError callback was called
expect(mockOnError).toHaveBeenCalledWith(
Event.ERROR_AUTH,
'Auth error message'
)
})
})
Loading