diff --git a/fixReactVirtualized.ts b/fixReactVirtualized.ts deleted file mode 100644 index 6f64b81802..0000000000 --- a/fixReactVirtualized.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* eslint-disable security/detect-non-literal-fs-filename */ -import { readFile, writeFile } from "fs/promises"; -import path from "path"; -import { fileURLToPath } from "url"; -import type { PluginOption } from "vite"; - -export const reactVirtualized = (): PluginOption => { - const wrongCode = `import { bpfrpt_proptype_WindowScroller } from "../WindowScroller.js";`; - - return { - name: "my:react-virtualized", - async configResolved() { - const reactVirtualizedPath = path.dirname(fileURLToPath(import.meta.resolve("react-virtualized"))); - - const brokenFilePath = path.join( - reactVirtualizedPath, - "..", - "es", - "WindowScroller", - "utils", - "onScroll.js" - ); - const brokenCode = await readFile(brokenFilePath, "utf-8"); - - const fixedCode = brokenCode.replace(wrongCode, ""); - await writeFile(brokenFilePath, fixedCode); - }, - }; -}; diff --git a/package-lock.json b/package-lock.json index 0e92f342eb..0132c9dd4a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "@monaco-editor/react": "^4.6.0", "@netlify/edge-functions": "^2.11.1", "@tanstack/react-table": "^8.21.3", - "@tanstack/react-virtual": "^3.13.13", + "@tanstack/react-virtual": "^3.13.14", "@tremor/react": "^3.18.7", "@types/lodash": "^4.17.13", "@types/pako": "^2.0.3", @@ -66,7 +66,6 @@ "react-select": "^5.9.0", "react-time-ago": "^7.3.3", "react-timezone-select": "^3.2.8", - "react-virtualized": "^9.22.5", "remark-gfm": "^4.0.1", "remark-github-blockquote-alert": "^1.3.0", "swiper": "^11.1.15", @@ -105,7 +104,6 @@ "@types/randomatic": "^3.1.5", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.1", - "@types/react-virtualized": "^9.22.0", "@types/react-window": "^1.8.8", "@typescript-eslint/eslint-plugin": "^8.21.0", "@typescript-eslint/parser": "^8.18.2", @@ -5651,12 +5649,12 @@ } }, "node_modules/@tanstack/react-virtual": { - "version": "3.13.13", - "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.13.tgz", - "integrity": "sha512-4o6oPMDvQv+9gMi8rE6gWmsOjtUZUYIJHv7EB+GblyYdi8U6OqLl8rhHWIUZSL1dUU2dPwTdTgybCKf9EjIrQg==", + "version": "3.13.14", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.14.tgz", + "integrity": "sha512-WG0d7mBD54eA7dgA3+sO5csS0B49QKqM6Gy5Rf31+Oq/LTKROQSao9m2N/vz1IqVragOKU5t5k1LAcqh/DfTxw==", "license": "MIT", "dependencies": { - "@tanstack/virtual-core": "3.13.13" + "@tanstack/virtual-core": "3.13.14" }, "funding": { "type": "github", @@ -5681,9 +5679,9 @@ } }, "node_modules/@tanstack/virtual-core": { - "version": "3.13.13", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.13.tgz", - "integrity": "sha512-uQFoSdKKf5S8k51W5t7b2qpfkyIbdHMzAn+AMQvHPxKUPeo1SsGaA4JRISQT87jm28b7z8OEqPcg1IOZagQHcA==", + "version": "3.13.14", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.14.tgz", + "integrity": "sha512-b5Uvd8J2dc7ICeX9SRb/wkCxWk7pUwN214eEPAQsqrsktSKTCmyLxOQWSMgogBByXclZeAdgZ3k4o0fIYUIBqQ==", "license": "MIT", "funding": { "type": "github", @@ -6418,17 +6416,6 @@ "@types/react": "*" } }, - "node_modules/@types/react-virtualized": { - "version": "9.22.2", - "resolved": "https://registry.npmjs.org/@types/react-virtualized/-/react-virtualized-9.22.2.tgz", - "integrity": "sha512-0Eg/ME3OHYWGxs+/n4VelfYrhXssireZaa1Uqj5SEkTpSaBu5ctFGOCVxcOqpGXRiEdrk/7uho9tlZaryCIjHA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/prop-types": "*", - "@types/react": "*" - } - }, "node_modules/@types/react-window": { "version": "1.8.8", "resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz", @@ -21326,12 +21313,6 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, - "node_modules/react-lifecycles-compat": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", - "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", - "license": "MIT" - }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", @@ -21541,33 +21522,6 @@ "react-dom": ">=16.8.0" } }, - "node_modules/react-virtualized": { - "version": "9.22.6", - "resolved": "https://registry.npmjs.org/react-virtualized/-/react-virtualized-9.22.6.tgz", - "integrity": "sha512-U5j7KuUQt3AaMatlMJ0UJddqSiX+Km0YJxSqbAzIiGw5EmNz0khMyqP2hzgu4+QUtm+QPIrxzUX4raJxmVJnHg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.7.2", - "clsx": "^1.0.4", - "dom-helpers": "^5.1.3", - "loose-envify": "^1.4.0", - "prop-types": "^15.7.2", - "react-lifecycles-compat": "^3.0.4" - }, - "peerDependencies": { - "react": "^16.3.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.3.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/react-virtualized/node_modules/clsx": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", - "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/react-window": { "version": "1.8.11", "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.11.tgz", diff --git a/package.json b/package.json index d4b651eb37..8dd6e9e3fa 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "@monaco-editor/react": "^4.6.0", "@netlify/edge-functions": "^2.11.1", "@tanstack/react-table": "^8.21.3", - "@tanstack/react-virtual": "^3.13.13", + "@tanstack/react-virtual": "^3.13.14", "@tremor/react": "^3.18.7", "@types/lodash": "^4.17.13", "@types/pako": "^2.0.3", @@ -110,7 +110,6 @@ "react-select": "^5.9.0", "react-time-ago": "^7.3.3", "react-timezone-select": "^3.2.8", - "react-virtualized": "^9.22.5", "remark-gfm": "^4.0.1", "remark-github-blockquote-alert": "^1.3.0", "swiper": "^11.1.15", @@ -149,7 +148,6 @@ "@types/randomatic": "^3.1.5", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.1", - "@types/react-virtualized": "^9.22.0", "@types/react-window": "^1.8.8", "@typescript-eslint/eslint-plugin": "^8.21.0", "@typescript-eslint/parser": "^8.18.2", diff --git a/src/components/atoms/table/tBody.tsx b/src/components/atoms/table/tBody.tsx index 37de842cc0..28573d46f6 100644 --- a/src/components/atoms/table/tBody.tsx +++ b/src/components/atoms/table/tBody.tsx @@ -1,19 +1,22 @@ -import React from "react"; +import React, { forwardRef } from "react"; import { TableProps } from "@interfaces/components"; import { cn } from "@utilities"; import { useTableVariant } from "@components/atoms/table"; -export const TBody = ({ children, className }: TableProps) => { +export const TBody = forwardRef(({ children, className }, ref) => { const { variant } = useTableVariant(); return (
{children}
); -}; +}); + +TBody.displayName = "TBody"; diff --git a/src/components/organisms/deployments/sessions/table/tableList.tsx b/src/components/organisms/deployments/sessions/table/tableList.tsx index 1afb75b4a3..0fb7d28222 100644 --- a/src/components/organisms/deployments/sessions/table/tableList.tsx +++ b/src/components/organisms/deployments/sessions/table/tableList.tsx @@ -1,7 +1,7 @@ -import React, { useCallback, useMemo, useState } from "react"; +import React, { useEffect, useMemo, useRef } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; import { useParams } from "react-router-dom"; -import { AutoSizer, List, ListRowRenderer } from "react-virtualized"; import { ModalName } from "@enums/components"; import { SessionsTableListProps } from "@interfaces/components"; @@ -22,13 +22,15 @@ export const SessionsTableList = ({ }: SessionsTableListProps) => { const { sessionId } = useParams(); const { openModal } = useModalStore(); - const [resizeHeight, setResizeHeight] = useState(0); + const parentRef = useRef(null); - const showDeleteModal = useCallback((id: string) => { - onSelectedSessionId(id); - openModal(ModalName.deleteDeploymentSession, id); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + const showDeleteModal = useMemo( + () => (id: string) => { + onSelectedSessionId(id); + openModal(ModalName.deleteDeploymentSession, id); + }, + [onSelectedSessionId, openModal] + ); const itemData = useMemo( () => ({ @@ -43,36 +45,48 @@ export const SessionsTableList = ({ [sessions, sessionId, openSession, showDeleteModal, onSessionRemoved, hideSourceColumn, hideActionsColumn] ); - const rowRenderer: ListRowRenderer = ({ index, key, style }) => ( - - ); + const virtualizer = useVirtualizer({ + count: sessions.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 40, + overscan: 5, + }); - const handleResize = useCallback(({ height }: { height: number }) => { - setResizeHeight(height - 20); - }, []); + useEffect(() => { + const virtualItems = virtualizer.getVirtualItems(); + if (virtualItems.length > 0) { + const startIndex = virtualItems[0].index; + const stopIndex = virtualItems[virtualItems.length - 1].index; + onItemsRendered({ + visibleStartIndex: startIndex, + visibleStopIndex: stopIndex, + overscanStartIndex: Math.max(0, startIndex - 5), + overscanStopIndex: Math.min(sessions.length - 1, stopIndex + 5), + }); + } + }, [virtualizer, onItemsRendered, sessions.length]); return ( - - - {({ height, width }) => ( - - onItemsRendered({ - visibleStartIndex: startIndex, - visibleStopIndex: stopIndex, - overscanStartIndex, - overscanStopIndex, - }) - } - rowCount={sessions.length} - rowHeight={40} - rowRenderer={rowRenderer} - width={width} - /> - )} - + +
+ {virtualizer.getVirtualItems().map((virtualItem) => ( +
+ +
+ ))} +
); }; diff --git a/src/components/organisms/deployments/sessions/tabs/activities/infiniteList.tsx b/src/components/organisms/deployments/sessions/tabs/activities/infiniteList.tsx index bad3f003f3..abc2c0bb95 100644 --- a/src/components/organisms/deployments/sessions/tabs/activities/infiniteList.tsx +++ b/src/components/organisms/deployments/sessions/tabs/activities/infiniteList.tsx @@ -1,113 +1,129 @@ -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { AutoSizer, InfiniteLoader, List, ListRowProps } from "react-virtualized"; - -import { defaultSessionsActivitiesPageSize } from "@src/constants"; -import { SessionLogType, EventListenerName } from "@src/enums"; -import { useVirtualizedList, useEventListener } from "@src/hooks"; +import { SessionLogType } from "@src/enums"; +import { useEventSubscription, useVirtualizedSessionList } from "@src/hooks"; import { SessionActivity } from "@src/interfaces/models"; import { cn } from "@src/utilities"; import { Frame } from "@components/atoms"; -import { LoadingOverlay } from "@components/molecules"; import { ActivityRow, SingleActivityInfo } from "@components/organisms/deployments/sessions/tabs/activities"; export const ActivityList = () => { - const { t } = useTranslation("deployments", { keyPrefix: "sessions.viewer" }); const [selectedActivity, setSelectedActivity] = useState(); - const [rowHeight, setRowHeight] = useState(60); const { - isRowLoaded, items: activities, - listRef, loadMoreRows, nextPageToken, - loading: loadingActivities, - } = useVirtualizedList(SessionLogType.Activity, defaultSessionsActivitiesPageSize); - - useEffect(() => { - const handleResize = () => { - setRowHeight(window.innerWidth < 1500 ? 80 : 60); - }; - - handleResize(); - - window.addEventListener("resize", handleResize); - - return () => window.removeEventListener("resize", handleResize); - }, []); - - useEventListener(EventListenerName.selectSessionActivity, (event: CustomEvent<{ activity?: SessionActivity }>) => { - const activity = event.detail?.activity; - setSelectedActivity(activity); + t, + loading, + parentRef, + virtualizer, + } = useVirtualizedSessionList(SessionLogType.Activity); + + const scrollStateRef = useRef({ + isLoading: false, + lastLoadTime: 0, + lastScrollTop: 0, + isInitialLoad: true, }); - const customRowRenderer = useCallback( - ({ index, key, style }: ListRowProps) => ( - - ), - [activities, setSelectedActivity] - ); - - const autoSizerClass = useMemo(() => cn({ hidden: selectedActivity }), [selectedActivity]); - - const rowCount = useMemo( - () => (nextPageToken ? activities.length + 1 : activities.length), - [activities.length, nextPageToken] - ); + useEffect(() => { + if (!activities.length || selectedActivity || !scrollStateRef.current.isInitialLoad) return; + + if (parentRef.current && !loading) { + parentRef.current.scrollTop = parentRef.current.scrollHeight; + scrollStateRef.current.isInitialLoad = false; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activities, loading, selectedActivity]); + + const loadMoreWithScroll = useCallback(async () => { + const now = Date.now(); + const { isLoading, lastLoadTime } = scrollStateRef.current; + + if (isLoading || loading || !nextPageToken || now - lastLoadTime < 1000) return; + + scrollStateRef.current.isLoading = true; + scrollStateRef.current.lastLoadTime = now; + + try { + if (parentRef.current) { + parentRef.current.style.overscrollBehavior = "none"; + } + + await loadMoreRows(); + } catch { + if (parentRef.current) { + parentRef.current.style.overscrollBehavior = "auto"; + } + scrollStateRef.current.isLoading = false; + } finally { + if (parentRef.current && !loading) { + parentRef.current.scrollTop = parentRef.current.scrollHeight; + scrollStateRef.current.isInitialLoad = false; + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [loading, nextPageToken, parentRef]); + + const handleScroll = useCallback(() => { + if (!parentRef.current || !nextPageToken || scrollStateRef.current.isLoading || selectedActivity) return; + + const scrollTop = parentRef.current.scrollTop; + const scrollingUp = scrollTop < scrollStateRef.current.lastScrollTop; + scrollStateRef.current.lastScrollTop = scrollTop; + + if (scrollTop < parentRef.current.clientHeight * 0.1 && scrollingUp) { + loadMoreWithScroll(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [parentRef, nextPageToken, selectedActivity]); + + useEventSubscription(parentRef, "scroll", handleScroll); + + const autoSizerClass = cn({ hidden: selectedActivity }); return ( - + {selectedActivity ? ( ) : null} - - - - {({ height, width }) => ( - + {activities.length > 0 ? ( +
- {({ onRowsRendered, registerChild }) => ( - { - if (ref) { - registerChild(ref); - listRef.current = ref; - } + {virtualizer.getVirtualItems().map((virtualItem) => ( +
- )} - + > + +
+ ))} +
+ ) : ( +
+ {t("noActivitiesFound")} +
)} -
- - {!activities.length ? ( -
- {t("noActivitiesFound")} -
- ) : null} + ); }; diff --git a/src/components/organisms/deployments/sessions/tabs/outputs.tsx b/src/components/organisms/deployments/sessions/tabs/outputs.tsx index 0433cdc056..768a07ccd6 100644 --- a/src/components/organisms/deployments/sessions/tabs/outputs.tsx +++ b/src/components/organisms/deployments/sessions/tabs/outputs.tsx @@ -1,29 +1,16 @@ -import React, { memo, useCallback, useEffect, useRef, useState } from "react"; +import React, { memo, useCallback, useEffect, useRef, useMemo } from "react"; -import { AutoSizer, CellMeasurer, CellMeasurerCache, InfiniteLoader, List, ListRowProps } from "react-virtualized"; - -import { useVirtualizedList } from "@hooks/useVirtualizedList"; -import { EventListenerName, SessionLogType } from "@src/enums"; -import { useEventListener } from "@src/hooks"; +import { SessionLogType } from "@src/enums"; +import { useEventSubscription, useVirtualizedSessionList } from "@src/hooks"; import { SessionOutputLog } from "@src/interfaces/models"; -const OutputRow = memo(({ log, measure }: { log: SessionOutputLog; measure: () => void }) => { - const rowRef = useRef(null); - - useEffect(() => { - const timer = setTimeout(() => { - measure(); - }, 0); - - return () => clearTimeout(timer); - }, [measure, log]); +const OutputRow = memo(({ log }: { log: SessionOutputLog }) => { + const cleanedPrint = log.print.replace(/^\n+|\n+$/g, ""); return ( -
-
-
[{log.time}]:
-
{log.print}
-
+
+
[{log.time}]:
+
{cleanedPrint}
); }); @@ -32,102 +19,110 @@ OutputRow.displayName = "OutputRow"; export const SessionOutputs = () => { const { - isRowLoaded, - items: outputs, - listRef, + items: rawOutputs, loadMoreRows, nextPageToken, t, loading, - } = useVirtualizedList(SessionLogType.Output); - const [isInitialLoad, setIsInitialLoad] = useState(true); - const cacheRef = useRef( - new CellMeasurerCache({ - fixedWidth: true, - minHeight: 22, - defaultHeight: 44, - keyMapper: (index) => index, - }) - ); + parentRef, + virtualizer, + } = useVirtualizedSessionList(SessionLogType.Output); + + const scrollStateRef = useRef({ + isLoading: false, + lastLoadTime: 0, + lastScrollTop: 0, + isInitialLoad: true, + }); + + const outputs = useMemo(() => { + return [...rawOutputs].reverse(); + }, [rawOutputs]); useEffect(() => { - if (listRef.current) { - cacheRef.current.clearAll(); - listRef.current.recomputeRowHeights(); - } - if (isInitialLoad) { - setIsInitialLoad(false); + if (!outputs.length || !scrollStateRef.current.isInitialLoad) return; + + if (parentRef.current && !loading) { + parentRef.current.scrollTop = parentRef.current.scrollHeight; + scrollStateRef.current.isInitialLoad = false; } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [outputs]); - - const customRowRenderer = useCallback( - ({ index, key, parent, style }: ListRowProps) => { - const log = outputs[index]; - - return ( - - {({ measure, registerChild }) => ( -
{ - if (element && registerChild) { - registerChild(element); - } - }} - style={style} - > - -
- )} -
- ); - }, - [outputs] - ); + }, [outputs, loading]); - const setListRef = useCallback( - (ref: List | null) => { - listRef.current = ref; - }, - [listRef] - ); - useEventListener(EventListenerName.sessionLogViewerScrollToTop, () => listRef.current?.scrollToRow(0)); + const loadMoreWithScroll = useCallback(async () => { + const now = Date.now(); + const { isLoading, lastLoadTime } = scrollStateRef.current; + + if (isLoading || loading || !nextPageToken || now - lastLoadTime < 1000) return; + + scrollStateRef.current.isLoading = true; + scrollStateRef.current.lastLoadTime = now; + + try { + if (parentRef.current) { + parentRef.current.style.overscrollBehavior = "none"; + } + + await loadMoreRows(); + } catch { + if (parentRef.current) { + parentRef.current.style.overscrollBehavior = "auto"; + } + } finally { + scrollStateRef.current.isLoading = false; + if (parentRef.current && !loading) { + parentRef.current.scrollTop = parentRef.current.scrollHeight; + scrollStateRef.current.isInitialLoad = false; + } + } + }, [loading, nextPageToken, loadMoreRows, parentRef]); + + const handleScroll = useCallback(() => { + if (!parentRef.current || !nextPageToken || scrollStateRef.current.isLoading) return; + + const scrollTop = parentRef.current.scrollTop; + const scrollingUp = scrollTop < scrollStateRef.current.lastScrollTop; + scrollStateRef.current.lastScrollTop = scrollTop; + + if (scrollTop < 100 && scrollingUp) { + loadMoreWithScroll(); + } + }, [nextPageToken, loadMoreWithScroll, parentRef]); + + useEventSubscription(parentRef, "scroll", handleScroll); return ( -
- - {({ height, width }) => ( - +
+ {outputs.length > 0 ? ( +
- {({ onRowsRendered, registerChild }) => ( - { - setListRef(ref); - registerChild(ref); + {virtualizer.getVirtualItems().map((virtualItem) => ( +
- )} - - )} - - {!outputs.length && !loading ? ( -
- {t("noLogsFound")} -
- ) : null} + > + +
+ ))} +
+ ) : !loading ? ( +
+ {t("noLogsFound")} +
+ ) : null} +
); }; diff --git a/src/components/organisms/events/table.tsx b/src/components/organisms/events/table.tsx index 311de1e38a..78266bb094 100644 --- a/src/components/organisms/events/table.tsx +++ b/src/components/organisms/events/table.tsx @@ -1,8 +1,8 @@ -import React, { KeyboardEvent, MouseEvent, useCallback, useEffect, useId, useMemo, useState } from "react"; +import React, { KeyboardEvent, MouseEvent, useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; import { useTranslation } from "react-i18next"; import { useNavigate, useParams } from "react-router-dom"; -import { AutoSizer, ListRowProps } from "react-virtualized"; import { useEventsDrawer } from "@contexts"; import { ModalName } from "@src/enums/components"; @@ -18,7 +18,6 @@ import { TableHeader } from "@components/organisms/events/table/header"; import { NoEventsSelected } from "@components/organisms/events/table/notSelected"; import { RedispatchEventModal } from "@components/organisms/events/table/redispatchEventModal"; import { EventRow } from "@components/organisms/events/table/row"; -import { VirtualizedList } from "@components/organisms/events/table/virtualizer"; const Title = ({ isDrawer, @@ -57,6 +56,7 @@ export const EventsTable = () => { const addToast = useToastStore((state) => state.addToast); const setSelectedEventId = useEventsDrawerStore((state) => state.setSelectedEventId); const [selectedEventIdForModal, setSelectedEventIdForModal] = useState(); + const parentRef = useRef(null); const { eventInfo, @@ -148,23 +148,12 @@ export const EventsTable = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const rowRenderer = useCallback( - ({ index, key, style }: ListRowProps) => { - const event = sortedEvents[index]; - - return ( - calculateEventAddress(event.eventId)} - onRedispatch={async () => openRedispatchModal(event.eventId)} - style={style} - /> - ); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [sortedEvents] - ); + const virtualizer = useVirtualizer({ + count: sortedEvents.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 58, // defaultEventsTableRowHeight + overscan: 5, + }); const tableContent = useMemo(() => { if ((loadingEvents && isInitialLoad) || isSourceLoad) { @@ -184,24 +173,42 @@ export const EventsTable = () => { -
- - {({ height, width }) => ( - - )} - +
+
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const event = sortedEvents[virtualItem.index]; + return ( +
+ calculateEventAddress(event.eventId)} + onRedispatch={async () => openRedispatchModal(event.eventId)} + style={{ height: "100%" }} + /> +
+ ); + })} +
); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isInitialLoad, sortedEvents, isSourceLoad]); + }, [isInitialLoad, sortedEvents, isSourceLoad, virtualizer.getVirtualItems()]); // eslint-disable-next-line react-hooks/exhaustive-deps const handleRefresh = useCallback(() => fetchEvents({ projectId, sourceId }), [projectId, sourceId]); diff --git a/src/components/organisms/events/table/index.ts b/src/components/organisms/events/table/index.ts index a1e06508d3..5d776de712 100644 --- a/src/components/organisms/events/table/index.ts +++ b/src/components/organisms/events/table/index.ts @@ -1,4 +1,3 @@ export { TableHeader } from "@components/organisms/events/table/header"; export { NoEventsSelected } from "@components/organisms/events/table/notSelected"; export { EventRow } from "@components/organisms/events/table/row"; -export { VirtualizedList } from "@components/organisms/events/table/virtualizer"; diff --git a/src/components/organisms/events/table/virtualizer.tsx b/src/components/organisms/events/table/virtualizer.tsx deleted file mode 100644 index 76a7d3a21e..0000000000 --- a/src/components/organisms/events/table/virtualizer.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React, { memo } from "react"; - -import { List, ListRowProps } from "react-virtualized"; - -import { defaultEventsTableRowHeight } from "@src/constants"; -import { BaseEvent } from "@src/types/models"; - -export const VirtualizedList = memo( - ({ - height, - rowRenderer, - sortedEvents, - width, - }: { - height: number; - rowRenderer: (props: ListRowProps) => React.ReactNode; - sortedEvents: BaseEvent[]; - width: number; - }) => ( - - ) -); - -VirtualizedList.displayName = "VirtualizedList"; diff --git a/src/components/organisms/eventsDrawer/drawerEventsList.tsx b/src/components/organisms/eventsDrawer/drawerEventsList.tsx index b32d5410a9..a2c4eb07fc 100644 --- a/src/components/organisms/eventsDrawer/drawerEventsList.tsx +++ b/src/components/organisms/eventsDrawer/drawerEventsList.tsx @@ -1,7 +1,7 @@ -import React, { KeyboardEvent, MouseEvent, useCallback, useEffect, useMemo, useState } from "react"; +import React, { KeyboardEvent, MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; import { useTranslation } from "react-i18next"; -import { AutoSizer, ListRowProps } from "react-virtualized"; import { useEventsDrawer } from "@contexts"; import { ModalName } from "@src/enums/components"; @@ -15,7 +15,6 @@ import { RefreshButton } from "@components/molecules"; import { TableHeader } from "@components/organisms/events/table/header"; import { RedispatchEventModal } from "@components/organisms/events/table/redispatchEventModal"; import { EventRow } from "@components/organisms/events/table/row"; -import { VirtualizedList } from "@components/organisms/events/table/virtualizer"; const Title = ({ section, @@ -51,6 +50,7 @@ export const DrawerEventsList = () => { const addToast = useToastStore((state) => state.addToast); const setSelectedEventId = useEventsDrawerStore((state) => state.setSelectedEventId); const [selectedEventIdForModal, setSelectedEventIdForModal] = useState(); + const parentRef = useRef(null); const { eventInfo, @@ -126,23 +126,12 @@ export const DrawerEventsList = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const rowRenderer = useCallback( - ({ index, key, style }: ListRowProps) => { - const event = sortedEvents[index]; - - return ( - handleEventClick(event.eventId)} - onRedispatch={async () => openRedispatchModal(event.eventId)} - style={style} - /> - ); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [sortedEvents, handleEventClick] - ); + const virtualizer = useVirtualizer({ + count: sortedEvents.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 58, // defaultEventsTableRowHeight + overscan: 5, + }); const tableContent = useMemo(() => { if ((loadingEvents && isInitialLoad) || isSourceLoad) { @@ -158,24 +147,42 @@ export const DrawerEventsList = () => { -
- - {({ height, width }) => ( - - )} - +
+
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const event = sortedEvents[virtualItem.index]; + return ( +
+ handleEventClick(event.eventId)} + onRedispatch={async () => openRedispatchModal(event.eventId)} + style={{ height: "100%" }} + /> +
+ ); + })} +
); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isInitialLoad, sortedEvents, isSourceLoad]); + }, [isInitialLoad, sortedEvents, isSourceLoad, virtualizer.getVirtualItems()]); // eslint-disable-next-line react-hooks/exhaustive-deps const handleRefresh = useCallback(() => fetchEvents({ projectId, sourceId }), [projectId, sourceId]); diff --git a/src/hooks/index.ts b/src/hooks/index.ts index a66015e29c..b91220c492 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -6,10 +6,10 @@ export { useCreateProjectFromTemplate } from "./useCreateProjectFromTemplate"; export { useUserTracking } from "./useUserTracking"; export { useHubspot } from "./useHubspot"; export { useResize } from "./useResize"; -export { useSafeEventListener } from "./useSafeEventListener"; +export { useEventSubscription } from "@src/hooks/useEventSubscription"; export { useSort } from "./useSort"; export { useToastAndLog } from "./useToastAndLog"; -export { useVirtualizedList } from "./useVirtualizedList"; +export { useVirtualizedSessionList } from "@src/hooks/useVirtualizedSessionList"; export { useWindowDimensions } from "./useWindowDimensions"; export { usePopover, usePopoverList } from "./usePopover"; export { useProjectActions } from "./useProjectActions"; diff --git a/src/hooks/useEventSubscription.ts b/src/hooks/useEventSubscription.ts new file mode 100644 index 0000000000..0e5e281b1b --- /dev/null +++ b/src/hooks/useEventSubscription.ts @@ -0,0 +1,25 @@ +import { RefObject, useEffect } from "react"; + +type EventMap = HTMLElementEventMap & WindowEventMap & DocumentEventMap; +type EventType = keyof EventMap; +type EventHandler = (event: EventMap[T]) => void; + +export const useEventSubscription = ( + elementRef: RefObject | E, + eventType: T, + handler: EventHandler +) => { + useEffect(() => { + const element = elementRef && "current" in elementRef ? elementRef.current : elementRef; + + if (!element) return; + + // @ts-expect-error - EventMap is not perfectly compatible with addEventListener + element.addEventListener(eventType, handler); + + return () => { + // @ts-expect-error - EventMap is not perfectly compatible with removeEventListener + element.removeEventListener(eventType, handler); + }; + }, [elementRef, eventType, handler]); +}; diff --git a/src/hooks/useVirtualizedList.tsx b/src/hooks/useVirtualizedList.tsx deleted file mode 100644 index 000d7430d8..0000000000 --- a/src/hooks/useVirtualizedList.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; - -import { useTranslation } from "react-i18next"; -import { useParams } from "react-router-dom"; -import { CellMeasurerCache, List, ListRowProps } from "react-virtualized"; - -import { defaultSessionLogRecordsListRowHeight, standardScreenHeightFallback } from "@src/constants"; -import { SessionLogType } from "@src/enums"; -import { VirtualizedListHookResult } from "@src/interfaces/hooks"; -import { SessionActivity, SessionOutputLog } from "@src/interfaces/models"; -import { SessionActivityData, SessionOutputData } from "@src/interfaces/store"; - -import { useActivitiesCacheStore, useOutputsCacheStore, useToastStore } from "@store"; - -export function useVirtualizedList( - type: SessionLogType, - itemHeight = defaultSessionLogRecordsListRowHeight, - customRowRenderer?: (props: ListRowProps, item: T) => React.ReactNode -): VirtualizedListHookResult { - const { sessionId } = useParams<{ sessionId: string }>(); - const { t } = useTranslation("deployments", { keyPrefix: "sessions.viewer" }); - const frameRef = useRef(null); - const addToast = useToastStore((state) => state.addToast); - const loadingRef = useRef(false); - const outputsCacheStore = useOutputsCacheStore(); - const activitiesCacheStore = useActivitiesCacheStore(); - - const { loadLogs, loading, sessions } = type === SessionLogType.Output ? outputsCacheStore : activitiesCacheStore; - - const [session, setSession] = useState(); - - const listRef = useRef(null); - - const items = useMemo(() => { - return session - ? ((type === SessionLogType.Output - ? (session as SessionOutputData).outputs - : (session as SessionActivityData).activities) as T[]) - : []; - }, [session, type]); - - useEffect(() => { - if (!sessionId) return; - - setSession(sessions[sessionId]); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [sessions]); - - const cache = useMemo( - () => - new CellMeasurerCache({ - fixedWidth: true, - defaultHeight: itemHeight, - }), - [itemHeight] - ); - - const isRowLoaded = useCallback(({ index }: { index: number }): boolean => !!items[index], [items]); - - const shouldLoadMore = useMemo( - () => !(loading || (session && session.hasLastSessionState && !session.nextPageToken)), - [loading, session] - ); - - const frameHeight = frameRef?.current?.offsetHeight || standardScreenHeightFallback; - const pageSize = Math.ceil((frameHeight / itemHeight) * 1.5); - - const fetchLogs = async (sessionId: string, pageSize: number, force?: boolean) => { - const { error } = await loadLogs(sessionId, pageSize, force); - - if (error) { - addToast({ - message: type === SessionLogType.Output ? t("outputLogsFetchError") : t("activityLogsFetchError"), - type: "error", - }); - } - }; - - const loadMoreRows = async () => { - if (!sessionId || !shouldLoadMore || loadingRef.current) { - return; - } - - loadingRef.current = true; - try { - await fetchLogs(sessionId, pageSize); - } finally { - loadingRef.current = false; - } - }; - - useEffect(() => { - if (!sessionId) return; - fetchLogs(sessionId, pageSize, true); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [sessionId]); - - const rowRenderer = useCallback( - (props: ListRowProps): React.ReactNode => { - const item = items[props.index] as T; - - return customRowRenderer ? ( - customRowRenderer(props, item) - ) : ( -
- {JSON.stringify(item)} -
- ); - }, - [items, customRowRenderer] - ); - - return { - items, - isRowLoaded, - loadMoreRows, - cache, - listRef, - frameRef, - t, - loading, - nextPageToken: session?.nextPageToken || null, - rowRenderer, - }; -} diff --git a/src/hooks/useVirtualizedSessionList.tsx b/src/hooks/useVirtualizedSessionList.tsx new file mode 100644 index 0000000000..c1dd8ae01b --- /dev/null +++ b/src/hooks/useVirtualizedSessionList.tsx @@ -0,0 +1,93 @@ +import { useEffect, useMemo, useRef, useState } from "react"; + +import { useVirtualizer } from "@tanstack/react-virtual"; +import { useTranslation } from "react-i18next"; +import { useParams } from "react-router-dom"; + +import { SessionLogType } from "@src/enums"; +import { VirtualizedSessionListHook } from "@src/interfaces/hooks"; +import { SessionActivity, SessionOutputLog } from "@src/interfaces/models"; +import { SessionActivityData, SessionOutputData } from "@src/interfaces/store"; + +import { useActivitiesCacheStore, useOutputsCacheStore, useToastStore } from "@store"; + +export function useVirtualizedSessionList( + type: SessionLogType +): VirtualizedSessionListHook { + const { sessionId } = useParams<{ sessionId: string }>(); + const { t } = useTranslation("deployments", { keyPrefix: "sessions.viewer" }); + const parentRef = useRef(null); + const addToast = useToastStore((state) => state.addToast); + + const outputsCacheStore = useOutputsCacheStore(); + const activitiesCacheStore = useActivitiesCacheStore(); + + const { sessions, loadLogs } = type === SessionLogType.Output ? outputsCacheStore : activitiesCacheStore; + + const [session, setSession] = useState(); + + const items = useMemo(() => { + return session + ? ((type === SessionLogType.Output + ? (session as SessionOutputData).outputs + : (session as SessionActivityData).activities) as T[]) + : []; + }, [session, type]); + + const loading = useMemo(() => !session, [session]); + + useEffect(() => { + if (sessionId && sessions[sessionId]) { + setSession(sessions[sessionId]); + } + }, [sessionId, sessions]); + + const shouldLoadMore = useMemo( + () => !(loading || (session && session.hasLastSessionState && !session.nextPageToken)), + [loading, session] + ); + + const fetchLogs = async (sessionId: string, pageSize: number, force?: boolean) => { + const { error } = await loadLogs(sessionId, pageSize, force); + + if (error) { + addToast({ + message: t("fetchLogsError"), + type: "error", + }); + return; + } + }; + + const loadMoreRows = async () => { + if (!sessionId || !shouldLoadMore || !session?.nextPageToken) { + return; + } + + fetchLogs(sessionId, 50); + }; + + useEffect(() => { + if (!sessionId) return; + fetchLogs(sessionId, 50, true); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sessionId]); + + const virtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 60, + overscan: 10, + measureElement: (element) => element.getBoundingClientRect().height, + }); + + return { + items, + loadMoreRows, + parentRef, + t, + loading, + nextPageToken: session?.nextPageToken || null, + virtualizer, + }; +} diff --git a/src/interfaces/hooks/index.ts b/src/interfaces/hooks/index.ts index e4e8c488f3..6346b0502a 100644 --- a/src/interfaces/hooks/index.ts +++ b/src/interfaces/hooks/index.ts @@ -1,5 +1,5 @@ export type { ResizeHook } from "@interfaces/hooks/useResize.interface"; -export type { VirtualizedListHookResult } from "@interfaces/hooks/useVirtualizedList.interface"; +export type { VirtualizedSessionListHook } from "@interfaces/hooks/useVirtualizedSessionList.interface.ts"; export type { IframeState, IframeError } from "@interfaces/hooks/useChatbotHook.interface"; export type { UseDefaultUserLoginArgs, diff --git a/src/interfaces/hooks/useVirtualizedList.interface.ts b/src/interfaces/hooks/useVirtualizedList.interface.ts deleted file mode 100644 index 2998fdf215..0000000000 --- a/src/interfaces/hooks/useVirtualizedList.interface.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { CellMeasurerCache, List, ListRowProps } from "react-virtualized"; - -export interface VirtualizedListHookResult { - items: T[]; - isRowLoaded: (params: { index: number }) => boolean; - loadMoreRows: () => Promise; - cache: CellMeasurerCache; - listRef: React.MutableRefObject; - frameRef: React.RefObject; - t: (key: string) => string; - nextPageToken: string | null; - rowRenderer: (props: ListRowProps) => React.ReactNode; - loading: boolean; -} diff --git a/src/interfaces/hooks/useVirtualizedSessionList.interface.ts b/src/interfaces/hooks/useVirtualizedSessionList.interface.ts new file mode 100644 index 0000000000..0a87b481d5 --- /dev/null +++ b/src/interfaces/hooks/useVirtualizedSessionList.interface.ts @@ -0,0 +1,11 @@ +import { Virtualizer } from "@tanstack/react-virtual"; + +export interface VirtualizedSessionListHook { + items: T[]; + loadMoreRows: () => Promise; + parentRef: React.RefObject; + t: (key: string) => string; + nextPageToken: string | null; + virtualizer: Virtualizer; + loading?: boolean; +} diff --git a/vite.config.ts b/vite.config.ts index 33c08a80f2..ef557c9efa 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -8,8 +8,6 @@ import mkcert from "vite-plugin-mkcert"; import { viteStaticCopy } from "vite-plugin-static-copy"; import svgr from "vite-plugin-svgr"; -import { reactVirtualized } from "./fixReactVirtualized"; - dotenv.config(); const packageJsonPath = new URL("package.json", import.meta.url).pathname; @@ -144,7 +142,6 @@ export default defineConfig({ }, ], }), - reactVirtualized(), ], resolve: { alias: {