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,14 +1,15 @@
import { Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { GlobeLoader } from './globe-loader'
/**
* LoadingBlock — central spinner с подписью. Заменяет {@code @nstart/ui}
* LoadingBlock. Для inline placeholder используйте Skeleton.
* LoadingBlock — central spinner с подписью. Используется везде где
* загружаются данные / окна. С 11.05.2026 заменили Loader2 spinner на
* GlobeLoader (orbital globe loader per /Users/zimin/Downloads/Globe loader).
*
* Size variants:
* - sm: small inline spinner (py-4)
* - md: default centered (py-12)
* - lg: prominent (py-20)
* - sm: small inline (py-4, 32px globe)
* - md: default centered (py-12, 64px globe)
* - lg: prominent full-page (py-20, 96px globe)
*/
export type LoadingBlockProps = React.HTMLAttributes<HTMLDivElement> & {
label?: React.ReactNode
@@ -16,9 +17,9 @@ export type LoadingBlockProps = React.HTMLAttributes<HTMLDivElement> & {
}
const SIZES = {
sm: { wrapper: 'py-4', icon: 'size-3' },
md: { wrapper: 'py-12', icon: 'size-5' },
lg: { wrapper: 'py-20', icon: 'size-8' },
sm: { wrapper: 'py-4', globe: 32 },
md: { wrapper: 'py-12', globe: 64 },
lg: { wrapper: 'py-20', globe: 96 },
} as const
export function LoadingBlock({ className, label, size = 'md', ...props }: LoadingBlockProps) {
@@ -28,14 +29,14 @@ export function LoadingBlock({ className, label, size = 'md', ...props }: Loadin
role="status"
aria-busy="true"
className={cn(
'flex flex-col items-center justify-center gap-2 text-mute',
'flex flex-col items-center justify-center gap-3 text-ink',
s.wrapper,
className,
)}
{...props}
>
<Loader2 className={cn('animate-spin text-accent', s.icon)} />
{label && <span className="text-body">{label}</span>}
<GlobeLoader size={s.globe} />
{label && <span className="text-body text-mute">{label}</span>}
</div>
)
}