Skip to content

♻️(frontend) refactor thread query cache management#642

Draft
jbpenrath wants to merge 1 commit intomainfrom
fix/unread-message-state
Draft

♻️(frontend) refactor thread query cache management#642
jbpenrath wants to merge 1 commit intomainfrom
fix/unread-message-state

Conversation

@jbpenrath
Copy link
Copy Markdown
Contributor

@jbpenrath jbpenrath commented Apr 22, 2026

Purpose

The thread query is an infinite one and the frontend logic is based on the structuralSharing concept of react-query to optimiscally update the react query cache on thread mutation in order to improve ux. This part is a tricky one and it's easy to introduce regression, that's why refactor it by moving the corresponding logic into a mailbox-cache module and battle test it.

Summary by CodeRabbit

Release Notes

  • Bug Fixes

    • Fixed thread deselection to occur immediately when marking messages as unread, improving responsiveness.
  • Improvements

    • Enhanced pagination handling to prevent unnecessary fetch attempts beyond the last page.
    • Optimized mailbox cache management for better performance and consistency when handling large thread lists.

The thread query is an infinite one and the frontend logic is
based on the structuralSharing concept of react-query to
optimiscally update the react query cache on thread mutation in
order to improve ux. This part is a tricky one and it's easy to
introduce regression, that's why refactor it by moving the corresponding
logic into a mailbox-cache module and battle test it.
@jbpenrath jbpenrath self-assigned this Apr 22, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 22, 2026

📝 Walkthrough

Walkthrough

The PR modifies thread selection timing across UI components (moving from async onSuccess callbacks to synchronous calls), introduces a new mailbox-cache utility module for optimistic React Query cache management (including merge, trim, and update functions), adds comprehensive test coverage for cache utilities, and refactors the mailbox provider to use the new shared cache helpers.

Changes

Cohort / File(s) Summary
Thread UI Timing Changes
src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-header.tsx, src/frontend/src/features/layouts/components/thread-view/components/thread-action-bar/index.tsx, src/frontend/src/features/layouts/components/thread-view/components/thread-message/thread-message-actions.tsx
Modified mark-as-unread/read handlers to invoke unselectThread() and onClearSelection() synchronously before mutation triggers, rather than deferring these calls to onSuccess callbacks.
Mailbox Cache Utilities
src/frontend/src/features/providers/mailbox-cache.ts
New module exporting MessageQueryInvalidationSource type, mergeOptimisticThreads() to restore omitted optimistic threads per-page, trimTrailingEmptyPages() to clean up empty terminal pages, and applyMessageUpdate() to apply delete/update invalidations and recompute is_unread based on read-pointer semantics.
Mailbox Cache Tests
src/frontend/src/features/providers/mailbox-cache.test.ts
Comprehensive Vitest suite (566 lines) covering optimistic thread merging across pagination/refetch scenarios, trailing page trimming, and message update behavior for delete/update invalidations with read-pointer-derived is_unread logic.
Mailbox Provider Refactoring
src/frontend/src/features/providers/mailbox.tsx
Replaced inline optimistic-merge logic with calls to mergeOptimisticThreads() and trimTrailingEmptyPages(), added custom queryFn to handle out-of-range 404 errors as terminal empty pages, and delegated message cache mutations to applyMessageUpdate().

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Poem

🐰 Hopping swift, no more delays—
Selection clears before mutations sway,
Cache utilities trim the fat,
Optimistic threads restore like that,
Quick sequence, clean cascade of gray! 🌙

🚥 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 accurately describes the main refactoring effort: moving complex thread query cache management logic into a dedicated mailbox-cache module with comprehensive test coverage.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 fix/unread-message-state

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@jbpenrath
Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 22, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (3)
src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-header.tsx (1)

186-197: Optional: consider guarding the synchronous unselect to the unread direction for consistency with the dropdown.

The main button is a toggle and currently calls unselectThread() / onClearSelection() synchronously for both directions, but the visibility-observer race only justifies it for the mark-as-unread path. The dropdown's "Mark as read" option (lines 291–304) still defers unselection to onSuccess, so the two controls diverge when the user marks an already-read selection as read (main button closes the open thread immediately; dropdown waits for success). Also note this is a behavior change for the mark-as-read direction, which previously kept the thread view open until the mutation succeeded.

If the intent is to keep sync-close strictly for the unread case, something like:

♻️ Proposed adjustment
             onClick={() => {
-                // Close the open thread before firing the mutation. Waiting for
-                // onSuccess would let the visibility observer re-observe the
-                // newly-unread messages and debounce a mark-as-read that silently
-                // reverts the action.
-                unselectThread();
-                onClearSelection();
-                markAsReadAt({
-                    threadIds: threadIdsToMark,
-                    readAt: selectionReadStatus === SelectionReadStatus.READ ? null : new Date().toISOString(),
-                });
+                const willMarkUnread = selectionReadStatus === SelectionReadStatus.READ;
+                if (willMarkUnread) {
+                    // Close the open thread before firing the mutation. Waiting for
+                    // onSuccess would let the visibility observer re-observe the
+                    // newly-unread messages and debounce a mark-as-read that silently
+                    // reverts the action.
+                    unselectThread();
+                    onClearSelection();
+                    markAsReadAt({ threadIds: threadIdsToMark, readAt: null });
+                } else {
+                    markAsReadAt({
+                        threadIds: threadIdsToMark,
+                        readAt: new Date().toISOString(),
+                        onSuccess: () => {
+                            unselectThread();
+                            onClearSelection();
+                        },
+                    });
+                }
             }}

Otherwise, consider aligning the dropdown "Mark as read" option (lines 291–304) to also unselect synchronously so both controls behave the same. Happy either way — just flagging the asymmetry.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-header.tsx`
around lines 186 - 197, The main button always calls unselectThread() and
onClearSelection() synchronously before markAsReadAt, but the dropdown's "Mark
as read" path defers unselection to the mutation onSuccess, causing inconsistent
UX; update the main-button click handler (the inline onClick that calls
unselectThread(), onClearSelection(), and markAsReadAt()) to only perform the
synchronous unselect when toggling to unread (i.e., when selectionReadStatus !==
SelectionReadStatus.READ), and otherwise defer unselecting/clearing selection
until the markAsReadAt mutation succeeds (aligning with the dropdown), or
alternatively change the dropdown handler that uses onSuccess to also call
unselectThread()/onClearSelection() synchronously—pick one approach and apply it
consistently around markAsReadAt and the mutation onSuccess callbacks.
src/frontend/src/features/providers/mailbox.tsx (1)

258-273: Nit: redundant page > 1 branch in previous.

Inside the catch block you already gate on page > 1 (line 260), so previous: page > 1 ? page - 1 : null can only ever evaluate to page - 1. The inner ternary is dead code.

♻️ Simplification
-                        return {
-                            status: 200,
-                            data: {
-                                count: 0,
-                                results: [],
-                                next: null,
-                                previous: page > 1 ? page - 1 : null,
-                            } as PaginatedThreadList,
-                            headers: new Headers(),
-                        } as threadsListResponse;
+                        return {
+                            status: 200,
+                            data: {
+                                count: 0,
+                                results: [],
+                                next: null,
+                                previous: page - 1,
+                            } as PaginatedThreadList,
+                            headers: new Headers(),
+                        } as threadsListResponse;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/frontend/src/features/providers/mailbox.tsx` around lines 258 - 273, The
catch block that handles APIError checks page > 1 already (using page derived
from pageParam), so the ternary used for the previous field is redundant; update
the returned object in the APIError 404 branch (the block that returns a
threadsListResponse / PaginatedThreadList) to set previous to page - 1 directly
instead of using previous: page > 1 ? page - 1 : null, leaving all other fields
intact and keeping the same APIError check and types.
src/frontend/src/features/providers/mailbox-cache.test.ts (1)

1-566: LGTM — battle-tested indeed.

Coverage is excellent: per-page re-insertion, duplicate prevention across pages, count inflation on the impacted page, reference preservation on no-op pages (line 316), fetchNextPage vs server-confirm distinction, mark-then-mark regressions, read-pointer derivation for both "up to here" and "from here", plus the no-pointer pass-through. The inline comments tying each test to the real scenario (e.g. lines 206-224, 226-252, 277-299) are great documentation.

One gap worth considering: every timestamp in the applyMessageUpdate suite uses the same ISO precision (.sssZ or none, but consistent per test). Adding a case that mixes 2026-01-02T00:00:00Z with 2026-01-02T00:00:00.000Z would surface the lexicographic-comparison fragility flagged on mailbox-cache.ts line 143.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/frontend/src/features/providers/mailbox-cache.test.ts` around lines 1 -
566, Tests for applyMessageUpdate miss a case where message timestamps have
mixed ISO precision (e.g. "2026-01-02T00:00:00Z" vs "2026-01-02T00:00:00.000Z"),
which can hide lexicographic comparison bugs referenced at mailbox-cache.ts line
143; add a unit test in mailbox-cache.test.ts under the "derives is_unread..."
scenarios that mixes precision across messages (use one message with ".000Z" and
another without) and asserts the intended readAt behavior, and then fix
applyMessageUpdate to normalize/parse timestamps (e.g. use Date.parse or
toISOString normalization) when deriving is_unread so comparisons are robust to
precision differences.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/frontend/src/features/providers/mailbox-cache.ts`:
- Around line 143-146: The current deriveIsUnread function compares ISO
timestamp strings lexicographically (createdAt > readAt) which is fragile across
different ISO precisions; change deriveIsUnread to parse createdAt and readAt
into numeric epoch times (e.g., Date.parse or new Date(...).getTime()) and
compare those numbers, handle readAt === null as before and guard against
invalid dates (NaN) by treating invalid readAt as unread or logging/falling
back; update tests to include a regression case where createdAt has milliseconds
and readAt does not (and vice versa) to ensure mixed-precision inputs yield the
correct boolean.

---

Nitpick comments:
In
`@src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-header.tsx`:
- Around line 186-197: The main button always calls unselectThread() and
onClearSelection() synchronously before markAsReadAt, but the dropdown's "Mark
as read" path defers unselection to the mutation onSuccess, causing inconsistent
UX; update the main-button click handler (the inline onClick that calls
unselectThread(), onClearSelection(), and markAsReadAt()) to only perform the
synchronous unselect when toggling to unread (i.e., when selectionReadStatus !==
SelectionReadStatus.READ), and otherwise defer unselecting/clearing selection
until the markAsReadAt mutation succeeds (aligning with the dropdown), or
alternatively change the dropdown handler that uses onSuccess to also call
unselectThread()/onClearSelection() synchronously—pick one approach and apply it
consistently around markAsReadAt and the mutation onSuccess callbacks.

In `@src/frontend/src/features/providers/mailbox-cache.test.ts`:
- Around line 1-566: Tests for applyMessageUpdate miss a case where message
timestamps have mixed ISO precision (e.g. "2026-01-02T00:00:00Z" vs
"2026-01-02T00:00:00.000Z"), which can hide lexicographic comparison bugs
referenced at mailbox-cache.ts line 143; add a unit test in
mailbox-cache.test.ts under the "derives is_unread..." scenarios that mixes
precision across messages (use one message with ".000Z" and another without) and
asserts the intended readAt behavior, and then fix applyMessageUpdate to
normalize/parse timestamps (e.g. use Date.parse or toISOString normalization)
when deriving is_unread so comparisons are robust to precision differences.

In `@src/frontend/src/features/providers/mailbox.tsx`:
- Around line 258-273: The catch block that handles APIError checks page > 1
already (using page derived from pageParam), so the ternary used for the
previous field is redundant; update the returned object in the APIError 404
branch (the block that returns a threadsListResponse / PaginatedThreadList) to
set previous to page - 1 directly instead of using previous: page > 1 ? page - 1
: null, leaving all other fields intact and keeping the same APIError check and
types.
🪄 Autofix (Beta)

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

Run ID: e4fcc765-93b2-4354-bc69-513a6c5998f3

📥 Commits

Reviewing files that changed from the base of the PR and between 7a0f6fb and 7b5f37b.

📒 Files selected for processing (6)
  • src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-header.tsx
  • src/frontend/src/features/layouts/components/thread-view/components/thread-action-bar/index.tsx
  • src/frontend/src/features/layouts/components/thread-view/components/thread-message/thread-message-actions.tsx
  • src/frontend/src/features/providers/mailbox-cache.test.ts
  • src/frontend/src/features/providers/mailbox-cache.ts
  • src/frontend/src/features/providers/mailbox.tsx

Comment on lines +143 to +146
const deriveIsUnread = (createdAt: string, readAt: string | null): boolean => {
if (readAt === null) return true;
return createdAt > readAt;
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Fragile timestamp comparison in deriveIsUnread.

Lexicographic > on ISO strings only works when both operands share the exact same shape. Mixing formats — e.g. 2026-01-02T00:00:00Z (no ms) vs 2026-01-02T00:00:00.000Z (with ms) — breaks ordering: 'Z' (0x5A) > '.' (0x2E), so the no-ms string sorts after the same instant formatted with ms, flipping is_unread for a message created at exactly the read pointer.

This happens silently in production when the API emits timestamps without fractional seconds while a client-side new Date().toISOString() (always .sssZ) feeds readAt. The test suite happens to use consistent formatting so it does not catch this.

🛡️ Proposed fix
-const deriveIsUnread = (createdAt: string, readAt: string | null): boolean => {
-    if (readAt === null) return true;
-    return createdAt > readAt;
-};
+const deriveIsUnread = (createdAt: string, readAt: string | null): boolean => {
+    if (readAt === null) return true;
+    return new Date(createdAt).getTime() > new Date(readAt).getTime();
+};

Worth adding a regression test with mixed-precision inputs.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/frontend/src/features/providers/mailbox-cache.ts` around lines 143 - 146,
The current deriveIsUnread function compares ISO timestamp strings
lexicographically (createdAt > readAt) which is fragile across different ISO
precisions; change deriveIsUnread to parse createdAt and readAt into numeric
epoch times (e.g., Date.parse or new Date(...).getTime()) and compare those
numbers, handle readAt === null as before and guard against invalid dates (NaN)
by treating invalid readAt as unread or logging/falling back; update tests to
include a regression case where createdAt has milliseconds and readAt does not
(and vice versa) to ensure mixed-precision inputs yield the correct boolean.

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