From e465f465dbad33eabd680d07bc0f43facd1e8c2d Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Tue, 31 Mar 2026 11:41:25 +0530 Subject: [PATCH 1/3] removes the proxy and refactors load survey function --- packages/js/src/index.test.ts | 185 +++++++---------- packages/js/src/index.ts | 23 ++- packages/js/src/lib/load-formbricks.test.ts | 211 ++++++++++++++++++-- packages/js/src/lib/load-formbricks.ts | 186 +++++++++-------- packages/js/src/types/formbricks.ts | 8 - 5 files changed, 380 insertions(+), 233 deletions(-) diff --git a/packages/js/src/index.test.ts b/packages/js/src/index.test.ts index 64891d7..da404dc 100644 --- a/packages/js/src/index.test.ts +++ b/packages/js/src/index.test.ts @@ -2,22 +2,21 @@ import { describe, test, expect, vi, beforeEach } 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 { 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,46 +68,38 @@ 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" - ); + expect(mockCallMethod).toHaveBeenCalledWith("registerRouteChange"); }); - test("should handle deprecated init method", async () => { + test("should delegate deprecated init to callMethod", async () => { const initConfig = { apiHost: "https://app.formbricks.com", environmentId: "env123", @@ -124,56 +111,15 @@ describe("formbricks proxy", () => { await formbricks.init(initConfig); - expect(mockLoadFormbricksToProxy).toHaveBeenCalledWith("init", initConfig); - }); - - 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"]; - - await formbricks[customMethod](args[0]); - - expect(mockLoadFormbricksToProxy).toHaveBeenCalledWith( - customMethod, - args[0] - ); + expect(mockCallMethod).toHaveBeenCalledWith("init", initConfig); }); - test("should return the result of loadFormbricksToProxy calls", async () => { - const mockResult = "test-result"; - mockLoadFormbricksToProxy.mockResolvedValue(mockResult as unknown as void); - - const result = await formbricks.setEmail("test@example.com"); - - expect(result).toBe(mockResult); - }); + test("should delegate setNonce to callMethod", async () => { + const nonce = "abc123"; - test("should propagate errors from loadFormbricksToProxy", async () => { - const error = new Error("Test error"); - mockLoadFormbricksToProxy.mockRejectedValue(error); + await formbricks.setNonce(nonce); - 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 +132,79 @@ describe("formbricks proxy", () => { await Promise.all(calls); - expect(mockLoadFormbricksToProxy).toHaveBeenCalledTimes(4); - expect(mockLoadFormbricksToProxy).toHaveBeenCalledWith( + expect(mockCallMethod).toHaveBeenCalledTimes(4); + expect(mockCallMethod).toHaveBeenCalledWith( "setEmail", "test@example.com" ); - expect(mockLoadFormbricksToProxy).toHaveBeenCalledWith( + 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.init).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 +215,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 899c568..1ea2f01 100644 --- a/packages/js/src/index.ts +++ b/packages/js/src/index.ts @@ -1,5 +1,5 @@ import { type TFormbricks as TFormbricksCore } from "./types/formbricks"; -import { loadFormbricksToProxy } from "./lib/load-formbricks"; +import { setup, callMethod } from "./lib/load-formbricks"; type TFormbricks = Omit & { track: (code: string) => Promise; @@ -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 9ebcd07..66deaba 100644 --- a/packages/js/src/lib/load-formbricks.test.ts +++ b/packages/js/src/lib/load-formbricks.test.ts @@ -1,15 +1,13 @@ import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"; -import { 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 8396cb9..a5003e3 100644 --- a/packages/js/src/lib/load-formbricks.ts +++ b/packages/js/src/lib/load-formbricks.ts @@ -1,59 +1,80 @@ 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 script = document.createElement("script"); + script.type = "text/javascript"; + script.src = `${appUrl}/js/formbricks.umd.cjs`; + 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,45 +94,58 @@ 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); + + coreInstance = instance; + await coreInstance.setup({ ...validatedArgs }); + processQueue(); } catch (err) { console.error("🧱 Formbricks - Error: setup failed", err); } finally { @@ -119,32 +153,18 @@ const handleSetupCall = async (args: unknown[]): Promise => { } }; -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 344a07b..e8254d4 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. From 30cc4e69fe49ad8484e2fd14df822515dc4f83e4 Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Tue, 31 Mar 2026 12:06:06 +0530 Subject: [PATCH 2/3] addressed feedback --- packages/js/src/lib/load-formbricks.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/js/src/lib/load-formbricks.ts b/packages/js/src/lib/load-formbricks.ts index 1c1cc5d..068f83e 100644 --- a/packages/js/src/lib/load-formbricks.ts +++ b/packages/js/src/lib/load-formbricks.ts @@ -11,9 +11,17 @@ const loadFormbricksSDK = async (appUrl: string): Promise> => { 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 = `${appUrl}/js/formbricks.umd.cjs`; + script.src = scriptSrc; script.async = true; const loadPromise = new Promise>((resolve) => { @@ -143,10 +151,11 @@ export const setup = async (config: { return; } + await instance.setup({ ...validatedArgs }); coreInstance = instance; - await coreInstance.setup({ ...validatedArgs }); processQueue(); } catch (err) { + coreInstance = null; console.error("🧱 Formbricks - Error: setup failed", err); } finally { isInitializing = false; From fbcc3bae1f5bccbef3fba402083622cd0251c32c Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Tue, 31 Mar 2026 12:10:48 +0530 Subject: [PATCH 3/3] fixes lint --- packages/js/src/index.test.ts | 9 +++------ packages/js/src/index.ts | 4 ++-- packages/js/src/lib/load-formbricks.test.ts | 20 ++++++++++---------- packages/js/src/lib/load-formbricks.ts | 8 ++++---- 4 files changed, 19 insertions(+), 22 deletions(-) diff --git a/packages/js/src/index.test.ts b/packages/js/src/index.test.ts index 3f6f47e..c7b238b 100644 --- a/packages/js/src/index.test.ts +++ b/packages/js/src/index.test.ts @@ -118,10 +118,7 @@ describe("formbricks", () => { await Promise.all(calls); expect(mockCallMethod).toHaveBeenCalledTimes(4); - expect(mockCallMethod).toHaveBeenCalledWith( - "setEmail", - "test@example.com", - ); + expect(mockCallMethod).toHaveBeenCalledWith("setEmail", "test@example.com"); expect(mockCallMethod).toHaveBeenCalledWith( "setAttribute", "userId", @@ -139,7 +136,7 @@ describe("formbricks", () => { formbricks.setup({ environmentId: "env123", appUrl: "https://app.formbricks.com", - }) + }), ).rejects.toThrow("Test error"); }); @@ -148,7 +145,7 @@ describe("formbricks", () => { mockCallMethod.mockRejectedValue(error); await expect(formbricks.setEmail("test@example.com")).rejects.toThrow( - "Test error" + "Test error", ); }); }); diff --git a/packages/js/src/index.ts b/packages/js/src/index.ts index 1ea2f01..6833f38 100644 --- a/packages/js/src/index.ts +++ b/packages/js/src/index.ts @@ -1,5 +1,5 @@ -import { type TFormbricks as TFormbricksCore } from "./types/formbricks"; -import { setup, callMethod } from "./lib/load-formbricks"; +import { callMethod, setup } from "./lib/load-formbricks"; +import type { TFormbricks as TFormbricksCore } from "./types/formbricks"; type TFormbricks = Omit & { track: (code: string) => Promise; diff --git a/packages/js/src/lib/load-formbricks.test.ts b/packages/js/src/lib/load-formbricks.test.ts index 29c4fad..3565c75 100644 --- a/packages/js/src/lib/load-formbricks.test.ts +++ b/packages/js/src/lib/load-formbricks.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; // We need to import the module after each reset let setup: (config: { appUrl: string; environmentId: string }) => Promise; @@ -313,7 +313,7 @@ describe("load-formbricks", () => { typedGlobalThis.formbricks = mockFormbricks; }, 25); return element; - } + }, ); const setupPromise = setup({ @@ -346,7 +346,7 @@ describe("load-formbricks", () => { } }, 0); return element; - } + }, ); const setupPromise = setup({ @@ -360,7 +360,7 @@ describe("load-formbricks", () => { await setupPromise; expect(consoleSpy).toHaveBeenCalledWith( - "🧱 Formbricks - Error: Failed to load Formbricks SDK" + "🧱 Formbricks - Error: Failed to load Formbricks SDK", ); expect(mockFormbricks.setup).not.toHaveBeenCalled(); }); @@ -374,7 +374,7 @@ describe("load-formbricks", () => { (element: Node) => { // Don't resolve — keep initializing return element; - } + }, ); // Start setup (will hang because onload never fires) @@ -390,7 +390,7 @@ describe("load-formbricks", () => { }); expect(warnSpy).toHaveBeenCalledWith( - "🧱 Formbricks - Warning: Formbricks is already initializing." + "🧱 Formbricks - Warning: Formbricks is already initializing.", ); // Clean up by letting the first setup timeout @@ -445,7 +445,7 @@ describe("load-formbricks", () => { await callMethod("track", "queued-event"); vi.spyOn(document.head, "appendChild").mockImplementation( - createSuccessfulScriptMock() + createSuccessfulScriptMock(), ); await setup({ appUrl: "https://app.formbricks.com", @@ -453,7 +453,7 @@ describe("load-formbricks", () => { }); expect(mockFormbricks.setEmail).toHaveBeenCalledWith( - "test@example.com" + "test@example.com", ); expect(mockFormbricks.track).toHaveBeenCalledWith("queued-event"); expect(callOrder).toEqual(["setEmail", "track"]); @@ -483,7 +483,7 @@ describe("load-formbricks", () => { test("should pass multiple arguments to methods", async () => { vi.spyOn(document.head, "appendChild").mockImplementation( - createSuccessfulScriptMock() + createSuccessfulScriptMock(), ); await setup({ @@ -495,7 +495,7 @@ describe("load-formbricks", () => { expect(mockFormbricks.setAttribute).toHaveBeenCalledWith( "key", - "value" + "value", ); }); }); diff --git a/packages/js/src/lib/load-formbricks.ts b/packages/js/src/lib/load-formbricks.ts index 068f83e..b18e410 100644 --- a/packages/js/src/lib/load-formbricks.ts +++ b/packages/js/src/lib/load-formbricks.ts @@ -54,7 +54,7 @@ const loadFormbricksSDK = async (appUrl: string): Promise> => { resolve({ ok: false, error: new Error( - "Formbricks SDK loaded but not available on globalThis" + "Formbricks SDK loaded but not available on globalThis", ), }); } @@ -77,7 +77,7 @@ const loadFormbricksSDK = async (appUrl: string): Promise> => { }; const validateSetupArgs = ( - config: unknown + config: unknown, ): { appUrl: string; environmentId: string } | null => { const { appUrl, environmentId } = config as { appUrl: string; @@ -114,14 +114,14 @@ const processQueue = (): void => { "function" ) { console.error( - `🧱 Formbricks - Error: Method ${entry.method} does not exist on formbricks` + `🧱 Formbricks - Error: Method ${entry.method} does not exist on formbricks`, ); continue; } // @ts-expect-error -- Required for dynamic function calls (coreInstance[entry.method as keyof typeof coreInstance] as unknown)( - ...entry.args + ...entry.args, ); } };