diff --git a/apps/minifront/package.json b/apps/minifront/package.json index f11286e202..7272f57220 100644 --- a/apps/minifront/package.json +++ b/apps/minifront/package.json @@ -61,6 +61,7 @@ }, "devDependencies": { "@chain-registry/types": "^0.45.38", + "@eslint/compat": "^1.1.0", "@types/lodash": "^4.17.4", "@types/react": "^18.3.2", "@types/react-dom": "^18.3.0", diff --git a/apps/minifront/src/components/dashboard/assets-table/index.tsx b/apps/minifront/src/components/dashboard/assets-table/index.tsx index 315efa32b9..6c7658472e 100644 --- a/apps/minifront/src/components/dashboard/assets-table/index.tsx +++ b/apps/minifront/src/components/dashboard/assets-table/index.tsx @@ -20,6 +20,8 @@ import { BalancesByAccount, groupByAccount, useBalancesResponses } from '../../. import { AbridgedZQueryState } from '@penumbra-zone/zquery/src/types'; import { shouldDisplay } from '../../../fetchers/balances/should-display'; import { sortByPriorityScore } from '../../../fetchers/balances/by-priority-score'; +import { LineWave } from 'react-loader-spinner'; +import { cn } from '@penumbra-zone/ui/lib/utils'; const getTradeLink = (balance: BalancesResponse): string => { const metadata = getMetadataFromBalancesResponseOptional(balance); @@ -34,18 +36,26 @@ const byAccountIndex = (a: BalancesByAccount, b: BalancesByAccount) => { const filteredBalancesByAccountSelector = ( zQueryState: AbridgedZQueryState, -): BalancesByAccount[] => - zQueryState.data - ?.filter(shouldDisplay) - .sort(sortByPriorityScore) - .reduce(groupByAccount, []) - .sort(byAccountIndex) ?? []; +): AbridgedZQueryState => { + const data = + zQueryState.data + ?.filter(shouldDisplay) + .sort(sortByPriorityScore) + .reduce(groupByAccount, []) + .sort(byAccountIndex) ?? []; + return { + ...zQueryState, + data, + }; +}; export default function AssetsTable() { - const balancesByAccount = useBalancesResponses({ + const balances = useBalancesResponses({ select: filteredBalancesByAccountSelector, shouldReselect: (before, after) => before?.data !== after.data, }); + const balancesByAccount = balances?.data; + const loading = balances?.loading; if (balancesByAccount?.length === 0) { return ( @@ -114,6 +124,17 @@ export default function AssetsTable() { ))} + {(loading ?? balancesByAccount === undefined) && ( +
+ +
+ )} ); } diff --git a/apps/minifront/src/fetchers/balances/index.ts b/apps/minifront/src/fetchers/balances/index.ts index 7c0a5d24a4..74771de0ed 100644 --- a/apps/minifront/src/fetchers/balances/index.ts +++ b/apps/minifront/src/fetchers/balances/index.ts @@ -7,7 +7,7 @@ import { AddressIndex } from '@penumbra-zone/protobuf/penumbra/core/keys/v1/keys import { ViewService } from '@penumbra-zone/protobuf'; import { penumbra } from '../../prax'; -interface BalancesProps { +export interface BalancesProps { accountFilter?: AddressIndex; assetIdFilter?: AssetId; } @@ -15,7 +15,7 @@ interface BalancesProps { export const getBalances = ({ accountFilter, assetIdFilter }: BalancesProps = {}): Promise< BalancesResponse[] > => { - const req = new BalancesRequest(); + const req = new BalancesRequest({}); if (accountFilter) { req.accountFilter = accountFilter; } @@ -26,3 +26,18 @@ export const getBalances = ({ accountFilter, assetIdFilter }: BalancesProps = {} const iterable = penumbra.service(ViewService).balances(req); return Array.fromAsync(iterable); }; + +export const getBalancesStream = ({ + accountFilter, + assetIdFilter, +}: BalancesProps = {}): AsyncIterable => { + const req = new BalancesRequest(); + if (accountFilter) { + req.accountFilter = accountFilter; + } + if (assetIdFilter) { + req.assetIdFilter = assetIdFilter; + } + + return penumbra.service(ViewService).balances(req); +}; diff --git a/apps/minifront/src/state/shared.ts b/apps/minifront/src/state/shared.ts index 78d95822b5..83371495ae 100644 --- a/apps/minifront/src/state/shared.ts +++ b/apps/minifront/src/state/shared.ts @@ -5,9 +5,10 @@ import { BalancesResponse } from '@penumbra-zone/protobuf/penumbra/view/v1/view_ import { ZQueryState, createZQuery } from '@penumbra-zone/zquery'; import { AbridgedZQueryState } from '@penumbra-zone/zquery/src/types'; import { SliceCreator, useStore } from '.'; -import { getAllAssets } from '../fetchers/assets'; -import { getBalances } from '../fetchers/balances'; import { getStakingTokenMetadata } from '../fetchers/registry'; +import { getBalancesStream } from '../fetchers/balances'; +import { getAllAssets } from '../fetchers/assets'; +import { uint8ArrayToHex } from '@penumbra-zone/types/hex'; /** * For Noble specifically we need to use a Bech32 encoding rather than Bech32m, @@ -33,9 +34,37 @@ export const { stakingTokenMetadata, useStakingTokenMetadata } = createZQuery({ }, }); +const getHash = (bal: BalancesResponse) => uint8ArrayToHex(bal.toBinary()); + export const { balancesResponses, useBalancesResponses } = createZQuery({ name: 'balancesResponses', - fetch: getBalances, + fetch: getBalancesStream, + stream: () => { + const balanceResponseIdsToKeep = new Set(); + + return { + onValue: ( + prevState: BalancesResponse[] | undefined = [], + balanceResponse: BalancesResponse, + ) => { + balanceResponseIdsToKeep.add(getHash(balanceResponse)); + + const existingIndex = prevState.findIndex(bal => getHash(bal) === getHash(balanceResponse)); + + // Update any existing items in place, rather than appending + // duplicates. + if (existingIndex >= 0) { + return prevState.toSpliced(existingIndex, 1, balanceResponse); + } else { + return [...prevState, balanceResponse]; + } + }, + + onEnd: (prevState = []) => + // Discard any balances from a previous stream. + prevState.filter(balanceResponse => balanceResponseIdsToKeep.has(getHash(balanceResponse))), + }; + }, getUseStore: () => useStore, get: state => state.shared.balancesResponses, set: setter => { @@ -61,7 +90,7 @@ export const { assets, useAssets } = createZQuery({ export interface SharedSlice { assets: ZQueryState; - balancesResponses: ZQueryState; + balancesResponses: ZQueryState>; stakingTokenMetadata: ZQueryState; } diff --git a/apps/minifront/src/state/staking/index.test.ts b/apps/minifront/src/state/staking/index.test.ts index f21051a091..78c0095faa 100644 --- a/apps/minifront/src/state/staking/index.test.ts +++ b/apps/minifront/src/state/staking/index.test.ts @@ -67,9 +67,10 @@ vi.mock('../../fetchers/registry', async () => ({ })); vi.mock('../../fetchers/balances', () => ({ - getBalances: vi.fn(async () => - Promise.resolve([ - { + getBalancesStream: vi.fn(() => ({ + [Symbol.asyncIterator]: async function* () { + await new Promise(resolve => void setTimeout(resolve, 0)); + yield { balanceView: new ValueView({ valueView: { case: 'knownAssetId', @@ -93,8 +94,8 @@ vi.mock('../../fetchers/balances', () => ({ }, }, }), - }, - { + }; + yield { balanceView: new ValueView({ valueView: { case: 'knownAssetId', @@ -118,8 +119,8 @@ vi.mock('../../fetchers/balances', () => ({ }, }, }), - }, - { + }; + yield { balanceView: new ValueView({ valueView: { case: 'knownAssetId', @@ -142,9 +143,9 @@ vi.mock('../../fetchers/balances', () => ({ }, }, }), - }, - ]), - ), + }; + }, + })), })); vi.mock('../../prax', () => ({ diff --git a/eslint.config.js b/eslint.config.js index 0a3d1a1eb2..b556d4153e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -107,7 +107,7 @@ export default tseslint.config( { name: 'custom:import-enabled', - plugins: { import: import_ }, + plugins: { import: fixupPluginRules(import_) }, settings: { 'import/resolver': { typescript: true } }, rules: { // be aware this rule doesn't always provide correct fixes. its bad fixes diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ecc60dde0..c4cf3178d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -291,6 +291,9 @@ importers: '@chain-registry/types': specifier: ^0.45.38 version: 0.45.38 + '@eslint/compat': + specifier: ^1.1.0 + version: 1.1.0 '@types/lodash': specifier: ^4.17.4 version: 4.17.6