R: Remove unnecessary rerenders from useEffect

This commit is contained in:
Ivan 2025-04-28 11:38:31 +03:00
parent d40e1ea256
commit 3d77317347
25 changed files with 197 additions and 113 deletions

View File

@ -1,6 +1,6 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { useRef, useState } from 'react';
import { ChevronDownIcon } from 'lucide-react';
import { IconRemove } from '../icons';
@ -49,11 +49,12 @@ export function ComboBox<Option>({
const [popoverWidth, setPopoverWidth] = useState<number | undefined>(undefined);
const triggerRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (triggerRef.current) {
function handleOpenChange(isOpen: boolean) {
setOpen(isOpen);
if (isOpen && triggerRef.current) {
setPopoverWidth(triggerRef.current.offsetWidth);
}
}, [open]);
}
function handleChangeValue(newValue: Option | null) {
onChange(newValue);
@ -66,7 +67,7 @@ export function ComboBox<Option>({
}
return (
<Popover open={open} onOpenChange={setOpen}>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<button
id={id}

View File

@ -1,6 +1,6 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { useRef, useState } from 'react';
import { ChevronDownIcon } from 'lucide-react';
import { IconRemove } from '../icons';
@ -43,11 +43,12 @@ export function ComboMulti<Option>({
const [popoverWidth, setPopoverWidth] = useState<number | undefined>(undefined);
const triggerRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (triggerRef.current) {
function handleOpenChange(isOpen: boolean) {
setOpen(isOpen);
if (isOpen && triggerRef.current) {
setPopoverWidth(triggerRef.current.offsetWidth);
}
}, [open]);
}
function handleAddValue(newValue: Option) {
if (value.includes(newValue)) {
@ -70,7 +71,7 @@ export function ComboMulti<Option>({
}
return (
<Popover open={open} onOpenChange={setOpen}>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<button
id={id}

View File

@ -44,11 +44,12 @@ export function SelectTree<ItemType>({
...restProps
}: SelectTreeProps<ItemType>) {
const foldable = new Set(items.filter(item => getParent(item) !== item).map(item => getParent(item)));
const [folded, setFolded] = useState<ItemType[]>(items);
const defaultFolded = items.filter(item => getParent(value) !== item && getParent(getParent(value)) !== item);
const [folded, setFolded] = useState<ItemType[]>(defaultFolded);
useEffect(() => {
setFolded(items.filter(item => getParent(value) !== item && getParent(getParent(value)) !== item));
}, [value, getParent, items]);
setFolded(defaultFolded);
}, [defaultFolded]);
function onFoldItem(target: ItemType) {
setFolded(prev =>

View File

@ -1,6 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { urls, useConceptNavigation } from '@/app';
@ -13,13 +13,26 @@ import { useQueryStrings } from '@/hooks/use-query-strings';
import { useResetPassword } from '../backend/use-reset-password';
function useTokenValidation(token: string, isPending: boolean) {
const { validateToken } = useResetPassword();
const [isTokenValidating, setIsTokenValidating] = useState(false);
const validate = async () => {
if (!isTokenValidating && !isPending) {
await validateToken({ token });
setIsTokenValidating(true);
}
};
return { isTokenValidating, validate };
}
export function Component() {
const router = useConceptNavigation();
const token = useQueryStrings().get('token') ?? '';
const { validateToken, resetPassword, isPending, error: serverError } = useResetPassword();
const { resetPassword, isPending, error: serverError } = useResetPassword();
const { isTokenValidating, validate } = useTokenValidation(token, isPending);
const [isTokenValidating, setIsTokenValidating] = useState(false);
const [newPassword, setNewPassword] = useState('');
const [newPasswordRepeat, setNewPasswordRepeat] = useState('');
@ -38,12 +51,9 @@ export function Component() {
}
}
useEffect(() => {
if (!isTokenValidating && !isPending) {
void validateToken({ token: token });
setIsTokenValidating(true);
void validate();
}
}, [token, validateToken, isTokenValidating, isPending]);
if (isPending) {
return <Loader />;

View File

@ -54,7 +54,7 @@ export function LibraryPage() {
return (
<>
<ToolbarSearch className='top-0 h-9' total={libraryItems.length} filtered={filtered.length} />
<div className='relative cc-fade-in flex'>
<div className='relative flex'>
<MiniButton
title='Выгрузить в формате CSV'
className='absolute z-tooltip -top-8 right-6 hidden sm:block'

View File

@ -43,11 +43,7 @@ export function EditorOssCard() {
/>
<div
onKeyDown={handleInput}
className={clsx(
'cc-fade-in',
'md:max-w-fit max-w-128 min-w-fit',
'flex flex-row flex-wrap pt-8 px-6 justify-center'
)}
className={clsx('md:max-w-fit max-w-128 min-w-fit', 'flex flex-row flex-wrap pt-8 px-6 justify-center')}
>
<div className='cc-column px-3'>
<FormOSS />

View File

@ -1,7 +1,7 @@
'use no memo'; // TODO: remove when react hook forms are compliant with react compiler
'use client';
import { useEffect } from 'react';
import { useRef } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
@ -30,7 +30,7 @@ export function FormOSS() {
control,
setValue,
reset,
formState: { isDirty, errors }
formState: { errors, isDirty }
} = useForm<IUpdateLibraryItemDTO>({
resolver: zodResolver(schemaUpdateLibraryItem),
defaultValues: {
@ -46,9 +46,11 @@ export function FormOSS() {
const visible = useWatch({ control, name: 'visible' });
const readOnly = useWatch({ control, name: 'read_only' });
useEffect(() => {
const prevDirty = useRef(isDirty);
if (prevDirty.current !== isDirty) {
prevDirty.current = isDirty;
setIsModified(isDirty);
}, [isDirty, setIsModified]);
}
function onSubmit(data: IUpdateLibraryItemDTO) {
return updateOss(data).then(() => reset({ ...data }));

View File

@ -411,7 +411,7 @@ export function OssFlow() {
<ContextMenu isOpen={isContextMenuOpen} onHide={() => setIsContextMenuOpen(false)} {...menuProps} />
<div
className={clsx('cc-fade-in relative w-[100vw] cc-mask-sides', !containMovement && 'cursor-relocate')}
className={clsx('relative w-[100vw] cc-mask-sides', !containMovement && 'cursor-relocate')}
style={{ height: mainHeight, fontFamily: 'Rubik' }}
>
<ReactFlow

View File

@ -1,6 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { urls, useConceptNavigation } from '@/app';
import { useAuthSuspense } from '@/features/auth';
@ -8,6 +8,7 @@ import { useLibrarySearchStore } from '@/features/library';
import { useDeleteItem } from '@/features/library/backend/use-delete-item';
import { RSTabID } from '@/features/rsform/pages/rsform-page/rsedit-context';
import { useRoleStore, UserRole } from '@/features/users';
import { useAdjustRole } from '@/features/users/stores/use-adjust-role';
import { usePreferencesStore } from '@/stores/preferences';
import { promptText } from '@/utils/labels';
@ -27,7 +28,6 @@ export const OssEditState = ({ itemID, children }: React.PropsWithChildren<OssEd
const adminMode = usePreferencesStore(state => state.adminMode);
const role = useRoleStore(state => state.role);
const adjustRole = useRoleStore(state => state.adjustRole);
const setSearchLocation = useLibrarySearchStore(state => state.setLocation);
const searchLocation = useLibrarySearchStore(state => state.location);
@ -36,21 +36,18 @@ export const OssEditState = ({ itemID, children }: React.PropsWithChildren<OssEd
const isOwned = !!user.id && user.id === schema.owner;
const isMutable = role > UserRole.READER && !schema.read_only;
const isEditor = !!user.id && schema.editors.includes(user.id);
const [selected, setSelected] = useState<number[]>([]);
const { deleteItem } = useDeleteItem();
useEffect(
() =>
adjustRole({
useAdjustRole({
isOwner: isOwned,
isEditor: !!user.id && schema.editors.includes(user.id),
isEditor: isEditor,
isStaff: user.is_staff,
adminMode: adminMode
}),
[schema, adjustRole, isOwned, user, adminMode]
);
});
function navigateTab(tab: OssTabID) {
const url = urls.oss_props({

View File

@ -1,6 +1,5 @@
'use client';
import { useEffect } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { useParams } from 'react-router';
import { z } from 'zod';
@ -12,6 +11,7 @@ import { isAxiosError } from '@/backend/api-transport';
import { TextURL } from '@/components/control';
import { type ErrorData } from '@/components/info-error';
import { useQueryStrings } from '@/hooks/use-query-strings';
import { useResetModification } from '@/hooks/use-reset-modification';
import { useModificationStore } from '@/stores/modification';
import { OperationTooltip } from '../../components/tooltip-oss-item';
@ -26,6 +26,8 @@ const paramsSchema = z.strictObject({
});
export function OssPage() {
useResetModification();
const router = useConceptNavigation();
const params = useParams();
const query = useQueryStrings();
@ -35,11 +37,9 @@ export function OssPage() {
tab: query.get('tab')
});
const { isModified, setIsModified } = useModificationStore();
const { isModified } = useModificationStore();
useBlockNavigation(isModified);
useEffect(() => setIsModified(false), [setIsModified]);
if (!urlData.id) {
router.replace({ path: urls.page404, force: true });
return null;

View File

@ -75,7 +75,6 @@ export function EditorConstituenta() {
tabIndex={-1}
className={clsx(
'relative ',
'cc-fade-in',
'min-h-80 max-w-[calc(min(100vw,80rem))] mx-auto',
'flex pt-8',
'overflow-y-auto overflow-x-clip',

View File

@ -1,7 +1,7 @@
'use no memo'; // TODO: remove when react hook forms are compliant with react compiler
'use client';
import { useEffect, useLayoutEffect, useMemo, useState } from 'react';
import { useMemo, useRef, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { toast } from 'react-toastify';
import { zodResolver } from '@hookform/resolvers/zod';
@ -44,19 +44,30 @@ export function FormConstituenta({ disabled, id, toggleReset, schema, activeCst,
const { isModified, setIsModified } = useModificationStore();
const isProcessing = useMutatingRSForm();
const { updateConstituenta: cstUpdate } = useUpdateConstituenta();
const showTypification = useDialogsStore(state => state.showShowTypeGraph);
const showEditTerm = useDialogsStore(state => state.showEditWordForms);
const showRenameCst = useDialogsStore(state => state.showRenameCst);
const {
register,
handleSubmit,
control,
reset,
formState: { isDirty }
} = useForm<IUpdateConstituentaDTO>({ resolver: zodResolver(schemaUpdateConstituenta) });
const { updateConstituenta: cstUpdate } = useUpdateConstituenta();
const showTypification = useDialogsStore(state => state.showShowTypeGraph);
const showEditTerm = useDialogsStore(state => state.showEditWordForms);
const showRenameCst = useDialogsStore(state => state.showRenameCst);
} = useForm<IUpdateConstituentaDTO>({
resolver: zodResolver(schemaUpdateConstituenta),
defaultValues: {
target: activeCst.id,
item_data: {
convention: activeCst.convention,
term_raw: activeCst.term_raw,
definition_raw: activeCst.definition_raw,
definition_formal: activeCst.definition_formal
}
}
});
const [forceComment, setForceComment] = useState(false);
const [localParse, setLocalParse] = useState<IExpressionParseDTO | null>(null);
const typification = useMemo(
@ -80,12 +91,21 @@ export function FormConstituenta({ disabled, id, toggleReset, schema, activeCst,
[activeCst, localParse]
);
const [forceComment, setForceComment] = useState(false);
const isBasic = isBasicConcept(activeCst.cst_type);
const isElementary = isBaseSet(activeCst.cst_type);
const showConvention = !!activeCst.convention || forceComment || isBasic;
useEffect(() => {
const prevActiveCstID = useRef(activeCst.id);
const prevToggleReset = useRef(toggleReset);
const prevSchema = useRef(schema);
if (
prevActiveCstID.current !== activeCst.id ||
prevToggleReset.current !== toggleReset ||
prevSchema.current !== schema
) {
prevActiveCstID.current = activeCst.id;
prevToggleReset.current = toggleReset;
prevSchema.current = schema;
reset({
target: activeCst.id,
item_data: {
@ -97,15 +117,19 @@ export function FormConstituenta({ disabled, id, toggleReset, schema, activeCst,
});
setForceComment(false);
setLocalParse(null);
}, [activeCst, schema, toggleReset, reset]);
}
useLayoutEffect(() => {
const prevDirty = useRef(isDirty);
if (prevDirty.current !== isDirty) {
prevDirty.current = isDirty;
setIsModified(isDirty);
return () => setIsModified(false);
}, [isDirty, activeCst, setIsModified]);
}
function onSubmit(data: IUpdateConstituentaDTO) {
return cstUpdate({ itemID: schema.id, data }).then(() => reset({ ...data }));
return cstUpdate({ itemID: schema.id, data }).then(() => {
setIsModified(false);
reset({ ...data });
});
}
function handleTypeGraph(event: React.MouseEvent<Element>) {

View File

@ -1,9 +1,10 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { useRef, useState } from 'react';
import { toast } from 'react-toastify';
import { type ReactCodeMirrorRef } from '@uiw/react-codemirror';
import { useResetOnChange } from '@/hooks/use-reset-on-change';
import { useDialogsStore } from '@/stores/dialogs';
import { usePreferencesStore } from '@/stores/preferences';
import { errorMsg } from '@/utils/labels';
@ -69,6 +70,11 @@ export function EditorRSExpression({
const { checkConstituenta: checkInternal, isPending } = useCheckConstituenta();
useResetOnChange([activeCst, toggleReset], () => {
setIsModified(false);
setParseData(null);
});
function checkConstituenta(
expression: string,
activeCst: IConstituenta,
@ -85,11 +91,6 @@ export function EditorRSExpression({
});
}
useEffect(() => {
setIsModified(false);
setParseData(null);
}, [activeCst, toggleReset]);
function handleChange(newValue: string) {
onChange(newValue);
setIsModified(newValue !== activeCst.definition_formal);

View File

@ -31,10 +31,7 @@ export function EditorRSFormCard() {
}
return (
<div
onKeyDown={handleInput}
className='relative cc-fade-in md:w-fit md:max-w-fit max-w-128 flex flex-row flex-wrap px-6 pt-8'
>
<div onKeyDown={handleInput} className='relative md:w-fit md:max-w-fit max-w-128 flex flex-row flex-wrap px-6 pt-8'>
<ToolbarItemCard
className='cc-tab-tools'
onSubmit={initiateSubmit}

View File

@ -1,7 +1,7 @@
'use no memo'; // TODO: remove when react hook forms are compliant with react compiler
'use client';
import { useEffect } from 'react';
import { useRef } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
@ -41,12 +41,23 @@ export function FormRSForm() {
reset,
formState: { isDirty, errors }
} = useForm<IUpdateLibraryItemDTO>({
resolver: zodResolver(schemaUpdateLibraryItem)
resolver: zodResolver(schemaUpdateLibraryItem),
defaultValues: {
id: schema.id,
item_type: LibraryItemType.RSFORM,
title: schema.title,
alias: schema.alias,
description: schema.description,
visible: schema.visible,
read_only: schema.read_only
}
});
const visible = useWatch({ control, name: 'visible' });
const readOnly = useWatch({ control, name: 'read_only' });
useEffect(() => {
const prevSchema = useRef(schema);
if (prevSchema.current !== schema) {
prevSchema.current = schema;
reset({
id: schema.id,
item_type: LibraryItemType.RSFORM,
@ -56,11 +67,13 @@ export function FormRSForm() {
visible: schema.visible,
read_only: schema.read_only
});
}, [schema, reset]);
}
useEffect(() => {
const prevDirty = useRef(isDirty);
if (prevDirty.current !== isDirty) {
prevDirty.current = isDirty;
setIsModified(isDirty);
}, [isDirty, setIsModified]);
}
function handleSelectVersion(version: CurrentVersion) {
router.push({ path: urls.schema(schema.id, version === 'latest' ? undefined : version) });

View File

@ -126,7 +126,7 @@ export function EditorRSList() {
const tableHeight = useFitHeight('4rem + 5px');
return (
<div tabIndex={-1} onKeyDown={handleKeyDown} className='relative cc-fade-in pt-8'>
<div tabIndex={-1} onKeyDown={handleKeyDown} className='relative pt-8'>
{isContentEditable ? (
<ToolbarRSList className='cc-tab-tools right-4 md:right-1/2 -translate-x-1/2 md:translate-x-0 cc-animate-position' />
) : null}

View File

@ -1,6 +1,6 @@
'use client';
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import {
type Edge,
MarkerType,
@ -121,17 +121,19 @@ export function TGFlow() {
}, PARAMETER.minimalTimeout);
}, [schema, filteredGraph, setNodes, setEdges, filter.noText, fitView, viewportInitialized, focusCst]);
useEffect(() => {
if (!viewportInitialized) {
return;
}
const prevSelected = useRef<number[]>([]);
if (
viewportInitialized &&
(prevSelected.current.length !== selected.length || prevSelected.current.some((id, i) => id !== selected[i]))
) {
prevSelected.current = selected;
setNodes(prev =>
prev.map(node => ({
...node,
selected: selected.includes(Number(node.id))
}))
);
}, [selected, setNodes, viewportInitialized]);
}
function handleSetSelected(newSelection: number[]) {
setSelected(newSelection);

View File

@ -1,12 +1,13 @@
'use client';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { urls, useConceptNavigation } from '@/app';
import { useAuthSuspense } from '@/features/auth';
import { useLibrarySearchStore } from '@/features/library';
import { useDeleteItem } from '@/features/library/backend/use-delete-item';
import { useRoleStore, UserRole } from '@/features/users';
import { useAdjustRole } from '@/features/users/stores/use-adjust-role';
import { useDialogsStore } from '@/stores/dialogs';
import { useModificationStore } from '@/stores/modification';
@ -39,7 +40,6 @@ export const RSEditState = ({
const router = useConceptNavigation();
const adminMode = usePreferencesStore(state => state.adminMode);
const role = useRoleStore(state => state.role);
const adjustRole = useRoleStore(state => state.adjustRole);
const setSearchLocation = useLibrarySearchStore(state => state.setLocation);
const searchLocation = useLibrarySearchStore(state => state.location);
@ -52,6 +52,7 @@ export const RSEditState = ({
const isMutable = role > UserRole.READER && !schema.read_only;
const isContentEditable = isMutable && !isArchive;
const isAttachedToOSS = schema.oss.length > 0;
const isEditor = !!user.id && schema.editors.includes(user.id);
const [selected, setSelected] = useState<number[]>([]);
const canDeleteSelected = selected.length > 0 && selected.every(id => !schema.cstByID.get(id)?.is_inherited);
@ -67,16 +68,12 @@ export const RSEditState = ({
const showDeleteCst = useDialogsStore(state => state.showDeleteCst);
const showCstTemplate = useDialogsStore(state => state.showCstTemplate);
useEffect(
() =>
adjustRole({
useAdjustRole({
isOwner: isOwned,
isEditor: !!user.id && schema.editors.includes(user.id),
isEditor: isEditor,
isStaff: user.is_staff,
adminMode: adminMode
}),
[schema, adjustRole, isOwned, user, adminMode]
);
});
function handleSetFocus(newValue: IConstituenta | null) {
setFocusCst(newValue);

View File

@ -1,6 +1,5 @@
'use client';
import { useEffect } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { useParams } from 'react-router';
import { z } from 'zod';
@ -12,6 +11,7 @@ import { Divider } from '@/components/container';
import { TextURL } from '@/components/control';
import { type ErrorData } from '@/components/info-error';
import { useQueryStrings } from '@/hooks/use-query-strings';
import { useResetModification } from '@/hooks/use-reset-modification';
import { useModificationStore } from '@/stores/modification';
import { ConstituentaTooltip } from '../../components/constituenta-tooltip';
@ -34,6 +34,8 @@ const paramsSchema = z.strictObject({
});
export function RSFormPage() {
useResetModification();
const router = useConceptNavigation();
const params = useParams();
const query = useQueryStrings();
@ -45,11 +47,9 @@ export function RSFormPage() {
activeID: query.get('active')
});
const { isModified, setIsModified } = useModificationStore();
const { isModified } = useModificationStore();
useBlockNavigation(isModified);
useEffect(() => setIsModified(false), [setIsModified]);
if (!urlData.id) {
router.replace({ path: urls.page404, force: true });
return null;

View File

@ -1,6 +1,6 @@
'use client';
import { useEffect } from 'react';
import { useRef } from 'react';
import { createColumnHelper, DataTable, type IConditionalStyle } from '@/components/data-table';
import { NoData, TextContent } from '@/components/view';
@ -26,11 +26,10 @@ export function TableSideConstituents({ autoScroll = true, maxHeight }: TableSid
const { activeCst, navigateCst } = useRSEdit();
const items = useFilteredItems();
useEffect(() => {
if (!activeCst) {
return;
}
if (autoScroll) {
const prevActiveCstID = useRef<number | null>(null);
if (autoScroll && prevActiveCstID.current !== activeCst?.id) {
prevActiveCstID.current = activeCst?.id ?? null;
if (!!activeCst) {
setTimeout(() => {
const element = document.getElementById(`${prefixes.cst_side_table}${activeCst.id}`);
if (element) {
@ -42,7 +41,7 @@ export function TableSideConstituents({ autoScroll = true, maxHeight }: TableSid
}
}, PARAMETER.refreshTimeout);
}
}, [activeCst, autoScroll]);
}
const columns = [
columnHelper.accessor('alias', {

View File

@ -55,7 +55,7 @@ export function FormSignup() {
return (
<form
className='cc-column cc-fade-in mx-auto w-144 px-6 py-3'
className='cc-column mx-auto w-144 px-6 py-3'
onSubmit={event => void handleSubmit(onSubmit)(event)}
onChange={resetErrors}
>

View File

@ -6,7 +6,7 @@ import { EditorProfile } from './editor-profile';
export function UserProfilePage() {
return (
<RequireAuth>
<div className='cc-fade-in flex flex-col py-2 mx-auto w-fit'>
<div className='flex flex-col py-2 mx-auto w-fit'>
<h1 className='mb-2 select-none'>Учетные данные пользователя</h1>
<div className='flex py-2'>
<EditorProfile />

View File

@ -0,0 +1,21 @@
import { useRef } from 'react';
import { useRoleStore } from './role';
interface AdjustRoleProps {
isOwner: boolean;
isEditor: boolean;
isStaff: boolean;
adminMode: boolean;
}
export function useAdjustRole(input: AdjustRoleProps) {
const adjustRole = useRoleStore(state => state.adjustRole);
const lastInput = useRef<string | null>(null);
const serializedInput = JSON.stringify(input);
if (lastInput.current !== serializedInput) {
lastInput.current = serializedInput;
adjustRole(input);
}
}

View File

@ -0,0 +1,13 @@
import { useRef } from 'react';
import { useModificationStore } from '@/stores/modification';
export function useResetModification() {
const { setIsModified } = useModificationStore();
const initialized = useRef(false);
if (!initialized.current) {
initialized.current = true;
setIsModified(false);
}
}

View File

@ -0,0 +1,10 @@
import { useRef } from 'react';
export function useResetOnChange<T>(deps: T[], resetFn: () => void) {
const lastDeps = useRef<string | null>(null);
const currentDeps = JSON.stringify(deps);
if (lastDeps.current !== currentDeps) {
lastDeps.current = currentDeps;
resetFn();
}
}