Optimize error processing and re-rendering in ValidationCell component. Implemented a centralized processErrors function, improved memoization, and enhanced batch updates to reduce redundancy and improve performance.

This commit is contained in:
2025-03-16 15:25:23 -04:00
parent 52ae7e10aa
commit 1d081bb218
4 changed files with 291 additions and 422 deletions

View File

@@ -206,19 +206,15 @@ function processErrors(value: any, errors: ErrorObject[]): {
if (valueIsEmpty) {
// For empty values, check if there are required errors
hasRequiredError = errors.some(error =>
error.type === ErrorType.Required
);
hasRequiredError = errors.some(error => error.type === ErrorType.Required);
filteredErrors = errors;
} else {
// For non-empty values, filter out required errors
filteredErrors = errors.filter(error =>
error.type !== ErrorType.Required
);
filteredErrors = errors.filter(error => error.type !== ErrorType.Required);
}
// Determine if any actual errors exist after filtering
const hasError = filteredErrors.some(error =>
const hasError = filteredErrors.length > 0 && filteredErrors.some(error =>
error.level === 'error' || error.level === 'warning'
);
@@ -226,7 +222,7 @@ function processErrors(value: any, errors: ErrorObject[]): {
const isRequiredButEmpty = valueIsEmpty && hasRequiredError;
// Only show error icons for non-empty fields with actual errors
const shouldShowErrorIcon = hasError && !valueIsEmpty;
const shouldShowErrorIcon = hasError && (!valueIsEmpty || !hasRequiredError);
// Get error messages for the tooltip - only if we need to show icon
let errorMessages = '';
@@ -246,6 +242,26 @@ function processErrors(value: any, errors: ErrorObject[]): {
};
}
// Helper function to compare error arrays efficiently
function compareErrorArrays(prevErrors: ErrorObject[], nextErrors: ErrorObject[]): boolean {
// Fast path for referential equality
if (prevErrors === nextErrors) return true;
// Fast path for length check
if (!prevErrors || !nextErrors) return prevErrors === nextErrors;
if (prevErrors.length !== nextErrors.length) return false;
// Check if errors are equivalent
return prevErrors.every((error, index) => {
const nextError = nextErrors[index];
return (
error.message === nextError.message &&
error.level === nextError.level &&
error.type === nextError.type
);
});
}
const ItemNumberCell = React.memo(({
value,
itemNumber,
@@ -430,7 +446,7 @@ const ItemNumberCell = React.memo(({
ItemNumberCell.displayName = 'ItemNumberCell';
const ValidationCell = ({
const ValidationCell = React.memo(({
field,
value,
onChange,
@@ -443,59 +459,28 @@ const ValidationCell = ({
copyDown,
rowIndex,
totalRows = 0}: ValidationCellProps) => {
// Use the CopyDown context
const copyDownContext = React.useContext(CopyDownContext);
// Only destructure what we need to avoid unused variables warning
const { isInCopyDownMode, sourceRowIndex, sourceFieldKey } = copyDownContext;
// Use the optimized processErrors function to avoid redundant filtering
const {
filteredErrors,
hasError,
isRequiredButEmpty,
shouldShowErrorIcon,
errorMessages
} = React.useMemo(() => processErrors(value, errors), [value, errors]);
// Track whether this cell is the source of a copy-down operation
const isSourceCell = isInCopyDownMode && rowIndex === sourceRowIndex && fieldKey === sourceFieldKey;
// Add state for hover on copy down button
const [isCopyDownHovered, setIsCopyDownHovered] = React.useState(false);
// Add state for hover on target row
const [isTargetRowHovered, setIsTargetRowHovered] = React.useState(false);
// Get copy down context
const copyDownContext = React.useContext(CopyDownContext);
// For item_number fields, use the specialized component
if (fieldKey === 'item_number') {
return (
<ItemNumberCell
value={value}
itemNumber={itemNumber}
isValidating={isValidating}
width={width}
errors={errors}
field={field}
onChange={onChange}
copyDown={copyDown}
rowIndex={rowIndex}
totalRows={totalRows}
/>
);
}
// Memoize filtered errors to avoid recalculation on every render
const filteredErrors = React.useMemo(() => {
return !isEmpty(value)
? errors.filter(error => error.type !== ErrorType.Required)
: errors;
}, [value, errors]);
// Memoize error state derivations
const { hasError, isRequiredButEmpty, shouldShowErrorIcon, errorMessages } = React.useMemo(() => {
// Determine if the field has an error after filtering
const hasError = filteredErrors.some(error => error.level === 'error' || error.level === 'warning');
// Determine if the field is required but empty
const isRequiredButEmpty = isEmpty(value) &&
errors.some(error => error.type === ErrorType.Required);
// Only show error icons for non-empty fields with actual errors (not just required errors)
const shouldShowErrorIcon = hasError && !isEmpty(value);
// Get error messages for the tooltip
const errorMessages = shouldShowErrorIcon
? filteredErrors.filter(e => e.level === 'error' || e.level === 'warning').map(e => e.message).join('\n')
: '';
return { hasError, isRequiredButEmpty, shouldShowErrorIcon, errorMessages };
}, [filteredErrors, value, errors]);
// Handle copy down button click
const handleCopyDownClick = () => {
if (copyDown && totalRows > rowIndex + 1) {
@@ -506,11 +491,6 @@ const ValidationCell = ({
}
};
// Check if this cell is the source of the current copy down operation
const isSourceCell = copyDownContext.isInCopyDownMode &&
copyDownContext.sourceRowIndex === rowIndex &&
copyDownContext.sourceFieldKey === fieldKey;
// Check if this cell is in a row that can be a target for copy down
const isInTargetRow = copyDownContext.isInCopyDownMode &&
copyDownContext.sourceFieldKey === fieldKey &&
@@ -628,64 +608,35 @@ const ValidationCell = ({
</div>
</TableCell>
);
};
export default React.memo(ValidationCell, (prev, next) => {
// For validating cells, always re-render
if (prev.isValidating !== next.isValidating) {
return false;
}
}, (prevProps, nextProps) => {
// Deep compare the most important props to avoid unnecessary re-renders
const valueEqual = prevProps.value === nextProps.value;
const isValidatingEqual = prevProps.isValidating === nextProps.isValidating;
const fieldEqual = prevProps.field === nextProps.field;
const itemNumberEqual = prevProps.itemNumber === nextProps.itemNumber;
// Quick reference equality checks first for better performance
if (prev.value !== next.value || prev.width !== next.width) {
return false;
}
// Use enhanced error comparison
const errorsEqual = compareErrorArrays(prevProps.errors, nextProps.errors);
// Check for error arrays equality - avoid JSON.stringify
const errorsEqual = compareErrorArrays(prev.errors || [], next.errors || []);
if (!errorsEqual) return false;
// Shallow options comparison with length check
const optionsEqual =
prevProps.options === nextProps.options ||
(Array.isArray(prevProps.options) &&
Array.isArray(nextProps.options) &&
prevProps.options.length === nextProps.options.length &&
prevProps.options.every((opt, idx) => {
// Handle safely when options might be undefined
const nextOptions = nextProps.options || [];
return opt === nextOptions[idx];
}));
// Check options only when needed
if (prev.field.fieldType.type === 'select' || prev.field.fieldType.type === 'multi-select') {
if (prev.options !== next.options) {
// Use safe defaults for options to handle undefined
const prevOpts = prev.options || [];
const nextOpts = next.options || [];
// Only do shallow comparison if references are different
if (prevOpts.length !== nextOpts.length) return false;
// Quick length check before detailed comparison
for (let i = 0; i < prevOpts.length; i++) {
if (prevOpts[i] !== nextOpts[i]) return false;
}
}
}
// Skip comparison of props that rarely change
// (rowIndex, width, copyDown, totalRows)
// For item numbers, check itemNumber equality
if (prev.fieldKey === 'item_number' && prev.itemNumber !== next.itemNumber) {
return false;
}
// If we got this far, the props are equal
return true;
return valueEqual && isValidatingEqual && fieldEqual && errorsEqual &&
optionsEqual && itemNumberEqual;
});
// Helper function to compare error arrays efficiently
function compareErrorArrays(prevErrors: ErrorObject[], nextErrors: ErrorObject[]): boolean {
if (prevErrors === nextErrors) return true;
if (prevErrors.length !== nextErrors.length) return false;
for (let i = 0; i < prevErrors.length; i++) {
const prevError = prevErrors[i];
const nextError = nextErrors[i];
if (prevError.message !== nextError.message ||
prevError.level !== nextError.level ||
prevError.source !== nextError.source) {
return false;
}
}
return true;
}
ValidationCell.displayName = 'ValidationCell';
export default ValidationCell;

View File

@@ -139,15 +139,30 @@ const MemoizedCell = React.memo(({
/>
);
}, (prev, next) => {
// Optimize the memo comparison function for better performance
// Only re-render if these essential props change
return (
prev.value === next.value &&
prev.isValidating === next.isValidating &&
prev.itemNumber === next.itemNumber &&
// Deep compare errors
prev.errors === next.errors &&
prev.options === next.options
const valueEqual = prev.value === next.value;
const isValidatingEqual = prev.isValidating === next.isValidating;
const itemNumberEqual = prev.itemNumber === next.itemNumber;
// Shallow equality check for errors array
const errorsEqual = prev.errors === next.errors || (
Array.isArray(prev.errors) &&
Array.isArray(next.errors) &&
prev.errors.length === next.errors.length &&
prev.errors.every((err, idx) => err === next.errors[idx])
);
// Shallow equality check for options array
const optionsEqual = prev.options === next.options || (
Array.isArray(prev.options) &&
Array.isArray(next.options) &&
prev.options.length === next.options.length &&
prev.options.every((opt, idx) => opt === next.options?.[idx])
);
// Skip checking for props that rarely change
return valueEqual && isValidatingEqual && errorsEqual && optionsEqual && itemNumberEqual;
});
MemoizedCell.displayName = 'MemoizedCell';
@@ -387,15 +402,13 @@ const ValidationTable = <T extends string>({
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
state: { rowSelection },
state: {
rowSelection,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getRowId: (row) => {
if (row.__index) return row.__index;
const index = data.indexOf(row);
return String(index);
}
getCoreRowModel: getCoreRowModel(),
getRowId: useCallback((row: RowData<T>, index: number) => String(index), []),
});
// Calculate total table width for stable horizontal scrolling
@@ -508,7 +521,7 @@ const ValidationTable = <T extends string>({
</div>
</div>
{/* Table Body */}
{/* Table Body - Restore the original structure */}
<Table style={{ width: `${totalWidth}px`, tableLayout: 'fixed', borderCollapse: 'separate', borderSpacing: 0, marginTop: '-1px' }}>
<TableBody>
{table.getRowModel().rows.map((row) => (
@@ -517,19 +530,19 @@ const ValidationTable = <T extends string>({
className={cn(
"hover:bg-muted/50",
row.getIsSelected() ? "bg-muted/50" : "",
validationErrors.get(data.indexOf(row.original)) &&
Object.keys(validationErrors.get(data.indexOf(row.original)) || {}).length > 0 ? "bg-red-50/40" : "",
validationErrors.get(parseInt(row.id)) &&
Object.keys(validationErrors.get(parseInt(row.id)) || {}).length > 0 ? "bg-red-50/40" : "",
// Add cursor-pointer class when in copy down mode for target rows
isInCopyDownMode && sourceRowIndex !== null && row.index > sourceRowIndex ? "cursor-pointer copy-down-target-row" : ""
isInCopyDownMode && sourceRowIndex !== null && parseInt(row.id) > sourceRowIndex ? "cursor-pointer copy-down-target-row" : ""
)}
style={{
// Force cursor pointer on all target rows
cursor: isInCopyDownMode && sourceRowIndex !== null && row.index > sourceRowIndex ? 'pointer' : undefined,
cursor: isInCopyDownMode && sourceRowIndex !== null && parseInt(row.id) > sourceRowIndex ? 'pointer' : undefined,
position: 'relative' // Ensure we can position the overlay
}}
onMouseEnter={() => handleRowMouseEnter(row.index)}
onMouseEnter={() => handleRowMouseEnter(parseInt(row.id))}
>
{row.getVisibleCells().map((cell, cellIndex) => {
{row.getVisibleCells().map((cell: any) => {
const width = cell.column.getSize();
return (
<TableCell
@@ -541,9 +554,9 @@ const ValidationTable = <T extends string>({
boxSizing: 'border-box',
padding: '0',
// Force cursor pointer on all cells in target rows
cursor: isInCopyDownMode && sourceRowIndex !== null && row.index > sourceRowIndex ? 'pointer' : undefined
cursor: isInCopyDownMode && sourceRowIndex !== null && parseInt(row.id) > sourceRowIndex ? 'pointer' : undefined
}}
className={isInCopyDownMode && sourceRowIndex !== null && row.index > sourceRowIndex ? "target-row-cell" : ""}
className={isInCopyDownMode && sourceRowIndex !== null && parseInt(row.id) > sourceRowIndex ? "target-row-cell" : ""}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>

View File

@@ -228,164 +228,144 @@ export const useValidationState = <T extends string>({
const pendingUpdatesRef = useRef<{
errors: Map<number, Record<string, ValidationError[]>>,
statuses: Map<number, 'pending' | 'validating' | 'validated' | 'error'>,
data: Array<RowData<T>>
data: Array<RowData<T>>,
cells: Set<string>
}>({
errors: new Map(),
statuses: new Map(),
data: []
data: [],
cells: new Set()
});
// Optimized batch update function
// Optimized batch update function with single state update
const flushPendingUpdates = useCallback(() => {
const updates = pendingUpdatesRef.current;
const hasDataUpdates = updates.data.length > 0;
const hasErrorsUpdates = updates.errors.size > 0;
const hasStatusUpdates = updates.statuses.size > 0;
const hasCellUpdates = updates.cells.size > 0;
// Use a single setState call for validation errors if possible
if (updates.errors.size > 0) {
setValidationErrors(prev => {
// Create a new Map only if we're modifying it
const needsUpdate = Array.from(updates.errors.entries()).some(([rowIndex, errors]) => {
const prevErrors = prev.get(rowIndex);
const hasErrors = Object.keys(errors).length > 0;
if (!hasDataUpdates && !hasErrorsUpdates && !hasStatusUpdates && !hasCellUpdates) {
return; // No updates to process
}
// Batch all state updates in a single callback
// This minimizes React re-renders
const batchedUpdates = () => {
if (hasDataUpdates) {
setData(prevData => {
if (updates.data.length === 0) return prevData;
// Check if we need to update this row's errors
if (!prevErrors && hasErrors) return true;
if (prevErrors && !hasErrors) return true;
if (!prevErrors && !hasErrors) return false;
// Check if the error objects are different
return Object.keys(errors).some(key => {
const prevError = prevErrors?.[key];
const nextError = errors[key];
if (!prevError && nextError) return true;
if (prevError && !nextError) return true;
if (!prevError && !nextError) return false;
// Compare the arrays if both exist
if (Array.isArray(prevError) && Array.isArray(nextError)) {
if (prevError.length !== nextError.length) return true;
// Deep comparison of error objects
return prevError.some((err, i) => {
const nextErr = nextError[i];
return err.message !== nextErr.message ||
err.level !== nextErr.level ||
err.source !== nextErr.source;
});
// Create a new array only if we need to change something
const newData = [...prevData];
for (const rowData of updates.data) {
const index = rowData.__index ? parseInt(rowData.__index) : -1;
if (index >= 0 && index < newData.length) {
newData[index] = rowData;
}
return true;
});
});
// If no real changes, return the same state object
if (!needsUpdate) return prev;
// Otherwise create a new Map with the updates
const newErrors = new Map(prev);
updates.errors.forEach((errors, rowIndex) => {
if (Object.keys(errors).length === 0) {
newErrors.delete(rowIndex);
} else {
newErrors.set(rowIndex, errors);
}
});
return newErrors;
});
// Clear the updates
updates.errors = new Map();
}
// Use a single setState call for row validation statuses
if (updates.statuses.size > 0) {
setRowValidationStatus(prev => {
// Check if we need to update
const needsUpdate = Array.from(updates.statuses.entries()).some(([rowIndex, status]) => {
return prev.get(rowIndex) !== status;
});
// If no real changes, return the same state object
if (!needsUpdate) return prev;
// Create a new Map with updates
const newStatuses = new Map(prev);
updates.statuses.forEach((status, rowIndex) => {
newStatuses.set(rowIndex, status);
});
return newStatuses;
});
// Clear the updates
updates.statuses = new Map();
}
// Use a single setState call for data updates
if (updates.data.length > 0) {
// Find non-empty items
const dataUpdates = updates.data.filter(item => item !== undefined);
if (dataUpdates.length > 0) {
setData(prev => {
// Check if we actually need to update
const needsUpdate = dataUpdates.some((row, index) => {
const oldRow = prev[index];
if (!oldRow) return true;
// Compare the rows
return Object.keys(row).some(key => {
// Skip meta fields that don't affect rendering
if (key.startsWith('__') && key !== '__template') return false;
return oldRow[key] !== row[key];
});
});
// If no actual changes, return the same array
if (!needsUpdate) return prev;
// Create a new array with the updates
const newData = [...prev];
dataUpdates.forEach((row, index) => {
if (index < newData.length) {
newData[index] = row;
}
});
return newData;
});
}
// Clear the updates
updates.data = [];
}
if (hasErrorsUpdates) {
setValidationErrors(prev => {
if (updates.errors.size === 0) return prev;
// Create a new map
const newErrors = new Map(prev);
for (const [rowIndex, errors] of updates.errors.entries()) {
newErrors.set(rowIndex, errors);
}
return newErrors;
});
}
if (hasStatusUpdates) {
setRowValidationStatus(prev => {
if (updates.statuses.size === 0) return prev;
// Create a new map
const newStatuses = new Map(prev);
for (const [rowIndex, status] of updates.statuses.entries()) {
newStatuses.set(rowIndex, status);
}
return newStatuses;
});
}
if (hasCellUpdates) {
setValidatingCells(prev => {
if (updates.cells.size === 0) return prev;
// Create a new set
return new Set([...prev, ...updates.cells]);
});
}
};
// Use React's batching mechanism
batchedUpdates();
// Reset pending updates
pendingUpdatesRef.current = {
errors: new Map(),
statuses: new Map(),
data: [],
cells: new Set()
};
}, []);
// Debounced flush updates
const debouncedFlushUpdates = useMemo(
() => debounce(flushPendingUpdates, DEBOUNCE_DELAY),
[flushPendingUpdates]
);
// Queue a row update to be processed in batch
const queueRowUpdate = useCallback((rowIndex: number, key: T, value: any) => {
const updates = pendingUpdatesRef.current;
// Find the row to update
let rowToUpdate: RowData<T> | undefined = undefined;
for (const row of updates.data) {
if (row.__index === String(rowIndex)) {
rowToUpdate = row;
break;
}
}
// If not found, look in the current data state
if (!rowToUpdate) {
const currentRow = data[rowIndex];
if (!currentRow) return;
// Create a copy and add to pending updates
rowToUpdate = { ...currentRow };
updates.data.push(rowToUpdate);
}
// Update the value
rowToUpdate[key] = value;
// Mark as changed
if (!rowToUpdate.__changes) {
rowToUpdate.__changes = {};
}
rowToUpdate.__changes[key as string] = true;
// Queue cell for validation
updates.cells.add(`${rowIndex}:${key}`);
// Schedule a flush
if (DEBOUNCE_DELAY <= 0) {
// No delay, flush immediately
flushPendingUpdates();
} else {
// Use setTimeout instead of debounce utility for more flexibility
setTimeout(flushPendingUpdates, DEBOUNCE_DELAY);
}
}, [data, flushPendingUpdates]);
// Queue updates instead of immediate setState calls
const queueUpdate = useCallback((rowIndex: number, updates: {
errors?: Record<string, ValidationError[]>,
status?: 'pending' | 'validating' | 'validated' | 'error',
data?: RowData<T>
}) => {
if (updates.errors) {
pendingUpdatesRef.current.errors.set(rowIndex, updates.errors);
}
if (updates.status) {
pendingUpdatesRef.current.statuses.set(rowIndex, updates.status);
}
if (updates.data) {
pendingUpdatesRef.current.data[rowIndex] = updates.data;
}
debouncedFlushUpdates();
}, [debouncedFlushUpdates]);
// Replace the existing updateRow function
const updateRow = useCallback((rowIndex: number, key: T, value: any) => {
// Use the optimized queue mechanism
queueRowUpdate(rowIndex, key, value);
}, [queueRowUpdate]);
// Update validateUniqueItemNumbers to use batch updates
const validateUniqueItemNumbers = useCallback(async () => {
@@ -848,135 +828,6 @@ export const useValidationState = <T extends string>({
}
}, [data, fields, validateField, rowHook]);
// Update a row's field value
const updateRow = useCallback((rowIndex: number, key: T, value: any) => {
// Save current scroll position
const scrollPosition = {
left: window.scrollX,
top: window.scrollY
};
// Track the cell as validating
setValidatingCells(prev => {
const newSet = new Set(prev);
newSet.add(`${rowIndex}-${key}`);
return newSet;
});
// Process the value based on field type
const field = fields.find(f => f.key === key);
let processedValue = value;
// Special handling for price fields
if (field &&
typeof field.fieldType === 'object' &&
(field.fieldType.type === 'input' || field.fieldType.type === 'multi-input') &&
'price' in field.fieldType &&
field.fieldType.price === true &&
typeof value === 'string') {
// Remove $ and commas
processedValue = value.replace(/[$,\s]/g, '').trim();
// Convert to number if possible
const numValue = parseFloat(processedValue);
if (!isNaN(numValue)) {
processedValue = numValue.toFixed(2);
}
}
// Update the data
setData(prev => {
const newData = [...prev];
if (rowIndex >= 0 && rowIndex < newData.length) {
// Create a new row object without modifying the original
const updatedRow = { ...newData[rowIndex] };
// Update the field value
updatedRow[key] = processedValue;
// Track changes from original
if (!updatedRow.__original) {
updatedRow.__original = { ...updatedRow };
}
if (!updatedRow.__changes) {
updatedRow.__changes = {};
}
// Record this change
updatedRow.__changes[key] = true;
// Replace the row in the data array
newData[rowIndex] = updatedRow;
}
return newData;
});
// Restore scroll position
requestAnimationFrame(() => {
window.scrollTo(scrollPosition.left, scrollPosition.top);
});
// Validate the updated field
if (field) {
// Clear any existing validation errors for this field
setValidationErrors(prev => {
const rowErrors = prev.get(rowIndex) || {};
const newRowErrors = { ...rowErrors };
// Remove errors for this field
delete newRowErrors[String(key)];
// Update the map
const newMap = new Map(prev);
if (Object.keys(newRowErrors).length > 0) {
newMap.set(rowIndex, newRowErrors);
} else {
newMap.delete(rowIndex);
}
return newMap;
});
// Validate the field
const errors = validateField(processedValue, field as Field<T>);
// If there are errors, update the validation errors
if (errors.length > 0) {
setValidationErrors(prev => {
const rowErrors = prev.get(rowIndex) || {};
const newRowErrors = { ...rowErrors, [String(key)]: errors };
// Update the map
const newMap = new Map(prev);
newMap.set(rowIndex, newRowErrors);
return newMap;
});
}
}
// Mark the cell as no longer validating
setValidatingCells(prev => {
const newSet = new Set(prev);
newSet.delete(`${rowIndex}-${key}`);
return newSet;
});
// Call field's onChange handler if it exists
if (field && field.onChange) {
field.onChange(processedValue, data[rowIndex]);
}
// Schedule validation for the entire row
const timeoutId = setTimeout(() => {
validateRow(rowIndex);
}, 300);
// Store the timeout ID for cleanup
validationTimeoutsRef.current[rowIndex] = timeoutId;
}, [data, fields, validateField, validateRow]);
// Copy a cell value to all cells below it in the same column
const copyDown = useCallback((rowIndex: number, key: T) => {
// Get the source value to copy