From da24dd6e40f70993ee3fe3805515af07313073df Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Tue, 23 Dec 2025 23:56:31 -0500 Subject: [PATCH 1/8] Adding old animation improvements --- src/components/animate/Animate.tsx | 57 +- src/components/animate/animation-utils.ts | 113 +++- src/components/simple-table/SimpleTable.tsx | 73 ++- src/components/simple-table/TableBody.tsx | 10 +- src/components/simple-table/TableContent.tsx | 9 +- src/components/simple-table/TableSection.tsx | 44 +- src/context/TableContext.tsx | 1 + src/hooks/useSortableData.ts | 7 +- src/hooks/useTableRowProcessing.ts | 640 ++++++++++--------- src/stories/examples/BasicExample.tsx | 1 + src/types/TableBodyProps.ts | 3 +- 11 files changed, 613 insertions(+), 345 deletions(-) diff --git a/src/components/animate/Animate.tsx b/src/components/animate/Animate.tsx index 39cbb0539..77a1a13b6 100644 --- a/src/components/animate/Animate.tsx +++ b/src/components/animate/Animate.tsx @@ -31,11 +31,13 @@ interface AnimateProps extends Omit, "id"> tableRow?: TableRow; } export const Animate = ({ children, id, parentRef, tableRow, ...props }: AnimateProps) => { - const { allowAnimations, isResizing, isScrolling, rowHeight } = useTableContext(); + const { allowAnimations, capturedPositionsRef, isResizing, isScrolling, rowHeight } = + useTableContext(); const elementRef = useRef(null); const fromBoundsRef = useRef(null); const previousScrollingState = usePrevious(isScrolling); const previousResizingState = usePrevious(isResizing); + const cleanupCallbackRef = useRef<(() => void) | null>(null); const bufferRowCount = useMemo(() => calculateBufferRowCount(rowHeight), [rowHeight]); useLayoutEffect(() => { // Early exit if animations are disabled - don't do any work at all @@ -48,8 +50,12 @@ export const Animate = ({ children, id, parentRef, tableRow, ...props }: Animate return; } - const toBounds = elementRef.current.getBoundingClientRect(); - const fromBounds = fromBoundsRef.current; + let toBounds = elementRef.current.getBoundingClientRect(); + + // CRITICAL: Check if we have a captured position for this element (react-flip-move pattern) + // This allows animations to continue smoothly even when interrupted by rapid clicks + const capturedPosition = capturedPositionsRef.current.get(id); + const fromBounds = capturedPosition || fromBoundsRef.current; // If we're currently scrolling, don't animate and don't update bounds if (isScrolling) { @@ -65,12 +71,18 @@ export const Animate = ({ children, id, parentRef, tableRow, ...props }: Animate // If resizing just ended, update the previous bounds without animating if (previousResizingState && !isResizing) { fromBoundsRef.current = toBounds; + capturedPositionsRef.current.delete(id); return; } // Store current bounds for next render fromBoundsRef.current = toBounds; + // Clear captured position after using it (it's been consumed) + if (capturedPosition) { + capturedPositionsRef.current.delete(id); + } + // If there's no previous bound data, don't animate (prevents first render animations) if (!fromBounds) { return; @@ -96,6 +108,40 @@ export const Animate = ({ children, id, parentRef, tableRow, ...props }: Animate hasPositionChanged = hasDOMPositionChanged; if (hasPositionChanged) { + // CRITICAL: Cancel any pending cleanup from the previous animation + // This prevents the old animation's cleanup from interfering with the new one + if (cleanupCallbackRef.current) { + cleanupCallbackRef.current(); + cleanupCallbackRef.current = null; + } + + // CRITICAL: Immediately stop any in-progress animation before starting a new one + // This prevents the old animation from interfering with position calculations + if (elementRef.current.style.transition) { + // Get current visual position (with transform applied) + const currentVisualY = elementRef.current.getBoundingClientRect().y; + + // Get the pure DOM position without any transforms + // Temporarily remove transform to get true DOM position + elementRef.current.style.transform = "none"; + elementRef.current.style.transition = "none"; + const pureDOMY = elementRef.current.getBoundingClientRect().y; + + // Calculate offset needed to keep element at current visual position + const offsetY = currentVisualY - pureDOMY; + + // Set the frozen transform to keep element at current visual position + elementRef.current.style.transform = `translate3d(0px, ${offsetY}px, 0px)`; + + // Force reflow to ensure the freeze is applied + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + elementRef.current.offsetHeight; + + // CRITICAL: Recapture toBounds after freezing the element + // The DOM position has changed (it's now at pureDOMY), so we need to update toBounds + toBounds = elementRef.current.getBoundingClientRect(); + } + // Merge animation config with defaults const finalConfig = { ...ANIMATION_CONFIGS.ROW_REORDER, @@ -284,16 +330,21 @@ export const Animate = ({ children, id, parentRef, tableRow, ...props }: Animate } } + // Start the animation and store the cleanup function flipElement({ element: elementRef.current, fromBounds, toBounds, finalConfig, + }).then((cleanup) => { + cleanupCallbackRef.current = cleanup; }); } else { } }, [ + id, allowAnimations, + capturedPositionsRef, bufferRowCount, isResizing, isScrolling, diff --git a/src/components/animate/animation-utils.ts b/src/components/animate/animation-utils.ts index 2bfad1556..5d286472e 100644 --- a/src/components/animate/animation-utils.ts +++ b/src/components/animate/animation-utils.ts @@ -70,14 +70,36 @@ export const calculateInvert = ( /** * Applies initial transform to element for FLIP animation + * Handles interrupting in-progress animations by adjusting the transform calculation */ export const applyInitialTransform = (element: HTMLElement, invert: { x: number; y: number }) => { - element.style.transform = `translate3d(${invert.x}px, ${invert.y}px, 0)`; + const currentVisualY = element.getBoundingClientRect().y; + const hasExistingTransform = element.style.transform && element.style.transform !== "none"; + + // If element has a frozen transform from an interrupted animation, + // we need to recalculate invert from pure DOM position + let adjustedInvertX = invert.x; + let adjustedInvertY = invert.y; + + if (hasExistingTransform) { + // Temporarily remove transform to get pure DOM position + element.style.transform = "none"; + const pureDOMY = element.getBoundingClientRect().y; + const pureDOMX = element.getBoundingClientRect().x; + + // Recalculate invert from pure DOM position + // fromBounds represents where we want to visually appear to come from + const fromBoundsY = currentVisualY + invert.y; + const fromBoundsX = element.getBoundingClientRect().x + invert.x; + + adjustedInvertY = fromBoundsY - pureDOMY; + adjustedInvertX = fromBoundsX - pureDOMX; + } + + element.style.transform = `translate3d(${adjustedInvertX}px, ${adjustedInvertY}px, 0)`; element.style.transition = "none"; - // Performance optimizations for smoother animations - element.style.willChange = "transform"; // Hint to browser for optimization - element.style.backfaceVisibility = "hidden"; // Prevent flickering during animation - // Add animating class to ensure proper z-index during animation + element.style.willChange = "transform"; + element.style.backfaceVisibility = "hidden"; element.classList.add("st-animating"); }; @@ -98,13 +120,14 @@ const cleanupAnimation = (element: HTMLElement) => { /** * Animates element to its final position + * Returns a Promise that resolves to a cleanup function that can cancel the animation */ const animateToFinalPosition = ( element: HTMLElement, config: AnimationConfig, options: FlipAnimationOptions = {}, id?: CellValue -): Promise => { +): Promise<() => void> => { return new Promise((resolve) => { // Force a reflow to ensure the initial transform is applied // eslint-disable-next-line @typescript-eslint/no-unused-expressions @@ -121,21 +144,45 @@ const animateToFinalPosition = ( // Animate to final position element.style.transform = "translate3d(0, 0, 0)"; + let isCleanedUp = false; + let timeoutId: ReturnType | null = null; + // Clean up after animation - const cleanup = () => { + const doCleanup = () => { + if (isCleanedUp) return; + isCleanedUp = true; + cleanupAnimation(element); - element.removeEventListener("transitionend", cleanup); + element.removeEventListener("transitionend", doCleanup); + + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } if (options.onComplete) { options.onComplete(); } - resolve(); }; - element.addEventListener("transitionend", cleanup); + element.addEventListener("transitionend", doCleanup); // Fallback timeout in case transitionend doesn't fire - setTimeout(cleanup, config.duration + (config.delay || 0) + 50); + timeoutId = setTimeout(doCleanup, config.duration + (config.delay || 0) + 50); + + // Return cleanup function that can cancel the animation + const cancelCleanup = () => { + if (isCleanedUp) return; + isCleanedUp = true; + + element.removeEventListener("transitionend", doCleanup); + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + }; + + resolve(cancelCleanup); }); }; @@ -167,6 +214,7 @@ export const getAnimationConfig = ( * Performs FLIP animation on a single element * This function can be called multiple times on the same element - it will automatically * interrupt any ongoing animation and start a new one. + * Returns a cleanup function that can be called to cancel the animation. */ export const flipElement = async ({ element, @@ -178,17 +226,21 @@ export const flipElement = async ({ finalConfig: FlipAnimationOptions; fromBounds: DOMRect; toBounds: DOMRect; -}): Promise => { +}): Promise<() => void> => { const invert = calculateInvert(fromBounds, toBounds); // Skip animation if element hasn't moved if (invert.x === 0 && invert.y === 0) { - return; + // Still need to clean up if there was an animation in progress + cleanupAnimation(element); + // Return no-op cleanup function + return () => {}; } // Skip animation entirely if user prefers reduced motion and no explicit override if (prefersReducedMotion() && finalConfig.respectReducedMotion !== false) { - return; + cleanupAnimation(element); + return () => {}; } // Determine movement type based on the invert values @@ -198,14 +250,19 @@ export const flipElement = async ({ // Get appropriate config based on movement type and user preferences const config = getAnimationConfig(finalConfig, movementType); - // Clean up any existing animation before starting a new one - cleanupAnimation(element); - - // Apply initial transform with limited values + // Apply new transform BEFORE cleaning up old one to prevent flickering + // This ensures there's no gap where the element snaps to its DOM position applyInitialTransform(element, invert); - // Animate to final position - await animateToFinalPosition(element, config, finalConfig); + // Now safe to clean up transition properties (but transform is already set above) + // Only clean transition-related properties, not transform + element.style.transition = ""; + element.style.transitionDelay = ""; + + // Animate to final position and get cleanup function + const cleanup = await animateToFinalPosition(element, config, finalConfig); + + return cleanup; }; /** @@ -251,21 +308,17 @@ export const animateWithCustomCoordinates = async ({ const endTransformY = endY - currentY; return new Promise((resolve) => { - // Clean up any existing animation - element.style.transition = ""; - element.style.transitionDelay = ""; - element.style.transform = ""; - element.style.willChange = ""; - element.style.backfaceVisibility = ""; - element.classList.remove("st-animating"); - - // Set initial position (startY) + // CRITICAL: Set initial position BEFORE cleaning up to prevent flickering + // Apply new transform first (overrides any existing transform) element.style.transform = `translate3d(0, ${startTransformY}px, 0)`; - element.style.transition = "none"; element.style.willChange = "transform"; element.style.backfaceVisibility = "hidden"; element.classList.add("st-animating"); + // Now clean up transition properties (transform already set above) + element.style.transition = "none"; + element.style.transitionDelay = ""; + // Force reflow // eslint-disable-next-line @typescript-eslint/no-unused-expressions element.offsetHeight; diff --git a/src/components/simple-table/SimpleTable.tsx b/src/components/simple-table/SimpleTable.tsx index e0a6cd86e..35822cf52 100644 --- a/src/components/simple-table/SimpleTable.tsx +++ b/src/components/simple-table/SimpleTable.tsx @@ -59,6 +59,7 @@ import { EmptyStateRenderer, } from "../../types/RowStateRendererProps"; import DefaultEmptyState from "../empty-state/DefaultEmptyState"; +import { ANIMATION_CONFIGS } from "../animate/animation-utils"; interface SimpleTableProps { allowAnimations?: boolean; // Flag for allowing animations @@ -225,6 +226,7 @@ const SimpleTableComp = ({ // Refs const draggedHeaderRef = useRef(null); const hoveredHeaderRef = useRef(null); + const capturedPositionsRef = useRef>(new Map()); const mainBodyRef = useRef(null); const pinnedLeftRef = useRef(null); @@ -571,6 +573,19 @@ const SimpleTableComp = ({ onFilterChange, }); + // Function to capture all element positions (called right before sort state change) + const captureAllPositions = useCallback(() => { + const allElements = document.querySelectorAll("[data-animate-id]"); + capturedPositionsRef.current.clear(); + allElements.forEach((el) => { + const id = el.getAttribute("data-animate-id"); + if (id && el instanceof HTMLElement) { + const rect = el.getBoundingClientRect(); + capturedPositionsRef.current.set(id, rect); + } + }); + }, []); + // Use custom hook for sorting (now operates on filtered rows) const { sort, sortedRows, updateSort, computeSortedRowsPreview } = useSortableData({ headers, @@ -580,6 +595,7 @@ const SimpleTableComp = ({ rowGrouping, initialSortColumn, initialSortDirection, + onBeforeSort: captureAllPositions, }); // Flatten sorted rows - this converts nested Row[] to flat TableRow[] @@ -707,10 +723,13 @@ const SimpleTableComp = ({ // Process rows through pagination and virtualization (now operates on flattened rows) const { currentTableRows, - rowsToRender, + currentVisibleRows, + rowsEnteringTheDom, prepareForFilterChange, prepareForSortChange, + cleanupAnimationRows, isAnimating, + animationStartTime, } = useTableRowProcessing({ allowAnimations, flattenedRows, @@ -728,6 +747,18 @@ const SimpleTableComp = ({ computeSortedRowsPreview: computeFlattenedSortedRowsPreview, }); + // Cleanup animation rows after animation completes + useEffect(() => { + if (isAnimating && animationStartTime > 0) { + // Animation duration is 200ms, add buffer for safety + const timeoutId = setTimeout(() => { + cleanupAnimationRows(); + }, ANIMATION_CONFIGS.ROW_REORDER.duration + 50); + + return () => clearTimeout(timeoutId); + } + }, [isAnimating, animationStartTime, cleanupAnimationRows]); + // Create a registry for cells to enable direct updates const cellRegistryRef = useRef>(new Map()); @@ -766,14 +797,19 @@ const SimpleTableComp = ({ const onSort = useCallback( (accessor: Accessor) => { // STAGE 1: Prepare animation by adding entering rows before applying sort - prepareForSortChange(accessor); + // Pass captureAllPositions so it can capture leaving rows before updating their positions + prepareForSortChange(accessor, currentVisibleRows, captureAllPositions); // STAGE 2: Apply sort after Stage 1 is rendered (next frame) - setTimeout(() => { - updateSort({ accessor }); - }, 0); + // Note: Position capture happens in updateSort via onBeforeSort callback + // Use double RAF to ensure browser has completed layout before capturing positions + requestAnimationFrame(() => { + requestAnimationFrame(() => { + updateSort({ accessor }); + }); + }); }, - [prepareForSortChange, updateSort] + [prepareForSortChange, updateSort, currentVisibleRows, captureAllPositions] ); const onTableHeaderDragEnd = useCallback((newHeaders: HeaderObject[]) => { @@ -818,7 +854,7 @@ const SimpleTableComp = ({ tableRef, updateFilter, updateSort, - visibleRows: rowsToRender, + visibleRows: currentVisibleRows, }); useExternalFilters({ filters, onFilterChange }); useExternalSort({ sort, onSortChange }); @@ -827,15 +863,22 @@ const SimpleTableComp = ({ const handleApplyFilter = useCallback( (filter: FilterCondition) => { // STAGE 1: Prepare animation by adding entering rows before applying filter - prepareForFilterChange(filter); + // Pass captureAllPositions so it can capture leaving rows before updating their positions + prepareForFilterChange(filter, captureAllPositions); // STAGE 2: Apply filter after Stage 1 is rendered (next frame) - setTimeout(() => { - // Update internal state and call external handler if provided - updateFilter(filter); - }, 0); + // Use double RAF to ensure browser has completed layout before capturing positions + requestAnimationFrame(() => { + requestAnimationFrame(() => { + // Capture positions right before filter state change + captureAllPositions(); + + // Update internal state and call external handler if provided + updateFilter(filter); + }); + }); }, - [prepareForFilterChange, updateFilter] + [prepareForFilterChange, updateFilter, captureAllPositions] ); // Check if we should show the empty state (no rows after filtering and not loading) @@ -849,6 +892,7 @@ const SimpleTableComp = ({ areAllRowsSelected, autoExpandColumns, canExpandRowGroup, + capturedPositionsRef, cellRegistry: cellRegistryRef.current, cellUpdateFlash, clearSelection, @@ -965,6 +1009,8 @@ const SimpleTableComp = ({
{ + currentVisibleRows.forEach((tableRow, index) => { const rowId = String( getRowId({ row: tableRow.row, @@ -121,7 +122,7 @@ const TableBody = ({ }); return indices; - }, [rowsToRender, rowIdAccessor]); + }, [currentVisibleRows, rowIdAccessor]); // Check if we should load more data const checkForLoadMore = useCallback( @@ -197,7 +198,8 @@ const TableBody = ({ headers, rowHeight, rowIndices, - rowsToRender, + currentVisibleRows, + rowsEnteringTheDom, setHoveredIndex, }; diff --git a/src/components/simple-table/TableContent.tsx b/src/components/simple-table/TableContent.tsx index 1b4e61fb8..9221b4a24 100644 --- a/src/components/simple-table/TableContent.tsx +++ b/src/components/simple-table/TableContent.tsx @@ -10,6 +10,8 @@ import TableRow from "../../types/TableRow"; // Define props for the frequently changing values not in context interface TableContentLocalProps { + currentVisibleRows: TableRow[]; + rowsEnteringTheDom: TableRow[]; pinnedLeftWidth: number; pinnedRightWidth: number; setScrollTop: (scrollTop: number) => void; @@ -17,10 +19,11 @@ interface TableContentLocalProps { shouldShowEmptyState: boolean; sort: SortColumn | null; tableRows: TableRow[]; - rowsToRender: TableRow[]; } const TableContent = ({ + currentVisibleRows, + rowsEnteringTheDom, pinnedLeftWidth, pinnedRightWidth, setScrollTop, @@ -28,7 +31,6 @@ const TableContent = ({ shouldShowEmptyState, sort, tableRows, - rowsToRender, }: TableContentLocalProps) => { // Get stable props from context const { columnResizing, editColumns, headers, collapsedHeaders, autoExpandColumns } = @@ -79,6 +81,8 @@ const TableContent = ({ }; const tableBodyProps: TableBodyProps = { + currentVisibleRows, + rowsEnteringTheDom, tableRows, mainTemplateColumns, pinnedLeftColumns, @@ -90,7 +94,6 @@ const TableContent = ({ setScrollTop, setScrollDirection, shouldShowEmptyState, - rowsToRender, }; return ( diff --git a/src/components/simple-table/TableSection.tsx b/src/components/simple-table/TableSection.tsx index 37f179225..33707e7af 100644 --- a/src/components/simple-table/TableSection.tsx +++ b/src/components/simple-table/TableSection.tsx @@ -21,11 +21,12 @@ import { useTableContext } from "../../context/TableContext"; interface TableSectionProps { columnIndexStart?: number; // This is to know how many columns there were before this section to see if the columns are odd or even columnIndices: ColumnIndices; + currentVisibleRows: TableRowType[]; + rowsEnteringTheDom: TableRowType[]; headers: HeaderObject[]; pinned?: Pinned; rowHeight: number; rowIndices: RowIndices; - rowsToRender: TableRowType[]; setHoveredIndex: (index: number | null) => void; templateColumns: string; totalHeight: number; @@ -37,6 +38,8 @@ const TableSection = forwardRef( { columnIndexStart, columnIndices, + currentVisibleRows, + rowsEnteringTheDom, headers, pinned, rowHeight, @@ -44,7 +47,6 @@ const TableSection = forwardRef( setHoveredIndex, templateColumns, totalHeight, - rowsToRender, width, }, ref @@ -76,7 +78,43 @@ const TableSection = forwardRef( ...(!pinned && { flexGrow: 1 }), }} > - {rowsToRender.map((tableRow, index) => { + {currentVisibleRows.map((tableRow, index) => { + // Generate unique key - use stateIndicator parentRowId for state rows + const rowId = tableRow.stateIndicator + ? `state-${tableRow.stateIndicator.parentRowId}-${tableRow.position}` + : getRowId({ + row: tableRow.row, + rowIdAccessor, + rowPath: tableRow.rowPath, + }); + + return ( + + {index !== 0 && ( + + )} + + + ); + })} + {rowsEnteringTheDom.map((tableRow, index) => { // Generate unique key - use stateIndicator parentRowId for state rows const rowId = tableRow.stateIndicator ? `state-${tableRow.stateIndicator.parentRowId}-${tableRow.position}` diff --git a/src/context/TableContext.tsx b/src/context/TableContext.tsx index 89c075ada..883e3cece 100644 --- a/src/context/TableContext.tsx +++ b/src/context/TableContext.tsx @@ -43,6 +43,7 @@ interface TableContextType { areAllRowsSelected?: () => boolean; autoExpandColumns?: boolean; canExpandRowGroup?: (row: Row) => boolean; + capturedPositionsRef: MutableRefObject>; cellRegistry?: Map; cellUpdateFlash?: boolean; clearSelection?: () => void; diff --git a/src/hooks/useSortableData.ts b/src/hooks/useSortableData.ts index de35648fd..e9680ec2b 100644 --- a/src/hooks/useSortableData.ts +++ b/src/hooks/useSortableData.ts @@ -50,6 +50,7 @@ const useSortableData = ({ rowGrouping, initialSortColumn, initialSortDirection, + onBeforeSort, }: { headers: HeaderObject[]; tableRows: Row[]; @@ -58,6 +59,7 @@ const useSortableData = ({ rowGrouping?: string[]; initialSortColumn?: string; initialSortDirection?: SortDirection; + onBeforeSort?: () => void; }) => { // Initialize sort state with initial values if provided const getInitialSort = useCallback((): SortColumn | null => { @@ -202,10 +204,13 @@ const useSortableData = ({ } // If currently desc, clear the sort (newSortColumn stays null) + // CRITICAL: Capture positions right before state change (react-flip-move pattern) + onBeforeSort?.(); + setSort(newSortColumn); onSortChange?.(newSortColumn); }, - [sort, headers, onSortChange] + [sort, headers, onSortChange, onBeforeSort] ); // Function to preview what rows would be after applying a sort diff --git a/src/hooks/useTableRowProcessing.ts b/src/hooks/useTableRowProcessing.ts index 46a5a7e19..171049475 100644 --- a/src/hooks/useTableRowProcessing.ts +++ b/src/hooks/useTableRowProcessing.ts @@ -44,22 +44,20 @@ const useTableRowProcessing = ({ computeSortedRowsPreview, }: UseTableRowProcessingProps) => { const [isAnimating, setIsAnimating] = useState(false); - const [extendedRows, setExtendedRows] = useState([]); - const previousTableRowsRef = useRef([]); // Track ALL processed rows, not just visible - const previousVisibleRowsRef = useRef([]); // Track only visible rows for animation + const [animationStartTime, setAnimationStartTime] = useState(0); + const [rowsEnteringTheDom, setRowsEnteringTheDom] = useState([]); + const [rowsLeavingTheDom, setRowsLeavingTheDom] = useState([]); // Calculate buffer row count based on actual row height // This ensures consistent ~800px overscan regardless of row size const bufferRowCount = useMemo(() => calculateBufferRowCount(rowHeight), [rowHeight]); - // Track original positions of all rows (before any sort/filter applied) - const originalPositionsRef = useRef>(new Map()); - - // Capture values when animation starts to avoid dependency issues in timeout effect - const animationCaptureRef = useRef<{ - tableRows: TableRow[]; - visibleRows: TableRow[]; - } | null>(null); + // Cleanup function to reset animation states + const cleanupAnimationRows = useCallback(() => { + setRowsEnteringTheDom([]); + setRowsLeavingTheDom([]); + setIsAnimating(false); + }, []); // Apply pagination to already-flattened rows and recalculate positions const applyPagination = useCallback( @@ -83,26 +81,6 @@ const useTableRowProcessing = ({ [currentPage, rowsPerPage, serverSidePagination, shouldPaginate] ); - // Establish original positions from unfiltered/unsorted data - useMemo(() => { - // Only set original positions once when component first loads - if (originalPositionsRef.current.size === 0 && originalFlattenedRows.length > 0) { - const newOriginalPositions = new Map(); - originalFlattenedRows.forEach((tableRow, index) => { - const id = String( - getRowId({ - row: tableRow.row, - rowIdAccessor, - rowPath: tableRow.rowPath, - }) - ); - newOriginalPositions.set(id, index); - }); - - originalPositionsRef.current = newOriginalPositions; - } - }, [originalFlattenedRows, rowIdAccessor]); - // Current table rows (paginated for display) // Now pagination happens on FLATTENED rows, so rowsPerPage correctly counts all visible rows const currentTableRows = useMemo(() => { @@ -126,245 +104,48 @@ const useTableRowProcessing = ({ }); }, [currentTableRows, contentHeight, rowHeight, scrollTop, scrollDirection, bufferRowCount]); - // Categorize rows based on ID changes - const categorizeRows = useCallback( - (previousRows: TableRow[], currentRows: TableRow[]) => { - const previousIds = new Set( - previousRows.map((tableRow) => - String( - getRowId({ - row: tableRow.row, - rowIdAccessor, - rowPath: tableRow.rowPath, - }) - ) - ) - ); - const currentIds = new Set( - currentRows.map((tableRow) => - String( - getRowId({ - row: tableRow.row, - rowIdAccessor, - rowPath: tableRow.rowPath, - }) - ) - ) - ); - - const staying = currentRows.filter((tableRow) => { - const id = String( + // Combine target visible rows with leaving rows for rendering + const visibleRowsWithLeaving = useMemo(() => { + // Create a set of IDs from rowsLeavingTheDom + const leavingRowIds = new Set( + rowsLeavingTheDom.map((tableRow) => + String( getRowId({ row: tableRow.row, rowIdAccessor, rowPath: tableRow.rowPath, }) - ); - return previousIds.has(id); - }); - - const entering = currentRows.filter((tableRow) => { - const id = String( - getRowId({ - row: tableRow.row, - rowIdAccessor, - rowPath: tableRow.rowPath, - }) - ); - return !previousIds.has(id); - }); - - const leaving = previousRows.filter((tableRow) => { - const id = String( - getRowId({ - row: tableRow.row, - rowIdAccessor, - rowPath: tableRow.rowPath, - }) - ); - return !currentIds.has(id); - }); - - return { staying, entering, leaving }; - }, - [rowIdAccessor] - ); - - // Check if there are actual row changes (comparing all rows, not just visible) - const hasRowChanges = useMemo(() => { - if (previousTableRowsRef.current.length === 0) { - return false; - } - - const currentIds = currentTableRows.map((tableRow) => - String( - getRowId({ - row: tableRow.row, - rowIdAccessor, - rowPath: tableRow.rowPath, - }) + ) ) ); - const previousIds = previousTableRowsRef.current.map((tableRow) => - String( + + // Filter out any rows from targetVisibleRows that are already in rowsLeavingTheDom + const uniqueTargetRows = targetVisibleRows.filter((tableRow) => { + const id = String( getRowId({ row: tableRow.row, rowIdAccessor, rowPath: tableRow.rowPath, }) - ) - ); - - const hasChanges = - currentIds.length !== previousIds.length || - !currentIds.every((id, index) => id === previousIds[index]); - - return hasChanges; - }, [currentTableRows, rowIdAccessor]); - - // Animation effect - useLayoutEffect(() => { - // Don't re-run effect while animation is in progress - if (isAnimating) { - return; - } - - // Helper to clear extended rows only if needed (avoid unnecessary state updates - // that would cause infinite re-renders) - const clearExtendedRowsIfNeeded = () => { - if (extendedRows.length > 0) { - setExtendedRows([]); - } - }; - - // Always sync when not animating - if (!allowAnimations || shouldPaginate) { - clearExtendedRowsIfNeeded(); - previousTableRowsRef.current = currentTableRows; - previousVisibleRowsRef.current = targetVisibleRows; - return; - } - - // Initialize on first render - if (previousTableRowsRef.current.length === 0) { - clearExtendedRowsIfNeeded(); - previousTableRowsRef.current = currentTableRows; - previousVisibleRowsRef.current = targetVisibleRows; - return; - } - - // Check if rows actually changed - this detects STAGE 2 (after sort/filter applied) - if (!hasRowChanges) { - clearExtendedRowsIfNeeded(); - previousTableRowsRef.current = currentTableRows; - previousVisibleRowsRef.current = targetVisibleRows; - return; - } - - // STAGE 2: Rows have new positions, trigger animation - // extendedRows already contains current + entering rows (from STAGE 1) - // Now the positions will update automatically when the component re-renders - - // Capture current values before starting animation - animationCaptureRef.current = { - tableRows: currentTableRows, - visibleRows: targetVisibleRows, - }; - - // Start animation - setIsAnimating(true); - }, [ - allowAnimations, - currentTableRows, - extendedRows.length, - hasRowChanges, - isAnimating, - shouldPaginate, - targetVisibleRows, - ]); - - // Separate effect to handle animation timeout - only runs when we have extended rows to animate - useLayoutEffect(() => { - if (isAnimating && animationCaptureRef.current && extendedRows.length > 0) { - // STAGE 3: After animation completes, remove leaving rows - const timeout = setTimeout(() => { - const captured = animationCaptureRef.current!; - setIsAnimating(false); - setExtendedRows([]); // Clear extended rows to use normal virtualization - previousTableRowsRef.current = captured.tableRows; - previousVisibleRowsRef.current = captured.visibleRows; - animationCaptureRef.current = null; // Clean up - }, ANIMATION_CONFIGS.ROW_REORDER.duration + 100); - - return () => clearTimeout(timeout); - } - }, [isAnimating, extendedRows.length]); // Depend on isAnimating AND extendedRows length - - // Final rows to render - handles 3-stage animation - const rowsToRender = useMemo(() => { - // If animations are disabled, always use normal virtualization - if (!allowAnimations || shouldPaginate) { - return targetVisibleRows; - } - - // If we have extended rows (from STAGE 1), we need to dynamically update their positions - // to reflect the current sort/filter state (STAGE 2) - if (extendedRows.length > 0) { - // Create a map of ALL positions from currentTableRows (not just visible ones) - // This ensures we have positions for existing rows AND entering rows - const positionMap = new Map(); - const displayPositionMap = new Map(); - currentTableRows.forEach((tableRow) => { - const id = String( - getRowId({ - row: tableRow.row, - rowIdAccessor, - rowPath: tableRow.rowPath, - }) - ); - positionMap.set(id, tableRow.position); - displayPositionMap.set(id, tableRow.displayPosition); - }); - - // Update ALL rows in extendedRows with their new positions - const updatedExtendedRows = extendedRows.map((tableRow) => { - const id = String( - getRowId({ - row: tableRow.row, - rowIdAccessor, - rowPath: tableRow.rowPath, - }) - ); - const newPosition = positionMap.get(id); - const newDisplayPosition = displayPositionMap.get(id); - - // If this row exists in the new sorted state, use its new position - // Otherwise keep the original position (for leaving rows that are no longer in the sorted data) - if (newPosition !== undefined && newDisplayPosition !== undefined) { - return { ...tableRow, position: newPosition, displayPosition: newDisplayPosition }; - } - return tableRow; - }); - - return updatedExtendedRows; - } + ); + return !leavingRowIds.has(id); + }); - // Default: use normal visible rows (STAGE 3 after animation completes) - return targetVisibleRows; - }, [ - targetVisibleRows, - extendedRows, - currentTableRows, - allowAnimations, - shouldPaginate, - rowIdAccessor, - ]); + // Combine unique target rows with leaving rows (leaving rows take precedence) + return [...uniqueTargetRows, ...rowsLeavingTheDom]; + }, [targetVisibleRows, rowsLeavingTheDom, rowIdAccessor]); // Animation handlers for filter/sort changes const prepareForFilterChange = useCallback( - (filter: FilterCondition) => { + (filter: FilterCondition, capturePositions?: () => void) => { if (!allowAnimations || shouldPaginate || contentHeight === undefined) return; + // CRITICAL: Capture positions of existing leaving rows BEFORE updating them + // This prevents teleporting when their positions change + if (capturePositions) { + capturePositions(); + } + // Calculate what rows would be after filter (already flattened from preview function) const newFlattenedRows = computeFilteredRowsPreview(filter); const newPaginatedRows = applyPagination(newFlattenedRows); @@ -377,21 +158,18 @@ const useTableRowProcessing = ({ scrollDirection, }); - // CRITICAL: Compare VISIBLE rows (before filter) vs what WILL BE visible (after filter) - // This identifies rows that are entering the visible area - const { entering: visibleEntering } = categorizeRows(targetVisibleRows, newVisibleRows); - - // Find these entering rows in the CURRENT table state (before filter) to get original positions - const enteringFromCurrentState = visibleEntering - .map((enteringRow) => { + // Find all rows that WILL BE visible after the filter, but with their CURRENT positions (before filter) + // This gives us the starting point for animation + const rowsInCurrentPosition = newVisibleRows + .map((newVisibleRow) => { const id = String( getRowId({ - row: enteringRow.row, + row: newVisibleRow.row, rowIdAccessor, - rowPath: enteringRow.rowPath, + rowPath: newVisibleRow.rowPath, }) ); - // Find this row in the current table state to get its original position + // Find this row in the CURRENT table state (before filter) to get its current position const currentStateRow = currentTableRows.find( (currentRow) => String( @@ -402,13 +180,157 @@ const useTableRowProcessing = ({ }) ) === id ); - return currentStateRow || enteringRow; // Fallback to enteringRow if not found + return currentStateRow || newVisibleRow; // Fallback to newVisibleRow if not found in current state }) .filter(Boolean) as TableRow[]; - if (enteringFromCurrentState.length > 0) { - setExtendedRows([...targetVisibleRows, ...enteringFromCurrentState]); - } + setIsAnimating(true); + setAnimationStartTime(Date.now()); + + // Add unique rows to rowsEnteringTheDom (don't add duplicates from targetVisibleRows or existing rowsEnteringTheDom) + setRowsEnteringTheDom((existingRows) => { + // Create set of IDs already in targetVisibleRows + const targetVisibleIds = new Set( + targetVisibleRows.map((tableRow) => + String( + getRowId({ + row: tableRow.row, + rowIdAccessor, + rowPath: tableRow.rowPath, + }) + ) + ) + ); + + // Create set of IDs already in existingRows + const existingRowIds = new Set( + existingRows.map((tableRow) => + String( + getRowId({ + row: tableRow.row, + rowIdAccessor, + rowPath: tableRow.rowPath, + }) + ) + ) + ); + + // Filter to only include rows that aren't already in targetVisibleRows or existingRows + const uniqueNewRows = rowsInCurrentPosition.filter((tableRow) => { + const id = String( + getRowId({ + row: tableRow.row, + rowIdAccessor, + rowPath: tableRow.rowPath, + }) + ); + return !targetVisibleIds.has(id) && !existingRowIds.has(id); + }); + + // Add unique rows to existing rows + return [...existingRows, ...uniqueNewRows]; + }); + + // Track rows that are leaving the DOM (currently visible but won't be visible after filter) + setRowsLeavingTheDom((existingRows) => { + // Create set of IDs that will be visible after filter + const newVisibleIds = new Set( + newVisibleRows.map((tableRow) => + String( + getRowId({ + row: tableRow.row, + rowIdAccessor, + rowPath: tableRow.rowPath, + }) + ) + ) + ); + + // Create map of existing leaving rows for quick lookup + const existingRowsMap = new Map( + existingRows.map((tableRow) => [ + String( + getRowId({ + row: tableRow.row, + rowIdAccessor, + rowPath: tableRow.rowPath, + }) + ), + tableRow, + ]) + ); + + // Find rows from targetVisibleRows that won't be visible after filter + const candidateLeavingRows = targetVisibleRows.filter((tableRow) => { + const id = String( + getRowId({ + row: tableRow.row, + rowIdAccessor, + rowPath: tableRow.rowPath, + }) + ); + return !newVisibleIds.has(id); + }); + + // Separate into rows that need position updates vs truly new leaving rows + const rowsToUpdate: TableRow[] = []; + const newLeavingRows: TableRow[] = []; + + candidateLeavingRows.forEach((leavingRow) => { + const id = String( + getRowId({ + row: leavingRow.row, + rowIdAccessor, + rowPath: leavingRow.rowPath, + }) + ); + // Find this row in the NEW processed rows to get its NEW position (after filter) + const rowInNewState = newPaginatedRows.find( + (newRow) => + String( + getRowId({ + row: newRow.row, + rowIdAccessor, + rowPath: newRow.rowPath, + }) + ) === id + ); + const rowWithNewPosition = rowInNewState || leavingRow; + + if (existingRowsMap.has(id)) { + // Row is already leaving, update its position + rowsToUpdate.push(rowWithNewPosition); + } else { + // Row is newly leaving + newLeavingRows.push(rowWithNewPosition); + } + }); + + // Update existing rows with new positions, keep rows not in update list unchanged + const updatedExistingRows = existingRows.map((tableRow) => { + const id = String( + getRowId({ + row: tableRow.row, + rowIdAccessor, + rowPath: tableRow.rowPath, + }) + ); + const updatedRow = rowsToUpdate.find( + (r) => + String( + getRowId({ + row: r.row, + rowIdAccessor, + rowPath: r.rowPath, + }) + ) === id + ); + return updatedRow || tableRow; + }); + + // Combine updated existing rows with new leaving rows + return [...updatedExistingRows, ...newLeavingRows]; + }); }, [ allowAnimations, @@ -419,7 +341,6 @@ const useTableRowProcessing = ({ rowHeight, scrollTop, scrollDirection, - categorizeRows, currentTableRows, targetVisibleRows, rowIdAccessor, @@ -428,9 +349,15 @@ const useTableRowProcessing = ({ ); const prepareForSortChange = useCallback( - (accessor: Accessor) => { + (accessor: Accessor, targetVisibleRows: TableRow[], capturePositions?: () => void) => { if (!allowAnimations || shouldPaginate || contentHeight === undefined) return; + // CRITICAL: Capture positions of existing leaving rows BEFORE updating them + // This prevents teleporting when their positions change + if (capturePositions) { + capturePositions(); + } + // Calculate what rows would be after sort (already flattened from preview function) const newFlattenedRows = computeSortedRowsPreview(accessor); const newPaginatedRows = applyPagination(newFlattenedRows); @@ -443,21 +370,18 @@ const useTableRowProcessing = ({ scrollDirection, }); - // CRITICAL: Compare VISIBLE rows (before sort) vs what WILL BE visible (after sort) - // This identifies rows that are entering the visible area - const { entering: visibleEntering } = categorizeRows(targetVisibleRows, newVisibleRows); - - // Find these entering rows in the CURRENT table state (before sort) to get original positions - const enteringFromCurrentState = visibleEntering - .map((enteringRow) => { + // Find all rows that WILL BE visible after the sort, but with their CURRENT positions (before sort) + // This gives us the starting point for animation + const rowsInCurrentPosition = newVisibleRows + .map((newVisibleRow) => { const id = String( getRowId({ - row: enteringRow.row, + row: newVisibleRow.row, rowIdAccessor, - rowPath: enteringRow.rowPath, + rowPath: newVisibleRow.rowPath, }) ); - // Find this row in the current table state to get its original position + // Find this row in the CURRENT table state (before sort) to get its current position const currentStateRow = currentTableRows.find( (currentRow) => String( @@ -468,13 +392,157 @@ const useTableRowProcessing = ({ }) ) === id ); - return currentStateRow || enteringRow; // Fallback to enteringRow if not found + return currentStateRow || newVisibleRow; // Fallback to newVisibleRow if not found in current state }) .filter(Boolean) as TableRow[]; - if (enteringFromCurrentState.length > 0) { - setExtendedRows([...targetVisibleRows, ...enteringFromCurrentState]); - } + setIsAnimating(true); + setAnimationStartTime(Date.now()); + + // Add unique rows to rowsEnteringTheDom (don't add duplicates from targetVisibleRows or existing rowsEnteringTheDom) + setRowsEnteringTheDom((existingRows) => { + // Create set of IDs already in targetVisibleRows + const targetVisibleIds = new Set( + targetVisibleRows.map((tableRow) => + String( + getRowId({ + row: tableRow.row, + rowIdAccessor, + rowPath: tableRow.rowPath, + }) + ) + ) + ); + + // Create set of IDs already in existingRows + const existingRowIds = new Set( + existingRows.map((tableRow) => + String( + getRowId({ + row: tableRow.row, + rowIdAccessor, + rowPath: tableRow.rowPath, + }) + ) + ) + ); + + // Filter to only include rows that aren't already in targetVisibleRows or existingRows + const uniqueNewRows = rowsInCurrentPosition.filter((tableRow) => { + const id = String( + getRowId({ + row: tableRow.row, + rowIdAccessor, + rowPath: tableRow.rowPath, + }) + ); + return !targetVisibleIds.has(id) && !existingRowIds.has(id); + }); + + // Add unique rows to existing rows + return [...existingRows, ...uniqueNewRows]; + }); + + // Track rows that are leaving the DOM (currently visible but won't be visible after sort) + setRowsLeavingTheDom((existingRows) => { + // Create set of IDs that will be visible after sort + const newVisibleIds = new Set( + newVisibleRows.map((tableRow) => + String( + getRowId({ + row: tableRow.row, + rowIdAccessor, + rowPath: tableRow.rowPath, + }) + ) + ) + ); + + // Create map of existing leaving rows for quick lookup + const existingRowsMap = new Map( + existingRows.map((tableRow) => [ + String( + getRowId({ + row: tableRow.row, + rowIdAccessor, + rowPath: tableRow.rowPath, + }) + ), + tableRow, + ]) + ); + + // Find rows from targetVisibleRows that won't be visible after sort + const candidateLeavingRows = targetVisibleRows.filter((tableRow) => { + const id = String( + getRowId({ + row: tableRow.row, + rowIdAccessor, + rowPath: tableRow.rowPath, + }) + ); + return !newVisibleIds.has(id); + }); + + // Separate into rows that need position updates vs truly new leaving rows + const rowsToUpdate: TableRow[] = []; + const newLeavingRows: TableRow[] = []; + + candidateLeavingRows.forEach((leavingRow) => { + const id = String( + getRowId({ + row: leavingRow.row, + rowIdAccessor, + rowPath: leavingRow.rowPath, + }) + ); + // Find this row in the NEW processed rows to get its NEW position (after sort) + const rowInNewState = newPaginatedRows.find( + (newRow) => + String( + getRowId({ + row: newRow.row, + rowIdAccessor, + rowPath: newRow.rowPath, + }) + ) === id + ); + const rowWithNewPosition = rowInNewState || leavingRow; + + if (existingRowsMap.has(id)) { + // Row is already leaving, update its position + rowsToUpdate.push(rowWithNewPosition); + } else { + // Row is newly leaving + newLeavingRows.push(rowWithNewPosition); + } + }); + + // Update existing rows with new positions, keep rows not in update list unchanged + const updatedExistingRows = existingRows.map((tableRow) => { + const id = String( + getRowId({ + row: tableRow.row, + rowIdAccessor, + rowPath: tableRow.rowPath, + }) + ); + const updatedRow = rowsToUpdate.find( + (r) => + String( + getRowId({ + row: r.row, + rowIdAccessor, + rowPath: r.rowPath, + }) + ) === id + ); + return updatedRow || tableRow; + }); + + // Combine updated existing rows with new leaving rows + return [...updatedExistingRows, ...newLeavingRows]; + }); }, [ allowAnimations, @@ -485,9 +553,7 @@ const useTableRowProcessing = ({ rowHeight, scrollTop, scrollDirection, - categorizeRows, currentTableRows, - targetVisibleRows, rowIdAccessor, bufferRowCount, ] @@ -495,11 +561,13 @@ const useTableRowProcessing = ({ return { currentTableRows, - currentVisibleRows: targetVisibleRows, + currentVisibleRows: visibleRowsWithLeaving, isAnimating, + animationStartTime, prepareForFilterChange, prepareForSortChange, - rowsToRender, + cleanupAnimationRows, + rowsEnteringTheDom, }; }; diff --git a/src/stories/examples/BasicExample.tsx b/src/stories/examples/BasicExample.tsx index 4a8b1bde8..27d6c2aec 100644 --- a/src/stories/examples/BasicExample.tsx +++ b/src/stories/examples/BasicExample.tsx @@ -4,6 +4,7 @@ import { UniversalTableProps } from "./StoryWrapper"; // Default args specific to BasicExample - exported for reuse in stories and tests export const basicExampleDefaults = { + allowAnimations: true, columnResizing: true, editColumns: true, selectableCells: true, diff --git a/src/types/TableBodyProps.ts b/src/types/TableBodyProps.ts index 8216321d4..ba4b3d56e 100644 --- a/src/types/TableBodyProps.ts +++ b/src/types/TableBodyProps.ts @@ -2,6 +2,8 @@ import { HeaderObject } from ".."; import TableRow from "./TableRow"; interface TableBodyProps { + currentVisibleRows: TableRow[]; + rowsEnteringTheDom: TableRow[]; mainTemplateColumns: string; pinnedLeftColumns: HeaderObject[]; pinnedLeftTemplateColumns: string; @@ -9,7 +11,6 @@ interface TableBodyProps { pinnedRightColumns: HeaderObject[]; pinnedRightTemplateColumns: string; pinnedRightWidth: number; - rowsToRender: TableRow[]; setScrollTop: (scrollTop: number) => void; setScrollDirection: (direction: "up" | "down" | "none") => void; shouldShowEmptyState: boolean; From d182d3ba3353380eaa6a5e5a745a0bf6a62ab50d Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Wed, 24 Dec 2025 00:03:23 -0500 Subject: [PATCH 2/8] More animation improvements --- src/components/animate/animation-utils.ts | 36 +---- src/components/simple-table/SimpleTable.tsx | 1 - src/hooks/useTableRowProcessing.ts | 137 +++----------------- src/stories/examples/BasicExample.tsx | 2 +- 4 files changed, 22 insertions(+), 154 deletions(-) diff --git a/src/components/animate/animation-utils.ts b/src/components/animate/animation-utils.ts index 5d286472e..62c11f3b0 100644 --- a/src/components/animate/animation-utils.ts +++ b/src/components/animate/animation-utils.ts @@ -13,23 +13,14 @@ export const prefersReducedMotion = (): boolean => { * Animation configs for different types of movements */ export const ANIMATION_CONFIGS = { - // For column reordering (horizontal movement) - COLUMN_REORDER: { - // duration: 3000, - duration: 180, - easing: "cubic-bezier(0.2, 0.0, 0.2, 1)", - delay: 0, - }, // For row reordering (vertical movement) ROW_REORDER: { - // duration: 3000, - duration: 200, + duration: 3000, easing: "cubic-bezier(0.2, 0.0, 0.2, 1)", delay: 0, }, // For reduced motion users REDUCED_MOTION: { - // duration: 3000, duration: 150, // Even faster for reduced motion easing: "ease-out", delay: 0, @@ -187,26 +178,15 @@ const animateToFinalPosition = ( }; /** - * Get appropriate animation config based on movement type and user preferences + * Get appropriate animation config based on user preferences */ -export const getAnimationConfig = ( - options: FlipAnimationOptions = {}, - movementType?: "column" | "row" -): AnimationConfig => { +export const getAnimationConfig = (options: FlipAnimationOptions = {}): AnimationConfig => { // Check for user's motion preferences first if (prefersReducedMotion()) { return { ...ANIMATION_CONFIGS.REDUCED_MOTION, ...options }; } - // Use specific config based on movement type - if (movementType === "column") { - return { ...ANIMATION_CONFIGS.COLUMN_REORDER, ...options }; - } - if (movementType === "row") { - return { ...ANIMATION_CONFIGS.ROW_REORDER, ...options }; - } - - // Fall back to default config + // Use row reorder config as default return { ...ANIMATION_CONFIGS.ROW_REORDER, ...options }; }; @@ -243,12 +223,8 @@ export const flipElement = async ({ return () => {}; } - // Determine movement type based on the invert values - const isColumnMovement = Math.abs(invert.x) > Math.abs(invert.y); - const movementType = isColumnMovement ? "column" : "row"; - - // Get appropriate config based on movement type and user preferences - const config = getAnimationConfig(finalConfig, movementType); + // Get appropriate config based on user preferences + const config = getAnimationConfig(finalConfig); // Apply new transform BEFORE cleaning up old one to prevent flickering // This ensures there's no gap where the element snaps to its DOM position diff --git a/src/components/simple-table/SimpleTable.tsx b/src/components/simple-table/SimpleTable.tsx index 35822cf52..62ed6abce 100644 --- a/src/components/simple-table/SimpleTable.tsx +++ b/src/components/simple-table/SimpleTable.tsx @@ -733,7 +733,6 @@ const SimpleTableComp = ({ } = useTableRowProcessing({ allowAnimations, flattenedRows, - originalFlattenedRows, currentPage, rowsPerPage, shouldPaginate, diff --git a/src/hooks/useTableRowProcessing.ts b/src/hooks/useTableRowProcessing.ts index 171049475..470aa375e 100644 --- a/src/hooks/useTableRowProcessing.ts +++ b/src/hooks/useTableRowProcessing.ts @@ -11,8 +11,6 @@ interface UseTableRowProcessingProps { allowAnimations: boolean; /** Already flattened rows from useFlattenedRows */ flattenedRows: TableRow[]; - /** Original flattened rows for establishing baseline positions */ - originalFlattenedRows: TableRow[]; currentPage: number; rowsPerPage: number; shouldPaginate: boolean; @@ -30,7 +28,6 @@ interface UseTableRowProcessingProps { const useTableRowProcessing = ({ allowAnimations, flattenedRows, - originalFlattenedRows, currentPage, rowsPerPage, shouldPaginate, @@ -231,11 +228,12 @@ const useTableRowProcessing = ({ return [...existingRows, ...uniqueNewRows]; }); - // Track rows that are leaving the DOM (currently visible but won't be visible after filter) + // Track rows that are leaving the DOM (currently visible but won't exist in new dataset after filter) setRowsLeavingTheDom((existingRows) => { - // Create set of IDs that will be visible after filter - const newVisibleIds = new Set( - newVisibleRows.map((tableRow) => + // CRITICAL: Create set of IDs that will exist in the FULL paginated dataset (not just viewport) + // A row is only "leaving" if it's being filtered out entirely, not just scrolling out of view + const newPaginatedIds = new Set( + newPaginatedRows.map((tableRow) => String( getRowId({ row: tableRow.row, @@ -260,7 +258,7 @@ const useTableRowProcessing = ({ ]) ); - // Find rows from targetVisibleRows that won't be visible after filter + // Find rows from targetVisibleRows that won't exist in the new paginated dataset (filtered out) const candidateLeavingRows = targetVisibleRows.filter((tableRow) => { const id = String( getRowId({ @@ -269,7 +267,8 @@ const useTableRowProcessing = ({ rowPath: tableRow.rowPath, }) ); - return !newVisibleIds.has(id); + // Only consider it "leaving" if it's not in the new paginated dataset at all + return !newPaginatedIds.has(id); }); // Separate into rows that need position updates vs truly new leaving rows @@ -284,21 +283,12 @@ const useTableRowProcessing = ({ rowPath: leavingRow.rowPath, }) ); - // Find this row in the NEW processed rows to get its NEW position (after filter) - const rowInNewState = newPaginatedRows.find( - (newRow) => - String( - getRowId({ - row: newRow.row, - rowIdAccessor, - rowPath: newRow.rowPath, - }) - ) === id - ); - const rowWithNewPosition = rowInNewState || leavingRow; + // This row is being filtered out, so it won't have a position in newPaginatedRows + // Keep its current position for animation purposes + const rowWithNewPosition = leavingRow; if (existingRowsMap.has(id)) { - // Row is already leaving, update its position + // Row is already leaving, keep it rowsToUpdate.push(rowWithNewPosition); } else { // Row is newly leaving @@ -443,106 +433,9 @@ const useTableRowProcessing = ({ return [...existingRows, ...uniqueNewRows]; }); - // Track rows that are leaving the DOM (currently visible but won't be visible after sort) - setRowsLeavingTheDom((existingRows) => { - // Create set of IDs that will be visible after sort - const newVisibleIds = new Set( - newVisibleRows.map((tableRow) => - String( - getRowId({ - row: tableRow.row, - rowIdAccessor, - rowPath: tableRow.rowPath, - }) - ) - ) - ); - - // Create map of existing leaving rows for quick lookup - const existingRowsMap = new Map( - existingRows.map((tableRow) => [ - String( - getRowId({ - row: tableRow.row, - rowIdAccessor, - rowPath: tableRow.rowPath, - }) - ), - tableRow, - ]) - ); - - // Find rows from targetVisibleRows that won't be visible after sort - const candidateLeavingRows = targetVisibleRows.filter((tableRow) => { - const id = String( - getRowId({ - row: tableRow.row, - rowIdAccessor, - rowPath: tableRow.rowPath, - }) - ); - return !newVisibleIds.has(id); - }); - - // Separate into rows that need position updates vs truly new leaving rows - const rowsToUpdate: TableRow[] = []; - const newLeavingRows: TableRow[] = []; - - candidateLeavingRows.forEach((leavingRow) => { - const id = String( - getRowId({ - row: leavingRow.row, - rowIdAccessor, - rowPath: leavingRow.rowPath, - }) - ); - // Find this row in the NEW processed rows to get its NEW position (after sort) - const rowInNewState = newPaginatedRows.find( - (newRow) => - String( - getRowId({ - row: newRow.row, - rowIdAccessor, - rowPath: newRow.rowPath, - }) - ) === id - ); - const rowWithNewPosition = rowInNewState || leavingRow; - - if (existingRowsMap.has(id)) { - // Row is already leaving, update its position - rowsToUpdate.push(rowWithNewPosition); - } else { - // Row is newly leaving - newLeavingRows.push(rowWithNewPosition); - } - }); - - // Update existing rows with new positions, keep rows not in update list unchanged - const updatedExistingRows = existingRows.map((tableRow) => { - const id = String( - getRowId({ - row: tableRow.row, - rowIdAccessor, - rowPath: tableRow.rowPath, - }) - ); - const updatedRow = rowsToUpdate.find( - (r) => - String( - getRowId({ - row: r.row, - rowIdAccessor, - rowPath: r.rowPath, - }) - ) === id - ); - return updatedRow || tableRow; - }); - - // Combine updated existing rows with new leaving rows - return [...updatedExistingRows, ...newLeavingRows]; - }); + // For sorting, rows don't actually "leave" - they just reorder + // So we don't add any rows to rowsLeavingTheDom for sort operations + // Rows will animate in place as they reorder }, [ allowAnimations, diff --git a/src/stories/examples/BasicExample.tsx b/src/stories/examples/BasicExample.tsx index 27d6c2aec..5c19ad734 100644 --- a/src/stories/examples/BasicExample.tsx +++ b/src/stories/examples/BasicExample.tsx @@ -47,7 +47,7 @@ const BasicExampleComponent = (props: UniversalTableProps) => {
From 40825eb44a73192c6f0aa72b49e39e8ee45b5b6c Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Wed, 24 Dec 2025 00:09:16 -0500 Subject: [PATCH 3/8] Progress --- src/hooks/useTableRowProcessing.ts | 3 +-- src/stories/examples/BasicExample.tsx | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/hooks/useTableRowProcessing.ts b/src/hooks/useTableRowProcessing.ts index 470aa375e..e4e97e236 100644 --- a/src/hooks/useTableRowProcessing.ts +++ b/src/hooks/useTableRowProcessing.ts @@ -1,8 +1,7 @@ -import { useMemo, useState, useLayoutEffect, useCallback, useRef } from "react"; +import { useMemo, useState, useCallback } from "react"; import { calculateBufferRowCount } from "../consts/general-consts"; import { getVisibleRows } from "../utils/infiniteScrollUtils"; import { getRowId } from "../utils/rowUtils"; -import { ANIMATION_CONFIGS } from "../components/animate/animation-utils"; import { Accessor } from "../types/HeaderObject"; import { FilterCondition } from "../types/FilterTypes"; import TableRow from "../types/TableRow"; diff --git a/src/stories/examples/BasicExample.tsx b/src/stories/examples/BasicExample.tsx index 5c19ad734..e0a536a04 100644 --- a/src/stories/examples/BasicExample.tsx +++ b/src/stories/examples/BasicExample.tsx @@ -47,7 +47,7 @@ const BasicExampleComponent = (props: UniversalTableProps) => {
From 1f04137a208514efaba32a375c424249c79e3a5d Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Wed, 24 Dec 2025 00:09:16 -0500 Subject: [PATCH 4/8] Progress --- src/components/animate/Animate.tsx | 28 +++++++++++++++++++++ src/components/simple-table/SimpleTable.tsx | 13 ---------- src/hooks/useTableRowProcessing.ts | 2 ++ src/stories/examples/BasicExample.tsx | 2 +- 4 files changed, 31 insertions(+), 14 deletions(-) diff --git a/src/components/animate/Animate.tsx b/src/components/animate/Animate.tsx index 77a1a13b6..a3c0ca0c2 100644 --- a/src/components/animate/Animate.tsx +++ b/src/components/animate/Animate.tsx @@ -40,6 +40,9 @@ export const Animate = ({ children, id, parentRef, tableRow, ...props }: Animate const cleanupCallbackRef = useRef<(() => void) | null>(null); const bufferRowCount = useMemo(() => calculateBufferRowCount(rowHeight), [rowHeight]); useLayoutEffect(() => { + if (id === "5-name") { + console.log("animate"); + } // Early exit if animations are disabled - don't do any work at all if (!allowAnimations) { return; @@ -93,6 +96,31 @@ export const Animate = ({ children, id, parentRef, tableRow, ...props }: Animate const deltaY = toBounds.y - fromBounds.y; const positionDelta = Math.abs(deltaX); + // LOG: Row 5 - Check visual position vs logical position + if (id === "5-name") { + // Get current visual position (with any transforms applied) + const currentVisualY = elementRef.current.getBoundingClientRect().y; + // Get current transform if any + const currentTransform = elementRef.current.style.transform; + const hasActiveAnimation = !!elementRef.current.style.transition; + + console.log( + "[Animate] Row 5 position check:", + JSON.stringify({ + logicalPosition: tableRow?.position, + fromY: fromBounds.y, + toY: toBounds.y, + currentVisualY, + deltaY, + hasCapturedPosition: !!capturedPosition, + currentTransform, + hasActiveAnimation, + willSkipAnimation: + positionDelta < COLUMN_REORDER_THRESHOLD && Math.abs(deltaY) <= ROW_REORDER_THRESHOLD, + }) + ); + } + // Only animate if position change is significant (indicates column/row reordering) if (positionDelta < COLUMN_REORDER_THRESHOLD && Math.abs(deltaY) <= ROW_REORDER_THRESHOLD) { return; diff --git a/src/components/simple-table/SimpleTable.tsx b/src/components/simple-table/SimpleTable.tsx index 62ed6abce..49ba4c2a5 100644 --- a/src/components/simple-table/SimpleTable.tsx +++ b/src/components/simple-table/SimpleTable.tsx @@ -612,19 +612,6 @@ const SimpleTableComp = ({ hasEmptyRenderer: Boolean(emptyStateRenderer), }); - // Also flatten the original aggregated rows for animation baseline positions - const originalFlattenedRows = useFlattenedRows({ - rows: aggregatedRows, - rowGrouping, - rowIdAccessor, - unexpandedRows, - expandAll, - rowStateMap, - hasLoadingRenderer: Boolean(loadingStateRenderer), - hasErrorRenderer: Boolean(errorStateRenderer), - hasEmptyRenderer: Boolean(emptyStateRenderer), - }); - // Create flattened preview functions for animations const computeFlattenedFilteredRowsPreview = useCallback( (filter: FilterCondition) => { diff --git a/src/hooks/useTableRowProcessing.ts b/src/hooks/useTableRowProcessing.ts index e4e97e236..1a4af4594 100644 --- a/src/hooks/useTableRowProcessing.ts +++ b/src/hooks/useTableRowProcessing.ts @@ -381,6 +381,7 @@ const useTableRowProcessing = ({ }) ) === id ); + return currentStateRow || newVisibleRow; // Fallback to newVisibleRow if not found in current state }) .filter(Boolean) as TableRow[]; @@ -425,6 +426,7 @@ const useTableRowProcessing = ({ rowPath: tableRow.rowPath, }) ); + return !targetVisibleIds.has(id) && !existingRowIds.has(id); }); diff --git a/src/stories/examples/BasicExample.tsx b/src/stories/examples/BasicExample.tsx index e0a536a04..dbb4fb15a 100644 --- a/src/stories/examples/BasicExample.tsx +++ b/src/stories/examples/BasicExample.tsx @@ -29,7 +29,7 @@ const BasicExampleComponent = (props: UniversalTableProps) => { // Define headers const headers: HeaderObject[] = [ - { accessor: "id", label: "ID", width: 80, isSortable: true, filterable: true }, + { accessor: "id", label: "ID", width: 120, isSortable: true, filterable: true }, { accessor: "name", label: "Name", From fa4a8dec39b7f985766fdf696ec831a979f02a54 Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Wed, 24 Dec 2025 08:19:59 -0500 Subject: [PATCH 5/8] Removing console.logs --- src/components/animate/Animate.tsx | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/src/components/animate/Animate.tsx b/src/components/animate/Animate.tsx index a3c0ca0c2..77a1a13b6 100644 --- a/src/components/animate/Animate.tsx +++ b/src/components/animate/Animate.tsx @@ -40,9 +40,6 @@ export const Animate = ({ children, id, parentRef, tableRow, ...props }: Animate const cleanupCallbackRef = useRef<(() => void) | null>(null); const bufferRowCount = useMemo(() => calculateBufferRowCount(rowHeight), [rowHeight]); useLayoutEffect(() => { - if (id === "5-name") { - console.log("animate"); - } // Early exit if animations are disabled - don't do any work at all if (!allowAnimations) { return; @@ -96,31 +93,6 @@ export const Animate = ({ children, id, parentRef, tableRow, ...props }: Animate const deltaY = toBounds.y - fromBounds.y; const positionDelta = Math.abs(deltaX); - // LOG: Row 5 - Check visual position vs logical position - if (id === "5-name") { - // Get current visual position (with any transforms applied) - const currentVisualY = elementRef.current.getBoundingClientRect().y; - // Get current transform if any - const currentTransform = elementRef.current.style.transform; - const hasActiveAnimation = !!elementRef.current.style.transition; - - console.log( - "[Animate] Row 5 position check:", - JSON.stringify({ - logicalPosition: tableRow?.position, - fromY: fromBounds.y, - toY: toBounds.y, - currentVisualY, - deltaY, - hasCapturedPosition: !!capturedPosition, - currentTransform, - hasActiveAnimation, - willSkipAnimation: - positionDelta < COLUMN_REORDER_THRESHOLD && Math.abs(deltaY) <= ROW_REORDER_THRESHOLD, - }) - ); - } - // Only animate if position change is significant (indicates column/row reordering) if (positionDelta < COLUMN_REORDER_THRESHOLD && Math.abs(deltaY) <= ROW_REORDER_THRESHOLD) { return; From ba6e243ac007aa445297199954edfc84978e0b18 Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Wed, 24 Dec 2025 09:05:31 -0500 Subject: [PATCH 6/8] Rows with same position after sort teleporting fix --- src/components/animate/animation-utils.ts | 17 +- src/components/simple-table/RenderCells.tsx | 83 +++++++- src/components/simple-table/TableCell.tsx | 201 ++++++++++++++++---- src/types/TableCellProps.ts | 63 +++++- 4 files changed, 309 insertions(+), 55 deletions(-) diff --git a/src/components/animate/animation-utils.ts b/src/components/animate/animation-utils.ts index 62c11f3b0..704fda4f0 100644 --- a/src/components/animate/animation-utils.ts +++ b/src/components/animate/animation-utils.ts @@ -15,16 +15,10 @@ export const prefersReducedMotion = (): boolean => { export const ANIMATION_CONFIGS = { // For row reordering (vertical movement) ROW_REORDER: { - duration: 3000, + duration: 10000, easing: "cubic-bezier(0.2, 0.0, 0.2, 1)", delay: 0, }, - // For reduced motion users - REDUCED_MOTION: { - duration: 150, // Even faster for reduced motion - easing: "ease-out", - delay: 0, - }, } as const; /** @@ -33,9 +27,7 @@ export const ANIMATION_CONFIGS = { export const createAnimationConfig = ( overrides: Partial = {} ): AnimationConfig => { - const baseConfig = prefersReducedMotion() - ? ANIMATION_CONFIGS.REDUCED_MOTION - : ANIMATION_CONFIGS.ROW_REORDER; // Default to row reorder as it's more common in tables + const baseConfig = ANIMATION_CONFIGS.ROW_REORDER; // Default to row reorder as it's more common in tables return { ...baseConfig, ...overrides }; }; @@ -181,11 +173,6 @@ const animateToFinalPosition = ( * Get appropriate animation config based on user preferences */ export const getAnimationConfig = (options: FlipAnimationOptions = {}): AnimationConfig => { - // Check for user's motion preferences first - if (prefersReducedMotion()) { - return { ...ANIMATION_CONFIGS.REDUCED_MOTION, ...options }; - } - // Use row reorder config as default return { ...ANIMATION_CONFIGS.ROW_REORDER, ...options }; }; diff --git a/src/components/simple-table/RenderCells.tsx b/src/components/simple-table/RenderCells.tsx index 67b4b277f..8e900fa0b 100644 --- a/src/components/simple-table/RenderCells.tsx +++ b/src/components/simple-table/RenderCells.tsx @@ -90,9 +90,46 @@ const RecursiveRenderCells = ({ // Get the column index for this header from our pre-calculated mapping const colIndex = columnIndices[header.accessor]; - // Get selection state for this cell - const { getBorderClass, isSelected, isInitialFocusedCell, rowIdAccessor, collapsedHeaders } = - useTableContext(); + // Get all context values to pass to TableCell + const { + getBorderClass, + isSelected, + isInitialFocusedCell, + rowIdAccessor, + collapsedHeaders, + // Additional context values needed by TableCell + canExpandRowGroup, + cellRegistry, + cellUpdateFlash, + columnBorders, + draggedHeaderRef, + enableRowSelection, + expandAll, + expandIcon, + handleMouseDown, + handleMouseOver, + handleRowSelect, + headers: allHeaders, + hoveredHeaderRef, + isCopyFlashing, + isLoading, + isRowSelected, + isWarningFlashing, + onCellEdit, + onCellClick, + onRowGroupExpand, + onTableHeaderDragEnd, + rowButtons, + rowGrouping, + setRowStateMap, + rowsWithSelectedCells, + selectedColumns, + setUnexpandedRows, + tableBodyContainerRef, + theme, + unexpandedRows, + useOddColumnBackground, + } = useTableContext(); // Calculate rowId once at the beginning (includes path for nested rows) const rowId = getRowId({ @@ -101,6 +138,42 @@ const RecursiveRenderCells = ({ rowPath: tableRow.rowPath, }); + // Create props object to pass to TableCell (avoids duplication) + // Provide defaults for optional context values + const tableCellContextProps = { + canExpandRowGroup, + cellRegistry, + cellUpdateFlash: cellUpdateFlash ?? false, + columnBorders, + draggedHeaderRef, + enableRowSelection: enableRowSelection ?? false, + expandAll, + expandIcon: expandIcon ?? null, + handleMouseDown, + handleMouseOver, + handleRowSelect, + headers: allHeaders, + hoveredHeaderRef, + isCopyFlashing, + isLoading: isLoading ?? false, + isRowSelected, + isWarningFlashing, + onCellEdit, + onCellClick, + onRowGroupExpand, + onTableHeaderDragEnd, + rowButtons, + rowGrouping, + setRowStateMap, + rowsWithSelectedCells, + selectedColumns, + setUnexpandedRows, + tableBodyContainerRef, + theme, + unexpandedRows, + useOddColumnBackground, + }; + if (header.children && header.children.length > 0) { const filteredChildren = header.children.filter((child) => displayCell({ header: child, pinned, headers, collapsedHeaders }) @@ -127,8 +200,10 @@ const RecursiveRenderCells = ({ key={parentCellKey} nestedIndex={nestedIndex} parentHeader={parentHeader} + rowIdAccessor={rowIdAccessor} rowIndex={rowIndex} tableRow={tableRow} + {...tableCellContextProps} /> {filteredChildren.map((child) => { const childCellKey = getCellId({ accessor: child.accessor, rowId }); @@ -196,8 +271,10 @@ const RecursiveRenderCells = ({ key={tableCellKey} nestedIndex={nestedIndex} parentHeader={parentHeader} + rowIdAccessor={rowIdAccessor} rowIndex={rowIndex} tableRow={tableRow} + {...tableCellContextProps} /> ); }; diff --git a/src/components/simple-table/TableCell.tsx b/src/components/simple-table/TableCell.tsx index 9ea6319c3..724c36d0d 100644 --- a/src/components/simple-table/TableCell.tsx +++ b/src/components/simple-table/TableCell.tsx @@ -6,7 +6,7 @@ import useDragHandler from "../../hooks/useDragHandler"; import { DRAG_THROTTLE_LIMIT } from "../../consts/general-consts"; import { getCellId, getCellKey } from "../../utils/cellUtils"; import TableCellProps from "../../types/TableCellProps"; -import { useTableContext } from "../../context/TableContext"; +// Context import removed - all values now passed as props import HeaderObject from "../../types/HeaderObject"; import { formatDate } from "../../utils/formatters"; import { @@ -104,43 +104,51 @@ const TableCell = ({ nestedIndex, parentHeader, rowIndex, + rowIdAccessor, tableRow, + // Context values now passed as props + canExpandRowGroup, + cellRegistry, + cellUpdateFlash, + columnBorders, + draggedHeaderRef, + enableRowSelection, + expandAll, + expandIcon, + handleMouseDown, + handleMouseOver, + handleRowSelect, + headers, + hoveredHeaderRef, + isCopyFlashing, + isLoading, + isRowSelected, + isWarningFlashing, + onCellEdit, + onCellClick, + onRowGroupExpand, + onTableHeaderDragEnd, + rowButtons, + rowGrouping, + setRowStateMap, + rowsWithSelectedCells, + selectedColumns, + setUnexpandedRows, + tableBodyContainerRef, + theme, + unexpandedRows, + useOddColumnBackground, }: TableCellProps) => { - // Get shared props from context - const { - canExpandRowGroup, - cellRegistry, - cellUpdateFlash, - columnBorders, - draggedHeaderRef, - enableRowSelection, - expandAll, - expandIcon, - handleMouseDown, - handleMouseOver, - handleRowSelect, - headers, - hoveredHeaderRef, - isCopyFlashing, - isLoading, - isRowSelected, - isWarningFlashing, - onCellEdit, - onCellClick, - onRowGroupExpand, - onTableHeaderDragEnd, - rowButtons, - rowGrouping, + // DEBUG: Log when component body executes + const debugRowId = getRowId({ + row: tableRow.row, rowIdAccessor, - setRowStateMap, - rowsWithSelectedCells, - selectedColumns, - setUnexpandedRows, - tableBodyContainerRef, - theme, - unexpandedRows, - useOddColumnBackground, - } = useTableContext(); + rowPath: tableRow.rowPath, + }); + const debugCellId = getCellId({ accessor: header.accessor, rowId: debugRowId }); + if (debugCellId === "5-name") { + console.log("🔴 TableCell BODY EXECUTING for 5-name"); + } const { depth, row, rowPath, absoluteRowIndex } = tableRow; @@ -445,6 +453,10 @@ const TableCell = ({ ] ); + if (cellId === "5-name") { + console.log("re-render"); + } + // Handle keyboard events when cell is focused const handleKeyDown = (e: KeyboardEvent) => { // If we're editing or this is a selection column, don't handle table navigation keys @@ -681,6 +693,18 @@ const TableCell = ({ * Only re-renders when essential props that affect display have changed */ const arePropsEqual = (prevProps: TableCellProps, nextProps: TableCellProps): boolean => { + const rowId = getRowId({ + row: prevProps.tableRow.row, + rowIdAccessor: prevProps.rowIdAccessor, + rowPath: prevProps.tableRow.rowPath, + }); + const prevCellId = getCellId({ accessor: prevProps.header.accessor, rowId }); + + if (prevCellId === "5-name") { + console.log("\n"); + console.log(prevProps); + console.log(nextProps); + } // Quick reference checks for props that change frequently if ( prevProps.rowIndex !== nextProps.rowIndex || @@ -689,6 +713,9 @@ const arePropsEqual = (prevProps: TableCellProps, nextProps: TableCellProps): bo prevProps.isInitialFocused !== nextProps.isInitialFocused || prevProps.borderClass !== nextProps.borderClass ) { + if (prevCellId === "5-name") { + console.log("re-render"); + } return false; } @@ -696,6 +723,9 @@ const arePropsEqual = (prevProps: TableCellProps, nextProps: TableCellProps): bo if (prevProps.tableRow !== nextProps.tableRow) { // If references differ, check if the underlying row data is the same if (prevProps.tableRow.row !== nextProps.tableRow.row) { + if (prevCellId === "5-name") { + console.log("row data changed"); + } return false; } // If row data is same but position changed, need to re-render for animations @@ -703,6 +733,9 @@ const arePropsEqual = (prevProps: TableCellProps, nextProps: TableCellProps): bo prevProps.tableRow.position !== nextProps.tableRow.position || prevProps.tableRow.displayPosition !== nextProps.tableRow.displayPosition ) { + if (prevCellId === "5-name") { + console.log("position changed"); + } return false; } } @@ -717,25 +750,123 @@ const arePropsEqual = (prevProps: TableCellProps, nextProps: TableCellProps): bo prevProps.header.cellRenderer !== nextProps.header.cellRenderer || prevProps.header.valueFormatter !== nextProps.header.valueFormatter ) { + if (prevCellId === "5-name") { + console.log("header changed"); + } return false; } } // Parent header reference check if (prevProps.parentHeader !== nextProps.parentHeader) { + if (prevCellId === "5-name") { + console.log("parent header changed"); + } return false; } // Display row number check if (prevProps.displayRowNumber !== nextProps.displayRowNumber) { + if (prevCellId === "5-name") { + console.log("display row number changed"); + } return false; } // Nested index check if (prevProps.nestedIndex !== nextProps.nestedIndex) { + if (prevCellId === "5-name") { + console.log("nested index changed"); + } return false; } + // Check rowIdAccessor + if (prevProps.rowIdAccessor !== nextProps.rowIdAccessor) { + if (prevCellId === "5-name") { + console.log("rowIdAccessor changed"); + } + return false; + } + + // Check context props that might change and affect rendering + // Most context props are stable references (functions, refs) so we skip them + // Only check props that can actually change and affect the cell's display + if ( + prevProps.isLoading !== nextProps.isLoading || + prevProps.expandAll !== nextProps.expandAll || + prevProps.enableRowSelection !== nextProps.enableRowSelection || + prevProps.columnBorders !== nextProps.columnBorders || + prevProps.cellUpdateFlash !== nextProps.cellUpdateFlash || + prevProps.useOddColumnBackground !== nextProps.useOddColumnBackground || + prevProps.theme !== nextProps.theme + ) { + if (prevCellId === "5-name") { + console.log("context props changed"); + } + return false; + } + + // Check if unexpandedRows Set changed (by reference) + if (prevProps.unexpandedRows !== nextProps.unexpandedRows) { + if (prevCellId === "5-name") { + console.log("unexpandedRows changed"); + } + return false; + } + + // Check if selectedColumns Set changed (by reference) + if (prevProps.selectedColumns !== nextProps.selectedColumns) { + if (prevCellId === "5-name") { + console.log("selectedColumns changed"); + } + return false; + } + + // Check if rowsWithSelectedCells Set changed (by reference) + if (prevProps.rowsWithSelectedCells !== nextProps.rowsWithSelectedCells) { + if (prevCellId === "5-name") { + console.log("rowsWithSelectedCells changed"); + } + return false; + } + + // Check if headers array changed (by reference) + if (prevProps.headers !== nextProps.headers) { + if (prevCellId === "5-name") { + console.log("headers changed"); + } + return false; + } + + // Check if rowGrouping array changed (by reference) + if (prevProps.rowGrouping !== nextProps.rowGrouping) { + if (prevCellId === "5-name") { + console.log("rowGrouping changed"); + } + return false; + } + + // Check if rowButtons array changed (by reference) + if (prevProps.rowButtons !== nextProps.rowButtons) { + if (prevCellId === "5-name") { + console.log("rowButtons changed"); + } + return false; + } + + // Skip checking these props as they are stable references that rarely/never change: + // - cellRegistry, draggedHeaderRef, hoveredHeaderRef, tableBodyContainerRef (MutableRefObjects) + // - setRowStateMap, setUnexpandedRows (setState functions) + // - handleMouseDown, handleMouseOver, handleRowSelect (functions) + // - isCopyFlashing, isWarningFlashing, isRowSelected (functions) + // - onCellEdit, onCellClick, onRowGroupExpand, onTableHeaderDragEnd (functions) + // - canExpandRowGroup (function) + // - expandIcon (ReactNode - should be stable) + + if (prevCellId === "5-name") { + console.log("all checks passed - skip re-render"); + } // If all checks pass, props are equal - skip re-render return true; }; diff --git a/src/types/TableCellProps.ts b/src/types/TableCellProps.ts index 982763b8f..300fbb47a 100644 --- a/src/types/TableCellProps.ts +++ b/src/types/TableCellProps.ts @@ -1,6 +1,10 @@ -import HeaderObject from "./HeaderObject"; +import HeaderObject, { Accessor } from "./HeaderObject"; import TableRow from "./TableRow"; -import Cell from "./Cell"; +import { MutableRefObject, ReactNode } from "react"; +import Row from "./Row"; +import CellValue from "./CellValue"; +import { RowButton } from "./RowButton"; +import Theme from "./Theme"; export interface TableCellProps { borderClass?: string; @@ -11,8 +15,63 @@ export interface TableCellProps { isInitialFocused?: boolean; nestedIndex: number; parentHeader?: HeaderObject; + rowIdAccessor: Accessor; rowIndex: number; tableRow: TableRow; + + // Context values passed as props to avoid context re-renders + // These match the context types - optional ones are truly optional in context + canExpandRowGroup?: (row: Row) => boolean; + cellRegistry?: Map void }>; + cellUpdateFlash: boolean; + columnBorders: boolean; + draggedHeaderRef: MutableRefObject; + enableRowSelection: boolean; + expandAll: boolean; + expandIcon: ReactNode; + handleMouseDown: (props: { rowIndex: number; colIndex: number; rowId: string | number }) => void; + handleMouseOver: (props: { rowIndex: number; colIndex: number; rowId: string | number }) => void; + handleRowSelect?: (rowId: string, selected: boolean) => void; + headers: HeaderObject[]; + hoveredHeaderRef: MutableRefObject; + isCopyFlashing: (props: { + rowIndex: number; + colIndex: number; + rowId: string | number; + }) => boolean; + isLoading: boolean; + isRowSelected?: (rowId: string) => boolean; + isWarningFlashing: (props: { + rowIndex: number; + colIndex: number; + rowId: string | number; + }) => boolean; + onCellEdit?: (props: { + accessor: Accessor; + newValue: CellValue; + row: Row; + rowIndex: number; + }) => void; + onCellClick?: (props: { + accessor: Accessor; + colIndex: number; + row: Row; + rowId: string | number; + rowIndex: number; + value: CellValue; + }) => void; + onRowGroupExpand?: (props: any) => void; + onTableHeaderDragEnd: (newHeaders: HeaderObject[]) => void; + rowButtons?: RowButton[]; + rowGrouping?: string[]; + setRowStateMap: React.Dispatch>>; + rowsWithSelectedCells: Set; + selectedColumns: Set; + setUnexpandedRows: React.Dispatch>>; + tableBodyContainerRef: MutableRefObject; + theme: Theme; + unexpandedRows: Set; + useOddColumnBackground: boolean; } export default TableCellProps; From f568f5df44bf9ebe25d2234b6f4109e5b8fcb8fd Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Wed, 24 Dec 2025 10:49:10 -0500 Subject: [PATCH 7/8] Context refactor --- src/components/simple-table/RenderCells.tsx | 84 +---- src/components/simple-table/TableCell.tsx | 188 +++------- src/context/TableContext.tsx | 386 ++++++++++++-------- src/context/TableContexts.tsx | 220 +++++++++++ src/stories/examples/BasicExample.tsx | 2 +- src/types/TableCellProps.ts | 63 +--- 6 files changed, 514 insertions(+), 429 deletions(-) create mode 100644 src/context/TableContexts.tsx diff --git a/src/components/simple-table/RenderCells.tsx b/src/components/simple-table/RenderCells.tsx index 8e900fa0b..dde56bf8d 100644 --- a/src/components/simple-table/RenderCells.tsx +++ b/src/components/simple-table/RenderCells.tsx @@ -90,46 +90,13 @@ const RecursiveRenderCells = ({ // Get the column index for this header from our pre-calculated mapping const colIndex = columnIndices[header.accessor]; - // Get all context values to pass to TableCell - const { - getBorderClass, - isSelected, - isInitialFocusedCell, - rowIdAccessor, - collapsedHeaders, - // Additional context values needed by TableCell - canExpandRowGroup, - cellRegistry, - cellUpdateFlash, - columnBorders, - draggedHeaderRef, - enableRowSelection, - expandAll, - expandIcon, - handleMouseDown, - handleMouseOver, - handleRowSelect, - headers: allHeaders, - hoveredHeaderRef, - isCopyFlashing, - isLoading, - isRowSelected, - isWarningFlashing, - onCellEdit, - onCellClick, - onRowGroupExpand, - onTableHeaderDragEnd, - rowButtons, - rowGrouping, - setRowStateMap, - rowsWithSelectedCells, - selectedColumns, - setUnexpandedRows, - tableBodyContainerRef, - theme, - unexpandedRows, - useOddColumnBackground, - } = useTableContext(); + // Get context values for cell selection and dynamic state + const { getBorderClass, isSelected, isInitialFocusedCell, rowIdAccessor, collapsedHeaders } = + useTableContext(); + + // Get only dynamic values to pass to TableCell + const { expandAll, isLoading, rowsWithSelectedCells, selectedColumns, unexpandedRows } = + useTableContext(); // Calculate rowId once at the beginning (includes path for nested rows) const rowId = getRowId({ @@ -138,40 +105,13 @@ const RecursiveRenderCells = ({ rowPath: tableRow.rowPath, }); - // Create props object to pass to TableCell (avoids duplication) - // Provide defaults for optional context values - const tableCellContextProps = { - canExpandRowGroup, - cellRegistry, - cellUpdateFlash: cellUpdateFlash ?? false, - columnBorders, - draggedHeaderRef, - enableRowSelection: enableRowSelection ?? false, + // Create props object with only dynamic values to pass to TableCell + const tableCellDynamicProps = { expandAll, - expandIcon: expandIcon ?? null, - handleMouseDown, - handleMouseOver, - handleRowSelect, - headers: allHeaders, - hoveredHeaderRef, - isCopyFlashing, isLoading: isLoading ?? false, - isRowSelected, - isWarningFlashing, - onCellEdit, - onCellClick, - onRowGroupExpand, - onTableHeaderDragEnd, - rowButtons, - rowGrouping, - setRowStateMap, rowsWithSelectedCells, selectedColumns, - setUnexpandedRows, - tableBodyContainerRef, - theme, unexpandedRows, - useOddColumnBackground, }; if (header.children && header.children.length > 0) { @@ -200,10 +140,9 @@ const RecursiveRenderCells = ({ key={parentCellKey} nestedIndex={nestedIndex} parentHeader={parentHeader} - rowIdAccessor={rowIdAccessor} rowIndex={rowIndex} tableRow={tableRow} - {...tableCellContextProps} + {...tableCellDynamicProps} /> {filteredChildren.map((child) => { const childCellKey = getCellId({ accessor: child.accessor, rowId }); @@ -271,10 +210,9 @@ const RecursiveRenderCells = ({ key={tableCellKey} nestedIndex={nestedIndex} parentHeader={parentHeader} - rowIdAccessor={rowIdAccessor} rowIndex={rowIndex} tableRow={tableRow} - {...tableCellContextProps} + {...tableCellDynamicProps} /> ); }; diff --git a/src/components/simple-table/TableCell.tsx b/src/components/simple-table/TableCell.tsx index 724c36d0d..3af89dee7 100644 --- a/src/components/simple-table/TableCell.tsx +++ b/src/components/simple-table/TableCell.tsx @@ -6,7 +6,7 @@ import useDragHandler from "../../hooks/useDragHandler"; import { DRAG_THROTTLE_LIMIT } from "../../consts/general-consts"; import { getCellId, getCellKey } from "../../utils/cellUtils"; import TableCellProps from "../../types/TableCellProps"; -// Context import removed - all values now passed as props +import { useTableStaticContext } from "../../context/TableContext"; import HeaderObject from "../../types/HeaderObject"; import { formatDate } from "../../utils/formatters"; import { @@ -104,51 +104,44 @@ const TableCell = ({ nestedIndex, parentHeader, rowIndex, - rowIdAccessor, tableRow, - // Context values now passed as props - canExpandRowGroup, - cellRegistry, - cellUpdateFlash, - columnBorders, - draggedHeaderRef, - enableRowSelection, + // Dynamic context values passed as props expandAll, - expandIcon, - handleMouseDown, - handleMouseOver, - handleRowSelect, - headers, - hoveredHeaderRef, - isCopyFlashing, isLoading, - isRowSelected, - isWarningFlashing, - onCellEdit, - onCellClick, - onRowGroupExpand, - onTableHeaderDragEnd, - rowButtons, - rowGrouping, - setRowStateMap, rowsWithSelectedCells, selectedColumns, - setUnexpandedRows, - tableBodyContainerRef, - theme, unexpandedRows, - useOddColumnBackground, }: TableCellProps) => { - // DEBUG: Log when component body executes - const debugRowId = getRowId({ - row: tableRow.row, + // Get static context values (won't cause re-renders when dynamic state changes) + const { + canExpandRowGroup, + cellRegistry, + cellUpdateFlash, + columnBorders, + draggedHeaderRef, + enableRowSelection, + expandIcon, + handleMouseDown, + handleMouseOver, + handleRowSelect, + headers, + hoveredHeaderRef, + isCopyFlashing, + isRowSelected, + isWarningFlashing, + onCellEdit, + onCellClick, + onRowGroupExpand, + onTableHeaderDragEnd, + rowButtons, + rowGrouping, rowIdAccessor, - rowPath: tableRow.rowPath, - }); - const debugCellId = getCellId({ accessor: header.accessor, rowId: debugRowId }); - if (debugCellId === "5-name") { - console.log("🔴 TableCell BODY EXECUTING for 5-name"); - } + setRowStateMap, + setUnexpandedRows, + tableBodyContainerRef, + theme, + useOddColumnBackground, + } = useTableStaticContext(); const { depth, row, rowPath, absoluteRowIndex } = tableRow; @@ -453,10 +446,6 @@ const TableCell = ({ ] ); - if (cellId === "5-name") { - console.log("re-render"); - } - // Handle keyboard events when cell is focused const handleKeyDown = (e: KeyboardEvent) => { // If we're editing or this is a selection column, don't handle table navigation keys @@ -689,22 +678,12 @@ const TableCell = ({ /** * Custom comparison function for React.memo optimization - * Checks if props have actually changed to prevent unnecessary re-renders - * Only re-renders when essential props that affect display have changed + * With split contexts, we only need to check: + * 1. Core cell props (rowIndex, colIndex, etc.) + * 2. Dynamic context props (expandAll, isLoading, etc.) + * Static context values don't trigger re-renders since they're from a separate context */ const arePropsEqual = (prevProps: TableCellProps, nextProps: TableCellProps): boolean => { - const rowId = getRowId({ - row: prevProps.tableRow.row, - rowIdAccessor: prevProps.rowIdAccessor, - rowPath: prevProps.tableRow.rowPath, - }); - const prevCellId = getCellId({ accessor: prevProps.header.accessor, rowId }); - - if (prevCellId === "5-name") { - console.log("\n"); - console.log(prevProps); - console.log(nextProps); - } // Quick reference checks for props that change frequently if ( prevProps.rowIndex !== nextProps.rowIndex || @@ -713,9 +692,6 @@ const arePropsEqual = (prevProps: TableCellProps, nextProps: TableCellProps): bo prevProps.isInitialFocused !== nextProps.isInitialFocused || prevProps.borderClass !== nextProps.borderClass ) { - if (prevCellId === "5-name") { - console.log("re-render"); - } return false; } @@ -723,9 +699,6 @@ const arePropsEqual = (prevProps: TableCellProps, nextProps: TableCellProps): bo if (prevProps.tableRow !== nextProps.tableRow) { // If references differ, check if the underlying row data is the same if (prevProps.tableRow.row !== nextProps.tableRow.row) { - if (prevCellId === "5-name") { - console.log("row data changed"); - } return false; } // If row data is same but position changed, need to re-render for animations @@ -733,11 +706,12 @@ const arePropsEqual = (prevProps: TableCellProps, nextProps: TableCellProps): bo prevProps.tableRow.position !== nextProps.tableRow.position || prevProps.tableRow.displayPosition !== nextProps.tableRow.displayPosition ) { - if (prevCellId === "5-name") { - console.log("position changed"); - } return false; } + // CRITICAL: If row data and position are the same, treat tableRow as equal + // even if other properties changed (like absoluteRowIndex, isLastGroupRow, etc.) + // This prevents interrupting ongoing animations when a new sort/filter is applied + // but the row ends up at the same position. } // Header comparison - compare by reference and key properties @@ -750,124 +724,46 @@ const arePropsEqual = (prevProps: TableCellProps, nextProps: TableCellProps): bo prevProps.header.cellRenderer !== nextProps.header.cellRenderer || prevProps.header.valueFormatter !== nextProps.header.valueFormatter ) { - if (prevCellId === "5-name") { - console.log("header changed"); - } return false; } } // Parent header reference check if (prevProps.parentHeader !== nextProps.parentHeader) { - if (prevCellId === "5-name") { - console.log("parent header changed"); - } return false; } // Display row number check if (prevProps.displayRowNumber !== nextProps.displayRowNumber) { - if (prevCellId === "5-name") { - console.log("display row number changed"); - } return false; } // Nested index check if (prevProps.nestedIndex !== nextProps.nestedIndex) { - if (prevCellId === "5-name") { - console.log("nested index changed"); - } return false; } - // Check rowIdAccessor - if (prevProps.rowIdAccessor !== nextProps.rowIdAccessor) { - if (prevCellId === "5-name") { - console.log("rowIdAccessor changed"); - } - return false; - } - - // Check context props that might change and affect rendering - // Most context props are stable references (functions, refs) so we skip them - // Only check props that can actually change and affect the cell's display - if ( - prevProps.isLoading !== nextProps.isLoading || - prevProps.expandAll !== nextProps.expandAll || - prevProps.enableRowSelection !== nextProps.enableRowSelection || - prevProps.columnBorders !== nextProps.columnBorders || - prevProps.cellUpdateFlash !== nextProps.cellUpdateFlash || - prevProps.useOddColumnBackground !== nextProps.useOddColumnBackground || - prevProps.theme !== nextProps.theme - ) { - if (prevCellId === "5-name") { - console.log("context props changed"); - } + // Check dynamic context props (these are the only context values passed as props now) + if (prevProps.isLoading !== nextProps.isLoading || prevProps.expandAll !== nextProps.expandAll) { return false; } // Check if unexpandedRows Set changed (by reference) if (prevProps.unexpandedRows !== nextProps.unexpandedRows) { - if (prevCellId === "5-name") { - console.log("unexpandedRows changed"); - } return false; } // Check if selectedColumns Set changed (by reference) if (prevProps.selectedColumns !== nextProps.selectedColumns) { - if (prevCellId === "5-name") { - console.log("selectedColumns changed"); - } return false; } // Check if rowsWithSelectedCells Set changed (by reference) if (prevProps.rowsWithSelectedCells !== nextProps.rowsWithSelectedCells) { - if (prevCellId === "5-name") { - console.log("rowsWithSelectedCells changed"); - } - return false; - } - - // Check if headers array changed (by reference) - if (prevProps.headers !== nextProps.headers) { - if (prevCellId === "5-name") { - console.log("headers changed"); - } - return false; - } - - // Check if rowGrouping array changed (by reference) - if (prevProps.rowGrouping !== nextProps.rowGrouping) { - if (prevCellId === "5-name") { - console.log("rowGrouping changed"); - } return false; } - // Check if rowButtons array changed (by reference) - if (prevProps.rowButtons !== nextProps.rowButtons) { - if (prevCellId === "5-name") { - console.log("rowButtons changed"); - } - return false; - } - - // Skip checking these props as they are stable references that rarely/never change: - // - cellRegistry, draggedHeaderRef, hoveredHeaderRef, tableBodyContainerRef (MutableRefObjects) - // - setRowStateMap, setUnexpandedRows (setState functions) - // - handleMouseDown, handleMouseOver, handleRowSelect (functions) - // - isCopyFlashing, isWarningFlashing, isRowSelected (functions) - // - onCellEdit, onCellClick, onRowGroupExpand, onTableHeaderDragEnd (functions) - // - canExpandRowGroup (function) - // - expandIcon (ReactNode - should be stable) - - if (prevCellId === "5-name") { - console.log("all checks passed - skip re-render"); - } - // If all checks pass, props are equal - skip re-render + // All checks passed - props are equal, skip re-render return true; }; diff --git a/src/context/TableContext.tsx b/src/context/TableContext.tsx index 883e3cece..db8c3cc9c 100644 --- a/src/context/TableContext.tsx +++ b/src/context/TableContext.tsx @@ -1,151 +1,14 @@ -import { - ReactNode, - RefObject, - MutableRefObject, - createContext, - useContext, - Dispatch, - SetStateAction, -} from "react"; -import { TableFilterState, FilterCondition } from "../types/FilterTypes"; -import TableRow from "../types/TableRow"; -import Cell from "../types/Cell"; -import HeaderObject, { Accessor } from "../types/HeaderObject"; -import OnSortProps from "../types/OnSortProps"; -import Theme from "../types/Theme"; -import CellValue from "../types/CellValue"; -import CellClickProps from "../types/CellClickProps"; -import { RowButton } from "../types/RowButton"; -import { HeaderDropdown } from "../types/HeaderDropdownProps"; -import OnRowGroupExpandProps from "../types/OnRowGroupExpandProps"; -import RowState from "../types/RowState"; -import Row from "../types/Row"; -import { - LoadingStateRenderer, - ErrorStateRenderer, - EmptyStateRenderer, -} from "../types/RowStateRendererProps"; +import { ReactNode, useMemo } from "react"; +import { TableStaticProvider, TableDynamicProvider, TableContextType } from "./TableContexts"; -// Define the interface for cell registry entries -export interface CellRegistryEntry { - updateContent: (newValue: CellValue) => void; -} - -// Define the interface for header cell registry entries -export interface HeaderRegistryEntry { - setEditing: (isEditing: boolean) => void; -} - -interface TableContextType { - // Stable values that don't change frequently - activeHeaderDropdown?: HeaderObject | null; - allowAnimations?: boolean; - areAllRowsSelected?: () => boolean; - autoExpandColumns?: boolean; - canExpandRowGroup?: (row: Row) => boolean; - capturedPositionsRef: MutableRefObject>; - cellRegistry?: Map; - cellUpdateFlash?: boolean; - clearSelection?: () => void; - collapsedHeaders: Set; - columnBorders: boolean; - columnReordering: boolean; - columnResizing: boolean; - copyHeadersToClipboard: boolean; - draggedHeaderRef: MutableRefObject; - editColumns?: boolean; - enableHeaderEditing?: boolean; - enableRowSelection?: boolean; - expandAll: boolean; - expandIcon?: ReactNode; - filterIcon?: ReactNode; - filters: TableFilterState; - includeHeadersInCSVExport: boolean; - loadingStateRenderer?: LoadingStateRenderer; - errorStateRenderer?: ErrorStateRenderer; - emptyStateRenderer?: EmptyStateRenderer; - forceUpdate: () => void; - rowStateMap: Map; - setRowStateMap: Dispatch>>; - rows: Row[]; - getBorderClass: (cell: Cell) => string; - handleApplyFilter: (filter: FilterCondition) => void; - handleClearAllFilters: () => void; - handleClearFilter: (accessor: Accessor) => void; - handleMouseDown: (cell: Cell) => void; - handleMouseOver: (cell: Cell) => void; - handleRowSelect?: (rowId: string, isSelected: boolean) => void; - handleSelectAll?: (isSelected: boolean) => void; - handleToggleRow?: (rowId: string) => void; - headerCollapseIcon?: ReactNode; - headerContainerRef: RefObject; - headerDropdown?: HeaderDropdown; - headerExpandIcon?: ReactNode; - headerRegistry?: Map; - headers: HeaderObject[]; - hoveredHeaderRef: MutableRefObject; - isAnimating: boolean; - isCopyFlashing: (cell: Cell) => boolean; - isInitialFocusedCell: (cell: Cell) => boolean; - isLoading?: boolean; - isResizing: boolean; - isRowSelected?: (rowId: string) => boolean; - isScrolling: boolean; - isSelected: (cell: Cell) => boolean; - isWarningFlashing: (cell: Cell) => boolean; - mainBodyRef: RefObject; - nextIcon: ReactNode; - onCellEdit?: (props: any) => void; - onCellClick?: (props: CellClickProps) => void; - onColumnOrderChange?: (newHeaders: HeaderObject[]) => void; - onColumnSelect?: (header: HeaderObject) => void; - onHeaderEdit?: (header: HeaderObject, newLabel: string) => void; - onLoadMore?: () => void; - onRowGroupExpand?: (props: OnRowGroupExpandProps) => void | Promise; - onSort: OnSortProps; - onTableHeaderDragEnd: (newHeaders: HeaderObject[]) => void; - pinnedLeftRef: RefObject; - pinnedRightRef: RefObject; - prevIcon: ReactNode; - rowButtons?: RowButton[]; - rowGrouping?: Accessor[]; - rowHeight: number; - headerHeight: number; - rowIdAccessor: Accessor; - scrollbarWidth: number; - selectColumns?: (columnIndices: number[], isShiftKey?: boolean) => void; - selectableColumns: boolean; - selectedColumns: Set; - columnsWithSelectedCells: Set; - rowsWithSelectedCells: Set; - selectedRows?: Set; - selectedRowCount?: number; - selectedRowsData?: any[]; - setActiveHeaderDropdown?: Dispatch>; - setCollapsedHeaders: Dispatch>>; - setHeaders: Dispatch>; - setInitialFocusedCell: Dispatch>; - setIsResizing: Dispatch>; - setIsScrolling: Dispatch>; - setSelectedCells: Dispatch>>; - setSelectedColumns: Dispatch>>; - setSelectedRows?: Dispatch>>; - setUnexpandedRows: Dispatch>>; - shouldPaginate: boolean; - sortDownIcon: ReactNode; - sortUpIcon: ReactNode; - tableBodyContainerRef: RefObject; - tableEmptyStateRenderer?: ReactNode; - tableRows: TableRow[]; - theme: Theme; - unexpandedRows: Set; - useHoverRowBackground: boolean; - useOddColumnBackground: boolean; - useOddEvenRowBackground: boolean; -} - -export const TableContext = createContext(undefined); +// Re-export everything from the new split contexts +export * from "./TableContexts"; +export type { TableContextType } from "./TableContexts"; +/** + * Combined TableProvider that wraps both Static and Dynamic contexts + * This maintains backward compatibility while enabling the split context optimization + */ export const TableProvider = ({ children, value, @@ -153,13 +16,228 @@ export const TableProvider = ({ children: ReactNode; value: TableContextType; }) => { - return {children}; -}; + // Split the value into static and dynamic parts + // Static values are memoized and only change when configuration changes + const staticValue = useMemo( + () => ({ + // Configuration + allowAnimations: value.allowAnimations, + autoExpandColumns: value.autoExpandColumns, + canExpandRowGroup: value.canExpandRowGroup, + cellUpdateFlash: value.cellUpdateFlash, + columnBorders: value.columnBorders, + columnReordering: value.columnReordering, + columnResizing: value.columnResizing, + copyHeadersToClipboard: value.copyHeadersToClipboard, + editColumns: value.editColumns, + enableHeaderEditing: value.enableHeaderEditing, + enableRowSelection: value.enableRowSelection, + includeHeadersInCSVExport: value.includeHeadersInCSVExport, + rowHeight: value.rowHeight, + headerHeight: value.headerHeight, + rowIdAccessor: value.rowIdAccessor, + scrollbarWidth: value.scrollbarWidth, + selectableColumns: value.selectableColumns, + shouldPaginate: value.shouldPaginate, + theme: value.theme, + useHoverRowBackground: value.useHoverRowBackground, + useOddColumnBackground: value.useOddColumnBackground, + useOddEvenRowBackground: value.useOddEvenRowBackground, + + // Refs (always stable) + capturedPositionsRef: value.capturedPositionsRef, + draggedHeaderRef: value.draggedHeaderRef, + hoveredHeaderRef: value.hoveredHeaderRef, + headerContainerRef: value.headerContainerRef, + mainBodyRef: value.mainBodyRef, + pinnedLeftRef: value.pinnedLeftRef, + pinnedRightRef: value.pinnedRightRef, + tableBodyContainerRef: value.tableBodyContainerRef, + + // Registries + cellRegistry: value.cellRegistry, + headerRegistry: value.headerRegistry, + + // Callbacks (should be memoized in parent) + forceUpdate: value.forceUpdate, + getBorderClass: value.getBorderClass, + handleApplyFilter: value.handleApplyFilter, + handleClearAllFilters: value.handleClearAllFilters, + handleClearFilter: value.handleClearFilter, + handleMouseDown: value.handleMouseDown, + handleMouseOver: value.handleMouseOver, + handleRowSelect: value.handleRowSelect, + handleSelectAll: value.handleSelectAll, + handleToggleRow: value.handleToggleRow, + isCopyFlashing: value.isCopyFlashing, + isInitialFocusedCell: value.isInitialFocusedCell, + isRowSelected: value.isRowSelected, + isSelected: value.isSelected, + isWarningFlashing: value.isWarningFlashing, + onCellEdit: value.onCellEdit, + onCellClick: value.onCellClick, + onColumnOrderChange: value.onColumnOrderChange, + onColumnSelect: value.onColumnSelect, + onHeaderEdit: value.onHeaderEdit, + onLoadMore: value.onLoadMore, + onRowGroupExpand: value.onRowGroupExpand, + onSort: value.onSort, + onTableHeaderDragEnd: value.onTableHeaderDragEnd, + selectColumns: value.selectColumns, + clearSelection: value.clearSelection, + areAllRowsSelected: value.areAllRowsSelected, + + // State setters (stable references) + setActiveHeaderDropdown: value.setActiveHeaderDropdown, + setCollapsedHeaders: value.setCollapsedHeaders, + setHeaders: value.setHeaders, + setInitialFocusedCell: value.setInitialFocusedCell, + setIsResizing: value.setIsResizing, + setIsScrolling: value.setIsScrolling, + setSelectedCells: value.setSelectedCells, + setSelectedColumns: value.setSelectedColumns, + setSelectedRows: value.setSelectedRows, + setUnexpandedRows: value.setUnexpandedRows, + setRowStateMap: value.setRowStateMap, + + // Arrays/Objects (should be memoized in parent) + headers: value.headers, + rowButtons: value.rowButtons, + rowGrouping: value.rowGrouping, + rows: value.rows, + + // Icons (stable) + expandIcon: value.expandIcon, + filterIcon: value.filterIcon, + headerCollapseIcon: value.headerCollapseIcon, + headerExpandIcon: value.headerExpandIcon, + nextIcon: value.nextIcon, + prevIcon: value.prevIcon, + sortDownIcon: value.sortDownIcon, + sortUpIcon: value.sortUpIcon, + + // Renderers (stable) + loadingStateRenderer: value.loadingStateRenderer, + errorStateRenderer: value.errorStateRenderer, + emptyStateRenderer: value.emptyStateRenderer, + tableEmptyStateRenderer: value.tableEmptyStateRenderer, + headerDropdown: value.headerDropdown, + }), + [ + value.allowAnimations, + value.autoExpandColumns, + value.canExpandRowGroup, + value.cellUpdateFlash, + value.columnBorders, + value.columnReordering, + value.columnResizing, + value.copyHeadersToClipboard, + value.editColumns, + value.enableHeaderEditing, + value.enableRowSelection, + value.includeHeadersInCSVExport, + value.rowHeight, + value.headerHeight, + value.rowIdAccessor, + value.scrollbarWidth, + value.selectableColumns, + value.shouldPaginate, + value.theme, + value.useHoverRowBackground, + value.useOddColumnBackground, + value.useOddEvenRowBackground, + value.capturedPositionsRef, + value.draggedHeaderRef, + value.hoveredHeaderRef, + value.headerContainerRef, + value.mainBodyRef, + value.pinnedLeftRef, + value.pinnedRightRef, + value.tableBodyContainerRef, + value.cellRegistry, + value.headerRegistry, + value.forceUpdate, + value.getBorderClass, + value.handleApplyFilter, + value.handleClearAllFilters, + value.handleClearFilter, + value.handleMouseDown, + value.handleMouseOver, + value.handleRowSelect, + value.handleSelectAll, + value.handleToggleRow, + value.isCopyFlashing, + value.isInitialFocusedCell, + value.isRowSelected, + value.isSelected, + value.isWarningFlashing, + value.onCellEdit, + value.onCellClick, + value.onColumnOrderChange, + value.onColumnSelect, + value.onHeaderEdit, + value.onLoadMore, + value.onRowGroupExpand, + value.onSort, + value.onTableHeaderDragEnd, + value.selectColumns, + value.clearSelection, + value.areAllRowsSelected, + value.setActiveHeaderDropdown, + value.setCollapsedHeaders, + value.setHeaders, + value.setInitialFocusedCell, + value.setIsResizing, + value.setIsScrolling, + value.setSelectedCells, + value.setSelectedColumns, + value.setSelectedRows, + value.setUnexpandedRows, + value.setRowStateMap, + value.headers, + value.rowButtons, + value.rowGrouping, + value.rows, + value.expandIcon, + value.filterIcon, + value.headerCollapseIcon, + value.headerExpandIcon, + value.nextIcon, + value.prevIcon, + value.sortDownIcon, + value.sortUpIcon, + value.loadingStateRenderer, + value.errorStateRenderer, + value.emptyStateRenderer, + value.tableEmptyStateRenderer, + value.headerDropdown, + ] + ); + + // Dynamic values change frequently - don't memoize + const dynamicValue = { + activeHeaderDropdown: value.activeHeaderDropdown, + collapsedHeaders: value.collapsedHeaders, + expandAll: value.expandAll, + filters: value.filters, + isAnimating: value.isAnimating, + isLoading: value.isLoading, + isResizing: value.isResizing, + isScrolling: value.isScrolling, + rowStateMap: value.rowStateMap, + selectedColumns: value.selectedColumns, + columnsWithSelectedCells: value.columnsWithSelectedCells, + rowsWithSelectedCells: value.rowsWithSelectedCells, + selectedRows: value.selectedRows, + selectedRowCount: value.selectedRowCount, + selectedRowsData: value.selectedRowsData, + tableRows: value.tableRows, + unexpandedRows: value.unexpandedRows, + }; -export const useTableContext = () => { - const context = useContext(TableContext); - if (context === undefined) { - throw new Error("useTableContext must be used within a TableProvider"); - } - return context; + return ( + + {children} + + ); }; diff --git a/src/context/TableContexts.tsx b/src/context/TableContexts.tsx new file mode 100644 index 000000000..8bcf4e049 --- /dev/null +++ b/src/context/TableContexts.tsx @@ -0,0 +1,220 @@ +import { + ReactNode, + RefObject, + MutableRefObject, + createContext, + useContext, + Dispatch, + SetStateAction, +} from "react"; +import { TableFilterState, FilterCondition } from "../types/FilterTypes"; +import TableRow from "../types/TableRow"; +import Cell from "../types/Cell"; +import HeaderObject, { Accessor } from "../types/HeaderObject"; +import OnSortProps from "../types/OnSortProps"; +import Theme from "../types/Theme"; +import CellValue from "../types/CellValue"; +import CellClickProps from "../types/CellClickProps"; +import { RowButton } from "../types/RowButton"; +import { HeaderDropdown } from "../types/HeaderDropdownProps"; +import OnRowGroupExpandProps from "../types/OnRowGroupExpandProps"; +import RowState from "../types/RowState"; +import Row from "../types/Row"; +import { + LoadingStateRenderer, + ErrorStateRenderer, + EmptyStateRenderer, +} from "../types/RowStateRendererProps"; + +// Define the interface for cell registry entries +export interface CellRegistryEntry { + updateContent: (newValue: CellValue) => void; +} + +// Define the interface for header cell registry entries +export interface HeaderRegistryEntry { + setEditing: (isEditing: boolean) => void; +} + +/** + * Static Context - Contains stable values that rarely/never change + * Components using this context won't re-render when dynamic state changes + */ +interface TableStaticContextType { + // Configuration (set once, rarely changes) + allowAnimations?: boolean; + autoExpandColumns?: boolean; + canExpandRowGroup?: (row: Row) => boolean; + cellUpdateFlash?: boolean; + columnBorders: boolean; + columnReordering: boolean; + columnResizing: boolean; + copyHeadersToClipboard: boolean; + editColumns?: boolean; + enableHeaderEditing?: boolean; + enableRowSelection?: boolean; + includeHeadersInCSVExport: boolean; + rowHeight: number; + headerHeight: number; + rowIdAccessor: Accessor; + scrollbarWidth: number; + selectableColumns: boolean; + shouldPaginate: boolean; + theme: Theme; + useHoverRowBackground: boolean; + useOddColumnBackground: boolean; + useOddEvenRowBackground: boolean; + + // Refs (stable references) + capturedPositionsRef: MutableRefObject>; + draggedHeaderRef: MutableRefObject; + hoveredHeaderRef: MutableRefObject; + headerContainerRef: RefObject; + mainBodyRef: RefObject; + pinnedLeftRef: RefObject; + pinnedRightRef: RefObject; + tableBodyContainerRef: RefObject; + + // Registries (stable references) + cellRegistry?: Map; + headerRegistry?: Map; + + // Stable callback functions (created once with useCallback) + forceUpdate: () => void; + getBorderClass: (cell: Cell) => string; + handleApplyFilter: (filter: FilterCondition) => void; + handleClearAllFilters: () => void; + handleClearFilter: (accessor: Accessor) => void; + handleMouseDown: (cell: Cell) => void; + handleMouseOver: (cell: Cell) => void; + handleRowSelect?: (rowId: string, isSelected: boolean) => void; + handleSelectAll?: (isSelected: boolean) => void; + handleToggleRow?: (rowId: string) => void; + isCopyFlashing: (cell: Cell) => boolean; + isInitialFocusedCell: (cell: Cell) => boolean; + isRowSelected?: (rowId: string) => boolean; + isSelected: (cell: Cell) => boolean; + isWarningFlashing: (cell: Cell) => boolean; + onCellEdit?: (props: any) => void; + onCellClick?: (props: CellClickProps) => void; + onColumnOrderChange?: (newHeaders: HeaderObject[]) => void; + onColumnSelect?: (header: HeaderObject) => void; + onHeaderEdit?: (header: HeaderObject, newLabel: string) => void; + onLoadMore?: () => void; + onRowGroupExpand?: (props: OnRowGroupExpandProps) => void | Promise; + onSort: OnSortProps; + onTableHeaderDragEnd: (newHeaders: HeaderObject[]) => void; + selectColumns?: (columnIndices: number[], isShiftKey?: boolean) => void; + clearSelection?: () => void; + areAllRowsSelected?: () => boolean; + + // State setters (stable references) + setActiveHeaderDropdown?: Dispatch>; + setCollapsedHeaders: Dispatch>>; + setHeaders: Dispatch>; + setInitialFocusedCell: Dispatch>; + setIsResizing: Dispatch>; + setIsScrolling: Dispatch>; + setSelectedCells: Dispatch>>; + setSelectedColumns: Dispatch>>; + setSelectedRows?: Dispatch>>; + setUnexpandedRows: Dispatch>>; + setRowStateMap: Dispatch>>; + + // Arrays/Objects that should be stable (memoized) + headers: HeaderObject[]; + rowButtons?: RowButton[]; + rowGrouping?: Accessor[]; + rows: Row[]; + + // Icons (stable ReactNodes) + expandIcon?: ReactNode; + filterIcon?: ReactNode; + headerCollapseIcon?: ReactNode; + headerExpandIcon?: ReactNode; + nextIcon: ReactNode; + prevIcon: ReactNode; + sortDownIcon: ReactNode; + sortUpIcon: ReactNode; + + // Renderers (stable functions/components) + loadingStateRenderer?: LoadingStateRenderer; + errorStateRenderer?: ErrorStateRenderer; + emptyStateRenderer?: EmptyStateRenderer; + tableEmptyStateRenderer?: ReactNode; + headerDropdown?: HeaderDropdown; +} + +/** + * Dynamic Context - Contains values that change frequently + * Only components that need these values should subscribe to this context + */ +interface TableDynamicContextType { + // Frequently changing state + activeHeaderDropdown?: HeaderObject | null; + collapsedHeaders: Set; + expandAll: boolean; + filters: TableFilterState; + isAnimating: boolean; + isLoading?: boolean; + isResizing: boolean; + isScrolling: boolean; + rowStateMap: Map; + selectedColumns: Set; + columnsWithSelectedCells: Set; + rowsWithSelectedCells: Set; + selectedRows?: Set; + selectedRowCount?: number; + selectedRowsData?: any[]; + tableRows: TableRow[]; + unexpandedRows: Set; +} + +export const TableStaticContext = createContext(undefined); +export const TableDynamicContext = createContext(undefined); + +export const TableStaticProvider = ({ + children, + value, +}: { + children: ReactNode; + value: TableStaticContextType; +}) => { + return {children}; +}; + +export const TableDynamicProvider = ({ + children, + value, +}: { + children: ReactNode; + value: TableDynamicContextType; +}) => { + return {children}; +}; + +export const useTableStaticContext = () => { + const context = useContext(TableStaticContext); + if (context === undefined) { + throw new Error("useTableStaticContext must be used within a TableStaticProvider"); + } + return context; +}; + +export const useTableDynamicContext = () => { + const context = useContext(TableDynamicContext); + if (context === undefined) { + throw new Error("useTableDynamicContext must be used within a TableDynamicProvider"); + } + return context; +}; + +// Keep the original combined context and hook for backward compatibility +// This allows gradual migration of components +export interface TableContextType extends TableStaticContextType, TableDynamicContextType {} + +export const useTableContext = (): TableContextType => { + const staticContext = useTableStaticContext(); + const dynamicContext = useTableDynamicContext(); + return { ...staticContext, ...dynamicContext }; +}; diff --git a/src/stories/examples/BasicExample.tsx b/src/stories/examples/BasicExample.tsx index dbb4fb15a..68ca06c31 100644 --- a/src/stories/examples/BasicExample.tsx +++ b/src/stories/examples/BasicExample.tsx @@ -47,7 +47,7 @@ const BasicExampleComponent = (props: UniversalTableProps) => { diff --git a/src/types/TableCellProps.ts b/src/types/TableCellProps.ts index 300fbb47a..d5bad30d7 100644 --- a/src/types/TableCellProps.ts +++ b/src/types/TableCellProps.ts @@ -1,12 +1,13 @@ -import HeaderObject, { Accessor } from "./HeaderObject"; +import HeaderObject from "./HeaderObject"; import TableRow from "./TableRow"; -import { MutableRefObject, ReactNode } from "react"; -import Row from "./Row"; -import CellValue from "./CellValue"; -import { RowButton } from "./RowButton"; -import Theme from "./Theme"; +/** + * TableCell Props - Only includes dynamic values that change frequently + * Static/stable values are accessed via useTableStaticContext() inside the component + * This allows React.memo to work effectively + */ export interface TableCellProps { + // Core cell identification props borderClass?: string; colIndex: number; displayRowNumber: number; @@ -15,63 +16,15 @@ export interface TableCellProps { isInitialFocused?: boolean; nestedIndex: number; parentHeader?: HeaderObject; - rowIdAccessor: Accessor; rowIndex: number; tableRow: TableRow; - // Context values passed as props to avoid context re-renders - // These match the context types - optional ones are truly optional in context - canExpandRowGroup?: (row: Row) => boolean; - cellRegistry?: Map void }>; - cellUpdateFlash: boolean; - columnBorders: boolean; - draggedHeaderRef: MutableRefObject; - enableRowSelection: boolean; + // Dynamic context values (change frequently, passed as props) expandAll: boolean; - expandIcon: ReactNode; - handleMouseDown: (props: { rowIndex: number; colIndex: number; rowId: string | number }) => void; - handleMouseOver: (props: { rowIndex: number; colIndex: number; rowId: string | number }) => void; - handleRowSelect?: (rowId: string, selected: boolean) => void; - headers: HeaderObject[]; - hoveredHeaderRef: MutableRefObject; - isCopyFlashing: (props: { - rowIndex: number; - colIndex: number; - rowId: string | number; - }) => boolean; isLoading: boolean; - isRowSelected?: (rowId: string) => boolean; - isWarningFlashing: (props: { - rowIndex: number; - colIndex: number; - rowId: string | number; - }) => boolean; - onCellEdit?: (props: { - accessor: Accessor; - newValue: CellValue; - row: Row; - rowIndex: number; - }) => void; - onCellClick?: (props: { - accessor: Accessor; - colIndex: number; - row: Row; - rowId: string | number; - rowIndex: number; - value: CellValue; - }) => void; - onRowGroupExpand?: (props: any) => void; - onTableHeaderDragEnd: (newHeaders: HeaderObject[]) => void; - rowButtons?: RowButton[]; - rowGrouping?: string[]; - setRowStateMap: React.Dispatch>>; rowsWithSelectedCells: Set; selectedColumns: Set; - setUnexpandedRows: React.Dispatch>>; - tableBodyContainerRef: MutableRefObject; - theme: Theme; unexpandedRows: Set; - useOddColumnBackground: boolean; } export default TableCellProps; From 4e754eb4475187f79afbd8a647842fcbb8c20b43 Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Wed, 24 Dec 2025 13:05:55 -0500 Subject: [PATCH 8/8] Rows re-animating after finish fix --- src/components/animate/Animate.tsx | 10 ++++++++-- src/components/animate/animation-utils.ts | 6 ++---- src/hooks/useTableRowProcessing.ts | 4 +++- src/stories/examples/BasicExample.tsx | 2 +- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/components/animate/Animate.tsx b/src/components/animate/Animate.tsx index 77a1a13b6..1f63bb4a3 100644 --- a/src/components/animate/Animate.tsx +++ b/src/components/animate/Animate.tsx @@ -7,7 +7,7 @@ import { calculateBufferRowCount, ROW_SEPARATOR_WIDTH } from "../../consts/gener // Animation thresholds const COLUMN_REORDER_THRESHOLD = 50; // px - minimum horizontal movement to trigger column reorder animation -const ROW_REORDER_THRESHOLD = 5; // px - minimum vertical movement to trigger row reorder animation +const ROW_REORDER_THRESHOLD = 10; // px - minimum vertical movement to trigger row reorder animation const DOM_POSITION_CHANGE_THRESHOLD = 5; // px - minimum position change to detect DOM movement // Stagger configuration @@ -39,6 +39,7 @@ export const Animate = ({ children, id, parentRef, tableRow, ...props }: Animate const previousResizingState = usePrevious(isResizing); const cleanupCallbackRef = useRef<(() => void) | null>(null); const bufferRowCount = useMemo(() => calculateBufferRowCount(rowHeight), [rowHeight]); + useLayoutEffect(() => { // Early exit if animations are disabled - don't do any work at all if (!allowAnimations) { @@ -146,8 +147,13 @@ export const Animate = ({ children, id, parentRef, tableRow, ...props }: Animate const finalConfig = { ...ANIMATION_CONFIGS.ROW_REORDER, onComplete: () => { - // Reset z-index after animation completes + // CRITICAL: Update fromBoundsRef to final position BEFORE resetting styles + // This prevents the next useLayoutEffect from seeing a stale position if (elementRef.current) { + const finalBounds = elementRef.current.getBoundingClientRect(); + fromBoundsRef.current = finalBounds; + + // Reset z-index after animation completes elementRef.current.style.zIndex = ""; elementRef.current.style.position = ""; elementRef.current.style.top = ""; diff --git a/src/components/animate/animation-utils.ts b/src/components/animate/animation-utils.ts index 704fda4f0..7bc826eca 100644 --- a/src/components/animate/animation-utils.ts +++ b/src/components/animate/animation-utils.ts @@ -1,4 +1,3 @@ -import CellValue from "../../types/CellValue"; import { AnimationConfig, FlipAnimationOptions, CustomAnimationOptions } from "./types"; /** @@ -15,7 +14,7 @@ export const prefersReducedMotion = (): boolean => { export const ANIMATION_CONFIGS = { // For row reordering (vertical movement) ROW_REORDER: { - duration: 10000, + duration: 3000, easing: "cubic-bezier(0.2, 0.0, 0.2, 1)", delay: 0, }, @@ -108,8 +107,7 @@ const cleanupAnimation = (element: HTMLElement) => { const animateToFinalPosition = ( element: HTMLElement, config: AnimationConfig, - options: FlipAnimationOptions = {}, - id?: CellValue + options: FlipAnimationOptions = {} ): Promise<() => void> => { return new Promise((resolve) => { // Force a reflow to ensure the initial transform is applied diff --git a/src/hooks/useTableRowProcessing.ts b/src/hooks/useTableRowProcessing.ts index 1a4af4594..f38fc3b78 100644 --- a/src/hooks/useTableRowProcessing.ts +++ b/src/hooks/useTableRowProcessing.ts @@ -339,7 +339,9 @@ const useTableRowProcessing = ({ const prepareForSortChange = useCallback( (accessor: Accessor, targetVisibleRows: TableRow[], capturePositions?: () => void) => { - if (!allowAnimations || shouldPaginate || contentHeight === undefined) return; + if (!allowAnimations || shouldPaginate || contentHeight === undefined) { + return; + } // CRITICAL: Capture positions of existing leaving rows BEFORE updating them // This prevents teleporting when their positions change diff --git a/src/stories/examples/BasicExample.tsx b/src/stories/examples/BasicExample.tsx index 68ca06c31..a67203267 100644 --- a/src/stories/examples/BasicExample.tsx +++ b/src/stories/examples/BasicExample.tsx @@ -29,7 +29,7 @@ const BasicExampleComponent = (props: UniversalTableProps) => { // Define headers const headers: HeaderObject[] = [ - { accessor: "id", label: "ID", width: 120, isSortable: true, filterable: true }, + { accessor: "id", label: "ID", width: 120, isSortable: true, filterable: true, type: "number" }, { accessor: "name", label: "Name",