diff --git a/frontend/src/components/OrganizationPaperFeed.tsx b/frontend/src/components/OrganizationPaperFeed.tsx index 3c5765854..7ee1efcf0 100644 --- a/frontend/src/components/OrganizationPaperFeed.tsx +++ b/frontend/src/components/OrganizationPaperFeed.tsx @@ -14,7 +14,7 @@ import { type SearchableSelectOption, } from "@/components/ui/searchable-select"; import { Skeleton } from "@/components/ui/skeleton"; -import { useIntersectionObserver } from "@/hooks/useIntersectionObserver"; +import { useInfiniteScroll } from "@/hooks/useInfiniteScroll"; import { useInfiniteOrganizationPapers } from "@/hooks/usePapers"; import { catalogApi } from "@/lib/api/catalog"; import { displayTaskName, transformPaperForCard } from "@/lib/transform"; @@ -66,15 +66,8 @@ export const OrganizationPaperFeed: React.FC<{ end_date: dateRange.endDate, enabled: !!organizationSlug, }); - const { targetRef, isIntersecting } = useIntersectionObserver({ - rootMargin: "200px", - }); - useEffect(() => { - if (isIntersecting && papersQ.hasNextPage && !papersQ.isFetchingNextPage) { - papersQ.fetchNextPage(); - } - }, [isIntersecting, papersQ.hasNextPage, papersQ.isFetchingNextPage]); // eslint-disable-line react-hooks/exhaustive-deps + const targetRef = useInfiniteScroll(papersQ); useEffect(() => { setLogoFailed(false); diff --git a/frontend/src/components/paper-detail/EditTasksDialog.tsx b/frontend/src/components/paper-detail/EditTasksDialog.tsx index 16e7e2cf3..737ad8896 100644 --- a/frontend/src/components/paper-detail/EditTasksDialog.tsx +++ b/frontend/src/components/paper-detail/EditTasksDialog.tsx @@ -165,7 +165,8 @@ export function EditTasksDialog({ // Apply the new task list to the paper detail cache *before* the network // round-trip so the chip the user just unchecked disappears immediately. // The dialog also closes here, then the canonical server response - // reconciles the cache in `onSuccess` (or rolls back on `onError`). + // reconciles the cache and confirms in `onSuccess` (or rolls back on + // `onError`). onMutate: async ({ taskIds }) => { if (submitMode === "pending") { // Pending submissions don't touch live data, so skip the optimistic @@ -205,23 +206,23 @@ export function EditTasksDialog({ }); }); - toast({ - title: "Tasks updated", - description: `Saved ${taskIds.length} task${ - taskIds.length === 1 ? "" : "s" - } for this paper.`, - }); onSuccess?.(); onOpenChange(false); return { snapshots }; }, - onSuccess: (updatedPaper) => { + onSuccess: (updatedPaper, { taskIds }) => { if (submitMode === "pending") { onSuccess?.(); onOpenChange(false); return; } + toast({ + title: "Tasks updated", + description: `Saved ${taskIds.length} task${ + taskIds.length === 1 ? "" : "s" + } for this paper.`, + }); if (updatedPaper) { refreshPaperQueries(queryClient, updatedPaper, [paperId, arxivId]); } diff --git a/frontend/src/hooks/useBookmarks.ts b/frontend/src/hooks/useBookmarks.ts index c4b28063e..2e02392a6 100644 --- a/frontend/src/hooks/useBookmarks.ts +++ b/frontend/src/hooks/useBookmarks.ts @@ -8,8 +8,8 @@ import { paperQueryKeys } from "@/lib/paperQueryKeys"; * Save button flips state without a round-trip. * * If the user is not signed in, `toggle()` redirects to the HF OAuth login - * (same pattern as `HfSignInButton`) so the user lands back on the page they - * came from. + * with Saved Papers authorization and resumes the save when the user lands + * back on the page they came from. */ import React from "react"; @@ -18,7 +18,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { accountApi } from "@/lib/api/account"; import type { BookmarkIdsResponse } from "@/lib/api/account.types"; import { ApiError } from "@/lib/apiTransport"; -import { apiUrl, isAuthDisabled } from "@/lib/apiUrl"; +import { isAuthDisabled } from "@/lib/apiUrl"; import { useAuth } from "@/lib/authContext"; import { SAVED_PAPERS_AUTHORIZED_PARAM, @@ -132,13 +132,7 @@ export function useToggleBookmark() { (paperId: string | number, isBookmarked: boolean) => { if (!user) { if (isAuthDisabled()) return; - const returnTo = - typeof window !== "undefined" - ? window.location.pathname + window.location.search - : "/"; - window.location.href = apiUrl( - `/api/v1/auth/hf/login?return_to=${encodeURIComponent(returnTo)}`, - ); + authorizeSavedPapers({ paperId, isBookmarked }); return; } mutation.mutate({ paperId, isBookmarked }); diff --git a/frontend/src/hooks/useInfiniteScroll.ts b/frontend/src/hooks/useInfiniteScroll.ts new file mode 100644 index 000000000..3ad4cd4ce --- /dev/null +++ b/frontend/src/hooks/useInfiniteScroll.ts @@ -0,0 +1,23 @@ +import { useEffect } from "react"; + +import { useIntersectionObserver } from "./useIntersectionObserver"; + +interface PageableQuery { + data: unknown; + hasNextPage: boolean; + isFetchingNextPage: boolean; + fetchNextPage: () => Promise; +} + +/** Fetch the next page while the returned sentinel ref is near the viewport. */ +export function useInfiniteScroll(query: PageableQuery, rootMargin = "200px") { + const { targetRef, isIntersecting } = useIntersectionObserver({ rootMargin }); + const { data, hasNextPage, isFetchingNextPage, fetchNextPage } = query; + + // Also rerun when results change: a filter change can leave the sentinel visible. + useEffect(() => { + if (isIntersecting && hasNextPage && !isFetchingNextPage) void fetchNextPage(); + }, [isIntersecting, hasNextPage, isFetchingNextPage, fetchNextPage, data]); + + return targetRef; +} diff --git a/frontend/src/lib/adminAuth.ts b/frontend/src/lib/adminAuth.ts deleted file mode 100644 index 39fcd2351..000000000 --- a/frontend/src/lib/adminAuth.ts +++ /dev/null @@ -1,17 +0,0 @@ -const ADMIN_TOKEN_STORAGE_KEY = "cronAdminToken"; - -export function getStoredAdminToken(): string { - if (typeof window === "undefined") return ""; - return window.localStorage.getItem(ADMIN_TOKEN_STORAGE_KEY)?.trim() ?? ""; -} - -export function setStoredAdminToken(token: string): void { - if (typeof window === "undefined") return; - - const trimmedToken = token.trim(); - if (trimmedToken) { - window.localStorage.setItem(ADMIN_TOKEN_STORAGE_KEY, trimmedToken); - } else { - window.localStorage.removeItem(ADMIN_TOKEN_STORAGE_KEY); - } -} diff --git a/frontend/src/lib/apiTransport.test.ts b/frontend/src/lib/apiTransport.test.ts index a4ee435df..b0828a101 100644 --- a/frontend/src/lib/apiTransport.test.ts +++ b/frontend/src/lib/apiTransport.test.ts @@ -1,12 +1,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { setStoredAdminToken } from "./adminAuth"; import { ApiError, ApiTransport, getApiErrorMessage, requestJson } from "./apiTransport"; describe("API transport", () => { beforeEach(() => { vi.restoreAllMocks(); - setStoredAdminToken(""); }); it("constructs the URL and parses a successful JSON response", async () => { @@ -57,8 +55,8 @@ describe("API transport", () => { expect(new Headers(options?.headers).get("Accept")).toBe("text/event-stream"); }); - it("includes browser credentials and the stored admin credential", async () => { - setStoredAdminToken("secret-admin-token"); + it("includes browser credentials and never sends an admin token", async () => { + window.localStorage.setItem("cronAdminToken", "secret-admin-token"); const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( new Response(JSON.stringify({ ok: true }), { status: 200 }), ); @@ -67,9 +65,8 @@ describe("API transport", () => { const options = fetchMock.mock.calls[0][1]; expect(options?.credentials).toBe("include"); - expect(new Headers(options?.headers).get("X-Admin-Token")).toBe( - "secret-admin-token", - ); + expect(new Headers(options?.headers).has("X-Admin-Token")).toBe(false); + window.localStorage.removeItem("cronAdminToken"); }); it("returns a typed error without applying browser policy", async () => { diff --git a/frontend/src/lib/apiTransport.ts b/frontend/src/lib/apiTransport.ts index 9f1c3080d..e8acf7e7b 100644 --- a/frontend/src/lib/apiTransport.ts +++ b/frontend/src/lib/apiTransport.ts @@ -1,4 +1,3 @@ -import { getStoredAdminToken } from "./adminAuth"; const LOG_API_REQUESTS = import.meta.env.DEV; @@ -85,14 +84,10 @@ export class ApiTransport { const { headers, ...requestOptions } = options ?? {}; const method = (requestOptions.method ?? "GET").toUpperCase(); const defaultCache: RequestCache = method === "GET" ? "default" : "no-store"; - const adminToken = getStoredAdminToken(); const requestHeaders = new Headers(headers); if (!requestHeaders.has("Content-Type")) { requestHeaders.set("Content-Type", "application/json"); } - if (adminToken && !requestHeaders.has("X-Admin-Token")) { - requestHeaders.set("X-Admin-Token", adminToken); - } const response = await fetch(url, { ...requestOptions, cache: requestOptions.cache ?? defaultCache, diff --git a/frontend/src/lib/authContext.tsx b/frontend/src/lib/authContext.tsx index b7525cb12..5b9f3745a 100644 --- a/frontend/src/lib/authContext.tsx +++ b/frontend/src/lib/authContext.tsx @@ -12,6 +12,7 @@ import React from "react"; import posthog from "posthog-js"; +import { useQueryClient } from "@tanstack/react-query"; import { authApi, type AuthUser } from "./api/auth"; import { authUrl } from "./apiUrl"; @@ -41,6 +42,8 @@ function consumeSignupMarker(): boolean { export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const [user, setUser] = React.useState(null); const [isLoading, setIsLoading] = React.useState(true); + const queryClient = useQueryClient(); + const previousUserRef = React.useRef(null); const refresh = React.useCallback(async () => { setIsLoading(true); @@ -83,6 +86,12 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children } }, [user]); + // Drop the signed-out user's cached data (bookmarks, follows, sessions). + React.useEffect(() => { + if (previousUserRef.current && !user) void queryClient.resetQueries(); + previousUserRef.current = user; + }, [queryClient, user]); + React.useEffect(() => { const handler = () => { setUser(null); diff --git a/frontend/src/pages/AutonomousOperations.tsx b/frontend/src/pages/AutonomousOperations.tsx index 0ad5639d3..575e24a87 100644 --- a/frontend/src/pages/AutonomousOperations.tsx +++ b/frontend/src/pages/AutonomousOperations.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Activity, ArrowLeft, ShieldAlert } from "lucide-react"; import { Link } from "react-router-dom"; +import "./CronDashboard.css"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; diff --git a/frontend/src/pages/SubmissionsDashboard.css b/frontend/src/pages/SubmissionsDashboard.css index 55fcdb5de..d29997154 100644 --- a/frontend/src/pages/SubmissionsDashboard.css +++ b/frontend/src/pages/SubmissionsDashboard.css @@ -315,43 +315,6 @@ color: var(--ink); font-weight: 500; } -.submission-loading, -.submission-empty { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 10px; - min-height: 240px; - padding: 32px; - text-align: center; - color: var(--ink-faint); -} -.submission-loading { - min-height: 160px; - flex-direction: row; - font-family: var(--mono); - font-size: 12px; -} -.submission-empty h1, -.submission-empty h2 { - font-family: var(--serif); - font-weight: 500; - color: var(--ink); -} -.submission-empty p { - max-width: 460px; - color: var(--ink-soft); -} -.submission-empty a { - color: var(--accent-text); - text-decoration: underline; -} -.submission-empty-warn { - border: 1px solid var(--hairline); - border-radius: 8px; - background: var(--bg-elevated); -} .submission-pagination { display: flex; flex-direction: column; diff --git a/frontend/src/pages/SubmissionsDashboard.test.tsx b/frontend/src/pages/SubmissionsDashboard.test.tsx index f380d0b3b..eb2b02c8d 100644 --- a/frontend/src/pages/SubmissionsDashboard.test.tsx +++ b/frontend/src/pages/SubmissionsDashboard.test.tsx @@ -92,6 +92,39 @@ beforeEach(() => { }); describe("SubmissionsDashboard", () => { + it("pages every status by offset instead of growing the request limit", async () => { + const rowsFor = (status: "pending" | "approved" | "rejected", parity: number) => + Array.from({ length: status === "rejected" ? 0 : 30 }, (_, index) => ({ + ...externalPaperSubmission, + id: 2 * index + parity + 1, + status, + created_at: new Date(Date.UTC(2026, 7, 10) - (2 * index + parity) * 60_000).toISOString(), + })); + const rows = { pending: rowsFor("pending", 0), approved: rowsFor("approved", 1), rejected: rowsFor("rejected", 0) }; + listPendingPaperEdits.mockImplementation( + async ({ status, limit, offset }: { status: keyof typeof rows; limit: number; offset: number }) => ({ + count: rows[status].length, + results: rows[status].slice(offset, offset + limit), + }), + ); + + renderDashboard(); + const [allStatuses] = await screen.findAllByRole("button", { name: /^all/ }); + await userEvent.click(allStatuses); + expect(await screen.findByText("Showing 25 of 60 submissions")).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Load more" })); + expect(await screen.findByText("Showing 50 of 60 submissions")).toBeInTheDocument(); + + const pageCalls = listPendingPaperEdits.mock.calls.map(([params]) => params).filter((params) => params.limit !== 1); + expect(pageCalls.every((params) => params.limit === 25)).toBe(true); + const nextPage = pageCalls.slice(-3); + expect(nextPage.map((params) => [params.status, params.offset])).toEqual([ + ["pending", 13], + ["approved", 12], + ["rejected", 0], + ]); + }); + it("shows a controlled error when pending-edit data is malformed", async () => { listPendingPaperEdits.mockRejectedValue( new Error("Malformed pending-edit payload: proposed_payload.task_ids: Required"), diff --git a/frontend/src/pages/SubmissionsDashboard.tsx b/frontend/src/pages/SubmissionsDashboard.tsx index 7e3c0a5cc..5e906ff29 100644 --- a/frontend/src/pages/SubmissionsDashboard.tsx +++ b/frontend/src/pages/SubmissionsDashboard.tsx @@ -3,7 +3,7 @@ import { paperQueryKeys } from "@/lib/paperQueryKeys"; import { invalidatePaperDetails, invalidatePaperFeeds } from "@/lib/queryInvalidation"; import React from "react"; import "./SubmissionsDashboard.css"; -import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useInfiniteQuery, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import { CheckCircle2, Inbox, @@ -40,6 +40,8 @@ const EDIT_TYPE_FILTERS: Array = [ "project_pages", "source_url", "x_threads", + "hf_artifacts", + "organizations", "new_task", "new_method", "new_benchmark", @@ -131,40 +133,42 @@ function sortByCreatedDesc(rows: PendingPaperEdit[]): PendingPaperEdit[] { return [...rows].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); } -async function fetchSubmissions( - status: PendingEditStatus | "all", +type StatusOffsets = Partial>; + +interface SubmissionPage extends ListData { + nextOffsets: StatusOffsets | null; +} + +// One page of the newest submissions across `statuses`. Each status list is +// sorted newest first, so merging the next PAGE_SIZE rows of every list and +// keeping the newest PAGE_SIZE gives the exact next page; each status offset +// advances only by the rows it contributed. +async function fetchSubmissionPage( + statuses: PendingEditStatus[], editType: PendingEditType | "all", - visibleLimit: number, autoOnly: boolean, -): Promise { - const edit_type = editType === "all" ? undefined : editType; - const reviewed_by_kind = autoOnly ? "auto" : undefined; - if (status !== "all") { - return submissionsApi.list({ - status, - edit_type, - reviewed_by_kind, - limit: visibleLimit, - offset: 0, - }); - } - - const pages = await Promise.all( - COUNT_STATUSES.map((itemStatus) => + offsets: StatusOffsets, +): Promise { + const lists = await Promise.all( + statuses.map((status) => submissionsApi.list({ - status: itemStatus, - edit_type, - reviewed_by_kind, - limit: visibleLimit, - offset: 0, + status, + edit_type: editType === "all" ? undefined : editType, + reviewed_by_kind: autoOnly ? "auto" : undefined, + limit: PAGE_SIZE, + offset: offsets[status] ?? 0, }), ), ); - - return { - count: pages.reduce((sum, page) => sum + page.count, 0), - results: sortByCreatedDesc(pages.flatMap((page) => page.results)).slice(0, visibleLimit), - }; + const results = sortByCreatedDesc(lists.flatMap((list) => list.results)).slice(0, PAGE_SIZE); + const nextOffsets: StatusOffsets = {}; + statuses.forEach((status, index) => { + const taken = results.filter((row) => lists[index].results.includes(row)).length; + nextOffsets[status] = (offsets[status] ?? 0) + taken; + }); + const count = lists.reduce((sum, list) => sum + list.count, 0); + const loaded = statuses.reduce((sum, status) => sum + (nextOffsets[status] ?? 0), 0); + return { count, results, nextOffsets: results.length > 0 && loaded < count ? nextOffsets : null }; } function AdminPlaceholder() { @@ -502,13 +506,8 @@ export default function SubmissionsDashboard() { const [status, setStatus] = React.useState("pending"); const [editType, setEditType] = React.useState("all"); const [autoOnly, setAutoOnly] = React.useState(false); - const [visibleLimit, setVisibleLimit] = React.useState(PAGE_SIZE); const isAdmin = !!user?.is_admin; - React.useEffect(() => { - setVisibleLimit(PAGE_SIZE); - }, [status, editType, autoOnly]); - const countsQuery = useQuery({ queryKey: ["pendingPaperEdits", "counts", editType, autoOnly], queryFn: async () => { @@ -532,23 +531,30 @@ export default function SubmissionsDashboard() { staleTime: 30 * 1000, }); - const listQuery = useQuery({ - queryKey: ["pendingPaperEdits", { status, editType, visibleLimit, autoOnly }], - queryFn: () => fetchSubmissions(status, editType, visibleLimit, autoOnly), + const listQuery = useInfiniteQuery({ + queryKey: ["pendingPaperEdits", { status, editType, autoOnly }], + queryFn: ({ pageParam }) => + fetchSubmissionPage(status === "all" ? COUNT_STATUSES : [status], editType, autoOnly, pageParam), + initialPageParam: {} as StatusOffsets, + getNextPageParam: (page) => page.nextOffsets ?? undefined, enabled: isAdmin, retry: false, }); + const submissions = React.useMemo( + () => listQuery.data?.pages.flatMap((page) => page.results) ?? [], + [listQuery.data], + ); const paperIds = React.useMemo( () => Array.from( new Set( - (listQuery.data?.results ?? []) + submissions .map((row) => row.paper_id) .filter((paperId): paperId is number => paperId != null), ), ).sort((a, b) => a - b), - [listQuery.data?.results], + [submissions], ); const paperQueries = useQueries({ @@ -591,9 +597,7 @@ export default function SubmissionsDashboard() { rejected: counts?.rejected, all: allCount, }; - const submissions = listQuery.data?.results ?? []; - const totalCount = listQuery.data?.count ?? 0; - const hasMore = submissions.length < totalCount; + const totalCount = listQuery.data?.pages[0]?.count ?? 0; return (
@@ -722,14 +726,14 @@ export default function SubmissionsDashboard() {

Showing {submissions.length} of {totalCount} submissions

- {hasMore && ( + {listQuery.hasNextPage && ( )} diff --git a/frontend/src/styles/features/admin.css b/frontend/src/styles/features/admin.css new file mode 100644 index 000000000..80ee17e19 --- /dev/null +++ b/frontend/src/styles/features/admin.css @@ -0,0 +1,38 @@ +/* Admin gate and dashboard placeholders, shared by App route gates and admin pages. */ +.submission-loading, +.submission-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + min-height: 240px; + padding: 32px; + text-align: center; + color: var(--ink-faint); +} +.submission-loading { + min-height: 160px; + flex-direction: row; + font-family: var(--mono); + font-size: 12px; +} +.submission-empty h1, +.submission-empty h2 { + font-family: var(--serif); + font-weight: 500; + color: var(--ink); +} +.submission-empty p { + max-width: 460px; + color: var(--ink-soft); +} +.submission-empty a { + color: var(--accent-text); + text-decoration: underline; +} +.submission-empty-warn { + border: 1px solid var(--hairline); + border-radius: 8px; + background: var(--bg-elevated); +} diff --git a/frontend/src/styles/features/index.css b/frontend/src/styles/features/index.css index a2fa773f2..4820d3039 100644 --- a/frontend/src/styles/features/index.css +++ b/frontend/src/styles/features/index.css @@ -11,3 +11,4 @@ @import "./paper-reader.css"; @import "./saved-papers.css"; @import "./home.css"; +@import "./admin.css"; diff --git a/frontend/src/views/AstraForRoboticsView.tsx b/frontend/src/views/AstraForRoboticsView.tsx index e2172cee6..a3f2fbce9 100644 --- a/frontend/src/views/AstraForRoboticsView.tsx +++ b/frontend/src/views/AstraForRoboticsView.tsx @@ -5,7 +5,7 @@ import { PaperCard } from "@/components"; import { PaperFeedFilters } from "@/components/PaperFeedFilters"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Skeleton } from "@/components/ui/skeleton"; -import { useIntersectionObserver } from "@/hooks/useIntersectionObserver"; +import { useInfiniteScroll } from "@/hooks/useInfiniteScroll"; import { useInfinitePapers } from "@/hooks/usePapers"; import { DEFAULT_SITE_SUFFIX, formatPageTitle } from "@/lib/latexPlainText"; import { transformPaperForCard } from "@/lib/transform"; @@ -157,9 +157,6 @@ const AstraForRoboticsView: React.FC = () => { has_code: hasCode, has_hf_artifacts: hasHfArtifacts, }); - const { targetRef, isIntersecting } = useIntersectionObserver({ - rootMargin: "200px", - }); useEffect(() => { document.title = formatPageTitle("GPT-6 Astra for Robotics"); @@ -168,11 +165,7 @@ const AstraForRoboticsView: React.FC = () => { }; }, []); - useEffect(() => { - if (isIntersecting && papersQ.hasNextPage && !papersQ.isFetchingNextPage) { - papersQ.fetchNextPage(); - } - }, [isIntersecting, papersQ.hasNextPage, papersQ.isFetchingNextPage]); // eslint-disable-line react-hooks/exhaustive-deps + const targetRef = useInfiniteScroll(papersQ); const cards = useMemo( () => diff --git a/frontend/src/views/AuthorDetailView.tsx b/frontend/src/views/AuthorDetailView.tsx index bc57d1646..2ba23df23 100644 --- a/frontend/src/views/AuthorDetailView.tsx +++ b/frontend/src/views/AuthorDetailView.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo } from "react"; +import React, { useMemo } from "react"; import { Link, Navigate, useParams, useSearchParams } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; @@ -9,7 +9,7 @@ import { PaperFeedFilters } from "@/components/PaperFeedFilters"; import ResearchFollowButton from "@/components/ResearchFollowButton"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Skeleton } from "@/components/ui/skeleton"; -import { useIntersectionObserver } from "@/hooks/useIntersectionObserver"; +import { useInfiniteScroll } from "@/hooks/useInfiniteScroll"; import { useInfinitePapers } from "@/hooks/usePapers"; import { papersApi } from "@/lib/api/papers"; import { readPaperDateRange, withPaperDateRange } from "@/lib/paperDateRange"; @@ -47,19 +47,13 @@ const AuthorFeed: React.FC<{ authorId: string }> = ({ authorId }) => { start_date: dateRange.startDate, end_date: dateRange.endDate, }); - const { targetRef, isIntersecting } = useIntersectionObserver({ rootMargin: "200px" }); const cards = useMemo( () => (papers.data?.pages ?? []).flatMap((page) => page.results.map(transformPaperForCard)), [papers.data], ); - // Also rerun when results change: a filter change can leave the sentinel visible. - useEffect(() => { - if (isIntersecting && papers.hasNextPage && !papers.isFetchingNextPage) { - papers.fetchNextPage(); - } - }, [isIntersecting, papers.hasNextPage, papers.isFetchingNextPage, cards.length]); // eslint-disable-line react-hooks/exhaustive-deps + const targetRef = useInfiniteScroll(papers); const totalCount = papers.data?.pages[0]?.count ?? 0; const setParam = (key: string, value: string | null) => { diff --git a/frontend/src/views/ConferenceTaskView.tsx b/frontend/src/views/ConferenceTaskView.tsx index 8f119c567..90e53b1ad 100644 --- a/frontend/src/views/ConferenceTaskView.tsx +++ b/frontend/src/views/ConferenceTaskView.tsx @@ -9,7 +9,7 @@ import MarkdownText from "@/components/MarkdownText"; import { catalogApi } from "@/lib/api/catalog"; import { displayTaskName, transformPaperForCard } from "@/lib/transform"; import { useInfiniteConferencePapers } from "@/hooks/usePapers"; -import { useIntersectionObserver } from "@/hooks/useIntersectionObserver"; +import { useInfiniteScroll } from "@/hooks/useInfiniteScroll"; import { Skeleton } from "@/components/ui/skeleton"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { readPaperDateRange, withPaperDateRange } from "@/lib/paperDateRange"; @@ -105,13 +105,8 @@ const ConferenceTaskView: React.FC = () => { enabled: !!confSlug, }); - const { targetRef: sentinelRef, isIntersecting } = useIntersectionObserver({ rootMargin: "200px" }); - useEffect(() => { - if (isIntersecting && papersQ.hasNextPage && !papersQ.isFetchingNextPage) { - papersQ.fetchNextPage(); - } - }, [isIntersecting, papersQ.hasNextPage, papersQ.isFetchingNextPage]); // eslint-disable-line react-hooks/exhaustive-deps + const sentinelRef = useInfiniteScroll(papersQ); useEffect(() => { window.scrollTo(0, 0); diff --git a/frontend/src/views/FeedView.tsx b/frontend/src/views/FeedView.tsx index 934ec753f..51accef15 100644 --- a/frontend/src/views/FeedView.tsx +++ b/frontend/src/views/FeedView.tsx @@ -33,7 +33,7 @@ import { displayTaskName, transformPaperForCard } from "@/lib/transform"; import { useInfinitePapers } from "@/hooks/usePapers"; import { papersApi } from "@/lib/api/papers"; import { paperQueryKeys } from "@/lib/paperQueryKeys"; -import { useIntersectionObserver } from "@/hooks/useIntersectionObserver"; +import { useInfiniteScroll } from "@/hooks/useInfiniteScroll"; import { Skeleton } from "@/components/ui/skeleton"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -447,12 +447,7 @@ const FeedView: React.FC<{ following?: boolean; xTrending?: boolean }> = ({ foll : (xTrending ? xFeed.data : publicFeed.data)?.pages.flatMap(page => page.results.map(paper => ({ ...transformPaperForCard(paper), followReasons: undefined }))) ?? [], [following, xTrending, followedFeed.data, xFeed.data, publicFeed.data]); - const { targetRef: sentinelRef, isIntersecting } = useIntersectionObserver({ rootMargin: "400px" }); - useEffect(() => { - if (isIntersecting && infinite.hasNextPage && !infinite.isFetchingNextPage) { - infinite.fetchNextPage(); - } - }, [isIntersecting, infinite.hasNextPage, infinite.isFetchingNextPage]); // eslint-disable-line react-hooks/exhaustive-deps + const sentinelRef = useInfiniteScroll(infinite, "400px"); const isLoading = infinite.isLoading || (following && auth?.isLoading); const error = infinite.error; diff --git a/frontend/src/views/MethodDetailView.tsx b/frontend/src/views/MethodDetailView.tsx index 150c6e18d..a26d21448 100644 --- a/frontend/src/views/MethodDetailView.tsx +++ b/frontend/src/views/MethodDetailView.tsx @@ -1,6 +1,6 @@ import ResearchFollowButton from "@/components/ResearchFollowButton"; import { paperQueryKeys } from "@/lib/paperQueryKeys"; -import React, { useEffect, useMemo, useState } from "react"; +import React, { useMemo, useState } from "react"; import { Link, useParams, useSearchParams } from "react-router-dom"; import { keepPreviousData, useInfiniteQuery, useQuery } from "@tanstack/react-query"; @@ -14,7 +14,7 @@ import { catalogApi } from "@/lib/api/catalog"; import { papersApi } from "@/lib/api/papers"; import { slugify } from "@/lib/slug"; import { transformPaperForCard } from "@/lib/transform"; -import { useIntersectionObserver } from "@/hooks/useIntersectionObserver"; +import { useInfiniteScroll } from "@/hooks/useInfiniteScroll"; import { Skeleton } from "@/components/ui/skeleton"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { resolveInternalPath } from "@/lib/internalLinks"; @@ -114,12 +114,7 @@ const MethodDetailView: React.FC = () => { return all.map(transformPaperForCard); }, [papersQ.data]); - const { targetRef: sentinelRef, isIntersecting } = useIntersectionObserver({ rootMargin: "200px" }); - useEffect(() => { - if (isIntersecting && papersQ.hasNextPage && !papersQ.isFetchingNextPage) { - papersQ.fetchNextPage(); - } - }, [isIntersecting, papersQ.hasNextPage, papersQ.isFetchingNextPage]); // eslint-disable-line react-hooks/exhaustive-deps + const sentinelRef = useInfiniteScroll(papersQ); if (methodQ.isLoading) { return ( diff --git a/frontend/src/views/PapersListView.tsx b/frontend/src/views/PapersListView.tsx index 0089081c5..d92021474 100644 --- a/frontend/src/views/PapersListView.tsx +++ b/frontend/src/views/PapersListView.tsx @@ -6,7 +6,7 @@ import { catalogApi } from "@/lib/api/catalog"; import { displayTaskName, transformPaperForCard } from "@/lib/transform"; import { useInfinitePapers, useInfinitePaperSearch } from "@/hooks/usePapers"; import { useDebounce } from "@/hooks/useDebounce"; -import { useIntersectionObserver } from "@/hooks/useIntersectionObserver"; +import { useInfiniteScroll } from "@/hooks/useInfiniteScroll"; import { Skeleton } from "@/components/ui/skeleton"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { useQuery } from "@tanstack/react-query"; @@ -145,12 +145,7 @@ const PapersListView: React.FC = () => { }); }, [infinite.data, isSearchMode, urlSearch]); - const { targetRef: sentinelRef, isIntersecting } = useIntersectionObserver({ rootMargin: "200px" }); - useEffect(() => { - if (isIntersecting && infinite.hasNextPage && !infinite.isFetchingNextPage) { - infinite.fetchNextPage(); - } - }, [isIntersecting, infinite.hasNextPage, infinite.isFetchingNextPage]); // eslint-disable-line react-hooks/exhaustive-deps + const sentinelRef = useInfiniteScroll(infinite); const totalCount = regularInfinite.data?.pages?.[0]?.count ?? 0; const scope = regularInfinite.data?.pages?.[0]?.scope; diff --git a/frontend/src/views/SavedPapersView.tsx b/frontend/src/views/SavedPapersView.tsx index 56377bf31..521e6036e 100644 --- a/frontend/src/views/SavedPapersView.tsx +++ b/frontend/src/views/SavedPapersView.tsx @@ -73,6 +73,9 @@ const SavedPapersView: React.FC = () => { ); } + // Position across all task groups, so only the first cards on the page load eagerly. + let cardPosition = 0; + return (
@@ -197,7 +200,7 @@ const SavedPapersView: React.FC = () => { ))}
diff --git a/frontend/src/views/StagingAccessRequestView.test.tsx b/frontend/src/views/StagingAccessRequestView.test.tsx index 9c28094f9..240715186 100644 --- a/frontend/src/views/StagingAccessRequestView.test.tsx +++ b/frontend/src/views/StagingAccessRequestView.test.tsx @@ -1,3 +1,4 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -31,9 +32,11 @@ describe("StagingAccessRequestView", () => { } as Response); render( - - - , + + + + + , ); expect(await screen.findByDisplayValue("verified-user")).toHaveAttribute("readonly"); diff --git a/frontend/src/views/SubmitView.tsx b/frontend/src/views/SubmitView.tsx index 8917e0a7d..1b713b3ef 100644 --- a/frontend/src/views/SubmitView.tsx +++ b/frontend/src/views/SubmitView.tsx @@ -21,6 +21,7 @@ import type { PaperSubmitPayload, PaperSubmitResponse } from "@/lib/api/papers.t import { papersApi } from "@/lib/api/papers"; import { authUrl } from "@/lib/apiUrl"; import { useAuth } from "@/lib/authContext"; +import { invalidatePaperFeeds } from "@/lib/queryInvalidation"; const SUBMIT_DRAFT_KEY = "pwc:paper-submit-draft"; @@ -147,15 +148,7 @@ const SubmitView: React.FC = () => { mutation.reset(); return; } - queryClient.invalidateQueries({ queryKey: ["papers"], refetchType: "none" }); - queryClient.invalidateQueries({ - queryKey: ["papers-infinite"], - refetchType: "none", - }); - queryClient.invalidateQueries({ - queryKey: ["trending-papers"], - refetchType: "none", - }); + invalidatePaperFeeds(queryClient); const importedNewVersion = data.refreshed && data.created && data.latest_version; toast({ title: importedNewVersion diff --git a/frontend/src/views/TagBrowseView.tsx b/frontend/src/views/TagBrowseView.tsx index 02be2285d..1d2123b99 100644 --- a/frontend/src/views/TagBrowseView.tsx +++ b/frontend/src/views/TagBrowseView.tsx @@ -24,7 +24,7 @@ import { usePreferences } from "@/lib/preferencesContext"; import { slugify } from "@/lib/slug"; import { displayTaskName, transformPaperForCard } from "@/lib/transform"; import { useInfiniteTaskPapers } from "@/hooks/usePapers"; -import { useIntersectionObserver } from "@/hooks/useIntersectionObserver"; +import { useInfiniteScroll } from "@/hooks/useInfiniteScroll"; import { Skeleton } from "@/components/ui/skeleton"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { readPaperDateRange, withPaperDateRange } from "@/lib/paperDateRange"; @@ -321,12 +321,7 @@ const TagBrowseView: React.FC = () => { return Object.values(counts).sort((a, b) => b.count - a.count).slice(0, 8); }, [papersQ.data]); - const { targetRef: sentinelRef, isIntersecting } = useIntersectionObserver({ rootMargin: "200px" }); - useEffect(() => { - if (isIntersecting && papersQ.hasNextPage && !papersQ.isFetchingNextPage) { - papersQ.fetchNextPage(); - } - }, [isIntersecting, papersQ.hasNextPage, papersQ.isFetchingNextPage]); // eslint-disable-line react-hooks/exhaustive-deps + const sentinelRef = useInfiniteScroll(papersQ); if (pageQ.isLoading) { return (