From f0747f48e91328edd296746854ff421fd849b85e Mon Sep 17 00:00:00 2001 From: ECWireless Date: Sun, 28 Jun 2026 12:58:58 -0600 Subject: [PATCH 1/6] feat: preserve orch table state on back nav --- components/OrchestratorList/index.tsx | 729 ++++++++++++++++---------- components/Table/index.tsx | 57 +- hooks/useExplorerStore.tsx | 45 ++ pages/index.tsx | 1 + pages/orchestrators.tsx | 1 + 5 files changed, 531 insertions(+), 302 deletions(-) diff --git a/components/OrchestratorList/index.tsx b/components/OrchestratorList/index.tsx index 8db7ba85..a22c0315 100644 --- a/components/OrchestratorList/index.tsx +++ b/components/OrchestratorList/index.tsx @@ -47,10 +47,12 @@ import { PERCENTAGE_PRECISION_MILLION, } from "@utils/web3"; import { OrchestratorsQueryResult, ProtocolQueryResult } from "apollo"; -import { useEnsData } from "hooks"; +import { OrchestratorListKey, useEnsData, useExplorerStore } from "hooks"; import { useBondingManagerAddress } from "hooks/useContracts"; import Link from "next/link"; -import { useCallback, useMemo, useState } from "react"; +import Router from "next/router"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { SortingRule } from "react-table"; import { formatUnits } from "viem"; import { useReadContract } from "wagmi"; @@ -76,11 +78,47 @@ const formatFactors = (factors: ROIFactors) => ? `LPT Only` : `ETH Only`; +const getScrollStorageKey = (listKey: OrchestratorListKey) => + `livepeer:orchestrator-list:${listKey}:scrollY`; + +const getListPath = (listKey: OrchestratorListKey) => + listKey === "home" ? "/" : "/orchestrators"; + +const readStoredScrollY = (listKey: OrchestratorListKey) => { + if (typeof window === "undefined") { + return null; + } + + try { + const value = window.sessionStorage.getItem(getScrollStorageKey(listKey)); + const scrollY = value ? Number(value) : NaN; + + return Number.isFinite(scrollY) ? scrollY : null; + } catch { + return null; + } +}; + +const writeStoredScrollY = (listKey: OrchestratorListKey, scrollY: number) => { + if (typeof window === "undefined") { + return; + } + + try { + window.sessionStorage.setItem( + getScrollStorageKey(listKey), + `${Math.max(0, Math.round(scrollY))}` + ); + } catch {} +}; + const OrchestratorList = ({ data, + listKey, protocolData, pageSize = 10, }: { + listKey: OrchestratorListKey; pageSize: number; protocolData: | NonNullable["protocol"] @@ -89,6 +127,12 @@ const OrchestratorList = ({ | NonNullable["transcoders"] | undefined; }) => { + const persistedState = useExplorerStore( + (state) => state.orchestratorLists[listKey] + ); + const setOrchestratorListState = useExplorerStore( + (state) => state.setOrchestratorListState + ); // Derive protocol inflation data const inflationRate = Number(protocolData?.inflation || 0) / PERCENTAGE_PRECISION_BILLION; @@ -108,16 +152,106 @@ const OrchestratorList = ({ [inflationRate, inflationChangeAmount] ); - const [principle, setPrinciple] = useState(150); - const [inflationChange, setInflationChange] = - useState("none"); - const [factors, setFactors] = useState("lpt+eth"); - const [timeHorizon, setTimeHorizon] = useState("one-year"); + const [principle, setPrinciple] = useState(persistedState.principle); + const [inflationChange, setInflationChange] = useState( + persistedState.inflationChange + ); + const [factors, setFactors] = useState(persistedState.factors); + const [timeHorizon, setTimeHorizon] = useState( + persistedState.timeHorizon + ); const maxSupplyTokens = useMemo( () => Math.floor(Number(protocolData?.totalSupply || 1e7)), [protocolData] ); + useEffect(() => { + setOrchestratorListState(listKey, { + factors, + inflationChange, + principle, + timeHorizon, + }); + }, [ + factors, + inflationChange, + listKey, + principle, + setOrchestratorListState, + timeHorizon, + ]); + + const saveCurrentScroll = useCallback(() => { + writeStoredScrollY(listKey, window.scrollY); + setOrchestratorListState(listKey, { scrollY: window.scrollY }); + }, [listKey, setOrchestratorListState]); + + const restoreSavedScroll = useCallback(() => { + const scrollY = readStoredScrollY(listKey) ?? persistedState.scrollY; + + if (scrollY <= 0) { + return; + } + + let frameCount = 0; + let frameId: number; + const restoreScroll = () => { + window.scrollTo(0, scrollY); + frameCount += 1; + + if (frameCount < 10 && Math.abs(window.scrollY - scrollY) > 2) { + frameId = requestAnimationFrame(restoreScroll); + } + }; + + frameId = requestAnimationFrame(restoreScroll); + const timeoutId = window.setTimeout(() => { + window.scrollTo(0, scrollY); + }, 250); + + return () => { + cancelAnimationFrame(frameId); + window.clearTimeout(timeoutId); + }; + }, [listKey, persistedState.scrollY]); + + useEffect(() => restoreSavedScroll(), [restoreSavedScroll]); + + useEffect(() => { + let frameId: number | null = null; + const saveScrollToStorage = () => { + if (frameId !== null) { + return; + } + + frameId = requestAnimationFrame(() => { + frameId = null; + writeStoredScrollY(listKey, window.scrollY); + }); + }; + + window.addEventListener("scroll", saveScrollToStorage, { passive: true }); + const restoreAfterRouteChange = (url: string) => { + const path = url.split("?")[0].split("#")[0]; + + if (path === getListPath(listKey)) { + restoreSavedScroll(); + } + }; + + Router.events.on("routeChangeStart", saveCurrentScroll); + Router.events.on("routeChangeComplete", restoreAfterRouteChange); + + return () => { + if (frameId !== null) { + cancelAnimationFrame(frameId); + } + window.removeEventListener("scroll", saveScrollToStorage); + Router.events.off("routeChangeStart", saveCurrentScroll); + Router.events.off("routeChangeComplete", restoreAfterRouteChange); + }; + }, [listKey, restoreSavedScroll, saveCurrentScroll]); + const formattedPrinciple = formatLPT(Number(principle) || 150, { precision: 0, }); @@ -1014,216 +1148,55 @@ const OrchestratorList = ({ [formattedPrinciple, timeHorizon, factors] ); - return ( - - - - - - - {"Forecasted Yield Assumptions"} - - - - - { - e.stopPropagation(); - }} - asChild - > - - - - + const handleTableStateChange = useCallback( + ({ + pageIndex, + sortBy, + }: { + pageIndex: number; + sortBy: SortingRule[]; + }) => { + setOrchestratorListState(listKey, { + pageIndex, + sortBy, + }); + }, + [listKey, setOrchestratorListState] + ); - - {"Time horizon:"} - - - {formatTimeHorizon(timeHorizon)} - - - - +
+ + + + + - - setTimeHorizon("half-year")} - > - {"6 months"} - - setTimeHorizon("one-year")} - > - {"1 year"} - - setTimeHorizon("two-years")} - > - {"2 years"} - - setTimeHorizon("three-years")} - > - {"3 years"} - - setTimeHorizon("four-years")} - > - {"4 years"} - - - - - - - { - e.stopPropagation(); - }} - asChild - > - - - - - - {"Delegation:"} - - - {formatLPT(principle, { precision: 1 })} - - - - - - - { - setPrinciple( - Number(e.target.value) > maxSupplyTokens - ? maxSupplyTokens - : Number(e.target.value) - ); - }} - min="1" - max={`${Number( - protocolData?.totalSupply || 1e7 - ).toFixed(0)}`} - /> - - LPT - - - - - - - + {"Forecasted Yield Assumptions"} + + + { @@ -1250,7 +1223,7 @@ const OrchestratorList = ({ marginRight: 3, }} > - {"Factors:"} + {"Time horizon:"} - {formatFactors(factors)} + {formatTimeHorizon(timeHorizon)} @@ -1278,113 +1251,291 @@ const OrchestratorList = ({ css={{ cursor: "pointer", }} - onSelect={() => setFactors("lpt+eth")} + onSelect={() => setTimeHorizon("half-year")} + > + {"6 months"} + + setTimeHorizon("one-year")} > - {formatFactors("lpt+eth")} + {"1 year"} setFactors("lpt")} + onSelect={() => setTimeHorizon("two-years")} > - {formatFactors("lpt")} + {"2 years"} setFactors("eth")} + onSelect={() => setTimeHorizon("three-years")} > - {formatFactors("eth")} + {"3 years"} + + setTimeHorizon("four-years")} + > + {"4 years"} - - - - { - e.stopPropagation(); - }} - asChild - > - + + { + e.stopPropagation(); }} + asChild > - - - - - - {"Inflation change:"} - - - {formatPercentChange(inflationChange)} - - - - - - + + + + {"Delegation:"} + + + {formatLPT(principle, { precision: 1 })} + + + + + setInflationChange("none")} > - {formatPercentChange("none")} - - + { + setPrinciple( + Number(e.target.value) > maxSupplyTokens + ? maxSupplyTokens + : Number(e.target.value) + ); + }} + min="1" + max={`${Number( + protocolData?.totalSupply || 1e7 + ).toFixed(0)}`} + /> + + LPT + + + + + + + + + { + e.stopPropagation(); + }} + asChild + > + setInflationChange("positive")} > - {formatPercentChange("positive")} - - + + + + + {"Factors:"} + + + {formatFactors(factors)} + + + + + + setFactors("lpt+eth")} + > + {formatFactors("lpt+eth")} + + setFactors("lpt")} + > + {formatFactors("lpt")} + + setFactors("eth")} + > + {formatFactors("eth")} + + + + + + + + { + e.stopPropagation(); + }} + asChild + > + setInflationChange("negative")} > - {formatPercentChange("negative")} - - - - - - - - } - /> + + + + + + {"Inflation change:"} + + + {formatPercentChange(inflationChange)} + + + + + + setInflationChange("none")} + > + {formatPercentChange("none")} + + setInflationChange("positive")} + > + {formatPercentChange("positive")} + + setInflationChange("negative")} + > + {formatPercentChange("negative")} + + + + + + + + } + /> + ); }; diff --git a/components/Table/index.tsx b/components/Table/index.tsx index a58a2d7b..42505038 100644 --- a/components/Table/index.tsx +++ b/components/Table/index.tsx @@ -9,33 +9,66 @@ import { Tr, } from "@livepeer/design-system"; import { ChevronDownIcon, ChevronUpIcon } from "@radix-ui/react-icons"; -import { ReactNode } from "react"; +import { ReactNode, useEffect } from "react"; import { Column, HeaderGroup, + SortingRule, TableInstance, + TableOptions, + TableState, usePagination, UsePaginationInstanceProps, + UsePaginationOptions, + UsePaginationState, useSortBy, UseSortByInstanceProps, + UseSortByOptions, + UseSortByState, useTable, } from "react-table"; import Pagination from "./Pagination"; +type TableInitialState = Partial> & + Partial> & + Partial>; + +type PersistedTableState = { + pageIndex: number; + pageSize: number; + sortBy: SortingRule[]; +}; + function DataTable({ heading = null, input = null, data, columns, initialState = {}, + autoResetPage, + autoResetSortBy, + onStateChange, }: { heading?: ReactNode; input?: ReactNode; data: T[]; columns: Column[]; - initialState: object; + initialState: TableInitialState; + autoResetPage?: boolean; + autoResetSortBy?: boolean; + onStateChange?: (state: PersistedTableState) => void; }) { + const tableOptions: TableOptions & + UsePaginationOptions & + UseSortByOptions = { + columns, + data, + initialState, + autoResetPage, + autoResetSortBy, + }; + const { getTableProps, getTableBodyProps, @@ -47,18 +80,16 @@ function DataTable({ pageCount, nextPage, previousPage, - state: { pageIndex }, - } = useTable( - { - columns, - data, - initialState, - }, - useSortBy, - usePagination - ) as TableInstance & + state: { pageIndex, pageSize, sortBy }, + } = useTable(tableOptions, useSortBy, usePagination) as TableInstance & UsePaginationInstanceProps & - UseSortByInstanceProps & { state: { pageIndex: number } }; + UseSortByInstanceProps & { + state: UsePaginationState & UseSortByState; + }; + + useEffect(() => { + onStateChange?.({ pageIndex, pageSize, sortBy }); + }, [onStateChange, pageIndex, pageSize, sortBy]); return ( <> diff --git a/hooks/useExplorerStore.tsx b/hooks/useExplorerStore.tsx index 980a1dc2..3df70ed9 100644 --- a/hooks/useExplorerStore.tsx +++ b/hooks/useExplorerStore.tsx @@ -1,9 +1,25 @@ import { ProposalExtended } from "@lib/api/treasury"; import { txMessages } from "lib/utils"; +import { SortingRule } from "react-table"; import { Address } from "viem"; import { create } from "zustand"; export type StakingAction = "undelegate" | "delegate" | null; +export type OrchestratorListKey = "home" | "orchestrators"; +export type OrchestratorListState = { + factors: "lpt+eth" | "lpt" | "eth"; + inflationChange: "positive" | "negative" | "none"; + pageIndex: number; + principle: number; + scrollY: number; + sortBy: SortingRule[]; + timeHorizon: + | "half-year" + | "one-year" + | "two-years" + | "three-years" + | "four-years"; +}; export type YieldResults = { roiFees: number; roiFeesLpt: number; @@ -48,12 +64,17 @@ export type ExplorerState = { bottomDrawerOpen: boolean; selectedStakingAction: StakingAction; yieldResults: YieldResults; + orchestratorLists: Record; latestTransaction: TransactionStatus | null; setWalletModalOpen: (v: boolean) => void; setBottomDrawerOpen: (v: boolean) => void; setSelectedStakingAction: (v: StakingAction) => void; setYieldResults: (v: YieldResults) => void; + setOrchestratorListState: ( + key: OrchestratorListKey, + value: Partial + ) => void; setLatestTransactionDetails: ( hash: string, @@ -66,6 +87,16 @@ export type ExplorerState = { clearLatestTransaction: () => void; }; +const defaultOrchestratorListState: OrchestratorListState = { + factors: "lpt+eth", + inflationChange: "none", + pageIndex: 0, + principle: 150, + scrollY: 0, + sortBy: [{ id: "earnings", desc: true }], + timeHorizon: "one-year", +}; + export const useExplorerStore = create()((set) => ({ walletModalOpen: false, bottomDrawerOpen: false, @@ -76,6 +107,10 @@ export const useExplorerStore = create()((set) => ({ roiRewards: 0.0, principle: 0.0, }, + orchestratorLists: { + home: defaultOrchestratorListState, + orchestrators: defaultOrchestratorListState, + }, latestTransaction: null, setWalletModalOpen: (v: boolean) => set(() => ({ walletModalOpen: v })), @@ -83,6 +118,16 @@ export const useExplorerStore = create()((set) => ({ setSelectedStakingAction: (v: StakingAction) => set(() => ({ selectedStakingAction: v })), setYieldResults: (v: YieldResults) => set(() => ({ yieldResults: v })), + setOrchestratorListState: (key, value) => + set(({ orchestratorLists }) => ({ + orchestratorLists: { + ...orchestratorLists, + [key]: { + ...orchestratorLists[key], + ...value, + }, + }, + })), setLatestTransactionDetails: ( hash: string, diff --git a/pages/index.tsx b/pages/index.tsx index 75628cc6..6ade046f 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -492,6 +492,7 @@ const Home = ({ {showOrchList ? ( diff --git a/pages/orchestrators.tsx b/pages/orchestrators.tsx index ab291282..7e76aded 100644 --- a/pages/orchestrators.tsx +++ b/pages/orchestrators.tsx @@ -123,6 +123,7 @@ const OrchestratorsPage = ({ {showOrchList ? ( From a531dbb589672814c6cab060496ae17225753520 Mon Sep 17 00:00:00 2001 From: ECWireless Date: Sun, 28 Jun 2026 14:04:01 -0600 Subject: [PATCH 2/6] fix: address Copilot comments --- components/OrchestratorList/index.tsx | 73 +++++++++++++++++++++------ hooks/useExplorerStore.tsx | 41 +++++++++------ 2 files changed, 82 insertions(+), 32 deletions(-) diff --git a/components/OrchestratorList/index.tsx b/components/OrchestratorList/index.tsx index a22c0315..51b515c7 100644 --- a/components/OrchestratorList/index.tsx +++ b/components/OrchestratorList/index.tsx @@ -51,7 +51,7 @@ import { OrchestratorListKey, useEnsData, useExplorerStore } from "hooks"; import { useBondingManagerAddress } from "hooks/useContracts"; import Link from "next/link"; import Router from "next/router"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { SortingRule } from "react-table"; import { formatUnits } from "viem"; import { useReadContract } from "wagmi"; @@ -133,6 +133,7 @@ const OrchestratorList = ({ const setOrchestratorListState = useExplorerStore( (state) => state.setOrchestratorListState ); + const activeRestoreCleanup = useRef<(() => void) | undefined>(undefined); // Derive protocol inflation data const inflationRate = Number(protocolData?.inflation || 0) / PERCENTAGE_PRECISION_BILLION; @@ -193,29 +194,64 @@ const OrchestratorList = ({ return; } - let frameCount = 0; - let frameId: number; + const getMaxScrollY = () => + Math.max( + document.documentElement.scrollHeight, + document.body.scrollHeight + ) - window.innerHeight; + + const isAtSavedScroll = () => Math.abs(window.scrollY - scrollY) <= 2; + const isTallEnough = () => getMaxScrollY() >= scrollY; + + if (isTallEnough() && isAtSavedScroll()) { + return; + } + + const startedAt = Date.now(); + const maxRestoreMs = 2000; + let frameId: number | undefined; + let cancelled = false; + const restoreScroll = () => { - window.scrollTo(0, scrollY); - frameCount += 1; + if (cancelled) { + return; + } - if (frameCount < 10 && Math.abs(window.scrollY - scrollY) > 2) { + if (isTallEnough()) { + window.scrollTo(0, scrollY); + } + + if ( + (!isTallEnough() || !isAtSavedScroll()) && + Date.now() - startedAt < maxRestoreMs + ) { frameId = requestAnimationFrame(restoreScroll); } }; frameId = requestAnimationFrame(restoreScroll); - const timeoutId = window.setTimeout(() => { - window.scrollTo(0, scrollY); - }, 250); return () => { - cancelAnimationFrame(frameId); - window.clearTimeout(timeoutId); + cancelled = true; + if (frameId !== undefined) { + cancelAnimationFrame(frameId); + } }; }, [listKey, persistedState.scrollY]); - useEffect(() => restoreSavedScroll(), [restoreSavedScroll]); + const startScrollRestore = useCallback(() => { + activeRestoreCleanup.current?.(); + activeRestoreCleanup.current = restoreSavedScroll(); + }, [restoreSavedScroll]); + + useEffect(() => { + startScrollRestore(); + + return () => { + activeRestoreCleanup.current?.(); + activeRestoreCleanup.current = undefined; + }; + }, [startScrollRestore]); useEffect(() => { let frameId: number | null = null; @@ -235,7 +271,7 @@ const OrchestratorList = ({ const path = url.split("?")[0].split("#")[0]; if (path === getListPath(listKey)) { - restoreSavedScroll(); + startScrollRestore(); } }; @@ -250,7 +286,7 @@ const OrchestratorList = ({ Router.events.off("routeChangeStart", saveCurrentScroll); Router.events.off("routeChangeComplete", restoreAfterRouteChange); }; - }, [listKey, restoreSavedScroll, saveCurrentScroll]); + }, [listKey, saveCurrentScroll, startScrollRestore]); const formattedPrinciple = formatLPT(Number(principle) || 150, { precision: 0, @@ -1346,10 +1382,15 @@ const OrchestratorList = ({ size="2" value={principle} onChange={(e) => { + const nextValue = Number(e.target.value); setPrinciple( - Number(e.target.value) > maxSupplyTokens + !Number.isFinite(nextValue) + ? 1 + : nextValue < 1 + ? 1 + : nextValue > maxSupplyTokens ? maxSupplyTokens - : Number(e.target.value) + : nextValue ); }} min="1" diff --git a/hooks/useExplorerStore.tsx b/hooks/useExplorerStore.tsx index 3df70ed9..6643efb1 100644 --- a/hooks/useExplorerStore.tsx +++ b/hooks/useExplorerStore.tsx @@ -1,4 +1,5 @@ import { ProposalExtended } from "@lib/api/treasury"; +import { ROIFactors, ROIInflationChange, ROITimeHorizon } from "@lib/roi"; import { txMessages } from "lib/utils"; import { SortingRule } from "react-table"; import { Address } from "viem"; @@ -7,18 +8,13 @@ import { create } from "zustand"; export type StakingAction = "undelegate" | "delegate" | null; export type OrchestratorListKey = "home" | "orchestrators"; export type OrchestratorListState = { - factors: "lpt+eth" | "lpt" | "eth"; - inflationChange: "positive" | "negative" | "none"; + factors: ROIFactors; + inflationChange: ROIInflationChange; pageIndex: number; principle: number; scrollY: number; sortBy: SortingRule[]; - timeHorizon: - | "half-year" - | "one-year" - | "two-years" - | "three-years" - | "four-years"; + timeHorizon: ROITimeHorizon; }; export type YieldResults = { roiFees: number; @@ -119,15 +115,28 @@ export const useExplorerStore = create()((set) => ({ set(() => ({ selectedStakingAction: v })), setYieldResults: (v: YieldResults) => set(() => ({ yieldResults: v })), setOrchestratorListState: (key, value) => - set(({ orchestratorLists }) => ({ - orchestratorLists: { - ...orchestratorLists, - [key]: { - ...orchestratorLists[key], - ...value, + set((state) => { + const { orchestratorLists } = state; + const current = orchestratorLists[key]; + const hasChanged = Object.entries(value).some( + ([field, nextValue]) => + current[field as keyof OrchestratorListState] !== nextValue + ); + + if (!hasChanged) { + return state; + } + + return { + orchestratorLists: { + ...orchestratorLists, + [key]: { + ...current, + ...value, + }, }, - }, - })), + }; + }), setLatestTransactionDetails: ( hash: string, From e41452b371ffe98289acd12e6531d1fc9ee7a4ce Mon Sep 17 00:00:00 2001 From: ECWireless Date: Fri, 3 Jul 2026 17:07:32 -0600 Subject: [PATCH 3/6] feat: preserve state for all major tables --- components/GatewayList/index.tsx | 42 +++-- components/OrchestratorList/index.tsx | 179 +++------------------- components/PerformanceList/index.tsx | 44 +++++- components/TransactionsList/index.tsx | 48 ++++-- hooks/index.tsx | 1 + hooks/useExplorerStore.tsx | 40 +++++ hooks/usePersistedExplorerListState.ts | 203 +++++++++++++++++++++++++ pages/gateways.tsx | 7 +- pages/index.tsx | 9 +- pages/transactions.tsx | 2 + 10 files changed, 385 insertions(+), 190 deletions(-) create mode 100644 hooks/usePersistedExplorerListState.ts diff --git a/components/GatewayList/index.tsx b/components/GatewayList/index.tsx index 7eb8dc44..e500a9fd 100644 --- a/components/GatewayList/index.tsx +++ b/components/GatewayList/index.tsx @@ -16,10 +16,10 @@ import { } from "@livepeer/design-system"; import { DotsHorizontalIcon } from "@radix-ui/react-icons"; import { GatewaysQuery } from "apollo"; -import { useEnsData } from "hooks"; +import { useEnsData, usePersistedExplorerListState } from "hooks"; import Link from "next/link"; import { useMemo } from "react"; -import { Column } from "react-table"; +import { Column, SortingRule } from "react-table"; type GatewayRow = NonNullable[number] & { depositNumber: number; @@ -30,11 +30,24 @@ type GatewayRow = NonNullable[number] & { const GatewayList = ({ data, + listKey, pageSize = 10, + routePath, }: { + listKey: string; pageSize?: number; + routePath: string; data: GatewaysQuery["gateways"] | undefined; }) => { + const { handleTableStateChange, persistedState, saveCurrentScroll } = + usePersistedExplorerListState({ + listKey, + routePath, + }); + const defaultSortBy = useMemo[]>( + () => [{ id: "ninetyDayVolumeNumber", desc: true }], + [] + ); const rows: GatewayRow[] = useMemo( () => (data ?? []).map((gateway) => ({ @@ -310,15 +323,22 @@ const GatewayList = ({ } return ( -
+ +
[]) + : defaultSortBy, + }} + /> + ); }; diff --git a/components/OrchestratorList/index.tsx b/components/OrchestratorList/index.tsx index 51b515c7..73f302ae 100644 --- a/components/OrchestratorList/index.tsx +++ b/components/OrchestratorList/index.tsx @@ -47,12 +47,16 @@ import { PERCENTAGE_PRECISION_MILLION, } from "@utils/web3"; import { OrchestratorsQueryResult, ProtocolQueryResult } from "apollo"; -import { OrchestratorListKey, useEnsData, useExplorerStore } from "hooks"; +import { + OrchestratorListKey, + OrchestratorListState, + useEnsData, + useExplorerStore, + usePersistedExplorerListState, +} from "hooks"; import { useBondingManagerAddress } from "hooks/useContracts"; import Link from "next/link"; -import Router from "next/router"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { SortingRule } from "react-table"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { formatUnits } from "viem"; import { useReadContract } from "wagmi"; @@ -78,40 +82,9 @@ const formatFactors = (factors: ROIFactors) => ? `LPT Only` : `ETH Only`; -const getScrollStorageKey = (listKey: OrchestratorListKey) => - `livepeer:orchestrator-list:${listKey}:scrollY`; - const getListPath = (listKey: OrchestratorListKey) => listKey === "home" ? "/" : "/orchestrators"; -const readStoredScrollY = (listKey: OrchestratorListKey) => { - if (typeof window === "undefined") { - return null; - } - - try { - const value = window.sessionStorage.getItem(getScrollStorageKey(listKey)); - const scrollY = value ? Number(value) : NaN; - - return Number.isFinite(scrollY) ? scrollY : null; - } catch { - return null; - } -}; - -const writeStoredScrollY = (listKey: OrchestratorListKey, scrollY: number) => { - if (typeof window === "undefined") { - return; - } - - try { - window.sessionStorage.setItem( - getScrollStorageKey(listKey), - `${Math.max(0, Math.round(scrollY))}` - ); - } catch {} -}; - const OrchestratorList = ({ data, listKey, @@ -133,7 +106,19 @@ const OrchestratorList = ({ const setOrchestratorListState = useExplorerStore( (state) => state.setOrchestratorListState ); - const activeRestoreCleanup = useRef<(() => void) | undefined>(undefined); + const setPersistedListState = useCallback( + (value: Partial) => { + setOrchestratorListState(listKey, value); + }, + [listKey, setOrchestratorListState] + ); + const { handleTableStateChange, saveCurrentScroll } = + usePersistedExplorerListState({ + listKey: `orchestrator-list:${listKey}`, + routePath: getListPath(listKey), + persistedState, + setPersistedState: setPersistedListState, + }); // Derive protocol inflation data const inflationRate = Number(protocolData?.inflation || 0) / PERCENTAGE_PRECISION_BILLION; @@ -182,112 +167,6 @@ const OrchestratorList = ({ timeHorizon, ]); - const saveCurrentScroll = useCallback(() => { - writeStoredScrollY(listKey, window.scrollY); - setOrchestratorListState(listKey, { scrollY: window.scrollY }); - }, [listKey, setOrchestratorListState]); - - const restoreSavedScroll = useCallback(() => { - const scrollY = readStoredScrollY(listKey) ?? persistedState.scrollY; - - if (scrollY <= 0) { - return; - } - - const getMaxScrollY = () => - Math.max( - document.documentElement.scrollHeight, - document.body.scrollHeight - ) - window.innerHeight; - - const isAtSavedScroll = () => Math.abs(window.scrollY - scrollY) <= 2; - const isTallEnough = () => getMaxScrollY() >= scrollY; - - if (isTallEnough() && isAtSavedScroll()) { - return; - } - - const startedAt = Date.now(); - const maxRestoreMs = 2000; - let frameId: number | undefined; - let cancelled = false; - - const restoreScroll = () => { - if (cancelled) { - return; - } - - if (isTallEnough()) { - window.scrollTo(0, scrollY); - } - - if ( - (!isTallEnough() || !isAtSavedScroll()) && - Date.now() - startedAt < maxRestoreMs - ) { - frameId = requestAnimationFrame(restoreScroll); - } - }; - - frameId = requestAnimationFrame(restoreScroll); - - return () => { - cancelled = true; - if (frameId !== undefined) { - cancelAnimationFrame(frameId); - } - }; - }, [listKey, persistedState.scrollY]); - - const startScrollRestore = useCallback(() => { - activeRestoreCleanup.current?.(); - activeRestoreCleanup.current = restoreSavedScroll(); - }, [restoreSavedScroll]); - - useEffect(() => { - startScrollRestore(); - - return () => { - activeRestoreCleanup.current?.(); - activeRestoreCleanup.current = undefined; - }; - }, [startScrollRestore]); - - useEffect(() => { - let frameId: number | null = null; - const saveScrollToStorage = () => { - if (frameId !== null) { - return; - } - - frameId = requestAnimationFrame(() => { - frameId = null; - writeStoredScrollY(listKey, window.scrollY); - }); - }; - - window.addEventListener("scroll", saveScrollToStorage, { passive: true }); - const restoreAfterRouteChange = (url: string) => { - const path = url.split("?")[0].split("#")[0]; - - if (path === getListPath(listKey)) { - startScrollRestore(); - } - }; - - Router.events.on("routeChangeStart", saveCurrentScroll); - Router.events.on("routeChangeComplete", restoreAfterRouteChange); - - return () => { - if (frameId !== null) { - cancelAnimationFrame(frameId); - } - window.removeEventListener("scroll", saveScrollToStorage); - Router.events.off("routeChangeStart", saveCurrentScroll); - Router.events.off("routeChangeComplete", restoreAfterRouteChange); - }; - }, [listKey, saveCurrentScroll, startScrollRestore]); - const formattedPrinciple = formatLPT(Number(principle) || 150, { precision: 0, }); @@ -1184,22 +1063,6 @@ const OrchestratorList = ({ [formattedPrinciple, timeHorizon, factors] ); - const handleTableStateChange = useCallback( - ({ - pageIndex, - sortBy, - }: { - pageIndex: number; - sortBy: SortingRule[]; - }) => { - setOrchestratorListState(listKey, { - pageIndex, - sortBy, - }); - }, - [listKey, setOrchestratorListState] - ); - return (
+ [ + "leaderboard-performance", + region, + pipeline ?? "transcoding", + model ?? "default", + ].join(":"), + [model, pipeline, region] + ); + const { handleTableStateChange, persistedState, saveCurrentScroll } = + usePersistedExplorerListState({ + listKey, + routePath: "/leaderboard", + }); + const defaultSortBy = useMemo( + () => [ { id: "scores", desc: true, }, ], + [] + ); + + const initialState = { + pageIndex: persistedState.pageIndex, + pageSize: pageSize, + sortBy: persistedState.sortBy.length + ? persistedState.sortBy + : defaultSortBy, hiddenColumns: [ "activationRound", "deactivationRound", @@ -320,7 +342,17 @@ const PerformanceList = ({ ] ); return ( -
+ +
+ ); }; diff --git a/components/TransactionsList/index.tsx b/components/TransactionsList/index.tsx index 89a78df7..bdb2c1fb 100644 --- a/components/TransactionsList/index.tsx +++ b/components/TransactionsList/index.tsx @@ -17,6 +17,7 @@ import { } from "@utils/web3"; import { EventsQueryResult, TreasuryProposal } from "apollo"; import { sentenceCase } from "change-case"; +import { usePersistedExplorerListState } from "hooks"; import { useCallback, useMemo } from "react"; export const FILTERED_EVENT_TYPENAMES = [ @@ -69,13 +70,31 @@ const renderEmoji = (emoji: string) => ( const TransactionsList = ({ events, + listKey, pageSize = 10, + routePath, }: { + listKey: string; pageSize: number; + routePath: string; events: NonNullable< EventsQueryResult["data"] >["transactions"][number]["events"]; }) => { + const { handleTableStateChange, persistedState, saveCurrentScroll } = + usePersistedExplorerListState({ + listKey, + routePath, + }); + const defaultSortBy = useMemo( + () => [ + { + id: "timestamp", + desc: true, + }, + ], + [] + ); const getAccountForRow = useCallback( ( event: NonNullable< @@ -547,19 +566,22 @@ const TransactionsList = ({ ); return ( -
+ +
+ ); }; diff --git a/hooks/index.tsx b/hooks/index.tsx index 9d37f82c..a22571b6 100644 --- a/hooks/index.tsx +++ b/hooks/index.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; // DO NOT IMPORT useHandleTransaction due to @rainbow-me/rainbowkit issues with SSR export * from "./useExplorerStore"; +export * from "./usePersistedExplorerListState"; export * from "./useSwr"; export * from "./wallet"; diff --git a/hooks/useExplorerStore.tsx b/hooks/useExplorerStore.tsx index 6643efb1..2d36084a 100644 --- a/hooks/useExplorerStore.tsx +++ b/hooks/useExplorerStore.tsx @@ -16,6 +16,11 @@ export type OrchestratorListState = { sortBy: SortingRule[]; timeHorizon: ROITimeHorizon; }; +export type ExplorerListState = { + pageIndex: number; + scrollY: number; + sortBy: SortingRule[]; +}; export type YieldResults = { roiFees: number; roiFeesLpt: number; @@ -61,6 +66,7 @@ export type ExplorerState = { selectedStakingAction: StakingAction; yieldResults: YieldResults; orchestratorLists: Record; + explorerLists: Record; latestTransaction: TransactionStatus | null; setWalletModalOpen: (v: boolean) => void; @@ -71,6 +77,10 @@ export type ExplorerState = { key: OrchestratorListKey, value: Partial ) => void; + setExplorerListState: ( + key: string, + value: Partial + ) => void; setLatestTransactionDetails: ( hash: string, @@ -93,6 +103,12 @@ const defaultOrchestratorListState: OrchestratorListState = { timeHorizon: "one-year", }; +export const defaultExplorerListState: ExplorerListState = { + pageIndex: 0, + scrollY: 0, + sortBy: [], +}; + export const useExplorerStore = create()((set) => ({ walletModalOpen: false, bottomDrawerOpen: false, @@ -107,6 +123,7 @@ export const useExplorerStore = create()((set) => ({ home: defaultOrchestratorListState, orchestrators: defaultOrchestratorListState, }, + explorerLists: {}, latestTransaction: null, setWalletModalOpen: (v: boolean) => set(() => ({ walletModalOpen: v })), @@ -137,6 +154,29 @@ export const useExplorerStore = create()((set) => ({ }, }; }), + setExplorerListState: (key, value) => + set((state) => { + const { explorerLists } = state; + const current = explorerLists[key] ?? defaultExplorerListState; + const hasChanged = Object.entries(value).some( + ([field, nextValue]) => + current[field as keyof ExplorerListState] !== nextValue + ); + + if (!hasChanged) { + return state; + } + + return { + explorerLists: { + ...explorerLists, + [key]: { + ...current, + ...value, + }, + }, + }; + }), setLatestTransactionDetails: ( hash: string, diff --git a/hooks/usePersistedExplorerListState.ts b/hooks/usePersistedExplorerListState.ts new file mode 100644 index 00000000..6a17c77c --- /dev/null +++ b/hooks/usePersistedExplorerListState.ts @@ -0,0 +1,203 @@ +import Router from "next/router"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { SortingRule } from "react-table"; + +import { + defaultExplorerListState, + ExplorerListState, + useExplorerStore, +} from "./useExplorerStore"; + +type PersistedListState = { + pageIndex: number; + scrollY: number; + sortBy: SortingRule[]; +}; + +type PersistedTableState = { + pageIndex: number; + sortBy: SortingRule[]; +}; + +type UsePersistedExplorerListStateOptions = { + listKey: string; + routePath: string; + persistedState?: PersistedListState; + setPersistedState?: (value: Partial) => void; +}; + +const getScrollStorageKey = (listKey: string) => + `livepeer:explorer-list:${listKey}:scrollY`; + +const readStoredScrollY = (listKey: string) => { + if (typeof window === "undefined") { + return null; + } + + try { + const value = window.sessionStorage.getItem(getScrollStorageKey(listKey)); + const scrollY = value ? Number(value) : NaN; + + return Number.isFinite(scrollY) ? scrollY : null; + } catch { + return null; + } +}; + +const writeStoredScrollY = (listKey: string, scrollY: number) => { + if (typeof window === "undefined") { + return; + } + + try { + window.sessionStorage.setItem( + getScrollStorageKey(listKey), + `${Math.max(0, Math.round(scrollY))}` + ); + } catch {} +}; + +export const usePersistedExplorerListState = ({ + listKey, + routePath, + persistedState: externalPersistedState, + setPersistedState: externalSetPersistedState, +}: UsePersistedExplorerListStateOptions) => { + const storedPersistedState = + useExplorerStore((state) => state.explorerLists[listKey]) ?? + defaultExplorerListState; + const setExplorerListState = useExplorerStore( + (state) => state.setExplorerListState + ); + const persistedState = externalPersistedState ?? storedPersistedState; + const setPersistedState = useMemo( + () => + externalSetPersistedState ?? + ((value: Partial) => + setExplorerListState(listKey, value)), + [externalSetPersistedState, listKey, setExplorerListState] + ); + const activeRestoreCleanup = useRef<(() => void) | undefined>(undefined); + + const saveCurrentScroll = useCallback(() => { + writeStoredScrollY(listKey, window.scrollY); + setPersistedState({ scrollY: window.scrollY }); + }, [listKey, setPersistedState]); + + const restoreSavedScroll = useCallback(() => { + const scrollY = readStoredScrollY(listKey) ?? persistedState.scrollY; + + if (scrollY <= 0) { + return; + } + + const getMaxScrollY = () => + Math.max( + document.documentElement.scrollHeight, + document.body.scrollHeight + ) - window.innerHeight; + + const isAtSavedScroll = () => Math.abs(window.scrollY - scrollY) <= 2; + const isTallEnough = () => getMaxScrollY() >= scrollY; + + if (isTallEnough() && isAtSavedScroll()) { + return; + } + + const startedAt = Date.now(); + const maxRestoreMs = 2000; + let frameId: number | undefined; + let cancelled = false; + + const restoreScroll = () => { + if (cancelled) { + return; + } + + if (isTallEnough()) { + window.scrollTo(0, scrollY); + } + + if ( + (!isTallEnough() || !isAtSavedScroll()) && + Date.now() - startedAt < maxRestoreMs + ) { + frameId = requestAnimationFrame(restoreScroll); + } + }; + + frameId = requestAnimationFrame(restoreScroll); + + return () => { + cancelled = true; + if (frameId !== undefined) { + cancelAnimationFrame(frameId); + } + }; + }, [listKey, persistedState.scrollY]); + + const startScrollRestore = useCallback(() => { + activeRestoreCleanup.current?.(); + activeRestoreCleanup.current = restoreSavedScroll(); + }, [restoreSavedScroll]); + + useEffect(() => { + startScrollRestore(); + + return () => { + activeRestoreCleanup.current?.(); + activeRestoreCleanup.current = undefined; + }; + }, [startScrollRestore]); + + useEffect(() => { + let frameId: number | null = null; + const saveScrollToStorage = () => { + if (frameId !== null) { + return; + } + + frameId = requestAnimationFrame(() => { + frameId = null; + writeStoredScrollY(listKey, window.scrollY); + }); + }; + + window.addEventListener("scroll", saveScrollToStorage, { passive: true }); + const restoreAfterRouteChange = (url: string) => { + const path = url.split("?")[0].split("#")[0]; + + if (path === routePath) { + startScrollRestore(); + } + }; + + Router.events.on("routeChangeStart", saveCurrentScroll); + Router.events.on("routeChangeComplete", restoreAfterRouteChange); + + return () => { + if (frameId !== null) { + cancelAnimationFrame(frameId); + } + window.removeEventListener("scroll", saveScrollToStorage); + Router.events.off("routeChangeStart", saveCurrentScroll); + Router.events.off("routeChangeComplete", restoreAfterRouteChange); + }; + }, [listKey, routePath, saveCurrentScroll, startScrollRestore]); + + const handleTableStateChange = useCallback( + ({ pageIndex, sortBy }: PersistedTableState) => { + setPersistedState({ + pageIndex, + sortBy: sortBy as SortingRule[], + }); + }, + [setPersistedState] + ); + + return { + handleTableStateChange, + persistedState, + saveCurrentScroll, + }; +}; diff --git a/pages/gateways.tsx b/pages/gateways.tsx index fefa2b0d..4eeaced4 100644 --- a/pages/gateways.tsx +++ b/pages/gateways.tsx @@ -46,7 +46,12 @@ const GatewaysPage = ({ gateways }: PageProps) => { the last 12 months. - + diff --git a/pages/index.tsx b/pages/index.tsx index 6ade046f..0d244974 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -580,7 +580,12 @@ const Home = ({ ) : ( - + )} @@ -660,7 +665,9 @@ const Home = ({ EventsQueryResult["data"] >["transactions"][number]["events"] } + listKey="home-transactions" pageSize={10} + routePath="/" /> diff --git a/pages/transactions.tsx b/pages/transactions.tsx index d0ededf9..6f81a0e2 100644 --- a/pages/transactions.tsx +++ b/pages/transactions.tsx @@ -72,7 +72,9 @@ const TransactionsPage = ({ hadError, events }: PageProps) => { EventsQueryResult["data"] >["transactions"][number]["events"] } + listKey="transactions" pageSize={TRANSACTIONS_PER_PAGE} + routePath="/transactions" /> )} From 4a1ff7e12cb0eeeb3a2ef5edb9fe34f384e6e722 Mon Sep 17 00:00:00 2001 From: ECWireless Date: Sat, 4 Jul 2026 09:02:16 -0600 Subject: [PATCH 4/6] docs: add JSDoc to new persisted... hook --- hooks/usePersistedExplorerListState.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/hooks/usePersistedExplorerListState.ts b/hooks/usePersistedExplorerListState.ts index 6a17c77c..82130f40 100644 --- a/hooks/usePersistedExplorerListState.ts +++ b/hooks/usePersistedExplorerListState.ts @@ -26,9 +26,11 @@ type UsePersistedExplorerListStateOptions = { setPersistedState?: (value: Partial) => void; }; +/** Builds the sessionStorage key used as a same-tab scroll backup for a list. */ const getScrollStorageKey = (listKey: string) => `livepeer:explorer-list:${listKey}:scrollY`; +/** Reads the last saved scroll offset for a list, returning null when unavailable. */ const readStoredScrollY = (listKey: string) => { if (typeof window === "undefined") { return null; @@ -44,6 +46,7 @@ const readStoredScrollY = (listKey: string) => { } }; +/** Writes a non-negative rounded scroll offset for a list into sessionStorage. */ const writeStoredScrollY = (listKey: string, scrollY: number) => { if (typeof window === "undefined") { return; @@ -57,6 +60,18 @@ const writeStoredScrollY = (listKey: string, scrollY: number) => { } catch {} }; +/** + * Persists table page/sort state and restores window scroll for a browsable list. + * + * `listKey` scopes stored state per list instance, while `routePath` identifies + * the route that should trigger restoration after Back navigation. Callers can + * provide `persistedState` and `setPersistedState` to reuse an existing store + * slice; otherwise the hook uses the generic explorer list store. + * + * Returns `handleTableStateChange` for the shared Table callback, + * `persistedState` for Table initial state, and `saveCurrentScroll` for click + * capture before navigating from a list item. + */ export const usePersistedExplorerListState = ({ listKey, routePath, From 1358fb0ecf47780a10712b3d734e3132c325c109 Mon Sep 17 00:00:00 2001 From: ECWireless Date: Mon, 6 Jul 2026 12:05:54 -0600 Subject: [PATCH 5/6] chore: move default sort outside of memo --- components/TransactionsList/index.tsx | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/components/TransactionsList/index.tsx b/components/TransactionsList/index.tsx index bdb2c1fb..85b519a9 100644 --- a/components/TransactionsList/index.tsx +++ b/components/TransactionsList/index.tsx @@ -68,6 +68,13 @@ const renderEmoji = (emoji: string) => ( ); +const DEFAULT_SORT_BY = [ + { + id: "timestamp", + desc: true, + }, +]; + const TransactionsList = ({ events, listKey, @@ -86,15 +93,6 @@ const TransactionsList = ({ listKey, routePath, }); - const defaultSortBy = useMemo( - () => [ - { - id: "timestamp", - desc: true, - }, - ], - [] - ); const getAccountForRow = useCallback( ( event: NonNullable< @@ -578,7 +576,7 @@ const TransactionsList = ({ pageSize, sortBy: persistedState.sortBy.length ? persistedState.sortBy - : defaultSortBy, + : DEFAULT_SORT_BY, }} /> From 8955564ec05b8b152880e9a0ed5666e59dd163e2 Mon Sep 17 00:00:00 2001 From: ECWireless Date: Mon, 6 Jul 2026 15:27:47 -0600 Subject: [PATCH 6/6] chore: clean up persisted table state defaults --- components/GatewayList/index.tsx | 10 +++++----- components/PerformanceList/index.tsx | 18 ++++++++---------- components/TransactionsList/index.tsx | 6 ++++-- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/components/GatewayList/index.tsx b/components/GatewayList/index.tsx index e500a9fd..bbf3f1fe 100644 --- a/components/GatewayList/index.tsx +++ b/components/GatewayList/index.tsx @@ -28,6 +28,10 @@ type GatewayRow = NonNullable[number] & { totalVolumeNumber: number; }; +const DEFAULT_SORT_BY: SortingRule[] = [ + { id: "ninetyDayVolumeNumber", desc: true }, +]; + const GatewayList = ({ data, listKey, @@ -44,10 +48,6 @@ const GatewayList = ({ listKey, routePath, }); - const defaultSortBy = useMemo[]>( - () => [{ id: "ninetyDayVolumeNumber", desc: true }], - [] - ); const rows: GatewayRow[] = useMemo( () => (data ?? []).map((gateway) => ({ @@ -335,7 +335,7 @@ const GatewayList = ({ pageSize, sortBy: persistedState.sortBy.length ? (persistedState.sortBy as SortingRule[]) - : defaultSortBy, + : DEFAULT_SORT_BY, }} /> diff --git a/components/PerformanceList/index.tsx b/components/PerformanceList/index.tsx index 88b919ea..ec136bfa 100644 --- a/components/PerformanceList/index.tsx +++ b/components/PerformanceList/index.tsx @@ -17,6 +17,13 @@ import { Column } from "react-table"; const EmptyData = () => ; +const DEFAULT_SORT_BY = [ + { + id: "scores", + desc: true, + }, +]; + const PerformanceList = ({ orchestratorIds, pageSize = 20, @@ -56,22 +63,13 @@ const PerformanceList = ({ listKey, routePath: "/leaderboard", }); - const defaultSortBy = useMemo( - () => [ - { - id: "scores", - desc: true, - }, - ], - [] - ); const initialState = { pageIndex: persistedState.pageIndex, pageSize: pageSize, sortBy: persistedState.sortBy.length ? persistedState.sortBy - : defaultSortBy, + : DEFAULT_SORT_BY, hiddenColumns: [ "activationRound", "deactivationRound", diff --git a/components/TransactionsList/index.tsx b/components/TransactionsList/index.tsx index 85b519a9..d5f6ee19 100644 --- a/components/TransactionsList/index.tsx +++ b/components/TransactionsList/index.tsx @@ -93,6 +93,8 @@ const TransactionsList = ({ listKey, routePath, }); + const pageCount = Math.max(1, Math.ceil((events?.length ?? 0) / pageSize)); + const pageIndex = Math.min(persistedState.pageIndex, pageCount - 1); const getAccountForRow = useCallback( ( event: NonNullable< @@ -566,13 +568,13 @@ const TransactionsList = ({ return (