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:
@@ -0,0 +1,134 @@
|
||||
import { useId as useReactId } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* GlobeLoader — orbital globe spinner per Downloads/Globe loader prototype.
|
||||
*
|
||||
* Чистый CSS/SVG (без d3 / topojson) — упрощённая sphere с graticule
|
||||
* meridians + pulsing outer ring + два контр-вращающихся whirl arcs с tail
|
||||
* gradient. Stylized в дизайн-системе: stroke=currentColor (берёт цвет ink
|
||||
* из родителя — works на любом фоне, в т.ч. dark mode).
|
||||
*
|
||||
* Использование:
|
||||
* <GlobeLoader /> // 64px default
|
||||
* <GlobeLoader size={120} /> // larger
|
||||
* <GlobeLoader className="text-mute" /> // tint
|
||||
*
|
||||
* Performance: всё в SVG + CSS animations (transform / opacity) — paint-only,
|
||||
* без JS frame loop. Безопасно держать одновременно на странице.
|
||||
*/
|
||||
|
||||
export type GlobeLoaderProps = {
|
||||
/** Pixel size of the spinner square (default 64). */
|
||||
size?: number
|
||||
/** Optional label rendered below (text-body, мute). */
|
||||
label?: React.ReactNode
|
||||
/** Extra wrapper class — обычно text-ink/text-mute для tint. */
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function GlobeLoader({ size = 64, label, className }: GlobeLoaderProps) {
|
||||
// ID gradient unique per instance чтобы несколько loader'ов не конфликтовали
|
||||
// через shared <defs id>. React 19 useId — SSR-safe + stable.
|
||||
const id = useReactId().replace(/:/g, '')
|
||||
const tailA = `globe-tail-a-${id}`
|
||||
const tailB = `globe-tail-b-${id}`
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-busy="true"
|
||||
aria-label="loading"
|
||||
className={cn('inline-flex flex-col items-center justify-center gap-2 text-ink', className)}
|
||||
>
|
||||
<div
|
||||
className="globe-loader relative"
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<svg viewBox="0 0 200 200" className="absolute inset-0 size-full overflow-visible">
|
||||
<defs>
|
||||
<linearGradient id={tailA} gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="200" y2="0">
|
||||
<stop offset="0%" stopColor="currentColor" stopOpacity="0" />
|
||||
<stop offset="60%" stopColor="currentColor" stopOpacity="0.18" />
|
||||
<stop offset="100%" stopColor="currentColor" stopOpacity="0.95" />
|
||||
</linearGradient>
|
||||
<linearGradient id={tailB} gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="200" y2="0">
|
||||
<stop offset="0%" stopColor="currentColor" stopOpacity="0" />
|
||||
<stop offset="70%" stopColor="currentColor" stopOpacity="0.12" />
|
||||
<stop offset="100%" stopColor="currentColor" stopOpacity="0.55" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* Pulsing outermost dashed ring */}
|
||||
<g className="globe-pulse">
|
||||
<circle
|
||||
cx="100"
|
||||
cy="100"
|
||||
r="96"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.6"
|
||||
strokeDasharray="1 4"
|
||||
/>
|
||||
</g>
|
||||
|
||||
{/* Outer whirl: 270° tail arc + leading dot + ghost arc */}
|
||||
<g className="globe-whirl">
|
||||
<path
|
||||
d="M 100 4 A 96 96 0 1 1 4 100"
|
||||
fill="none"
|
||||
stroke={`url(#${tailA})`}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<circle cx="100" cy="4" r="2.4" fill="currentColor" />
|
||||
<path
|
||||
d="M 100 12 A 88 88 0 0 1 188 100"
|
||||
fill="none"
|
||||
stroke={`url(#${tailB})`}
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
opacity="0.7"
|
||||
/>
|
||||
</g>
|
||||
|
||||
{/* Inner whirl, counter-rotating, tighter */}
|
||||
<g className="globe-whirl-rev">
|
||||
<path
|
||||
d="M 100 18 A 82 82 0 1 1 18 100"
|
||||
fill="none"
|
||||
stroke={`url(#${tailA})`}
|
||||
strokeWidth="1.1"
|
||||
strokeLinecap="round"
|
||||
opacity="0.55"
|
||||
/>
|
||||
<circle cx="100" cy="18" r="1.6" fill="currentColor" opacity="0.85" />
|
||||
</g>
|
||||
|
||||
{/* Sphere + graticule. Без d3 — фиксированные meridians/parallels.
|
||||
* Meridians = vertical ellipses with varying rx (orthographic-like
|
||||
* projection приближение). Parallels = horizontal lines (clipped
|
||||
* sphere implicit через circle clip). */}
|
||||
<g className="globe-sphere">
|
||||
<circle cx="100" cy="100" r="60" fill="none" stroke="currentColor" strokeWidth="1" />
|
||||
{/* meridians at different rx values — looks like rotating projection */}
|
||||
<g stroke="currentColor" strokeWidth="0.5" strokeOpacity="0.35" fill="none">
|
||||
<ellipse cx="100" cy="100" rx="60" ry="60" />
|
||||
<ellipse cx="100" cy="100" rx="45" ry="60" />
|
||||
<ellipse cx="100" cy="100" rx="25" ry="60" />
|
||||
<ellipse cx="100" cy="100" rx="5" ry="60" />
|
||||
{/* parallels — horizontal lines clipped within the sphere */}
|
||||
<line x1="44" y1="80" x2="156" y2="80" />
|
||||
<line x1="40" y1="100" x2="160" y2="100" />
|
||||
<line x1="44" y1="120" x2="156" y2="120" />
|
||||
<line x1="55" y1="60" x2="145" y2="60" />
|
||||
<line x1="55" y1="140" x2="145" y2="140" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
{label && <span className="text-body text-mute">{label}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user