fix(auth): guard stale submissions and rework form error mapping (#2358)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-02 17:26:18 +02:00
1 parent f703969e80
commit 25f4332b9e
43 files changed
+739 -683

No files matched your search

+82 -43
View File
@@ -1,82 +1,121 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {FormEvent} from 'react';
import {useCallback, useState} from 'react';
import {useCallback, useLayoutEffect, useState} from 'react';
interface FormField {
value: string;
error?: string;
}
interface FormState {
[key: string]: FormField;
type FormState = ReadonlyMap<string, FormField>;
class FormSubmissionOwner {}
interface FormLifecycle {
activeSubmission: FormSubmissionOwner | null;
mounted: boolean;
}
export interface FormSubmission {
getValue: (fieldName: string) => string;
isCurrent: () => boolean;
}
interface UseFormOptions {
initialValues?: Record<string, string>;
onSubmit: (values: Record<string, string>) => Promise<void>;
initialValues: Record<string, string>;
onSubmit: (submission: FormSubmission) => Promise<void>;
}
export interface UseFormReturn {
setValue: (fieldName: string, value: string) => void;
setError: (fieldName: string, error: string) => void;
setErrors: (errors: Record<string, string>) => void;
setErrors: (errors: ReadonlyMap<string, string>) => void;
getValue: (fieldName: string) => string;
getError: (fieldName: string) => string | undefined;
handleSubmit: (e?: FormEvent) => Promise<void>;
handleSubmit: (event?: FormEvent) => Promise<void>;
isSubmitting: boolean;
}
export function useForm({initialValues = {}, onSubmit}: UseFormOptions): UseFormReturn {
const [fields, setFields] = useState<FormState>(() => {
const initial: FormState = {};
for (const [key, value] of Object.entries(initialValues)) {
initial[key] = {value};
}
return initial;
});
function createFormState(initialValues: Record<string, string>): FormState {
const fields = new Map<string, FormField>();
for (const [fieldName, value] of Object.entries(initialValues)) {
fields.set(fieldName, {value});
}
return fields;
}
function withFieldError(fields: FormState, fieldName: string, error: string): FormField {
const field = fields.get(fieldName);
return {value: field?.value ?? '', error};
}
function resolveFormValues(fields: FormState): ReadonlyMap<string, string> {
const values = new Map<string, string>();
for (const [fieldName, field] of fields) {
values.set(fieldName, field.value);
}
return values;
}
function isFormSubmissionCurrent(lifecycle: FormLifecycle, submission: FormSubmissionOwner): boolean {
return lifecycle.mounted && lifecycle.activeSubmission === submission;
}
export function useForm({initialValues, onSubmit}: UseFormOptions): UseFormReturn {
const [fields, setFields] = useState<FormState>(() => createFormState(initialValues));
const [isSubmitting, setIsSubmitting] = useState(false);
const [lifecycle] = useState<FormLifecycle>(() => ({activeSubmission: null, mounted: false}));
useLayoutEffect(() => {
lifecycle.mounted = true;
return () => {
lifecycle.mounted = false;
lifecycle.activeSubmission = null;
};
}, [lifecycle]);
const setValue = useCallback((fieldName: string, value: string) => {
setFields((prev) => ({
...prev,
[fieldName]: {...prev[fieldName], value, error: undefined},
}));
setFields((currentFields) => new Map(currentFields).set(fieldName, {value}));
}, []);
const setError = useCallback((fieldName: string, error: string) => {
setFields((prev) => ({
...prev,
[fieldName]: {...prev[fieldName], error},
}));
setFields((currentFields) =>
new Map(currentFields).set(fieldName, withFieldError(currentFields, fieldName, error)),
);
}, []);
const setErrors = useCallback((errors: Record<string, string>) => {
setFields((prev) => {
const updated = {...prev};
for (const [fieldName, error] of Object.entries(errors)) {
updated[fieldName] = {...updated[fieldName], error};
const setErrors = useCallback((errors: ReadonlyMap<string, string>) => {
setFields((currentFields) => {
const updatedFields = new Map(currentFields);
for (const [fieldName, error] of errors) {
updatedFields.set(fieldName, withFieldError(currentFields, fieldName, error));
}
return updated;
return updatedFields;
});
}, []);
const getValue = useCallback((fieldName: string): string => fields[fieldName]?.value || '', [fields]);
const getError = useCallback((fieldName: string): string | undefined => fields[fieldName]?.error, [fields]);
const getValues = useCallback((): Record<string, string> => {
const values: Record<string, string> = {};
for (const [key, field] of Object.entries(fields)) {
values[key] = field.value;
}
return values;
}, [fields]);
const getValue = useCallback((fieldName: string): string => fields.get(fieldName)?.value ?? '', [fields]);
const getError = useCallback((fieldName: string): string | undefined => fields.get(fieldName)?.error, [fields]);
const handleSubmit = useCallback(
async (e?: FormEvent) => {
e?.preventDefault();
async (event?: FormEvent) => {
event?.preventDefault();
if (lifecycle.activeSubmission != null) {
return;
}
const submissionOwner = new FormSubmissionOwner();
lifecycle.activeSubmission = submissionOwner;
setIsSubmitting(true);
const submittedValues = resolveFormValues(fields);
const submission: FormSubmission = {
getValue: (fieldName) => submittedValues.get(fieldName) ?? '',
isCurrent: () => isFormSubmissionCurrent(lifecycle, submissionOwner),
};
try {
await onSubmit(getValues());
await onSubmit(submission);
} finally {
setIsSubmitting(false);
if (isFormSubmissionCurrent(lifecycle, submissionOwner)) {
lifecycle.activeSubmission = null;
setIsSubmitting(false);
}
}
},
[onSubmit, getValues],
[fields, lifecycle, onSubmit],
);
return {
setValue,