From 44df2be51ce0370020b55144924c0c75164ff5c9 Mon Sep 17 00:00:00 2001 From: freepilot-bot <215356755+freepilot-bot@users.noreply.github.com> Date: Tue, 10 Jun 2025 13:14:59 +0000 Subject: [PATCH] feat: implement SWR caching for transaction list - Add transaction caching functions to browser storage with 5min TTL - Implement swrGetTransactions in api.ts for stale-while-revalidate pattern - Update useTransactions hook to use SWR caching instead of direct API calls - Add clearTransactionsCache function for manual cache invalidation - Add refreshTransactions function to force fresh data when needed This improves performance on slower wallet connections by showing cached transaction data immediately while updating with fresh data in background. --- src/app/hooks/useTransactions.ts | 91 +++++++++++++++++++++++--------- src/common/lib/api.ts | 40 ++++++++++++++ src/common/lib/cache.ts | 30 ++++++++++- 3 files changed, 135 insertions(+), 26 deletions(-) diff --git a/src/app/hooks/useTransactions.ts b/src/app/hooks/useTransactions.ts index c3839a294c..fe28ca3d77 100644 --- a/src/app/hooks/useTransactions.ts +++ b/src/app/hooks/useTransactions.ts @@ -1,56 +1,97 @@ import dayjs from "dayjs"; import { useCallback, useState } from "react"; import toast from "~/app/components/Toast"; +import { useAccount } from "~/app/context/AccountContext"; import { useSettings } from "~/app/context/SettingsContext"; import api from "~/common/lib/api"; import { Transaction } from "~/types"; export const useTransactions = () => { const { settings, getFormattedFiat } = useSettings(); + const { account } = useAccount(); const [transactions, setTransactions] = useState([]); const [isLoadingTransactions, setIsLoadingTransactions] = useState(true); + const processTransactions = useCallback( + async (rawTransactions: any[]) => { + const processedTransactions = rawTransactions.map((transaction) => ({ + ...transaction, + title: transaction.memo, + timeAgo: dayjs( + transaction.settleDate || transaction.creationDate + ).fromNow(), + timestamp: transaction.settleDate || transaction.creationDate, + })); + + for (const transaction of processedTransactions) { + if ( + transaction.displayAmount && + transaction.displayAmount[1] === settings.currency + ) + continue; + transaction.totalAmountFiat = settings.showFiat + ? await getFormattedFiat(transaction.totalAmount) + : ""; + } + + return processedTransactions; + }, + [settings, getFormattedFiat] + ); + const loadTransactions = useCallback( async (limit?: number) => { + if (!account?.id) return; + try { - const getTransactionsResponse = await api.getTransactions({ - limit, - }); - const transactions = getTransactionsResponse.transactions.map( - (transaction) => ({ - ...transaction, - title: transaction.memo, - timeAgo: dayjs( - transaction.settleDate || transaction.creationDate - ).fromNow(), - timestamp: transaction.settleDate || transaction.creationDate, - }) + let hasReceivedResponse = false; + + // Use SWR to get cached data first, then fresh data + await api.swr.getTransactions( + account.id, + { limit }, + async (response) => { + const processedTransactions = await processTransactions( + response.transactions + ); + setTransactions(processedTransactions); + + // Set loading to false after we get the first response (cached or fresh) + if (!hasReceivedResponse) { + setIsLoadingTransactions(false); + hasReceivedResponse = true; + } + } ); - for (const transaction of transactions) { - if ( - transaction.displayAmount && - transaction.displayAmount[1] === settings.currency - ) - continue; - transaction.totalAmountFiat = settings.showFiat - ? await getFormattedFiat(transaction.totalAmount) - : ""; + // If we didn't get any cached data, we still need to ensure loading is set to false + if (!hasReceivedResponse) { + setIsLoadingTransactions(false); } - - setTransactions(transactions); - setIsLoadingTransactions(false); } catch (e) { console.error(e); + setIsLoadingTransactions(false); if (e instanceof Error) toast.error(`Error: ${e.message}`); } }, - [settings, getFormattedFiat] + [account?.id, processTransactions] + ); + + const refreshTransactions = useCallback( + async (limit?: number) => { + if (!account?.id) return; + + // Clear cache first to force fresh data + await api.clearTransactionsCache(account.id); + await loadTransactions(limit); + }, + [account?.id, loadTransactions] ); return { transactions, isLoadingTransactions, loadTransactions, + refreshTransactions, }; }; diff --git a/src/common/lib/api.ts b/src/common/lib/api.ts index a33350d296..44c35b9ba1 100644 --- a/src/common/lib/api.ts +++ b/src/common/lib/api.ts @@ -38,8 +38,11 @@ import type { import { getAccountsCache, + getTransactionsCache, removeAccountFromCache, + removeTransactionsFromCache, storeAccounts, + storeTransactions, } from "./cache"; import msg from "./msg"; @@ -146,6 +149,41 @@ export const swrGetAccountInfo = async ( .catch(reject); }); }; + +/** + * stale-while-revalidate get transactions + * @param accountId - account id + * @param options - transaction options (limit, etc.) + * @param callback - will be called first with cached (stale) data first, then with fresh data. + */ +export const swrGetTransactions = async ( + accountId: string, + options?: { limit?: number }, + callback?: (transactions: { transactions: Invoice[] }) => void +): Promise<{ transactions: Invoice[] }> => { + const cachedTransactions = await getTransactionsCache(accountId); + + return new Promise((resolve, reject) => { + if (cachedTransactions) { + const cachedResponse = { transactions: cachedTransactions }; + if (callback) callback(cachedResponse); + resolve(cachedResponse); + } + + // Update transactions with most recent data, save to cache. + getTransactions(options) + .then((response) => { + storeTransactions(accountId, response.transactions); + if (callback) callback(response); + return resolve(response); + }) + .catch(reject); + }); +}; +export const clearTransactionsCache = async (accountId: string) => { + await removeTransactionsFromCache(accountId); +}; + export const getAccounts = () => msg.request("getAccounts"); export const getAccount = (id?: string) => msg.request("getAccount", { @@ -313,7 +351,9 @@ export default { setSetting, swr: { getAccountInfo: swrGetAccountInfo, + getTransactions: swrGetTransactions, }, + clearTransactionsCache, removeAccount, unlock, getBlocklist, diff --git a/src/common/lib/cache.ts b/src/common/lib/cache.ts index d2e5aafe42..d1b392aac8 100644 --- a/src/common/lib/cache.ts +++ b/src/common/lib/cache.ts @@ -1,5 +1,5 @@ import browser from "webextension-polyfill"; -import type { AccountInfo } from "~/types"; +import type { AccountInfo, Invoice } from "~/types"; export const getAccountsCache = async () => { let accountsCache: { [id: string]: AccountInfo } = {}; @@ -24,3 +24,31 @@ export const removeAccountFromCache = async (id: string) => { } return accountsCache; }; + +export const getTransactionsCache = async (accountId: string) => { + const cacheKey = `transactions_${accountId}`; + const result = await browser.storage.local.get([cacheKey]); + if (result[cacheKey]) { + const cached = JSON.parse(result[cacheKey]); + // Check if cache is less than 5 minutes old + if (Date.now() - cached.timestamp < 5 * 60 * 1000) { + return cached.transactions; + } + } + return null; +}; + +export const storeTransactions = (accountId: string, transactions: Invoice[]) => { + const cacheKey = `transactions_${accountId}`; + browser.storage.local.set({ + [cacheKey]: JSON.stringify({ + transactions, + timestamp: Date.now(), + }), + }); +}; + +export const removeTransactionsFromCache = async (accountId: string) => { + const cacheKey = `transactions_${accountId}`; + await browser.storage.local.remove([cacheKey]); +};