From 4d1dff36d82be7f4a0e274d9e83332342ca6fe42 Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Thu, 3 Sep 2026 10:28:54 +0530 Subject: [PATCH 1/2] feat: add formbricks.on/off event subscriptions, forwarded to the SDK before setup [ENG-1814] --- apps/playground/src/app.tsx | 69 ++++++++++- packages/js/src/index.ts | 8 +- packages/js/src/lib/load-formbricks.test.ts | 121 ++++++++++++++++++++ packages/js/src/lib/load-formbricks.ts | 95 ++++++++++++++- packages/js/src/types/formbricks.ts | 41 +++++++ 5 files changed, 331 insertions(+), 3 deletions(-) diff --git a/apps/playground/src/app.tsx b/apps/playground/src/app.tsx index ccdd366..fd237bc 100644 --- a/apps/playground/src/app.tsx +++ b/apps/playground/src/app.tsx @@ -1,4 +1,4 @@ -import formbricks from "@formbricks/js"; +import formbricks, { type TFormbricksEventName } from "@formbricks/js"; import { useEffect, useState } from "react"; import fbsetup from "./assets/fb-setup.png"; @@ -9,8 +9,23 @@ const userAttributes = { "Attribute 3": "three", }; +const FORMBRICKS_EVENT_NAMES: TFormbricksEventName[] = [ + "formbricks_setup_successful", + "formbricks_action_tracked", + "formbricks_survey_shown", + "formbricks_response_submitted", + "formbricks_survey_closed", +]; + +interface TCapturedEvent { + at: string; + event: string; + payload: string; +} + export default function App(): React.JSX.Element { const [darkMode, setDarkMode] = useState(false); + const [events, setEvents] = useState([]); useEffect(() => { document.body.classList.toggle("dark", darkMode); @@ -32,6 +47,21 @@ export default function App(): React.JSX.Element { : null, ].filter((value): value is string => value !== null); + // Subscribed BEFORE setup() — the only order in which + // formbricks_setup_successful can be observed. + const unsubscribers = FORMBRICKS_EVENT_NAMES.map((name) => + formbricks.on(name, (payload) => { + setEvents((previous) => [ + { + at: new Date().toLocaleTimeString(), + event: name, + payload: JSON.stringify(payload), + }, + ...previous, + ]); + }), + ); + if (missingEnvVars.length === 0) { formbricks.setup({ workspaceId: import.meta.env.VITE_FORMBRICKS_WORKSPACE_ID, @@ -42,6 +72,12 @@ export default function App(): React.JSX.Element { `Formbricks not initialized because the following environment variable(s) are missing: ${missingEnvVars.join(", ")}`, ); } + + return () => { + for (const unsubscribe of unsubscribers) { + unsubscribe(); + } + }; }, []); return ( @@ -105,6 +141,37 @@ export default function App(): React.JSX.Element { to see the logs.

+
+

+ 3. Events (formbricks.on) +

+

+ Everything below arrived through{" "} + formbricks.on(), + subscribed before setup(){" "} + — including{" "} + + formbricks_setup_successful + + . +

+ {events.length === 0 ? ( +

+ No events yet. +

+ ) : ( +
    + {events.map((event) => ( +
  • + + {event.at} + {" "} + {event.event} {event.payload} +
  • + ))} +
+ )} +
diff --git a/packages/js/src/index.ts b/packages/js/src/index.ts index 82bfb03..1cf0bf1 100644 --- a/packages/js/src/index.ts +++ b/packages/js/src/index.ts @@ -1,4 +1,4 @@ -import { callMethod, setup } from "./lib/load-formbricks"; +import { callMethod, off, on, setup } from "./lib/load-formbricks"; import type { TFormbricks } from "./types/formbricks"; declare global { @@ -18,6 +18,12 @@ const formbricks: TFormbricks = { track: (code, properties) => callMethod("track", code, properties), logout: () => callMethod("logout"), registerRouteChange: () => callMethod("registerRouteChange"), + on: (event, handler) => on(event, handler), + off: (event, handler) => off(event, handler), }; +export type { + TFormbricksEventName, + TFormbricksEventPayloads, +} from "./types/formbricks"; export default formbricks; diff --git a/packages/js/src/lib/load-formbricks.test.ts b/packages/js/src/lib/load-formbricks.test.ts index 6b72aa4..09351b1 100644 --- a/packages/js/src/lib/load-formbricks.test.ts +++ b/packages/js/src/lib/load-formbricks.test.ts @@ -4,6 +4,8 @@ import type { TSetupConfig } from "../types/formbricks"; // We need to import the module after each reset let setup: (config: TSetupConfig) => Promise; let callMethod: (method: string, ...args: unknown[]) => Promise; +let on: typeof import("./load-formbricks").on; +let off: typeof import("./load-formbricks").off; // Mock the globalThis formbricks object const mockFormbricks = { @@ -118,6 +120,8 @@ describe("load-formbricks", () => { const module = await import("./load-formbricks"); setup = module.setup; callMethod = module.callMethod; + on = module.on; + off = module.off; }); afterEach(() => { @@ -574,4 +578,121 @@ describe("load-formbricks", () => { }); }); }); + describe("on / off subscriptions", () => { + // An instance whose setup() emits formbricks_setup_successful to whatever was registered on it + // beforehand — the ordering the wrapper must preserve. + const createEmittingInstance = () => { + const registry = new Map void>>(); + const instance = { + ...mockFormbricks, + on: vi.fn((event: string, handler: (payload: unknown) => void) => { + const handlers = registry.get(event) ?? new Set<(payload: unknown) => void>(); + handlers.add(handler); + registry.set(event, handlers); + return () => handlers.delete(handler); + }), + off: vi.fn((event: string, handler: (payload: unknown) => void) => { + registry.get(event)?.delete(handler); + }), + setup: vi.fn(() => { + registry + .get("formbricks_setup_successful") + ?.forEach((handler) => handler({ workspaceId: "ws_1" })); + return Promise.resolve(); + }), + }; + return instance; + }; + + const runSetupWith = async (instance: Record) => { + vi.spyOn(document.head, "appendChild").mockImplementation((element: Node) => { + const script = element as HTMLScriptElement; + setTimeout(() => { + typedGlobalThis.formbricks = instance; + if (script.onload) script.onload({} as Event); + }, 0); + return element; + }); + await setup({ appUrl: "https://app.formbricks.com", workspaceId: "ws_1" }); + }; + + test("subscriptions made before setup are forwarded to the SDK before setup runs", async () => { + const instance = createEmittingInstance(); + const handler = vi.fn(); + + on("formbricks_setup_successful", handler); + await runSetupWith(instance); + + // The whole point: setup_successful fired during setup() and the handler heard it. + expect(handler).toHaveBeenCalledWith({ workspaceId: "ws_1" }); + // And mechanically: on() reached the instance before setup() did. + const onOrder = instance.on.mock.invocationCallOrder[0]; + const setupOrder = instance.setup.mock.invocationCallOrder[0]; + expect(onOrder).toBeLessThan(setupOrder); + }); + + test("an unsubscribe taken before load prevents the subscription from ever reaching the SDK", async () => { + const instance = createEmittingInstance(); + const handler = vi.fn(); + + const unsubscribe = on("formbricks_setup_successful", handler); + unsubscribe(); + await runSetupWith(instance); + + expect(instance.on).not.toHaveBeenCalled(); + expect(handler).not.toHaveBeenCalled(); + }); + + test("off() before load removes a pending subscription", async () => { + const instance = createEmittingInstance(); + const handler = vi.fn(); + + on("formbricks_survey_shown", handler); + off("formbricks_survey_shown", handler); + await runSetupWith(instance); + + expect(instance.on).not.toHaveBeenCalled(); + }); + + test("an unsubscribe taken before load still works after the subscription was forwarded", async () => { + const instance = createEmittingInstance(); + const handler = vi.fn(); + + const unsubscribe = on("formbricks_survey_shown", handler); + await runSetupWith(instance); + unsubscribe(); + + expect(instance.off).toHaveBeenCalledWith("formbricks_survey_shown", handler); + }); + + test("after setup, on() and off() pass straight through to the SDK", async () => { + const instance = createEmittingInstance(); + await runSetupWith(instance); + + const handler = vi.fn(); + on("formbricks_survey_closed", handler); + expect(instance.on).toHaveBeenCalledWith("formbricks_survey_closed", handler); + + off("formbricks_survey_closed", handler); + expect(instance.off).toHaveBeenCalledWith("formbricks_survey_closed", handler); + }); + + test("an older self-hosted SDK without events warns instead of crashing", async () => { + const consoleWarnSpy = createConsoleWarnSpy(); + const legacyInstance = { ...mockFormbricks }; // no on/off + const handler = vi.fn(); + + on("formbricks_setup_successful", handler); + + await expect(runSetupWith(legacyInstance)).resolves.toBeUndefined(); + expect(handler).not.toHaveBeenCalled(); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("does not support events"), + ); + + // And a post-setup on() against the legacy instance is a safe no-op. + const unsubscribe = on("formbricks_survey_shown", vi.fn()); + expect(() => unsubscribe()).not.toThrow(); + }); + }); }); diff --git a/packages/js/src/lib/load-formbricks.ts b/packages/js/src/lib/load-formbricks.ts index e46b6a0..8958a66 100644 --- a/packages/js/src/lib/load-formbricks.ts +++ b/packages/js/src/lib/load-formbricks.ts @@ -1,4 +1,9 @@ -import type { TFormbricks, TSetupConfig } from "../types/formbricks"; +import type { + TFormbricks, + TFormbricksEventName, + TFormbricksEventPayloads, + TSetupConfig, +} from "../types/formbricks"; type Result = { ok: true; data: T } | { ok: false; error: E }; @@ -6,6 +11,90 @@ let coreInstance: TFormbricks | null = null; let isInitializing = false; const queue: { method: string; args: unknown[] }[] = []; +/** + * Subscriptions made before the SDK script has loaded. Unlike the method queue below, these are + * forwarded to the SDK BEFORE `setup()` runs — a `formbricks_setup_successful` handler queued + * behind setup would always register too late to hear it. Entries stay in the array so the + * unsubscribe closures handed back by `on()` keep working across the load boundary: `target` + * records where a subscription was forwarded, `removed` marks ones taken back while still pending. + */ +interface TPendingSubscription { + event: TFormbricksEventName; + handler: (payload: unknown) => void; + target: TFormbricks | null; + removed: boolean; +} +const subscriptions: TPendingSubscription[] = []; + +/** + * A self-hosted instance can serve an older js-core that predates events — feature-detect instead + * of crashing, so the rest of the SDK keeps working against it. + */ +const supportsEvents = (instance: TFormbricks): boolean => { + if (typeof instance.on === "function" && typeof instance.off === "function") { + return true; + } + console.warn( + "🧱 Formbricks - Warning: this Formbricks instance does not support events yet (formbricks.on). Update your self-hosted Formbricks to use event subscriptions.", + ); + return false; +}; + +const flushSubscriptionsTo = (instance: TFormbricks): void => { + if (subscriptions.length > 0 && !supportsEvents(instance)) return; + for (const entry of subscriptions) { + if (entry.removed || entry.target) continue; + instance.on(entry.event, entry.handler); + entry.target = instance; + } +}; + +export const on = ( + event: E, + handler: (payload: TFormbricksEventPayloads[E]) => void, +): (() => void) => { + if (coreInstance) { + if (!supportsEvents(coreInstance)) { + return () => undefined; + } + return coreInstance.on(event, handler); + } + + const entry: TPendingSubscription = { + event, + handler: handler as (payload: unknown) => void, + target: null, + removed: false, + }; + subscriptions.push(entry); + + return () => { + entry.removed = true; + entry.target?.off(event, handler); + }; +}; + +export const off = ( + event: E, + handler: (payload: TFormbricksEventPayloads[E]) => void, +): void => { + for (const entry of subscriptions) { + if ( + entry.event === event && + entry.handler === (handler as (payload: unknown) => void) && + !entry.removed + ) { + entry.removed = true; + entry.target?.off(event, handler); + } + } + + // Subscriptions made after load went straight to the SDK and are not in the array above. + if (coreInstance && typeof coreInstance.off === "function") { + coreInstance.off(event, handler); + } +}; + const loadFormbricksSDK = async (appUrl: string): Promise> => { if ((globalThis as unknown as Record).formbricks) { return { ok: true, data: undefined }; @@ -162,6 +251,10 @@ export const setup = async (config: TSetupConfig): Promise => { return; } + // Before setup on purpose: subscriptions must be listening when setup emits + // formbricks_setup_successful. The method queue stays after setup — those calls need a + // set-up SDK, subscriptions need the opposite order. + flushSubscriptionsTo(instance); await instance.setup({ ...validatedArgs }); coreInstance = instance; processQueue(); diff --git a/packages/js/src/types/formbricks.ts b/packages/js/src/types/formbricks.ts index 94046ce..af3226f 100644 --- a/packages/js/src/types/formbricks.ts +++ b/packages/js/src/types/formbricks.ts @@ -1,3 +1,22 @@ +/** + * What each Formbricks event carries. Kept in sync with js-core's `TFormbricksEventPayloads` + * (packages/js-core/src/lib/common/events.ts in the formbricks monorepo) — the wrapper has no + * dependency on js-core, so the contract is declared on both sides. + */ +export interface TFormbricksEventPayloads { + formbricks_setup_successful: { workspaceId: string }; + formbricks_action_tracked: { action: string }; + formbricks_survey_shown: { surveyId: string }; + formbricks_response_submitted: { + surveyId: string; + responseId?: string; + finished: boolean; + }; + formbricks_survey_closed: { surveyId: string }; +} + +export type TFormbricksEventName = keyof TFormbricksEventPayloads; + export interface TFormbricks { /** * @description Initializes the Formbricks SDK. @@ -63,6 +82,28 @@ export interface TFormbricks { * @description Registers a route change. */ registerRouteChange: () => Promise; + + /** + * @description Subscribes to a Formbricks event. Safe to call before setup(); subscriptions made + * early are forwarded to the SDK before setup runs, so `formbricks_setup_successful` is caught. + * @param event - Full event name, e.g. "formbricks_survey_shown". + * @param handler - Called with that event's payload. + * @returns A function that removes this subscription. + */ + on: ( + event: E, + handler: (payload: TFormbricksEventPayloads[E]) => void, + ) => () => void; + + /** + * @description Removes a subscription registered with on(). + * @param event - The event name the handler was registered for. + * @param handler - The same function reference that was passed to on(). + */ + off: ( + event: E, + handler: (payload: TFormbricksEventPayloads[E]) => void, + ) => void; } export type TSetupConfig = From 7d740ecd95354199632b92e7d3652ca0511e2f91 Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Thu, 3 Sep 2026 10:43:09 +0530 Subject: [PATCH 2/2] fix: satisfy biome in the subscription tests (no forEach return, formatting) --- packages/js/src/lib/load-formbricks.test.ts | 45 ++++++++++++++------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/packages/js/src/lib/load-formbricks.test.ts b/packages/js/src/lib/load-formbricks.test.ts index 09351b1..fd79ea5 100644 --- a/packages/js/src/lib/load-formbricks.test.ts +++ b/packages/js/src/lib/load-formbricks.test.ts @@ -586,7 +586,8 @@ describe("load-formbricks", () => { const instance = { ...mockFormbricks, on: vi.fn((event: string, handler: (payload: unknown) => void) => { - const handlers = registry.get(event) ?? new Set<(payload: unknown) => void>(); + const handlers = + registry.get(event) ?? new Set<(payload: unknown) => void>(); handlers.add(handler); registry.set(event, handlers); return () => handlers.delete(handler); @@ -595,9 +596,9 @@ describe("load-formbricks", () => { registry.get(event)?.delete(handler); }), setup: vi.fn(() => { - registry - .get("formbricks_setup_successful") - ?.forEach((handler) => handler({ workspaceId: "ws_1" })); + registry.get("formbricks_setup_successful")?.forEach((handler) => { + handler({ workspaceId: "ws_1" }); + }); return Promise.resolve(); }), }; @@ -605,15 +606,20 @@ describe("load-formbricks", () => { }; const runSetupWith = async (instance: Record) => { - vi.spyOn(document.head, "appendChild").mockImplementation((element: Node) => { - const script = element as HTMLScriptElement; - setTimeout(() => { - typedGlobalThis.formbricks = instance; - if (script.onload) script.onload({} as Event); - }, 0); - return element; + vi.spyOn(document.head, "appendChild").mockImplementation( + (element: Node) => { + const script = element as HTMLScriptElement; + setTimeout(() => { + typedGlobalThis.formbricks = instance; + if (script.onload) script.onload({} as Event); + }, 0); + return element; + }, + ); + await setup({ + appUrl: "https://app.formbricks.com", + workspaceId: "ws_1", }); - await setup({ appUrl: "https://app.formbricks.com", workspaceId: "ws_1" }); }; test("subscriptions made before setup are forwarded to the SDK before setup runs", async () => { @@ -662,7 +668,10 @@ describe("load-formbricks", () => { await runSetupWith(instance); unsubscribe(); - expect(instance.off).toHaveBeenCalledWith("formbricks_survey_shown", handler); + expect(instance.off).toHaveBeenCalledWith( + "formbricks_survey_shown", + handler, + ); }); test("after setup, on() and off() pass straight through to the SDK", async () => { @@ -671,10 +680,16 @@ describe("load-formbricks", () => { const handler = vi.fn(); on("formbricks_survey_closed", handler); - expect(instance.on).toHaveBeenCalledWith("formbricks_survey_closed", handler); + expect(instance.on).toHaveBeenCalledWith( + "formbricks_survey_closed", + handler, + ); off("formbricks_survey_closed", handler); - expect(instance.off).toHaveBeenCalledWith("formbricks_survey_closed", handler); + expect(instance.off).toHaveBeenCalledWith( + "formbricks_survey_closed", + handler, + ); }); test("an older self-hosted SDK without events warns instead of crashing", async () => {