Implement virtual scrolling in Table component for improved performance

- Added useRef to track container and first row for height measurement.
- Introduced rowHeight state to dynamically calculate row height.
- Implemented range state to manage visible rows based on scroll position.
- Added scroll event listener to compute visible range efficiently.
- Adjusted rendering logic to display only a subset of rows based on the computed range.
- Included top and bottom spacers to maintain table height during scrolling.
- Refactored input change handling to remain functional with new rendering logic.
This commit is contained in:
2025-08-14 11:37:28 +02:00
parent 214a3cb3c8
commit 9d64e7c274
2 changed files with 8170 additions and 869 deletions

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import Cookies from "js-cookie";
import { useQuery } from "@tanstack/react-query";
import { getTableData, readCachedTableData } from "../utils/userHandler";
@@ -20,6 +20,14 @@ const Table: React.FC = () => {
const [rows, setRows] = useState<DataPackage[]>([]); // holds normalized cache view
const [files, setFiles] = useState<File[]>([]);
const [search, setSearch] = useState("");
const containerRef = useRef<HTMLDivElement | null>(null);
const firstRowRef = useRef<HTMLTableRowElement | null>(null);
const [rowHeight, setRowHeight] = useState<number>(0);
const [range, setRange] = useState<{ start: number; end: number }>({
start: 0,
end: 0,
});
const OVERSCAN = 100; // Render 100 rows above and below the viewport
// Einheitliche Input-Styles (nur Tailwind)
const inputClasses =
@@ -69,19 +77,6 @@ const Table: React.FC = () => {
return () => window.removeEventListener("storage", handler);
}, []);
// Handles input changes for table rows
const handleInputChange = (
losnummer: string,
field: keyof DataPackage,
value: string
) => {
setRows((prevRows) =>
prevRows.map((row) =>
row.losnummer === losnummer ? { ...row, [field]: value } : row
)
);
};
// Filter rows by search query (case-insensitive)
const filteredRows = useMemo(() => {
const q = search.trim().toLowerCase();
@@ -101,6 +96,80 @@ const Table: React.FC = () => {
});
}, [rows, search]);
// Measure row height once the first visible row mounts
useEffect(() => {
if (!firstRowRef.current) return;
const h = firstRowRef.current.offsetHeight;
if (h && h !== rowHeight) setRowHeight(h);
}, [firstRowRef.current, rowHeight, range.start]);
// Compute the current visible range based on scroll position and container size
const recomputeRange = () => {
const container = containerRef.current;
if (!container) return;
const total = filteredRows.length;
if (total === 0) {
if (range.start !== 0 || range.end !== 0) setRange({ start: 0, end: 0 });
return;
}
const rh = rowHeight || 52; // Fallback estimate until measured
const scrollTop = container.scrollTop;
const containerHeight = container.clientHeight || 0;
const firstVisible = Math.floor(scrollTop / rh);
const visibleCount = Math.max(1, Math.ceil(containerHeight / rh));
const start = Math.max(0, firstVisible - OVERSCAN);
const end = Math.min(total, firstVisible + visibleCount + OVERSCAN);
if (start !== range.start || end !== range.end) setRange({ start, end });
};
// Attach scroll listener
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const onScroll = () => {
// use requestAnimationFrame to keep it smooth
requestAnimationFrame(recomputeRange);
};
el.addEventListener("scroll", onScroll, { passive: true });
// Initial compute
recomputeRange();
return () => el.removeEventListener("scroll", onScroll);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rowHeight, filteredRows.length]);
// Recompute range whenever data set changes significantly (search, data load)
useEffect(() => {
// Reset scroll position when filter changes to show top of list
const el = containerRef.current;
if (el) el.scrollTop = 0;
// Next frame compute range for new data
requestAnimationFrame(recomputeRange);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [search, rows.length]);
// Handles input changes for table rows
const handleInputChange = (
losnummer: string,
field: keyof DataPackage,
value: string
) => {
setRows((prevRows) =>
prevRows.map((row) =>
row.losnummer === losnummer ? { ...row, [field]: value } : row
)
);
};
// (filteredRows defined above)
const start = range.start;
const end =
range.end > 0 ? range.end : Math.min(filteredRows.length, 2 * OVERSCAN);
const visibleSlice = filteredRows.slice(start, end);
const rh = rowHeight || 52;
const topPadding = start * rh;
const bottomPadding = Math.max(0, (filteredRows.length - end) * rh);
return (
<>
<SubHeaderAdmin
@@ -128,7 +197,10 @@ const Table: React.FC = () => {
</button>
</div>
*/}
<div className="overflow-auto rounded-lg shadow ring-1 ring-black/5">
<div
ref={containerRef}
className="overflow-auto rounded-lg shadow ring-1 ring-black/5"
>
<table className="min-w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50 sticky top-0">
<tr>
@@ -191,10 +263,16 @@ const Table: React.FC = () => {
</td>
</tr>
)}
{filteredRows.map((row, idx) => (
{filteredRows.length > 0 && (
<tr key="top-spacer" style={{ height: topPadding }}>
<td colSpan={8} />
</tr>
)}
{visibleSlice.map((row, i) => (
<tr
key={row.losnummer ?? idx}
key={row.losnummer ?? start + i}
className="hover:bg-gray-50 transition-colors"
ref={i === 0 ? firstRowRef : undefined}
>
<td className="px-4 py-2 font-mono text-xs text-gray-900 w-10 min-w-[2.5rem]">
<input
@@ -289,6 +367,11 @@ const Table: React.FC = () => {
</td>
</tr>
))}
{filteredRows.length > 0 && (
<tr key="bottom-spacer" style={{ height: bottomPadding }}>
<td colSpan={8} />
</tr>
)}
</tbody>
</table>
</div>