diff --git a/package-lock.json b/package-lock.json index ab34ee78..cd86d826 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,12 +10,10 @@ "dependencies": { "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", - "@heroicons/react": "^2.2.0", "@mui/icons-material": "^5.16.4", - "@mui/lab": "^5.0.0-alpha.173", "@mui/material": "^5.16.1", + "@mui/system": "^5.18.0", "@tanstack/react-query": "^5.51.15", - "@types/dompurify": "^3.0.5", "@types/react-syntax-highlighter": "^15.5.13", "axios": "^1.7.2", "date-fns": "^4.1.0", @@ -31,7 +29,6 @@ "react-markdown": "^9.0.1", "react-router-dom": "^6.25.1", "react-syntax-highlighter": "^16.1.0", - "react-tooltip": "^5.30.0", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1" }, @@ -249,6 +246,7 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -292,6 +290,7 @@ "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -870,15 +869,6 @@ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, - "node_modules/@heroicons/react": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz", - "integrity": "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==", - "license": "MIT", - "peerDependencies": { - "react": ">= 16 || ^19.0.0-rc" - } - }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -983,39 +973,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@mui/base": { - "version": "5.0.0-beta.40-1", - "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.40-1.tgz", - "integrity": "sha512-agKXuNNy0bHUmeU7pNmoZwNFr7Hiyhojkb9+2PVyDG5+6RafYuyMgbrav8CndsB7KUc/U51JAw9vKNDLYBzaUA==", - "deprecated": "This package has been replaced by @base-ui/react", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.23.9", - "@floating-ui/react-dom": "^2.0.8", - "@mui/types": "~7.2.15", - "@mui/utils": "^5.17.1", - "@popperjs/core": "^2.11.8", - "clsx": "^2.1.0", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@mui/core-downloads-tracker": { "version": "5.18.0", "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.18.0.tgz", @@ -1052,52 +1009,12 @@ } } }, - "node_modules/@mui/lab": { - "version": "5.0.0-alpha.177", - "resolved": "https://registry.npmjs.org/@mui/lab/-/lab-5.0.0-alpha.177.tgz", - "integrity": "sha512-bdCxxtNjlWAgN9rtrwlmFydJ1qxA3IIbb6OlomGFsIXw0zGoHomLyjvh72q/R3yUAC0kvSef18cHY1UalLylyQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.23.9", - "@mui/base": "5.0.0-beta.40-1", - "@mui/system": "^5.18.0", - "@mui/types": "~7.2.15", - "@mui/utils": "^5.17.1", - "clsx": "^2.1.0", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@emotion/react": "^11.5.0", - "@emotion/styled": "^11.3.0", - "@mui/material": ">=5.15.0", - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/react": { - "optional": true - }, - "@emotion/styled": { - "optional": true - }, - "@types/react": { - "optional": true - } - } - }, "node_modules/@mui/material": { "version": "5.18.0", "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.18.0.tgz", "integrity": "sha512-bbH/HaJZpFtXGvWg3TsBWG4eyt3gah3E7nCNU8GLyRjVoWcA91Vm/T+sjHfUcwgJSw9iLtucfHBoq+qW/T30aA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.23.9", "@mui/core-downloads-tracker": "^5.18.0", @@ -1957,15 +1874,6 @@ "@types/ms": "*" } }, - "node_modules/@types/dompurify": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", - "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", - "license": "MIT", - "dependencies": { - "@types/trusted-types": "*" - } - }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2028,6 +1936,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -2065,7 +1974,8 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/@types/unist": { "version": "3.0.3", @@ -2079,6 +1989,7 @@ "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.1", @@ -2108,6 +2019,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -2332,6 +2244,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2569,12 +2482,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/classnames": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", - "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", - "license": "MIT" - }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -2855,6 +2762,7 @@ "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz", "integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "tslib": "2.3.0", "zrender": "6.0.0" @@ -3018,6 +2926,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5519,6 +5428,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5659,6 +5569,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -5699,6 +5610,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -5812,20 +5724,6 @@ "react": ">= 0.14.0" } }, - "node_modules/react-tooltip": { - "version": "5.30.0", - "resolved": "https://registry.npmjs.org/react-tooltip/-/react-tooltip-5.30.0.tgz", - "integrity": "sha512-Yn8PfbgQ/wmqnL7oBpz1QiDaLKrzZMdSUUdk7nVeGTwzbxCAJiJzR4VSYW+eIO42F1INt57sPUmpgKv0KwJKtg==", - "license": "MIT", - "dependencies": { - "@floating-ui/dom": "^1.6.1", - "classnames": "^2.3.0" - }, - "peerDependencies": { - "react": ">=16.14.0", - "react-dom": ">=16.14.0" - } - }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", @@ -6474,6 +6372,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6627,6 +6526,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/src/components/common/DebouncedSearchInput.tsx b/src/components/common/DebouncedSearchInput.tsx index bfbaf4e0..ced16ae9 100644 --- a/src/components/common/DebouncedSearchInput.tsx +++ b/src/components/common/DebouncedSearchInput.tsx @@ -55,6 +55,7 @@ export function DebouncedSearchInput({ const [draftValue, setDraftValue] = useState(initialDraft); const debounced = useDebouncedValue(draftValue, debounceMs); const didInitInitialRef = useRef(false); + const skipInitialDebouncedSyncRef = useRef(true); useEffect(() => { if (!didInitInitialRef.current) { @@ -65,6 +66,12 @@ export function DebouncedSearchInput({ }, [initialDraft]); useEffect(() => { + // Parent tables already hydrate filters from the URL; firing on mount would + // call setFilter with the same value and (before the no-op guard) reset page. + if (skipInitialDebouncedSyncRef.current) { + skipInitialDebouncedSyncRef.current = false; + return; + } onDebouncedChange(debounced); }, [debounced, onDebouncedChange]); diff --git a/src/components/issues/IssuesList.tsx b/src/components/issues/IssuesList.tsx index e3ad100d..3e52b222 100644 --- a/src/components/issues/IssuesList.tsx +++ b/src/components/issues/IssuesList.tsx @@ -39,6 +39,11 @@ import ReactECharts from 'echarts-for-react'; import { format } from 'date-fns'; import { IssueBounty } from '../../api/models/Issues'; import { usePrices } from '../../hooks/usePrices'; +import { + useClampUrlPage, + useResetPageOnDepsChange, + useUrlPaginationParam, +} from '../../hooks/useUrlPaginationParam'; import { formatAlphaToUsd, formatDate, @@ -62,6 +67,7 @@ import { ISSUES_LIST_ROWS, ISSUES_DEFAULT_CARD_ROWS, ISSUES_DEFAULT_LIST_ROWS, + ISSUES_VALID_ROWS, clampRowsForIssuesView, getIssuesViewModeFromQuery, readStoredIssuesViewMode, @@ -266,10 +272,14 @@ const IssuesList: React.FC = ({ const [sortDirection, setSortDirection] = useState('desc'); const [searchQuery, setSearchQuery] = useState(''); const [showChart, setShowChart] = useState(false); - const [page, setPage] = useState(0); - const [rowsPerPage, setRowsPerPage] = useState(() => - viewMode === 'cards' ? ISSUES_DEFAULT_CARD_ROWS : ISSUES_DEFAULT_LIST_ROWS, - ); + const [page, setPage, rowsPerPage, setRowsPerPage] = useUrlPaginationParam({ + pageParam: 'page', + rows: { + paramName: 'rows', + allowed: ISSUES_VALID_ROWS, + defaultRows: ISSUES_DEFAULT_LIST_ROWS, + }, + }); const isLargeScreen = useMediaQuery(theme.breakpoints.up('xl')); const [portalTarget, setPortalTarget] = useState(null); @@ -423,16 +433,16 @@ const IssuesList: React.FC = ({ const pageSize = clampRowsForIssuesView(rowsPerPage, viewMode); const totalPages = Math.max(1, Math.ceil(sortedIssues.length / pageSize)); - // Reset to page 0 when filter/search/sort/view/rowsPerPage changes. - // set-state-during-render (not useEffect) so React discards the stale render - // before commit, avoiding a one-frame flash of the clamped old page. - const paginationResetKey = `${filterType}|${searchQuery}|${sortKey}|${sortDirection}|${viewMode}|${pageSize}`; - const [prevPaginationResetKey, setPrevPaginationResetKey] = - useState(paginationResetKey); - if (paginationResetKey !== prevPaginationResetKey) { - setPrevPaginationResetKey(paginationResetKey); - setPage(0); - } + useResetPageOnDepsChange(setPage, [ + filterType, + searchQuery, + sortKey, + sortDirection, + viewMode, + pageSize, + ]); + + useClampUrlPage(page, setPage, totalPages, !isLoading); const safePage = Math.min(page, totalPages - 1); diff --git a/src/components/issues/issuesViewMode.ts b/src/components/issues/issuesViewMode.ts index f7deaa00..99e2ada8 100644 --- a/src/components/issues/issuesViewMode.ts +++ b/src/components/issues/issuesViewMode.ts @@ -34,6 +34,10 @@ export const getIssuesViewModeFromQuery = ( // the last row of cards is never partial. export const ISSUES_LIST_ROWS = [10, 25, 50] as const; export const ISSUES_CARD_ROWS = [12, 24, 48] as const; +export const ISSUES_VALID_ROWS: readonly number[] = [ + ...ISSUES_LIST_ROWS, + ...ISSUES_CARD_ROWS, +]; export const ISSUES_DEFAULT_LIST_ROWS = 10; export const ISSUES_DEFAULT_CARD_ROWS = 12; diff --git a/src/components/leaderboard/TopMinersTable.tsx b/src/components/leaderboard/TopMinersTable.tsx index dacf92ce..8652b30a 100644 --- a/src/components/leaderboard/TopMinersTable.tsx +++ b/src/components/leaderboard/TopMinersTable.tsx @@ -27,6 +27,11 @@ import { MinerCard } from './MinerCard'; import { MinersList } from './MinersList'; import theme, { STATUS_COLORS } from '../../theme'; import { useDataTableParams } from '../../hooks/useDataTableParams'; +import { + useClampUrlPage, + useResetPageOnDepsChange, + useUrlPaginationParam, +} from '../../hooks/useUrlPaginationParam'; import { FILTERS_PANEL_QUERY_PARAM, parseFiltersPanelOpen, @@ -281,6 +286,7 @@ const TopMinersTable: React.FC = ({ // Reuse the hook's `page` slot for our "show more" count — setSort and // filter changes reset it, which is the behavior we want. paramKeys: { page: VISIBLE_QUERY_PARAM }, + pageUrlFormat: 'raw', filters: filtersConfig, }); @@ -380,7 +386,12 @@ const TopMinersTable: React.FC = ({ setVisibleCount(0); }, [filteredMiners.length, visibleCount, setVisibleCount]); - const isLargeScreen = useMediaQuery(theme.breakpoints.up('xl')); + const xlQuery = theme.breakpoints.up('xl'); + const isLargeScreen = useMediaQuery(xlQuery, { + defaultMatches: + typeof window !== 'undefined' && + window.matchMedia(xlQuery.replace(/^@media\s*/, '')).matches, + }); // Resolve the sidebar portal target synchronously so a tab switch (which // remounts this table) renders straight into the sidebar instead of // flashing the toolbar inline for one frame. @@ -388,31 +399,31 @@ const TopMinersTable: React.FC = ({ document.getElementById('tabs-options-portal'), ); const observerTarget = useRef(null); - const [stackedLayoutPage, setStackedLayoutPage] = useState(0); + const [stackedLayoutPage, setStackedLayoutPage] = useUrlPaginationParam({ + pageParam: 'page', + }); - useEffect(() => { - setStackedLayoutPage(0); - }, [ + useResetPageOnDepsChange(setStackedLayoutPage, [ sortOption, sortDirection, viewMode, searchQuery, eligibleOssFilter, eligibleDiscoveryFilter, + isLargeScreen, ]); - useEffect(() => { - setStackedLayoutPage(0); - }, [isLargeScreen]); - const stackedLayoutTotalPages = Math.max( 1, Math.ceil(filteredMiners.length / MINERS_PAGE_SIZE), ); - useEffect(() => { - setStackedLayoutPage((p) => Math.min(p, stackedLayoutTotalPages - 1)); - }, [stackedLayoutTotalPages]); + useClampUrlPage( + stackedLayoutPage, + setStackedLayoutPage, + stackedLayoutTotalPages, + !isLoading, + ); const visibleMiners = useMemo(() => { if (isLargeScreen) { diff --git a/src/components/leaderboard/TopRepositoriesTable.tsx b/src/components/leaderboard/TopRepositoriesTable.tsx index 8754c79d..ee13236f 100644 --- a/src/components/leaderboard/TopRepositoriesTable.tsx +++ b/src/components/leaderboard/TopRepositoriesTable.tsx @@ -60,6 +60,7 @@ import { truncateText, } from '../../utils'; import { useDataTableParams } from '../../hooks/useDataTableParams'; +import { useClampUrlPage } from '../../hooks/useUrlPaginationParam'; import { useWatchlist } from '../../hooks/useWatchlist'; import { RankIcon } from './RankIcon'; import { getRepositoryOwnerAvatarBackground, type RepoStats } from './types'; @@ -378,13 +379,21 @@ const TopRepositoriesTable: React.FC = ({ [rankedRepositories], ); + const totalPages = Math.max( + 1, + Math.ceil(filteredRepositories.length / rowsPerPage), + ); + useClampUrlPage(page, setPage, totalPages, !isLoading); + + const safePage = Math.min(page, totalPages - 1); + const pagedRepositories = useMemo( () => filteredRepositories.slice( - page * rowsPerPage, - page * rowsPerPage + rowsPerPage, + safePage * rowsPerPage, + safePage * rowsPerPage + rowsPerPage, ), - [filteredRepositories, page, rowsPerPage], + [filteredRepositories, safePage, rowsPerPage], ); /* Chart shows the top N repos for the chosen metric (independent of the @@ -1467,7 +1476,7 @@ const TopRepositoriesTable: React.FC = ({ component="div" count={filteredRepositories.length} rowsPerPage={rowsPerPage} - page={page} + page={safePage} onPageChange={handleChangePage} onRowsPerPageChange={handleChangeRowsPerPage} showFirstButton diff --git a/src/components/repositories/LanguageWeightsTable.tsx b/src/components/repositories/LanguageWeightsTable.tsx index 3516d4ba..393ab49f 100644 --- a/src/components/repositories/LanguageWeightsTable.tsx +++ b/src/components/repositories/LanguageWeightsTable.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo, useRef, useEffect } from 'react'; +import React, { useState, useMemo, useRef } from 'react'; import { Box, TablePagination, @@ -32,6 +32,11 @@ import { echartsTransparentBackground, } from '../../utils/echarts/gittensorChartTheme'; import { DataTable, type DataTableColumn } from '../common/DataTable'; +import { + useClampUrlPage, + useResetPageOnDepsChange, + useUrlPaginationParam, +} from '../../hooks/useUrlPaginationParam'; type SortField = 'extension' | 'weight' | 'language'; type SortOrder = 'asc' | 'desc'; @@ -47,6 +52,9 @@ interface LanguageDisplayRow extends LanguageRow { displayNumber: number; } +const LANGUAGE_ROWS_OPTIONS = [5, 10, 25, 50] as const; +const LANGUAGE_DEFAULT_ROWS = 10; + const LanguageWeightsTable: React.FC = () => { const theme = useTheme(); const { data: languages, isLoading } = useLanguagesAndWeights(); @@ -54,8 +62,14 @@ const LanguageWeightsTable: React.FC = () => { const [sortField, setSortField] = useState('weight'); const [sortOrder, setSortOrder] = useState('desc'); const [showChart, setShowChart] = useState(false); - const [page, setPage] = useState(0); - const [rowsPerPage, setRowsPerPage] = useState(10); + const [page, setPage, rowsPerPage, setRowsPerPage] = useUrlPaginationParam({ + pageParam: 'page', + rows: { + paramName: 'rows', + allowed: LANGUAGE_ROWS_OPTIONS, + defaultRows: LANGUAGE_DEFAULT_ROWS, + }, + }); const containerRef = useRef(null); // Scrolls only the table's own scrollport back to the first row. Avoids the @@ -80,19 +94,10 @@ const LanguageWeightsTable: React.FC = () => { const handleChangePage = (_event: unknown, newPage: number) => { setPage(newPage); - }; - - const handleChangeRowsPerPage = ( - event: React.ChangeEvent, - ) => { - setRowsPerPage(parseInt(event.target.value, 10)); - setPage(0); scrollTableToTop(); }; - useEffect(() => { - setPage(0); - }, [searchQuery]); + useResetPageOnDepsChange(setPage, [searchQuery]); const filteredAndSortedLanguages = useMemo(() => { if (!languages) return []; @@ -134,19 +139,29 @@ const LanguageWeightsTable: React.FC = () => { return filtered; }, [languages, searchQuery, sortField, sortOrder]); + const totalLanguagePages = Math.max( + 1, + Math.ceil(filteredAndSortedLanguages.length / rowsPerPage), + ); + + useClampUrlPage(page, setPage, totalLanguagePages, !isLoading); + const paginatedLanguages = useMemo(() => { - const startIndex = page * rowsPerPage; + const safePage = Math.min(page, totalLanguagePages - 1); + const startIndex = safePage * rowsPerPage; const endIndex = startIndex + rowsPerPage; return filteredAndSortedLanguages.slice(startIndex, endIndex); - }, [filteredAndSortedLanguages, page, rowsPerPage]); + }, [filteredAndSortedLanguages, page, rowsPerPage, totalLanguagePages]); + + const safePage = Math.min(page, totalLanguagePages - 1); const displayRows = useMemo( () => paginatedLanguages.map((lang, i) => ({ ...lang, - displayNumber: page * rowsPerPage + i + 1, + displayNumber: safePage * rowsPerPage + i + 1, })), - [paginatedLanguages, page, rowsPerPage], + [paginatedLanguages, safePage, rowsPerPage], ); const chartOption = useMemo(() => { @@ -360,7 +375,6 @@ const LanguageWeightsTable: React.FC = () => { value={rowsPerPage} onChange={(e) => { setRowsPerPage(Number(e.target.value)); - setPage(0); scrollTableToTop(); }} sx={{ @@ -498,9 +512,9 @@ const LanguageWeightsTable: React.FC = () => { component="div" count={filteredAndSortedLanguages.length} rowsPerPage={rowsPerPage} - page={page} + page={safePage} onPageChange={handleChangePage} - onRowsPerPageChange={handleChangeRowsPerPage} + onRowsPerPageChange={() => {}} showFirstButton showLastButton sx={{ diff --git a/src/hooks/useDataTableParams.ts b/src/hooks/useDataTableParams.ts index 21567430..a8f91150 100644 --- a/src/hooks/useDataTableParams.ts +++ b/src/hooks/useDataTableParams.ts @@ -49,6 +49,12 @@ type UseDataTableParamsConfig< // Override URL parameter names. Useful when multiple tables coexist on the // same page (e.g. prefix with the table name). paramKeys?: Partial; + /** + * How the `page` URL param is encoded. Default `one-based` (page 1 = first + * page, omitted from URL). Use `raw` when the slot stores a non-page integer + * (e.g. leaderboard `visible` item count). + */ + pageUrlFormat?: 'one-based' | 'raw'; /** * Additional URL-backed filters beyond sort/pagination. Each entry * provides `parse` / `serialize` so the hook stays type-safe. @@ -56,7 +62,9 @@ type UseDataTableParamsConfig< filters?: { [K in keyof Filters]: FilterConfig }; }; -type UseDataTableParamsResult< +type SetPage = (next: number | ((prev: number) => number)) => void; + +export type UseDataTableParamsResult< SortKey extends string, Filters extends Record = Record, > = { @@ -65,7 +73,7 @@ type UseDataTableParamsResult< page: number; rowsPerPage: number; setSort: (field: SortKey) => void; - setPage: (page: number) => void; + setPage: SetPage; setRowsPerPage: (rowsPerPage: number) => void; filters: Filters; setFilter: (key: K, value: Filters[K]) => void; @@ -91,12 +99,20 @@ const parseSortOrder = ( return fallback; }; -const parsePage = (value: string | null): number => { +const parseRawNonNegativeInt = (value: string | null): number => { if (!value) return 0; const parsed = Number.parseInt(value, 10); return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0; }; +/** URL stores 1-based page numbers; hook consumers use 0-based indices. */ +const parseOneBasedPage = (value: string | null): number => { + if (!value) return 0; + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed < 1) return 0; + return parsed - 1; +}; + const parseRowsPerPage = ( value: string | null, defaultRowsPerPage: number, @@ -137,6 +153,7 @@ export const useDataTableParams = < defaultRowsPerPage = 25, rowsPerPageOptions, paramKeys: paramKeysOverride, + pageUrlFormat = 'one-based', filters: filtersConfig, }: UseDataTableParamsConfig): UseDataTableParamsResult< SortKey, @@ -171,9 +188,17 @@ export const useDataTableParams = < [searchParams, paramKeys.order, orderFor, sortField], ); + const parsePageFromUrl = useCallback( + (value: string | null) => + pageUrlFormat === 'raw' + ? parseRawNonNegativeInt(value) + : parseOneBasedPage(value), + [pageUrlFormat], + ); + const page = useMemo( - () => parsePage(searchParams.get(paramKeys.page)), - [searchParams, paramKeys.page], + () => parsePageFromUrl(searchParams.get(paramKeys.page)), + [searchParams, paramKeys.page, parsePageFromUrl], ); const rowsPerPage = useMemo( @@ -245,19 +270,35 @@ export const useDataTableParams = < [setSearchParams, paramKeys, sortKeys, defaultSortKey, orderFor], ); - const setPage = useCallback( - (nextPage: number) => { + const setPage = useCallback( + (nextPage) => { setSearchParams( (prev) => { const next = new URLSearchParams(prev); - if (nextPage <= 0) next.delete(paramKeys.page); - else next.set(paramKeys.page, String(nextPage)); + const current = + pageUrlFormat === 'raw' + ? parseRawNonNegativeInt(prev.get(paramKeys.page)) + : parseOneBasedPage(prev.get(paramKeys.page)); + const resolved = + typeof nextPage === 'function' ? nextPage(current) : nextPage; + const clamped = + Number.isFinite(resolved) && resolved >= 0 + ? Math.floor(resolved) + : 0; + if (pageUrlFormat === 'raw') { + if (clamped <= 0) next.delete(paramKeys.page); + else next.set(paramKeys.page, String(clamped)); + } else if (clamped <= 0) { + next.delete(paramKeys.page); + } else { + next.set(paramKeys.page, String(clamped + 1)); + } return next; }, { replace: true }, ); }, - [setSearchParams, paramKeys.page], + [setSearchParams, paramKeys.page, pageUrlFormat], ); const setRowsPerPage = useCallback( @@ -288,14 +329,14 @@ export const useDataTableParams = < (prev) => { const next = new URLSearchParams(prev); const serialized = config.serialize(value); - const previous = prev.get(filterParamKey); - const valueChanged = previous !== (serialized ?? null); + const currentRaw = prev.get(filterParamKey); + const unchanged = + serialized === null + ? currentRaw === null || currentRaw === '' + : serialized === currentRaw; if (serialized === null) next.delete(filterParamKey); else next.set(filterParamKey, serialized); - // Only reset the page slot when the filter value actually changed, - // so transient re-emissions (e.g. effect deps churning when other - // URL params update) don't clobber the user's current page. - if (resetPage && valueChanged) next.delete(paramKeys.page); + if (resetPage && !unchanged) next.delete(paramKeys.page); return next; }, { replace: true }, diff --git a/src/hooks/useUrlPaginationParam.ts b/src/hooks/useUrlPaginationParam.ts new file mode 100644 index 00000000..fd636b45 --- /dev/null +++ b/src/hooks/useUrlPaginationParam.ts @@ -0,0 +1,221 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + type DependencyList, +} from 'react'; +import { useSearchParams } from 'react-router-dom'; + +/** + * Parse a 1-based page number from the URL into a 0-based index for + * `TablePagination` and slice math. Missing or invalid values → first page (0). + */ +export const parseUrlPageParam = (raw: string | null): number => { + if (!raw) return 0; + const n = Number.parseInt(raw, 10); + if (!Number.isFinite(n) || n < 1) return 0; + return n - 1; +}; + +/** Parse a plain non-negative integer (not page numbering). */ +export const parseUrlNonNegativeInt = (raw: string | null): number => { + if (!raw) return 0; + const n = Number.parseInt(raw, 10); + return Number.isFinite(n) && n >= 0 ? n : 0; +}; + +/** Serialize a 0-based page index to a 1-based URL value, or `null` for page 1. */ +export const serializeUrlPageParam = (zeroBasedPage: number): string | null => { + const clamped = Number.isFinite(zeroBasedPage) + ? Math.floor(zeroBasedPage) + : 0; + if (clamped <= 0) return null; + return String(clamped + 1); +}; + +export type UrlPaginationRowsConfig = { + /** Query key for page size (e.g. `rows`, `langRows`). */ + paramName: string; + allowed: readonly number[]; + defaultRows: number; + /** When true (default), drop the rows param when it equals `defaultRows`. */ + omitWhenDefault?: boolean; + /** When true (default), changing rows deletes the page param. */ + resetPageOnRowsChange?: boolean; +}; + +/** Options when only the page index is synced (no `rows` in the URL). */ +export type UrlPaginationPageOnlyOptions = { + pageParam?: string; +}; + +export type UrlPaginationOptionsWithRows = UrlPaginationPageOnlyOptions & { + rows: UrlPaginationRowsConfig; +}; + +type SetPage = (next: number | ((prev: number) => number)) => void; +type SetRows = (next: number) => void; + +function parseRowsFromUrl( + raw: string | null, + allowed: readonly number[], + defaultRows: number, +): number { + const n = raw ? Number.parseInt(raw, 10) : NaN; + if (Number.isFinite(n) && (allowed as readonly number[]).includes(n)) { + return n; + } + return defaultRows; +} + +export function useUrlPaginationParam( + options: UrlPaginationOptionsWithRows, +): readonly [number, SetPage, number, SetRows]; + +export function useUrlPaginationParam( + options: UrlPaginationPageOnlyOptions, +): readonly [number, SetPage]; + +export function useUrlPaginationParam( + paramName?: string, +): readonly [number, SetPage]; + +export function useUrlPaginationParam( + paramNameOrOptions?: + | string + | UrlPaginationPageOnlyOptions + | UrlPaginationOptionsWithRows, +): readonly [number, SetPage] | readonly [number, SetPage, number, SetRows] { + const normalized: UrlPaginationPageOnlyOptions & { + rows?: UrlPaginationRowsConfig; + } = + typeof paramNameOrOptions === 'string' || paramNameOrOptions === undefined + ? { pageParam: paramNameOrOptions ?? 'page' } + : paramNameOrOptions; + + const pageParam = normalized.pageParam ?? 'page'; + const rowsConfig = normalized.rows; + + const [searchParams, setSearchParams] = useSearchParams(); + + const page = useMemo( + () => parseUrlPageParam(searchParams.get(pageParam)), + [searchParams, pageParam], + ); + + const setPage = useCallback( + (next: number | ((prev: number) => number)) => { + setSearchParams( + (prev) => { + const nextParams = new URLSearchParams(prev); + const current = parseUrlPageParam(nextParams.get(pageParam)); + const resolved = typeof next === 'function' ? next(current) : next; + const clamped = + Number.isFinite(resolved) && resolved >= 0 + ? Math.floor(resolved) + : 0; + const serialized = serializeUrlPageParam(clamped); + if (serialized === null) nextParams.delete(pageParam); + else nextParams.set(pageParam, serialized); + return nextParams; + }, + { replace: true }, + ); + }, + [pageParam, setSearchParams], + ); + + const rowsParamName = rowsConfig?.paramName; + const rowsAllowed = rowsConfig?.allowed; + const rowsDefault = rowsConfig?.defaultRows; + const omitWhenDefault = rowsConfig?.omitWhenDefault ?? true; + const resetPageOnRowsChange = rowsConfig?.resetPageOnRowsChange ?? true; + + const rowsPerPage = useMemo(() => { + if (!rowsParamName || !rowsAllowed || rowsDefault === undefined) return 0; + return parseRowsFromUrl( + searchParams.get(rowsParamName), + rowsAllowed, + rowsDefault, + ); + }, [searchParams, rowsParamName, rowsAllowed, rowsDefault]); + + const setRowsPerPage = useCallback( + (next: number) => { + if (!rowsParamName || !rowsAllowed || rowsDefault === undefined) return; + if (!(rowsAllowed as readonly number[]).includes(next)) return; + setSearchParams( + (prev) => { + const nextParams = new URLSearchParams(prev); + if (omitWhenDefault && next === rowsDefault) + nextParams.delete(rowsParamName); + else nextParams.set(rowsParamName, String(next)); + if (resetPageOnRowsChange) nextParams.delete(pageParam); + return nextParams; + }, + { replace: true }, + ); + }, + [ + pageParam, + rowsParamName, + rowsAllowed, + rowsDefault, + omitWhenDefault, + resetPageOnRowsChange, + setSearchParams, + ], + ); + + if (rowsConfig) { + return [page, setPage, rowsPerPage, setRowsPerPage] as const; + } + return [page, setPage] as const; +} + +/** + * Resets pagination when `deps` change, but not on the initial mount — so a + * reload with `?prsPage=3` keeps the URL page intact. + */ +export function useResetPageOnDepsChange( + setPage: SetPage, + deps: DependencyList, +): void { + const isFirstRenderRef = useRef(true); + const prevDepsRef = useRef(null); + useEffect(() => { + if (isFirstRenderRef.current) { + isFirstRenderRef.current = false; + prevDepsRef.current = deps; + return; + } + const prev = prevDepsRef.current; + prevDepsRef.current = deps; + if (prev === null) return; + const changed = deps.some((dep, index) => !Object.is(dep, prev[index])); + if (changed) { + setPage(0); + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- caller supplies deps + }, deps); +} + +/** + * Clamps an out-of-range page after data is ready. Skips while `ready` is false + * so an empty loading state does not wipe a deep-linked page from the URL. + */ +export function useClampUrlPage( + page: number, + setPage: SetPage, + totalPages: number, + ready: boolean, +): void { + useEffect(() => { + if (!ready || totalPages < 1) return; + const maxIndex = totalPages - 1; + if (page > maxIndex) { + setPage(maxIndex); + } + }, [page, setPage, totalPages, ready]); +} diff --git a/src/pages/WatchlistPage.tsx b/src/pages/WatchlistPage.tsx index 1266566c..6e41abe0 100644 --- a/src/pages/WatchlistPage.tsx +++ b/src/pages/WatchlistPage.tsx @@ -76,6 +76,11 @@ import { MINER_ISSUES_FULL_HISTORY_SINCE_ISO, } from '../api'; import { useFiltersPanelOpenInUrl } from '../hooks/useFiltersPanelUrlState'; +import { + useClampUrlPage, + useResetPageOnDepsChange, + useUrlPaginationParam, +} from '../hooks/useUrlPaginationParam'; import type { CommitLog, MinerIssue, @@ -1467,8 +1472,13 @@ const WatchlistStackedPagination: React.FC<{ ); /** Sidebar is beside main content only at `xl+`; below that, paginate tables so stacked sidebars stay reachable. */ -const useWatchlistSidebarFixedRight = () => - useMediaQuery(theme.breakpoints.up('xl')); +const useWatchlistSidebarFixedRight = () => { + const query = theme.breakpoints.up('xl'); + const defaultMatches = + typeof window !== 'undefined' && + window.matchMedia(query.replace(/^@media\s*/, '')).matches; + return useMediaQuery(query, { defaultMatches }); +}; const ReposList: React.FC<{ itemKeys: string[] }> = ({ itemKeys }) => { const theme = useTheme(); @@ -1480,20 +1490,20 @@ const ReposList: React.FC<{ itemKeys: string[] }> = ({ itemKeys }) => { const [viewMode, setViewMode] = useWatchlistViewMode(); const [showChart, setShowChart] = useState(false); const [useLogScale, setUseLogScale] = useState(false); - const [page, setPage] = useState(0); + const [page, setPage] = useUrlPaginationParam({ pageParam: 'reposPage' }); const observerTarget = useRef(null); const [isLoadingMore, setIsLoadingMore] = useState(false); const [sortField, setSortField] = useState('weight'); const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); - useEffect(() => { - setPage(0); - }, [sidebarFixedRight]); - - useEffect(() => { - setPage(0); - }, [searchQuery, sortField, sortOrder, viewMode]); + useResetPageOnDepsChange(setPage, [sidebarFixedRight]); + useResetPageOnDepsChange(setPage, [ + searchQuery, + sortField, + sortOrder, + viewMode, + ]); const handleSort = (field: RepoSortKey) => { if (sortField === field) { @@ -1602,6 +1612,10 @@ const ReposList: React.FC<{ itemKeys: string[] }> = ({ itemKeys }) => { return m; }, [sorted]); + const totalRepoPages = Math.max(1, Math.ceil(sorted.length / ROWS_PER_PAGE)); + + useClampUrlPage(page, setPage, totalRepoPages, repos !== undefined); + const paged = useMemo(() => { if (sidebarFixedRight) { return sorted.slice(0, (page + 1) * ROWS_PER_PAGE); @@ -1628,7 +1642,7 @@ const ReposList: React.FC<{ itemKeys: string[] }> = ({ itemKeys }) => { ); observer.observe(target); return () => observer.disconnect(); - }, [sidebarFixedRight, page, filtered.length]); + }, [sidebarFixedRight, page, filtered.length, setPage]); const maxWeight = useMemo( () => @@ -2353,7 +2367,7 @@ const BountiesList: React.FC<{ itemKeys: string[] }> = ({ itemKeys }) => { const [searchQuery, setSearchQuery] = useState(''); const [statusFilter, setStatusFilter] = useState('all'); const [viewMode, setViewMode] = useWatchlistViewMode(); - const [page, setPage] = useState(0); + const [page, setPage] = useUrlPaginationParam({ pageParam: 'bountiesPage' }); const observerTarget = useRef(null); const [isLoadingMore, setIsLoadingMore] = useState(false); @@ -2378,13 +2392,14 @@ const BountiesList: React.FC<{ itemKeys: string[] }> = ({ itemKeys }) => { } }, [sortField, bountyVisibleSortKeys]); - useEffect(() => { - setPage(0); - }, [sidebarFixedRight]); - - useEffect(() => { - setPage(0); - }, [statusFilter, searchQuery, sortField, sortOrder, viewMode]); + useResetPageOnDepsChange(setPage, [sidebarFixedRight]); + useResetPageOnDepsChange(setPage, [ + statusFilter, + searchQuery, + sortField, + sortOrder, + viewMode, + ]); const handleSort = useCallback( (field: BountySortKey) => { @@ -2397,7 +2412,7 @@ const BountiesList: React.FC<{ itemKeys: string[] }> = ({ itemKeys }) => { } setPage(0); }, - [sortField, bountyVisibleSortKeys, getDefaultSortDirection], + [sortField, bountyVisibleSortKeys, getDefaultSortDirection, setPage], ); const counts = useMemo(() => getBountyCounts(items), [items]); @@ -2433,9 +2448,7 @@ const BountiesList: React.FC<{ itemKeys: string[] }> = ({ itemKeys }) => { Math.ceil(filtered.length / ROWS_PER_PAGE), ); - useEffect(() => { - setPage((p) => Math.min(p, totalBountyPages - 1)); - }, [totalBountyPages]); + useClampUrlPage(page, setPage, totalBountyPages, !isLoading); const paged = useMemo(() => { if (sidebarFixedRight) { @@ -2463,7 +2476,7 @@ const BountiesList: React.FC<{ itemKeys: string[] }> = ({ itemKeys }) => { ); observer.observe(target); return () => observer.disconnect(); - }, [sidebarFixedRight, page, filtered.length]); + }, [sidebarFixedRight, page, filtered.length, setPage]); return ( @@ -3236,26 +3249,20 @@ const PRsList: React.FC<{ itemKeys: string[] }> = ({ itemKeys }) => { isPrStatusFilterStored, ); const [viewMode, setViewMode] = useWatchlistViewMode(); - const [page, setPage] = useState(0); + const [page, setPage] = useUrlPaginationParam({ pageParam: 'prsPage' }); const observerTarget = useRef(null); const [isLoadingMore, setIsLoadingMore] = useState(false); const [sortField, setSortField] = useState('date'); const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); - useEffect(() => { - setPage(0); - }, [sidebarFixedRight]); - - useEffect(() => { - setPage(0); - }, [ + useResetPageOnDepsChange(setPage, [sidebarFixedRight]); + useResetPageOnDepsChange(setPage, [ statusFilter, searchQuery, sortField, sortOrder, viewMode, - isWatched, activeSources, ]); @@ -3335,9 +3342,7 @@ const PRsList: React.FC<{ itemKeys: string[] }> = ({ itemKeys }) => { const totalPrPages = Math.max(1, Math.ceil(filtered.length / ROWS_PER_PAGE)); - useEffect(() => { - setPage((p) => Math.min(p, totalPrPages - 1)); - }, [totalPrPages]); + useClampUrlPage(page, setPage, totalPrPages, !isLoading); const paged = useMemo(() => { if (sidebarFixedRight) { @@ -3365,7 +3370,7 @@ const PRsList: React.FC<{ itemKeys: string[] }> = ({ itemKeys }) => { ); observer.observe(target); return () => observer.disconnect(); - }, [sidebarFixedRight, page, filtered.length]); + }, [sidebarFixedRight, page, filtered.length, setPage]); return ( @@ -4211,20 +4216,21 @@ const IssuesList: React.FC<{ minerIds: string[] }> = ({ minerIds }) => { const [searchQuery, setSearchQuery] = useState(''); const [statusFilter, setStatusFilter] = useState('all'); const [viewMode, setViewMode] = useWatchlistViewMode(); - const [page, setPage] = useState(0); + const [page, setPage] = useUrlPaginationParam({ pageParam: 'issuesPage' }); const observerTarget = useRef(null); const [isLoadingMore, setIsLoadingMore] = useState(false); const [sortField, setSortField] = useState('date'); const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); - useEffect(() => { - setPage(0); - }, [sidebarFixedRight]); - - useEffect(() => { - setPage(0); - }, [statusFilter, searchQuery, sortField, sortOrder, viewMode]); + useResetPageOnDepsChange(setPage, [sidebarFixedRight]); + useResetPageOnDepsChange(setPage, [ + statusFilter, + searchQuery, + sortField, + sortOrder, + viewMode, + ]); const handleSort = (field: IssueSortKey) => { if (sortField === field) { @@ -4270,9 +4276,7 @@ const IssuesList: React.FC<{ minerIds: string[] }> = ({ minerIds }) => { Math.ceil(filtered.length / ROWS_PER_PAGE), ); - useEffect(() => { - setPage((p) => Math.min(p, totalIssuePages - 1)); - }, [totalIssuePages]); + useClampUrlPage(page, setPage, totalIssuePages, !isLoading); const paged = useMemo(() => { if (sidebarFixedRight) { @@ -4300,7 +4304,7 @@ const IssuesList: React.FC<{ minerIds: string[] }> = ({ minerIds }) => { ); observer.observe(target); return () => observer.disconnect(); - }, [sidebarFixedRight, page, filtered.length]); + }, [sidebarFixedRight, page, filtered.length, setPage]); return (