@@ -60,6 +60,39 @@ export interface CellRendererProps {
6060 onChange ?: ( value : any ) => void ;
6161}
6262
63+ /**
64+ * Coerce a value to a safe primitive for rendering.
65+ * Handles MongoDB wrapper types ($numberDecimal, $oid, $date), expanded
66+ * reference objects, and arrays so that no raw object is ever passed as
67+ * a React child — preventing React error #310.
68+ */
69+ export function coerceToSafeValue ( value : unknown ) : string | number | boolean | null | undefined {
70+ if ( value == null ) return value as null | undefined ;
71+ if ( typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' ) return value ;
72+ if ( value instanceof Date ) return value . toISOString ( ) ;
73+ if ( Array . isArray ( value ) ) {
74+ return value . map ( ( v ) => {
75+ if ( v != null && typeof v === 'object' ) {
76+ const obj = v as Record < string , unknown > ;
77+ return String ( obj . name || obj . label || obj . _id || '[Object]' ) ;
78+ }
79+ return String ( v ) ;
80+ } ) . join ( ', ' ) ;
81+ }
82+ if ( typeof value === 'object' ) {
83+ const obj = value as Record < string , unknown > ;
84+ // MongoDB numeric wrapper: { $numberDecimal: "250000" }
85+ if ( '$numberDecimal' in obj ) return Number ( obj . $numberDecimal ) ;
86+ // MongoDB ObjectId wrapper: { $oid: "abc123" }
87+ if ( '$oid' in obj ) return String ( obj . $oid ) ;
88+ // MongoDB date wrapper: { $date: "2024-01-01T00:00:00Z" }
89+ if ( '$date' in obj ) return String ( obj . $date ) ;
90+ // Expanded reference / general object: extract name/label/_id
91+ return String ( obj . name || obj . label || obj . _id || '[Object]' ) ;
92+ }
93+ return String ( value ) ;
94+ }
95+
6396/**
6497 * Format currency value
6598 */
@@ -192,7 +225,8 @@ export function formatDateTime(value: string | Date): string {
192225 * Text field cell renderer
193226 */
194227export function TextCellRenderer ( { value } : CellRendererProps ) : React . ReactElement {
195- return < span className = "truncate" > { ( value != null && value !== '' ) ? String ( value ) : '-' } </ span > ;
228+ const safe = coerceToSafeValue ( value ) ;
229+ return < span className = "truncate" > { ( safe != null && safe !== '' ) ? String ( safe ) : '-' } </ span > ;
196230}
197231
198232/**
@@ -201,11 +235,13 @@ export function TextCellRenderer({ value }: CellRendererProps): React.ReactEleme
201235export function NumberCellRenderer ( { value, field } : CellRendererProps ) : React . ReactElement {
202236 if ( value == null ) return < span className = "text-muted-foreground" > -</ span > ;
203237
238+ const safe = coerceToSafeValue ( value ) ;
204239 const numField = field as any ;
205240 const precision = numField . precision ?? 0 ;
206- const formatted = typeof value === 'number'
207- ? new Intl . NumberFormat ( 'en-US' , { minimumFractionDigits : precision , maximumFractionDigits : precision } ) . format ( value )
208- : value ;
241+ const num = Number ( safe ) ;
242+ const formatted = ! isNaN ( num )
243+ ? new Intl . NumberFormat ( 'en-US' , { minimumFractionDigits : precision , maximumFractionDigits : precision } ) . format ( num )
244+ : String ( safe ) ;
209245
210246 return < span className = "tabular-nums" > { formatted } </ span > ;
211247}
@@ -216,9 +252,11 @@ export function NumberCellRenderer({ value, field }: CellRendererProps): React.R
216252export function CurrencyCellRenderer ( { value, field } : CellRendererProps ) : React . ReactElement {
217253 if ( value == null ) return < span className = "text-muted-foreground" > -</ span > ;
218254
255+ const safe = coerceToSafeValue ( value ) ;
219256 const currencyField = field as any ;
220257 const currency = currencyField . currency || 'USD' ;
221- const formatted = formatCurrency ( Number ( value ) , currency ) ;
258+ const num = Number ( safe ) ;
259+ const formatted = ! isNaN ( num ) ? formatCurrency ( num , currency ) : String ( safe ) ;
222260
223261 return < span className = "tabular-nums font-medium whitespace-nowrap" > { formatted } </ span > ;
224262}
@@ -232,9 +270,13 @@ const WHOLE_PERCENT_FIELD_PATTERN = /progress|completion/;
232270export function PercentCellRenderer ( { value, field } : CellRendererProps ) : React . ReactElement {
233271 if ( value == null ) return < span className = "text-muted-foreground" > -</ span > ;
234272
273+ const safe = coerceToSafeValue ( value ) ;
235274 const percentField = field as any ;
236275 const precision = percentField . precision ?? 0 ;
237- const numValue = Number ( value ) ;
276+ const numValue = Number ( safe ) ;
277+ if ( isNaN ( numValue ) ) {
278+ return < span className = "tabular-nums whitespace-nowrap" > { String ( safe ) } </ span > ;
279+ }
238280 // Use field name to disambiguate 0-1 fraction vs 0-100 whole number:
239281 // Fields like "progress" or "completion" store values as 0-100, not 0-1
240282 const isWholePercentField = WHOLE_PERCENT_FIELD_PATTERN . test ( field ?. name ?. toLowerCase ( ) || '' ) ;
@@ -319,17 +361,18 @@ export function BooleanCellRenderer({ value, field }: CellRendererProps): React.
319361 */
320362export function DateCellRenderer ( { value, field } : CellRendererProps ) : React . ReactElement {
321363 if ( ! value ) return < span className = "text-muted-foreground" > -</ span > ;
364+ const safe = coerceToSafeValue ( value ) ;
322365 const dateField = field as any ;
323366 const style = dateField . format || 'relative' ;
324- const formatted = formatDate ( value , style ) ;
367+ const formatted = formatDate ( safe as string | Date , style ) ;
325368
326369 // Determine if date is overdue (in the past)
327- const date = typeof value === 'string' ? new Date ( value ) : value ;
370+ const date = typeof safe === 'string' ? new Date ( safe ) : safe ;
328371 const isValidDate = date instanceof Date && ! isNaN ( date . getTime ( ) ) ;
329372 const startOfToday = new Date ( ) ;
330373 startOfToday . setHours ( 0 , 0 , 0 , 0 ) ;
331374 const isOverdue = isValidDate && date < startOfToday ;
332- const isoString = isValidDate ? date . toISOString ( ) : String ( value ) ;
375+ const isoString = isValidDate ? date . toISOString ( ) : String ( safe ) ;
333376
334377 return (
335378 < span
@@ -346,8 +389,9 @@ export function DateCellRenderer({ value, field }: CellRendererProps): React.Rea
346389 */
347390export function DateTimeCellRenderer ( { value } : CellRendererProps ) : React . ReactElement {
348391 if ( ! value ) return < span className = "text-muted-foreground" > -</ span > ;
349- const date = typeof value === 'string' ? new Date ( value ) : value ;
350- if ( isNaN ( date . getTime ( ) ) ) return < span className = "text-muted-foreground" > -</ span > ;
392+ const safe = coerceToSafeValue ( value ) ;
393+ const date = typeof safe === 'string' ? new Date ( safe ) : safe ;
394+ if ( ! ( date instanceof Date ) || isNaN ( date . getTime ( ) ) ) return < span className = "text-muted-foreground" > -</ span > ;
351395
352396 const datePart = date . toLocaleDateString ( undefined , {
353397 month : 'numeric' ,
@@ -478,12 +522,13 @@ export function SelectCellRenderer({ value, field }: CellRendererProps): React.R
478522export function EmailCellRenderer ( { value } : CellRendererProps ) : React . ReactElement {
479523 if ( ! value ) return < span > -</ span > ;
480524
525+ const safe = String ( coerceToSafeValue ( value ) ?? '' ) ;
481526 const [ copied , setCopied ] = React . useState ( false ) ;
482527
483528 const handleCopy = ( e : React . MouseEvent ) => {
484529 e . stopPropagation ( ) ;
485530 e . preventDefault ( ) ;
486- navigator . clipboard . writeText ( String ( value ) ) . then ( ( ) => {
531+ navigator . clipboard . writeText ( safe ) . then ( ( ) => {
487532 setCopied ( true ) ;
488533 setTimeout ( ( ) => setCopied ( false ) , 2000 ) ;
489534 } ) . catch ( ( ) => { /* clipboard not available */ } ) ;
@@ -497,10 +542,10 @@ export function EmailCellRenderer({ value }: CellRendererProps): React.ReactElem
497542 asChild
498543 >
499544 < a
500- href = { `mailto:${ value } ` }
545+ href = { `mailto:${ safe } ` }
501546 onClick = { ( e ) => e . stopPropagation ( ) }
502547 >
503- { value }
548+ { safe }
504549 </ a >
505550 </ Button >
506551 < button
@@ -525,19 +570,20 @@ export function EmailCellRenderer({ value }: CellRendererProps): React.ReactElem
525570export function UrlCellRenderer ( { value } : CellRendererProps ) : React . ReactElement {
526571 if ( ! value ) return < span > -</ span > ;
527572
573+ const safe = String ( coerceToSafeValue ( value ) ?? '' ) ;
528574 return (
529575 < Button
530576 variant = "link"
531577 className = "p-0 h-auto font-normal text-blue-600 hover:text-blue-800"
532578 asChild
533579 >
534580 < a
535- href = { value }
581+ href = { safe }
536582 target = "_blank"
537583 rel = "noopener noreferrer"
538584 onClick = { ( e ) => e . stopPropagation ( ) }
539585 >
540- { value }
586+ { safe }
541587 </ a >
542588 </ Button >
543589 ) ;
@@ -549,12 +595,13 @@ export function UrlCellRenderer({ value }: CellRendererProps): React.ReactElemen
549595export function PhoneCellRenderer ( { value } : CellRendererProps ) : React . ReactElement {
550596 if ( ! value ) return < span > -</ span > ;
551597
598+ const safe = String ( coerceToSafeValue ( value ) ?? '' ) ;
552599 const [ copied , setCopied ] = React . useState ( false ) ;
553600
554601 const handleCopy = ( e : React . MouseEvent ) => {
555602 e . stopPropagation ( ) ;
556603 e . preventDefault ( ) ;
557- navigator . clipboard . writeText ( String ( value ) ) . then ( ( ) => {
604+ navigator . clipboard . writeText ( safe ) . then ( ( ) => {
558605 setCopied ( true ) ;
559606 setTimeout ( ( ) => setCopied ( false ) , 2000 ) ;
560607 } ) . catch ( ( ) => { /* clipboard not available */ } ) ;
@@ -563,12 +610,12 @@ export function PhoneCellRenderer({ value }: CellRendererProps): React.ReactElem
563610 return (
564611 < span className = "inline-flex items-center gap-1 group/phone" >
565612 < a
566- href = { `tel:${ value } ` }
613+ href = { `tel:${ safe } ` }
567614 className = "inline-flex items-center gap-1 text-blue-600 hover:text-blue-800"
568615 onClick = { ( e ) => e . stopPropagation ( ) }
569616 >
570617 < PhoneIcon className = "h-3 w-3" />
571- { value }
618+ { safe }
572619 </ a >
573620 < button
574621 type = "button"
@@ -697,9 +744,10 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
697744 * Formula field cell renderer (read-only)
698745 */
699746export function FormulaCellRenderer ( { value } : CellRendererProps ) : React . ReactElement {
747+ const safe = coerceToSafeValue ( value ) ;
700748 return (
701749 < span className = "text-gray-700 font-mono text-sm" >
702- { value != null ? String ( value ) : '-' }
750+ { safe != null ? String ( safe ) : '-' }
703751 </ span >
704752 ) ;
705753}
0 commit comments