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..75906c2 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) @@ -106,12 +102,15 @@ Required at the root level. Leverages React’s `Context` API to allow access to _props_ -- `optimizely : ReactSDKClient` created from `createInstance` -- `user: { id: string; attributes?: { [key: string]: any } } | Promise` User info object - `id` and `attributes` will be passed to the SDK for every feature flag, A/B test, or `track` call, or a `Promise` for the same kind of object -- `timeout : Number` (optional) The amount of time for `useDecision` to return `null` flag Decision while waiting for the SDK instance to become ready, before resolving. -- `isServerSide : Boolean` (optional) must pass `true` here for server side rendering -- `userId : String` (optional) **_Deprecated, prefer using `user` instead_**. Another way to provide user id. The `user` object prop takes precedence when both are provided. -- `userAttributes : Object` : (optional) **_Deprecated, prefer using `user` instead_**. Another way to provide user attributes. The `user` object prop takes precedence when both are provided. +| Prop | Type | Required | Description | +| --- | --- | --- | --- | +| `optimizely` | `ReactSDKClient` | Yes | Instance created from `createInstance` | +| `user` | `{ id: string; attributes?: { [key: string]: any } }` \| `Promise` | No | User info object — `id` and `attributes` will be passed to the SDK for every feature flag, A/B test, or `track` call. Can also be a `Promise` for the same kind of object. | +| `timeout` | `number` | No | The amount of time for `useDecision` to return `null` flag Decision while waiting for the SDK instance to become ready, before resolving. | +| `isServerSide` | `boolean` | No | Must pass `true` for server side rendering. | +| `userId` | `string` | No | **Deprecated, prefer `user` instead.** Another way to provide user id. The `user` prop takes precedence when both are provided. | +| `userAttributes` | `object` | No | **Deprecated, prefer `user` instead.** Another way to provide user attributes. The `user` prop takes precedence when both are provided. | +| `qualifiedSegments` | `string[] \| null` | No | Pre-fetched ODP audience segments for the user. Useful during SSR where async segment fetching is unavailable. Use [`getQualifiedSegments`](#getqualifiedsegments) to obtain these segments server-side. | ### Readiness @@ -155,9 +154,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 +276,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 +297,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 +322,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 +333,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 ; } ``` @@ -385,25 +385,27 @@ The following type definitions are used in the `ReactSDKClient` interface: `ReactSDKClient` instances have the methods/properties listed below. Note that in general, the API largely matches that of the core `@optimizely/optimizely-sdk` client instance, which is documented on the [Optimizely Feature Experimentation developer docs site](https://docs.developers.optimizely.com/experimentation/v4.0.0-full-stack/docs/welcome). The major exception is that, for most methods, user id & attributes are **_optional_** arguments. `ReactSDKClient` has a current user. This user's id & attributes are automatically applied to all method calls, and overrides can be provided as arguments to these method calls if desired. -- `onReady(opts?: { timeout?: number }): Promise` Returns a Promise that fulfills with an `onReadyResult` object representing the initialization process. The instance is ready when it has fetched a datafile and a user is available (via `setUser` being called with an object, or a Promise passed to `setUser` becoming fulfilled). If the `timeout` period happens before the client instance is ready, the `onReadyResult` object will contain an additional key, `dataReadyPromise`, which can be used to determine when, if ever, the instance does become ready. -- `user: User` The current user associated with this client instance -- `setUser(userInfo: User | Promise): void` Call this to update the current user -- `onUserUpdate(handler: (userInfo: User) => void): () => void` Subscribe a callback to be called when this instance's current user changes. Returns a function that will unsubscribe the callback. -- `decide(key: string, options?: optimizely.OptimizelyDecideOption[], overrideUserId?: string, overrideAttributes?: optimizely.UserAttributes): OptimizelyDecision` Returns a decision result for a flag key for a user. The decision result is returned in an OptimizelyDecision object, and contains all data required to deliver the flag rule. -- `decideAll(options?: optimizely.OptimizelyDecideOption[], overrideUserId?: string, overrideAttributes?: optimizely.UserAttributes): { [key: string]: OptimizelyDecision }` Returns decisions for all active (unarchived) flags for a user. -- `decideForKeys(keys: string[], options?: optimizely.OptimizelyDecideOption[], overrideUserId?: string, overrideAttributes?: optimizely.UserAttributes): { [key: string]: OptimizelyDecision }` Returns an object of decision results mapped by flag keys. -- `activate(experimentKey: string, overrideUserId?: string, overrideAttributes?: UserAttributes): string | null` Activate an experiment, and return the variation for the given user. -- `getVariation(experimentKey: string, overrideUserId?: string, overrideAttributes?: UserAttributes): string | null` Return the variation for the given experiment and user. -- `getFeatureVariables(featureKey: string, overrideUserId?: string, overrideAttributes?: UserAttributes): VariableValuesObject`: Decide and return variable values for the given feature and user
Warning: Deprecated since 2.1.0
`getAllFeatureVariables` is added in JavaScript SDK which is similarly returning all the feature variables, but it sends only single notification of type `all-feature-variables` instead of sending for each variable. As `getFeatureVariables` was added when this functionality wasn't provided by `JavaScript SDK`, so there is no need of it now and it would be removed in next major release -- `getFeatureVariableString(featureKey: string, variableKey: string, overrideUserId?: string, overrideAttributes?: optimizely.UserAttributes): string | null`: Decide and return the variable value for the given feature, variable, and user -- `getFeatureVariableInteger(featureKey: string, variableKey: string, overrideUserId?: string, overrideAttributes?: UserAttributes): number | null` Decide and return the variable value for the given feature, variable, and user -- `getFeatureVariableBoolean(featureKey: string, variableKey: string, overrideUserId?: string, overrideAttributes?: UserAttributes): boolean | null` Decide and return the variable value for the given feature, variable, and user -- `getFeatureVariableDouble(featureKey: string, variableKey: string, overrideUserId?: string, overrideAttributes?: UserAttributes): number | null` Decide and return the variable value for the given feature, variable, and user -- `isFeatureEnabled(featureKey: string, overrideUserId?: string, overrideAttributes?: UserAttributes): boolean` Return the enabled status for the given feature and user -- `getEnabledFeatures(overrideUserId?: string, overrideAttributes?: UserAttributes): Array`: Return the keys of all features enabled for the given user -- `track(eventKey: string, overrideUserId?: string | EventTags, overrideAttributes?: UserAttributes, eventTags?: EventTags): void` Track an event to the Optimizely results backend -- `setForcedVariation(experiment: string, overrideUserIdOrVariationKey: string, variationKey?: string | null): boolean` Set a forced variation for the given experiment, variation, and user. **Note**: calling `setForcedVariation` on a given client will trigger a re-render of all `useExperiment` hooks and `OptimizelyExperiment` components that are using that client. -- `getForcedVariation(experiment: string, overrideUserId?: string): string | null` Get the forced variation for the given experiment, variation, and user +| Method / Property | Signature | Description | +| --- | --- | --- | +| `onReady` | `(opts?: { timeout?: number }): Promise` | Returns a Promise that fulfills with an `onReadyResult` object representing the initialization process. The instance is ready when it has fetched a datafile and a user is available (via `setUser` being called with an object, or a Promise passed to `setUser` becoming fulfilled). If the `timeout` period happens before the client instance is ready, the `onReadyResult` object will contain an additional key, `dataReadyPromise`, which can be used to determine when, if ever, the instance does become ready. | +| `user` | `User` | The current user associated with this client instance. | +| `setUser` | `(userInfo: User \| Promise, qualifiedSegments?: string[]): Promise` | Call this to update the current user. Optionally pass `qualifiedSegments` to set pre-fetched ODP audience segments on the user context. | +| `onUserUpdate` | `(handler: (userInfo: User) => void): () => void` | Subscribe a callback to be called when this instance's current user changes. Returns a function that will unsubscribe the callback. | +| `decide` | `(key: string, options?: OptimizelyDecideOption[], overrideUserId?: string, overrideAttributes?: UserAttributes): OptimizelyDecision` | Returns a decision result for a flag key for a user. The decision result is returned in an `OptimizelyDecision` object, and contains all data required to deliver the flag rule. | +| `decideAll` | `(options?: OptimizelyDecideOption[], overrideUserId?: string, overrideAttributes?: UserAttributes): { [key: string]: OptimizelyDecision }` | Returns decisions for all active (unarchived) flags for a user. | +| `decideForKeys` | `(keys: string[], options?: OptimizelyDecideOption[], overrideUserId?: string, overrideAttributes?: UserAttributes): { [key: string]: OptimizelyDecision }` | Returns an object of decision results mapped by flag keys. | +| `activate` | `(experimentKey: string, overrideUserId?: string, overrideAttributes?: UserAttributes): string \| null` | Activate an experiment, and return the variation for the given user. | +| `getVariation` | `(experimentKey: string, overrideUserId?: string, overrideAttributes?: UserAttributes): string \| null` | Return the variation for the given experiment and user. | +| `getFeatureVariables` | `(featureKey: string, overrideUserId?: string, overrideAttributes?: UserAttributes): VariableValuesObject` | **Deprecated since 2.1.0.** Decide and return variable values for the given feature and user. Use `getAllFeatureVariables` instead. | +| `getFeatureVariableString` | `(featureKey: string, variableKey: string, overrideUserId?: string, overrideAttributes?: UserAttributes): string \| null` | Decide and return the variable value for the given feature, variable, and user. | +| `getFeatureVariableInteger` | `(featureKey: string, variableKey: string, overrideUserId?: string, overrideAttributes?: UserAttributes): number \| null` | Decide and return the variable value for the given feature, variable, and user. | +| `getFeatureVariableBoolean` | `(featureKey: string, variableKey: string, overrideUserId?: string, overrideAttributes?: UserAttributes): boolean \| null` | Decide and return the variable value for the given feature, variable, and user. | +| `getFeatureVariableDouble` | `(featureKey: string, variableKey: string, overrideUserId?: string, overrideAttributes?: UserAttributes): number \| null` | Decide and return the variable value for the given feature, variable, and user. | +| `isFeatureEnabled` | `(featureKey: string, overrideUserId?: string, overrideAttributes?: UserAttributes): boolean` | Return the enabled status for the given feature and user. | +| `getEnabledFeatures` | `(overrideUserId?: string, overrideAttributes?: UserAttributes): Array` | Return the keys of all features enabled for the given user. | +| `track` | `(eventKey: string, overrideUserId?: string \| EventTags, overrideAttributes?: UserAttributes, eventTags?: EventTags): void` | Track an event to the Optimizely results backend. | +| `setForcedVariation` | `(experiment: string, overrideUserIdOrVariationKey: string, variationKey?: string \| null): boolean` | Set a forced variation for the given experiment, variation, and user. **Note:** triggers a re-render of all `useExperiment` hooks and `OptimizelyExperiment` components using that client. | +| `getForcedVariation` | `(experiment: string, overrideUserId?: string): string \| null` | Get the forced variation for the given experiment, variation, and user. | ## Rollout or experiment a feature user-by-user @@ -411,70 +413,102 @@ 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). Pre-fetch the datafile and pass it to `createInstance` so decisions are available synchronously. Server-side instances are short-lived (created per request), so configure them to avoid unnecessary background work: -**Caveats** +```jsx +import { createInstance, OptimizelyProvider, OptimizelyDecideOption, useDecision } from '@optimizely/react-sdk'; + +function App() { + const isServerSide = typeof window === 'undefined'; + const [optimizely] = useState(() => + createInstance({ + datafile, + sdkKey: process.env.NEXT_PUBLIC_OPTIMIZELY_SDK_KEY || '', + datafileOptions: { autoUpdate: !isServerSide }, + defaultDecideOptions: isServerSide ? [OptimizelyDecideOption.DISABLE_DECISION_EVENT] : [], + odpOptions: { + disabled: isServerSide, + }, + }) + ); +} -1. You must download the datafile manually and pass in via the `datafile` option. Can not use `sdkKey` to automatically download. +function MyComponent() { + const [decision] = useDecision('flag1'); + return decision.enabled ?

Feature enabled

:

Feature disabled

; +} -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. + + +; +``` -### Setting up `` +| Option | Server value | Why | +| ---------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------ | +| `datafile` | Pre-fetched datafile JSON | Provides the datafile directly so the SDK is ready synchronously to make decisions | +| `datafileOptions.autoUpdate` | `false` | No need to poll for datafile updates on a per-request instance | +| `defaultDecideOptions` | `[DISABLE_DECISION_EVENT]` | avoids duplicate decision events if the client will also fire them after hydration | +| `odpOptions.disabled` | `true` | Disables ODP event manager processing during SSR — avoids unnecessary event batching, API calls, and VUID tracking overhead | -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. +> **ODP audience segments during SSR:** Disabling ODP prevents automatic segment fetching, but you can still make audience-segment-based decisions by passing pre-fetched segments via the `qualifiedSegments` prop on `OptimizelyProvider`. -```jsx - - - +### `getQualifiedSegments` + +A standalone async utility that fetches qualified ODP audience segments for a user, given a datafile. It parses the datafile to extract ODP configuration and segment conditions, queries the ODP GraphQL API, and returns only the segments where the user is qualified. + +```ts +import { getQualifiedSegments } from '@optimizely/react-sdk'; + +const segments = await getQualifiedSegments(userId, datafile); ``` -All other Optimizely components, such as `` and `` can remain the same. +| Argument | Type | Description | +| ---------- | --------------------------------- | ------------------------------------------------ | +| `userId` | `string` | The user ID to fetch qualified segments for | +| `datafile` | `string \| Record` | The Optimizely datafile (JSON object or string) | -### Full example +**Returns:** `Promise` -```jsx -import * as React from 'react'; -import * as ReactDOMServer from 'react-dom/server'; +> **Caching recommendation:** The ODP segment fetch adds latency to server rendering. Consider caching the result per user to avoid re-fetching on every request. -import { - createInstance, - OptimizelyProvider, - useDecision, -} from '@optimizely/react-sdk'; +### React Server Components -const fetch = require('node-fetch'); +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: -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

} -
- ); -} +```tsx +import { createInstance } from '@optimizely/react-sdk'; -async function main() { - const resp = await fetch('https://cdn.optimizely.com/datafiles/.json'); - const datafile = await resp.json(); - const optimizelyClient = createInstance({ - datafile, +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. +- **User Promise not supported** — User `Promise` is not supported during SSR. +- **ODP segments** — ODP audience segments require async I/O and are not available during server rendering. Use [`getQualifiedSegments`](#getqualifiedsegments) to pre-fetch segments server-side and pass them via the `qualifiedSegments` prop on `OptimizelyProvider` to enable synchronous ODP-based decisions. Without it, consider deferring the decision to the client using the fallback pattern. + +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..9852da4 --- /dev/null +++ b/docs/nextjs-integration.md @@ -0,0 +1,331 @@ +# Next.js Integration Guide + +This guide covers how to use the Optimizely React SDK with Next.js for server-side rendering (SSR), static site generation (SSG), 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 several 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 + +**Option A: Using the SDK's built-in datafile fetching (Recommended)** + +Create a module-level SDK instance with your `sdkKey` and use a notification listener to detect when the datafile is ready. This approach benefits from the SDK's built-in polling and caching, making it suitable when you want automatic datafile updates across requests. + +```ts +// src/data/getDatafile.ts +import { createInstance } from '@optimizely/react-sdk'; + +const pollingInstance = createInstance({ + sdkKey: process.env.NEXT_PUBLIC_OPTIMIZELY_SDK_KEY || "", +}); + +const pollingInstance = createInstane(); + +const configReady = new Promise((resolve) => { + pollingInstance.notificationCenter.addNotificationListener( + enums.NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE, + () => resolve(); + ); +} + +export function getDatafile(): Promise { + return configReady.then(() => pollingInstance.getOptimizelyConfig()?.getDatafile()); +} +``` + +**Option B: Direct CDN fetch** + +Fetch the datafile directly from CDN. + +```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 isServerSide = typeof window === 'undefined'; + + const [optimizely] = useState(() => + createInstance({ + datafile, + sdkKey: process.env.NEXT_PUBLIC_OPTIMIZELY_SDK_KEY || '', + datafileOptions: { autoUpdate: !isServerSide }, + defaultDecideOptions: isServerSide ? [OptimizelyDecideOption.DISABLE_DECISION_EVENT] : [], + odpOptions: { + disabled: isServerSide, + }, + }) + ); + + 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} + + + ); +} +``` + +#### Pre-fetching ODP audience segments + +If your project uses ODP audience segments, you can pre-fetch them server-side using `getQualifiedSegments` and pass them to the provider via the `qualifiedSegments` prop. + +```tsx +// src/app/layout.tsx +import { getQualifiedSegments } from '@optimizely/react-sdk'; + +export default async function RootLayout({ children }: { children: React.ReactNode }) { + const datafile = await getDatafile(); + const segments = await getQualifiedSegments('user-123', datafile); + + return ( + + + + {children} + + + + ); +} +``` + +> **Caching recommendation:** The ODP segment fetch adds latency to initial page loads. Consider caching the result per user to avoid re-fetching on every request. + +## Next.js Pages Router + +In the Pages Router, fetch the datafile server-side and pass it as a prop. There are three data-fetching strategies depending on your needs. + +### 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. Fetch the datafile + +Choose the data-fetching strategy that best fits your use case: + +#### Option A: `getInitialProps` — app-wide setup + +Fetches the datafile for every page via `_app.tsx`. Useful when you want Optimizely available globally across all pages. + +```tsx +// pages/_app.tsx +import { OptimizelyClientProvider } from '@/providers/OptimizelyProvider'; +import type { AppProps, AppContext } from 'next/app'; +import { getDatafile } from '@/data/getDatafile'; + +export default function App({ Component, pageProps }: AppProps) { + return ( + + + + ); +} + +App.getInitialProps = async (appContext: AppContext) => { + const appProps = await App.getInitialProps(appContext); + const datafile = await getDatafile(); + return { ...appProps, pageProps: { ...appProps.pageProps, datafile } }; +}; +``` + +Similar to App Router example, if you have ODP enabled and want to pre-fetch segments, you can do following - + +```tsx +import { getQualifiedSegments } from "@optimizely/react-sdk"; + +App.getInitialProps = async (appContext: AppContext) => { + const appProps = await App.getInitialProps(appContext); + const datafile = await getDatafile(); + const segments = await getQualifiedSegments('user-123', datafile); + return { ...appProps, pageProps: { ...appProps.pageProps, datafile, segments } }; +}; +``` + + +#### Option B: `getServerSideProps` — per-page setup + +Fetches the datafile per request on specific pages. Useful when only certain pages need feature flags. + +```tsx +// pages/index.tsx +export async function getServerSideProps() { + const datafile = await getDatafile(); + + return { props: { datafile } }; +} +``` + +#### Option C: `getStaticProps` — static generation with revalidation + +Fetches the datafile at build time and revalidates periodically. Best for static pages where per-request freshness is not critical. + +```tsx +// pages/index.tsx +export async function getStaticProps() { + const datafile = await getDatafile(); + + 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

; +} +``` + +## Static Site Generation (SSG) + +For statically generated pages, the SDK cannot make decisions during the build because there is no per-user context at build time. Instead, use the SDK as a regular client-side React library — the static HTML serves a default or loading state, and decisions resolve on the client after hydration. + +```tsx +'use client'; + +import { OptimizelyProvider, createInstance, useDecision } from '@optimizely/react-sdk'; + +const optimizely = createInstance({ sdkKey: 'YOUR_SDK_KEY' }); + +export function App() { + return ( + + + + ); +} + +function FeatureBanner() { + const [decision, isClientReady, didTimeout] = useDecision('banner-flag'); + + if (!isClientReady && !didTimeout) { + return

Loading...

; + } + + 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

; +} +``` + +### User Promise not supported + +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 + +ODP (Optimizely Data Platform) audience segments require fetching segment data via an async network call, which is not available during server rendering. To include segment data during SSR, pass pre-fetched segments via the `qualifiedSegments` prop on `OptimizelyProvider`: + +```tsx + + {children} + +``` + +This enables synchronous ODP-based decisions during server rendering. If `qualifiedSegments` is not provided, decisions will be made without audience segment data — in that case, consider deferring the decision to the client using the loading state fallback pattern described above, where ODP segments are fetched automatically when ODP is enabled. diff --git a/package.json b/package.json index 303393d..1450232 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,20 @@ "types": "dist/index.d.ts", "main": "dist/react-sdk.js", "browser": "dist/react-sdk.js", + "exports": { + ".": { + "react-server": { + "types": "./dist/server.d.ts", + "import": "./dist/server.es.js", + "require": "./dist/server.js", + "default": "./dist/server.js" + }, + "types": "./dist/index.d.ts", + "import": "./dist/react-sdk.es.js", + "require": "./dist/react-sdk.js", + "default": "./dist/react-sdk.js" + } + }, "directories": { "lib": "lib" }, diff --git a/scripts/build.js b/scripts/build.js index 11e6859..d658bcd 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -1,5 +1,5 @@ /** - * Copyright 2019, 2023 Optimizely + * Copyright 2019, 2023, 2026 Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,7 @@ * limitations under the License. */ +/* eslint-disable @typescript-eslint/no-var-requires */ const path = require('path'); const execSync = require('child_process').execSync; @@ -46,3 +47,13 @@ exec(`./node_modules/.bin/rollup -c scripts/config.js -f system -o dist/${packag EXTERNALS: 'forBrowsers', BUILD_ENV: 'production', }); + +console.log('\nBuilding server ES modules...'); +exec(`./node_modules/.bin/rollup -c scripts/config.js -f es -o dist/server.es.js`, { + ENTRY: 'src/server.ts', +}); + +console.log('\nBuilding server CommonJS modules...'); +exec(`./node_modules/.bin/rollup -c scripts/config.js -f cjs -o dist/server.js`, { + ENTRY: 'src/server.ts', +}); diff --git a/scripts/config.js b/scripts/config.js index f939b65..4098a56 100644 --- a/scripts/config.js +++ b/scripts/config.js @@ -1,5 +1,5 @@ /** - * Copyright 2019, 2023 Optimizely + * Copyright 2019, 2023, 2026 Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,7 @@ * limitations under the License. */ +/* eslint-disable @typescript-eslint/no-var-requires */ const typescript = require('rollup-plugin-typescript2'); const commonjs = require('@rollup/plugin-commonjs'); const replace = require('@rollup/plugin-replace'); @@ -65,7 +66,7 @@ function getPlugins(env, externals) { } const config = { - input: 'src/index.ts', + input: process.env.ENTRY || 'src/index.ts', output: { globals: { react: 'React', 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/Provider.spec.tsx b/src/Provider.spec.tsx index 4d89012..fb62449 100644 --- a/src/Provider.spec.tsx +++ b/src/Provider.spec.tsx @@ -58,13 +58,13 @@ describe('OptimizelyProvider', () => { it('should resolve user promise and set user in optimizely', async () => { render(); - await waitFor(() => expect(mockReactClient.setUser).toHaveBeenCalledWith(user1)); + await waitFor(() => expect(mockReactClient.setUser).toHaveBeenCalledWith(user1, undefined)); }); it('should render successfully with user provided', () => { render(); - expect(mockReactClient.setUser).toHaveBeenCalledWith(user1); + expect(mockReactClient.setUser).toHaveBeenCalledWith(user1, undefined); }); it('should throw error, if setUser throws error', () => { @@ -76,10 +76,13 @@ describe('OptimizelyProvider', () => { it('should render successfully with userId provided', () => { render(); - expect(mockReactClient.setUser).toHaveBeenCalledWith({ - id: user1.id, - attributes: {}, - }); + expect(mockReactClient.setUser).toHaveBeenCalledWith( + { + id: user1.id, + attributes: {}, + }, + undefined + ); }); it('should render successfully without user or userId provided', () => { @@ -87,13 +90,13 @@ describe('OptimizelyProvider', () => { mockReactClient.user = undefined; render(); - expect(mockReactClient.setUser).toHaveBeenCalledWith(DefaultUser); + expect(mockReactClient.setUser).toHaveBeenCalledWith(DefaultUser, undefined); }); it('should render successfully with user id & attributes provided', () => { render(); - expect(mockReactClient.setUser).toHaveBeenCalledWith(user1); + expect(mockReactClient.setUser).toHaveBeenCalledWith(user1, undefined); }); it('should succeed just userAttributes provided', () => { @@ -101,25 +104,31 @@ describe('OptimizelyProvider', () => { mockReactClient.user = undefined; render(); - expect(mockReactClient.setUser).toHaveBeenCalledWith({ - id: DefaultUser.id, - attributes: { attr1: 'value1' }, - }); + expect(mockReactClient.setUser).toHaveBeenCalledWith( + { + id: DefaultUser.id, + attributes: { attr1: 'value1' }, + }, + undefined + ); }); it('should succeed with the initial user available in client', () => { render(); - expect(mockReactClient.setUser).toHaveBeenCalledWith(user1); + expect(mockReactClient.setUser).toHaveBeenCalledWith(user1, undefined); }); it('should succeed with the initial user id and newly passed attributes', () => { render(); - expect(mockReactClient.setUser).toHaveBeenCalledWith({ - id: user1.id, - attributes: { attr1: 'value2' }, - }); + expect(mockReactClient.setUser).toHaveBeenCalledWith( + { + id: user1.id, + attributes: { attr1: 'value2' }, + }, + undefined + ); }); it('should not update when isServerSide is true', () => { @@ -142,7 +151,7 @@ describe('OptimizelyProvider', () => { // Change props to trigger componentDidUpdate rerender(); - expect(mockReactClient.setUser).toHaveBeenCalledWith(user1); + expect(mockReactClient.setUser).toHaveBeenCalledWith(user1, undefined); }); it('should update user if users are not equal', () => { @@ -153,7 +162,7 @@ describe('OptimizelyProvider', () => { // Change props to a different user to trigger componentDidUpdate rerender(); - expect(mockReactClient.setUser).toHaveBeenCalledWith(user2); + expect(mockReactClient.setUser).toHaveBeenCalledWith(user2, undefined); }); it('should not update user if users are equal', () => { diff --git a/src/Provider.tsx b/src/Provider.tsx index 5d4a134..c2f4167 100644 --- a/src/Provider.tsx +++ b/src/Provider.tsx @@ -30,6 +30,7 @@ interface OptimizelyProviderProps { user?: Promise | UserInfo; userId?: string; userAttributes?: UserAttributes; + qualifiedSegments?: string[] | null; children?: React.ReactNode; } @@ -46,7 +47,7 @@ export class OptimizelyProvider extends React.Component { - const { optimizely, userId, userAttributes, user } = this.props; + const { optimizely, userId, userAttributes, user, qualifiedSegments } = this.props; if (!optimizely) { logger.error('OptimizelyProvider must be passed an instance of the Optimizely SDK client'); @@ -58,7 +59,7 @@ export class OptimizelyProvider extends React.Component { - optimizely.setUser(res); + optimizely.setUser(res, qualifiedSegments); }); } else { finalUser = { @@ -89,7 +90,7 @@ export class OptimizelyProvider extends React.Component { expect(instance.fetchQualifiedSegments).toHaveBeenCalledTimes(3); }); + + it('should set qualifiedSegments synchronously on userContext before fetchQualifiedSegments is called', async () => { + jest.spyOn(mockInnerClient, 'isOdpIntegrated').mockReturnValue(true); + jest.spyOn(mockOptimizelyUserContext, 'getUserId').mockReturnValue(userId); + jest.spyOn(mockOptimizelyUserContext, 'getAttributes').mockReturnValue(userAttributes); + + const segments = ['segment1', 'segment2']; + let segmentsAtFetchTime: string[] | null | undefined; + + instance = createInstance(config); + jest.spyOn(instance, 'fetchQualifiedSegments').mockImplementation(async () => { + // Capture qualifiedSegments at the time fetchQualifiedSegments is called + segmentsAtFetchTime = mockOptimizelyUserContext.qualifiedSegments; + return true; + }); + + await instance.setUser({ id: userId, attributes: userAttributes }, segments); + + // Verify segments were already set on the userContext before fetchQualifiedSegments ran + expect(segmentsAtFetchTime).toEqual(segments); + }); + + it('should not set qualifiedSegments when ODP is explicitly disabled', async () => { + jest.spyOn(mockInnerClient, 'isOdpIntegrated').mockReturnValue(true); + jest.spyOn(mockOptimizelyUserContext, 'getUserId').mockReturnValue(userId); + jest.spyOn(mockOptimizelyUserContext, 'getAttributes').mockReturnValue(userAttributes); + + const segments = ['segment1', 'segment2']; + + instance = createInstance({ + ...config, + odpOptions: { disabled: true }, + }); + jest.spyOn(instance, 'fetchQualifiedSegments').mockResolvedValue(true); + + await instance.setUser({ id: userId, attributes: userAttributes }, segments); + + expect(mockOptimizelyUserContext.qualifiedSegments).toBeUndefined(); + }); + + it('should not set qualifiedSegments when ODP is not integrated', async () => { + jest.spyOn(mockInnerClient, 'isOdpIntegrated').mockReturnValue(false); + jest.spyOn(mockOptimizelyUserContext, 'getUserId').mockReturnValue(userId); + jest.spyOn(mockOptimizelyUserContext, 'getAttributes').mockReturnValue(userAttributes); + + const segments = ['segment1', 'segment2']; + + instance = createInstance(config); + jest.spyOn(instance, 'fetchQualifiedSegments').mockResolvedValue(true); + + await instance.setUser({ id: userId, attributes: userAttributes }, segments); + + expect(mockOptimizelyUserContext.qualifiedSegments).toBeUndefined(); + }); + + it('should not set qualifiedSegments for anonymous/default users', async () => { + jest.spyOn(mockInnerClient, 'isOdpIntegrated').mockReturnValue(true); + jest.spyOn(mockOptimizelyUserContext, 'getUserId').mockReturnValue(validVuid); + + const segments = ['segment1', 'segment2']; + + instance = createInstance(config); + jest.spyOn(instance, 'fetchQualifiedSegments').mockResolvedValue(true); + + await instance.setUser(DefaultUser, segments); + + expect(mockOptimizelyUserContext.qualifiedSegments).toBeUndefined(); + }); }); describe('onUserUpdate', () => { diff --git a/src/client.ts b/src/client.ts index cfc2115..b564884 100644 --- a/src/client.ts +++ b/src/client.ts @@ -59,7 +59,7 @@ export interface ReactSDKClient user: UserInfo; client: optimizely.Client | null; onReady(opts?: { timeout?: number }): Promise; - setUser(userInfo: UserInfo): Promise; + setUser(userInfo: UserInfo, qualifiedSegments?: string[] | null): Promise; onUserUpdate(handler: OnUserUpdateHandler): DisposeFn; isReady(): boolean; getIsReadyPromiseFulfilled(): boolean; @@ -381,7 +381,7 @@ class OptimizelyReactSDKClient implements ReactSDKClient { return await this.userContext.fetchQualifiedSegments(options); } - public async setUser(userInfo: UserInfo): Promise { + public async setUser(userInfo: UserInfo, qualifiedSegments?: string[] | null): Promise { // If user id is not present and ODP is explicitly off, user promise will be pending until setUser is called again with proper user id if (userInfo.id === null && this.odpExplicitlyOff) { return; @@ -403,8 +403,16 @@ class OptimizelyReactSDKClient implements ReactSDKClient { } else { // synchronous user context setting is required including for server side rendering (SSR) this.setCurrentUserContext(userInfo); + // Synchronous segments setting + if ( + this.userContext && + !this.odpExplicitlyOff && + this._client?.isOdpIntegrated() && + qualifiedSegments !== undefined + ) { + this.userContext.qualifiedSegments = qualifiedSegments; + } - // we need to wait for fetch qualified segments success for failure await this._client?.onReady(); } diff --git a/src/hooks.spec.tsx b/src/hooks.spec.tsx index d43042f..aa23d12 100644 --- a/src/hooks.spec.tsx +++ b/src/hooks.spec.tsx @@ -1,5 +1,5 @@ /** - * Copyright 2022, 2023, 2024 Optimizely + * Copyright 2022, 2023, 2024, 2026 Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -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,115 @@ 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 pass qualifiedSegments to setUser when provided via OptimizelyProvider', async () => { + const segments = ['segment1', 'segment2']; + decideMock.mockReturnValue({ ...defaultDecision, enabled: true }); + + render( + + + + ); + + await waitFor(() => { + expect(optimizelyMock.setUser).toHaveBeenCalledWith({ id: 'testuser', attributes: {} }, segments); + }); + }); + + it('should make sync decision when qualifiedSegments are provided via OptimizelyProvider with ODP integrated', async () => { + const segments = ['segment1', 'segment2']; + + // Mock ODP integration + (optimizelyMock as any)._client = { + isOdpIntegrated: () => true, + }; + + // Client not fully ready yet (fetchQualifiedSegments still pending) + (optimizelyMock.isReady as any) = () => false; + (optimizelyMock.getIsReadyPromiseFulfilled as any) = () => false; + + // Simulate setUser synchronously making config and userContext (with segments) available + // This mirrors real behavior: setUser synchronously sets userContext.qualifiedSegments + // before awaiting onReady + (optimizelyMock.setUser as jest.Mock).mockImplementation((_user, qualifiedSegments) => { + (optimizelyMock.getOptimizelyConfig as jest.Mock).mockReturnValue({}); + (optimizelyMock.getUserContext as jest.Mock).mockReturnValue({ qualifiedSegments }); + }); + + decideMock.mockReturnValue({ ...defaultDecision, enabled: true }); + + // Hold onReady so client never becomes "ready" during initial render + let resolveReadyPromise: (result: { success: boolean }) => void; + const readyPromise = new Promise((res) => { + resolveReadyPromise = res; + }); + getOnReadyPromise = (): Promise => readyPromise; + + render( + + + + ); + + // hasConfigAndUserInfo returns true (config + userContext + odpIntegrated + qualifiedSegments) + // so useDecision should evaluate a sync decision even though client is not ready + expect(mockLog).toHaveBeenCalledTimes(1); + expect(mockLog).toHaveBeenCalledWith(true); + // Cleanup: resolve onReady promise to prevent test timeout + await act(async () => { + resolveReadyPromise!({ success: 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..267d818 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -249,6 +249,15 @@ function useCompareAttrsMemoize(value: UserAttributes | undefined): UserAttribut return ref.current; } +function hasConfigAndUserInfo(optimizely: ReactSDKClient | null): boolean { + const hasConfig = !!optimizely?.getOptimizelyConfig(); + const userContext = optimizely?.getUserContext(); + // @ts-ignore + const isOdpIntegrated = optimizely?._client?.isOdpIntegrated(); + const areSegmentsAvailable = !!(isOdpIntegrated && userContext?.qualifiedSegments !== undefined); + return !!(hasConfig && userContext && (!isOdpIntegrated || areSegmentsAvailable)); +} + /** * A React Hook that retrieves the variation for an experiment, optionally * auto updating that value based on underlying user or datafile changes. @@ -270,9 +279,10 @@ export const useExperiment: UseExperiment = (experimentKey, options = {}, overri const isClientReady = isServerSide || !!optimizely?.isReady(); const isReadyPromiseFulfilled = !!optimizely?.getIsReadyPromiseFulfilled(); + const isExperimentReady = hasConfigAndUserInfo(optimizely) || isClientReady; const [state, setState] = useState(() => { - const decisionState = isClientReady ? getCurrentDecision() : { variation: null }; + const decisionState = isExperimentReady ? getCurrentDecision() : { variation: null }; return { ...decisionState, clientReady: isClientReady, @@ -368,9 +378,10 @@ export const useFeature: UseFeature = (featureKey, options = {}, overrides = {}) const isClientReady = isServerSide || !!optimizely?.isReady(); const isReadyPromiseFulfilled = !!optimizely?.getIsReadyPromiseFulfilled(); + const isFeatureReady = hasConfigAndUserInfo(optimizely) || isClientReady; const [state, setState] = useState(() => { - const decisionState = isClientReady ? getCurrentDecision() : { isEnabled: false, variables: {} }; + const decisionState = isFeatureReady ? getCurrentDecision() : { isEnabled: false, variables: {} }; return { ...decisionState, clientReady: isClientReady, @@ -468,8 +479,10 @@ export const useDecision: UseDecision = (flagKey, options = {}, overrides = {}) const isClientReady = isServerSide || !!optimizely?.isReady(); const isReadyPromiseFulfilled = !!optimizely?.getIsReadyPromiseFulfilled(); + const isDecisionReady = hasConfigAndUserInfo(optimizely) || isClientReady; + const [state, setState] = useState<{ decision: OptimizelyDecision } & InitializationState>(() => { - const decisionState = isClientReady + const decisionState = isDecisionReady ? getCurrentDecision() : { decision: defaultDecision, diff --git a/src/index.ts b/src/index.ts index 63634d9..cf85f81 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,7 +21,7 @@ export { useFeature, useExperiment, useDecision, useTrackEvent } from './hooks'; export { withOptimizely, WithOptimizelyProps, WithoutOptimizelyProps } from './withOptimizely'; export { OptimizelyExperiment } from './Experiment'; export { OptimizelyVariation } from './Variation'; -export { OptimizelyDecision } from './utils'; +export { OptimizelyDecision, getQualifiedSegments } from './utils'; export { logging, diff --git a/src/reactUtils.tsx b/src/reactUtils.tsx new file mode 100644 index 0000000..09f97f2 --- /dev/null +++ b/src/reactUtils.tsx @@ -0,0 +1,39 @@ +/** + * Copyright 2019, 2026 Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import hoistNonReactStatics from 'hoist-non-react-statics'; +import * as React from 'react'; + +export interface AcceptsForwardedRef { + forwardedRef?: React.Ref; +} + +export function hoistStaticsAndForwardRefs>( + Target: React.ComponentType

, + Source: React.ComponentType, + displayName: string +): React.ForwardRefExoticComponent & React.RefAttributes> { + // Make sure to hoist statics and forward any refs through from Source to Target + // From the React docs: + // https://reactjs.org/docs/higher-order-components.html#static-methods-must-be-copied-over + // https://reactjs.org/docs/forwarding-refs.html#forwarding-refs-in-higher-order-components + const forwardRef: React.ForwardRefRenderFunction = (props, ref) => ; + forwardRef.displayName = `${displayName}(${Source.displayName || Source.name})`; + return hoistNonReactStatics< + React.ForwardRefExoticComponent & React.RefAttributes>, + React.ComponentType + >(React.forwardRef(forwardRef), Source); +} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..08943b2 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,42 @@ +/** + * Copyright 2026 Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Server-safe entry point for @optimizely/react-sdk. + * + * This module can be safely imported in React Server Components (RSC) + * as it does not use any client-only React APIs (createContext, hooks, etc.). + */ + +export { createInstance, ReactSDKClient } from './client'; + +export { OptimizelyDecision } from './utils'; + +export { default as logOnlyEventDispatcher } from './logOnlyEventDispatcher'; + +export { + logging, + errorHandler, + setLogger, + setLogLevel, + enums, + eventDispatcher, + OptimizelyDecideOption, + ActivateListenerPayload, + TrackListenerPayload, + ListenerPayload, + OptimizelySegmentOption, +} from '@optimizely/optimizely-sdk'; diff --git a/src/utils.spec.tsx b/src/utils.spec.tsx index 07cad2f..8932444 100644 --- a/src/utils.spec.tsx +++ b/src/utils.spec.tsx @@ -1,5 +1,5 @@ /** - * Copyright 2024 Optimizely + * Copyright 2024, 2026 Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,7 @@ * limitations under the License. */ import * as utils from './utils'; +import * as reactUtils from './reactUtils'; import React, { forwardRef } from 'react'; import { render, screen } from '@testing-library/react'; import hoistNonReactStatics from 'hoist-non-react-statics'; @@ -74,7 +75,7 @@ describe('utils', () => { } } - const WrappedComponent = utils.hoistStaticsAndForwardRefs(TestComponent, SourceComponent, 'WrappedComponent'); + const WrappedComponent = reactUtils.hoistStaticsAndForwardRefs(TestComponent, SourceComponent, 'WrappedComponent'); it('should forward refs and hoist static methods', () => { const ref = React.createRef(); @@ -203,4 +204,103 @@ describe('utils', () => { expect(utils.sprintf('Two placeholders: %s and %s', 'first')).toBe('Two placeholders: first and undefined'); }); }); + + describe('getQualifiedSegments', () => { + const odpIntegration = { + key: 'odp', + publicKey: 'test-api-key', + host: 'https://odp.example.com', + }; + + const makeDatafile = (overrides: Record = {}) => ({ + integrations: [odpIntegration], + typedAudiences: [ + { + conditions: ['or', { match: 'qualified', value: 'seg1' }, { match: 'qualified', value: 'seg2' }], + }, + ], + ...overrides, + }); + + const mockFetchResponse = (body: any, ok = true) => { + global.fetch = jest.fn().mockResolvedValue({ + ok, + json: () => Promise.resolve(body), + }); + }; + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns null when datafile is invalid or missing ODP integration', async () => { + // undefined datafile + // @ts-ignore + expect(await utils.getQualifiedSegments('user-1')).toBeNull(); + // invalid JSON string + expect(await utils.getQualifiedSegments('user-1', '{bad json')).toBeNull(); + // no ODP integration + expect(await utils.getQualifiedSegments('user-1', { integrations: [] })).toBeNull(); + // ODP integration missing publicKey + expect( + await utils.getQualifiedSegments('user-1', { + integrations: [{ key: 'odp', host: 'https://odp.example.com' }], + }) + ).toBeNull(); + }); + + it('returns empty array when ODP is integrated but no segment conditions exist', async () => { + const datafile = makeDatafile({ typedAudiences: [], audiences: [] }); + const result = await utils.getQualifiedSegments('user-1', datafile); + expect(result).toEqual([]); + expect(global.fetch).toBeUndefined(); + }); + + it('calls ODP GraphQL API and returns only qualified segments', async () => { + mockFetchResponse({ + data: { + customer: { + audiences: { + edges: [ + { node: { name: 'seg1', state: 'qualified' } }, + { node: { name: 'seg2', state: 'not_qualified' } }, + ], + }, + }, + }, + }); + + const result = await utils.getQualifiedSegments('user-1', makeDatafile()); + + expect(result).toEqual(['seg1']); + expect(global.fetch).toHaveBeenCalledWith('https://odp.example.com/v3/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': 'test-api-key', + }, + body: expect.stringContaining('user-1'), + }); + }); + + it('returns null when fetch fails or response is not ok', async () => { + // network error + global.fetch = jest.fn().mockRejectedValue(new Error('network error')); + expect(await utils.getQualifiedSegments('user-1', makeDatafile())).toBeNull(); + + // non-200 response + mockFetchResponse({}, false); + expect(await utils.getQualifiedSegments('user-1', makeDatafile())).toBeNull(); + }); + + it('returns null when response contains GraphQL errors or missing edges', async () => { + // GraphQL errors + mockFetchResponse({ errors: [{ message: 'something went wrong' }] }); + expect(await utils.getQualifiedSegments('user-1', makeDatafile())).toBeNull(); + + // missing edges path + mockFetchResponse({ data: {} }); + expect(await utils.getQualifiedSegments('user-1', makeDatafile())).toBeNull(); + }); + }); }); diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..89e91a1 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,251 @@ +/** + * Copyright 2019, 2026 Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as optimizely from '@optimizely/optimizely-sdk'; + +export type UserInfo = { + id: string | null; + attributes?: optimizely.UserAttributes; +}; + +export interface OptimizelyDecision extends Omit { + userContext: UserInfo; +} + +export function areUsersEqual(user1: UserInfo, user2: UserInfo): boolean { + if (user1.id !== user2.id) { + return false; + } + + const user1Attributes = user1.attributes || {}; + const user2Attributes = user2.attributes || {}; + + const user1Keys = Object.keys(user1Attributes); + const user2Keys = Object.keys(user2Attributes); + + if (user1Keys.length !== user2Keys.length) { + return false; + } + + for (const key of user1Keys) { + if (user1Attributes[key] !== user2Attributes[key]) { + return false; + } + } + + return true; +} + +function coerceUnknownAttrsValueForComparison(maybeAttrs: unknown): optimizely.UserAttributes { + if (typeof maybeAttrs === 'object' && maybeAttrs !== null) { + return maybeAttrs as optimizely.UserAttributes; + } + return {} as optimizely.UserAttributes; +} + +/** + * Equality check applied to override user attributes passed into hooks. Used to determine when we need to recompute + * a decision because a new set of override attributes was passed into a hook. + * @param {UserAttributes|undefined} oldAttrs + * @param {UserAttributes|undefined} newAttrs + * @returns boolean + */ +export function areAttributesEqual(maybeOldAttrs: unknown, maybeNewAttrs: unknown): boolean { + const oldAttrs = coerceUnknownAttrsValueForComparison(maybeOldAttrs); + const newAttrs = coerceUnknownAttrsValueForComparison(maybeNewAttrs); + const oldAttrsKeys = Object.keys(oldAttrs); + const newAttrsKeys = Object.keys(newAttrs); + if (oldAttrsKeys.length !== newAttrsKeys.length) { + // Different attr count - must update + return false; + } + return oldAttrsKeys.every((oldAttrKey: string) => { + return oldAttrKey in newAttrs && oldAttrs[oldAttrKey] === newAttrs[oldAttrKey]; + }); +} + +export function createFailedDecision(flagKey: string, message: string, user: UserInfo): OptimizelyDecision { + return { + enabled: false, + flagKey: flagKey, + ruleKey: null, + variationKey: null, + variables: {}, + reasons: [message], + userContext: { + id: user.id, + attributes: user.attributes, + }, + }; +} + +export function sprintf(format: string, ...args: any[]): string { + let i = 0; + return format.replace(/%s/g, () => { + const arg = args[i++]; + const type = typeof arg; + if (type === 'function') { + return arg(); + } else if (type === 'string') { + return arg; + } else { + return String(arg); + } + }); +} + +const QUALIFIED = 'qualified'; + +/** + * Extracts ODP segments from audience conditions in the datafile. + * Looks for conditions with `match: 'qualified'` and collects their values. + */ +function extractSegmentsFromConditions(condition: any): string[] { + if (typeof condition === 'string') { + return []; + } + + if (Array.isArray(condition)) { + const segments: string[] = []; + condition.forEach((c) => segments.push(...extractSegmentsFromConditions(c))); + return segments; + } + + if (condition && typeof condition === 'object' && condition['match'] === 'qualified') { + return [condition['value']]; + } + + return []; +} + +/** + * Builds the GraphQL query payload for fetching audience segments from ODP. + */ +function buildGraphQLQuery(userId: string, segmentsToCheck: string[]): string { + const segmentsList = segmentsToCheck.map((s) => `\\"${s}\\"`).join(','); + return `{"query" : "query {customer(fs_user_id : \\"${userId}\\") {audiences(subset: [${segmentsList}]) {edges {node {name state}}}}}"}`; +} + +/** + * Fetches qualified ODP segments for a user given a datafile and user ID. + * + * This is a standalone, self-contained utility that: + * 1. Parses the datafile to extract ODP configuration (apiKey, apiHost) + * 2. Collects all ODP segments referenced in audience conditions + * 3. Queries the ODP GraphQL API + * 4. Returns only the segments where the user is qualified + * + * @param userId - The user ID to fetch qualified segments for + * @param datafile - The Optimizely datafile (JSON object or string) + * @returns Array of qualified segment names, empty array if no segments configured, + * or null if ODP is not integrated or the fetch fails. + * + * @example + * ```ts + * const segments = await getQualifiedSegments('user-123', datafile); + * if (segments) { + * console.log('Qualified segments:', segments); + * } + * ``` + */ +export async function getQualifiedSegments( + userId: string, + datafile: string | Record +): Promise { + let datafileObj: any; + + if (typeof datafile === 'string') { + try { + datafileObj = JSON.parse(datafile); + } catch { + return null; + } + } else if (typeof datafile === 'object') { + datafileObj = datafile; + } else { + return null; + } + + // Extract ODP integration config from datafile + let apiKey = ''; + let apiHost = ''; + let odpIntegrated = false; + + if (Array.isArray(datafileObj.integrations)) { + for (const integration of datafileObj.integrations) { + if (integration.key === 'odp') { + odpIntegrated = true; + apiKey = integration.publicKey || ''; + apiHost = integration.host || ''; + break; + } + } + } + + if (!odpIntegrated || !apiKey || !apiHost) { + return null; + } + + // Collect all ODP segments from audience conditions + const allSegments = new Set(); + const audiences = [...(datafileObj.audiences || []), ...(datafileObj.typedAudiences || [])]; + + for (const audience of audiences) { + if (audience.conditions) { + const conditions = + typeof audience.conditions === 'string' ? JSON.parse(audience.conditions) : audience.conditions; + extractSegmentsFromConditions(conditions).forEach((s) => allSegments.add(s)); + } + } + + const segmentsToCheck = Array.from(allSegments); + if (segmentsToCheck.length === 0) { + return []; + } + + const endpoint = `${apiHost}/v3/graphql`; + const query = buildGraphQLQuery(userId, segmentsToCheck); + + try { + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + }, + body: query, + }); + + if (!response.ok) { + return null; + } + + const json = await response.json(); + + if (json.errors?.length > 0) { + return null; + } + + const edges = json?.data?.customer?.audiences?.edges; + if (!edges) { + return null; + } + + return edges.filter((edge: any) => edge.node.state === QUALIFIED).map((edge: any) => edge.node.name); + } catch { + return null; + } +} diff --git a/src/utils.tsx b/src/utils.tsx deleted file mode 100644 index b1f35bc..0000000 --- a/src/utils.tsx +++ /dev/null @@ -1,131 +0,0 @@ -/** - * Copyright 2019, Optimizely - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import hoistNonReactStatics from 'hoist-non-react-statics'; -import * as optimizely from '@optimizely/optimizely-sdk'; -import * as React from 'react'; - -export type UserInfo = { - id: string | null; - attributes?: optimizely.UserAttributes; -}; - -export interface OptimizelyDecision extends Omit { - userContext: UserInfo; -} - -export function areUsersEqual(user1: UserInfo, user2: UserInfo): boolean { - if (user1.id !== user2.id) { - return false; - } - - const user1Attributes = user1.attributes || {}; - const user2Attributes = user2.attributes || {}; - - const user1Keys = Object.keys(user1Attributes); - const user2Keys = Object.keys(user2Attributes); - - if (user1Keys.length !== user2Keys.length) { - return false; - } - - for (const key of user1Keys) { - if (user1Attributes[key] !== user2Attributes[key]) { - return false; - } - } - - return true; -} - -export interface AcceptsForwardedRef { - forwardedRef?: React.Ref; -} - -export function hoistStaticsAndForwardRefs>( - Target: React.ComponentType

, - Source: React.ComponentType, - displayName: string -): React.ForwardRefExoticComponent & React.RefAttributes> { - // Make sure to hoist statics and forward any refs through from Source to Target - // From the React docs: - // https://reactjs.org/docs/higher-order-components.html#static-methods-must-be-copied-over - // https://reactjs.org/docs/forwarding-refs.html#forwarding-refs-in-higher-order-components - const forwardRef: React.ForwardRefRenderFunction = (props, ref) => ; - forwardRef.displayName = `${displayName}(${Source.displayName || Source.name})`; - return hoistNonReactStatics< - React.ForwardRefExoticComponent & React.RefAttributes>, - React.ComponentType - >(React.forwardRef(forwardRef), Source); -} - -function coerceUnknownAttrsValueForComparison(maybeAttrs: unknown): optimizely.UserAttributes { - if (typeof maybeAttrs === 'object' && maybeAttrs !== null) { - return maybeAttrs as optimizely.UserAttributes; - } - return {} as optimizely.UserAttributes; -} - -/** - * Equality check applied to override user attributes passed into hooks. Used to determine when we need to recompute - * a decision because a new set of override attributes was passed into a hook. - * @param {UserAttributes|undefined} oldAttrs - * @param {UserAttributes|undefined} newAttrs - * @returns boolean - */ -export function areAttributesEqual(maybeOldAttrs: unknown, maybeNewAttrs: unknown): boolean { - const oldAttrs = coerceUnknownAttrsValueForComparison(maybeOldAttrs); - const newAttrs = coerceUnknownAttrsValueForComparison(maybeNewAttrs); - const oldAttrsKeys = Object.keys(oldAttrs); - const newAttrsKeys = Object.keys(newAttrs); - if (oldAttrsKeys.length !== newAttrsKeys.length) { - // Different attr count - must update - return false; - } - return oldAttrsKeys.every((oldAttrKey: string) => { - return oldAttrKey in newAttrs && oldAttrs[oldAttrKey] === newAttrs[oldAttrKey]; - }); -} - -export function createFailedDecision(flagKey: string, message: string, user: UserInfo): OptimizelyDecision { - return { - enabled: false, - flagKey: flagKey, - ruleKey: null, - variationKey: null, - variables: {}, - reasons: [message], - userContext: { - id: user.id, - attributes: user.attributes, - }, - }; -} - -export function sprintf(format: string, ...args: any[]): string { - let i = 0; - return format.replace(/%s/g, () => { - const arg = args[i++]; - const type = typeof arg; - if (type === 'function') { - return arg(); - } else if (type === 'string') { - return arg; - } else { - return String(arg); - } - }); -} diff --git a/src/withOptimizely.spec.tsx b/src/withOptimizely.spec.tsx index f68626e..afc0e6c 100644 --- a/src/withOptimizely.spec.tsx +++ b/src/withOptimizely.spec.tsx @@ -70,7 +70,7 @@ describe('withOptimizely', () => { ); await waitFor(() => expect(optimizelyClient.setUser).toHaveBeenCalledTimes(1)); - expect(optimizelyClient.setUser).toHaveBeenCalledWith({ id: userId, attributes }); + expect(optimizelyClient.setUser).toHaveBeenCalledWith({ id: userId, attributes }, undefined); }); }); @@ -84,10 +84,13 @@ describe('withOptimizely', () => { ); await waitFor(() => expect(optimizelyClient.setUser).toHaveBeenCalledTimes(1)); - expect(optimizelyClient.setUser).toHaveBeenCalledWith({ - id: userId, - attributes: {}, - }); + expect(optimizelyClient.setUser).toHaveBeenCalledWith( + { + id: userId, + attributes: {}, + }, + undefined + ); }); }); @@ -101,10 +104,13 @@ describe('withOptimizely', () => { ); await waitFor(() => expect(optimizelyClient.setUser).toHaveBeenCalledTimes(1)); - expect(optimizelyClient.setUser).toHaveBeenCalledWith({ - id: userId, - attributes: {}, - }); + expect(optimizelyClient.setUser).toHaveBeenCalledWith( + { + id: userId, + attributes: {}, + }, + undefined + ); }); }); @@ -119,10 +125,13 @@ describe('withOptimizely', () => { ); await waitFor(() => expect(optimizelyClient.setUser).toHaveBeenCalledTimes(1)); - expect(optimizelyClient.setUser).toHaveBeenCalledWith({ - id: userId, - attributes, - }); + expect(optimizelyClient.setUser).toHaveBeenCalledWith( + { + id: userId, + attributes, + }, + undefined + ); }); }); @@ -143,10 +152,13 @@ describe('withOptimizely', () => { ); await waitFor(() => expect(optimizelyClient.setUser).toHaveBeenCalledTimes(1)); - expect(optimizelyClient.setUser).toHaveBeenCalledWith({ - id: userId, - attributes, - }); + expect(optimizelyClient.setUser).toHaveBeenCalledWith( + { + id: userId, + attributes, + }, + undefined + ); }); }); diff --git a/src/withOptimizely.tsx b/src/withOptimizely.tsx index 0160f17..b822bc2 100644 --- a/src/withOptimizely.tsx +++ b/src/withOptimizely.tsx @@ -1,5 +1,5 @@ /** - * Copyright 2018-2019, Optimizely + * Copyright 2018-2019, 2026 Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,7 +17,7 @@ import * as React from 'react'; import { OptimizelyContextConsumer, OptimizelyContextInterface } from './Context'; import { ReactSDKClient } from './client'; -import { hoistStaticsAndForwardRefs } from './utils'; +import { hoistStaticsAndForwardRefs } from './reactUtils'; export interface WithOptimizelyProps { optimizely: ReactSDKClient | null;