From 1b1e0c18bf3c76ccae3876c3c14048e794c3df57 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Mon, 11 May 2026 16:31:50 +0000 Subject: [PATCH] =?UTF-8?q?React=2019=20Phase=203:=20login/forgot/register?= =?UTF-8?q?=20forms=20=E2=86=92=20useActionState=20+=20useFormStatus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate all three inline forms in LoginView.tsx to React 19 Actions: - Login form: handleLoginSubmit → loginAction(prevState, FormData) wrapped in useActionState. Submit button extracted as reading pending via useFormStatus, dropping the local `submitting` flag for the login flow. Reads username/password/remember from FormData; rememberMe checkbox now carries name="remember". - Forgot form (inline): forgotAction wrapped in useActionState; awaits parent's onSubmit so pending stays true through the parent fetch. ForgotSubmitButton uses useFormStatus. - Register credentials step: credentialsAction with useActionState; the step transition (setStep('avatar')) happens inside the action after pingServer + onCheckEmail. - Register avatar step: avatarAction validates username, pings server, checks availability, then awaits onSubmit. The button label uses isAvatarPending to show "Creating…" without prop drilling submitting. - DialogSharedProps onSubmit signatures updated to return Promise so dialog actions can await the parent's fetch. - lockState memo replaced with a direct readLock() call in render: the previous useMemo depended on `submitting` to refresh after a failed attempt; now any re-render (triggered by the action's pending toggle) recomputes it. - Remove unused FormEvent import; remove unused checking state in RegisterDialog (replaced by isCredentialsPending / isAvatarPending). https://claude.ai/code/session_01GrR87LAqnAEyKG2ZbmQt5Q --- src/components/login/LoginView.tsx | 221 +++++++++++++++-------------- 1 file changed, 118 insertions(+), 103 deletions(-) diff --git a/src/components/login/LoginView.tsx b/src/components/login/LoginView.tsx index adb8dbb..1177dc9 100644 --- a/src/components/login/LoginView.tsx +++ b/src/components/login/LoginView.tsx @@ -1,5 +1,6 @@ import { AvatarScaleType, AvatarSetType, GetAvatarRenderManager, GetConfiguration, IAvatarImage } from '@nitrots/nitro-renderer'; -import { FC, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { FC, useActionState, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useFormStatus } from 'react-dom'; import { ClearRememberLogin, GetConfigurationValue, GetRememberLogin, StoreRememberLoginFromPayload } from '../../api'; import { configFileUrl } from '../../secure-assets'; import flagBr from '../../assets/images/flag_icon/flag_icon_br.png'; @@ -172,6 +173,20 @@ const applyLocaleSelection = (locale: LoginLocale): void => catch {} }; +const LoginSubmitButton: FC<{ isEntering: boolean; isLocked: boolean; loginPingingServer: boolean }> = ({ isEntering, isLocked, loginPingingServer }) => +{ + const { pending } = useFormStatus(); + + return ( + + ); +}; + export interface LoginViewProps { onAuthenticated: (ssoTicket: string) => void; @@ -357,7 +372,7 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa return () => window.clearTimeout(timeout); }, [ info ]); - const lockState = useMemo(() => readLock(), [ submitting ]); + const lockState = readLock(); const now = Date.now(); const isLocked = lockState.lockedUntil > now; @@ -445,45 +460,46 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa } }, [ checkServerReachable ]); - const handleLoginSubmit = useCallback(async (event: FormEvent) => + const loginAction = useCallback(async (_prev: null, formData: FormData): Promise => { - event.preventDefault(); - - if(submitting || isEntering) return; + if(isEntering) return null; const nowTs = Date.now(); - if(nowTs - submitTimeRef.current < 1000) return; + if(nowTs - submitTimeRef.current < 1000) return null; submitTimeRef.current = nowTs; - const state = readLock(); - if(state.lockedUntil > nowTs) + const usernameInput = String(formData.get('username') || '').trim(); + const passwordInput = String(formData.get('password') || ''); + const rememberFlag = formData.get('remember') === 'on'; + + const lockSnapshot = readLock(); + if(lockSnapshot.lockedUntil > nowTs) { - const remaining = Math.ceil((state.lockedUntil - nowTs) / 1000); + const remaining = Math.ceil((lockSnapshot.lockedUntil - nowTs) / 1000); setError(t('nitro.login.error.too_many_attempts', 'Too many attempts. Try again in %seconds%s.', [ 'seconds' ], [ String(remaining) ])); - return; + return null; } - if(!username.trim() || !password) + if(!usernameInput || !passwordInput) { setError(t('nitro.login.error.missing_credentials', 'Please enter both your Habbo name and password.')); - return; + return null; } if(turnstileEnabled && !loginTurnstileToken) { setError(t('nitro.login.error.turnstile', 'Please complete the security check.')); - return; + return null; } setError(null); - setSubmitting(true); try { const { ok, payload } = await postJson(loginUrl, { - username: username.trim(), - password, - remember: rememberMe, + username: usernameInput, + password: passwordInput, + remember: rememberFlag, turnstileToken: turnstileEnabled ? loginTurnstileToken : undefined }); @@ -492,10 +508,10 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa if(ok && ssoTicket) { clearLock(); - if(rememberMe) StoreRememberLoginFromPayload(payload, typeof payload.username === 'string' ? payload.username : username.trim(), ssoTicket); + if(rememberFlag) StoreRememberLoginFromPayload(payload, typeof payload.username === 'string' ? payload.username : usernameInput, ssoTicket); else ClearRememberLogin(); onAuthenticated(ssoTicket); - return; + return null; } recordFailure(); @@ -503,17 +519,17 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa setError(message); resetLoginTurnstile(); } - catch(err) + catch { recordFailure(); setError(t('nitro.login.error.login_unreachable', 'Unable to reach the login service. Please try again.')); resetLoginTurnstile(); } - finally - { - setSubmitting(false); - } - }, [ submitting, isEntering, username, password, rememberMe, turnstileEnabled, loginTurnstileToken, loginUrl, postJson, clearLock, recordFailure, onAuthenticated, resetLoginTurnstile, pingLoginServer ]); + + return null; + }, [ isEntering, turnstileEnabled, loginTurnstileToken, loginUrl, postJson, clearLock, recordFailure, onAuthenticated, resetLoginTurnstile ]); + + const [ , submitLoginAction, isLoginPending ] = useActionState(loginAction, null); const checkEmailUrl = GetConfigurationValue('login.check-email.endpoint', '/api/auth/check-email'); const checkUsernameUrl = GetConfigurationValue('login.check-username.endpoint', '/api/auth/check-username'); @@ -735,7 +751,7 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa
{ t('nitro.login.card.title', 'What\'s your Habbo called?') }
-
+
= ({ onAuthenticated, isEntering = fa
{ step === 'credentials' && -
+
{ t('nitro.login.register.intro.credentials', 'Let\'s create your account. Enter your email and pick a password — we\'ll check that email isn\'t already in use.') }
@@ -1426,17 +1430,17 @@ const RegisterDialog: FC = props => }
- setEmail(e.target.value) } />
- setPassword(e.target.value) } />
- setConfirm(e.target.value) } />
{ (localError || error) &&
{ localError || error }
} @@ -1444,14 +1448,14 @@ const RegisterDialog: FC = props =>
1/2
} { step === 'avatar' && -
+
{ t('nitro.login.register.intro.avatar', 'Now it\'s time to make your own Habbo character! To make your own Habbo, please start by choosing your Habbo Name.') }
@@ -1542,7 +1546,7 @@ const RegisterDialog: FC = props => 2/2
@@ -1556,12 +1560,19 @@ const RegisterDialog: FC = props => interface ForgotDialogProps extends DialogSharedProps { - onSubmit: (body: { email: string; turnstileToken: string; }, onDialogReset: () => void) => void; + onSubmit: (body: { email: string; turnstileToken: string; }, onDialogReset: () => void) => Promise | void; } +const ForgotSubmitButton: FC = () => +{ + const { pending } = useFormStatus(); + + return ; +}; + const ForgotDialog: FC = props => { - const { onCancel, onSubmit, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props; + const { onCancel, onSubmit, error, info, turnstileEnabled, turnstileSiteKey } = props; const [ email, setEmail ] = useState(''); const [ localError, setLocalError ] = useState(null); const [ turnstileToken, setTurnstileToken ] = useState(''); @@ -1573,19 +1584,23 @@ const ForgotDialog: FC = props => setResetSignal(prev => prev + 1); }, []); - const handle = (event: FormEvent) => + const forgotAction = useCallback(async (_prev: null, formData: FormData): Promise => { - event.preventDefault(); setLocalError(null); - if(!email.trim()) + const emailInput = String(formData.get('email') || '').trim(); + + if(!emailInput) { setLocalError(t('nitro.login.forgot.error.email_required', 'Please enter your email address.')); - return; + return null; } - onSubmit({ email: email.trim(), turnstileToken }, resetWidget); - }; + await onSubmit({ email: emailInput, turnstileToken }, resetWidget); + return null; + }, [ onSubmit, turnstileToken, resetWidget ]); + + const [ , submitForgotAction ] = useActionState(forgotAction, null); return (
@@ -1595,10 +1610,10 @@ const ForgotDialog: FC = props => { t('nitro.login.forgot.title', 'Reset password') }
-
+
- setEmail(e.target.value) } />
{ turnstileEnabled && @@ -1613,7 +1628,7 @@ const ForgotDialog: FC = props => { (localError || error) &&
{ localError || error }
} { info &&
{ info }
}
- +