Mobile: improve performance for transaction list usage (#6755)

* Mobile: add usePullToRefreshOnScrollContainer hook, bypass PTR library, refactor transaction list

* Remove usePullToRefreshOnScrollContainer hook from desktop client

* Enhance PullToRefresh component with optional style prop and refactor TransactionList layout

* Refactor TransactionListItem component: remove unused imports and hooks, update props type to use ListBoxItemRenderProps
This commit is contained in:
Matiss Janis Aboltins
2026-01-24 09:39:46 +01:00
committed by GitHub
parent f55a42d817
commit c3e3a258e0
5 changed files with 182 additions and 178 deletions

View File

@@ -1,9 +1,12 @@
import React, { type ComponentProps } from 'react';
import BasePullToRefresh from 'react-simple-pull-to-refresh';
import { type CSSProperties } from '@actual-app/components/styles';
import { css } from '@emotion/css';
type PullToRefreshProps = ComponentProps<typeof BasePullToRefresh>;
type PullToRefreshProps = ComponentProps<typeof BasePullToRefresh> & {
style?: CSSProperties;
};
export function PullToRefresh(props: PullToRefreshProps) {
return (
@@ -18,6 +21,7 @@ export function PullToRefresh(props: PullToRefreshProps) {
'& .ptr__children': {
overflow: 'hidden auto',
},
...(props.style || {}),
})}
{...props}
// Force async because the library errors out when a sync onRefresh method is provided.

View File

@@ -10,6 +10,7 @@ import {
Collection,
Header,
ListBox,
ListBoxItem,
ListBoxSection,
ListLayout,
Virtualizer,
@@ -161,14 +162,14 @@ export function TransactionList({
);
return (
<>
<View style={{ flex: 1 }}>
{isLoading && (
<Loading
style={{ paddingBottom: 8 }}
style={{ flex: 'none', paddingBottom: 8 }}
aria-label={t('Loading transactions...')}
/>
)}
<View style={{ flex: 1, overflow: 'auto' }}>
<View style={{ flex: 1 }}>
<Virtualizer
layout={ListLayout}
layoutOptions={{
@@ -181,6 +182,7 @@ export function TransactionList({
selectionMode={
selectedTransactions.size > 0 ? 'multiple' : 'single'
}
style={{ flex: 1, overflow: 'auto' }}
selectedKeys={selectedTransactions}
dependencies={[
selectedTransactions,
@@ -232,14 +234,18 @@ export function TransactionList({
)}
>
{transaction => (
<TransactionListItem
key={transaction.id}
showRunningBalance={showRunningBalances}
runningBalance={runningBalances?.get(transaction.id)}
value={transaction}
onPress={trans => onTransactionPress(trans)}
onLongPress={trans => onTransactionPress(trans, true)}
/>
<ListBoxItem textValue={transaction.id} value={transaction}>
{itemProps => (
<TransactionListItem
{...itemProps}
showRunningBalance={showRunningBalances}
runningBalance={runningBalances?.get(transaction.id)}
transaction={transaction}
onPress={trans => onTransactionPress(trans)}
onLongPress={trans => onTransactionPress(trans, true)}
/>
)}
</ListBoxItem>
)}
</Collection>
</ListBoxSection>
@@ -264,7 +270,7 @@ export function TransactionList({
showMakeTransfer={showMakeTransfer}
/>
)}
</>
</View>
);
}

View File

@@ -1,9 +1,6 @@
import React, {
type ComponentPropsWithoutRef,
type CSSProperties,
} from 'react';
import React, { type CSSProperties } from 'react';
import { mergeProps } from 'react-aria';
import { ListBoxItem } from 'react-aria-components';
import { type ListBoxItemRenderProps } from 'react-aria-components';
import { useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
@@ -74,10 +71,8 @@ const getScheduleIconStyle = ({ isPreview }: { isPreview: boolean }) => ({
color: isPreview ? theme.pageTextLight : theme.menuItemText,
});
type TransactionListItemProps = Omit<
ComponentPropsWithoutRef<typeof ListBoxItem<TransactionEntity>>,
'onPress'
> & {
type TransactionListItemProps = ListBoxItemRenderProps & {
transaction?: TransactionEntity;
showRunningBalance?: boolean;
runningBalance?: IntegerAmount;
onPress: (transaction: TransactionEntity) => void;
@@ -89,13 +84,12 @@ export function TransactionListItem({
runningBalance,
onPress,
onLongPress,
...props
transaction,
...itemProps
}: TransactionListItemProps) {
const { t } = useTranslation();
const { list: categories } = useCategories();
const { value: transaction } = props;
const payee = usePayee(transaction?.payee || '');
const displayPayee = useDisplayPayee({ transaction });
@@ -156,173 +150,162 @@ export function TransactionListItem({
const textStyle = getTextStyle({ isPreview });
return (
<ListBoxItem textValue={id} {...props}>
{itemProps => (
<PressResponder {...mergeProps(pressProps, longPressProps)}>
<Button
{...itemProps}
style={{
userSelect: 'none',
height: ROW_HEIGHT,
width: '100%',
borderRadius: 0,
...(itemProps.isSelected
? {
borderWidth: '0 0 0 4px',
borderColor: theme.mobileTransactionSelected,
borderStyle: 'solid',
}
: {
borderWidth: '0 0 1px 0',
borderColor: theme.tableBorder,
borderStyle: 'solid',
}),
...(isPreview
? {
backgroundColor: theme.tableRowHeaderBackground,
}
: {
backgroundColor: theme.tableBackground,
}),
}}
>
<View
style={{
flexDirection: 'row',
flex: 1,
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 4px',
}}
>
<View style={{ flex: 1 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<PayeeIcons
transaction={transaction}
transferAccount={transferAccount}
/>
<TextOneLine
<PressResponder {...mergeProps(pressProps, longPressProps)}>
<Button
{...itemProps}
style={{
userSelect: 'none',
height: ROW_HEIGHT,
width: '100%',
borderRadius: 0,
...(itemProps.isSelected
? {
borderWidth: '0 0 0 4px',
borderColor: theme.mobileTransactionSelected,
borderStyle: 'solid',
}
: {
borderWidth: '0 0 1px 0',
borderColor: theme.tableBorder,
borderStyle: 'solid',
}),
...(isPreview
? {
backgroundColor: theme.tableRowHeaderBackground,
}
: {
backgroundColor: theme.tableBackground,
}),
}}
>
<View
style={{
flexDirection: 'row',
flex: 1,
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 4px',
}}
>
<View style={{ flex: 1 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<PayeeIcons
transaction={transaction}
transferAccount={transferAccount}
/>
<TextOneLine
style={{
...textStyle,
fontWeight: isAdded ? '600' : '400',
...(!displayPayee && !isPreview
? {
color: theme.pageTextLight,
fontStyle: 'italic',
}
: {}),
}}
>
{displayPayee || t('(No payee)')}
</TextOneLine>
</View>
{isPreview ? (
<Status status={previewStatus} isSplit={isParent || isChild} />
) : (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
marginTop: 3,
}}
>
{isReconciled ? (
<SvgLockClosed
style={{
...textStyle,
fontWeight: isAdded ? '600' : '400',
...(!displayPayee && !isPreview
? {
color: theme.pageTextLight,
fontStyle: 'italic',
}
: {}),
width: 11,
height: 11,
color: theme.noticeTextLight,
marginRight: 5,
}}
>
{displayPayee || t('(No payee)')}
</TextOneLine>
</View>
{isPreview ? (
<Status
status={previewStatus}
isSplit={isParent || isChild}
/>
) : (
<View
<SvgCheckCircle1
style={{
flexDirection: 'row',
alignItems: 'center',
marginTop: 3,
width: 11,
height: 11,
color: isCleared
? theme.noticeTextLight
: theme.pageTextSubdued,
marginRight: 5,
}}
>
{isReconciled ? (
<SvgLockClosed
style={{
width: 11,
height: 11,
color: theme.noticeTextLight,
marginRight: 5,
}}
/>
) : (
<SvgCheckCircle1
style={{
width: 11,
height: 11,
color: isCleared
? theme.noticeTextLight
: theme.pageTextSubdued,
marginRight: 5,
}}
/>
)}
{(isParent || isChild) && (
<SvgSplit
style={{
width: 12,
height: 12,
marginRight: 5,
}}
/>
)}
<TextOneLine
style={{
fontSize: 11,
marginTop: 1,
fontWeight: '400',
color: prettyCategory
? theme.tableText
: theme.menuItemTextSelected,
fontStyle:
specialCategory || !prettyCategory
? 'italic'
: undefined,
textAlign: 'left',
}}
>
{prettyCategory || t('Uncategorized')}
</TextOneLine>
</View>
/>
)}
{notes && (
<TextOneLine
{(isParent || isChild) && (
<SvgSplit
style={{
fontSize: 11,
marginTop: 4,
fontWeight: '400',
color: theme.tableText,
textAlign: 'left',
opacity: 0.85,
width: 12,
height: 12,
marginRight: 5,
}}
>
<NotesTagFormatter notes={notes} />
</TextOneLine>
/>
)}
</View>
<View
style={{ justifyContent: 'center', alignItems: 'flex-end' }}
>
<Text
<TextOneLine
style={{
...textStyle,
...styles.tnum,
...makeAmountFullStyle(amount),
fontSize: 11,
marginTop: 1,
fontWeight: '400',
color: prettyCategory
? theme.tableText
: theme.menuItemTextSelected,
fontStyle:
specialCategory || !prettyCategory ? 'italic' : undefined,
textAlign: 'left',
}}
>
{integerToCurrency(amount)}
</Text>
{showRunningBalance && runningBalance !== undefined && (
<Text
style={{
fontSize: 11,
fontWeight: '400',
...styles.tnum,
...makeBalanceAmountStyle(runningBalance),
}}
>
{integerToCurrency(runningBalance)}
</Text>
)}
{prettyCategory || t('Uncategorized')}
</TextOneLine>
</View>
</View>
</Button>
</PressResponder>
)}
</ListBoxItem>
)}
{notes && (
<TextOneLine
style={{
fontSize: 11,
marginTop: 4,
fontWeight: '400',
color: theme.tableText,
textAlign: 'left',
opacity: 0.85,
}}
>
<NotesTagFormatter notes={notes} />
</TextOneLine>
)}
</View>
<View style={{ justifyContent: 'center', alignItems: 'flex-end' }}>
<Text
style={{
...textStyle,
...styles.tnum,
...makeAmountFullStyle(amount),
}}
>
{integerToCurrency(amount)}
</Text>
{showRunningBalance && runningBalance !== undefined && (
<Text
style={{
fontSize: 11,
fontWeight: '400',
...styles.tnum,
...makeBalanceAmountStyle(runningBalance),
}}
>
{integerToCurrency(runningBalance)}
</Text>
)}
</View>
</View>
</Button>
</PressResponder>
);
}

View File

@@ -147,6 +147,11 @@ export function TransactionListWithBalances({
<PullToRefresh
isPullable={!isLoading && !!onRefresh}
onRefresh={async () => onRefresh?.()}
style={{
'& .ptr__children': {
display: 'flex',
},
}}
>
<TransactionList
isLoading={isLoading}