Skip to content

perf(ui): paint the home feed from a window instead of the whole history - #929

Closed
QuantumExplorer wants to merge 1 commit into
developfrom
perf/home-feed-first-paint
Closed

perf(ui): paint the home feed from a window instead of the whole history#929
QuantumExplorer wants to merge 1 commit into
developfrom
perf/home-feed-first-paint

Conversation

@QuantumExplorer

@QuantumExplorer QuantumExplorer commented Aug 7, 2026

Copy link
Copy Markdown
Member

Answering the question behind this: no, nothing was fetched as you scrolled

LazyVStack renders lazily, but the data was fully materialised first. On every load, for a wallet with ~1.7k transactions and ~1.7k UTXOs:

  1. activeWalletTxids fetches every PersistentTxo and faults transaction + spendingTransaction on each, purely to build a txid set.
  2. fetchAndWrap fetches every matching PersistentTransaction and wraps all of them, each touching isCoinJoinMixingTx (another relationship walk).
  3. The per-transaction pass runs over the whole array — rate refresh, metadata across four providers, CrowdNode/CoinJoin/shielded classification, filtering.
  4. Only then is txItems published, in one shot.

No fetchLimit, no window, no cursor anywhere in that path. Load time scales with total history, and it is paid on every full reload — not just launch.

What this does

Adds TransactionSource.recentTransactions(limit:) and publishes a first paint from the newest 60 before the full reload runs.

The windowed fetch skips the txid prescan entirely: it walks transactions newest-first in pages and tests wallet membership per row (the same union fetchOne already uses), so first paint reads about a screenful rather than the whole table. Pages, because the store can hold other wallets' rows; bounded by maxPages so a store dominated by another wallet degrades to a short prefix instead of scanning to the end.

The full reload is unchanged and still produces the authoritative result.

Why this is windowed first paint, not scroll-driven pagination

True paging would be a semantic change here, not a tuning knob. The feed contains aggregate rows whose membership is only correct once the entire history has been seen:

  • the CrowdNode sign-up set,
  • the per-day CoinJoin mixing sets,
  • the combined CoinJoin withdrawal set.

Build those from a 50-row window and they render with the wrong contents; extend the window on scroll and they mutate rows the user is already looking at. Shielded and Platform activity come from separate whole-history sources and interleave by date, so a Core-only page isn't a valid prefix of the timeline either.

So the first paint omits aggregate rows and the transactions belonging to them, rather than approximating them. Rows are only ever added, never corrected away underneath the user. Genuine pagination would need those aggregates reworked into something incrementally computable — a real piece of design work, and a much bigger change than this.

Safety

  • Runs only when no load has completed, so an incremental refresh never flashes a partial list over rows already on screen.
  • The main-thread publish bails if hasLoadedInitialTxItems is already set — a full reload that finishes first wins, since its result is complete.
  • Touches neither txByHash nor the grouping sets: that state belongs to the full reload, and the incremental-update path reads it.
  • Filter flags in the window are window-scoped and used only for filtering the preview; the published hasRewardsHistory / hasMasternodeHistory still come from the full pass over real history.

Verification

dashpay scheme builds clean (arm64 simulator).

Not measured on-device: the simulator is PIN-gated, so I have no before/after timing on the real wallet — the reasoning above is from the code path, not a profile. Worth confirming the first paint actually lands quickly on your ~1.7k-transaction wallet, and that the transition to the full list is not visibly jarring.

Summary by CodeRabbit

  • New Features
    • Recent transactions now appear immediately on the home screen while the complete transaction history loads.
    • The initial transaction view shows up to 60 relevant standalone transactions.
    • Aggregate CoinJoin, CrowdNode, shielded, and platform activity is excluded from the initial feed.
    • The provisional transaction list is replaced automatically when the full history finishes loading.

The feed showed nothing until every transaction had been read and
classified. On a wallet with ~1.7k transactions and ~1.7k UTXOs that is
seconds of work before the first row: activeWalletTxids scans every
PersistentTxo and faults two relationships apiece to build a txid set,
fetchAndWrap then wraps every matching row, and only then does the
per-transaction pass (rate refresh, metadata across four providers,
classification) run. LazyVStack renders lazily, but by then the cost is
already paid — nothing was ever fetched as the user scrolled.

Adds TransactionSource.recentTransactions(limit:) and publishes a first
paint from the newest 60 before the full reload runs. The windowed fetch
skips the txid prescan entirely: it walks transactions newest-first in
pages and tests wallet membership per row — the same union fetchOne
uses — so first paint reads about a screenful instead of the table.

The first paint is deliberately partial and only ever adds rows. It
omits aggregate rows (CrowdNode, the per-day CoinJoin mixing sets, the
CoinJoin withdrawal set) along with the transactions that belong to
them, because their membership is only correct once the whole history
has been seen; rendering them from a window would show groups with the
wrong contents, and correcting them afterwards would churn rows the user
is already looking at. Shielded and Platform activity are likewise left
to the full pass, which owns their whole-history reads. It touches
neither txByHash nor the grouping sets — that state belongs to the full
reload and the incremental-update path reads it.

It runs only when no load has completed, so an incremental refresh never
flashes a partial list over rows already on screen, and a full reload
that lands first wins: the main-thread publish bails if
hasLoadedInitialTxItems is already set.

This is windowed first paint, not scroll-driven pagination — see the PR
for why the aggregate rows make true paging a semantic change rather
than a tuning knob.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: dc050737-8578-4cd2-aed5-890e3f073148

📥 Commits

Reviewing files that changed from the base of the PR and between 6822412 and 4dedfda.

📒 Files selected for processing (2)
  • DashWallet/Sources/UI/Home/Views/HomeViewModel.swift
  • DashWallet/Sources/UI/Onboarding/Stubs/StubTransactionSource.swift

📝 Walkthrough

Walkthrough

Changes

Recent transaction feed

Layer / File(s) Summary
Recent transaction retrieval
DashWallet/Sources/UI/Home/Views/HomeViewModel.swift, DashWallet/Sources/UI/Onboarding/Stubs/StubTransactionSource.swift
TransactionSource now supports bounded recent retrieval. SwiftDashSDKWalletSource scans newest persisted transactions, filters them by wallet membership, and returns accumulated results from bounded pages. Preview and stub sources implement the new method.
Provisional home feed publication
DashWallet/Sources/UI/Home/Views/HomeViewModel.swift
HomeViewModel builds and publishes up to 60 filtered, grouped transactions before the initial full history reload. The full reload then replaces the provisional feed.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant HomeViewModel
  participant TransactionSource
  participant HomeFeed
  HomeViewModel->>TransactionSource: request recent transactions
  TransactionSource-->>HomeViewModel: bounded transaction set
  HomeViewModel->>HomeViewModel: filter and group provisional transactions
  HomeViewModel->>HomeFeed: publish provisional feed
  HomeViewModel->>TransactionSource: load full wallet history
  TransactionSource-->>HomeViewModel: complete transaction history
  HomeViewModel->>HomeFeed: replace provisional feed
Loading

Possibly related PRs

  • dashpay/dashwallet-ios#792: Both changes modify HomeViewModel transaction loading, but this change adds bounded recent loading while #792 throttles reloads and moves full fetching off the main thread.

Suggested reviewers: llbartekll, jeanpierreroma

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the windowed initial loading optimization for the home feed.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/home-feed-first-paint

Comment @coderabbitai help to get the list of available commands.

@QuantumExplorer

Copy link
Copy Markdown
Member Author

Closing: measured, and this doesn't fix the symptom.

Device log from the built branch:

17:20:07  Starting full transaction reload
17:20:10  first paint published 37 groups from 60 recent transactions
17:20:11  Full reload complete, 998 groups, 1756 transactions cached

First paint works as designed, but lands only ~1s before the full reload it was meant to pre-empt — the full pass over all 1,756 transactions is ~1-2s, not the multi-second cost I inferred from reading the code. I never measured before proposing this, and the activeWalletTxids TXO scan I singled out is evidently not dominant.

The real latency is upstream of the feed (the reload doesn't start until +3s), and the log also shows 7 full reloads in 36s, which is a better explanation for a sluggish feed than the initial load. Chasing those instead.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant