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..83c7c6b 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[]` | No | Pre-fetched ODP audience segments for the user. Useful during SSR where async segment fetching is unavailable. | ### 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,83 @@ 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`. See the [Limitations — ODP segments](#limitations) section for details. -```jsx - - - -``` +### React Server Components -All other Optimizely components, such as `` and `` can remain the same. +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: -### Full example +```tsx +import { createInstance } from '@optimizely/react-sdk'; -```jsx -import * as React from 'react'; -import * as ReactDOMServer from 'react-dom/server'; +export default async function ServerExperiment() { + const client = createInstance({ + sdkKey: process.env.OPTIMIZELY_SDK_KEY || '', + }); -import { - createInstance, - OptimizelyProvider, - useDecision, -} from '@optimizely/react-sdk'; + client.setUser({ + id: 'user-123', + }); -const fetch = require('node-fetch'); + await client.onReady(); -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

} -
- ); -} + const decision = client.decide('flag-1'); -async function main() { - const resp = await fetch('https://cdn.optimizely.com/datafiles/.json'); - const datafile = await resp.json(); - const optimizelyClient = createInstance({ - datafile, - }); + client.close(); - const output = ReactDOMServer.renderToString( - - - - ); - console.log('output', output); + 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** — ODP audience segments require async I/O and are not available during server rendering. Pass pre-fetched segments 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..0430b2e --- /dev/null +++ b/docs/nextjs-integration.md @@ -0,0 +1,291 @@ +# 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} + + + ); +} +``` + +## 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 } }; +}; +``` + +#### 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..f0f047f 100644 --- a/src/Provider.tsx +++ b/src/Provider.tsx @@ -30,6 +30,7 @@ interface OptimizelyProviderProps { user?: Promise | UserInfo; userId?: string; userAttributes?: UserAttributes; + qualifiedSegments?: string[]; 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..566dd66 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[]): 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[]): 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; @@ -404,6 +404,9 @@ class OptimizelyReactSDKClient implements ReactSDKClient { // synchronous user context setting is required including for server side rendering (SSR) this.setCurrentUserContext(userInfo); + if (this.userContext && !this.odpExplicitlyOff && this._client?.isOdpIntegrated() && qualifiedSegments) { + 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..c792054 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,66 @@ 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 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, diff --git a/src/reactUtils.tsx b/src/reactUtils.tsx new file mode 100644 index 0000000..3deaf0f --- /dev/null +++ b/src/reactUtils.tsx @@ -0,0 +1,39 @@ +/** + * 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 + * + * 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..bf27636 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(); diff --git a/src/utils.tsx b/src/utils.ts similarity index 73% rename from src/utils.tsx rename to src/utils.ts index b1f35bc..345204a 100644 --- a/src/utils.tsx +++ b/src/utils.ts @@ -1,5 +1,5 @@ /** - * Copyright 2019, Optimizely + * 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. @@ -14,9 +14,7 @@ * 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; @@ -51,27 +49,6 @@ export function areUsersEqual(user1: UserInfo, user2: UserInfo): boolean { 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; 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;