Skip to content

Commit dc136a1

Browse files
committed
feat(react): Extract useSignInWithProvider for shadcn usage
1 parent fba78c5 commit dc136a1

File tree

3 files changed

+163
-9
lines changed

3 files changed

+163
-9
lines changed

packages/react/src/auth/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,4 @@ export {
6464
type MicrosoftSignInButtonProps,
6565
} from "./oauth/microsoft-sign-in-button";
6666
export { TwitterSignInButton, TwitterLogo, type TwitterSignInButtonProps } from "./oauth/twitter-sign-in-button";
67-
export { OAuthButton, type OAuthButtonProps } from "./oauth/oauth-button";
67+
export { OAuthButton, useSignInWithProvider, type OAuthButtonProps } from "./oauth/oauth-button";

packages/react/src/auth/oauth/oauth-button.test.tsx

Lines changed: 149 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@
1414
*/
1515

1616
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
17-
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
18-
import { OAuthButton } from "./oauth-button";
17+
import { render, screen, fireEvent, cleanup, renderHook, act } from "@testing-library/react";
18+
import { OAuthButton, useSignInWithProvider } from "./oauth-button";
1919
import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils";
2020
import { enUs, registerLocale } from "@firebase-ui/translations";
2121
import type { AuthProvider, UserCredential } from "firebase/auth";
@@ -224,3 +224,150 @@ describe("<OAuthButton />", () => {
224224
expect(screen.queryByText(expectedError)).toBeNull();
225225
});
226226
});
227+
228+
describe("useSignInWithProvider", () => {
229+
const mockGoogleProvider = { providerId: "google.com" } as AuthProvider;
230+
const mockFacebookProvider = { providerId: "facebook.com" } as AuthProvider;
231+
232+
beforeEach(() => {
233+
vi.clearAllMocks();
234+
});
235+
236+
it("returns error and callback", () => {
237+
const ui = createMockUI();
238+
const wrapper = ({ children }: { children: React.ReactNode }) => (
239+
<CreateFirebaseUIProvider ui={ui}>{children}</CreateFirebaseUIProvider>
240+
);
241+
242+
const { result } = renderHook(() => useSignInWithProvider(mockGoogleProvider), { wrapper });
243+
244+
expect(result.current.error).toBeNull();
245+
expect(typeof result.current.callback).toBe("function");
246+
});
247+
248+
it("calls signInWithProvider when callback is executed", async () => {
249+
const mockSignInWithProvider = vi.mocked(signInWithProvider);
250+
const ui = createMockUI();
251+
const wrapper = ({ children }: { children: React.ReactNode }) => (
252+
<CreateFirebaseUIProvider ui={ui}>{children}</CreateFirebaseUIProvider>
253+
);
254+
255+
const { result } = renderHook(() => useSignInWithProvider(mockGoogleProvider), { wrapper });
256+
257+
await act(async () => {
258+
await result.current.callback();
259+
});
260+
261+
expect(mockSignInWithProvider).toHaveBeenCalledTimes(1);
262+
expect(mockSignInWithProvider).toHaveBeenCalledWith(ui.get(), mockGoogleProvider);
263+
});
264+
265+
it("sets error state when FirebaseUIError occurs", async () => {
266+
const { FirebaseUIError } = await import("@firebase-ui/core");
267+
const mockSignInWithProvider = vi.mocked(signInWithProvider);
268+
const ui = createMockUI();
269+
const mockError = new FirebaseUIError(
270+
ui.get(),
271+
new FirebaseError("auth/user-not-found", "No account found with this email address")
272+
);
273+
mockSignInWithProvider.mockRejectedValue(mockError);
274+
275+
const wrapper = ({ children }: { children: React.ReactNode }) => (
276+
<CreateFirebaseUIProvider ui={ui}>{children}</CreateFirebaseUIProvider>
277+
);
278+
279+
const { result } = renderHook(() => useSignInWithProvider(mockGoogleProvider), { wrapper });
280+
281+
await act(async () => {
282+
await result.current.callback();
283+
});
284+
285+
expect(result.current.error).toBe("No account found with this email address");
286+
});
287+
288+
it("sets unknown error message when non-Firebase error occurs", async () => {
289+
const mockSignInWithProvider = vi.mocked(signInWithProvider);
290+
const regularError = new Error("Regular error");
291+
mockSignInWithProvider.mockRejectedValue(regularError);
292+
293+
// Mock console.error to prevent test output noise
294+
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
295+
296+
const ui = createMockUI({
297+
locale: registerLocale("test", {
298+
errors: {
299+
unknownError: "unknownError",
300+
},
301+
}),
302+
});
303+
304+
const wrapper = ({ children }: { children: React.ReactNode }) => (
305+
<CreateFirebaseUIProvider ui={ui}>{children}</CreateFirebaseUIProvider>
306+
);
307+
308+
const { result } = renderHook(() => useSignInWithProvider(mockGoogleProvider), { wrapper });
309+
310+
await act(async () => {
311+
await result.current.callback();
312+
});
313+
314+
expect(consoleErrorSpy).toHaveBeenCalledWith(regularError);
315+
expect(result.current.error).toBe("unknownError");
316+
317+
// Restore console.error
318+
consoleErrorSpy.mockRestore();
319+
});
320+
321+
it("clears error when callback is called again", async () => {
322+
const { FirebaseUIError } = await import("@firebase-ui/core");
323+
const mockSignInWithProvider = vi.mocked(signInWithProvider);
324+
const ui = createMockUI();
325+
326+
// First call fails, second call succeeds
327+
mockSignInWithProvider
328+
.mockRejectedValueOnce(
329+
new FirebaseUIError(ui.get(), new FirebaseError("auth/wrong-password", "Incorrect password"))
330+
)
331+
.mockResolvedValueOnce({} as UserCredential);
332+
333+
const wrapper = ({ children }: { children: React.ReactNode }) => (
334+
<CreateFirebaseUIProvider ui={ui}>{children}</CreateFirebaseUIProvider>
335+
);
336+
337+
const { result } = renderHook(() => useSignInWithProvider(mockGoogleProvider), { wrapper });
338+
339+
// First call - should set error
340+
await act(async () => {
341+
await result.current.callback();
342+
});
343+
344+
expect(result.current.error).toBe("Incorrect password");
345+
346+
// Second call - should clear error
347+
await act(async () => {
348+
await result.current.callback();
349+
});
350+
351+
expect(result.current.error).toBeNull();
352+
});
353+
354+
it("maintains stable callback reference when provider changes", () => {
355+
const ui = createMockUI();
356+
const wrapper = ({ children }: { children: React.ReactNode }) => (
357+
<CreateFirebaseUIProvider ui={ui}>{children}</CreateFirebaseUIProvider>
358+
);
359+
360+
const { result, rerender } = renderHook(({ provider }) => useSignInWithProvider(provider), {
361+
wrapper,
362+
initialProps: { provider: mockGoogleProvider },
363+
});
364+
365+
const firstCallback = result.current.callback;
366+
367+
// Change provider
368+
rerender({ provider: mockFacebookProvider });
369+
370+
// Callback should be different due to dependency change
371+
expect(result.current.callback).not.toBe(firstCallback);
372+
});
373+
});

packages/react/src/auth/oauth/oauth-button.tsx

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
import { FirebaseUIError, getTranslation, signInWithProvider } from "@firebase-ui/core";
2020
import type { AuthProvider } from "firebase/auth";
2121
import type { PropsWithChildren } from "react";
22-
import { useState } from "react";
22+
import { useCallback, useState } from "react";
2323
import { Button } from "~/components/button";
2424
import { useUI } from "~/hooks";
2525

@@ -28,12 +28,11 @@ export type OAuthButtonProps = PropsWithChildren<{
2828
themed?: boolean | string;
2929
}>;
3030

31-
export function OAuthButton({ provider, children, themed }: OAuthButtonProps) {
31+
export function useSignInWithProvider(provider: AuthProvider) {
3232
const ui = useUI();
33-
3433
const [error, setError] = useState<string | null>(null);
3534

36-
const handleOAuthSignIn = async () => {
35+
const callback = useCallback(async () => {
3736
setError(null);
3837
try {
3938
await signInWithProvider(ui, provider);
@@ -45,7 +44,15 @@ export function OAuthButton({ provider, children, themed }: OAuthButtonProps) {
4544
console.error(error);
4645
setError(getTranslation(ui, "errors", "unknownError"));
4746
}
48-
};
47+
}, [ui, provider, setError]);
48+
49+
return { error, callback };
50+
}
51+
52+
export function OAuthButton({ provider, children, themed }: OAuthButtonProps) {
53+
const ui = useUI();
54+
55+
const { error, callback } = useSignInWithProvider(provider);
4956

5057
return (
5158
<div>
@@ -54,7 +61,7 @@ export function OAuthButton({ provider, children, themed }: OAuthButtonProps) {
5461
data-themed={themed}
5562
data-provider={provider.providerId}
5663
disabled={ui.state !== "idle"}
57-
onClick={handleOAuthSignIn}
64+
onClick={callback}
5865
className="fui-provider__button"
5966
>
6067
{children}

0 commit comments

Comments
 (0)