|
6 | 6 | * LICENSE file in the root directory of this source tree. |
7 | 7 | */ |
8 | 8 |
|
9 | | -import React, { forwardRef, useContext, useMemo } from 'react'; |
| 9 | +import React, { forwardRef, useContext, useMemo, Component } from 'react'; |
10 | 10 | import { SchemaNode, ComponentRegistry, ExpressionEvaluator } from '@object-ui/core'; |
11 | 11 | import { SchemaRendererContext } from './context/SchemaRendererContext'; |
12 | 12 | import { resolveI18nLabel } from './utils/i18n'; |
@@ -34,6 +34,51 @@ function resolveAriaProps(schema: Record<string, any>): Record<string, string | |
34 | 34 | return aria; |
35 | 35 | } |
36 | 36 |
|
| 37 | +/** |
| 38 | + * Per-component Error Boundary for SchemaRenderer. |
| 39 | + * Catches render errors in individual components, preventing one broken |
| 40 | + * component from crashing the entire page. |
| 41 | + */ |
| 42 | +interface SchemaErrorBoundaryState { |
| 43 | + hasError: boolean; |
| 44 | + error: Error | null; |
| 45 | +} |
| 46 | + |
| 47 | +export class SchemaErrorBoundary extends Component< |
| 48 | + { componentType?: string; children: React.ReactNode }, |
| 49 | + SchemaErrorBoundaryState |
| 50 | +> { |
| 51 | + state: SchemaErrorBoundaryState = { hasError: false, error: null }; |
| 52 | + |
| 53 | + static getDerivedStateFromError(error: Error): SchemaErrorBoundaryState { |
| 54 | + return { hasError: true, error }; |
| 55 | + } |
| 56 | + |
| 57 | + handleRetry = () => { |
| 58 | + this.setState({ hasError: false, error: null }); |
| 59 | + }; |
| 60 | + |
| 61 | + render() { |
| 62 | + if (this.state.hasError && this.state.error) { |
| 63 | + return ( |
| 64 | + <div className="p-4 border border-orange-400 rounded bg-orange-50 text-orange-700 my-2" role="alert"> |
| 65 | + <p className="font-medium"> |
| 66 | + Component{this.props.componentType ? ` "${this.props.componentType}"` : ''} failed to render |
| 67 | + </p> |
| 68 | + <p className="text-sm mt-1">{this.state.error.message}</p> |
| 69 | + <button |
| 70 | + onClick={this.handleRetry} |
| 71 | + className="mt-2 text-sm underline hover:no-underline" |
| 72 | + > |
| 73 | + Retry |
| 74 | + </button> |
| 75 | + </div> |
| 76 | + ); |
| 77 | + } |
| 78 | + return this.props.children; |
| 79 | + } |
| 80 | +} |
| 81 | + |
37 | 82 | export const SchemaRenderer = forwardRef<any, { schema: SchemaNode } & Record<string, any>>(({ schema, ...props }, _ref) => { |
38 | 83 | const context = useContext(SchemaRendererContext); |
39 | 84 | const dataSource = context?.dataSource || {}; |
@@ -105,15 +150,19 @@ export const SchemaRenderer = forwardRef<any, { schema: SchemaNode } & Record<st |
105 | 150 | // Extract AriaPropsSchema properties for accessibility |
106 | 151 | const ariaProps = resolveAriaProps(evaluatedSchema); |
107 | 152 |
|
108 | | - return React.createElement(Component, { |
109 | | - schema: evaluatedSchema, |
110 | | - ...componentProps, // Spread non-metadata schema properties as props |
111 | | - ...(evaluatedSchema.props || {}), // Override with explicit props if provided |
112 | | - ...ariaProps, // Inject ARIA attributes from AriaPropsSchema |
113 | | - className: evaluatedSchema.className, |
114 | | - 'data-obj-id': evaluatedSchema.id, |
115 | | - 'data-obj-type': evaluatedSchema.type, |
116 | | - ...props |
117 | | - }); |
| 153 | + return ( |
| 154 | + <SchemaErrorBoundary componentType={evaluatedSchema.type}> |
| 155 | + {React.createElement(Component, { |
| 156 | + schema: evaluatedSchema, |
| 157 | + ...componentProps, // Spread non-metadata schema properties as props |
| 158 | + ...(evaluatedSchema.props || {}), // Override with explicit props if provided |
| 159 | + ...ariaProps, // Inject ARIA attributes from AriaPropsSchema |
| 160 | + className: evaluatedSchema.className, |
| 161 | + 'data-obj-id': evaluatedSchema.id, |
| 162 | + 'data-obj-type': evaluatedSchema.type, |
| 163 | + ...props |
| 164 | + })} |
| 165 | + </SchemaErrorBoundary> |
| 166 | + ); |
118 | 167 | }); |
119 | 168 | SchemaRenderer.displayName = 'SchemaRenderer'; |
0 commit comments