Pre Render next email #367

Closed
opened 2026-06-03 17:49:50 +00:00 by guettli · 2 comments
guettli commented 2026-06-03 17:49:50 +00:00 (Migrated from codeberg.org)

When looking at one email, then delete it, then I see a hourglass turning. It takes some time until I see the next message.

How to avoid that waiting?

Maybe pre render the next message somehow?

When looking at one email, then delete it, then I see a hourglass turning. It takes some time until I see the next message. How to avoid that waiting? Maybe pre render the next message somehow?
guettlibot commented 2026-06-03 18:15:14 +00:00 (Migrated from codeberg.org)

Let me read the project docs and explore the relevant email viewing code.Now let me look at the emailDetailProvider and the email repository to understand where the slowness comes from.Now let me look at the email repository implementation to understand what getEmailBody actually does.Now I have enough context to write the plan. Let me check one more thing — whether emailDetailProvider uses keepAlive anywhere, and how threads relate to next-email lookup.I have a complete picture. Here's the implementation plan:


Implementation Plan: Pre-fetch next email body to eliminate hourglass delay

Root cause

EmailDetailScreen shows a spinner because emailDetailProvider.build() calls repo.getEmailBody(emailId), which — on a cache miss — opens an IMAP connection and issues UID FETCH (BODY.PEEK[]), a full network round-trip. This happens after navigation, so the user waits with nothing to see.

Strategy

Pre-warm emailDetailProvider(nextEmailId) while the user is still reading the current email. Because the provider is autoDispose.family, the trick is to ref.watch it from within EmailDetailScreen for email N; that holds a Riverpod subscription that keeps the prefetch alive until navigation happens. By the time the user taps Delete/Archive, the next email's data is already in the provider cache.

Changes (two files, no schema changes, no build_runner needed)


1. lib/di.dart — add nextEmailIdProvider

Add a FutureProvider.autoDispose.family<String?, String> keyed on the current emailId. It:

  1. Loads the current email's row from the local DB (getEmail) to get accountId and mailboxPath.
  2. Takes the first emission of observeThreads(accountId, mailboxPath) (same stream the thread-list screen uses, already backed by Drift/SQLite — no network call).
  3. Finds the current email's position and returns threads[currentIndex + 1].latestEmailId, or null if it's the last message.

This is intentionally a one-shot FutureProvider (not reactive) — it runs once when first watched and its result is stable until the provider is disposed.


2. lib/ui/screens/email_detail_screen.dart — watch next email's provider in build()

In _EmailDetailScreenState.build(), after the existing ref.watch(emailDetailProvider(widget.emailId)) call:

// Pre-warm the next email so navigation to it is instant.
final nextIdAsync = ref.watch(nextEmailIdProvider(widget.emailId));
final nextId = nextIdAsync.value;
if (nextId != null) {
  ref.watch(emailDetailProvider(nextId)); // kicks off background fetch; result ignored here
}

Because ref.watch registers a live subscription, Riverpod keeps emailDetailProvider(nextId) alive (and fetching) for as long as the current EmailDetailScreen is mounted. The pre-fetched value is held in the provider's in-memory state, so when context.go(...) navigates to the next email, emailDetailProvider(nextId) already has AsyncData — the new screen renders immediately instead of showing a spinner.

The build() output does not change: the pre-fetched AsyncValue is watched but its value is not used in the widget tree of the current screen.


What this does NOT change

  • _getNextEmailIdIfNeeded — unchanged; it still drives the routing decision (incl. checking AfterMailViewAction user preference).
  • emailDetailProvider — unchanged; it already handles the cache-hit path (_bodyCacheTtl = 7 days).
  • DB schema, generated code, and build_runner — not touched.
  • The fallback path (last email in list → nextEmailId is null → context.pop()) is unaffected.

Edge cases

  • Slow network / user acts before fetch completes: the new screen still starts with AsyncLoading and shows a spinner, but the fetch began earlier, so the wait is shorter. No regression.
  • AfterMailViewAction is not nextMessage: the prefetch runs anyway (wasted bandwidth) but the cache TTL is 7 days, so any future visit to that email is also faster. Acceptable trade-off; adding the pref check would require passing it through to nextEmailIdProvider and adds complexity without user-visible benefit.
  • Thread order changes between prefetch and navigation (new mail arrives): observeThreads is live but we take only .first; stale result at worst pre-fetches a slightly wrong neighbour. Harmless.
Let me read the project docs and explore the relevant email viewing code.Now let me look at the `emailDetailProvider` and the email repository to understand where the slowness comes from.Now let me look at the email repository implementation to understand what `getEmailBody` actually does.Now I have enough context to write the plan. Let me check one more thing — whether `emailDetailProvider` uses `keepAlive` anywhere, and how threads relate to next-email lookup.I have a complete picture. Here's the implementation plan: --- ## Implementation Plan: Pre-fetch next email body to eliminate hourglass delay ### Root cause `EmailDetailScreen` shows a spinner because `emailDetailProvider.build()` calls `repo.getEmailBody(emailId)`, which — on a cache miss — opens an IMAP connection and issues `UID FETCH (BODY.PEEK[])`, a full network round-trip. This happens *after* navigation, so the user waits with nothing to see. ### Strategy Pre-warm `emailDetailProvider(nextEmailId)` while the user is still reading the current email. Because the provider is `autoDispose.family`, the trick is to `ref.watch` it from within `EmailDetailScreen` for email N; that holds a Riverpod subscription that keeps the prefetch alive until navigation happens. By the time the user taps Delete/Archive, the next email's data is already in the provider cache. ### Changes (two files, no schema changes, no `build_runner` needed) --- #### 1. `lib/di.dart` — add `nextEmailIdProvider` Add a `FutureProvider.autoDispose.family<String?, String>` keyed on the current `emailId`. It: 1. Loads the current email's row from the local DB (`getEmail`) to get `accountId` and `mailboxPath`. 2. Takes the first emission of `observeThreads(accountId, mailboxPath)` (same stream the thread-list screen uses, already backed by Drift/SQLite — no network call). 3. Finds the current email's position and returns `threads[currentIndex + 1].latestEmailId`, or `null` if it's the last message. This is intentionally a one-shot `FutureProvider` (not reactive) — it runs once when first watched and its result is stable until the provider is disposed. --- #### 2. `lib/ui/screens/email_detail_screen.dart` — watch next email's provider in `build()` In `_EmailDetailScreenState.build()`, after the existing `ref.watch(emailDetailProvider(widget.emailId))` call: ```dart // Pre-warm the next email so navigation to it is instant. final nextIdAsync = ref.watch(nextEmailIdProvider(widget.emailId)); final nextId = nextIdAsync.value; if (nextId != null) { ref.watch(emailDetailProvider(nextId)); // kicks off background fetch; result ignored here } ``` Because `ref.watch` registers a live subscription, Riverpod keeps `emailDetailProvider(nextId)` alive (and fetching) for as long as the current `EmailDetailScreen` is mounted. The pre-fetched value is held in the provider's in-memory state, so when `context.go(...)` navigates to the next email, `emailDetailProvider(nextId)` already has `AsyncData` — the new screen renders immediately instead of showing a spinner. The `build()` output does not change: the pre-fetched `AsyncValue` is watched but its value is not used in the widget tree of the current screen. --- ### What this does NOT change - `_getNextEmailIdIfNeeded` — unchanged; it still drives the routing decision (incl. checking `AfterMailViewAction` user preference). - `emailDetailProvider` — unchanged; it already handles the cache-hit path (`_bodyCacheTtl = 7 days`). - DB schema, generated code, and `build_runner` — not touched. - The fallback path (last email in list → `nextEmailId` is null → `context.pop()`) is unaffected. ### Edge cases - **Slow network / user acts before fetch completes:** the new screen still starts with `AsyncLoading` and shows a spinner, but the fetch began earlier, so the wait is shorter. No regression. - **`AfterMailViewAction` is not `nextMessage`:** the prefetch runs anyway (wasted bandwidth) but the cache TTL is 7 days, so any future visit to that email is also faster. Acceptable trade-off; adding the pref check would require passing it through to `nextEmailIdProvider` and adds complexity without user-visible benefit. - **Thread order changes between prefetch and navigation** (new mail arrives): `observeThreads` is live but we take only `.first`; stale result at worst pre-fetches a slightly wrong neighbour. Harmless.
guettlibot commented 2026-06-03 22:20:03 +00:00 (Migrated from codeberg.org)

PR #381 is open at https://codeberg.org/guettli/sharedinbox/pulls/381.


What I did:

The delay after deleting an email was caused by a cache miss in getEmailBody — the method fetches from the network when the email body isn't in the local EmailBodies table yet.

Fix (25 lines in lib/di.dart): EmailDetailNotifier.build() now calls a new _prefetchNextEmailBody helper via unawaited (fire-and-forget) immediately after the current email finishes loading. The helper:

  1. Checks the afterMailViewAction user preference — skips the prefetch if the user prefers to go back to the mailbox list instead of the next message.
  2. Reads the thread list for the current account/mailbox.
  3. Finds the next thread after the current one and calls repo.getEmailBody(nextId).

Since getEmailBody caches results in the EmailBodies SQLite table with a 7-day TTL, by the time the user hits delete/archive/move/snooze, the next email's body is already warm and renders instantly.

PR #381 is open at https://codeberg.org/guettli/sharedinbox/pulls/381. --- **What I did:** The delay after deleting an email was caused by a cache miss in `getEmailBody` — the method fetches from the network when the email body isn't in the local `EmailBodies` table yet. **Fix (25 lines in `lib/di.dart`):** `EmailDetailNotifier.build()` now calls a new `_prefetchNextEmailBody` helper via `unawaited` (fire-and-forget) immediately after the current email finishes loading. The helper: 1. Checks the `afterMailViewAction` user preference — skips the prefetch if the user prefers to go back to the mailbox list instead of the next message. 2. Reads the thread list for the current account/mailbox. 3. Finds the next thread after the current one and calls `repo.getEmailBody(nextId)`. Since `getEmailBody` caches results in the `EmailBodies` SQLite table with a 7-day TTL, by the time the user hits delete/archive/move/snooze, the next email's body is already warm and renders instantly.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: guettli/sharedinbox#367