fix(form): break drawer counter/dirty infinite-loop + add GlobeLoader + AppErrorBoundary
Maximum update depth exceeded showed на /dictionaries/$name когда RecordDrawer
открывался в create mode. Root cause:
1. SchemaDrivenForm useEffect deps включал onCounterChange/onDirtyChange.
2. Parent route передавал inline arrow (() => setDrawerCount({...})) —
новая identity каждый render.
3. setDrawerCount({visible, total}) создавал новый object literal даже
при тех же значениях → React re-render → effect fires → loop.
Fix:
- SchemaDrivenForm: ref-pattern для callback. useEffect subscribed только
на value deps (visibleFieldCount/totalFieldCount/dirtyCount), latest
callback хранится в ref.
- dataValues — useMemo с frozen EMPTY_DATA default чтобы не invalidate'ить
isFieldVisible memo каждый render.
Plus:
- GlobeLoader component — orbital globe spinner per Downloads/Globe loader
prototype. Pure SVG + CSS animations, без d3/topojson.
- LoadingBlock переключен с Loader2 на GlobeLoader (все usage обновляется
автоматически: RecordHistoryDrawer, dictionaries.$name editor loading,
search results, webhooks list, etc).
- AppErrorBoundary — friendly fallback вместо TanStack DefaultErrorComponent.
GlobeLoader (paused/dimmed) + 'Что-то пошло не так' + [Обновить][На главную].
Dev-only collapsible с raw stack trace.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useForm, Controller, type Path, type SubmitHandler } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Ajv, { type ErrorObject } from 'ajv'
|
||||
@@ -70,6 +70,10 @@ type Props = {
|
||||
}
|
||||
|
||||
const ajv = new Ajv({ allErrors: true, strict: false })
|
||||
|
||||
// Stable empty-object literal — используется как default для dataValues
|
||||
// чтобы не создавать новый `{}` на каждый render (deps memo invalidation).
|
||||
const EMPTY_DATA: Record<string, unknown> = Object.freeze({})
|
||||
addFormats(ajv)
|
||||
|
||||
const isLocalized = (s: JsonSchema): boolean => Boolean(s['x-localized'])
|
||||
@@ -149,7 +153,12 @@ export const SchemaDrivenForm = ({
|
||||
// search: substring match (case-insensitive) против key OR labelOf(key)
|
||||
// onlyFilled: hide fields где data[key] empty (null/undefined/''/false/[]/0)
|
||||
// Применяется только в sections layout (drawer use case).
|
||||
const dataValues = (mode === 'edit' ? defaultValues?.data : undefined) ?? {}
|
||||
// useMemo чтобы default {} не создавал new object literal каждый render —
|
||||
// иначе isFieldVisible useMemo ниже invalidate'ится → каскад re-renders.
|
||||
const dataValues = useMemo(
|
||||
() => (mode === 'edit' ? defaultValues?.data : undefined) ?? EMPTY_DATA,
|
||||
[mode, defaultValues?.data],
|
||||
)
|
||||
const isFieldVisible = useMemo(() => {
|
||||
const search = (searchFilter ?? '').trim().toLowerCase()
|
||||
return (key: string): boolean => {
|
||||
@@ -183,9 +192,18 @@ export const SchemaDrivenForm = ({
|
||||
const totalFieldCount = buckets.identity.length + buckets.description.length + buckets.extra.length
|
||||
const visibleFieldCount =
|
||||
filteredBuckets.identity.length + filteredBuckets.description.length + filteredBuckets.extra.length
|
||||
// Ref-pattern для callback: parent часто передаёт inline arrow
|
||||
// (() => setState(...)) — identity меняется каждый render. Если включить
|
||||
// callback в effect deps → effect fires → setState → re-render → loop
|
||||
// ("Maximum update depth exceeded"). Держим latest callback в ref и
|
||||
// вызываем по value-deps только. (review #infinite-loop-2026-05-11)
|
||||
const onCounterChangeRef = useRef(onCounterChange)
|
||||
useEffect(() => {
|
||||
onCounterChange?.(visibleFieldCount, totalFieldCount)
|
||||
}, [visibleFieldCount, totalFieldCount, onCounterChange])
|
||||
onCounterChangeRef.current = onCounterChange
|
||||
})
|
||||
useEffect(() => {
|
||||
onCounterChangeRef.current?.(visibleFieldCount, totalFieldCount)
|
||||
}, [visibleFieldCount, totalFieldCount])
|
||||
|
||||
const submit: SubmitHandler<FormValues> = (values) => {
|
||||
if (values.validFrom && values.validTo) {
|
||||
@@ -243,9 +261,15 @@ export const SchemaDrivenForm = ({
|
||||
: 0
|
||||
return top + dataDirty
|
||||
}, [formState.dirtyFields])
|
||||
// Same ref-pattern as onCounterChange: parent's inline callback identity
|
||||
// changes every render → loop. Subscribe to value (dirtyCount) only.
|
||||
const onDirtyChangeRef = useRef(onDirtyChange)
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(dirtyCount)
|
||||
}, [dirtyCount, onDirtyChange])
|
||||
onDirtyChangeRef.current = onDirtyChange
|
||||
})
|
||||
useEffect(() => {
|
||||
onDirtyChangeRef.current?.(dirtyCount)
|
||||
}, [dirtyCount])
|
||||
|
||||
// Section visibility — в 'sections' layout все buckets рендерятся подряд,
|
||||
// в 'tabs' — только active. Sections layout per handoff Screen 3 prototype.
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRouter } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/ui'
|
||||
import { GlobeLoader } from '@/ui/components/globe-loader'
|
||||
|
||||
/**
|
||||
* AppErrorBoundary — friendly UI который рендерится через TanStack Router
|
||||
* `errorComponent` когда route выкидывает unhandled exception (включая
|
||||
* "Maximum update depth exceeded" infinite loops).
|
||||
*
|
||||
* Юзер видит:
|
||||
* - illustrated GlobeLoader (статичный paused)
|
||||
* - "Что-то пошло не так. Попробуйте обновить страницу"
|
||||
* - кнопки [Обновить] / [На главную]
|
||||
* - dev-only collapsible с raw error (только в dev mode, не в prod)
|
||||
*
|
||||
* НЕ показывает raw stack trace в prod — это утечка деталей реализации
|
||||
* и тревожит юзера.
|
||||
*/
|
||||
|
||||
export type AppErrorBoundaryProps = {
|
||||
error: Error
|
||||
reset?: () => void
|
||||
}
|
||||
|
||||
export function AppErrorBoundary({ error, reset }: AppErrorBoundaryProps) {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const [showDetails, setShowDetails] = useState(false)
|
||||
// Vite dev mode → import.meta.env.DEV — показываем raw error для разработки.
|
||||
const isDev = import.meta.env.DEV
|
||||
|
||||
const handleRetry = () => {
|
||||
if (reset) reset()
|
||||
else router.invalidate()
|
||||
}
|
||||
|
||||
const handleHome = () => {
|
||||
router.navigate({ to: '/dictionaries' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="min-h-[60vh] flex flex-col items-center justify-center px-6 py-12 text-center"
|
||||
>
|
||||
<div className="opacity-40 mb-6">
|
||||
<GlobeLoader size={120} />
|
||||
</div>
|
||||
<h1 className="text-title-xl text-ink font-semibold mb-2">
|
||||
{t('error.boundary.title', { defaultValue: 'Что-то пошло не так' })}
|
||||
</h1>
|
||||
<p className="text-body text-mute max-w-md mb-6">
|
||||
{t('error.boundary.description', {
|
||||
defaultValue:
|
||||
'Произошла непредвиденная ошибка при отображении страницы. Попробуйте обновить — обычно это помогает. Если повторится, сообщите нам.',
|
||||
})}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="primary" size="sm" onClick={handleRetry}>
|
||||
{t('error.boundary.retry', { defaultValue: 'Обновить' })}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={handleHome}>
|
||||
{t('error.boundary.home', { defaultValue: 'На главную' })}
|
||||
</Button>
|
||||
</div>
|
||||
{isDev && (
|
||||
<div className="mt-8 w-full max-w-2xl text-left">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDetails((v) => !v)}
|
||||
className="text-cell text-mute hover:text-ink-2 underline underline-offset-2"
|
||||
>
|
||||
{showDetails ? 'Скрыть детали (dev)' : 'Показать детали (dev)'}
|
||||
</button>
|
||||
{showDetails && (
|
||||
<pre className="mt-2 rounded-md border border-line bg-surface-2 p-3 text-mono text-cell text-ink-2 overflow-auto max-h-64 whitespace-pre-wrap">
|
||||
{error.message}
|
||||
{error.stack ? `\n\n${error.stack}` : ''}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user