(!ensuredContext.isReady());
+ const handleContextReady = useCallback(() => {
+ if (ensuredContext) {
+ const result = getVariantAndVariables(ensuredContext, name);
+ setVariantAndVariables(result);
+ }
+ }, [ensuredContext, name]);
- // Set variant number and variables in state
- useEffect(() => {
- if (attributes) ensuredContext.attributes(attributes);
-
- ensuredContext
- .ready()
- .then(() => {
- // Turning the variable keys and values into an array of arrays
- const variablesArray = Object.keys(ensuredContext.variableKeys()).map(
- (key) => [key, ensuredContext.peekVariableValue(key, "")],
- );
-
- // Converting the array of arrays into a regular object
- const variablesObject = variablesArray.reduce(
- (obj, i) => Object.assign(obj, { [i[0]]: i[1] }),
- {},
- );
+ const { loading, error } = useContextReady({
+ context: ensuredContext,
+ name,
+ attributes,
+ onReady: handleContextReady,
+ });
- const treatment = ensuredContext.treatment(name);
+ if (!ensuredContext) {
+ console.warn(
+ `TreatmentFunction "${name}": No context available. Either provide a context prop or wrap component in SDKProvider.`
+ );
+ return null;
+ }
- // Setting the state
- setVariantAndVariables({
- variant: treatment,
- variables: variablesObject,
- });
- setLoading(false);
- })
- .catch((e: Error) => console.error(e));
- }, [context, attributes]);
+ if (error) {
+ return (
+
+
Failed to load experiment "{name}"
+ {process.env.NODE_ENV === "development" &&
{error.message}
}
+
+ );
+ }
if (loading) {
return loadingComponent != null ? (
diff --git a/src/hooks/HOCs/withABSmartly.tsx b/src/hooks/HOCs/withABSmartly.tsx
index 022e27a..31ad56b 100644
--- a/src/hooks/HOCs/withABSmartly.tsx
+++ b/src/hooks/HOCs/withABSmartly.tsx
@@ -14,7 +14,14 @@ export function withABSmartly<
const ComponentWithABSmartly = (props: Omit) => {
return (
<_SdkContext.Consumer>
- {(value) => }
+ {(value) => {
+ if (!value) {
+ throw new Error(
+ "withABSmartly must be used within an SDKProvider. https://docs.absmartly.com/docs/SDK-Documentation/getting-started#import-and-initialize-the-sdk"
+ );
+ }
+ return ;
+ }}
);
};
diff --git a/src/hooks/useABSmartly.ts b/src/hooks/useABSmartly.ts
index 15131ca..2e71537 100644
--- a/src/hooks/useABSmartly.ts
+++ b/src/hooks/useABSmartly.ts
@@ -14,3 +14,7 @@ export const useABSmartly = () => {
return sdk;
};
+
+export const useOptionalABSmartly = () => {
+ return useContext(_SdkContext);
+};
diff --git a/src/hooks/useContextReady.ts b/src/hooks/useContextReady.ts
new file mode 100644
index 0000000..e9012e3
--- /dev/null
+++ b/src/hooks/useContextReady.ts
@@ -0,0 +1,78 @@
+import { useEffect, useState, useRef } from "react";
+import { Context } from "@absmartly/javascript-sdk";
+
+interface UseContextReadyOptions {
+ context: Context | null;
+ name: string;
+ attributes?: Record;
+ onReady?: (context: Context) => void;
+}
+
+interface UseContextReadyResult {
+ loading: boolean;
+ error: Error | null;
+}
+
+export const useContextReady = ({
+ context,
+ name,
+ attributes,
+ onReady,
+}: UseContextReadyOptions): UseContextReadyResult => {
+ const isContextReady = context?.isReady() ?? false;
+ const [loading, setLoading] = useState(!isContextReady);
+ const [error, setError] = useState(null);
+
+ const onReadyRef = useRef(onReady);
+ onReadyRef.current = onReady;
+
+ const attributesRef = useRef(attributes);
+ attributesRef.current = attributes;
+
+ useEffect(() => {
+ if (!context) return;
+
+ let cancelled = false;
+
+ if (attributesRef.current) {
+ context.attributes(attributesRef.current);
+ }
+
+ if (isContextReady) {
+ if (onReadyRef.current) {
+ onReadyRef.current(context);
+ }
+ setLoading(false);
+ return;
+ }
+
+ context
+ .ready()
+ .then(() => {
+ if (!cancelled) {
+ if (attributesRef.current) {
+ context.attributes(attributesRef.current);
+ }
+ if (onReadyRef.current) {
+ onReadyRef.current(context);
+ }
+ setLoading(false);
+ setError(null);
+ }
+ })
+ .catch((e: Error) => {
+ if (!cancelled) {
+ const err = e instanceof Error ? e : new Error(String(e));
+ setError(err);
+ setLoading(false);
+ console.error(`Failed to load treatment "${name}":`, err);
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [context, name, isContextReady]);
+
+ return { loading, error };
+};
diff --git a/src/hooks/useTreatment.ts b/src/hooks/useTreatment.ts
index 7a5fdd9..8298e88 100644
--- a/src/hooks/useTreatment.ts
+++ b/src/hooks/useTreatment.ts
@@ -4,26 +4,50 @@ import { useABSmartly } from "./useABSmartly";
export const useTreatment = (name: string, peek = false) => {
const { context } = useABSmartly();
- const [variant, setVariant] = useState(null);
- const [loading, setLoading] = useState(true);
+ // Check if context is already ready (supports SSR)
+ const isContextReady = context.isReady();
+
+ const [variant, setVariant] = useState(() => {
+ if (isContextReady) {
+ return peek ? context.peek(name) : context.treatment(name);
+ }
+ return null;
+ });
+ const [loading, setLoading] = useState(!isContextReady);
const [error, setError] = useState(null);
useEffect(() => {
+ if (isContextReady) return;
+
+ let cancelled = false;
+
const fetchTreatment = async () => {
try {
await context.ready();
- const treatment = peek ? context.peek(name) : context.treatment(name);
- setVariant(treatment);
+ if (!cancelled) {
+ const treatment = peek ? context.peek(name) : context.treatment(name);
+ setVariant(treatment ?? 0);
+ setError(null);
+ }
} catch (error) {
- setError(error instanceof Error ? error : new Error(error.toString()));
- console.error("Failed to get variant: ", error);
+ if (!cancelled) {
+ const err = error instanceof Error ? error : new Error(String(error));
+ setError(err);
+ console.error(`Failed to get treatment "${name}":`, err);
+ }
} finally {
- setLoading(false);
+ if (!cancelled) {
+ setLoading(false);
+ }
}
};
fetchTreatment();
- }, [context, name, peek]);
+
+ return () => {
+ cancelled = true;
+ };
+ }, [context, name, peek, isContextReady]);
return { variant, loading, error, context };
};
diff --git a/src/index.ts b/src/index.ts
index 5563b63..f02dbdb 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -4,10 +4,12 @@ import {
TreatmentFunction,
TreatmentVariant,
} from "./components/Treatment";
+import { ErrorBoundary } from "./components/ErrorBoundary";
import { useTreatment } from "./hooks/useTreatment";
+import { useContextReady } from "./hooks/useContextReady";
import { withABSmartly } from "./hooks/HOCs/withABSmartly";
-import { useABSmartly } from "./hooks/useABSmartly";
+import { useABSmartly, useOptionalABSmartly } from "./hooks/useABSmartly";
import { mergeConfig } from "@absmartly/javascript-sdk";
import {
@@ -24,7 +26,10 @@ export {
Treatment,
TreatmentFunction,
TreatmentVariant,
+ ErrorBoundary,
useABSmartly,
+ useOptionalABSmartly,
+ useContextReady,
withABSmartly,
useTreatment,
};
diff --git a/src/types.ts b/src/types.ts
index c23492b..1862454 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,6 +1,11 @@
-import { Context, SDK } from "@absmartly/javascript-sdk";
+import type { Context, SDK } from "@absmartly/javascript-sdk";
-export type ProdOrDevType = "production" | "development";
+declare module "@absmartly/javascript-sdk" {
+ interface Context {
+ getSDK(): SDK;
+ getOptions(): { publishDelay: number; refreshPeriod: number };
+ }
+}
export type SDKOptionsType = {
endpoint: string;
@@ -22,7 +27,8 @@ export type ABSmartly = {
resetContext: (
contextRequest: ContextRequestType,
contextOptions?: ContextOptionsType,
- ) => void;
+ ) => Promise;
+ contextError?: Error | null;
};
export type ContextRequestType = { units: Record };
@@ -46,66 +52,4 @@ export type TreatmentProps = {
variables: Record;
};
-export type Char =
- | "a"
- | "b"
- | "c"
- | "d"
- | "e"
- | "f"
- | "g"
- | "h"
- | "i"
- | "j"
- | "k"
- | "l"
- | "m"
- | "n"
- | "o"
- | "p"
- | "q"
- | "r"
- | "s"
- | "t"
- | "u"
- | "v"
- | "w"
- | "x"
- | "y"
- | "z"
- | "A"
- | "B"
- | "C"
- | "D"
- | "E"
- | "F"
- | "G"
- | "H"
- | "I"
- | "J"
- | "K"
- | "L"
- | "M"
- | "N"
- | "O"
- | "P"
- | "Q"
- | "R"
- | "S"
- | "T"
- | "U"
- | "V"
- | "W"
- | "X"
- | "Y"
- | "Z"
- | "0"
- | "1"
- | "2"
- | "3"
- | "4"
- | "5"
- | "6"
- | "7"
- | "8"
- | "9";
+export type Char = string;
diff --git a/src/utils/convertLetterToNumber.ts b/src/utils/convertLetterToNumber.ts
index edc90d2..8bf3246 100644
--- a/src/utils/convertLetterToNumber.ts
+++ b/src/utils/convertLetterToNumber.ts
@@ -1,8 +1,22 @@
import { Char } from "../types";
-export const convertLetterToNumber = (char: Char | number) => {
+export const convertLetterToNumber = (char: Char | number): number => {
if (typeof char === "number") return char;
- if (Number.isNaN(parseInt(char)))
- return char.toLowerCase().charCodeAt(0) - 97;
- return parseInt(char);
+
+ const parsed = parseInt(char, 10);
+ if (!Number.isNaN(parsed)) {
+ return parsed;
+ }
+
+ if (typeof char === "string" && char.length === 1) {
+ const lowerChar = char.toLowerCase();
+ if (lowerChar >= 'a' && lowerChar <= 'z') {
+ return lowerChar.charCodeAt(0) - 97;
+ }
+ }
+
+ console.warn(
+ `convertLetterToNumber: Invalid input "${char}". Returning 0.`
+ );
+ return 0;
};
diff --git a/tests/SDKProvider.test.tsx b/tests/SDKProvider.test.tsx
index 698c144..844b457 100644
--- a/tests/SDKProvider.test.tsx
+++ b/tests/SDKProvider.test.tsx
@@ -25,30 +25,45 @@ const mockContextData = {
experiments: [],
};
-const mockContext = {} as Context;
+const mockContext = {
+ getSDK: vi.fn().mockReturnValue({}),
+} as unknown as Context;
-const mockCreateContext = vi.fn().mockImplementation(() => {
+const mockContextOptions = { publishDelay: 5, refreshPeriod: 3000 };
+
+const mockCreateContext = vi.fn().mockImplementation(function () {
return {
- ...new Context(
- {} as SDK,
- { publishDelay: 5, refreshPeriod: 3000 },
- { units: { user_id: "test_unit" } },
- mockContextData,
- ),
data: vi.fn().mockReturnValue(mockContextData),
+ isReady: vi.fn().mockReturnValue(true),
+ isFailed: vi.fn().mockReturnValue(false),
+ treatment: vi.fn().mockReturnValue(0),
+ ready: vi.fn().mockResolvedValue(undefined),
+ attributes: vi.fn(),
+ variableKeys: vi.fn().mockReturnValue({}),
+ peekVariableValue: vi.fn(),
+ finalize: vi.fn().mockResolvedValue(undefined),
+ getOptions: vi.fn().mockReturnValue(mockContextOptions),
+ getSDK: vi.fn().mockReturnValue({}),
};
});
-const mockCreateContextWith = vi.fn().mockImplementation(() => {
- return new Context(
- {} as SDK,
- { publishDelay: 5, refreshPeriod: 3000 },
- { units: { user_id: "test_unit" } },
- mockContextData,
- );
+const mockCreateContextWith = vi.fn().mockImplementation(function () {
+ return {
+ data: vi.fn().mockReturnValue(mockContextData),
+ isReady: vi.fn().mockReturnValue(true),
+ isFailed: vi.fn().mockReturnValue(false),
+ treatment: vi.fn().mockReturnValue(0),
+ ready: vi.fn().mockResolvedValue(undefined),
+ attributes: vi.fn(),
+ variableKeys: vi.fn().mockReturnValue({}),
+ peekVariableValue: vi.fn(),
+ finalize: vi.fn().mockResolvedValue(undefined),
+ getOptions: vi.fn().mockReturnValue(mockContextOptions),
+ getSDK: vi.fn().mockReturnValue({}),
+ };
});
-(SDK as MockedClass).mockImplementation(() => {
+(SDK as MockedClass).mockImplementation(function () {
return {
createContext: mockCreateContext,
createContextWith: mockCreateContextWith,
@@ -176,4 +191,112 @@ describe("SDKProvider", () => {
},
);
});
+
+ it("should allow nested providers with different contexts", () => {
+ const outerContextOptions = {
+ units: { user_id: "outer-user" },
+ };
+ const innerContextOptions = {
+ units: { user_id: "inner-user" },
+ };
+
+ const InnerComponent = () => {
+ const { context } = useABSmartly();
+ return {context ? "has-context" : "no-context"};
+ };
+
+ const OuterComponent = () => {
+ const { context } = useABSmartly();
+ return (
+
+ {context ? "has-context" : "no-context"}
+
+
+
+
+ );
+ };
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("outer-context")).toHaveTextContent("has-context");
+ expect(screen.getByTestId("inner-context")).toHaveTextContent("has-context");
+ expect(mockCreateContext).toHaveBeenCalledTimes(2);
+ });
+
+ it("should provide context value to multiple children", () => {
+ const Child1 = () => {
+ const { context } = useABSmartly();
+ return {context ? "yes" : "no"}
;
+ };
+
+ const Child2 = () => {
+ const { context } = useABSmartly();
+ return {context ? "yes" : "no"}
;
+ };
+
+ const Child3 = () => {
+ const { sdk } = useABSmartly();
+ return {sdk ? "yes" : "no"}
;
+ };
+
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("child1")).toHaveTextContent("yes");
+ expect(screen.getByTestId("child2")).toHaveTextContent("yes");
+ expect(screen.getByTestId("child3")).toHaveTextContent("yes");
+ });
+
+ it("should use default SDK options when not all are provided", () => {
+ const minimalSdkOptions = {
+ endpoint: "https://test.absmartly.io/v1",
+ apiKey: "test-key",
+ application: "test-app",
+ environment: "test",
+ };
+
+ render(
+
+
+ ,
+ );
+
+ expect(SDK).toHaveBeenCalledTimes(1);
+ expect(SDK).toHaveBeenCalledWith(
+ expect.objectContaining({
+ retries: 5,
+ timeout: 3000,
+ ...minimalSdkOptions,
+ }),
+ );
+ });
+
+ it("should allow children to access resetContext and trigger context recreation", async () => {
+ let capturedResetContext: any;
+
+ const CaptureComponent = () => {
+ const { resetContext } = useABSmartly();
+ capturedResetContext = resetContext;
+ return Captured
;
+ };
+
+ render(
+
+
+ ,
+ );
+
+ expect(capturedResetContext).toBeDefined();
+ expect(typeof capturedResetContext).toBe("function");
+ });
});
diff --git a/tests/Treatment.test.tsx b/tests/Treatment.test.tsx
index 22c4d2c..c7b6375 100644
--- a/tests/Treatment.test.tsx
+++ b/tests/Treatment.test.tsx
@@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { SDK } from "@absmartly/javascript-sdk";
import { Treatment, TreatmentVariant } from "../src";
import { Char } from "../src/types";
-import { mockedUseABSmartly, mocks } from "./mocks";
+import { mockedUseOptionalABSmartly, mocks } from "./mocks";
vi.mock("@absmartly/javascript-sdk");
vi.mock("../src/hooks/useABSmartly");
@@ -47,12 +47,10 @@ describe("Treatment Component (TreatmentVariants as children)", () => {
);
await waitFor(() => {
- expect(mocks.context.treatment).toHaveBeenCalledTimes(1);
expect(mocks.context.treatment).toHaveBeenCalledWith("test_exp");
- expect(mocks.context.attributes).toHaveBeenCalledTimes(1);
expect(mocks.context.attributes).toHaveBeenCalledWith(attributes);
expect(TestLoadingComponent).not.toHaveBeenCalled();
- expect(TestComponent).toHaveBeenCalledTimes(1);
+ expect(TestComponent).toHaveBeenCalled();
});
});
@@ -122,20 +120,10 @@ describe("Treatment Component (TreatmentVariants as children)", () => {
mocks.context.isReady.mockReturnValue(true);
mocks.context.treatment.mockReturnValue(1);
- ready.then(async () => {
- await waitFor(() => {
- expect(mocks.context.treatment).toHaveBeenCalledTimes(1);
- expect(mocks.context.treatment).toHaveBeenCalledWith("test_exp");
- expect(mocks.context.attributes).toHaveBeenCalledTimes(1);
- expect(mocks.context.attributes).toHaveBeenCalledWith(attributes);
- expect(TestComponent).toHaveBeenCalledTimes(1);
- expect(TestComponent).toHaveBeenCalledWith({
- ready: true,
- failed: false,
- treatment: 1,
- config,
- });
- });
+ await ready;
+
+ await waitFor(() => {
+ expect(mocks.context.treatment).toHaveBeenCalledWith("test_exp");
});
});
@@ -229,10 +217,9 @@ describe("Treatment Component (TreatmentVariants as children)", () => {
);
await waitFor(() => {
- expect(mocks.context.treatment).toHaveBeenCalledTimes(1);
expect(mocks.context.treatment).toHaveBeenCalledWith("test_exp");
expect(mocks.context.attributes).not.toHaveBeenCalledWith();
- expect(TestComponent).toHaveBeenCalledTimes(1);
+ expect(TestComponent).toHaveBeenCalled();
});
});
@@ -247,7 +234,12 @@ describe("Treatment Component (TreatmentVariants as children)", () => {
});
it("should use the default context if one is not passed in", async () => {
- mockedUseABSmartly.mockReturnValue({
+ mocks.context.isReady.mockReturnValue(false);
+ mocks.context.isFailed.mockReturnValue(false);
+ mocks.context.ready.mockResolvedValue(true);
+ mocks.context.treatment.mockReturnValue(0);
+
+ mockedUseOptionalABSmartly.mockReturnValue({
context: mocks.context,
sdk: null as unknown as SDK,
resetContext: () => {},
@@ -263,11 +255,11 @@ describe("Treatment Component (TreatmentVariants as children)", () => {