From cb9530914aa839c94d45d802cf883e4fe5c4055c Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Thu, 19 Mar 2026 13:51:25 +0000 Subject: [PATCH 1/3] [#371] Switch Load more pagination to cursor-based offset Instead of re-fetching all rows with an increasing limit, each "Load more" click now fetches only the next page at the correct offset and appends to accumulated results. Affects reader donation history, reader trading history, and writer donation history. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/dashboard/reader/page.tsx | 81 ++++++++++++++++++++----------- src/app/dashboard/writer/page.tsx | 30 ++++++++---- 2 files changed, 74 insertions(+), 37 deletions(-) diff --git a/src/app/dashboard/reader/page.tsx b/src/app/dashboard/reader/page.tsx index 69cea98c..323a0066 100644 --- a/src/app/dashboard/reader/page.tsx +++ b/src/app/dashboard/reader/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useRef, useState } from "react"; import { useAccount } from "wagmi"; import { useQuery } from "@tanstack/react-query"; import { supabase, type Donation, type TradeHistory } from "../../../../lib/supabase"; @@ -31,19 +31,17 @@ interface DonationPage { async function fetchDonationPage( address: string, - _page: number, - limit: number = PAGE_SIZE, + offset: number, + pageSize: number = PAGE_SIZE, ): Promise { if (!supabase) return { rows: [], totalCount: 0 }; - const from = 0; - const to = limit - 1; const { data, count, error } = await supabase .from("donations") .select("*", { count: "exact" }) .eq("donor_address", address.toLowerCase()) .eq("contract_address", STORY_FACTORY.toLowerCase()) .order("block_timestamp", { ascending: false }) - .range(from, to) + .range(offset, offset + pageSize - 1) .returns(); if (error) throw error; return { rows: data ?? [], totalCount: count ?? 0 }; @@ -51,19 +49,32 @@ async function fetchDonationPage( export default function ReaderDashboard() { const { address, isConnected } = useAccount(); - const [limit, setLimit] = useState(PAGE_SIZE); - - // Reset when wallet address changes - useEffect(() => { - setLimit(PAGE_SIZE); - }, [address]); + const [offset, setOffset] = useState(0); + const allDonationsRef = useRef([]); + const [prevAddress, setPrevAddress] = useState(address); + if (address !== prevAddress) { + setPrevAddress(address); + setOffset(0); + allDonationsRef.current = []; + } - const { data, isLoading, error } = useQuery({ - queryKey: ["reader-donations", address, limit], - queryFn: () => fetchDonationPage(address!, 0, limit), + const { data, isLoading, isFetching, error } = useQuery({ + queryKey: ["reader-donations", address, offset], + queryFn: () => fetchDonationPage(address!, offset), enabled: isConnected && !!address, }); + // Accumulate pages as new data arrives + if (data) { + if (offset === 0) { + if (allDonationsRef.current.length === 0 || allDonationsRef.current[0]?.id !== data.rows[0]?.id) { + allDonationsRef.current = data.rows; + } + } else if (allDonationsRef.current.length === offset) { + allDonationsRef.current = [...allDonationsRef.current, ...data.rows]; + } + } + // Fetch reserve token decimals dynamically const { data: reserveDecimals = 18 } = useQuery({ queryKey: ["reserve-decimals"], @@ -76,7 +87,7 @@ export default function ReaderDashboard() { }, }); - const donations = data?.rows ?? []; + const donations = allDonationsRef.current; const totalCount = data?.totalCount ?? 0; if (!isConnected) { @@ -147,10 +158,11 @@ export default function ReaderDashboard() { {hasMore && ( )} @@ -197,15 +209,17 @@ function DonationRow({ donation, decimals }: { donation: Donation; decimals: num const TRADE_PAGE_SIZE = 10; function TradingHistory({ address }: { address: string }) { - const [limit, setLimit] = useState(TRADE_PAGE_SIZE); + const [offset, setOffset] = useState(0); + const allTradesRef = useRef([]); const [prevAddress, setPrevAddress] = useState(address); if (address !== prevAddress) { setPrevAddress(address); - setLimit(TRADE_PAGE_SIZE); + setOffset(0); + allTradesRef.current = []; } - const { data, isLoading } = useQuery({ - queryKey: ["reader-trades", address, limit], + const { data, isLoading, isFetching } = useQuery({ + queryKey: ["reader-trades", address, offset], queryFn: async () => { if (!supabase) return { rows: [], totalCount: 0 }; const { data: rows, count } = await supabase @@ -213,13 +227,23 @@ function TradingHistory({ address }: { address: string }) { .select("*", { count: "exact" }) .eq("user_address", address.toLowerCase()) .order("block_timestamp", { ascending: false }) - .range(0, limit - 1) + .range(offset, offset + TRADE_PAGE_SIZE - 1) .returns(); return { rows: rows ?? [], totalCount: count ?? 0 }; }, }); - const trades = data?.rows ?? []; + if (data) { + if (offset === 0) { + if (allTradesRef.current.length === 0 || allTradesRef.current[0]?.id !== data.rows[0]?.id) { + allTradesRef.current = data.rows; + } + } else if (allTradesRef.current.length === offset) { + allTradesRef.current = [...allTradesRef.current, ...data.rows]; + } + } + + const trades = allTradesRef.current; const totalCount = data?.totalCount ?? 0; const hasMore = trades.length < totalCount; @@ -289,10 +313,11 @@ function TradingHistory({ address }: { address: string }) { {hasMore && ( )} diff --git a/src/app/dashboard/writer/page.tsx b/src/app/dashboard/writer/page.tsx index 88b7c385..3213b79c 100644 --- a/src/app/dashboard/writer/page.tsx +++ b/src/app/dashboard/writer/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { useAccount, useSignMessage } from "wagmi"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { formatUnits } from "viem"; @@ -188,10 +188,11 @@ function StorylineDetail({ storyline, writerAddress }: { storyline: Storyline; w const DONATION_PAGE_SIZE = 10; function WriterDonationHistory({ storylineId }: { storylineId: number }) { - const [limit, setLimit] = useState(DONATION_PAGE_SIZE); + const [offset, setOffset] = useState(0); + const allDonationsRef = useRef([]); - const { data } = useQuery({ - queryKey: ["writer-donations", storylineId, limit], + const { data, isFetching } = useQuery({ + queryKey: ["writer-donations", storylineId, offset], queryFn: async () => { if (!supabase) return { rows: [], totalCount: 0 }; const { data: rows, count } = await supabase @@ -200,13 +201,23 @@ function WriterDonationHistory({ storylineId }: { storylineId: number }) { .eq("storyline_id", storylineId) .eq("contract_address", STORY_FACTORY.toLowerCase()) .order("block_timestamp", { ascending: false }) - .range(0, limit - 1) + .range(offset, offset + DONATION_PAGE_SIZE - 1) .returns(); return { rows: rows ?? [], totalCount: count ?? 0 }; }, }); - const donations = data?.rows ?? []; + if (data) { + if (offset === 0) { + if (allDonationsRef.current.length === 0 || allDonationsRef.current[0]?.id !== data.rows[0]?.id) { + allDonationsRef.current = data.rows; + } + } else if (allDonationsRef.current.length === offset) { + allDonationsRef.current = [...allDonationsRef.current, ...data.rows]; + } + } + + const donations = allDonationsRef.current; const totalCount = data?.totalCount ?? 0; const hasMore = donations.length < totalCount; @@ -257,10 +268,11 @@ function WriterDonationHistory({ storylineId }: { storylineId: number }) { {hasMore && ( )} From 31fa4f4048582cd5f4b66245eafcdb6a7b702478 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Thu, 19 Mar 2026 13:53:02 +0000 Subject: [PATCH 2/3] [#371] Reset accumulated donations on storylineId change Fixes stale data bug: WriterDonationHistory now clears accumulated ref and resets offset when storylineId prop changes. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/dashboard/writer/page.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/app/dashboard/writer/page.tsx b/src/app/dashboard/writer/page.tsx index 3213b79c..a2a44fd5 100644 --- a/src/app/dashboard/writer/page.tsx +++ b/src/app/dashboard/writer/page.tsx @@ -190,6 +190,12 @@ const DONATION_PAGE_SIZE = 10; function WriterDonationHistory({ storylineId }: { storylineId: number }) { const [offset, setOffset] = useState(0); const allDonationsRef = useRef([]); + const [prevStorylineId, setPrevStorylineId] = useState(storylineId); + if (storylineId !== prevStorylineId) { + setPrevStorylineId(storylineId); + setOffset(0); + allDonationsRef.current = []; + } const { data, isFetching } = useQuery({ queryKey: ["writer-donations", storylineId, offset], From caa6fa7febbbab2dc4a86c5d48184622afecf061 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Thu, 19 Mar 2026 13:57:11 +0000 Subject: [PATCH 3/3] [#371] Switch to useInfiniteQuery for React Compiler compliance Replace manual ref-based accumulation with TanStack Query's useInfiniteQuery, which natively handles page accumulation without accessing refs during render. Fixes react-hooks/refs lint errors. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/dashboard/reader/page.tsx | 146 ++++++++++++------------------ src/app/dashboard/writer/page.tsx | 57 +++++------- 2 files changed, 83 insertions(+), 120 deletions(-) diff --git a/src/app/dashboard/reader/page.tsx b/src/app/dashboard/reader/page.tsx index 323a0066..a53a63be 100644 --- a/src/app/dashboard/reader/page.tsx +++ b/src/app/dashboard/reader/page.tsx @@ -1,8 +1,7 @@ "use client"; -import { useRef, useState } from "react"; import { useAccount } from "wagmi"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useInfiniteQuery } from "@tanstack/react-query"; import { supabase, type Donation, type TradeHistory } from "../../../../lib/supabase"; import { formatPrice } from "../../../../lib/format"; import { ReaderPortfolio } from "../../../components/ReaderPortfolio"; @@ -24,57 +23,40 @@ function formatTruncated(value: bigint, decimals: number, digits = 6): string { const PAGE_SIZE = 10; -interface DonationPage { - rows: Donation[]; - totalCount: number; -} - -async function fetchDonationPage( - address: string, - offset: number, - pageSize: number = PAGE_SIZE, -): Promise { - if (!supabase) return { rows: [], totalCount: 0 }; - const { data, count, error } = await supabase - .from("donations") - .select("*", { count: "exact" }) - .eq("donor_address", address.toLowerCase()) - .eq("contract_address", STORY_FACTORY.toLowerCase()) - .order("block_timestamp", { ascending: false }) - .range(offset, offset + pageSize - 1) - .returns(); - if (error) throw error; - return { rows: data ?? [], totalCount: count ?? 0 }; -} - export default function ReaderDashboard() { const { address, isConnected } = useAccount(); - const [offset, setOffset] = useState(0); - const allDonationsRef = useRef([]); - const [prevAddress, setPrevAddress] = useState(address); - if (address !== prevAddress) { - setPrevAddress(address); - setOffset(0); - allDonationsRef.current = []; - } - const { data, isLoading, isFetching, error } = useQuery({ - queryKey: ["reader-donations", address, offset], - queryFn: () => fetchDonationPage(address!, offset), + const { + data, + isLoading, + isFetchingNextPage, + fetchNextPage, + hasNextPage, + error, + } = useInfiniteQuery({ + queryKey: ["reader-donations", address], + queryFn: async ({ pageParam = 0 }) => { + if (!supabase) return { rows: [] as Donation[], totalCount: 0 }; + const { data: rows, count, error } = await supabase + .from("donations") + .select("*", { count: "exact" }) + .eq("donor_address", address!.toLowerCase()) + .eq("contract_address", STORY_FACTORY.toLowerCase()) + .order("block_timestamp", { ascending: false }) + .range(pageParam, pageParam + PAGE_SIZE - 1) + .returns(); + if (error) throw error; + return { rows: rows ?? [], totalCount: count ?? 0 }; + }, + initialPageParam: 0, + getNextPageParam: (_lastPage, allPages) => { + const totalFetched = allPages.reduce((sum, p) => sum + p.rows.length, 0); + const totalCount = allPages[0]?.totalCount ?? 0; + return totalFetched < totalCount ? totalFetched : undefined; + }, enabled: isConnected && !!address, }); - // Accumulate pages as new data arrives - if (data) { - if (offset === 0) { - if (allDonationsRef.current.length === 0 || allDonationsRef.current[0]?.id !== data.rows[0]?.id) { - allDonationsRef.current = data.rows; - } - } else if (allDonationsRef.current.length === offset) { - allDonationsRef.current = [...allDonationsRef.current, ...data.rows]; - } - } - // Fetch reserve token decimals dynamically const { data: reserveDecimals = 18 } = useQuery({ queryKey: ["reserve-decimals"], @@ -87,8 +69,8 @@ export default function ReaderDashboard() { }, }); - const donations = allDonationsRef.current; - const totalCount = data?.totalCount ?? 0; + const donations = data?.pages.flatMap((p) => p.rows) ?? []; + const totalCount = data?.pages[0]?.totalCount ?? 0; if (!isConnected) { return ( @@ -106,8 +88,6 @@ export default function ReaderDashboard() { BigInt(0), ); - const hasMore = donations.length < totalCount; - return (

@@ -132,7 +112,7 @@ export default function ReaderDashboard() { {donations.length > 0 && ( {" "} - · {formatTruncated(totalDonated, reserveDecimals)} {RESERVE_LABEL} on this page + · {formatTruncated(totalDonated, reserveDecimals)} {RESERVE_LABEL} total loaded )}

@@ -156,13 +136,13 @@ export default function ReaderDashboard() { )}

- {hasMore && ( + {hasNextPage && ( )} @@ -209,43 +189,35 @@ function DonationRow({ donation, decimals }: { donation: Donation; decimals: num const TRADE_PAGE_SIZE = 10; function TradingHistory({ address }: { address: string }) { - const [offset, setOffset] = useState(0); - const allTradesRef = useRef([]); - const [prevAddress, setPrevAddress] = useState(address); - if (address !== prevAddress) { - setPrevAddress(address); - setOffset(0); - allTradesRef.current = []; - } - - const { data, isLoading, isFetching } = useQuery({ - queryKey: ["reader-trades", address, offset], - queryFn: async () => { - if (!supabase) return { rows: [], totalCount: 0 }; + const { + data, + isLoading, + isFetchingNextPage, + fetchNextPage, + hasNextPage, + } = useInfiniteQuery({ + queryKey: ["reader-trades", address], + queryFn: async ({ pageParam = 0 }) => { + if (!supabase) return { rows: [] as TradeHistory[], totalCount: 0 }; const { data: rows, count } = await supabase .from("trade_history") .select("*", { count: "exact" }) .eq("user_address", address.toLowerCase()) .order("block_timestamp", { ascending: false }) - .range(offset, offset + TRADE_PAGE_SIZE - 1) + .range(pageParam, pageParam + TRADE_PAGE_SIZE - 1) .returns(); return { rows: rows ?? [], totalCount: count ?? 0 }; }, + initialPageParam: 0, + getNextPageParam: (_lastPage, allPages) => { + const totalFetched = allPages.reduce((sum, p) => sum + p.rows.length, 0); + const totalCount = allPages[0]?.totalCount ?? 0; + return totalFetched < totalCount ? totalFetched : undefined; + }, }); - if (data) { - if (offset === 0) { - if (allTradesRef.current.length === 0 || allTradesRef.current[0]?.id !== data.rows[0]?.id) { - allTradesRef.current = data.rows; - } - } else if (allTradesRef.current.length === offset) { - allTradesRef.current = [...allTradesRef.current, ...data.rows]; - } - } - - const trades = allTradesRef.current; - const totalCount = data?.totalCount ?? 0; - const hasMore = trades.length < totalCount; + const trades = data?.pages.flatMap((p) => p.rows) ?? []; + const totalCount = data?.pages[0]?.totalCount ?? 0; return (
@@ -311,13 +283,13 @@ function TradingHistory({ address }: { address: string }) { )} - {hasMore && ( + {hasNextPage && ( )}
diff --git a/src/app/dashboard/writer/page.tsx b/src/app/dashboard/writer/page.tsx index a2a44fd5..201ddfda 100644 --- a/src/app/dashboard/writer/page.tsx +++ b/src/app/dashboard/writer/page.tsx @@ -1,8 +1,8 @@ "use client"; -import { useRef, useState } from "react"; +import { useState } from "react"; import { useAccount, useSignMessage } from "wagmi"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient, useInfiniteQuery } from "@tanstack/react-query"; import { formatUnits } from "viem"; import { supabase, type Storyline, type Donation } from "../../../../lib/supabase"; import { getTokenTVL } from "../../../../lib/price"; @@ -188,44 +188,35 @@ function StorylineDetail({ storyline, writerAddress }: { storyline: Storyline; w const DONATION_PAGE_SIZE = 10; function WriterDonationHistory({ storylineId }: { storylineId: number }) { - const [offset, setOffset] = useState(0); - const allDonationsRef = useRef([]); - const [prevStorylineId, setPrevStorylineId] = useState(storylineId); - if (storylineId !== prevStorylineId) { - setPrevStorylineId(storylineId); - setOffset(0); - allDonationsRef.current = []; - } - - const { data, isFetching } = useQuery({ - queryKey: ["writer-donations", storylineId, offset], - queryFn: async () => { - if (!supabase) return { rows: [], totalCount: 0 }; + const { + data, + isFetchingNextPage, + fetchNextPage, + hasNextPage, + } = useInfiniteQuery({ + queryKey: ["writer-donations", storylineId], + queryFn: async ({ pageParam = 0 }) => { + if (!supabase) return { rows: [] as Donation[], totalCount: 0 }; const { data: rows, count } = await supabase .from("donations") .select("*", { count: "exact" }) .eq("storyline_id", storylineId) .eq("contract_address", STORY_FACTORY.toLowerCase()) .order("block_timestamp", { ascending: false }) - .range(offset, offset + DONATION_PAGE_SIZE - 1) + .range(pageParam, pageParam + DONATION_PAGE_SIZE - 1) .returns(); return { rows: rows ?? [], totalCount: count ?? 0 }; }, + initialPageParam: 0, + getNextPageParam: (_lastPage, allPages) => { + const totalFetched = allPages.reduce((sum, p) => sum + p.rows.length, 0); + const totalCount = allPages[0]?.totalCount ?? 0; + return totalFetched < totalCount ? totalFetched : undefined; + }, }); - if (data) { - if (offset === 0) { - if (allDonationsRef.current.length === 0 || allDonationsRef.current[0]?.id !== data.rows[0]?.id) { - allDonationsRef.current = data.rows; - } - } else if (allDonationsRef.current.length === offset) { - allDonationsRef.current = [...allDonationsRef.current, ...data.rows]; - } - } - - const donations = allDonationsRef.current; - const totalCount = data?.totalCount ?? 0; - const hasMore = donations.length < totalCount; + const donations = data?.pages.flatMap((p) => p.rows) ?? []; + const totalCount = data?.pages[0]?.totalCount ?? 0; if (donations.length === 0) return null; @@ -272,13 +263,13 @@ function WriterDonationHistory({ storylineId }: { storylineId: number }) { ))} - {hasMore && ( + {hasNextPage && ( )}