Skip to content

Commit 9e2b980

Browse files
Copilothotlong
andcommitted
fix: add coerceToSafeValue to cell renderers at source, revert HeaderHighlight to use getCellRenderer
The proper architectural fix for React error #310: instead of working around unsafe renderers in HeaderHighlight, fix the renderers themselves. Added coerceToSafeValue() utility in @object-ui/fields that safely coerces MongoDB wrapper types ($numberDecimal, $oid, $date), expanded reference objects, arrays, and Date instances to primitive values. Applied coerceToSafeValue in: NumberCellRenderer, CurrencyCellRenderer, PercentCellRenderer, TextCellRenderer, EmailCellRenderer, UrlCellRenderer, PhoneCellRenderer, FormulaCellRenderer, DateCellRenderer, DateTimeCellRenderer. HeaderHighlight reverted to use getCellRenderer for type-aware rendering, since all renderers are now safe against non-primitive values. 16 new tests for coerceToSafeValue and cell renderer object safety. Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 23350ed commit 9e2b980

File tree

4 files changed

+228
-160
lines changed

4 files changed

+228
-160
lines changed

packages/fields/src/__tests__/cell-renderers.test.tsx

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1041,3 +1041,132 @@ describe('PercentCellRenderer progress-type fields', () => {
10411041
expect(bar).toHaveAttribute('aria-valuenow', '50');
10421042
});
10431043
});
1044+
1045+
// =========================================================================
1046+
// Object value safety (coerceToSafeValue) — prevents React error #310
1047+
// =========================================================================
1048+
import {
1049+
NumberCellRenderer,
1050+
CurrencyCellRenderer,
1051+
FormulaCellRenderer,
1052+
coerceToSafeValue,
1053+
} from '../index';
1054+
1055+
describe('coerceToSafeValue', () => {
1056+
it('should pass through primitives unchanged', () => {
1057+
expect(coerceToSafeValue('hello')).toBe('hello');
1058+
expect(coerceToSafeValue(42)).toBe(42);
1059+
expect(coerceToSafeValue(true)).toBe(true);
1060+
expect(coerceToSafeValue(null)).toBe(null);
1061+
expect(coerceToSafeValue(undefined)).toBe(undefined);
1062+
});
1063+
1064+
it('should extract number from MongoDB $numberDecimal', () => {
1065+
expect(coerceToSafeValue({ $numberDecimal: '250000' })).toBe(250000);
1066+
});
1067+
1068+
it('should extract string from MongoDB $oid', () => {
1069+
expect(coerceToSafeValue({ $oid: 'abc123' })).toBe('abc123');
1070+
});
1071+
1072+
it('should extract string from MongoDB $date', () => {
1073+
expect(coerceToSafeValue({ $date: '2024-01-01T00:00:00Z' })).toBe('2024-01-01T00:00:00Z');
1074+
});
1075+
1076+
it('should extract name from expanded reference object', () => {
1077+
expect(coerceToSafeValue({ _id: 'x', name: 'Acme Corp' })).toBe('Acme Corp');
1078+
});
1079+
1080+
it('should extract label when name is not present', () => {
1081+
expect(coerceToSafeValue({ _id: 'x', label: 'Active' })).toBe('Active');
1082+
});
1083+
1084+
it('should fall back to _id when no name/label', () => {
1085+
expect(coerceToSafeValue({ _id: 'abc123' })).toBe('abc123');
1086+
});
1087+
1088+
it('should handle arrays of primitives', () => {
1089+
expect(coerceToSafeValue(['a', 'b', 'c'])).toBe('a, b, c');
1090+
});
1091+
1092+
it('should handle arrays of objects', () => {
1093+
expect(coerceToSafeValue([{ name: 'Alice' }, { name: 'Bob' }])).toBe('Alice, Bob');
1094+
});
1095+
1096+
it('should convert Date to ISO string', () => {
1097+
const d = new Date('2024-06-15T12:00:00Z');
1098+
expect(coerceToSafeValue(d)).toBe('2024-06-15T12:00:00.000Z');
1099+
});
1100+
});
1101+
1102+
describe('NumberCellRenderer object safety', () => {
1103+
it('should handle MongoDB $numberDecimal without crashing', () => {
1104+
const { container } = render(
1105+
<NumberCellRenderer
1106+
value={{ $numberDecimal: '250000' }}
1107+
field={{ name: 'amount', type: 'number' } as any}
1108+
/>
1109+
);
1110+
expect(container.innerHTML).not.toBe('');
1111+
expect(screen.getByText('250,000')).toBeInTheDocument();
1112+
});
1113+
1114+
it('should handle expanded reference object without crashing', () => {
1115+
const { container } = render(
1116+
<NumberCellRenderer
1117+
value={{ _id: 'abc', name: 'Not a number' }}
1118+
field={{ name: 'amount', type: 'number' } as any}
1119+
/>
1120+
);
1121+
expect(container.innerHTML).not.toBe('');
1122+
// Should render the extracted name string, not crash
1123+
expect(screen.getByText('Not a number')).toBeInTheDocument();
1124+
});
1125+
});
1126+
1127+
describe('CurrencyCellRenderer object safety', () => {
1128+
it('should handle MongoDB $numberDecimal', () => {
1129+
const { container } = render(
1130+
<CurrencyCellRenderer
1131+
value={{ $numberDecimal: '5000' }}
1132+
field={{ name: 'price', type: 'currency', currency: 'USD' } as any}
1133+
/>
1134+
);
1135+
expect(container.innerHTML).not.toBe('');
1136+
expect(screen.getByText(/5,000/)).toBeInTheDocument();
1137+
});
1138+
});
1139+
1140+
describe('TextCellRenderer object safety', () => {
1141+
it('should extract name from object instead of [object Object]', () => {
1142+
render(
1143+
<TextCellRenderer
1144+
value={{ _id: 'abc', name: 'Acme Corp' }}
1145+
field={{ name: 'company', type: 'text' } as any}
1146+
/>
1147+
);
1148+
expect(screen.getByText('Acme Corp')).toBeInTheDocument();
1149+
});
1150+
1151+
it('should handle arrays of objects', () => {
1152+
render(
1153+
<TextCellRenderer
1154+
value={[{ name: 'Alice' }, { name: 'Bob' }]}
1155+
field={{ name: 'contacts', type: 'text' } as any}
1156+
/>
1157+
);
1158+
expect(screen.getByText('Alice, Bob')).toBeInTheDocument();
1159+
});
1160+
});
1161+
1162+
describe('FormulaCellRenderer object safety', () => {
1163+
it('should extract value from MongoDB $numberDecimal', () => {
1164+
render(
1165+
<FormulaCellRenderer
1166+
value={{ $numberDecimal: '42.5' }}
1167+
field={{ name: 'calc', type: 'formula' } as any}
1168+
/>
1169+
);
1170+
expect(screen.getByText('42.5')).toBeInTheDocument();
1171+
});
1172+
});

packages/fields/src/index.tsx

Lines changed: 68 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*/
194227
export 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
201235
export 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
216252
export 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/;
232270
export 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
*/
320362
export 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
*/
347390
export 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
478522
export 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
525570
export 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
549595
export 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
*/
699746
export 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

Comments
 (0)