diff --git a/.changeset/backmerge-02-07-26.md b/.changeset/backmerge-02-07-26.md new file mode 100644 index 000000000..ef6bf1ce4 --- /dev/null +++ b/.changeset/backmerge-02-07-26.md @@ -0,0 +1,7 @@ +--- +"create-eth": patch +--- + +- fix: duplicated logs (https://github.com/scaffold-eth/scaffold-eth-2/pull/1300) +- cache block explorer transaction total to avoid full-chain rescans on pagination (https://github.com/scaffold-eth/scaffold-eth-2/pull/1301) +- UI tweaks (https://github.com/scaffold-eth/scaffold-eth-2/pull/1304) diff --git a/templates/base/packages/nextjs/app/blockexplorer/_components/ContractTabs.tsx b/templates/base/packages/nextjs/app/blockexplorer/_components/ContractTabs.tsx index 20630fb51..760c7ae83 100644 --- a/templates/base/packages/nextjs/app/blockexplorer/_components/ContractTabs.tsx +++ b/templates/base/packages/nextjs/app/blockexplorer/_components/ContractTabs.tsx @@ -26,7 +26,7 @@ const publicClient = createPublicClient({ }); export const ContractTabs = ({ address, contractData }: PageProps) => { - const { blocks, transactionReceipts, currentPage, totalTransactions, setCurrentPage } = useFetchBlocks(); + const { blocks, transactionReceipts, currentPage, hasNextPage, setCurrentPage } = useFetchBlocks(address); const [activeTab, setActiveTab] = useState("transactions"); const [isContract, setIsContract] = useState(false); @@ -39,15 +39,6 @@ export const ContractTabs = ({ address, contractData }: PageProps) => { checkIsContract(); }, [address]); - const filteredBlocks = blocks.filter(block => - block.transactions.some(tx => { - if (typeof tx === "string") { - return false; - } - return tx.from.toLowerCase() === address.toLowerCase() || tx.to?.toLowerCase() === address.toLowerCase(); - }), - ); - return ( <> {isContract && ( @@ -84,8 +75,8 @@ export const ContractTabs = ({ address, contractData }: PageProps) => { )} {activeTab === "transactions" && (
- - + +
)} {activeTab === "code" && contractData && ( diff --git a/templates/base/packages/nextjs/app/blockexplorer/_components/PaginationButton.tsx b/templates/base/packages/nextjs/app/blockexplorer/_components/PaginationButton.tsx index 15a4f62a6..871c7d988 100644 --- a/templates/base/packages/nextjs/app/blockexplorer/_components/PaginationButton.tsx +++ b/templates/base/packages/nextjs/app/blockexplorer/_components/PaginationButton.tsx @@ -2,15 +2,13 @@ import { ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/24/outline"; type PaginationButtonProps = { currentPage: number; - totalItems: number; + hasNextPage: boolean; setCurrentPage: (page: number) => void; }; -const ITEMS_PER_PAGE = 20; - -export const PaginationButton = ({ currentPage, totalItems, setCurrentPage }: PaginationButtonProps) => { +export const PaginationButton = ({ currentPage, hasNextPage, setCurrentPage }: PaginationButtonProps) => { const isPrevButtonDisabled = currentPage === 0; - const isNextButtonDisabled = currentPage + 1 >= Math.ceil(totalItems / ITEMS_PER_PAGE); + const isNextButtonDisabled = !hasNextPage; const prevButtonClass = isPrevButtonDisabled ? "btn-disabled cursor-default" : "btn-primary"; const nextButtonClass = isNextButtonDisabled ? "btn-disabled cursor-default" : "btn-primary"; diff --git a/templates/base/packages/nextjs/app/blockexplorer/page.tsx b/templates/base/packages/nextjs/app/blockexplorer/page.tsx index 71d1561a9..daa070146 100644 --- a/templates/base/packages/nextjs/app/blockexplorer/page.tsx +++ b/templates/base/packages/nextjs/app/blockexplorer/page.tsx @@ -9,7 +9,7 @@ import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork"; import { notification } from "~~/utils/scaffold-eth"; const BlockExplorer: NextPage = () => { - const { blocks, transactionReceipts, currentPage, totalTransactions, setCurrentPage, error } = useFetchBlocks(); + const { blocks, transactionReceipts, currentPage, hasNextPage, setCurrentPage, error } = useFetchBlocks(); const { targetNetwork } = useTargetNetwork(); const [isLocalNetwork, setIsLocalNetwork] = useState(true); const [hasError, setHasError] = useState(false); @@ -75,7 +75,7 @@ const BlockExplorer: NextPage = () => {
- +
); }; diff --git a/templates/base/packages/nextjs/components/Header.tsx.template.mjs b/templates/base/packages/nextjs/components/Header.tsx.template.mjs index b113e5a7b..4f7647bbf 100644 --- a/templates/base/packages/nextjs/components/Header.tsx.template.mjs +++ b/templates/base/packages/nextjs/components/Header.tsx.template.mjs @@ -51,7 +51,7 @@ export const HeaderMenuLinks = () => { passHref className={\`\${ isActive ? "bg-base-300" : "" - } hover:bg-base-300 focus:!bg-base-300 h-full px-4 text-sm gap-2 flex items-center\`} + } hover:bg-base-300 focus:!bg-base-300 h-full px-4 text-sm gap-2 flex items-center whitespace-nowrap\`} > {icon} {label} @@ -77,7 +77,7 @@ export const Header = () => { return (
-
+
diff --git a/templates/base/packages/nextjs/components/SwitchTheme.tsx b/templates/base/packages/nextjs/components/SwitchTheme.tsx index 6e49638e8..431e1847f 100644 --- a/templates/base/packages/nextjs/components/SwitchTheme.tsx +++ b/templates/base/packages/nextjs/components/SwitchTheme.tsx @@ -26,10 +26,11 @@ export const SwitchTheme = ({ className }: { className?: string }) => { return (
+ {/* [--depth:0] removes DaisyUI's glossy top highlight so the indicator is a single flat color */} diff --git a/templates/base/packages/nextjs/hooks/scaffold-eth/useContractLogs.ts b/templates/base/packages/nextjs/hooks/scaffold-eth/useContractLogs.ts index 27775d5be..60dd837dd 100644 --- a/templates/base/packages/nextjs/hooks/scaffold-eth/useContractLogs.ts +++ b/templates/base/packages/nextjs/hooks/scaffold-eth/useContractLogs.ts @@ -9,31 +9,67 @@ export const useContractLogs = (address: Address) => { const client = usePublicClient({ chainId: targetNetwork.id }); useEffect(() => { + setLogs([]); + let unwatch: (() => void) | undefined; + let lastFetchedBlock = 0n; + // Set to true on effect cleanup. Guards against in-flight fetchLogs completing after + // unmount or re-run (e.g. React Strict Mode, address/client change) and registering + // a duplicate watcher or calling setLogs with stale data. + let ignore = false; + const fetchLogs = async () => { if (!client) return console.error("Client not found"); try { + const latestBlock = await client.getBlockNumber(); + if (ignore) return; + const existingLogs = await client.getLogs({ address: address, fromBlock: 0n, - toBlock: "latest", + toBlock: latestBlock, }); + if (ignore) return; + + lastFetchedBlock = latestBlock; setLogs(existingLogs); + + unwatch = client.watchBlockNumber({ + onBlockNumber: async blockNumber => { + const fromBlock = lastFetchedBlock + 1n; + if (fromBlock > blockNumber) return; + + // Advance cursor before await so concurrent block callbacks don't overlap + lastFetchedBlock = blockNumber; + + try { + const newLogs = await client.getLogs({ + address: address, + fromBlock: fromBlock, + toBlock: blockNumber, + }); + if (ignore) return; + + if (newLogs.length > 0) { + setLogs(prevLogs => [...prevLogs, ...newLogs]); + } + } catch (error) { + // Roll back the cursor so the next block callback retries this range + lastFetchedBlock = fromBlock - 1n; + console.error("Failed to fetch logs:", error); + } + }, + }); } catch (error) { console.error("Failed to fetch logs:", error); } }; + fetchLogs(); - return client?.watchBlockNumber({ - onBlockNumber: async (_blockNumber, prevBlockNumber) => { - const newLogs = await client.getLogs({ - address: address, - fromBlock: prevBlockNumber, - toBlock: "latest", - }); - setLogs(prevLogs => [...prevLogs, ...newLogs]); - }, - }); + return () => { + ignore = true; + unwatch?.(); + }; }, [address, client]); return logs; diff --git a/templates/base/packages/nextjs/hooks/scaffold-eth/useFetchBlocks.ts b/templates/base/packages/nextjs/hooks/scaffold-eth/useFetchBlocks.ts index dd7c8a200..3420550da 100644 --- a/templates/base/packages/nextjs/hooks/scaffold-eth/useFetchBlocks.ts +++ b/templates/base/packages/nextjs/hooks/scaffold-eth/useFetchBlocks.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from "react"; import { + Address, Block, Hash, Transaction, @@ -48,14 +49,17 @@ const groupTransactionsByBlock = (items: { block: Block; tx: Transaction }[]): B return groupedBlocks; }; -const fetchTransactionsPage = async ( +// Collects the requested page plus one extra transaction (the cheap "is there a next page?" +// signal), counting only transactions that pass matchesFilter — so address views paginate over +// the address's own transactions instead of slicing a global page. +const fetchPageItems = async ( latestBlock: bigint, page: number, -): Promise<{ items: { block: Block; tx: Transaction }[]; totalTransactions: number }> => { + matchesFilter: (tx: Transaction) => boolean, +): Promise<{ block: Block; tx: Transaction }[]> => { const skipCount = page * TRANSACTIONS_PER_PAGE; const pageItems: { block: Block; tx: Transaction }[] = []; let skipped = 0; - let totalTransactions = 0; for (let blockNum = latestBlock; blockNum >= 0n; ) { const batchEnd = blockNum - BigInt(BLOCK_BATCH_SIZE - 1); @@ -72,15 +76,18 @@ const fetchTransactionsPage = async ( for (const block of fetchedBlocks) { for (const tx of block.transactions as Transaction[]) { - totalTransactions++; + if (!matchesFilter(tx)) { + continue; + } if (skipped < skipCount) { skipped++; continue; } - if (pageItems.length < TRANSACTIONS_PER_PAGE) { - pageItems.push({ block, tx }); + pageItems.push({ block, tx }); + if (pageItems.length > TRANSACTIONS_PER_PAGE) { + return pageItems; } } } @@ -88,24 +95,34 @@ const fetchTransactionsPage = async ( blockNum = batchStart - 1n; } - return { items: pageItems, totalTransactions }; + return pageItems; }; -export const useFetchBlocks = () => { +export const useFetchBlocks = (addressFilter?: Address) => { const [blocks, setBlocks] = useState([]); const [transactionReceipts, setTransactionReceipts] = useState<{ [key: string]: TransactionReceipt; }>({}); const [currentPage, setCurrentPage] = useState(0); - const [totalTransactions, setTotalTransactions] = useState(0); + const [hasNextPage, setHasNextPage] = useState(false); const [error, setError] = useState(null); + const matchesAddressFilter = useCallback( + (tx: Transaction) => { + if (!addressFilter) return true; + const filter = addressFilter.toLowerCase(); + return tx.from.toLowerCase() === filter || tx.to?.toLowerCase() === filter; + }, + [addressFilter], + ); + const fetchBlocks = useCallback(async () => { setError(null); try { const blockNumber = await testClient.getBlockNumber(); - const { items, totalTransactions: totalTxCount } = await fetchTransactionsPage(blockNumber, currentPage); + const fetchedItems = await fetchPageItems(blockNumber, currentPage, matchesAddressFilter); + const items = fetchedItems.slice(0, TRANSACTIONS_PER_PAGE); items.forEach(({ tx }) => decodeTransactionData(tx)); @@ -122,12 +139,12 @@ export const useFetchBlocks = () => { ); setBlocks(groupTransactionsByBlock(items)); - setTotalTransactions(totalTxCount); + setHasNextPage(fetchedItems.length > TRANSACTIONS_PER_PAGE); setTransactionReceipts(prevReceipts => ({ ...prevReceipts, ...Object.assign({}, ...txReceipts) })); } catch (err) { setError(err instanceof Error ? err : new Error("An error occurred.")); } - }, [currentPage]); + }, [currentPage, matchesAddressFilter]); useEffect(() => { fetchBlocks(); @@ -146,6 +163,12 @@ export const useFetchBlocks = () => { blockWithTxDetails = { ...newBlock, transactions: transactionsDetails }; } + const matchingTransactions = (blockWithTxDetails.transactions as Transaction[]).filter(matchesAddressFilter); + if (matchingTransactions.length === 0) { + return; + } + blockWithTxDetails = { ...blockWithTxDetails, transactions: matchingTransactions }; + (blockWithTxDetails.transactions as Transaction[]).forEach(tx => decodeTransactionData(tx)); const receipts = await Promise.all( @@ -163,26 +186,23 @@ export const useFetchBlocks = () => { setBlocks(prevBlocks => { const latestBlockNumber = blockWithTxDetails.number!; const existingBlockIndex = prevBlocks.findIndex(block => block.number === latestBlockNumber); - const existingBlockTransactionsCount = - existingBlockIndex >= 0 ? prevBlocks[existingBlockIndex].transactions.length : 0; - const latestBlockTransactionsCount = blockWithTxDetails.transactions.length; const nextBlocks = existingBlockIndex >= 0 ? prevBlocks.map((block, index) => (index === existingBlockIndex ? blockWithTxDetails : block)) : [blockWithTxDetails, ...prevBlocks]; - const transactionsDelta = latestBlockTransactionsCount - existingBlockTransactionsCount; - if (transactionsDelta !== 0) { - setTotalTransactions(prevTotal => Math.max(0, prevTotal + transactionsDelta)); - } - const trimmedBlocks = [...nextBlocks]; let transactionsInTrimmedBlocks = trimmedBlocks.reduce( (count, block) => count + block.transactions.length, 0, ); + // More than one page of transactions are now in view, so a next page exists. + if (transactionsInTrimmedBlocks > TRANSACTIONS_PER_PAGE) { + setHasNextPage(true); + } + while (transactionsInTrimmedBlocks > TRANSACTIONS_PER_PAGE && trimmedBlocks.length > 0) { const removedBlock = trimmedBlocks.pop(); if (removedBlock) { @@ -201,13 +221,13 @@ export const useFetchBlocks = () => { }; return testClient.watchBlocks({ onBlock: handleNewBlock, includeTransactions: true }); - }, [currentPage]); + }, [currentPage, matchesAddressFilter]); return { blocks, transactionReceipts, currentPage, - totalTransactions, + hasNextPage, setCurrentPage, error, };