React 19 Phase 3: login/forgot/register forms → useActionState + useFormStatus

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 <LoginSubmitButton/> 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<void>
  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
This commit is contained in:
simoleo89
2026-05-11 16:31:50 +00:00
parent a1bee1d825
commit 1b1e0c18bf
+118 -103
View File
@@ -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 (
<button
type="submit"
className="ok-button"
disabled={ pending || isEntering || isLocked }>
{ isEntering ? t('nitro.login.entering', 'Entering…') : (pending || loginPingingServer) ? t('nitro.login.server.checking', 'Checking…') : t('login.title', 'Log in') }
</button>
);
};
export interface LoginViewProps
{
onAuthenticated: (ssoTicket: string) => void;
@@ -357,7 +372,7 @@ export const LoginView: FC<LoginViewProps> = ({ 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<LoginViewProps> = ({ onAuthenticated, isEntering = fa
}
}, [ checkServerReachable ]);
const handleLoginSubmit = useCallback(async (event: FormEvent<HTMLFormElement>) =>
const loginAction = useCallback(async (_prev: null, formData: FormData): Promise<null> =>
{
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<LoginViewProps> = ({ 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<LoginViewProps> = ({ 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<null, FormData>(loginAction, null);
const checkEmailUrl = GetConfigurationValue<string>('login.check-email.endpoint', '/api/auth/check-email');
const checkUsernameUrl = GetConfigurationValue<string>('login.check-username.endpoint', '/api/auth/check-username');
@@ -735,7 +751,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
<div className="nitro-login-card">
<div className="card-title">{ t('nitro.login.card.title', 'What\'s your Habbo called?') }</div>
<form className="card-body" onSubmit={ handleLoginSubmit } autoComplete="on">
<form className="card-body" action={ submitLoginAction } autoComplete="on">
<div className="field">
<label htmlFor="login-username">{ t('login.username', 'Name of your Habbo') }</label>
<input
@@ -763,6 +779,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
<label className="remember-row">
<input
type="checkbox"
name="remember"
checked={ rememberMe }
onChange={ e => setRememberMe(e.target.checked) }
/>
@@ -788,11 +805,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
{ error && <div className="error-line">{ error }</div> }
{ info && <div className="info-line">{ info }</div> }
<div className="submit-row">
<button
type="submit"
className="ok-button"
disabled={ submitting || isEntering || isLocked }
>{ isEntering ? t('nitro.login.entering', 'Entering…') : loginPingingServer ? t('nitro.login.server.checking', 'Checking…') : t('login.title', 'Log in') }</button>
<LoginSubmitButton isEntering={ isEntering } isLocked={ isLocked } loginPingingServer={ loginPingingServer } />
</div>
<a className="forgot" onClick={ () => setMode('forgot') }>{ t('login.forgot_password', 'Forgotten your password?') }</a>
</form>
@@ -840,7 +853,7 @@ interface DialogSharedProps
interface RegisterDialogProps extends DialogSharedProps
{
onSubmit: (body: { username: string; email: string; password: string; figure: string; gender: string; turnstileToken: string; }, onDialogReset: () => void) => void;
onSubmit: (body: { username: string; email: string; password: string; figure: string; gender: string; turnstileToken: string; }, onDialogReset: () => void) => Promise<void> | void;
onCheckEmail: (email: string) => Promise<{ available: boolean; error?: string }>;
onCheckUsername: (username: string) => Promise<{ available: boolean; error?: string }>;
onCheckServer: () => Promise<boolean>;
@@ -1067,7 +1080,6 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
const [ gender, setGender ] = useState<GenderKey>('F');
const [ selection, setSelection ] = useState<FigureSelection>(() => ({ ...FALLBACK_DEFAULTS.F }));
const [ localError, setLocalError ] = useState<string | null>(null);
const [ checking, setChecking ] = useState(false);
const [ turnstileToken, setTurnstileToken ] = useState('');
const [ resetSignal, setResetSignal ] = useState(0);
const [ serverReachable, setServerReachable ] = useState<boolean | null>(null);
@@ -1236,54 +1248,50 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
password.length >= 8 &&
password === confirm;
const handleCredentialsNext = async (event: FormEvent<HTMLFormElement>) =>
const credentialsAction = useCallback(async (_prev: null, _formData: FormData): Promise<null> =>
{
event.preventDefault();
setLocalError(null);
if(!email.trim() || !password || !confirm)
{
setLocalError(t('nitro.login.register.error.missing_fields', 'Please fill in every field.'));
return;
return null;
}
if(!EMAIL_REGEX.test(email.trim()))
{
setLocalError(t('nitro.login.register.error.invalid_email', 'Please enter a valid email address.'));
return;
return null;
}
if(password.length < 8)
{
setLocalError(t('nitro.login.register.error.password_too_short', 'Your password must be at least 8 characters.'));
return;
return null;
}
if(password !== confirm)
{
setLocalError(t('nitro.login.register.error.password_mismatch', 'Passwords do not match.'));
return;
return null;
}
setChecking(true);
try
const serverOk = await pingServer();
if(!serverOk)
{
const serverOk = await pingServer();
if(!serverOk)
{
setLocalError(t('nitro.login.error.server_offline', 'The gameserver is not running. Please try again later.'));
return;
}
const result = await onCheckEmail(email.trim());
if(!result.available)
{
setLocalError(result.error || t('nitro.login.error.email_taken', 'This email is already in use.'));
return;
}
setStep('avatar');
setLocalError(t('nitro.login.error.server_offline', 'The gameserver is not running. Please try again later.'));
return null;
}
finally
const result = await onCheckEmail(email.trim());
if(!result.available)
{
setChecking(false);
setLocalError(result.error || t('nitro.login.error.email_taken', 'This email is already in use.'));
return null;
}
};
setStep('avatar');
return null;
}, [ email, password, confirm, pingServer, onCheckEmail ]);
const [ , submitCredentialsAction, isCredentialsPending ] = useActionState<null, FormData>(credentialsAction, null);
const applyGender = (newGender: GenderKey) =>
{
@@ -1345,61 +1353,57 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
const figure = buildFigureString(selection);
const previewSrc = useAvatarPreview(figure, gender, AvatarSetType.FULL);
const handleAvatarSubmit = async (event: FormEvent<HTMLFormElement>) =>
const avatarAction = useCallback(async (_prev: null, _formData: FormData): Promise<null> =>
{
event.preventDefault();
setLocalError(null);
const trimmed = username.trim();
if(!trimmed)
{
setLocalError(t('nitro.login.register.error.username_required', 'Please choose a Habbo name.'));
return;
return null;
}
if(trimmed.length < 3 || trimmed.length > 16)
{
setLocalError(t('nitro.login.register.error.username_length', 'Habbo name must be 316 characters.'));
return;
return null;
}
if(turnstileEnabled && !turnstileToken)
{
setLocalError(t('nitro.login.error.turnstile', 'Please complete the security check.'));
return;
return null;
}
setChecking(true);
try
const serverOk = await pingServer();
if(!serverOk)
{
const serverOk = await pingServer();
if(!serverOk)
{
setLocalError(t('nitro.login.error.server_offline', 'The gameserver is not running. Please try again later.'));
return;
}
const result = await onCheckUsername(trimmed);
if(!result.available)
{
setLocalError(result.error || t('nitro.login.error.username_taken', 'This Habbo name is already taken.'));
return;
}
}
finally
{
setChecking(false);
setLocalError(t('nitro.login.error.server_offline', 'The gameserver is not running. Please try again later.'));
return null;
}
onSubmit({
const result = await onCheckUsername(trimmed);
if(!result.available)
{
setLocalError(result.error || t('nitro.login.error.username_taken', 'This Habbo name is already taken.'));
return null;
}
await onSubmit({
username: trimmed,
email: email.trim(),
password,
figure,
figure: buildFigureString(selection),
gender,
turnstileToken
}, resetWidget);
};
const busy = submitting || checking || pingingServer;
return null;
}, [ username, turnstileEnabled, turnstileToken, pingServer, onCheckUsername, onSubmit, email, password, selection, gender, resetWidget ]);
const [ , submitAvatarAction, isAvatarPending ] = useActionState<null, FormData>(avatarAction, null);
const busy = submitting || isCredentialsPending || isAvatarPending || pingingServer;
const serverOffline = serverReachable === false;
return (
@@ -1412,7 +1416,7 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
</div>
{ step === 'credentials' &&
<form className="card-body" onSubmit={ handleCredentialsNext } autoComplete="on">
<form className="card-body" action={ submitCredentialsAction } autoComplete="on">
<div className="register-intro">
{ 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.') }
</div>
@@ -1426,17 +1430,17 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
}
<div className="field">
<label htmlFor="register-email">{ t('nitro.login.register.email', 'Email') }</label>
<input id="register-email" type="email" maxLength={ 120 } autoComplete="email"
<input id="register-email" name="email" type="email" maxLength={ 120 } autoComplete="email"
value={ email } onChange={ e => setEmail(e.target.value) } />
</div>
<div className="field">
<label htmlFor="register-password">{ t('generic.password', 'Password') }</label>
<input id="register-password" type="password" maxLength={ 128 } autoComplete="new-password"
<input id="register-password" name="password" type="password" maxLength={ 128 } autoComplete="new-password"
value={ password } onChange={ e => setPassword(e.target.value) } />
</div>
<div className="field">
<label htmlFor="register-confirm">{ t('nitro.login.register.confirm_password', 'Confirm password') }</label>
<input id="register-confirm" type="password" maxLength={ 128 } autoComplete="new-password"
<input id="register-confirm" name="confirm" type="password" maxLength={ 128 } autoComplete="new-password"
value={ confirm } onChange={ e => setConfirm(e.target.value) } />
</div>
{ (localError || error) && <div className="error-line">{ localError || error }</div> }
@@ -1444,14 +1448,14 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
<div className="step-footer">
<span className="step-indicator">1/2</span>
<button type="submit" className="ok-button" disabled={ !credentialsValid || busy || serverOffline }>
{ checking || pingingServer ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.register.next', 'Next') }
{ isCredentialsPending || pingingServer ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.register.next', 'Next') }
</button>
</div>
</form>
}
{ step === 'avatar' &&
<form className="card-body" onSubmit={ handleAvatarSubmit } autoComplete="on">
<form className="card-body" action={ submitAvatarAction } autoComplete="on">
<div className="register-intro">
{ 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.') }
</div>
@@ -1542,7 +1546,7 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
<button type="button" className="ok-button back-button" onClick={ () => setStep('credentials') } disabled={ busy }>{ t('nitro.login.register.back', 'Back') }</button>
<span className="step-indicator">2/2</span>
<button type="submit" className="ok-button" disabled={ !username.trim() || busy || serverOffline }>
{ submitting ? t('nitro.login.register.creating', 'Creating…') : (checking || pingingServer) ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.register.next', 'Next') }
{ (submitting || isAvatarPending) ? t('nitro.login.register.creating', 'Creating…') : pingingServer ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.register.next', 'Next') }
</button>
</div>
</form>
@@ -1556,12 +1560,19 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
interface ForgotDialogProps extends DialogSharedProps
{
onSubmit: (body: { email: string; turnstileToken: string; }, onDialogReset: () => void) => void;
onSubmit: (body: { email: string; turnstileToken: string; }, onDialogReset: () => void) => Promise<void> | void;
}
const ForgotSubmitButton: FC = () =>
{
const { pending } = useFormStatus();
return <button type="submit" className="ok-button" disabled={ pending }>{ t('nitro.login.forgot.send_email', 'Send email') }</button>;
};
const ForgotDialog: FC<ForgotDialogProps> = 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<string | null>(null);
const [ turnstileToken, setTurnstileToken ] = useState('');
@@ -1573,19 +1584,23 @@ const ForgotDialog: FC<ForgotDialogProps> = props =>
setResetSignal(prev => prev + 1);
}, []);
const handle = (event: FormEvent<HTMLFormElement>) =>
const forgotAction = useCallback(async (_prev: null, formData: FormData): Promise<null> =>
{
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<null, FormData>(forgotAction, null);
return (
<div className="nitro-login-modal">
@@ -1595,10 +1610,10 @@ const ForgotDialog: FC<ForgotDialogProps> = props =>
<span>{ t('nitro.login.forgot.title', 'Reset password') }</span>
<span className="nitro-card-close-button" role="button" aria-label={ t('generic.close', 'Close') } onClick={ onCancel } />
</div>
<form className="card-body" onSubmit={ handle } autoComplete="on">
<form className="card-body" action={ submitForgotAction } autoComplete="on">
<div className="field">
<label htmlFor="forgot-email">{ t('nitro.login.forgot.email_label', 'Email address') }</label>
<input id="forgot-email" type="email" maxLength={ 120 } autoComplete="email"
<input id="forgot-email" name="email" type="email" maxLength={ 120 } autoComplete="email"
value={ email } onChange={ e => setEmail(e.target.value) } />
</div>
{ turnstileEnabled &&
@@ -1613,7 +1628,7 @@ const ForgotDialog: FC<ForgotDialogProps> = props =>
{ (localError || error) && <div className="error-line">{ localError || error }</div> }
{ info && <div className="info-line">{ info }</div> }
<div className="submit-row">
<button type="submit" className="ok-button" disabled={ submitting }>{ t('nitro.login.forgot.send_email', 'Send email') }</button>
<ForgotSubmitButton />
</div>
</form>
</div>