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