Skip to content

Commit 4d9e726

Browse files
albin-baby-2002devin-ai-integration[bot]albin-baby-2002
authored
fix: make SSR-safe and fix ESLint warning for Beacon in UserDropdown (#24275)
* fix(user-dropdown): make SSR-safe and fix ESLint warning for Beacon * fix: added ssr guard in second useEffect and added type for beacon instead of ts-ignoring * fix: add dependency array to Beacon useEffect Address review feedback by adding [user?.username] dependency array to the Beacon session-data useEffect, improving performance by preventing unnecessary re-runs on every render. Co-authored-by: albin-baby-2002 <albinbaby2002@gmail.com> Co-Authored-By: unknown <> * test: add automated tests for UserDropdown Beacon functionality Add comprehensive tests for: - Beacon session-data calls with correct payload - Handling of undefined Beacon (no errors) - Username change triggering useEffect re-run - SSR safety (window undefined check) - Component rendering states (loading, user available, no user) Co-authored-by: albin-baby-2002 <albinbaby2002@gmail.com> Co-Authored-By: unknown <> * refactor: remove useless comments and misleading SSR test - Remove redundant comments above vi.mock calls (per keithwillcode feedback) - Remove SSR safety test that provided false coverage since jsdom always has window defined (per Cubic feedback) - The 'should not throw error when Beacon is undefined' test already covers graceful handling Co-Authored-By: unknown <> * fix: remove unused @ts-expect-error directives The Window interface is augmented with optional Beacon and Support properties, so TypeScript allows deleting them without errors. Co-Authored-By: unknown <> * fix: handle lazy-loaded Beacon with polling When Beacon loads lazily after component mount, the useEffect now polls every second until Beacon becomes available, then sends session data and clears the interval. This ensures session info is always sent even when HelpScout loads asynchronously. Added test to verify lazy loading behavior works correctly. Co-Authored-By: unknown <> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: albin-baby-2002 <albinbaby2002@gmail.com>
1 parent 0a0b320 commit 4d9e726

2 files changed

Lines changed: 284 additions & 10 deletions

File tree

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
import { render, waitFor } from "@testing-library/react";
2+
import React from "react";
3+
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
4+
5+
vi.mock("next-auth/react", () => ({
6+
signOut: vi.fn(),
7+
}));
8+
9+
vi.mock("next/navigation", () => ({
10+
usePathname: () => "/settings",
11+
}));
12+
13+
vi.mock("@calcom/lib/hooks/useLocale", () => ({
14+
useLocale: () => ({
15+
t: (key: string) => key,
16+
}),
17+
}));
18+
19+
const mockUseMeQuery = vi.fn();
20+
vi.mock("@calcom/trpc/react/hooks/useMeQuery", () => ({
21+
default: () => mockUseMeQuery(),
22+
}));
23+
24+
vi.mock("@calcom/web/components/settings/platform/hooks/useGetUserAttributes", () => ({
25+
useGetUserAttributes: () => ({
26+
isPlatformUser: false,
27+
}),
28+
}));
29+
30+
vi.mock("@calcom/web/modules/ee/support/lib/freshchat/FreshChatProvider", () => ({
31+
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
32+
}));
33+
34+
vi.mock("@calcom/ui/components/avatar", () => ({
35+
Avatar: () => <div data-testid="avatar">Avatar</div>,
36+
}));
37+
38+
vi.mock("@calcom/ui/components/icon", () => ({
39+
Icon: ({ name }: { name: string }) => <span data-testid={`icon-${name}`}>{name}</span>,
40+
}));
41+
42+
vi.mock("@calcom/ui/components/dropdown", () => ({
43+
Dropdown: ({ children }: { children: React.ReactNode }) => <div data-testid="dropdown">{children}</div>,
44+
DropdownItem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
45+
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
46+
DropdownMenuItem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
47+
DropdownMenuPortal: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
48+
DropdownMenuSeparator: () => <hr />,
49+
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
50+
}));
51+
52+
vi.mock("@calcom/ui/classNames", () => ({
53+
default: (...args: string[]) => args.filter(Boolean).join(" "),
54+
}));
55+
56+
describe("UserDropdown", () => {
57+
let mockBeacon: ReturnType<typeof vi.fn>;
58+
let mockSupportOpen: ReturnType<typeof vi.fn>;
59+
60+
beforeEach(() => {
61+
vi.clearAllMocks();
62+
63+
mockBeacon = vi.fn();
64+
mockSupportOpen = vi.fn();
65+
66+
Object.defineProperty(window, "Beacon", {
67+
value: mockBeacon,
68+
writable: true,
69+
configurable: true,
70+
});
71+
72+
Object.defineProperty(window, "Support", {
73+
value: { open: mockSupportOpen, shouldShowTriggerButton: vi.fn() },
74+
writable: true,
75+
configurable: true,
76+
});
77+
78+
Object.defineProperty(window, "screen", {
79+
value: { width: 1920, height: 1080 },
80+
writable: true,
81+
configurable: true,
82+
});
83+
});
84+
85+
afterEach(() => {
86+
delete window.Beacon;
87+
delete window.Support;
88+
});
89+
90+
describe("Beacon session-data functionality", () => {
91+
it("should call Beacon with session-data when Beacon is available and user has username", async () => {
92+
mockUseMeQuery.mockReturnValue({
93+
data: { username: "testuser", name: "Test User", avatarUrl: null, avatar: null },
94+
isPending: false,
95+
});
96+
97+
const { UserDropdown } = await import("./UserDropdown");
98+
render(<UserDropdown />);
99+
100+
await waitFor(() => {
101+
expect(mockBeacon).toHaveBeenCalledWith("session-data", {
102+
username: "testuser",
103+
screenResolution: "1920x1080",
104+
});
105+
});
106+
});
107+
108+
it("should call Beacon with 'Unknown' username when user has no username", async () => {
109+
mockUseMeQuery.mockReturnValue({
110+
data: { username: null, name: "Test User", avatarUrl: null, avatar: null },
111+
isPending: false,
112+
});
113+
114+
const { UserDropdown } = await import("./UserDropdown");
115+
render(<UserDropdown />);
116+
117+
await waitFor(() => {
118+
expect(mockBeacon).toHaveBeenCalledWith("session-data", {
119+
username: "Unknown",
120+
screenResolution: "1920x1080",
121+
});
122+
});
123+
});
124+
125+
it("should not throw error when Beacon is undefined", async () => {
126+
delete window.Beacon;
127+
128+
mockUseMeQuery.mockReturnValue({
129+
data: { username: "testuser", name: "Test User", avatarUrl: null, avatar: null },
130+
isPending: false,
131+
});
132+
133+
const { UserDropdown } = await import("./UserDropdown");
134+
135+
// Should not throw
136+
expect(() => render(<UserDropdown />)).not.toThrow();
137+
});
138+
139+
it("should call Beacon when it loads lazily after mount", async () => {
140+
// Start with Beacon undefined (simulating lazy load)
141+
delete window.Beacon;
142+
143+
mockUseMeQuery.mockReturnValue({
144+
data: { username: "testuser", name: "Test User", avatarUrl: null, avatar: null },
145+
isPending: false,
146+
});
147+
148+
const { UserDropdown } = await import("./UserDropdown");
149+
render(<UserDropdown />);
150+
151+
// Beacon should not have been called yet
152+
expect(mockBeacon).not.toHaveBeenCalled();
153+
154+
// Simulate Beacon loading after mount
155+
Object.defineProperty(window, "Beacon", {
156+
value: mockBeacon,
157+
writable: true,
158+
configurable: true,
159+
});
160+
161+
// Wait for the polling interval to detect Beacon
162+
await waitFor(
163+
() => {
164+
expect(mockBeacon).toHaveBeenCalledWith("session-data", {
165+
username: "testuser",
166+
screenResolution: "1920x1080",
167+
});
168+
},
169+
{ timeout: 2000 }
170+
);
171+
});
172+
173+
it("should update Beacon session-data when username changes", async () => {
174+
const { rerender } = render(<div />);
175+
176+
// First render with initial username
177+
mockUseMeQuery.mockReturnValue({
178+
data: { username: "user1", name: "User One", avatarUrl: null, avatar: null },
179+
isPending: false,
180+
});
181+
182+
const { UserDropdown } = await import("./UserDropdown");
183+
const { rerender: rerenderComponent } = render(<UserDropdown />);
184+
185+
await waitFor(() => {
186+
expect(mockBeacon).toHaveBeenCalledWith("session-data", {
187+
username: "user1",
188+
screenResolution: "1920x1080",
189+
});
190+
});
191+
192+
// Clear mock to track new calls
193+
mockBeacon.mockClear();
194+
195+
// Update username
196+
mockUseMeQuery.mockReturnValue({
197+
data: { username: "user2", name: "User Two", avatarUrl: null, avatar: null },
198+
isPending: false,
199+
});
200+
201+
rerenderComponent(<UserDropdown />);
202+
203+
await waitFor(() => {
204+
expect(mockBeacon).toHaveBeenCalledWith("session-data", {
205+
username: "user2",
206+
screenResolution: "1920x1080",
207+
});
208+
});
209+
});
210+
});
211+
212+
describe("Component rendering", () => {
213+
it("should return null when user is not available and not pending", async () => {
214+
mockUseMeQuery.mockReturnValue({
215+
data: null,
216+
isPending: false,
217+
});
218+
219+
const { UserDropdown } = await import("./UserDropdown");
220+
const { container } = render(<UserDropdown />);
221+
222+
// Component should return null
223+
expect(container.firstChild).toBeNull();
224+
});
225+
226+
it("should render dropdown when user is available", async () => {
227+
mockUseMeQuery.mockReturnValue({
228+
data: { username: "testuser", name: "Test User", avatarUrl: null, avatar: null },
229+
isPending: false,
230+
});
231+
232+
const { UserDropdown } = await import("./UserDropdown");
233+
const { getByTestId } = render(<UserDropdown />);
234+
235+
expect(getByTestId("dropdown")).toBeInTheDocument();
236+
});
237+
238+
it("should render dropdown when isPending is true (loading state)", async () => {
239+
mockUseMeQuery.mockReturnValue({
240+
data: null,
241+
isPending: true,
242+
});
243+
244+
const { UserDropdown } = await import("./UserDropdown");
245+
const { getByTestId } = render(<UserDropdown />);
246+
247+
expect(getByTestId("dropdown")).toBeInTheDocument();
248+
});
249+
});
250+
});

‎apps/web/modules/shell/user-dropdown/UserDropdown.tsx‎

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,17 @@ declare global {
2828
open: () => void;
2929
shouldShowTriggerButton: (showTrigger: boolean) => void;
3030
};
31+
Beacon?: BeaconFunction;
3132
}
3233
}
3334

35+
// NOTE: This interface only includes types for commands we currently use.
36+
type BeaconFunction = {
37+
(command: "session-data", data: Record<string, string | number>): void;
38+
// Catch-all for other methods - add explicit types above if using new commands
39+
(...args: unknown[]): void;
40+
};
41+
3442
interface UserDropdownProps {
3543
small?: boolean;
3644
}
@@ -43,16 +51,31 @@ export function UserDropdown({ small }: UserDropdownProps) {
4351
const isPlatformPages = pathname?.startsWith("/settings/platform");
4452

4553
useEffect(() => {
46-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
47-
//@ts-ignore
48-
const Beacon = window.Beacon;
49-
// window.Beacon is defined when user actually opens up HelpScout and username is available here. On every re-render update session info, so that it is always latest.
50-
Beacon &&
51-
Beacon("session-data", {
52-
username: user?.username || "Unknown",
53-
screenResolution: `${screen.width}x${screen.height}`,
54-
});
55-
});
54+
if (typeof window === "undefined") return;
55+
56+
const sendSessionData = () => {
57+
const Beacon = window.Beacon;
58+
if (Beacon) {
59+
Beacon("session-data", {
60+
username: user?.username || "Unknown",
61+
screenResolution: `${screen.width}x${screen.height}`,
62+
});
63+
return true;
64+
}
65+
return false;
66+
};
67+
68+
// Try immediately, then poll if Beacon isn't loaded yet
69+
if (!sendSessionData()) {
70+
const intervalId = setInterval(() => {
71+
if (sendSessionData()) {
72+
clearInterval(intervalId);
73+
}
74+
}, 1000);
75+
76+
return () => clearInterval(intervalId);
77+
}
78+
}, [user?.username]);
5679

5780
const [menuOpen, setMenuOpen] = useState(false);
5881
const [openSupportAfterClose, setOpenSupportAfterClose] = useState(false);
@@ -66,6 +89,7 @@ export function UserDropdown({ small }: UserDropdownProps) {
6689
};
6790

6891
useEffect(() => {
92+
if (typeof window === "undefined") return;
6993
if (!menuOpen && openSupportAfterClose) {
7094
setTimeout(() => {
7195
window.Support?.open();

0 commit comments

Comments
 (0)