Skip to content

Commit 4270ec0

Browse files
tyler-daneclaude
andcommitted
refactor(web): drop test-only surface from the reconnect toast
- move the once-per-load latch into user-metadata.util (its only consumer) as a plain two-line guard, deleting showGoogleReconnectToastOnLoad, its boolean return, and the resetGoogleReconnectToastOnLoadForTests export - give GoogleReconnectToast a syncPendingLocalEvents parameter default so the test injects a plain mock instead of a flag-gated process-wide mock.module of google.auth.util - pass showGoogleReconnectToast to the factory bare instead of wrapping it in a void lambda No useEffect/useRef/useState in the diff. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a7155e3 commit 4270ec0

4 files changed

Lines changed: 40 additions & 74 deletions

File tree

packages/web/src/auth/compass/user/util/user-metadata.util.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { Status } from "@core/errors/status.codes";
22
import { UserApi } from "@web/api/user.api";
33
import { userMetadataActions } from "@web/auth/state/user-metadata.store";
4-
import { showGoogleReconnectToastOnLoad } from "@web/common/utils/toast/google-reconnect.toast";
4+
import { showGoogleReconnectToast } from "@web/common/utils/toast/google-reconnect.toast";
55

66
let refreshUserMetadataRequest: Promise<void> | null = null;
7+
let hasShownReconnectToastThisLoad = false;
78

89
export const refreshUserMetadata = async (options?: {
910
force?: boolean;
@@ -26,10 +27,15 @@ export const refreshUserMetadata = async (options?: {
2627
userMetadataActions.set(metadata);
2728

2829
// Catches returning users whose Google grant died while they were away:
29-
// they get the actionable reconnect toast on load instead of having to
30-
// discover the palette's "Reconnect Google Calendar" action.
31-
if (metadata.google?.connectionState === "RECONNECT_REQUIRED") {
32-
showGoogleReconnectToastOnLoad();
30+
// they get the actionable reconnect toast instead of having to discover
31+
// the palette's "Reconnect Google Calendar" action. At most once per
32+
// page load, so a dismissal isn't nagged by later refreshes.
33+
if (
34+
metadata.google?.connectionState === "RECONNECT_REQUIRED" &&
35+
!hasShownReconnectToastThisLoad
36+
) {
37+
hasShownReconnectToastThisLoad = true;
38+
showGoogleReconnectToast();
3339
}
3440
})
3541
.catch((error) => {

packages/web/src/auth/google/util/google.auth.util.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ const googleAuthUtil = createGoogleAuthUtil({
5555
);
5656
},
5757
}),
58-
showReconnectToast: () => void showGoogleReconnectToast(),
58+
showReconnectToast: showGoogleReconnectToast,
5959
syncLocalEventsToCloud: () => syncLocalEventsToCloud(),
6060
toastError: toast.error,
6161
});

packages/web/src/common/utils/toast/google-reconnect.toast.test.tsx

Lines changed: 15 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { render, screen, waitFor } from "@testing-library/react";
22
import userEvent from "@testing-library/user-event";
3-
import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test";
3+
import { beforeEach, describe, expect, it, mock } from "bun:test";
44

55
// mock.module is process-wide and leaks across test files (deleted-toast's
66
// react-toastify mock would otherwise leak in here with no error/dismiss), so
@@ -27,44 +27,31 @@ mock.module(
2727
}),
2828
);
2929

30-
// The real module is captured up front and a flag (flipped off in afterAll)
31-
// decides which implementation runs per call, keeping other test files'
32-
// imports of google.auth.util intact.
33-
const actualGoogleAuthUtil = await import(
34-
"@web/auth/google/util/google.auth.util"
30+
const { GoogleReconnectToast, showGoogleReconnectToast } = await import(
31+
"@web/common/utils/toast/google-reconnect.toast"
3532
);
36-
const mockSyncPendingLocalEvents = mock();
37-
let isGoogleAuthUtilMocked = true;
38-
39-
mock.module("@web/auth/google/util/google.auth.util", () => ({
40-
...actualGoogleAuthUtil,
41-
syncPendingLocalEvents: () =>
42-
isGoogleAuthUtilMocked
43-
? mockSyncPendingLocalEvents()
44-
: actualGoogleAuthUtil.syncPendingLocalEvents(),
45-
}));
4633

47-
const {
48-
GoogleReconnectToast,
49-
resetGoogleReconnectToastOnLoadForTests,
50-
showGoogleReconnectToast,
51-
showGoogleReconnectToastOnLoad,
52-
} = await import("@web/common/utils/toast/google-reconnect.toast");
34+
const mockSyncPendingLocalEvents = mock();
5335

54-
afterAll(() => {
55-
isGoogleAuthUtilMocked = false;
56-
});
36+
const renderToast = () =>
37+
render(
38+
<GoogleReconnectToast
39+
toastId="google-revoked-api"
40+
syncPendingLocalEvents={mockSyncPendingLocalEvents}
41+
/>,
42+
);
5743

5844
describe("GoogleReconnectToast", () => {
5945
beforeEach(() => {
6046
mockStartGoogleAuthorization.mockClear();
6147
mockSyncPendingLocalEvents.mockClear();
6248
toast.error.mockClear();
6349
toast.dismiss.mockClear();
50+
toast.isActive.mockReturnValue(false);
6451
});
6552

6653
it("explains the disconnect without blaming the user or implying data loss", () => {
67-
render(<GoogleReconnectToast toastId="google-revoked-api" />);
54+
renderToast();
6855

6956
expect(
7057
screen.getByText("Google Calendar disconnected"),
@@ -78,7 +65,7 @@ describe("GoogleReconnectToast", () => {
7865

7966
it("flushes pending local events, dismisses itself, then starts the consent flow", async () => {
8067
mockSyncPendingLocalEvents.mockResolvedValue(true);
81-
render(<GoogleReconnectToast toastId="google-revoked-api" />);
68+
renderToast();
8269

8370
await userEvent.click(
8471
screen.getByRole("button", { name: "Reconnect Google Calendar" }),
@@ -93,7 +80,7 @@ describe("GoogleReconnectToast", () => {
9380

9481
it("stays open and does not start authorization when the local-event flush fails", async () => {
9582
mockSyncPendingLocalEvents.mockResolvedValue(false);
96-
render(<GoogleReconnectToast toastId="google-revoked-api" />);
83+
renderToast();
9784

9885
await userEvent.click(
9986
screen.getByRole("button", { name: "Reconnect Google Calendar" }),
@@ -121,17 +108,3 @@ describe("showGoogleReconnectToast", () => {
121108
expect(toast.error).not.toHaveBeenCalled();
122109
});
123110
});
124-
125-
describe("showGoogleReconnectToastOnLoad", () => {
126-
beforeEach(() => {
127-
resetGoogleReconnectToastOnLoadForTests();
128-
});
129-
130-
// Asserted via the return value rather than toast.error call counts: other
131-
// test files leak process-wide mocks of error-toast.util, so whether the
132-
// underlying toast fires here depends on suite order.
133-
it("shows the reconnect toast at most once per page load", () => {
134-
expect(showGoogleReconnectToastOnLoad()).toBe(true);
135-
expect(showGoogleReconnectToastOnLoad()).toBe(false);
136-
});
137-
});

packages/web/src/common/utils/toast/google-reconnect.toast.tsx

Lines changed: 13 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7,29 +7,34 @@ import {
77
showErrorToast,
88
} from "@web/common/utils/toast/error-toast.util";
99

10+
// Imported dynamically to avoid a module cycle: google.auth.util shows this
11+
// toast, and the reconnect flow needs google.auth.util's local-event flush.
12+
const flushPendingLocalEvents = async (): Promise<boolean> => {
13+
const { syncPendingLocalEvents } = await import(
14+
"@web/auth/google/util/google.auth.util"
15+
);
16+
return syncPendingLocalEvents();
17+
};
18+
1019
interface GoogleReconnectToastProps {
1120
toastId: Id;
21+
syncPendingLocalEvents?: () => Promise<boolean>;
1222
}
1323

1424
// Shown when Google reports invalid_grant, which covers both "access expired"
1525
// and "user revoked access" with no way to tell them apart, so the copy must
16-
// stay accurate for either cause.
26+
// stay accurate for either cause. Hooks are fine here: ToastContainer renders
27+
// inside GoogleOAuthProvider (CompassProvider).
1728
export const GoogleReconnectToast = ({
1829
toastId,
30+
syncPendingLocalEvents = flushPendingLocalEvents,
1931
}: GoogleReconnectToastProps) => {
20-
// Legal here: ToastContainer renders inside GoogleOAuthProvider
21-
// (CompassProvider), so the OAuth context is available.
2232
const { startGoogleAuthorization } = useStartGoogleAuthorization({
2333
intent: "connectCalendar",
2434
prompt: "consent",
2535
});
2636

2737
const handleReconnect = async () => {
28-
// Imported dynamically to avoid a module cycle: google.auth.util shows
29-
// this toast, and this handler needs google.auth.util's local-event flush.
30-
const { syncPendingLocalEvents } = await import(
31-
"@web/auth/google/util/google.auth.util"
32-
);
3338
const didSyncLocalEvents = await syncPendingLocalEvents();
3439

3540
// The flush already showed its own error toast; keep this one around so
@@ -71,21 +76,3 @@ export function showGoogleReconnectToast(): Id {
7176
},
7277
);
7378
}
74-
75-
// At most once per page load, so a dismissal isn't nagged mid-session but the
76-
// reminder returns on the next load until the user reconnects.
77-
let hasShownOnLoad = false;
78-
79-
export function showGoogleReconnectToastOnLoad(): boolean {
80-
if (hasShownOnLoad) {
81-
return false;
82-
}
83-
84-
hasShownOnLoad = true;
85-
showGoogleReconnectToast();
86-
return true;
87-
}
88-
89-
export function resetGoogleReconnectToastOnLoadForTests(): void {
90-
hasShownOnLoad = false;
91-
}

0 commit comments

Comments
 (0)