Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 68 additions & 1 deletion apps/playground/src/app.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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<TCapturedEvent[]>([]);

useEffect(() => {
document.body.classList.toggle("dark", darkMode);
Expand All @@ -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,
Expand All @@ -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 (
Expand Down Expand Up @@ -105,6 +141,37 @@ export default function App(): React.JSX.Element {
to see the logs.
</p>
</div>
<div className="mt-4 rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">
3. Events (formbricks.on)
</h3>
<p className="text-slate-700 dark:text-slate-300">
Everything below arrived through{" "}
<code className="dark:text-white">formbricks.on()</code>,
subscribed before <code className="dark:text-white">setup()</code>{" "}
— including{" "}
<code className="dark:text-white">
formbricks_setup_successful
</code>
.
</p>
{events.length === 0 ? (
<p className="mt-3 text-sm text-slate-500 dark:text-slate-400">
No events yet.
</p>
) : (
<ul className="mt-3 max-h-64 space-y-1 overflow-auto font-mono text-xs text-slate-800 dark:text-slate-200">
{events.map((event) => (
<li key={`${event.at}-${event.event}-${event.payload}`}>
<span className="text-slate-500 dark:text-slate-400">
{event.at}
</span>{" "}
<strong>{event.event}</strong> {event.payload}
</li>
))}
</ul>
)}
</div>
</div>
<div className="md:grid md:grid-cols-3">
<div className="col-span-3 self-start rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900">
Expand Down
8 changes: 7 additions & 1 deletion packages/js/src/index.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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;
136 changes: 136 additions & 0 deletions packages/js/src/lib/load-formbricks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import type { TSetupConfig } from "../types/formbricks";
// We need to import the module after each reset
let setup: (config: TSetupConfig) => Promise<void>;
let callMethod: (method: string, ...args: unknown[]) => Promise<void>;
let on: typeof import("./load-formbricks").on;
let off: typeof import("./load-formbricks").off;

// Mock the globalThis formbricks object
const mockFormbricks = {
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -574,4 +578,136 @@ 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<string, Set<(payload: unknown) => 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<string, unknown>) => {
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();
});
});
});
95 changes: 94 additions & 1 deletion packages/js/src/lib/load-formbricks.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,100 @@
import type { TFormbricks, TSetupConfig } from "../types/formbricks";
import type {
TFormbricks,
TFormbricksEventName,
TFormbricksEventPayloads,
TSetupConfig,
} from "../types/formbricks";

type Result<T, E = Error> = { ok: true; data: T } | { ok: false; error: E };

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 = <E extends TFormbricksEventName>(
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 = <E extends TFormbricksEventName>(
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<Result<void>> => {
if ((globalThis as unknown as Record<string, unknown>).formbricks) {
return { ok: true, data: undefined };
Expand Down Expand Up @@ -162,6 +251,10 @@ export const setup = async (config: TSetupConfig): Promise<void> => {
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();
Expand Down
Loading
Loading