diff --git a/.gitignore b/.gitignore index 69897ad..11cb45c 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ lib .npmrc dist/ build/ +.build/ +.github/prompts/ .rpt2_cache .env diff --git a/README.md b/README.md index 9cd42c5..1aba445 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,6 @@ Refer to the [React SDK's developer documentation](https://docs.developers.optim For React Native, review the [React Native developer documentation](https://docs.developers.optimizely.com/feature-experimentation/docs/javascript-react-native-sdk). - ### Features - Automatic datafile downloading @@ -28,11 +27,7 @@ The React SDK is compatible with `React 16.8.0 +` ### Example ```jsx -import { - createInstance, - OptimizelyProvider, - useDecision, -} from '@optimizely/react-sdk'; +import { createInstance, OptimizelyProvider, useDecision } from '@optimizely/react-sdk'; const optimizelyClient = createInstance({ sdkKey: 'your-optimizely-sdk-key', @@ -43,8 +38,8 @@ function MyComponent() { return ( - { decision.variationKey === 'relevant_first' && } - { decision.variationKey === 'recent_first' && } + {decision.variationKey === 'relevant_first' && } + {decision.variationKey === 'recent_first' && } ); } @@ -70,7 +65,8 @@ class App extends React.Component { npm install @optimizely/react-sdk ``` -For **React Native**, installation instruction is bit different. Check out the +For **React Native**, installation instruction is bit different. Check out the + - [Official Installation guide](https://docs.developers.optimizely.com/feature-experimentation/docs/install-sdk-reactnative) - [Expo React Native Sample App](https://github.com/optimizely/expo-react-native-sdk-sample) @@ -155,9 +151,9 @@ function MyComponent() { const [decision, isClientReady, didTimeout] = useDecision('the-flag'); return ( - { isClientReady &&
The Component
} - { didTimeout &&
Default Component
} - { /* If client is not ready and time out has not occured yet, do not render anything */ } + {isClientReady &&
The Component
} + {didTimeout &&
Default Component
} + {/* If client is not ready and time out has not occured yet, do not render anything */}
); } @@ -277,7 +273,7 @@ class MyComp extends React.Component { constructor(props) { super(props); const { optimizely } = this.props; - const decision = optimizely.decide('feat1'); + const decision = optimizely.decide('feat1'); this.state = { decision.enabled, @@ -298,9 +294,11 @@ const WrappedMyComponent = withOptimizely(MyComp); Any component under the `` can access the Optimizely `ReactSDKClient` via the `OptimizelyContext` with `useContext`. _arguments_ + - `OptimizelyContext : React.Context` The Optimizely context initialized in a parent component (or App). _returns_ + - Wrapped object: - `optimizely : ReactSDKClient` The client object which was passed to the `OptimizelyProvider` - `isServerSide : boolean` Value that was passed to the `OptimizelyProvider` @@ -321,10 +319,10 @@ function MyComponent() { }; return ( <> - { decision.enabled &&

My feature is enabled

} - { !decision.enabled &&

My feature is disabled

} - { decision.variationKey === 'control-variation' &&

Current Variation

} - { decision.variationKey === 'experimental-variation' &&

Better Variation

} + {decision.enabled &&

My feature is enabled

} + {!decision.enabled &&

My feature is disabled

} + {decision.variationKey === 'control-variation' &&

Current Variation

} + {decision.variationKey === 'experimental-variation' &&

Better Variation

} ); @@ -332,23 +330,22 @@ function MyComponent() { ``` ### Tracking + Use the built-in `useTrackEvent` hook to access the `track` method of optimizely instance ```jsx import { useTrackEvent } from '@optimizely/react-sdk'; function SignupButton() { - const [track, clientReady, didTimeout] = useTrackEvent() + const [track, clientReady, didTimeout] = useTrackEvent(); const handleClick = () => { - if(clientReady) { - track('signup-clicked') + if (clientReady) { + track('signup-clicked'); } - } + }; - return ( - - ) + return ; } ``` @@ -411,70 +408,99 @@ To rollout or experiment on a feature by user rather than by random percentage, ## Server Side Rendering -Right now server side rendering is possible with a few caveats. +The React SDK supports server-side rendering (SSR). To generate synchronous decisions during SSR, you must pre-fetch the datafile and pass it to `createInstance`. Using `sdkKey` alone is not supported for SSR because it requires an asynchronous network call. -**Caveats** +### Setup -1. You must download the datafile manually and pass in via the `datafile` option. Can not use `sdkKey` to automatically download. +Fetch the datafile on the server, create an Optimizely instance, and wrap your app with ``: -2. Rendering of components must be completely synchronous (this is true for all server side rendering), thus the Optimizely SDK assumes that the optimizely client has been instantiated and fired it's `onReady` event already. +```jsx +import { createInstance, OptimizelyProvider, useDecision } from '@optimizely/react-sdk'; -### Setting up `` +// Pre-fetched datafile (fetching mechanism depends on your framework) +const optimizelyClient = createInstance({ + datafile, // must be provided for SSR + sdkKey: 'YOUR_SDK_KEY' +}); -Similar to browser side rendering you will need to wrap your app (or portion of the app using Optimizely) in the `` component. A new prop -`isServerSide` must be equal to true. +function MyComponent() { + const [decision] = useDecision('flag1'); + return decision.enabled ?

Feature enabled

:

Feature disabled

; +} -```jsx - - - +// Wrap your app with OptimizelyProvider + + +; ``` -All other Optimizely components, such as `` and `` can remain the same. +### Configuring the instance for server use -### Full example +Server-side instances are short-lived (created per request) and may not be garbage collected immediately. To avoid unnecessary background work and ensure events are dispatched before the instance is discarded, configure `createInstance` with server-appropriate options: ```jsx -import * as React from 'react'; -import * as ReactDOMServer from 'react-dom/server'; +import { createInstance, OptimizelyDecideOption } from '@optimizely/react-sdk'; -import { - createInstance, - OptimizelyProvider, - useDecision, -} from '@optimizely/react-sdk'; +const isServer = typeof window === 'undefined'; -const fetch = require('node-fetch'); +const optimizelyClient = createInstance({ + datafile, + sdkKey: 'YOUR_SDK_KEY' + datafileOptions: { autoUpdate: !isServer }, + eventBatchSize: isServer ? 1 : 10, + eventMaxQueueSize: isServer ? 1 : 100, + // Optional: disable decision events on server if they will be sent from the client + defaultDecideOptions: isServer ? [OptimizelyDecideOption.DISABLE_DECISION_EVENT] : [], +}); +``` -function MyComponent() { - const [decision] = useDecision('flag1'); - return ( - - { decision.enabled &&

The feature is enabled

} - { !decision.enabled &&

The feature is not enabled

} - { decision.variationKey === 'variation1' &&

Variation 1

} - { decision.variationKey === 'variation2' &&

Variation 2

} -
- ); -} +| Option | Server value | Why | +|---|---|---| +| `datafileOptions.autoUpdate` | `false` | No need to poll for datafile updates on a per-request instance | +| `eventBatchSize` | `1` | Flush events immediately — the instance won't live long enough for a batch to fill | +| `eventMaxQueueSize` | `1` | Prevent event accumulation in a short-lived instance | +| `defaultDecideOptions` | `[DISABLE_DECISION_EVENT]` | Optional — avoids duplicate decision events if the client will also fire them after hydration | -async function main() { - const resp = await fetch('https://cdn.optimizely.com/datafiles/.json'); - const datafile = await resp.json(); - const optimizelyClient = createInstance({ - datafile, +### React Server Components + +The SDK can also be used directly in React Server Components without `OptimizelyProvider`. Create an instance, set the user, wait for readiness, and make decisions — all within an `async` server component: + +```tsx +import { createInstance } from '@optimizely/react-sdk'; + +export default async function ServerExperiment() { + const client = createInstance({ + sdkKey: process.env.OPTIMIZELY_SDK_KEY || '', }); - const output = ReactDOMServer.renderToString( - - - - ); - console.log('output', output); + client.setUser({ + id: 'user-123', + }); + + await client.onReady(); + + const decision = client.decide('flag-1'); + + client.close(); + + return decision.enabled + ?

Experiment Variation

+ :

Control

; } -main(); ``` +### Next.js Integration + +For detailed Next.js examples covering both App Router and Pages Router patterns, see the [Next.js Integration Guide](docs/nextjs-integration.md). + +### Limitations + +- **Datafile required** — SSR requires a pre-fetched datafile. Using `sdkKey` alone falls back to a failed decision. +- **Static user only** — User `Promise` is not supported during SSR. +- **ODP segments unavailable** — ODP audience segments require async I/O and are not available during server rendering. + +For more details and workarounds, see the [Next.js Integration Guide — Limitations](docs/nextjs-integration.md#limitations). + ## Disabled event dispatcher To disable sending all events to Optimizely's results backend, use the `logOnlyEventDispatcher` when creating a client: diff --git a/docs/nextjs-integration.md b/docs/nextjs-integration.md new file mode 100644 index 0000000..b06f960 --- /dev/null +++ b/docs/nextjs-integration.md @@ -0,0 +1,203 @@ +# Next.js Integration Guide + +This guide covers how to use the Optimizely React SDK with Next.js for server-side rendering (SSR) and React Server Components. + +## Prerequisites + +Install the React SDK: + +```bash +npm install @optimizely/react-sdk +``` + +You will need your Optimizely SDK key, available from the Optimizely app under **Settings > Environments**. + +## SSR with Pre-fetched Datafile + +Server-side rendering requires a pre-fetched datafile. The SDK cannot fetch the datafile asynchronously during server rendering, so you must fetch it beforehand and pass it to `createInstance`. + +There are many ways to pre-fetch the datafile on the server. Below are two common approaches you could follow. + +## Next.js App Router + +In the App Router, fetch the datafile in an async server component (e.g., your root layout) and pass it as a prop to a client-side provider. + +### 1. Create a datafile fetcher + +```ts +// src/data/getDatafile.ts +const CDN_URL = `https://cdn.optimizely.com/datafiles/${process.env.NEXT_PUBLIC_OPTIMIZELY_SDK_KEY}.json`; + +export async function getDatafile() { + const res = await fetch(CDN_URL); + + if (!res.ok) { + throw new Error(`Failed to fetch datafile: ${res.status}`); + } + + return res.json(); +} +``` + +### 2. Create a client-side provider + +Since `OptimizelyProvider` uses React Context (a client-side feature), it must be wrapped in a `'use client'` component: + +```tsx +// src/providers/OptimizelyProvider.tsx +'use client'; + +import { OptimizelyProvider, createInstance, OptimizelyDecideOption } from '@optimizely/react-sdk'; +import { ReactNode, useState } from 'react'; + +export function OptimizelyClientProvider({ children, datafile }: { children: ReactNode; datafile: object }) { + const [optimizely] = useState(() => { + const isServer = typeof window === 'undefined'; + return createInstance({ + datafile, + datafileOptions: { autoUpdate: !isServer }, + eventBatchSize: isServer ? 1 : 10, + eventMaxQueueSize: isServer ? 1 : 100, + // Optional: disable decision events on server if they will be sent from the client + defaultDecideOptions: isServer ? [OptimizelyDecideOption.DISABLE_DECISION_EVENT] : [], + }); + }); + const isServerSide = typeof window === 'undefined'; + + return ( + + {children} + + ); +} +``` + +> See [Configuring the instance for server use](../README.md#configuring-the-instance-for-server-use) in the README for an explanation of each option. + +### 3. Wire it up in your root layout + +```tsx +// src/app/layout.tsx +import { OptimizelyClientProvider } from '@/providers/OptimizelyProvider'; +import { getDatafile } from '@/data/getDatafile'; + +export default async function RootLayout({ children }: { children: React.ReactNode }) { + const datafile = await getDatafile(); + + return ( + + + {children} + + + ); +} +``` + +## Next.js Pages Router + +In the Pages Router, fetch the datafile in `getServerSideProps` (or `getStaticProps`) and pass it through `_app.tsx`. + +### 1. Create a client-side provider + +Same as the [App Router provider](#2-create-a-client-side-provider) above (without the `'use client'` directive, which is not needed in Pages Router). + +### 2. Set up `_app.tsx` + +```tsx +// pages/_app.tsx +import { OptimizelyClientProvider } from '@/providers/OptimizelyProvider'; +import type { AppProps } from 'next/app'; + +export default function App({ Component, pageProps }: AppProps) { + return ( + + + + ); +} +``` + +### 3. Fetch the datafile in your page + +```tsx +// pages/index.tsx +export async function getServerSideProps() { + const res = await fetch(`https://cdn.optimizely.com/datafiles/${process.env.NEXT_PUBLIC_OPTIMIZELY_SDK_KEY}.json`); + const datafile = await res.json(); + + return { props: { datafile } }; +} +``` + +#### Alternative: Static generation with revalidation + +If you prefer build-time fetching with periodic revalidation instead of per-request fetching: + +```tsx +export async function getStaticProps() { + const res = await fetch(`https://cdn.optimizely.com/datafiles/${process.env.NEXT_PUBLIC_OPTIMIZELY_SDK_KEY}.json`); + const datafile = await res.json(); + + return { + props: { datafile }, + revalidate: 60, // re-fetch every 60 seconds + }; +} +``` + +## Using Feature Flags in Client Components + +Once the provider is set up, use the `useDecision` hook in any client component: + +```tsx +'use client'; + +import { useDecision } from '@optimizely/react-sdk'; + +export default function FeatureBanner() { + const [decision] = useDecision('banner-flag'); + + return decision.enabled ?

New Banner

:

Default Banner

; +} +``` + +## Limitations + +### Datafile required for SSR + +SSR with `sdkKey` alone (without a pre-fetched datafile) is **not supported** because it requires an asynchronous network call that cannot complete during synchronous server rendering. If no datafile is provided, decisions will fall back to defaults. + +To handle this gracefully, render a loading state and let the client hydrate with the real decision: + +```tsx +'use client'; + +import { useDecision } from '@optimizely/react-sdk'; + +export default function MyFeature() { + const [decision, isClientReady, didTimeout] = useDecision('flag-1'); + + if (!didTimeout && !isClientReady) { + return

Loading...

; + } + + return decision.enabled ?

Feature Enabled

:

Feature Disabled

; +} +``` + +### Static user only + +User `Promise` is not supported during SSR. You must provide a static user object to `OptimizelyProvider`: + +```tsx +// Supported + + +// NOT supported during SSR + +``` + +### ODP audience segments unavailable + +ODP (Optimizely Data Platform) audience segments require fetching segment data via an async network call, which is not available during server rendering. Decisions will be made without audience segment data. If your experiment relies on ODP segments, consider using the loading state pattern above and deferring the decision to the client. diff --git a/src/Experiment.spec.tsx b/src/Experiment.spec.tsx index 3686538..7e6f166 100644 --- a/src/Experiment.spec.tsx +++ b/src/Experiment.spec.tsx @@ -63,6 +63,8 @@ describe('', () => { getIsUsingSdkKey: () => true, onForcedVariationsUpdate: jest.fn().mockReturnValue(() => {}), setUser: jest.fn(), + getOptimizelyConfig: jest.fn().mockImplementation(() => (isReady ? {} : null)), + getUserContext: jest.fn().mockImplementation(() => (isReady ? {} : null)), } as unknown as ReactSDKClient; }); @@ -512,6 +514,11 @@ describe('', () => { }); describe('when the isServerSide prop is true', () => { + beforeEach(() => { + (optimizelyMock.getOptimizelyConfig as jest.Mock).mockReturnValue({}); + (optimizelyMock.getUserContext as jest.Mock).mockReturnValue({}); + }); + it('should immediately render the result of the experiment without waiting', async () => { render( diff --git a/src/Feature.spec.tsx b/src/Feature.spec.tsx index 2de8810..1fe1340 100644 --- a/src/Feature.spec.tsx +++ b/src/Feature.spec.tsx @@ -60,6 +60,8 @@ describe('', () => { isReady: jest.fn().mockImplementation(() => isReady), getIsReadyPromiseFulfilled: () => true, getIsUsingSdkKey: () => true, + getOptimizelyConfig: jest.fn().mockImplementation(() => (isReady ? {} : null)), + getUserContext: jest.fn().mockImplementation(() => (isReady ? {} : null)), } as unknown as ReactSDKClient; }); @@ -310,6 +312,11 @@ describe('', () => { }); describe('when the isServerSide prop is true', () => { + beforeEach(() => { + (optimizelyMock.getOptimizelyConfig as jest.Mock).mockReturnValue({}); + (optimizelyMock.getUserContext as jest.Mock).mockReturnValue({}); + }); + it('should immediately render the result of isFeatureEnabled and getFeatureVariables', async () => { const { container } = render( diff --git a/src/hooks.spec.tsx b/src/hooks.spec.tsx index d43042f..865dd01 100644 --- a/src/hooks.spec.tsx +++ b/src/hooks.spec.tsx @@ -172,6 +172,8 @@ describe('hooks', () => { setForcedDecision: setForcedDecisionMock, track: jest.fn(), setUser: jest.fn(), + getOptimizelyConfig: jest.fn().mockImplementation(() => (readySuccess ? {} : null)), + getUserContext: jest.fn().mockImplementation(() => (readySuccess ? {} : null)), } as unknown as ReactSDKClient; mockLog = jest.fn(); @@ -1018,6 +1020,47 @@ describe('hooks', () => { await waitFor(() => expect(mockLog).toHaveBeenCalledWith(true)); }); + it('should re-render with updated decision after fetchQualifiedSegments completes via setUser', async () => { + // Simulate ODP scenario: config + userContext available synchronously (canMakeDecision = true), + // but client not fully ready yet (fetchQualifiedSegments still pending) + (optimizelyMock.getOptimizelyConfig as jest.Mock).mockReturnValue({}); + (optimizelyMock.getUserContext as jest.Mock).mockReturnValue({}); + (optimizelyMock.isReady as any) = () => false; + (optimizelyMock.getIsReadyPromiseFulfilled as any) = () => false; + + // Phase 1: decision without ODP segments + decideMock.mockReturnValue({ ...defaultDecision, enabled: false }); + + let resolveReadyPromise: (result: { success: boolean }) => void; + const readyPromise: Promise = new Promise((res) => { + resolveReadyPromise = res; + }); + getOnReadyPromise = (): Promise => readyPromise; + + render( + + + + ); + + // Phase 1: canMakeDecision is true, so hook evaluates sync decision (without segments) + expect(mockLog).toHaveBeenCalledTimes(1); + expect(mockLog).toHaveBeenCalledWith(false); + + mockLog.mockReset(); + + // Phase 2: fetchQualifiedSegments completes, setUser resolves userPromise, onReady resolves + // Now decision includes segment-based targeting + decideMock.mockReturnValue({ ...defaultDecision, enabled: true }); + + await act(async () => { + resolveReadyPromise!({ success: true }); + }); + + await waitFor(() => expect(mockLog).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(mockLog).toHaveBeenCalledWith(true)); + }); + it('should re-render after updating the override user ID argument', async () => { decideMock.mockReturnValue({ ...defaultDecision }); const { rerender } = render( diff --git a/src/hooks.ts b/src/hooks.ts index a84b266..0fc17ed 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -270,9 +270,9 @@ export const useExperiment: UseExperiment = (experimentKey, options = {}, overri const isClientReady = isServerSide || !!optimizely?.isReady(); const isReadyPromiseFulfilled = !!optimizely?.getIsReadyPromiseFulfilled(); - + const canMakeDecision = !!(optimizely?.getOptimizelyConfig() && optimizely.getUserContext()); const [state, setState] = useState(() => { - const decisionState = isClientReady ? getCurrentDecision() : { variation: null }; + const decisionState = canMakeDecision ? getCurrentDecision() : { variation: null }; return { ...decisionState, clientReady: isClientReady, @@ -368,9 +368,10 @@ export const useFeature: UseFeature = (featureKey, options = {}, overrides = {}) const isClientReady = isServerSide || !!optimizely?.isReady(); const isReadyPromiseFulfilled = !!optimizely?.getIsReadyPromiseFulfilled(); + const canMakeDecision = !!(optimizely?.getOptimizelyConfig() && optimizely.getUserContext()); const [state, setState] = useState(() => { - const decisionState = isClientReady ? getCurrentDecision() : { isEnabled: false, variables: {} }; + const decisionState = canMakeDecision ? getCurrentDecision() : { isEnabled: false, variables: {} }; return { ...decisionState, clientReady: isClientReady, @@ -467,9 +468,10 @@ export const useDecision: UseDecision = (flagKey, options = {}, overrides = {}) const isClientReady = isServerSide || !!optimizely?.isReady(); const isReadyPromiseFulfilled = !!optimizely?.getIsReadyPromiseFulfilled(); + const canMakeDecision = !!(optimizely?.getOptimizelyConfig() && optimizely.getUserContext()); const [state, setState] = useState<{ decision: OptimizelyDecision } & InitializationState>(() => { - const decisionState = isClientReady + const decisionState = canMakeDecision ? getCurrentDecision() : { decision: defaultDecision,