diff --git a/packages/react/src/auth/screens/sign-up-auth-screen.test.tsx b/packages/react/src/auth/screens/sign-up-auth-screen.test.tsx
index 3cb14217a..083f3c8a5 100644
--- a/packages/react/src/auth/screens/sign-up-auth-screen.test.tsx
+++ b/packages/react/src/auth/screens/sign-up-auth-screen.test.tsx
@@ -21,8 +21,20 @@ import { registerLocale } from "@firebase-oss/ui-translations";
import { MultiFactorResolver, type User } from "firebase/auth";
vi.mock("~/auth/forms/sign-up-auth-form", () => ({
- SignUpAuthForm: ({ onSignInClick }: { onSignInClick?: () => void }) => (
+ SignUpAuthForm: ({
+ onSignUp,
+ onSignInClick,
+ }: {
+ onSignUp?: (credential: any) => void;
+ onSignInClick?: () => void;
+ }) => (
+
@@ -294,7 +306,31 @@ describe("
", () => {
expect(onSignUp).toHaveBeenCalledWith(mockUser);
});
- it("calls onSignUp when user authenticates via useOnUserAuthenticated hook", () => {
+ it("calls onSignUp from the resolved form credential", () => {
+ const onSignUp = vi.fn();
+
+ const mockAuth = {
+ onAuthStateChanged: vi.fn(() => vi.fn()),
+ };
+
+ const ui = createMockUI({
+ auth: mockAuth as any,
+ });
+
+ render(
+
+
+
+ );
+
+ fireEvent.click(screen.getByTestId("sign-up-success-button"));
+
+ expect(onSignUp).toHaveBeenCalledTimes(1);
+ expect(onSignUp).toHaveBeenCalledWith({ uid: "signup-form-user", isAnonymous: false });
+ expect(mockAuth.onAuthStateChanged).not.toHaveBeenCalled();
+ });
+
+ it("calls onSignUp from auth state for child-based sign-up flows", () => {
const onSignUp = vi.fn();
let authStateChangeCallback: ((user: User | null) => void) | null = null;
@@ -311,14 +347,16 @@ describe("
", () => {
render(
-
+
+
+
);
expect(mockAuth.onAuthStateChanged).toHaveBeenCalledTimes(1);
const mockUser = {
- uid: "test-user-id",
+ uid: "child-provider-user",
isAnonymous: false,
} as User;
@@ -330,6 +368,42 @@ describe("
", () => {
expect(onSignUp).toHaveBeenCalledWith(mockUser);
});
+ it("dedupes the same user across form and auth state paths", () => {
+ const onSignUp = vi.fn();
+ let authStateChangeCallback: ((user: User | null) => void) | null = null;
+
+ const mockAuth = {
+ onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => {
+ authStateChangeCallback = callback;
+ return vi.fn();
+ }),
+ };
+
+ const ui = createMockUI({
+ auth: mockAuth as any,
+ });
+
+ render(
+
+
+
+
+
+ );
+
+ fireEvent.click(screen.getByTestId("sign-up-success-button"));
+
+ act(() => {
+ authStateChangeCallback!({
+ uid: "signup-form-user",
+ isAnonymous: false,
+ } as User);
+ });
+
+ expect(onSignUp).toHaveBeenCalledTimes(1);
+ expect(onSignUp).toHaveBeenCalledWith({ uid: "signup-form-user", isAnonymous: false });
+ });
+
it("does not call onSignUp for anonymous users", () => {
const onSignUp = vi.fn();
let authStateChangeCallback: ((user: User | null) => void) | null = null;
@@ -347,7 +421,9 @@ describe("
", () => {
render(
-
+
+
+
);
diff --git a/packages/react/src/auth/screens/sign-up-auth-screen.tsx b/packages/react/src/auth/screens/sign-up-auth-screen.tsx
index 433af8fb9..804f7582f 100644
--- a/packages/react/src/auth/screens/sign-up-auth-screen.tsx
+++ b/packages/react/src/auth/screens/sign-up-auth-screen.tsx
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-import type { PropsWithChildren } from "react";
+import { type PropsWithChildren, useCallback, useRef } from "react";
import type { User } from "firebase/auth";
import { Divider } from "~/components/divider";
import { useOnUserAuthenticated, useUI } from "~/hooks";
@@ -39,11 +39,28 @@ export type SignUpAuthScreenProps = PropsWithChildren
(null);
const titleText = getTranslation(ui, "labels", "signUp");
const subtitleText = getTranslation(ui, "prompts", "enterDetailsToCreate");
- useOnUserAuthenticated(onSignUp);
+ const handleSignUp = useCallback(
+ (user: User) => {
+ if (handledUserIdRef.current === user.uid) {
+ return;
+ }
+
+ handledUserIdRef.current = user.uid;
+ onSignUp?.(user);
+ },
+ [onSignUp]
+ );
+
+ // The built-in email/password form can report success from the resolved credential,
+ // which is the earliest point where requireDisplayName() has finished updating the profile.
+ // We keep the auth-state listener only for flows that complete outside that callback,
+ // such as child sign-up actions and MFA completion.
+ useOnUserAuthenticated(children || ui.multiFactorResolver ? handleSignUp : undefined);
if (ui.multiFactorResolver) {
return ;
@@ -57,7 +74,12 @@ export function SignUpAuthScreen({ children, onSignUp, ...props }: SignUpAuthScr
{subtitleText}
-
+ {
+ handleSignUp(credential.user);
+ }}
+ />
{children ? (
<>
{getTranslation(ui, "messages", "dividerOr")}
diff --git a/packages/react/src/hooks.test.tsx b/packages/react/src/hooks.test.tsx
index 9425e1b6c..af5f4ebee 100644
--- a/packages/react/src/hooks.test.tsx
+++ b/packages/react/src/hooks.test.tsx
@@ -1067,33 +1067,19 @@ describe("useOnUserAuthenticated", () => {
});
it("works without a callback", () => {
- let authStateChangeCallback: ((user: User | null) => void) | null = null;
-
const mockAuth = {
- onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => {
- authStateChangeCallback = callback;
- return vi.fn();
- }),
+ onAuthStateChanged: vi.fn(() => vi.fn()),
};
const mockUI = createMockUI({
auth: mockAuth as any,
});
- const mockUser = {
- uid: "test-user-id",
- isAnonymous: false,
- } as User;
-
renderHook(() => useOnUserAuthenticated(), {
wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }),
});
- act(() => {
- authStateChangeCallback!(mockUser);
- });
-
- expect(mockAuth.onAuthStateChanged).toHaveBeenCalledTimes(1);
+ expect(mockAuth.onAuthStateChanged).not.toHaveBeenCalled();
});
it("unsubscribes from auth state changes on unmount", () => {
diff --git a/packages/react/src/hooks.ts b/packages/react/src/hooks.ts
index 071c7e158..69ed7359e 100644
--- a/packages/react/src/hooks.ts
+++ b/packages/react/src/hooks.ts
@@ -66,9 +66,13 @@ export function useOnUserAuthenticated(callback?: (user: User) => void) {
const auth = ui.auth;
useEffect(() => {
+ if (!callback) {
+ return;
+ }
+
return auth.onAuthStateChanged((user) => {
if (user && !user.isAnonymous) {
- callback?.(user);
+ callback(user);
}
});
}, [auth, callback]);
diff --git a/packages/shadcn/src/components/sign-up-auth-screen.test.tsx b/packages/shadcn/src/components/sign-up-auth-screen.test.tsx
index 14277d350..9726d4e8f 100644
--- a/packages/shadcn/src/components/sign-up-auth-screen.test.tsx
+++ b/packages/shadcn/src/components/sign-up-auth-screen.test.tsx
@@ -23,10 +23,26 @@ import { FirebaseUIProvider } from "@firebase-oss/ui-react";
import { MultiFactorResolver, type User } from "firebase/auth";
vi.mock("./sign-up-auth-form", () => ({
- SignUpAuthForm: ({ onSignUp, onSignInClick }: any) => (
+ SignUpAuthForm: ({
+ onSignUp,
+ onSignInClick,
+ }: {
+ onSignUp?: (credential: any) => void;
+ onSignInClick?: () => void;
+ }) => (
SignUpAuthForm
- {onSignUp &&
onSignUp provided
}
+ {onSignUp && (
+ <>
+
onSignUp provided
+
+ >
+ )}
{onSignInClick &&
onSignInClick provided
}
),
@@ -126,6 +142,39 @@ describe("", () => {
expect(screen.getByTestId("onSignInClick-prop")).toBeInTheDocument();
});
+ it("calls onSignUp from the resolved form credential", () => {
+ const onSignUp = vi.fn();
+
+ const mockAuth = {
+ onAuthStateChanged: vi.fn(() => vi.fn()),
+ };
+
+ const mockUI = createMockUI({
+ auth: mockAuth as any,
+ locale: registerLocale("test", {
+ labels: {
+ signUp: "Register",
+ },
+ prompts: {
+ enterDetailsToCreate: "Enter your details to create an account",
+ },
+ }),
+ });
+
+ render(
+
+
+
+ );
+
+ act(() => {
+ screen.getByTestId("sign-up-success-button").click();
+ });
+
+ expect(onSignUp).toHaveBeenCalledTimes(1);
+ expect(onSignUp).toHaveBeenCalledWith({ uid: "signup-form-user", isAnonymous: false });
+ });
+
it("should not render separator when no children", () => {
const mockUI = createMockUI({
locale: registerLocale("test", {
@@ -279,7 +328,7 @@ describe("", () => {
expect(onSignUp).toHaveBeenCalledWith(mockUser);
});
- it("calls onSignUp when user authenticates via useOnUserAuthenticated hook", () => {
+ it("calls onSignUp when child-based sign-up flows authenticate via auth state", () => {
const onSignUp = vi.fn();
let authStateChangeCallback: ((user: User | null) => void) | null = null;
@@ -296,7 +345,9 @@ describe("", () => {
render(
-
+
+ Child Component
+
);
@@ -315,6 +366,44 @@ describe("", () => {
expect(onSignUp).toHaveBeenCalledWith(mockUser);
});
+ it("dedupes the same user across form and auth state paths", () => {
+ const onSignUp = vi.fn();
+ let authStateChangeCallback: ((user: User | null) => void) | null = null;
+
+ const mockAuth = {
+ onAuthStateChanged: vi.fn((callback: (user: User | null) => void) => {
+ authStateChangeCallback = callback;
+ return vi.fn();
+ }),
+ };
+
+ const mockUI = createMockUI({
+ auth: mockAuth as any,
+ });
+
+ render(
+
+
+ Child Component
+
+
+ );
+
+ act(() => {
+ screen.getByTestId("sign-up-success-button").click();
+ });
+
+ act(() => {
+ authStateChangeCallback!({
+ uid: "signup-form-user",
+ isAnonymous: false,
+ } as User);
+ });
+
+ expect(onSignUp).toHaveBeenCalledTimes(1);
+ expect(onSignUp).toHaveBeenCalledWith({ uid: "signup-form-user", isAnonymous: false });
+ });
+
it("does not call onSignUp for anonymous users", () => {
const onSignUp = vi.fn();
let authStateChangeCallback: ((user: User | null) => void) | null = null;
@@ -332,7 +421,9 @@ describe("", () => {
render(
-
+
+ Child Component
+
);
diff --git a/packages/shadcn/src/components/sign-up-auth-screen.tsx b/packages/shadcn/src/components/sign-up-auth-screen.tsx
index acc40a126..20cd73937 100644
--- a/packages/shadcn/src/components/sign-up-auth-screen.tsx
+++ b/packages/shadcn/src/components/sign-up-auth-screen.tsx
@@ -18,21 +18,38 @@
import { getTranslation } from "@firebase-oss/ui-core";
import { useUI, type SignUpAuthScreenProps, useOnUserAuthenticated } from "@firebase-oss/ui-react";
+import { useCallback, useRef } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { SignUpAuthForm } from "@/components/sign-up-auth-form";
import { MultiFactorAuthAssertionScreen } from "@/components/multi-factor-auth-assertion-screen";
+import { type User } from "firebase/auth";
export type { SignUpAuthScreenProps };
export function SignUpAuthScreen({ children, onSignUp, ...props }: SignUpAuthScreenProps) {
const ui = useUI();
+ const handledUserIdRef = useRef(null);
const titleText = getTranslation(ui, "labels", "signUp");
const subtitleText = getTranslation(ui, "prompts", "enterDetailsToCreate");
- useOnUserAuthenticated(onSignUp);
+ const handleSignUp = useCallback(
+ (user: User) => {
+ if (handledUserIdRef.current === user.uid) {
+ return;
+ }
+
+ handledUserIdRef.current = user.uid;
+ onSignUp?.(user);
+ },
+ [onSignUp]
+ );
+
+ // Mirror the React package behavior: the built-in form reports success from the
+ // resolved credential, while auth-state remains the fallback for child actions and MFA.
+ useOnUserAuthenticated(children || ui.multiFactorResolver ? handleSignUp : undefined);
if (ui.multiFactorResolver) {
return ;
@@ -46,7 +63,12 @@ export function SignUpAuthScreen({ children, onSignUp, ...props }: SignUpAuthScr
{subtitleText}
-
+ {
+ handleSignUp(credential.user);
+ }}
+ />
{children ? (
<>