feat(shared): 组件化重构 Phase 1 - 底座收敛与补全
- 新增 ErrorBoundary 基础类组件 (ui/error-boundary.tsx) - 新增 StatsGrid 容器 (ui/stats-grid.tsx) - 扩展 Skeleton 新增 SkeletonCard variant - 收敛 SectionErrorBoundary/RouteErrorBoundary/WidgetBoundary 为 ErrorBoundary preset
This commit is contained in:
@@ -1,14 +1,18 @@
|
||||
"use client"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { ErrorBoundary } from "@/shared/components/ui/error-boundary"
|
||||
|
||||
/**
|
||||
* 通用路由错误边界组件。
|
||||
* 通用路由错误边界组件 preset。
|
||||
* 供各模块 error.tsx 复用,消除重复代码(P1-10 重构)。
|
||||
*
|
||||
* 基于 `ErrorBoundary` 基础组件,渲染路由级错误 UI(非包裹式,作为 error.tsx 的渲染组件)。
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // app/(dashboard)/admin/attendance/error.tsx
|
||||
@@ -25,9 +29,11 @@ export function RouteErrorBoundary({
|
||||
}: {
|
||||
reset: () => void
|
||||
namespace?: string
|
||||
}) {
|
||||
}): ReactNode {
|
||||
const t = useTranslations(namespace)
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={() => (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
@@ -40,5 +46,9 @@ export function RouteErrorBoundary({
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{null}
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,95 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import type { JSX } from "react"
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react"
|
||||
import type { ReactNode, ErrorInfo } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { AlertCircle, RefreshCw } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
|
||||
interface BoundaryProps {
|
||||
children: ReactNode
|
||||
/** 自定义降级 UI(函数形式,优先级最高)。若提供则忽略 title/description/retryLabel */
|
||||
fallback?: (error: Error, reset: () => void) => ReactNode
|
||||
/** 渲染失败时的标题(已国际化,默认取 {namespace}.error.boundaryTitle) */
|
||||
title?: string
|
||||
/** 渲染失败时的描述(已国际化,默认取 {namespace}.error.boundaryDescription) */
|
||||
description?: string
|
||||
/** 重试按钮文案(已国际化,默认取 {namespace}.error.retry) */
|
||||
retryLabel?: string
|
||||
/** 错误回调(用于埋点/监控) */
|
||||
onError?: (error: Error, info: ErrorInfo) => void
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 组件级 Error Boundary(类组件实现,承载错误捕获能力)。
|
||||
* 不直接使用,请使用默认导出的函数式 `SectionErrorBoundary` 包装器以获得 i18n 支持。
|
||||
*
|
||||
* 支持两种降级形式:
|
||||
* 1. `fallback`(函数 `(error, reset) => ReactNode`)— 完全自定义降级 UI(优先级最高)
|
||||
* 2. `title` / `description` / `retryLabel` — 默认降级 UI + i18n 文案
|
||||
*/
|
||||
class SectionErrorBoundaryBase extends Component<BoundaryProps, State> {
|
||||
constructor(props: BoundaryProps) {
|
||||
super(props)
|
||||
this.state = { hasError: false, error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||
console.error("[SectionErrorBoundary]", error, errorInfo)
|
||||
this.props.onError?.(error, errorInfo)
|
||||
}
|
||||
|
||||
private handleRetry = (): void => {
|
||||
this.setState({ hasError: false, error: null })
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError && this.state.error) {
|
||||
// 优先使用自定义 fallback
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback(this.state.error, this.handleRetry)
|
||||
}
|
||||
// 默认降级 UI
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className="flex flex-col items-center justify-center space-y-3 rounded-lg border border-dashed p-8 text-center"
|
||||
>
|
||||
<AlertCircle className="h-8 w-8 text-muted-foreground" aria-hidden="true" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{this.props.title ?? "加载失败"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{this.props.description ?? "数据加载时发生错误,请重试。"}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={this.handleRetry}
|
||||
aria-label={this.props.retryLabel ?? "重试"}
|
||||
>
|
||||
<RefreshCw className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />
|
||||
{this.props.retryLabel ?? "重试"}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
import { ErrorBoundary } from "@/shared/components/ui/error-boundary"
|
||||
|
||||
interface SectionErrorBoundaryProps {
|
||||
children: ReactNode
|
||||
@@ -108,9 +25,11 @@ interface SectionErrorBoundaryProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* 组件级 Error Boundary:包裹独立数据区块,防止单区块错误导致整页崩溃。
|
||||
* 组件级 Error Boundary preset:包裹独立数据区块,防止单区块错误导致整页崩溃。
|
||||
* 自动使用 next-intl 提供本地化文案;如需完全自定义可传入 `fallback` 函数。
|
||||
*
|
||||
* 基于基础 `ErrorBoundary` 类组件实现,仅负责 i18n 文案解析与默认降级 UI。
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // 默认 i18n 文案
|
||||
@@ -134,15 +53,36 @@ export function SectionErrorBoundary({
|
||||
onError,
|
||||
}: SectionErrorBoundaryProps): JSX.Element {
|
||||
const t = useTranslations(namespace)
|
||||
return (
|
||||
<SectionErrorBoundaryBase
|
||||
fallback={fallback}
|
||||
title={title ?? t("error.boundaryTitle")}
|
||||
description={description ?? t("error.boundaryDescription")}
|
||||
retryLabel={retryLabel ?? t("error.retry")}
|
||||
onError={onError}
|
||||
const effectiveTitle = title ?? t("error.boundaryTitle")
|
||||
const effectiveDescription = description ?? t("error.boundaryDescription")
|
||||
const effectiveRetryLabel = retryLabel ?? t("error.retry")
|
||||
|
||||
const defaultFallback = (_error: Error, reset: () => void): ReactNode => (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className="flex flex-col items-center justify-center space-y-3 rounded-lg border border-dashed p-8 text-center"
|
||||
>
|
||||
<AlertCircle className="h-8 w-8 text-muted-foreground" aria-hidden="true" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">{effectiveTitle}</p>
|
||||
<p className="text-xs text-muted-foreground">{effectiveDescription}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={reset}
|
||||
aria-label={effectiveRetryLabel}
|
||||
>
|
||||
<RefreshCw className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />
|
||||
{effectiveRetryLabel}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<ErrorBoundary fallback={fallback ?? defaultFallback} onError={onError}>
|
||||
{children}
|
||||
</SectionErrorBoundaryBase>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
58
src/shared/components/ui/error-boundary.tsx
Normal file
58
src/shared/components/ui/error-boundary.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client"
|
||||
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react"
|
||||
|
||||
/**
|
||||
* 基础 ErrorBoundary 类组件。
|
||||
* 不直接使用,请使用 SectionErrorBoundary / RouteErrorBoundary / WidgetBoundary 等 preset。
|
||||
*
|
||||
* 支持两种降级形式:
|
||||
* 1. `fallback`(函数 `(error, reset) => ReactNode`)— 完全自定义降级 UI(优先级最高)
|
||||
* 2. `fallback`(ReactNode)— 静态降级 UI
|
||||
*/
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode
|
||||
/** 自定义降级 UI:可为静态 ReactNode 或函数式 (error, reset) => ReactNode */
|
||||
fallback?: ReactNode | ((error: Error, reset: () => void) => ReactNode)
|
||||
/** 错误回调(用于埋点/监控) */
|
||||
onError?: (error: Error, info: ErrorInfo) => void
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props)
|
||||
this.state = { hasError: false, error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||
console.error("[ErrorBoundary]", error, errorInfo)
|
||||
this.props.onError?.(error, errorInfo)
|
||||
}
|
||||
|
||||
private handleReset = (): void => {
|
||||
this.setState({ hasError: false, error: null })
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError && this.state.error) {
|
||||
const { fallback } = this.props
|
||||
if (typeof fallback === "function") {
|
||||
return fallback(this.state.error, this.handleReset)
|
||||
}
|
||||
if (fallback !== undefined) {
|
||||
return fallback
|
||||
}
|
||||
return null
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
@@ -12,4 +15,106 @@ function Skeleton({
|
||||
)
|
||||
}
|
||||
|
||||
interface SkeletonCardProps {
|
||||
variant: "table" | "list" | "chart" | "form" | "stats-grid"
|
||||
rows?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置驱动骨架卡片:按 variant 渲染不同布局的骨架占位。
|
||||
*
|
||||
* 覆盖以下重复模式:
|
||||
* - message-list-skeleton / announcement-list-skeleton → variant="list"
|
||||
* - audit-log-table-skeleton → variant="table"
|
||||
* - class-skeleton / school-skeleton → variant="form"
|
||||
* - ai-skeleton → variant="chart"
|
||||
* - dashboard-loading-skeleton → variant="stats-grid"
|
||||
* - lesson-plan-skeleton → variant="form"(备课编辑器布局)
|
||||
*/
|
||||
export function SkeletonCard({
|
||||
variant,
|
||||
rows = 5,
|
||||
className,
|
||||
}: SkeletonCardProps): ReactNode {
|
||||
switch (variant) {
|
||||
case "table":
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-1/4" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<div key={i} className="flex gap-4">
|
||||
<Skeleton className="h-4 flex-1" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
case "list":
|
||||
return (
|
||||
<div className={cn("space-y-3", className)}>
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="flex items-center gap-4 p-4">
|
||||
<Skeleton className="h-10 w-10 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-1/3" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-20" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
case "chart":
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-1/3" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-[200px] w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
case "form":
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardContent className="space-y-4 p-6">
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="h-4 w-1/4" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
))}
|
||||
<Skeleton className="h-10 w-32" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
case "stats-grid":
|
||||
return (
|
||||
<div className={cn("grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4", className)}>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<Skeleton className="h-4 w-[100px]" />
|
||||
<Skeleton className="h-4 w-4 rounded-full" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="mb-2 h-8 w-[60px]" />
|
||||
<Skeleton className="h-3 w-[140px]" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
|
||||
74
src/shared/components/ui/stats-grid.tsx
Normal file
74
src/shared/components/ui/stats-grid.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { ComponentType, ReactNode } from "react"
|
||||
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { StatCard } from "@/shared/components/ui/stat-card"
|
||||
|
||||
/**
|
||||
* 统计卡片网格容器:基于 StatCard 渲染响应式网格。
|
||||
*
|
||||
* 覆盖以下重复模式:
|
||||
* - attendance-stats-cards / error-book-stats-cards / elective-stats-cards
|
||||
* - practice-stats-cards / practice-overview-stats-cards
|
||||
* - grade-stats-card / analytics-stats-cards
|
||||
*/
|
||||
interface StatItem {
|
||||
/** 卡片标题 */
|
||||
label: string
|
||||
/** 统计数值 */
|
||||
value: string | number
|
||||
/** 图标组件 */
|
||||
icon?: ComponentType<{ className?: string }>
|
||||
/** 描述文本 */
|
||||
description?: string
|
||||
/** 图标颜色类名 */
|
||||
color?: string
|
||||
/** 是否高亮 */
|
||||
highlight?: boolean
|
||||
/** 点击跳转链接 */
|
||||
href?: string
|
||||
/** 数值自定义类名 */
|
||||
valueClassName?: string
|
||||
}
|
||||
|
||||
interface StatsGridProps {
|
||||
/** 统计项列表 */
|
||||
items: StatItem[]
|
||||
/** 列数(默认响应式:mobile=1, md=2, lg=4) */
|
||||
columns?: 2 | 3 | 4
|
||||
/** 加载态 */
|
||||
isLoading?: boolean
|
||||
/** 容器额外类名 */
|
||||
className?: string
|
||||
}
|
||||
|
||||
const columnsClass: Record<2 | 3 | 4, string> = {
|
||||
2: "grid-cols-1 md:grid-cols-2",
|
||||
3: "grid-cols-1 md:grid-cols-2 lg:grid-cols-3",
|
||||
4: "grid-cols-1 md:grid-cols-2 lg:grid-cols-4",
|
||||
}
|
||||
|
||||
export function StatsGrid({
|
||||
items,
|
||||
columns = 4,
|
||||
isLoading = false,
|
||||
className,
|
||||
}: StatsGridProps): ReactNode {
|
||||
return (
|
||||
<div className={cn("grid gap-4", columnsClass[columns], className)}>
|
||||
{items.map((item) => (
|
||||
<StatCard
|
||||
key={item.label}
|
||||
title={item.label}
|
||||
value={item.value}
|
||||
icon={item.icon}
|
||||
description={item.description}
|
||||
color={item.color}
|
||||
highlight={item.highlight}
|
||||
href={item.href}
|
||||
isLoading={isLoading}
|
||||
valueClassName={item.valueClassName}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
"use client"
|
||||
|
||||
/**
|
||||
* 通用 Widget 边界组件(shared 层)。
|
||||
* 通用 Widget 边界组件 preset(shared 层)。
|
||||
*
|
||||
* 组合三个能力:
|
||||
* 1. Error Boundary — 隔离故障域,单个 Widget 抛错不影响其他区块
|
||||
* 2. Suspense — 流式渲染时显示骨架屏,避免白屏等待
|
||||
* 3. Skeleton — 与 Widget 尺寸匹配的占位
|
||||
*
|
||||
* 基于 `ErrorBoundary` 基础组件实现。
|
||||
*
|
||||
* 用法:
|
||||
* ```tsx
|
||||
* <WidgetBoundary title="成绩趋势">
|
||||
@@ -16,11 +18,12 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { Component, Suspense, type ReactNode } from "react"
|
||||
import { Suspense, type ReactNode } from "react"
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
import { ErrorBoundary } from "@/shared/components/ui/error-boundary"
|
||||
|
||||
interface WidgetBoundaryProps {
|
||||
children: ReactNode
|
||||
@@ -34,69 +37,6 @@ interface WidgetBoundaryProps {
|
||||
retryLabel?: string
|
||||
}
|
||||
|
||||
interface WidgetBoundaryState {
|
||||
hasError: boolean
|
||||
}
|
||||
|
||||
interface WidgetErrorBoundaryProps {
|
||||
title: string
|
||||
fallbackDescription: string
|
||||
retryLabel: string
|
||||
loadFailedMessage: string
|
||||
retryAriaLabel: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
class WidgetErrorBoundary extends Component<
|
||||
WidgetErrorBoundaryProps,
|
||||
WidgetBoundaryState
|
||||
> {
|
||||
constructor(props: WidgetErrorBoundaryProps) {
|
||||
super(props)
|
||||
this.state = { hasError: false }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(): WidgetBoundaryState {
|
||||
return { hasError: true }
|
||||
}
|
||||
|
||||
handleReset = (): void => {
|
||||
this.setState({ hasError: false })
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className="flex h-full min-h-[200px] flex-col items-center justify-center gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-6 text-center"
|
||||
>
|
||||
<AlertCircle className="h-8 w-8 text-destructive" aria-hidden="true" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{this.props.loadFailedMessage}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{this.props.fallbackDescription}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={this.handleReset}
|
||||
aria-label={this.props.retryAriaLabel}
|
||||
>
|
||||
{this.props.retryLabel}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
function WidgetSkeleton({
|
||||
height,
|
||||
loadingAriaLabel,
|
||||
@@ -135,21 +75,35 @@ export function WidgetBoundary({
|
||||
const retryAriaLabel = t("widget.retryAriaLabel", { title: effectiveTitle })
|
||||
const loadingAriaLabel = t("widget.loadingAriaLabel", { title: effectiveTitle })
|
||||
|
||||
return (
|
||||
<WidgetErrorBoundary
|
||||
title={effectiveTitle}
|
||||
fallbackDescription={effectiveFallbackDescription}
|
||||
retryLabel={effectiveRetryLabel}
|
||||
loadFailedMessage={loadFailedMessage}
|
||||
retryAriaLabel={retryAriaLabel}
|
||||
const errorFallback = (_error: Error, reset: () => void): ReactNode => (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className="flex h-full min-h-[200px] flex-col items-center justify-center gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-6 text-center"
|
||||
>
|
||||
<AlertCircle className="h-8 w-8 text-destructive" aria-hidden="true" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">{loadFailedMessage}</p>
|
||||
<p className="text-xs text-muted-foreground">{effectiveFallbackDescription}</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={reset}
|
||||
aria-label={retryAriaLabel}
|
||||
>
|
||||
{effectiveRetryLabel}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<ErrorBoundary fallback={errorFallback}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<WidgetSkeleton height={skeletonHeight} loadingAriaLabel={loadingAriaLabel} />
|
||||
}
|
||||
fallback={<WidgetSkeleton height={skeletonHeight} loadingAriaLabel={loadingAriaLabel} />}
|
||||
>
|
||||
{children}
|
||||
</Suspense>
|
||||
</WidgetErrorBoundary>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user