diff --git a/apps/playground/next-env.d.ts b/apps/playground/next-env.d.ts
new file mode 100644
index 0000000..c4b7818
--- /dev/null
+++ b/apps/playground/next-env.d.ts
@@ -0,0 +1,6 @@
+///
+///
+import "./.next/dev/types/routes.d.ts";
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/packages/js/src/index.test.ts b/packages/js/src/index.test.ts
index 6b2a9c2..c7b238b 100644
--- a/packages/js/src/index.test.ts
+++ b/packages/js/src/index.test.ts
@@ -2,22 +2,21 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
// Mock the load-formbricks module first (hoisted)
vi.mock("./lib/load-formbricks", () => ({
- loadFormbricksToProxy: vi.fn().mockResolvedValue(undefined),
+ setup: vi.fn().mockResolvedValue(undefined),
+ callMethod: vi.fn().mockResolvedValue(undefined),
}));
import formbricks from "./index";
import * as loadFormbricksModule from "./lib/load-formbricks";
-import type { TFormbricks } from "./types/formbricks";
-// Get the mocked function
-const mockLoadFormbricksToProxy = vi.mocked(
- loadFormbricksModule.loadFormbricksToProxy,
-);
+const mockSetup = vi.mocked(loadFormbricksModule.setup);
+const mockCallMethod = vi.mocked(loadFormbricksModule.callMethod);
-describe("formbricks proxy", () => {
+describe("formbricks", () => {
beforeEach(() => {
vi.clearAllMocks();
- mockLoadFormbricksToProxy.mockResolvedValue(undefined);
+ mockSetup.mockResolvedValue(undefined);
+ mockCallMethod.mockResolvedValue(undefined);
});
test("should export a formbricks object", () => {
@@ -25,7 +24,7 @@ describe("formbricks proxy", () => {
expect(typeof formbricks).toBe("object");
});
- test("should proxy setup method calls to loadFormbricksToProxy", async () => {
+ test("should delegate setup to setup()", async () => {
const setupArgs = {
environmentId: "env123",
appUrl: "https://app.formbricks.com",
@@ -33,39 +32,35 @@ describe("formbricks proxy", () => {
await formbricks.setup(setupArgs);
- expect(mockLoadFormbricksToProxy).toHaveBeenCalledWith("setup", setupArgs);
+ expect(mockSetup).toHaveBeenCalledWith(setupArgs);
});
- test("should proxy track method calls to loadFormbricksToProxy", async () => {
+ test("should delegate track to callMethod", async () => {
const trackCode = "button-click";
await formbricks.track(trackCode);
- expect(mockLoadFormbricksToProxy).toHaveBeenCalledWith("track", trackCode);
+ expect(mockCallMethod).toHaveBeenCalledWith("track", trackCode);
});
- test("should proxy setEmail method calls to loadFormbricksToProxy", async () => {
+ test("should delegate setEmail to callMethod", async () => {
const email = "test@example.com";
await formbricks.setEmail(email);
- expect(mockLoadFormbricksToProxy).toHaveBeenCalledWith("setEmail", email);
+ expect(mockCallMethod).toHaveBeenCalledWith("setEmail", email);
});
- test("should proxy setAttribute method calls to loadFormbricksToProxy", async () => {
+ test("should delegate setAttribute to callMethod", async () => {
const key = "userId";
const value = "user123";
await formbricks.setAttribute(key, value);
- expect(mockLoadFormbricksToProxy).toHaveBeenCalledWith(
- "setAttribute",
- key,
- value,
- );
+ expect(mockCallMethod).toHaveBeenCalledWith("setAttribute", key, value);
});
- test("should proxy setAttributes method calls to loadFormbricksToProxy", async () => {
+ test("should delegate setAttributes to callMethod", async () => {
const attributes = {
userId: "user123",
plan: "premium",
@@ -73,107 +68,43 @@ describe("formbricks proxy", () => {
await formbricks.setAttributes(attributes);
- expect(mockLoadFormbricksToProxy).toHaveBeenCalledWith(
- "setAttributes",
- attributes,
- );
+ expect(mockCallMethod).toHaveBeenCalledWith("setAttributes", attributes);
});
- test("should proxy setLanguage method calls to loadFormbricksToProxy", async () => {
+ test("should delegate setLanguage to callMethod", async () => {
const language = "en";
await formbricks.setLanguage(language);
- expect(mockLoadFormbricksToProxy).toHaveBeenCalledWith(
- "setLanguage",
- language,
- );
+ expect(mockCallMethod).toHaveBeenCalledWith("setLanguage", language);
});
- test("should proxy setUserId method calls to loadFormbricksToProxy", async () => {
+ test("should delegate setUserId to callMethod", async () => {
const userId = "user123";
await formbricks.setUserId(userId);
- expect(mockLoadFormbricksToProxy).toHaveBeenCalledWith("setUserId", userId);
+ expect(mockCallMethod).toHaveBeenCalledWith("setUserId", userId);
});
- test("should proxy logout method calls to loadFormbricksToProxy", async () => {
+ test("should delegate logout to callMethod", async () => {
await formbricks.logout();
- expect(mockLoadFormbricksToProxy).toHaveBeenCalledWith("logout");
+ expect(mockCallMethod).toHaveBeenCalledWith("logout");
});
- test("should proxy registerRouteChange method calls to loadFormbricksToProxy", async () => {
+ test("should delegate registerRouteChange to callMethod", async () => {
await formbricks.registerRouteChange();
- expect(mockLoadFormbricksToProxy).toHaveBeenCalledWith(
- "registerRouteChange",
- );
- });
-
- test("should handle deprecated init method", async () => {
- const initConfig = {
- apiHost: "https://app.formbricks.com",
- environmentId: "env123",
- userId: "user123",
- attributes: {
- plan: "premium",
- },
- };
-
- await formbricks.init(initConfig);
-
- expect(mockLoadFormbricksToProxy).toHaveBeenCalledWith("init", initConfig);
+ expect(mockCallMethod).toHaveBeenCalledWith("registerRouteChange");
});
- test("should handle track method with properties", async () => {
- const trackCode = "purchase";
- const properties = {
- hiddenFields: {
- productId: "prod123",
- amount: 99.99,
- categories: ["electronics", "gadgets"],
- },
- };
-
- await formbricks.track(trackCode, properties);
-
- expect(mockLoadFormbricksToProxy).toHaveBeenCalledWith(
- "track",
- trackCode,
- properties,
- );
- });
-
- test("should handle any method call through the proxy", async () => {
- const customMethod: keyof TFormbricks = "track";
- const args = ["arg1"];
+ test("should delegate setNonce to callMethod", async () => {
+ const nonce = "abc123";
- await formbricks[customMethod](args[0]);
+ await formbricks.setNonce(nonce);
- expect(mockLoadFormbricksToProxy).toHaveBeenCalledWith(
- customMethod,
- args[0],
- );
- });
-
- test("should return the result of loadFormbricksToProxy calls", async () => {
- const mockResult = "test-result";
- mockLoadFormbricksToProxy.mockResolvedValue(mockResult as never);
-
- const result = await formbricks.setEmail("test@example.com");
-
- expect(result).toBe(mockResult);
- });
-
- test("should propagate errors from loadFormbricksToProxy", async () => {
- const error = new Error("Test error");
- mockLoadFormbricksToProxy.mockRejectedValue(error);
-
- await expect(formbricks.setEmail("test@example.com")).rejects.toThrow(
- "Test error",
- );
+ expect(mockCallMethod).toHaveBeenCalledWith("setNonce", nonce);
});
test("should handle multiple concurrent method calls", async () => {
@@ -186,57 +117,75 @@ describe("formbricks proxy", () => {
await Promise.all(calls);
- expect(mockLoadFormbricksToProxy).toHaveBeenCalledTimes(4);
- expect(mockLoadFormbricksToProxy).toHaveBeenCalledWith(
- "setEmail",
- "test@example.com",
- );
- expect(mockLoadFormbricksToProxy).toHaveBeenCalledWith(
+ expect(mockCallMethod).toHaveBeenCalledTimes(4);
+ expect(mockCallMethod).toHaveBeenCalledWith("setEmail", "test@example.com");
+ expect(mockCallMethod).toHaveBeenCalledWith(
"setAttribute",
"userId",
"user123",
);
- expect(mockLoadFormbricksToProxy).toHaveBeenCalledWith("track", "event1");
- expect(mockLoadFormbricksToProxy).toHaveBeenCalledWith("setLanguage", "en");
+ expect(mockCallMethod).toHaveBeenCalledWith("track", "event1");
+ expect(mockCallMethod).toHaveBeenCalledWith("setLanguage", "en");
+ });
+
+ test("should propagate errors from setup", async () => {
+ const error = new Error("Test error");
+ mockSetup.mockRejectedValue(error);
+
+ await expect(
+ formbricks.setup({
+ environmentId: "env123",
+ appUrl: "https://app.formbricks.com",
+ }),
+ ).rejects.toThrow("Test error");
+ });
+
+ test("should propagate errors from callMethod", async () => {
+ const error = new Error("Test error");
+ mockCallMethod.mockRejectedValue(error);
+
+ await expect(formbricks.setEmail("test@example.com")).rejects.toThrow(
+ "Test error",
+ );
});
});
-describe("proxy behavior", () => {
+describe("method signatures", () => {
beforeEach(() => {
vi.clearAllMocks();
- mockLoadFormbricksToProxy.mockResolvedValue(undefined);
+ mockSetup.mockResolvedValue(undefined);
+ mockCallMethod.mockResolvedValue(undefined);
});
- test("should work with property access", () => {
- // Test that we can access properties on the proxy
+ test("should have all expected methods", () => {
expect(typeof formbricks.setup).toBe("function");
expect(typeof formbricks.track).toBe("function");
expect(typeof formbricks.setEmail).toBe("function");
+ expect(typeof formbricks.setAttribute).toBe("function");
+ expect(typeof formbricks.setAttributes).toBe("function");
+ expect(typeof formbricks.setLanguage).toBe("function");
+ expect(typeof formbricks.setUserId).toBe("function");
+ expect(typeof formbricks.setNonce).toBe("function");
+ expect(typeof formbricks.logout).toBe("function");
+ expect(typeof formbricks.registerRouteChange).toBe("function");
});
test("should handle method calls with no arguments", async () => {
await formbricks.logout();
- expect(mockLoadFormbricksToProxy).toHaveBeenCalledWith("logout");
+ expect(mockCallMethod).toHaveBeenCalledWith("logout");
});
test("should handle method calls with single argument", async () => {
await formbricks.setUserId("user123");
- expect(mockLoadFormbricksToProxy).toHaveBeenCalledWith(
- "setUserId",
- "user123",
- );
+ expect(mockCallMethod).toHaveBeenCalledWith("setUserId", "user123");
});
test("should handle method calls with multiple arguments", async () => {
await formbricks.setAttribute("key", "value");
- expect(mockLoadFormbricksToProxy).toHaveBeenCalledWith(
- "setAttribute",
- "key",
- "value",
- );
+ expect(mockCallMethod).toHaveBeenCalledWith("setAttribute", "key", "value");
});
test("should handle method calls with object arguments", async () => {
@@ -247,21 +196,18 @@ describe("proxy behavior", () => {
await formbricks.setup(setupConfig);
- expect(mockLoadFormbricksToProxy).toHaveBeenCalledWith(
- "setup",
- setupConfig,
- );
+ expect(mockSetup).toHaveBeenCalledWith(setupConfig);
});
});
describe("type safety", () => {
beforeEach(() => {
vi.clearAllMocks();
- mockLoadFormbricksToProxy.mockResolvedValue(undefined);
+ mockSetup.mockResolvedValue(undefined);
+ mockCallMethod.mockResolvedValue(undefined);
});
test("should maintain type safety for known methods", () => {
- // These should compile without errors due to proper typing
const testTypeSafety = () => {
void formbricks.setup({ environmentId: "env", appUrl: "url" });
void formbricks.track("event");
diff --git a/packages/js/src/index.ts b/packages/js/src/index.ts
index 4073319..6833f38 100644
--- a/packages/js/src/index.ts
+++ b/packages/js/src/index.ts
@@ -1,4 +1,4 @@
-import { loadFormbricksToProxy } from "./lib/load-formbricks";
+import { callMethod, setup } from "./lib/load-formbricks";
import type { TFormbricks as TFormbricksCore } from "./types/formbricks";
type TFormbricks = Omit & {
@@ -11,16 +11,17 @@ declare global {
}
}
-const formbricksProxyHandler: ProxyHandler = {
- get(_target, prop, _receiver) {
- return (...args: unknown[]) =>
- loadFormbricksToProxy(prop as keyof TFormbricks, ...args);
- },
+const formbricks: TFormbricks = {
+ setup: (setupConfig) => setup(setupConfig),
+ setEmail: (email) => callMethod("setEmail", email),
+ setAttribute: (key, value) => callMethod("setAttribute", key, value),
+ setAttributes: (attributes) => callMethod("setAttributes", attributes),
+ setLanguage: (language) => callMethod("setLanguage", language),
+ setUserId: (userId) => callMethod("setUserId", userId),
+ setNonce: (nonce) => callMethod("setNonce", nonce),
+ track: (code) => callMethod("track", code),
+ logout: () => callMethod("logout"),
+ registerRouteChange: () => callMethod("registerRouteChange"),
};
-const formbricks: TFormbricksCore = new Proxy(
- {} as TFormbricks,
- formbricksProxyHandler,
-);
-
export default formbricks;
diff --git a/packages/js/src/lib/load-formbricks.test.ts b/packages/js/src/lib/load-formbricks.test.ts
index 81a8963..3565c75 100644
--- a/packages/js/src/lib/load-formbricks.test.ts
+++ b/packages/js/src/lib/load-formbricks.test.ts
@@ -1,15 +1,13 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
-import type { TFormbricks } from "../types/formbricks";
// We need to import the module after each reset
-let loadFormbricksToProxy: (
- prop: keyof TFormbricks,
- ...args: unknown[]
-) => Promise;
+let setup: (config: { appUrl: string; environmentId: string }) => Promise;
+let callMethod: (method: string, ...args: unknown[]) => Promise;
// Mock the globalThis formbricks object
const mockFormbricks = {
setup: vi.fn(),
+ init: vi.fn(),
track: vi.fn(),
setEmail: vi.fn(),
setAttribute: vi.fn(),
@@ -32,7 +30,7 @@ const simulateScriptSuccess = (script: HTMLScriptElement) => {
const simulateScriptError = (script: HTMLScriptElement) => {
if (script.onerror) {
- script.onerror({} as Event);
+ (script.onerror as (event: Event) => void)({} as Event);
}
};
@@ -117,14 +115,15 @@ describe("load-formbricks", () => {
// Re-import the module to get fresh state
const module = await import("./load-formbricks");
- loadFormbricksToProxy = module.loadFormbricksToProxy;
+ setup = module.setup;
+ callMethod = module.callMethod;
});
afterEach(() => {
vi.restoreAllMocks();
});
- describe("loadFormbricksToProxy", () => {
+ describe("setup", () => {
describe("setup functionality", () => {
test("should handle setup call with valid arguments", async () => {
const setupArgs = {
@@ -136,7 +135,7 @@ describe("load-formbricks", () => {
.spyOn(document.head, "appendChild")
.mockImplementation(createSuccessfulScriptMock());
- await loadFormbricksToProxy("setup", setupArgs);
+ await setup(setupArgs);
expect(mockAppendChild).toHaveBeenCalledWith(
expect.objectContaining({
@@ -167,7 +166,7 @@ describe("load-formbricks", () => {
.spyOn(document.head, "appendChild")
.mockImplementation(createSuccessfulScriptMock());
- await loadFormbricksToProxy("setup", invalidSetupArgs);
+ await setup(invalidSetupArgs);
expect(mockAppendChild).toHaveBeenCalledWith(
expect.objectContaining({
@@ -182,7 +181,8 @@ describe("load-formbricks", () => {
test("should log error when appUrl is missing", async () => {
const consoleSpy = createConsoleErrorSpy();
- await loadFormbricksToProxy("setup", {
+ await setup({
+ appUrl: "",
environmentId: "env123",
});
@@ -194,8 +194,9 @@ describe("load-formbricks", () => {
test("should log error when environmentId is missing", async () => {
const consoleSpy = createConsoleErrorSpy();
- await loadFormbricksToProxy("setup", {
+ await setup({
appUrl: "https://app.formbricks.com",
+ environmentId: "",
});
expect(consoleSpy).toHaveBeenCalledWith(
@@ -214,7 +215,7 @@ describe("load-formbricks", () => {
const appendChildSpy = vi.spyOn(document.head, "appendChild");
- await loadFormbricksToProxy("setup", setupArgs);
+ await setup(setupArgs);
expect(appendChildSpy).not.toHaveBeenCalled();
expect(mockFormbricks.setup).toHaveBeenCalledWith(setupArgs);
@@ -235,7 +236,7 @@ describe("load-formbricks", () => {
const originalSetTimeout = mockSetTimeoutImmediate();
- await loadFormbricksToProxy("setup", setupArgs);
+ await setup(setupArgs);
expect(consoleSpy).toHaveBeenCalledWith(
"🧱 Formbricks - Error: Failed to load Formbricks SDK",
@@ -256,7 +257,7 @@ describe("load-formbricks", () => {
createErrorScriptMock(),
);
- await loadFormbricksToProxy("setup", setupArgs);
+ await setup(setupArgs);
expect(consoleSpy).toHaveBeenCalledWith(
"🧱 Formbricks - Error: Failed to load Formbricks SDK",
@@ -274,7 +275,7 @@ describe("load-formbricks", () => {
createSetupFailureMock(),
);
- await loadFormbricksToProxy("setup", setupArgs);
+ await setup(setupArgs);
expect(consoleSpy).toHaveBeenCalledWith(
"🧱 Formbricks - Error: setup failed",
@@ -283,11 +284,133 @@ describe("load-formbricks", () => {
});
});
+ describe("UMD detection fallback (polling)", () => {
+ // These tests simulate the scenario where another script on the page
+ // leaks `exports` or `module` into the global scope, causing the UMD
+ // wrapper to route the factory result to `module.exports` instead of
+ // `globalThis.formbricks`. The polling fallback should recover when
+ // js-core's explicit `globalThis.formbricks = ...` assignment runs
+ // shortly after.
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ test("should recover via polling when globalThis.formbricks is set after onload", async () => {
+ vi.useFakeTimers();
+
+ vi.spyOn(document.head, "appendChild").mockImplementation(
+ (element: Node) => {
+ const script = element as HTMLScriptElement;
+ // onload fires but global is NOT set (UMD detection was fooled)
+ setTimeout(() => {
+ if (script.onload) {
+ script.onload({} as Event);
+ }
+ }, 0);
+ // js-core's explicit globalThis assignment runs shortly after
+ setTimeout(() => {
+ typedGlobalThis.formbricks = mockFormbricks;
+ }, 25);
+ return element;
+ },
+ );
+
+ const setupPromise = setup({
+ appUrl: "https://app.formbricks.com",
+ environmentId: "env123",
+ });
+
+ // Advance past onload (0ms) + delayed assignment (25ms) + poll cycle (30ms)
+ await vi.advanceTimersByTimeAsync(100);
+
+ await setupPromise;
+
+ expect(mockFormbricks.setup).toHaveBeenCalledWith({
+ appUrl: "https://app.formbricks.com",
+ environmentId: "env123",
+ });
+ });
+
+ test("should fail when globalThis.formbricks is never set after onload", async () => {
+ vi.useFakeTimers();
+ const consoleSpy = createConsoleErrorSpy();
+
+ vi.spyOn(document.head, "appendChild").mockImplementation(
+ (element: Node) => {
+ const script = element as HTMLScriptElement;
+ // onload fires but global is never set
+ setTimeout(() => {
+ if (script.onload) {
+ script.onload({} as Event);
+ }
+ }, 0);
+ return element;
+ },
+ );
+
+ const setupPromise = setup({
+ appUrl: "https://app.formbricks.com",
+ environmentId: "env123",
+ });
+
+ // Advance past onload (0ms) + all 50 poll attempts (50 * 10ms = 500ms)
+ await vi.advanceTimersByTimeAsync(600);
+
+ await setupPromise;
+
+ expect(consoleSpy).toHaveBeenCalledWith(
+ "🧱 Formbricks - Error: Failed to load Formbricks SDK",
+ );
+ expect(mockFormbricks.setup).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("concurrent setup calls", () => {
+ test("should warn if setup is called while already initializing", async () => {
+ const warnSpy = createConsoleWarnSpy();
+
+ vi.spyOn(document.head, "appendChild").mockImplementation(
+ (element: Node) => {
+ // Don't resolve — keep initializing
+ return element;
+ },
+ );
+
+ // Start setup (will hang because onload never fires)
+ const firstSetup = setup({
+ appUrl: "https://app.formbricks.com",
+ environmentId: "env123",
+ });
+
+ // Call setup again while first is still running
+ await setup({
+ appUrl: "https://app.formbricks.com",
+ environmentId: "env123",
+ });
+
+ expect(warnSpy).toHaveBeenCalledWith(
+ "🧱 Formbricks - Warning: Formbricks is already initializing.",
+ );
+
+ // Clean up by letting the first setup timeout
+ const originalSetTimeout = mockSetTimeoutImmediate();
+ // Wait for the pending promise with a short timeout
+ await Promise.race([
+ firstSetup,
+ new Promise((resolve) => originalSetTimeout(resolve, 100)),
+ ]);
+ globalThis.setTimeout = originalSetTimeout;
+ });
+ });
+ });
+
+ describe("callMethod", () => {
describe("method queueing", () => {
test("should queue non-setup methods when not initialized", async () => {
const consoleSpy = createConsoleWarnSpy();
- await loadFormbricksToProxy("track", "test-event");
+ await callMethod("track", "test-event");
expect(consoleSpy).toHaveBeenCalledWith(
"🧱 Formbricks - Warning: Formbricks not initialized. This method will be queued and executed after initialization.",
@@ -296,17 +419,45 @@ describe("load-formbricks", () => {
test("should flush queued methods after setup", async () => {
const warnSpy = createConsoleWarnSpy();
- await loadFormbricksToProxy("track", "queued-event");
+ await callMethod("track", "queued-event");
expect(warnSpy).toHaveBeenCalled();
vi.spyOn(document.head, "appendChild").mockImplementation(
createSuccessfulScriptMock(),
);
- await loadFormbricksToProxy("setup", {
+ await setup({
appUrl: "https://app.formbricks.com",
environmentId: "env123",
});
expect(mockFormbricks.track).toHaveBeenCalledWith("queued-event");
});
+
+ test("should flush multiple queued methods in order after setup", async () => {
+ createConsoleWarnSpy();
+ const callOrder: string[] = [];
+ mockFormbricks.setEmail.mockImplementation(() => {
+ callOrder.push("setEmail");
+ });
+ mockFormbricks.track.mockImplementation(() => {
+ callOrder.push("track");
+ });
+
+ await callMethod("setEmail", "test@example.com");
+ await callMethod("track", "queued-event");
+
+ vi.spyOn(document.head, "appendChild").mockImplementation(
+ createSuccessfulScriptMock(),
+ );
+ await setup({
+ appUrl: "https://app.formbricks.com",
+ environmentId: "env123",
+ });
+
+ expect(mockFormbricks.setEmail).toHaveBeenCalledWith(
+ "test@example.com",
+ );
+ expect(mockFormbricks.track).toHaveBeenCalledWith("queued-event");
+ expect(callOrder).toEqual(["setEmail", "track"]);
+ });
});
describe("after initialization", () => {
@@ -321,14 +472,32 @@ describe("load-formbricks", () => {
);
// First, set up the SDK
- await loadFormbricksToProxy("setup", setupArgs);
+ await setup(setupArgs);
// Now test that subsequent calls execute directly
- await loadFormbricksToProxy("track", "test-event");
+ await callMethod("track", "test-event");
expect(mockFormbricks.setup).toHaveBeenCalledWith(setupArgs);
expect(mockFormbricks.track).toHaveBeenCalledWith("test-event");
});
+
+ test("should pass multiple arguments to methods", async () => {
+ vi.spyOn(document.head, "appendChild").mockImplementation(
+ createSuccessfulScriptMock(),
+ );
+
+ await setup({
+ appUrl: "https://app.formbricks.com",
+ environmentId: "env123",
+ });
+
+ await callMethod("setAttribute", "key", "value");
+
+ expect(mockFormbricks.setAttribute).toHaveBeenCalledWith(
+ "key",
+ "value",
+ );
+ });
});
});
});
diff --git a/packages/js/src/lib/load-formbricks.ts b/packages/js/src/lib/load-formbricks.ts
index 0fd1dac..b18e410 100644
--- a/packages/js/src/lib/load-formbricks.ts
+++ b/packages/js/src/lib/load-formbricks.ts
@@ -1,59 +1,88 @@
import type { TFormbricks } from "../types/formbricks";
-declare global {
- const formbricks: TFormbricks &
- Record unknown>;
-}
-
type Result = { ok: true; data: T } | { ok: false; error: E };
+let coreInstance: TFormbricks | null = null;
let isInitializing = false;
-let isInitialized = false;
-// Load the SDK, return the result
-const loadFormbricksSDK = async (
- apiHostParam: string,
-): Promise> => {
- if (!(globalThis as unknown as Record).formbricks) {
- const scriptTag = document.createElement("script");
- scriptTag.type = "text/javascript";
- scriptTag.src = `${apiHostParam}/js/formbricks.umd.cjs`;
- scriptTag.async = true;
- const getFormbricks = async (): Promise =>
- new Promise((resolve, reject) => {
- const timeoutId = setTimeout(() => {
- reject(new Error(`Formbricks SDK loading timed out`));
- }, 10000);
- scriptTag.onload = () => {
- clearTimeout(timeoutId);
- resolve();
- };
- scriptTag.onerror = () => {
- clearTimeout(timeoutId);
- reject(new Error(`Failed to load Formbricks SDK`));
- };
+const queue: { method: string; args: unknown[] }[] = [];
+
+const loadFormbricksSDK = async (appUrl: string): Promise> => {
+ if ((globalThis as unknown as Record).formbricks) {
+ return { ok: true, data: undefined };
+ }
+
+ const scriptSrc = `${appUrl}/js/formbricks.umd.cjs`;
+
+ // Remove any previously appended script to prevent duplicates on retry
+ const existingScript = document.querySelector(`script[src="${scriptSrc}"]`);
+ if (existingScript) {
+ existingScript.remove();
+ }
+
+ const script = document.createElement("script");
+ script.type = "text/javascript";
+ script.src = scriptSrc;
+ script.async = true;
+
+ const loadPromise = new Promise>((resolve) => {
+ const timeoutId = setTimeout(() => {
+ resolve({
+ ok: false,
+ error: new Error("Formbricks SDK loading timed out"),
});
- document.head.appendChild(scriptTag);
- try {
- await getFormbricks();
- return { ok: true, data: undefined };
- } catch (error) {
- const err = error as { message?: string };
- return {
+ }, 10000);
+
+ script.onload = () => {
+ clearTimeout(timeoutId);
+
+ // UMD should set globalThis.formbricks synchronously on execution.
+ // Poll briefly as a fallback in case UMD environment detection was
+ // fooled (e.g. a leaked `exports` global) and the assignment was
+ // routed to module.exports instead of globalThis.
+ if ((globalThis as unknown as Record).formbricks) {
+ resolve({ ok: true, data: undefined });
+ return;
+ }
+
+ let attempts = 0;
+ const poll = setInterval(() => {
+ if ((globalThis as unknown as Record).formbricks) {
+ clearInterval(poll);
+ resolve({ ok: true, data: undefined });
+ } else if (++attempts >= 50) {
+ clearInterval(poll);
+ resolve({
+ ok: false,
+ error: new Error(
+ "Formbricks SDK loaded but not available on globalThis",
+ ),
+ });
+ }
+ }, 10);
+ };
+
+ script.onerror = () => {
+ clearTimeout(timeoutId);
+ resolve({
ok: false,
- error: new Error(err.message ?? `Failed to load Formbricks SDK`),
- };
- }
- }
- return { ok: true, data: undefined };
-};
+ error: new Error("Failed to load Formbricks SDK"),
+ });
+ };
+ });
-const functionsToProcess: { prop: string; args: unknown[] }[] = [];
+ // Register handlers above BEFORE appending to DOM so that a cached
+ // script whose onload fires on the next microtask is always caught.
+ document.head.appendChild(script);
+ return loadPromise;
+};
const validateSetupArgs = (
- args: unknown[],
+ config: unknown,
): { appUrl: string; environmentId: string } | null => {
- const argsTyped = args[0] as { appUrl: string; environmentId: string };
- const { appUrl, environmentId } = argsTyped;
+ const { appUrl, environmentId } = config as {
+ appUrl: string;
+ environmentId: string;
+ };
if (!appUrl) {
console.error("🧱 Formbricks - Error: appUrl is required");
@@ -73,78 +102,78 @@ const validateSetupArgs = (
return { appUrl: appUrlWithoutTrailingSlash, environmentId };
};
-const processQueuedFunctions = (formbricksInstance: TFormbricks): void => {
- for (const { prop: functionProp, args: functionArgs } of functionsToProcess) {
+const processQueue = (): void => {
+ while (queue.length > 0) {
+ const entry = queue.shift();
+ // Should never happen as we check for length above
+ if (!entry) break;
+ if (!coreInstance) break;
+
if (
- typeof formbricksInstance[
- functionProp as keyof typeof formbricksInstance
- ] !== "function"
+ typeof coreInstance[entry.method as keyof typeof coreInstance] !==
+ "function"
) {
console.error(
- `🧱 Formbricks - Error: Method ${functionProp} does not exist on formbricks`,
+ `🧱 Formbricks - Error: Method ${entry.method} does not exist on formbricks`,
);
continue;
}
+
// @ts-expect-error -- Required for dynamic function calls
- (formbricksInstance[functionProp] as unknown)(...functionArgs);
+ (coreInstance[entry.method as keyof typeof coreInstance] as unknown)(
+ ...entry.args,
+ );
}
};
-const handleSetupCall = async (args: unknown[]): Promise => {
+export const setup = async (config: {
+ appUrl: string;
+ environmentId: string;
+}): Promise => {
if (isInitializing) {
console.warn(
"🧱 Formbricks - Warning: Formbricks is already initializing.",
);
return;
}
- const validatedArgs = validateSetupArgs(args);
+
+ const validatedArgs = validateSetupArgs(config);
if (!validatedArgs) return;
+
isInitializing = true;
try {
- const loadSDKResult = await loadFormbricksSDK(validatedArgs.appUrl);
- const formbricksInstance = (
- globalThis as unknown as Record
- ).formbricks as TFormbricks;
- if (!loadSDKResult.ok || !formbricksInstance) {
+ const loadResult = await loadFormbricksSDK(validatedArgs.appUrl);
+ const instance = (globalThis as unknown as Record)
+ .formbricks as TFormbricks | undefined;
+
+ if (!loadResult.ok || !instance) {
console.error("🧱 Formbricks - Error: Failed to load Formbricks SDK");
return;
}
- await formbricksInstance.setup({ ...validatedArgs });
- isInitialized = true;
- processQueuedFunctions(formbricks);
+
+ await instance.setup({ ...validatedArgs });
+ coreInstance = instance;
+ processQueue();
} catch (err) {
+ coreInstance = null;
console.error("🧱 Formbricks - Error: setup failed", err);
} finally {
isInitializing = false;
}
};
-const executeFormbricksMethod = async (
- prop: string,
- args: unknown[],
-): Promise => {
- const formbricksInstance = (globalThis as unknown as Record)
- .formbricks;
-
- if (!formbricksInstance) return;
-
- // @ts-expect-error -- Required for dynamic function calls
- // eslint-disable-next-line @typescript-eslint/no-unsafe-call
- await formbricksInstance[prop](...args);
-};
-
-export const loadFormbricksToProxy = async (
- prop: string,
+export const callMethod = async (
+ method: string,
...args: unknown[]
): Promise => {
- if (isInitialized) {
- await executeFormbricksMethod(prop, args);
- } else if (prop === "setup") {
- await handleSetupCall(args);
+ if (coreInstance) {
+ // @ts-expect-error -- Required for dynamic function calls
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call
+ await coreInstance[method](...args);
} else {
console.warn(
"🧱 Formbricks - Warning: Formbricks not initialized. This method will be queued and executed after initialization.",
);
- functionsToProcess.push({ prop, args });
+ queue.push({ method, args });
}
};
diff --git a/packages/js/src/types/formbricks.ts b/packages/js/src/types/formbricks.ts
index 340ebca..cc3f0ad 100644
--- a/packages/js/src/types/formbricks.ts
+++ b/packages/js/src/types/formbricks.ts
@@ -1,12 +1,4 @@
export interface TFormbricks {
- /** @deprecated Use setup() instead. This method will be removed in a future version */
- init: (initConfig: {
- apiHost: string;
- environmentId: string;
- userId?: string;
- attributes?: Record;
- }) => Promise;
-
/**
* @description Initializes the Formbricks SDK.
* @param setupConfig - The configuration for the Formbricks SDK.