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 () => {