admin管理员组

文章数量:1356464

For no reason (I think), when I put a my validate method in my useEffect dependencies, that cause a loop ONLY in test because apparently my validate method is reinstantiated => because my isPatternValid and isRequiredValid methods are reinstantiated... But I have no idea why they are reinstantiated in my test...

Input.tsx :

import {FC, useCallback, useContext, useEffect, useMemo, useRef, useState} from "react";
import "./Input.scss";
import {useTranslation} from "react-i18next";
import {FormContext, Tooltip} from "@components";

export type DisabledInput = {
    readonly hideStyle: boolean,
    readonly isDisabled: boolean
}

export type Validation = {
    readonly id: string,
    readonly type?: "email" | "mobile" | "uri",
    readonly required?: boolean
}

type InputProps = {
    readonly defaultValue?: string,
    readonly className?: string,
    readonly disabled?: DisabledInput,
    readonly placeholder?: string,
    readonly name?: string,
    readonly validationType?: Validation
}

export const Input: FC<InputProps> =
    ({defaultValue = "", className, disabled, placeholder, name = "", validationType}) => {
    const {validation, resetCallback} = useContext(FormContext);
    const {t} = useTranslation();
    const [inputValue, setInputValue] = useState(defaultValue);
    const [errMsg, setErrMsg] = useState<Record<string, string>>({});
    const [isValidInput, setIsValidInput] = useState<boolean>(true);
    const errMsgLength = Object.values(errMsg).length > 0;
    const inputRef = useRef<HTMLInputElement>(null);

    const errorMessages = useMemo(() => ({email: t("form.badEmail"), mobile: t("form.badMobile"), uri: t("form.badUri")}), [t]);
    const patterns = useMemo(() => ({
        email: /^[\w-.]+@([\w-]+\.)+[\w-]{2,4}$/,
        mobile: /^\d{10,15}$/,
        uri: /^(http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)?(localhost|[a-z0-9*]+([-.]{1}[a-z0-9*]+)*\.[a-z*]{2,5})(:[0-9]{1,5})?(\/.*)?$/
    }), []);

    const isPatternValid = useCallback((value: string) => {
        if (!validationType?.type) return true;
        const isValid = patterns[validationType.type].test(value) || value === "";
        setErrMsg(prev => {
            const newInputsValidation = {...prev};
            if (isValid) delete newInputsValidation[validationType.type!];
            else newInputsValidation[validationType.type!] = errorMessages[validationType.type!];
            return newInputsValidation;
        });
        return isValid;
    }, [errorMessages, patterns, validationType?.type]);

    const isRequiredValid = useCallback((value: string) => {
        if (validationType?.required && value === "") {
            setErrMsg(prev => ({...prev, required: t("form.required")}));
            return false;
        }
        setErrMsg(prev => {
            const newInputsValidation = {...prev};
            delete newInputsValidation["required"];
            return newInputsValidation;
        });
        return true;
    }, [t, validationType?.required]);

    const validate = useCallback((value: string) => {
        const patternIsValid = isPatternValid(value);
        const requiredIsValid = isRequiredValid(value);
        setIsValidInput(patternIsValid && requiredIsValid);
    }, [isRequiredValid, isPatternValid]);

    useEffect(() => {
        const resetInput = () => {
            validate(defaultValue);
            setInputValue(defaultValue);
        };
        resetCallback.subscribe(resetInput);
        return () => resetCallback.unsubscribe(resetInput);
    }, [resetCallback, defaultValue, validate]);

    useEffect(() => {
        if (!validationType?.id) return;
        validation.subscribe(validationType?.id, isValidInput);
        return () => validation.unsubscribe(validationType?.id);
    }, [validationType?.id, isValidInput, validation]);

    useEffect(() => validate(defaultValue), [defaultValue, validate]);

    return (
        <div className={`input ${className || ""}`}>
            <input
                ref={inputRef}
                disabled={disabled?.isDisabled}
                name={name}
                onChange={e => {
                   validate(e.target.value);
                   setInputValue(e.target.value);
                }}
                data-testid="input"
                className={`input__field${errMsgLength ? " error" : ""}${disabled?.hideStyle ? " hideDecoration" : " showDecoration"}`}
                type="text"
                value={inputValue}
                placeholder={placeholder}/>
            {disabled?.isDisabled && <Tooltip targetRef={inputRef} tooltipContent={inputValue || placeholder}/>}
            {(!disabled?.isDisabled && errMsgLength) &&
                <>
                    <i data-testid="errorIcon" className="ri-error-warning-line errorIcon"></i>
                    <div className="errorMsg">
                        {Object.values(errMsg).map((value, i) => (
                            <span key={i}>* {value}</span>
                        ))}
                    </div>
                </>
            }
        </div>
    );
};

Form.tsx :

import {createContext, forwardRef, ReactNode, useCallback, useMemo, useState} from "react";
import {Button} from "@components";
import {useTranslation} from "react-i18next";
import "./Form.scss";

type FormContext = {
    validation: {
        subscribe: (name: string, isValid: boolean) => void,
        unsubscribe: (name: string) => void
    }
    resetCallback: {
        subscribe: (callback: () => void) => void,
        unsubscribe: (callback: () => void) => void
    }
}

type FormProps = {
    children: ReactNode,
    onSubmit: (formData: FormData) => Promise<void>,
    onCancel?: () => void,
    showBtn?: boolean,
    className?: string
}

export const FormContext = createContext<FormContext>({
    validation: {
        subscribe: () => {},
        unsubscribe: () => {}
    },
    resetCallback: {
        subscribe: () => {},
        unsubscribe: () => {}
    },
});

export const Form = forwardRef<HTMLFormElement, FormProps>(({
        children,
        onCancel,
        onSubmit,
        showBtn = true,
        className
    }, ref
) => {
    const {t} = useTranslation();
    const [inputsValidation, setInputsValidation] = useState<Record<string, boolean>>({});
    const [isSubmitting, setIsSubmitting] = useState(false);
    const [resetCallbacks, setResetCallbacks] = useState<(() => void)[]>([]);

    const validation = useMemo(() => ({
        subscribe: (id: string, isValid: boolean) => setInputsValidation(prev => ({ ...prev, [id]: isValid })),
        unsubscribe: (id: string) =>
            setInputsValidation(inputsValidation => {
                const newInputsValidation = { ...inputsValidation };
                delete newInputsValidation[id];
                return newInputsValidation;
            })
    }), []);

    const allInputsValid = Object.values(inputsValidation).every(Boolean);

    const resetCallback = useMemo(() => ({
        subscribe: (callback: () => void) => setResetCallbacks(prev => [...prev, callback]),
        unsubscribe: (callback: () => void) => setResetCallbacks(prev => prev.filter(cb => cb !== callback))
    }), []);

    const triggerReset = useCallback(() => resetCallbacks.forEach(callback => callback()), [resetCallbacks]);

    return (
        <FormContext.Provider value={{validation, resetCallback}}>
            <form data-testid="form" ref={ref} className={`form ${className || undefined}`} onSubmit={e => {
                e.preventDefault();
                setIsSubmitting(true);
                onSubmit(new FormData(e.target as HTMLFormElement))
                    .then(() => setIsSubmitting(false));
            }}>
                {children}
                {showBtn &&
                    <div className="form__formActions">
                        {onCancel &&
                            <Button className="form__formActions__cancel" disabled={isSubmitting}
                                    onClick={() => {
                                        onCancel();
                                        triggerReset();
                                    }}>{t("form.cancel")}</Button>}
                        <Button className="form__formActions__submit" disabled={isSubmitting || !allInputsValid}
                                isLoading={isSubmitting} type="submit">
                            {t("form.validate")}
                        </Button>
                    </div>
                }
            </form>
        </FormContext.Provider>
    );
});

My test :

import {describe, expect, it, vi} from "vitest";
import {Form, FormContext, Input} from "@components";
import {fireEvent, render, screen, waitFor} from "@testing-library/react";
import {FC, useContext, useEffect} from "react";


const onSubmitMock = vi.fn().mockResolvedValue(undefined);
const onCancelMock = vi.fn();
const resetCallbackMock = vi.fn();

const FormTest: FC<{isValid: boolean}> = ({isValid}) => (
    <Form onSubmit={() => onSubmitMock()} onCancel={() => onCancelMock()}>
        <Input name="test-input1" {...(!isValid && {validationType: {id: "test-id", required: true}})}/>
        <Input name="test-input2"/>
        <Input name="test-input3"/>
        <FormChild/>
    </Form>
);

const FormChild: FC = () => {
    const {resetCallback} = useContext(FormContext);
    useEffect(() => {
        resetCallback.subscribe(resetCallbackMock);
        return () => resetCallback.unsubscribe(resetCallbackMock);
    }, [resetCallback]);
    return <></>;
};

describe("<Form/>", () => {
    it("shouldn't call submit method if form is invalid", async () => {
        render(<FormTest isValid={false}/>);
        const validateBtn = screen.getByText("form.validate");
        fireEvent.click(validateBtn);
        await waitFor(() => {
            expect(validateBtn).toBeDisabled();
            expect(onSubmitMock).not.toBeCalled();
        });
    });
});

本文标签: reactjsInfinite loop only when I test my React componentStack Overflow