From 20b1e7192f1c3d603ebd904ef85cca8ffa3a1d3d Mon Sep 17 00:00:00 2001 From: Junichi Sugiura Date: Tue, 6 Aug 2024 11:43:58 +0200 Subject: [PATCH] Fix Signup popup in Safari by removing async logic on cta (#533) * Fix Signup popup in Safari by removing async logic on cta * Remove react-hook-form deps from login * Add workaround note for signup validation logic * Fix error message on signup/login form --- packages/keychain/package.json | 1 - .../keychain/src/components/connect/Login.tsx | 178 +++++++++--------- .../src/components/connect/Signup.tsx | 78 ++++---- .../keychain/src/components/connect/utils.ts | 6 +- packages/ui/package.json | 3 +- packages/ui/src/components/Field.tsx | 5 +- packages/ui/src/stories/Field.stories.tsx | 2 +- pnpm-lock.yaml | 22 +-- 8 files changed, 141 insertions(+), 154 deletions(-) diff --git a/packages/keychain/package.json b/packages/keychain/package.json index 3c2d1aa89..c823d8bc3 100644 --- a/packages/keychain/package.json +++ b/packages/keychain/package.json @@ -36,7 +36,6 @@ "next": "^13.4.19", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-hook-form": "^7.52.1", "react-query": "^3.39.2", "starknet": "^6.11.0", "use-sound": "^4.0.1", diff --git a/packages/keychain/src/components/connect/Login.tsx b/packages/keychain/src/components/connect/Login.tsx index 2b76516c4..34f73e811 100644 --- a/packages/keychain/src/components/connect/Login.tsx +++ b/packages/keychain/src/components/connect/Login.tsx @@ -1,10 +1,9 @@ import { Field } from "@cartridge/ui"; import { Button } from "@chakra-ui/react"; import { Container, Footer, Content, useLayout } from "components/layout"; -import { SubmitHandler, useForm, useController } from "react-hook-form"; import { useCallback, useEffect, useState } from "react"; import Controller from "utils/controller"; -import { FormInput, LoginMode, LoginProps } from "./types"; +import { LoginMode, LoginProps } from "./types"; import { useAnalytics } from "hooks/analytics"; import { fetchAccount, validateUsernameFor } from "./utils"; import { RegistrationLink } from "./RegistrationLink"; @@ -44,102 +43,97 @@ function Form({ const [isLoading, setIsLoading] = useState(false); const [expiresAt] = useState(3000000000n); const [error, setError] = useState(); - - const { handleSubmit, formState, control, setValue } = useForm({ - defaultValues: { username: prefilledName }, - }); - const { field: usernameField } = useController({ - name: "username", - control, - rules: { - required: "Username required", - minLength: { - value: 3, - message: "Username must be at least 3 characters", - }, - validate: validateUsernameFor("login"), - }, + const [usernameField, setUsernameField] = useState({ + value: prefilledName, + error: undefined, }); + const [isValidating, setIsValidating] = useState(false); - const onSubmit: SubmitHandler = useCallback( - async (values) => { - setIsLoading(true); + const onSubmit = useCallback(async () => { + setIsValidating(true); + const error = await validateUsernameFor("login")(usernameField.value); + if (error) { + setUsernameField((u) => ({ ...u, error })); + } - const { - account: { - credentials: { - webauthn: [{ id: credentialId, publicKey }], - }, - contractAddress: address, + setIsValidating(false); + + setIsLoading(true); + + const { + account: { + credentials: { + webauthn: [{ id: credentialId, publicKey }], }, - } = await fetchAccount(values.username); - - try { - const controller = new Controller({ - chainId, - rpcUrl, - address, - username: values.username, - publicKey, - credentialId, - }); - - switch (mode) { - case LoginMode.Webauthn: - await doLogin(values.username, credentialId); - break; - case LoginMode.Controller: - if (policies.length === 0) { - throw new Error("Policies required for controller "); - } - - await controller.approve(origin, expiresAt, policies); - break; - } - - controller.store(); - setController(controller); - - if (onSuccess) { - onSuccess(); - } - - log({ type: "webauthn_login", address }); - } catch (e) { - setError(e); - - log({ - type: "webauthn_login_error", - payload: { - error: e?.message, - }, - address, - }); + contractAddress: address, + }, + } = await fetchAccount(usernameField.value); + + try { + const controller = new Controller({ + chainId, + rpcUrl, + address, + username: usernameField.value, + publicKey, + credentialId, + }); + + switch (mode) { + case LoginMode.Webauthn: + await doLogin(usernameField.value, credentialId); + break; + case LoginMode.Controller: + if (policies.length === 0) { + throw new Error("Policies required for controller "); + } + + await controller.approve(origin, expiresAt, policies); + break; } - setIsLoading(false); - }, - [ - chainId, - rpcUrl, - origin, - policies, - expiresAt, - mode, - log, - onSuccess, - setController, - ], - ); + controller.store(); + setController(controller); + + if (onSuccess) { + onSuccess(); + } + + log({ type: "webauthn_login", address }); + } catch (e) { + setError(e); + + log({ + type: "webauthn_login_error", + payload: { + error: e?.message, + }, + address, + }); + } + + setIsLoading(false); + }, [ + usernameField.value, + chainId, + rpcUrl, + origin, + policies, + expiresAt, + mode, + log, + onSuccess, + setController, + ]); useEffect(() => { - if (!formState.isValidating || !footer.isOpen) return; + if (!isValidating || !footer.isOpen) return; footer.onToggle(); - }, [formState.isValidating, footer]); + }, [isValidating, footer]); return (
setError(undefined)} > @@ -148,16 +142,18 @@ function Form({ autoFocus onChange={(e) => { setError(undefined); - e.target.value = e.target.value.toLowerCase(); - usernameField.onChange(e); + setUsernameField((u) => ({ + ...u, + value: e.target.value.toLowerCase(), + })); }} placeholder="Username" - error={formState.errors.username} - isLoading={formState.isValidating} + error={usernameField.error} + isLoading={isValidating} isDisabled={isLoading} onClear={() => { setError(undefined); - setValue(usernameField.name, ""); + setUsernameField((u) => ({ ...u, value: "" })); }} /> diff --git a/packages/keychain/src/components/connect/Signup.tsx b/packages/keychain/src/components/connect/Signup.tsx index 6ea0d45b9..8e3a1184b 100644 --- a/packages/keychain/src/components/connect/Signup.tsx +++ b/packages/keychain/src/components/connect/Signup.tsx @@ -1,11 +1,11 @@ import { Field } from "@cartridge/ui"; import { Button } from "@chakra-ui/react"; import { Container, Footer, Content } from "components/layout"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useAccountQuery } from "generated/graphql"; import Controller from "utils/controller"; import { PopupCenter } from "utils/url"; -import { FormInput, SignupProps } from "./types"; +import { SignupProps } from "./types"; import { isIframe, validateUsernameFor } from "./utils"; import { RegistrationLink } from "./RegistrationLink"; import { doSignup } from "hooks/account"; @@ -13,8 +13,8 @@ import { useControllerTheme } from "hooks/theme"; import { useConnection } from "hooks/connection"; import { ErrorAlert } from "components/ErrorAlert"; import { useDeploy } from "hooks/deploy"; -import { useController, useForm } from "react-hook-form"; import { constants } from "starknet"; +import { useDebounce } from "hooks/debounce"; export function Signup({ prefilledName = "", @@ -27,27 +27,36 @@ export function Signup({ const { deployRequest } = useDeploy(); const [error, setError] = useState(); const [isRegistering, setIsRegistering] = useState(false); + const [usernameField, setUsernameField] = useState({ + value: prefilledName, + error: undefined, + }); + const [isValidating, setIsValidating] = useState(false); - const { - handleSubmit, - formState, - control, - setValue, - setError: setFieldError, - } = useForm({ defaultValues: { username: prefilledName } }); const { hasPrefundRequest } = useConnection(); - const { field: usernameField } = useController({ - name: "username", - control, - rules: { - required: "Username required", - minLength: { - value: 3, - message: "Username must be at least 3 characters", - }, - validate: validateUsernameFor("signup"), - }, - }); + const { debouncedValue: username, debouncing } = useDebounce( + usernameField.value, + 1000, + ); + + // In order for Safari to open "Create Passkey" modal successfully, submit handler has to be async. + // The workaround is to call async validation function every time when username input changes + useEffect(() => { + setError(undefined); + + if (username) { + const validate = async () => { + setIsValidating(true); + const error = await validateUsernameFor("signup")(username); + if (error) { + setUsernameField((u) => ({ ...u, error })); + } + + setIsValidating(false); + }; + validate(); + } + }, [username]); const onSubmit = useCallback(() => { setError(undefined); @@ -74,13 +83,10 @@ export function Signup({ doSignup(decodeURIComponent(usernameField.value)) .catch((e) => { - setFieldError(usernameField.name, { - type: "custom", - message: e.message, - }); + setUsernameField((u) => ({ ...u, error: e.message })); }) .finally(() => setIsRegistering(false)); - }, [setFieldError, usernameField]); + }, [usernameField]); // for polling approach when iframe useAccountQuery( @@ -154,16 +160,18 @@ export function Signup({ autoFocus onChange={(e) => { setError(undefined); - e.target.value = e.target.value.toLowerCase(); - usernameField.onChange(e); + setUsernameField((u) => ({ + ...u, + value: e.target.value.toLowerCase(), + })); }} placeholder="Username" - error={formState.errors.username} - isLoading={formState.isValidating} + error={usernameField.error} + isLoading={isValidating} isDisabled={isRegistering} onClear={() => { setError(undefined); - setValue(usernameField.name, ""); + setUsernameField((u) => ({ ...u, value: "" })); }} /> @@ -176,10 +184,8 @@ export function Signup({ diff --git a/packages/keychain/src/components/connect/utils.ts b/packages/keychain/src/components/connect/utils.ts index a4dd60def..f1ec898d2 100644 --- a/packages/keychain/src/components/connect/utils.ts +++ b/packages/keychain/src/components/connect/utils.ts @@ -8,7 +8,11 @@ import { AuthAction } from "./Authenticate"; export function validateUsernameFor(type: AuthAction) { return async (val: string) => { - if (val.split(" ").length > 1) { + if (!val) { + return "Username required"; + } else if (val.length < 3) { + return "Username must be at least 3 characters"; + } else if (val.split(" ").length > 1) { return "Username cannot contain spaces"; } diff --git a/packages/ui/package.json b/packages/ui/package.json index 72375cfbf..b53650d2d 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -46,7 +46,6 @@ "vite-plugin-dts": "^3.5.3" }, "dependencies": { - "@chakra-ui/anatomy": "^2.2.1", - "react-hook-form": "^7.52.1" + "@chakra-ui/anatomy": "^2.2.1" } } diff --git a/packages/ui/src/components/Field.tsx b/packages/ui/src/components/Field.tsx index ea9e99e68..a1a9a8805 100644 --- a/packages/ui/src/components/Field.tsx +++ b/packages/ui/src/components/Field.tsx @@ -10,7 +10,6 @@ import { Text, Spinner, } from "@chakra-ui/react"; -import { FieldError } from "react-hook-form"; import { AlertIcon, TimesCircleIcon } from "./icons"; import { forwardRef, useCallback, useState } from "react"; @@ -23,7 +22,7 @@ export const Field = forwardRef( isLoading, ...inputProps }: InputProps & { - error?: FieldError; + error?: string; onClear?: () => void; containerStyles?: StackProps; isLoading?: boolean; @@ -82,7 +81,7 @@ export const Field = forwardRef( - {error.message} + {error} )} diff --git a/packages/ui/src/stories/Field.stories.tsx b/packages/ui/src/stories/Field.stories.tsx index 0519abc48..dd94eb72d 100644 --- a/packages/ui/src/stories/Field.stories.tsx +++ b/packages/ui/src/stories/Field.stories.tsx @@ -33,6 +33,6 @@ export const Normal: Story = {}; export const Error: Story = { args: { - error: { type: "validate", message: "Cannot contain special characters" }, + error: "Cannot contain special characters", }, }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62220808f..45adae006 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -240,9 +240,6 @@ importers: react-dom: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) - react-hook-form: - specifier: ^7.52.1 - version: 7.52.1(react@18.3.1) react-query: specifier: ^3.39.2 version: 3.39.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -351,9 +348,6 @@ importers: react-dom: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) - react-hook-form: - specifier: ^7.52.1 - version: 7.52.1(react@18.3.1) devDependencies: '@cartridge/tsconfig': specifier: workspace:^ @@ -10574,12 +10568,6 @@ packages: react: ^16.6.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.6.0 || ^17.0.0 || ^18.0.0 - react-hook-form@7.52.1: - resolution: {integrity: sha512-uNKIhaoICJ5KQALYZ4TOaOLElyM+xipord+Ha3crEFhTntdLvWZqVY49Wqd/0GiVCA/f9NjemLeiNPjG7Hpurg==} - engines: {node: '>=12.22.0'} - peerDependencies: - react: ^16.8.0 || ^17 || ^18 || ^19 - react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -26151,7 +26139,7 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.4.38 - postcss-load-config@4.0.2(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.4.17)(typescript@5.4.5)): + postcss-load-config@4.0.2(postcss@8.4.38)(ts-node@10.9.2(@types/node@20.12.7)(typescript@5.4.5)): dependencies: lilconfig: 3.1.1 yaml: 2.4.2 @@ -26709,10 +26697,6 @@ snapshots: react-fast-compare: 3.2.2 shallowequal: 1.1.0 - react-hook-form@7.52.1(react@18.3.1): - dependencies: - react: 18.3.1 - react-is@16.13.1: {} react-is@17.0.2: {} @@ -27915,7 +27899,7 @@ snapshots: postcss: 8.4.38 postcss-import: 15.1.0(postcss@8.4.38) postcss-js: 4.0.1(postcss@8.4.38) - postcss-load-config: 4.0.2(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.4.17)(typescript@5.4.5)) + postcss-load-config: 4.0.2(postcss@8.4.38)(ts-node@10.9.2(@types/node@20.12.7)(typescript@5.4.5)) postcss-nested: 6.0.1(postcss@8.4.38) postcss-selector-parser: 6.0.16 resolve: 1.22.8 @@ -28180,7 +28164,7 @@ snapshots: execa: 5.1.1 globby: 11.1.0 joycon: 3.1.1 - postcss-load-config: 4.0.2(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.4.17)(typescript@5.4.5)) + postcss-load-config: 4.0.2(postcss@8.4.38)(ts-node@10.9.2(@types/node@20.12.7)(typescript@5.4.5)) resolve-from: 5.0.0 rollup: 4.17.2 source-map: 0.8.0-beta.0