From ca53322b14265c26b94db73c5fed7cb7cdf1f343 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Mon, 11 May 2026 22:31:57 +0300 Subject: [PATCH] fix(form): break drawer counter/dirty infinite-loop + add GlobeLoader + AppErrorBoundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/components/form/SchemaDrivenForm.tsx | 36 ++++- .../components/layout/AppErrorBoundary.tsx | 87 ++++++++++++ ordinis-admin-ui/src/routes/__root.tsx | 4 + ordinis-admin-ui/src/styles.css | 39 +++++ .../src/ui/components/globe-loader.tsx | 134 ++++++++++++++++++ .../src/ui/components/loading-block.tsx | 25 ++-- ordinis-admin-ui/src/ui/index.ts | 1 + 7 files changed, 308 insertions(+), 18 deletions(-) create mode 100644 ordinis-admin-ui/src/components/layout/AppErrorBoundary.tsx create mode 100644 ordinis-admin-ui/src/ui/components/globe-loader.tsx diff --git a/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx b/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx index 4508069..e2905cf 100644 --- a/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx +++ b/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx @@ -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 = 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 = (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. diff --git a/ordinis-admin-ui/src/components/layout/AppErrorBoundary.tsx b/ordinis-admin-ui/src/components/layout/AppErrorBoundary.tsx new file mode 100644 index 0000000..03b4696 --- /dev/null +++ b/ordinis-admin-ui/src/components/layout/AppErrorBoundary.tsx @@ -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 ( +
+
+ +
+

+ {t('error.boundary.title', { defaultValue: 'Что-то пошло не так' })} +

+

+ {t('error.boundary.description', { + defaultValue: + 'Произошла непредвиденная ошибка при отображении страницы. Попробуйте обновить — обычно это помогает. Если повторится, сообщите нам.', + })} +

+
+ + +
+ {isDev && ( +
+ + {showDetails && ( +
+              {error.message}
+              {error.stack ? `\n\n${error.stack}` : ''}
+            
+ )} +
+ )} +
+ ) +} diff --git a/ordinis-admin-ui/src/routes/__root.tsx b/ordinis-admin-ui/src/routes/__root.tsx index 0ad56c6..087ef83 100644 --- a/ordinis-admin-ui/src/routes/__root.tsx +++ b/ordinis-admin-ui/src/routes/__root.tsx @@ -4,6 +4,7 @@ import { useState } from 'react' import { Sidebar, MobileSidebar } from '@/components/layout/Sidebar' import { TopBar } from '@/components/layout/TopBar' import { UpdateBanner } from '@/components/version/UpdateBanner' +import { AppErrorBoundary } from '@/components/layout/AppErrorBoundary' /** * Root layout (Stage 3.1 + 3.x mobile responsive): @@ -24,6 +25,9 @@ import { UpdateBanner } from '@/components/version/UpdateBanner' */ export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({ component: RootLayout, + // Catch unhandled errors inside any route — show friendly UI instead of + // TanStack default "Something went wrong! Hide Error" + raw stack trace. + errorComponent: AppErrorBoundary, }) function RootLayout() { diff --git a/ordinis-admin-ui/src/styles.css b/ordinis-admin-ui/src/styles.css index 453deed..6f5d7b4 100644 --- a/ordinis-admin-ui/src/styles.css +++ b/ordinis-admin-ui/src/styles.css @@ -374,6 +374,45 @@ body { color: var(--color-mute); } +/* === GlobeLoader keyframes — orbital spinner (src/ui/components/globe-loader.tsx). + * Класс .globe-loader на wrapper'е активирует вложенные .globe-* animations + * через стандартные CSS animation rules. Transform-only — GPU-композитятся. */ +@keyframes ord-globe-spin { + to { transform: rotate(360deg); } +} +@keyframes ord-globe-pulse { + 0%, 100% { opacity: 0.18; } + 50% { opacity: 0.42; } +} +.globe-loader .globe-whirl { + transform-origin: 100px 100px; + transform-box: fill-box; + animation: ord-globe-spin 3.6s linear infinite; +} +.globe-loader .globe-whirl-rev { + transform-origin: 100px 100px; + transform-box: fill-box; + animation: ord-globe-spin 5.4s linear infinite reverse; +} +.globe-loader .globe-pulse { + transform-origin: 100px 100px; + transform-box: fill-box; + animation: ord-globe-pulse 2.4s ease-in-out infinite; +} +.globe-loader .globe-sphere { + transform-origin: 100px 100px; + transform-box: fill-box; + animation: ord-globe-spin 14s linear infinite; +} +@media (prefers-reduced-motion: reduce) { + .globe-loader .globe-whirl, + .globe-loader .globe-whirl-rev, + .globe-loader .globe-pulse, + .globe-loader .globe-sphere { + animation: none; + } +} + /* === Legacy fallback: старые компоненты используют nstart token names * (--color-carbon, --color-ultramarain etc). Маппим их на новые pending * полная миграция. === */ diff --git a/ordinis-admin-ui/src/ui/components/globe-loader.tsx b/ordinis-admin-ui/src/ui/components/globe-loader.tsx new file mode 100644 index 0000000..62ee49f --- /dev/null +++ b/ordinis-admin-ui/src/ui/components/globe-loader.tsx @@ -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). + * + * Использование: + * // 64px default + * // larger + * // 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 . React 19 useId — SSR-safe + stable. + const id = useReactId().replace(/:/g, '') + const tailA = `globe-tail-a-${id}` + const tailB = `globe-tail-b-${id}` + + return ( +
+
+ + + + + + + + + + + + + + + {/* Pulsing outermost dashed ring */} + + + + + {/* Outer whirl: 270° tail arc + leading dot + ghost arc */} + + + + + + + {/* Inner whirl, counter-rotating, tighter */} + + + + + + {/* Sphere + graticule. Без d3 — фиксированные meridians/parallels. + * Meridians = vertical ellipses with varying rx (orthographic-like + * projection приближение). Parallels = horizontal lines (clipped + * sphere implicit через circle clip). */} + + + {/* meridians at different rx values — looks like rotating projection */} + + + + + + {/* parallels — horizontal lines clipped within the sphere */} + + + + + + + + +
+ {label && {label}} +
+ ) +} + diff --git a/ordinis-admin-ui/src/ui/components/loading-block.tsx b/ordinis-admin-ui/src/ui/components/loading-block.tsx index e24e54a..90e2af3 100644 --- a/ordinis-admin-ui/src/ui/components/loading-block.tsx +++ b/ordinis-admin-ui/src/ui/components/loading-block.tsx @@ -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 & { label?: React.ReactNode @@ -16,9 +17,9 @@ export type LoadingBlockProps = React.HTMLAttributes & { } 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} > - - {label && {label}} + + {label && {label}} ) } diff --git a/ordinis-admin-ui/src/ui/index.ts b/ordinis-admin-ui/src/ui/index.ts index 6818fe1..0e60d6c 100644 --- a/ordinis-admin-ui/src/ui/index.ts +++ b/ordinis-admin-ui/src/ui/index.ts @@ -78,6 +78,7 @@ export { Panel, type PanelProps } from './components/panel' export { PageHeader, type PageHeaderProps } from './components/page-header' export { EmptyState, type EmptyStateProps } from './components/empty-state' export { LoadingBlock, type LoadingBlockProps } from './components/loading-block' +export { GlobeLoader, type GlobeLoaderProps } from './components/globe-loader' // === Tables === export {