538 lines
16 KiB
Markdown
538 lines
16 KiB
Markdown
# ValidationTable Scroll Position Issue
|
|
|
|
## Problem Description
|
|
|
|
The `ValidationTable` component in the inventory application suffers from a persistent scroll position issue. When the table content updates or re-renders, the scroll position resets to the top left corner. This creates a poor user experience, especially when users are working with large datasets and need to maintain their position while making edits or filtering data.
|
|
|
|
Specific behaviors:
|
|
- Scroll position resets to the top left corner during re-renders
|
|
- User loses their place in the table when data is updated
|
|
- The table does not preserve vertical or horizontal scroll position
|
|
|
|
## Relevant Files
|
|
|
|
- **`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationTable.tsx`**
|
|
- Main component that renders the validation table
|
|
- Handles scroll position management
|
|
|
|
- **`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationContainer.tsx`**
|
|
- Parent component that wraps ValidationTable
|
|
- Creates an EnhancedValidationTable wrapper component
|
|
- Manages data and state for the validation table
|
|
|
|
- **`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidationState.tsx`**
|
|
- Provides state management and data manipulation functions
|
|
- Contains scroll-related code in the `updateRow` function
|
|
|
|
- **`inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationCell.tsx`**
|
|
- Renders individual cells in the table
|
|
- May influence re-renders that affect scroll position
|
|
|
|
## Failed Attempts
|
|
|
|
We've tried multiple approaches to fix the scroll position issue, none of which have been successful:
|
|
|
|
### 1. Using Refs for Scroll Position
|
|
|
|
```typescript
|
|
const scrollPosition = useRef({
|
|
left: 0,
|
|
top: 0
|
|
});
|
|
|
|
// Capture position on scroll
|
|
const handleScroll = useCallback(() => {
|
|
if (tableContainerRef.current) {
|
|
scrollPosition.current = {
|
|
left: tableContainerRef.current.scrollLeft,
|
|
top: tableContainerRef.current.scrollTop
|
|
};
|
|
}
|
|
}, []);
|
|
|
|
// Restore in useLayoutEffect
|
|
useLayoutEffect(() => {
|
|
const container = tableContainerRef.current;
|
|
if (container) {
|
|
const { left, top } = scrollPosition.current;
|
|
if (left || top) {
|
|
container.scrollLeft = left;
|
|
container.scrollTop = top;
|
|
}
|
|
}
|
|
});
|
|
```
|
|
|
|
Result: Scroll position was still lost during updates.
|
|
|
|
### 2. Multiple Restoration Attempts with Timeouts
|
|
|
|
```typescript
|
|
// Multiple timeouts at different intervals
|
|
setTimeout(() => {
|
|
if (tableContainerRef.current) {
|
|
tableContainerRef.current.scrollTop = savedPosition.top;
|
|
tableContainerRef.current.scrollLeft = savedPosition.left;
|
|
}
|
|
}, 0);
|
|
|
|
setTimeout(() => {
|
|
if (tableContainerRef.current) {
|
|
tableContainerRef.current.scrollTop = savedPosition.top;
|
|
tableContainerRef.current.scrollLeft = savedPosition.left;
|
|
}
|
|
}, 50);
|
|
|
|
// Additional timeouts at 100ms, 300ms
|
|
```
|
|
|
|
Result: Still not reliable, scroll position would reset between timeouts or after all timeouts completed.
|
|
|
|
### 3. Using MutationObserver and ResizeObserver
|
|
|
|
```typescript
|
|
// Create a mutation observer to detect DOM changes
|
|
const mutationObserver = new MutationObserver(() => {
|
|
if (shouldPreserveScroll) {
|
|
restoreScrollPosition();
|
|
}
|
|
});
|
|
|
|
// Start observing the table for DOM changes
|
|
mutationObserver.observe(scrollableContainer, {
|
|
childList: true,
|
|
subtree: true,
|
|
attributes: true,
|
|
attributeFilter: ['style', 'class']
|
|
});
|
|
|
|
// Create a resize observer
|
|
const resizeObserver = new ResizeObserver(() => {
|
|
if (shouldPreserveScroll) {
|
|
restoreScrollPosition();
|
|
}
|
|
});
|
|
|
|
// Observe the table container
|
|
resizeObserver.observe(scrollableContainer);
|
|
```
|
|
|
|
Result: Did not reliably maintain scroll position, and sometimes caused other rendering issues.
|
|
|
|
### 4. Recursive Restoration Approach
|
|
|
|
```typescript
|
|
let attempts = 0;
|
|
const maxAttempts = 5;
|
|
|
|
const restore = () => {
|
|
if (tableContainerRef.current) {
|
|
tableContainerRef.current.scrollTop = y;
|
|
tableContainerRef.current.scrollLeft = x;
|
|
|
|
attempts++;
|
|
if (attempts < maxAttempts) {
|
|
setTimeout(restore, 50 * attempts);
|
|
}
|
|
}
|
|
};
|
|
|
|
restore();
|
|
```
|
|
|
|
Result: No improvement, scroll position still reset.
|
|
|
|
### 5. Using React State for Scroll Position
|
|
|
|
```typescript
|
|
const [scrollPos, setScrollPos] = useState<{top: number; left: number}>({top: 0, left: 0});
|
|
|
|
// Track the scroll event
|
|
useEffect(() => {
|
|
const handleScroll = () => {
|
|
if (scrollContainerRef.current) {
|
|
setScrollPos({
|
|
top: scrollContainerRef.current.scrollTop,
|
|
left: scrollContainerRef.current.scrollLeft
|
|
});
|
|
}
|
|
};
|
|
|
|
// Add scroll listener...
|
|
}, []);
|
|
|
|
// Restore scroll position
|
|
useLayoutEffect(() => {
|
|
const container = scrollContainerRef.current;
|
|
const { top, left } = scrollPos;
|
|
|
|
if (top > 0 || left > 0) {
|
|
requestAnimationFrame(() => {
|
|
if (container) {
|
|
container.scrollTop = top;
|
|
container.scrollLeft = left;
|
|
}
|
|
});
|
|
}
|
|
}, [scrollPos, data]);
|
|
```
|
|
|
|
Result: Caused the screen to shake violently when scrolling and did not preserve position.
|
|
|
|
### 6. Using Key Attribute for Stability
|
|
|
|
```typescript
|
|
return (
|
|
<div
|
|
key="validation-table-container"
|
|
ref={scrollContainerRef}
|
|
className="overflow-auto max-h-[calc(100vh-300px)]"
|
|
>
|
|
{/* Table content */}
|
|
</div>
|
|
);
|
|
```
|
|
|
|
Result: Did not resolve the issue and may have contributed to rendering instability.
|
|
|
|
### 7. Removing Scroll Management from Other Components
|
|
|
|
We removed scroll position management code from:
|
|
- `useValidationState.tsx` (in the updateRow function)
|
|
- `ValidationContainer.tsx` (in the enhancedUpdateRow function)
|
|
|
|
Result: This did not fix the issue either.
|
|
|
|
### 8. Simple Scroll Position Management with Event Listeners
|
|
|
|
```typescript
|
|
// Create a ref to store scroll position
|
|
const scrollPosition = useRef({ left: 0, top: 0 });
|
|
const tableContainerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Save scroll position when scrolling
|
|
const handleScroll = useCallback(() => {
|
|
if (tableContainerRef.current) {
|
|
scrollPosition.current = {
|
|
left: tableContainerRef.current.scrollLeft,
|
|
top: tableContainerRef.current.scrollTop
|
|
};
|
|
}
|
|
}, []);
|
|
|
|
// Add scroll listener
|
|
useEffect(() => {
|
|
const container = tableContainerRef.current;
|
|
if (container) {
|
|
container.addEventListener('scroll', handleScroll);
|
|
return () => container.removeEventListener('scroll', handleScroll);
|
|
}
|
|
}, [handleScroll]);
|
|
|
|
// Restore scroll position after data changes
|
|
useLayoutEffect(() => {
|
|
const container = tableContainerRef.current;
|
|
if (container) {
|
|
const { left, top } = scrollPosition.current;
|
|
if (left > 0 || top > 0) {
|
|
container.scrollLeft = left;
|
|
container.scrollTop = top;
|
|
}
|
|
}
|
|
}, [data]);
|
|
```
|
|
|
|
Result: Still did not maintain scroll position during updates.
|
|
|
|
### 9. Memoized Scroll Container Component
|
|
|
|
```typescript
|
|
// Create a stable scroll container that won't re-render with the table
|
|
const ScrollContainer = React.memo(({ children }: { children: React.ReactNode }) => {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const scrollPosition = useRef({ left: 0, top: 0 });
|
|
|
|
const handleScroll = useCallback(() => {
|
|
if (containerRef.current) {
|
|
scrollPosition.current = {
|
|
left: containerRef.current.scrollLeft,
|
|
top: containerRef.current.scrollTop
|
|
};
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const container = containerRef.current;
|
|
if (container) {
|
|
// Set initial scroll position if it exists
|
|
if (scrollPosition.current.left > 0 || scrollPosition.current.top > 0) {
|
|
container.scrollLeft = scrollPosition.current.left;
|
|
container.scrollTop = scrollPosition.current.top;
|
|
}
|
|
container.addEventListener('scroll', handleScroll);
|
|
return () => container.removeEventListener('scroll', handleScroll);
|
|
}
|
|
}, [handleScroll]);
|
|
|
|
return (
|
|
<div ref={containerRef} className="overflow-auto max-h-[calc(100vh-300px)]">
|
|
{children}
|
|
</div>
|
|
);
|
|
});
|
|
```
|
|
|
|
Result: Still did not maintain scroll position during updates, even with a memoized container.
|
|
|
|
### 10. Using TanStack Table State Management
|
|
|
|
```typescript
|
|
// Track scroll state in the table instance
|
|
const [scrollState, setScrollState] = useState({ scrollLeft: 0, scrollTop: 0 });
|
|
|
|
const table = useReactTable({
|
|
data,
|
|
columns,
|
|
getCoreRowModel: getCoreRowModel(),
|
|
state: {
|
|
rowSelection,
|
|
// Include scroll position in table state
|
|
scrollLeft: scrollState.scrollLeft,
|
|
scrollTop: scrollState.scrollTop
|
|
},
|
|
onStateChange: (updater) => {
|
|
if (typeof updater === 'function') {
|
|
const newState = updater({
|
|
rowSelection,
|
|
scrollLeft: scrollState.scrollLeft,
|
|
scrollTop: scrollState.scrollTop
|
|
});
|
|
if ('scrollLeft' in newState || 'scrollTop' in newState) {
|
|
setScrollState({
|
|
scrollLeft: newState.scrollLeft ?? scrollState.scrollLeft,
|
|
scrollTop: newState.scrollTop ?? scrollState.scrollTop
|
|
});
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Handle scroll events
|
|
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
|
|
const target = event.target as HTMLDivElement;
|
|
setScrollState({
|
|
scrollLeft: target.scrollLeft,
|
|
scrollTop: target.scrollTop
|
|
});
|
|
}, []);
|
|
|
|
// Restore scroll position after updates
|
|
useLayoutEffect(() => {
|
|
if (tableContainerRef.current) {
|
|
tableContainerRef.current.scrollLeft = scrollState.scrollLeft;
|
|
tableContainerRef.current.scrollTop = scrollState.scrollTop;
|
|
}
|
|
}, [data, scrollState]);
|
|
```
|
|
|
|
Result: Still did not maintain scroll position during updates, even with table state management.
|
|
|
|
### 11. Using CSS Sticky Positioning
|
|
|
|
```typescript
|
|
return (
|
|
<div className="relative max-h-[calc(100vh-300px)] overflow-auto">
|
|
<Table>
|
|
<TableHeader className="sticky top-0 z-10 bg-background">
|
|
<TableRow>
|
|
{table.getFlatHeaders().map((header) => (
|
|
<TableHead
|
|
key={header.id}
|
|
style={{
|
|
width: `${header.getSize()}px`,
|
|
minWidth: `${header.getSize()}px`,
|
|
position: 'sticky',
|
|
top: 0,
|
|
backgroundColor: 'inherit'
|
|
}}
|
|
>
|
|
{/* Header content */}
|
|
</TableHead>
|
|
))}
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{/* Table body content */}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
);
|
|
```
|
|
|
|
Result: Still did not maintain scroll position during updates, even with native CSS scrolling.
|
|
|
|
### 12. Optimized Memoization with Object.is
|
|
|
|
```typescript
|
|
// Memoize data structures to prevent unnecessary re-renders
|
|
const memoizedData = useMemo(() => data, [data]);
|
|
const memoizedValidationErrors = useMemo(() => validationErrors, [validationErrors]);
|
|
const memoizedValidatingCells = useMemo(() => validatingCells, [validatingCells]);
|
|
const memoizedItemNumbers = useMemo(() => itemNumbers, [itemNumbers]);
|
|
|
|
// Use Object.is for more efficient comparisons
|
|
export default React.memo(ValidationTable, (prev, next) => {
|
|
if (!Object.is(prev.data.length, next.data.length)) return false;
|
|
|
|
if (prev.validationErrors.size !== next.validationErrors.size) return false;
|
|
for (const [key, value] of prev.validationErrors) {
|
|
if (!next.validationErrors.has(key)) return false;
|
|
if (!Object.is(value, next.validationErrors.get(key))) return false;
|
|
}
|
|
|
|
// ... more optimized comparisons ...
|
|
});
|
|
```
|
|
|
|
Result: Caused the page to crash with "TypeError: undefined has no properties" in the MemoizedCell component.
|
|
|
|
### 13. Simplified Component Structure
|
|
|
|
```typescript
|
|
const ValidationTable = <T extends string>({
|
|
data,
|
|
fields,
|
|
rowSelection,
|
|
setRowSelection,
|
|
updateRow,
|
|
validationErrors,
|
|
// ... other props
|
|
}) => {
|
|
const tableContainerRef = useRef<HTMLDivElement>(null);
|
|
const lastScrollPosition = useRef({ left: 0, top: 0 });
|
|
|
|
// Simple scroll position management
|
|
const handleScroll = useCallback(() => {
|
|
if (tableContainerRef.current) {
|
|
lastScrollPosition.current = {
|
|
left: tableContainerRef.current.scrollLeft,
|
|
top: tableContainerRef.current.scrollTop
|
|
};
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const container = tableContainerRef.current;
|
|
if (container) {
|
|
container.addEventListener('scroll', handleScroll);
|
|
return () => container.removeEventListener('scroll', handleScroll);
|
|
}
|
|
}, [handleScroll]);
|
|
|
|
useLayoutEffect(() => {
|
|
const container = tableContainerRef.current;
|
|
if (container) {
|
|
const { left, top } = lastScrollPosition.current;
|
|
if (left > 0 || top > 0) {
|
|
requestAnimationFrame(() => {
|
|
if (container) {
|
|
container.scrollLeft = left;
|
|
container.scrollTop = top;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}, [data]);
|
|
|
|
return (
|
|
<div ref={tableContainerRef} className="overflow-auto max-h-[calc(100vh-300px)]">
|
|
<Table>
|
|
{/* ... table content ... */}
|
|
<TableBody>
|
|
{table.getRowModel().rows.map((row) => (
|
|
<TableRow
|
|
key={row.id}
|
|
className={cn(
|
|
"hover:bg-muted/50",
|
|
row.getIsSelected() ? "bg-muted/50" : "",
|
|
validationErrors.get(data.indexOf(row.original)) ? "bg-red-50/40" : ""
|
|
)}
|
|
>
|
|
{/* ... row content ... */}
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
Result: Still did not maintain scroll position during updates. However, this implementation restored the subtle red highlight on rows with validation errors, which is a useful visual indicator that should be preserved in future attempts.
|
|
|
|
### 14. Portal-Based Scroll Container
|
|
|
|
```typescript
|
|
// Create a stable container outside of React's control
|
|
const createStableContainer = () => {
|
|
const containerId = 'validation-table-container';
|
|
let container = document.getElementById(containerId);
|
|
|
|
if (!container) {
|
|
container = document.createElement('div');
|
|
container.id = containerId;
|
|
container.className = 'overflow-auto';
|
|
container.style.maxHeight = 'calc(100vh - 300px)';
|
|
document.body.appendChild(container);
|
|
}
|
|
|
|
return container;
|
|
};
|
|
|
|
const ValidationTable = <T extends string>({...props}) => {
|
|
const [container] = useState(createStableContainer);
|
|
const [mounted, setMounted] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setMounted(true);
|
|
return () => {
|
|
if (container && container.parentNode) {
|
|
container.parentNode.removeChild(container);
|
|
}
|
|
};
|
|
}, [container]);
|
|
|
|
// ... table configuration ...
|
|
|
|
return createPortal(content, container);
|
|
};
|
|
```
|
|
|
|
Result: The table contents failed to render at all. The portal-based approach to maintain scroll position by moving the scroll container outside of React's control was unsuccessful.
|
|
|
|
## Current Understanding
|
|
|
|
The scroll position issue appears to be complex and likely stems from multiple factors:
|
|
|
|
1. React's virtual DOM reconciliation may be replacing the scroll container element during updates
|
|
2. The table uses complex memo patterns with custom equality checks that may not be working as expected
|
|
3. The data structure may be changing in ways that cause complete re-renders
|
|
4. The component hierarchy (with EnhancedValidationTable wrapper) may be affecting DOM stability
|
|
|
|
## Next Steps to Consider
|
|
|
|
At this point, we have tried multiple approaches without success:
|
|
1. Various scroll position management techniques
|
|
2. Memoization and optimization strategies
|
|
3. Different component structures
|
|
4. Portal-based rendering
|
|
|
|
Given that none of these approaches have fully resolved the issue, it may be worth:
|
|
1. Investigating if there are any parent component updates forcing re-renders
|
|
2. Profiling the application to identify the exact timing of scroll position resets
|
|
3. Considering if the current table implementation could be simplified
|
|
4. Exploring if the data update patterns could be optimized to reduce re-renders
|
|
|
|
## Conclusion
|
|
|
|
The scroll position issue has proven resistant to multiple solution attempts. Each approach has either failed to maintain scroll position, introduced new issues, or in some cases (like the portal-based approach) prevented the table from rendering entirely. A deeper investigation into the component lifecycle and data flow may be necessary to identify the root cause. |