Skip to content

perf(ui): incremental paged home timeline — reloads stop being O(wallet) - #976

Merged
QuantumExplorer merged 2 commits into
developfrom
claude/xenodochial-bouman-06f8aa
Aug 10, 2026
Merged

perf(ui): incremental paged home timeline — reloads stop being O(wallet)#976
QuantumExplorer merged 2 commits into
developfrom
claude/xenodochial-bouman-06f8aa

Conversation

@QuantumExplorer

@QuantumExplorer QuantumExplorer commented Aug 10, 2026

Copy link
Copy Markdown
Member

Note

Same change as #974, which was merged into feat/marketplace-browse after that branch had already been squash-merged to develop (#972) — so the paged timeline never reached develop. This is the identical commit rebased onto develop (clean rebase; the three touched files are byte-identical) and re-verified with the canonical dashpay build on the develop base. If it instead lands via a future feat/marketplace-browse merge, this PR can simply be closed.

Issue being fixed or feature implemented

After the scoped wallet-source fetches (#972), HomeViewModel's full reload was the last O(wallet) hot path: every debounced sync/save tick called fetchAll()fetchAndWrap, re-materializing and re-wrapping the entire history — measured 4.5–6.1s idle (8.6s under recovery-sync load) per tick on a 7,007-tx CoinJoin-heavy wallet. During a recovery sync the reload ticks burned seconds of CPU each, indefinitely.

What was done?

The feed now renders from a windowed row cache instead of the whole history:

  • Day-completed first pagefetchTimelineWindow loads the newest ~100 rows via a firstSeen-index keyset scan and always finishes its boundary calendar day, so a loaded day is never partially represented and the per-day CoinJoin mixing groups stay exact without a full-history pass. firstSeen doubles as the day key: the persister adopts the block timestamp once a tx is mined, so it equals the display date for settled history.
  • Grow on scroll — a stamp-keyed tail sentinel in HomeView pages the next day-completed slice in (fetchOlderTimelinePage), auto-continuing while visible so a filter that matches nothing keeps paging.
  • Delta reconcile on ticksfetchTimelineDelta fetches only rows with lastUpdated past the reconcile stamp AND firstSeen inside the window, SQL-side. Restored history landing below the window costs nothing. Saves that delete feed rows (UnconfirmedTransactionRemover) and fiat-currency changes flag a window-sized refetch instead — deltas can't observe deletions, and cached wrappers hold currency-specific strings.
  • Aggregations decoupled from row wrapping — the Rewards/Masternode filter gates become fetchLimit=1 existence probes on transactionTypeKind (coinbase=8, provider kinds 2–5); the cross-day "CoinJoin Withdrawals" group totals every tagged sweep via point lookups (CoinJoinWithdrawalStore.allTxids) and renders once its day is paged in; shielded/platform items clamp to the loaded day range; the "Date unknown" band renders only with full history loaded.
  • Recovery growth cap — while the window still covers the whole (small) history mid-restore, deltas grow it with the wallet; past 400 rows it re-tightens to ~one page (never after the user explicitly paged).
  • TransactionSource grows the timeline API with fixture defaults (whole allTransactions as one complete window), so the onboarding stub and previews work unchanged.
  • Fixed in passing: UInt64 bounds in a SwiftData #Predicate round-trip through SQLite's signed Int64 — a .max "no upper bound" sentinel compared as −1 and silently matched nothing. scopedRows clamps bounds to Int64.max.

How Has This Been Tested?

  • Clean canonical dashpay simulator build (ARCHS=arm64) on the develop base after the rebase.
  • In-process measurement on the QA-iPhone16 sim against the synthetic 7,007-tx / 12,223-TXO mainnet store (lldb, same recipe as the Marketplace Browse tab + scoped wallet-source fetches #972 baseline): first page 558ms cold / 173ms warm (100 rows), older page 310ms (104 rows), no-change delta 1ms, realistic 10-min delta 4ms (picked up 2 rows the live persister wrote mid-sync), filter-gate probes 12–30ms — vs 4.5–6.1s per full pass. Gate probes returned the ground-truth answers (hasRewards/hasMasternodes both true on the synthetic store).
  • Unit-test target remains broken repo-wide (pre-existing); no runnable tests exist for this area.

Reviewer notes: the delta stamp must never advance from older-page fetches (a paged-in row's lastUpdated can postdate window updates the next delta still has to pick up); receipt matching for shielded items sees the loaded window — the only miss is a receipt up to 1h before an item across the window's bottom midnight, which self-resolves when that day pages in.

Breaking Changes

None — UI behavior change only: history below the loaded window renders progressively as the user scrolls instead of all at once.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have made corresponding changes to the documentation

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added automatic pagination for transaction history, allowing older transactions to load as you scroll.
    • Added loading indicators and clearer history-loading status.
  • Bug Fixes
    • Improved transaction ordering and consistency across grouped, shielded, reward, and masternode activity.
    • Improved handling of transaction updates, deletions, wallet changes, and currency changes.
  • Performance
    • Reduced unnecessary transaction loading while keeping recent activity up to date.

…llet)

HomeViewModel's reload was the last O(wallet) hot path after the scoped-
fetch work: every debounced sync/save tick re-materialized and re-wrapped
the entire history (fetchAll -> walletTxRollup + full IN-fetch), measured
4.5-8.6s per tick on a 7,007-tx CoinJoin-heavy wallet. During a recovery
sync that burned seconds of CPU per second, indefinitely.

The feed now renders from a windowed row cache instead of the whole
history:

- First paint loads the newest ~100 rows via a firstSeen-index keyset
  page (fetchTimelineWindow). Pages are DAY-COMPLETED — the fetch always
  finishes its boundary calendar day, so a loaded day is never partially
  represented and the per-day CoinJoin mixing groups stay exact without
  a full-history pass. firstSeen doubles as the day key: the persister
  adopts the block timestamp once a tx is mined, so it matches the
  display date for settled history.
- Scrolling to the feed's tail pages the next day-completed slice in
  (fetchOlderTimelinePage + a stamp-keyed sentinel row that re-fires
  while visible, so filters that match nothing keep paging).
- Save/balance/sync ticks reconcile via fetchTimelineDelta: rows with
  lastUpdated past the reconcile stamp AND firstSeen inside the window,
  SQL-side. Restored history landing below the window costs nothing.
  Saves that DELETE feed rows (UnconfirmedTransactionRemover) and fiat-
  currency changes flag a window-sized refetch instead (deltas can't see
  deletions; cached wrappers hold currency-specific strings).
- The Rewards/Masternode filter gates are answered by fetchLimit=1
  existence probes on transactionTypeKind (coinbase=8, provider kinds
  2-5) — never by scanning wrapped history. The cross-day "CoinJoin
  Withdrawals" group totals all tagged sweeps via point lookups
  (CoinJoinWithdrawalStore.allTxids) and renders once its day is paged
  in. Shielded/platform items clamp to the loaded day range; the
  "Date unknown" band renders only with full history loaded.
- A recovery-sync growth cap trims an untouched full-history window
  back to ~one page past 400 rows (never after explicit paging).
- Fixed in passing: UInt64 predicate bounds round-trip through SQLite's
  signed Int64, so a .max sentinel compared as -1 and matched nothing —
  scopedRows clamps bounds to Int64.max.

TransactionSource grows the timeline API with fixture defaults (whole
allTransactions as one complete window), so the onboarding stub and
previews keep working unchanged.

Measured on the synthetic 7,007-tx mainnet store (QA-iPhone16 sim, lldb
in-process): first page 558ms cold / 173ms warm (100 rows), older page
310ms (104 rows), no-change delta 1ms, realistic 10-min delta 4ms
(2 rows), filter-gate probes 12-30ms — vs 4.5-6.1s per full pass
(idle; 8.6s under recovery-sync load).

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

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@QuantumExplorer, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 55 seconds

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 00952729-65a4-4791-af04-43a78424b687

📥 Commits

Reviewing files that changed from the base of the PR and between 4ddc49c and e86cd14.

📒 Files selected for processing (2)
  • DashWallet/Sources/UI/Home/Views/HomeView.swift
  • DashWallet/Sources/UI/Home/Views/HomeViewModel.swift
📝 Walkthrough

Walkthrough

Changes

Timeline history management

Layer / File(s) Summary
Timeline source contracts and queries
DashWallet/Sources/UI/Home/Views/HomeViewModel.swift
TransactionSource now supports bounded windows, older pages, deltas, filter gates, and transaction lookups. Fixture and SDK-backed sources implement the expanded contract with SwiftData fallbacks.
Window reconciliation and paging state
DashWallet/Sources/UI/Home/Views/HomeViewModel.swift
HomeViewModel tracks timeline boundaries, reconciles changes incrementally, handles invalidation and deletion, trims cached rows, and publishes paging state.
Windowed activity rendering and ordering
DashWallet/Sources/UI/Home/Views/HomeViewModel.swift
Activity rows are limited to the loaded window. Equal-date transactions use deterministic ordering.
Pagination trigger and withdrawal lookup
DashWallet/Sources/UI/Home/Views/HomeView.swift, DashWallet/Sources/Models/CoinJoin/CoinJoinWithdrawalStore.swift
HomeView loads older pages through a keyed sentinel. CoinJoinWithdrawalStore returns active-wallet withdrawal transaction IDs.

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

Sequence Diagram(s)

sequenceDiagram
  participant WalletOwner
  participant HomeView
  participant HomeViewModel
  participant TransactionSource
  WalletOwner->>HomeView: scroll to history tail
  HomeView->>HomeViewModel: loadMoreHistory()
  HomeViewModel->>TransactionSource: request older timeline page
  TransactionSource-->>HomeViewModel: return page and availability
  HomeViewModel-->>HomeView: publish rows and page stamp
  HomeView-->>WalletOwner: render additional history
Loading

Possibly related PRs

Suggested reviewers: llbartekll, jeanpierreroma

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 62.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 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 identifies the incremental, paged Home timeline performance improvement.
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 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/xenodochial-bouman-06f8aa

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
DashWallet/Sources/UI/Home/Views/HomeViewModel.swift (1)

687-691: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Split rebuildTimelineItems — SwiftLint reports a complexity error.

SwiftLint reports cyclomatic_complexity 25 against the limit of 10 at Line 687, at error severity. The method now performs sweep-txid backfill, sorting, filtering, CoinJoin grouping, activity interleaving, grouping, and publishing.

Extract the Core-row classification loop, the activity interleaving, and the publish step into separate private methods.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@DashWallet/Sources/UI/Home/Views/HomeViewModel.swift` around lines 687 - 691,
Reduce the cyclomatic complexity of rebuildTimelineItems by extracting its
Core-row classification loop, activity interleaving, and publishing logic into
separate private methods. Keep rebuildTimelineItems responsible for
orchestration and preserve the existing ordering, filtering, grouping, and
published output behavior.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@DashWallet/Sources/UI/Home/Views/HomeView.swift`:
- Around line 404-419: Update the HomeView transaction-content layout so the
pagination sentinel controlled by canLoadMoreHistory is rendered even when
txItems is empty. Move or duplicate the existing ProgressView block outside the
txItems.isEmpty conditional, preserving its historyPageStamp identity and
loadMoreHistory onAppear behavior so filtered pagination continues.

In `@DashWallet/Sources/UI/Home/Views/HomeViewModel.swift`:
- Around line 556-564: In the pendingWindowRefetch branch, move the
pendingWindowRefetch = false assignment into the successful timelineDelta
handling after replaceWindow(with: delta) completes. Keep the flag set when
timelineDelta returns nil so the refetch request is retried later.
- Around line 622-637: The trim boundary in trimWindowIfNeeded() must use the
same firstSeen key as olderTimelinePage and timelineDelta. Sort and determine
the anchor day from each transaction’s firstSeen value, then retain rows based
on that value while preserving the existing dictionary rebuild,
windowOldestDayStart update, and hasOlderHistory behavior.

---

Nitpick comments:
In `@DashWallet/Sources/UI/Home/Views/HomeViewModel.swift`:
- Around line 687-691: Reduce the cyclomatic complexity of rebuildTimelineItems
by extracting its Core-row classification loop, activity interleaving, and
publishing logic into separate private methods. Keep rebuildTimelineItems
responsible for orchestration and preserve the existing ordering, filtering,
grouping, and published output behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 548d406b-2e0c-4b2c-8654-e2dedc5bea67

📥 Commits

Reviewing files that changed from the base of the PR and between 9dceb85 and 4ddc49c.

📒 Files selected for processing (3)
  • DashWallet/Sources/Models/CoinJoin/CoinJoinWithdrawalStore.swift
  • DashWallet/Sources/UI/Home/Views/HomeView.swift
  • DashWallet/Sources/UI/Home/Views/HomeViewModel.swift

Comment thread DashWallet/Sources/UI/Home/Views/HomeView.swift Outdated
Comment thread DashWallet/Sources/UI/Home/Views/HomeViewModel.swift
Comment thread DashWallet/Sources/UI/Home/Views/HomeViewModel.swift
…, firstSeen-keyed trim

Review feedback on the paged home timeline (#976):

- The tail sentinel now renders outside the empty/non-empty branch: a
  filter can hide every loaded row, and the empty state previously could
  not page deeper to find matches. The "no transactions to display" copy
  shows only when no unloaded history remains — with pages left it was a
  false claim.
- `pendingWindowRefetch` clears only after the window re-read actually
  happens; a nil delta (host momentarily unbound) previously dropped the
  request and left currency-stale wrappers until an unrelated trigger.
- The recovery growth cap re-tightens by refetching the first page from
  the store instead of trimming in memory: the store computes the
  boundary in firstSeen space — the same key paging and deltas filter by
  — while cached wrappers only expose the display date, which can differ
  for legacy rows and let a date-keyed trim drop rows paging never
  re-fetches.
- rebuildTimelineItems split (input assembly, activity interleave,
  publish) to bring its cyclomatic complexity back under the SwiftLint
  bound.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@QuantumExplorer
QuantumExplorer merged commit aebb73c into develop Aug 10, 2026
2 checks passed
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