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:
Zimin A.N.
2026-05-11 22:31:57 +03:00
parent 7429cff009
commit ca53322b14
7 changed files with 308 additions and 18 deletions
@@ -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.