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.