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
86 changes: 81 additions & 5 deletions packages/react/src/auth/screens/sign-up-auth-screen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}) => (
<div data-testid="sign-up-auth-form">
<button
data-testid="sign-up-success-button"
onClick={() => onSignUp?.({ user: { uid: "signup-form-user", isAnonymous: false } })}
>
Sign Up Success
</button>
<button data-testid="back-to-sign-in-button" onClick={onSignInClick}>
Back to Sign In
</button>
Expand Down Expand Up @@ -294,7 +306,31 @@ describe("<SignUpAuthScreen />", () => {
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(
<CreateFirebaseUIProvider ui={ui}>
<SignUpAuthScreen onSignUp={onSignUp} />
</CreateFirebaseUIProvider>
);

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;

Expand All @@ -311,14 +347,16 @@ describe("<SignUpAuthScreen />", () => {

render(
<CreateFirebaseUIProvider ui={ui}>
<SignUpAuthScreen onSignUp={onSignUp} />
<SignUpAuthScreen onSignUp={onSignUp}>
<div data-testid="child-sign-up-provider" />
</SignUpAuthScreen>
</CreateFirebaseUIProvider>
);

expect(mockAuth.onAuthStateChanged).toHaveBeenCalledTimes(1);

const mockUser = {
uid: "test-user-id",
uid: "child-provider-user",
isAnonymous: false,
} as User;

Expand All @@ -330,6 +368,42 @@ describe("<SignUpAuthScreen />", () => {
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(
<CreateFirebaseUIProvider ui={ui}>
<SignUpAuthScreen onSignUp={onSignUp}>
<div data-testid="child-sign-up-provider" />
</SignUpAuthScreen>
</CreateFirebaseUIProvider>
);

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;
Expand All @@ -347,7 +421,9 @@ describe("<SignUpAuthScreen />", () => {

render(
<CreateFirebaseUIProvider ui={ui}>
<SignUpAuthScreen onSignUp={onSignUp} />
<SignUpAuthScreen onSignUp={onSignUp}>
<div data-testid="child-sign-up-provider" />
</SignUpAuthScreen>
</CreateFirebaseUIProvider>
);

Expand Down
28 changes: 25 additions & 3 deletions packages/react/src/auth/screens/sign-up-auth-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -39,11 +39,28 @@ export type SignUpAuthScreenProps = PropsWithChildren<Omit<SignUpAuthFormProps,
*/
export function SignUpAuthScreen({ children, onSignUp, ...props }: SignUpAuthScreenProps) {
const ui = useUI();
const handledUserIdRef = useRef<string | null>(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 <MultiFactorAuthAssertionScreen />;
Expand All @@ -57,7 +74,12 @@ export function SignUpAuthScreen({ children, onSignUp, ...props }: SignUpAuthScr
<CardSubtitle>{subtitleText}</CardSubtitle>
</CardHeader>
<CardContent>
<SignUpAuthForm {...props} />
<SignUpAuthForm
{...props}
onSignUp={(credential) => {
handleSignUp(credential.user);
}}
/>
{children ? (
<>
<Divider>{getTranslation(ui, "messages", "dividerOr")}</Divider>
Expand Down
18 changes: 2 additions & 16 deletions packages/react/src/hooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
6 changes: 5 additions & 1 deletion packages/react/src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
101 changes: 96 additions & 5 deletions packages/shadcn/src/components/sign-up-auth-screen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}) => (
<div data-testid="sign-up-auth-form">
<div>SignUpAuthForm</div>
{onSignUp && <div data-testid="onSignUp-prop">onSignUp provided</div>}
{onSignUp && (
<>
<div data-testid="onSignUp-prop">onSignUp provided</div>
<button
data-testid="sign-up-success-button"
onClick={() => onSignUp({ user: { uid: "signup-form-user", isAnonymous: false } })}
>
Sign Up Success
</button>
</>
)}
{onSignInClick && <div data-testid="onSignInClick-prop">onSignInClick provided</div>}
</div>
),
Expand Down Expand Up @@ -126,6 +142,39 @@ describe("<SignUpAuthScreen />", () => {
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(
<FirebaseUIProvider ui={mockUI}>
<SignUpAuthScreen onSignUp={onSignUp} />
</FirebaseUIProvider>
);

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", {
Expand Down Expand Up @@ -279,7 +328,7 @@ describe("<SignUpAuthScreen />", () => {
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;

Expand All @@ -296,7 +345,9 @@ describe("<SignUpAuthScreen />", () => {

render(
<FirebaseUIProvider ui={mockUI}>
<SignUpAuthScreen onSignUp={onSignUp} />
<SignUpAuthScreen onSignUp={onSignUp}>
<div data-testid="child-component">Child Component</div>
</SignUpAuthScreen>
</FirebaseUIProvider>
);

Expand All @@ -315,6 +366,44 @@ describe("<SignUpAuthScreen />", () => {
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(
<FirebaseUIProvider ui={mockUI}>
<SignUpAuthScreen onSignUp={onSignUp}>
<div data-testid="child-component">Child Component</div>
</SignUpAuthScreen>
</FirebaseUIProvider>
);

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;
Expand All @@ -332,7 +421,9 @@ describe("<SignUpAuthScreen />", () => {

render(
<FirebaseUIProvider ui={mockUI}>
<SignUpAuthScreen onSignUp={onSignUp} />
<SignUpAuthScreen onSignUp={onSignUp}>
<div data-testid="child-component">Child Component</div>
</SignUpAuthScreen>
</FirebaseUIProvider>
);

Expand Down
Loading
Loading