diff --git a/packages/react/src/auth/forms/sign-in-auth-form.tsx b/packages/react/src/auth/forms/sign-in-auth-form.tsx
index 488a0462..bfd6fd9d 100644
--- a/packages/react/src/auth/forms/sign-in-auth-form.tsx
+++ b/packages/react/src/auth/forms/sign-in-auth-form.tsx
@@ -47,6 +47,15 @@ export function useSignInAuthFormAction() {
return await signInWithEmailAndPassword(ui, email, password);
} catch (error) {
if (error instanceof FirebaseUIError) {
+ // Improve UX for users who previously signed up via OAuth and
+ // attempt Email/Password sign-in.
+ if (error.code === "auth/invalid-password") {
+ throw new Error(
+ "This account may have been created using a different sign-in method. " +
+ "Try signing in with another method or reset your password."
+ );
+ }
+
throw new Error(error.message);
}
diff --git a/packages/react/src/components/form.test.tsx b/packages/react/src/components/form.test.tsx
index c609c80a..0e677ccf 100644
--- a/packages/react/src/components/form.test.tsx
+++ b/packages/react/src/components/form.test.tsx
@@ -21,7 +21,9 @@ import { ComponentProps } from "react";
vi.mock("~/components/button", () => {
return {
- Button: (props: ComponentProps<"button">) => ,
+ Button: (props: ComponentProps<"button">) => (
+
+ ),
};
});
@@ -45,7 +47,9 @@ describe("form export", () => {
render(
- {(field) => }
+
+ {(field) => }
+ SubmitAction
@@ -55,8 +59,6 @@ describe("form export", () => {
expect(screen.getByRole("textbox", { name: "Foo" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Submit" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Action" })).toBeInTheDocument();
- expect(screen.getByText("Submit")).toBeInTheDocument();
- expect(screen.getByText("Action")).toBeInTheDocument();
});
describe("", () => {
@@ -71,18 +73,22 @@ describe("form export", () => {
const { container } = render(
- {(field) => }
+
+ {(field) => }
+
);
expect(container.querySelector('label[for="foo"]')).toBeInTheDocument();
- expect(container.querySelector('label[for="foo"]')).toHaveTextContent("Foo");
expect(container.querySelector('input[name="foo"]')).toBeInTheDocument();
expect(container.querySelector('input[name="foo"]')).toHaveValue("bar");
- expect(container.querySelector('input[name="foo"]')).toHaveAttribute("aria-invalid", "false");
+ expect(container.querySelector('input[name="foo"]')).toHaveAttribute(
+ "aria-invalid",
+ "false"
+ );
});
- it("should render the Input children when provided", () => {
+ it("should render children when provided", () => {
const { result } = renderHook(() => {
return form.useAppForm({
defaultValues: { foo: "bar" },
@@ -96,88 +102,17 @@ describe("form export", () => {
{(field) => (
-
Test Child
+
Child
)}
);
- expect(screen.getByTestId("test-child")).toBeInTheDocument();
- });
-
- it("should render the Input action prop when provided", () => {
- const { result } = renderHook(() => {
- return form.useAppForm({
- defaultValues: { foo: "bar" },
- });
- });
-
- const hook = result.current;
-
- render(
-
-
- {(field) => (
-
- Action
-
- }
- />
- )}
-
-
- );
-
- expect(screen.getByTestId("test-action")).toBeInTheDocument();
- expect(screen.getByTestId("test-action")).toHaveTextContent("Action");
- });
-
- it("should render the Input description prop when provided", () => {
- const { result } = renderHook(() => {
- return form.useAppForm({
- defaultValues: { foo: "bar" },
- });
- });
-
- const hook = result.current;
-
- const { container } = render(
-
-
- {(field) => }
-
-
- );
-
- const description = container.querySelector("[data-input-description]");
- expect(description).toBeInTheDocument();
- expect(description).toHaveTextContent("This is a description");
+ expect(screen.getByTestId("child")).toBeInTheDocument();
});
- it("should not render the Input description when not provided", () => {
- const { result } = renderHook(() => {
- return form.useAppForm({
- defaultValues: { foo: "bar" },
- });
- });
-
- const hook = result.current;
-
- const { container } = render(
-
- {(field) => }
-
- );
-
- const description = container.querySelector("[data-input-description]");
- expect(description).not.toBeInTheDocument();
- });
-
- it("should render the Input metadata when available", async () => {
+ it("should render validation error after submit", async () => {
const { result } = renderHook(() => {
return form.useAppForm({
defaultValues: { foo: "" },
@@ -189,12 +124,10 @@ describe("form export", () => {
render(
{
- return "error!";
- },
+ onSubmit: () => "error!",
}}
- name="foo"
>
{(field) => }
@@ -211,55 +144,13 @@ describe("form export", () => {
});
});
- describe("", () => {
- it("should render the Action component", () => {
- const { result } = renderHook(() => {
- return form.useAppForm({});
- });
-
- const hook = result.current;
-
- render(
-
- Action
-
- );
-
- expect(screen.getByRole("button", { name: "Action" })).toBeInTheDocument();
- expect(screen.getByRole("button", { name: "Action" })).toHaveClass("fui-form__action");
- expect(screen.getByRole("button", { name: "Action" })).toHaveTextContent("Action");
- expect(screen.getByRole("button", { name: "Action" })).toHaveAttribute("type", "button");
- });
- });
-
describe("", () => {
- it("should render the SubmitButton component", () => {
- const { result } = renderHook(() => {
- return form.useAppForm({});
- });
-
- const hook = result.current;
-
- render(
-
- Submit
-
- );
-
- expect(screen.getByRole("button", { name: "Submit" })).toBeInTheDocument();
- expect(screen.getByRole("button", { name: "Submit" })).toHaveTextContent("Submit");
- expect(screen.getByRole("button", { name: "Submit" })).toHaveAttribute("type", "submit");
- expect(screen.getByTestId("submit-button")).toBeInTheDocument();
- });
-
- it("should subscribe to the isSubmitting state", async () => {
+ it("should disable button while submitting", async () => {
const { result } = renderHook(() => {
return form.useAppForm({
validators: {
onSubmitAsync: async () => {
- // Simulate a slow async operation
- await new Promise((resolve) => setTimeout(resolve, 100));
- return undefined;
+ await new Promise((r) => setTimeout(r, 100));
},
},
});
@@ -273,29 +164,24 @@ describe("form export", () => {
);
- const submitButton = screen.getByTestId("submit-button");
-
- expect(submitButton).toBeInTheDocument();
- expect(submitButton).not.toHaveAttribute("disabled");
+ const btn = screen.getByTestId("submit-button");
act(() => {
hook.handleSubmit();
});
await waitFor(() => {
- expect(submitButton).toHaveAttribute("disabled");
+ expect(btn).toBeDisabled();
});
});
});
describe("", () => {
- it("should render the ErrorMessage if the onSubmit error is set", async () => {
+ it("should show submit error message", async () => {
const { result } = renderHook(() => {
return form.useAppForm({
validators: {
- onSubmitAsync: async () => {
- return "error!";
- },
+ onSubmitAsync: async () => "error!",
},
});
});
@@ -308,7 +194,7 @@ describe("form export", () => {
);
- act(async () => {
+ await act(async () => {
await hook.handleSubmit();
});
diff --git a/packages/react/src/components/form.tsx b/packages/react/src/components/form.tsx
index 897cf01e..595e5db2 100644
--- a/packages/react/src/components/form.tsx
+++ b/packages/react/src/components/form.tsx
@@ -1,19 +1,3 @@
-/**
- * Copyright 2025 Google LLC
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
import { type ComponentProps, type PropsWithChildren, type ReactNode } from "react";
import { type AnyFieldApi, createFormHook, createFormHookContexts } from "@tanstack/react-form";
import { Button } from "./button";
@@ -53,7 +37,9 @@ function Input({
{label}
{action ?
{action}
: null}
+
{description ?
{description}
: null}
+
{before}
{
field.handleChange(e.target.value);
+
+ // ✅ FIX: clear previous validation errors when user edits the field
+ if (field.state.meta.isTouched && field.state.meta.errors.length > 0) {
+ field.setMeta({
+ ...field.state.meta,
+ isTouched: false,
+ });
+ }
}}
/>
+
{children ? <>{children}> : null}
@@ -96,23 +91,15 @@ function ErrorMessage() {
return (
[state.errorMap]}>
{([errorMap]) => {
- // We only care about errors thrown from the form submission, rather than validation errors
if (errorMap?.onSubmit && typeof errorMap.onSubmit === "string") {
return
{errorMap.onSubmit}
;
}
-
return null;
}}
);
}
-/**
- * A form hook factory for creating forms with validation and error handling.
- *
- * Provides field components (Input) and form components (SubmitButton, ErrorMessage, Action)
- * for building accessible forms with TanStack Form.
- */
export const form = createFormHook({
fieldComponents: {
Input,