Skip to content

mobile: refresh label counts when a local note write changes them - #936

Merged
hanzei merged 1 commit into
masterfrom
claude/mobile-label-count-invalidation
Aug 24, 2026
Merged

mobile: refresh label counts when a local note write changes them#936
hanzei merged 1 commit into
masterfrom
claude/mobile-label-count-invalidation

Conversation

@hanzei

@hanzei hanzei commented Aug 24, 2026

Copy link
Copy Markdown
Owner

Found while auditing for siblings of the stale-dashboard bug in #935 — same category, different derived cache. Independent of that PR; either can land first.

The bug

The drawer's per-label counts are derived from the notes table, not the labels one:

-- getLocalLabelCounts
SELECT labels_json FROM notes WHERE archived = 0 AND deleted_at IS NULL

So a note write that touches no label still changes them, by moving a note in or out of that counted set. But nothing in useNotes.ts ever invalidated labelCountsQueryKey — while useLabels.ts invalidates it for every membership edit, and the SSE handler invalidates it for every remote note event. This was the local-write path missing a rule the rest of the app follows.

Result: archiving, trashing, restoring, or duplicating a labelled note left the drawer showing the pre-write number. Recovery was an isConnected flip, an SSE reconnect resync, or an app restart — and since the drawer stays mounted for the life of the navigator, merely opening it refreshes nothing.

Verified against a real SQLite test database before writing any fix. SQLite was already correct; only the cache was behind:

AFTER ARCHIVE,    drawer count = {"l1":1}   truth in SQLite = {}
AFTER TRASH,      drawer count = {"l1":1}   truth in SQLite = {}
AFTER DUPLICATE,  drawer count = {"l1":1}   truth in SQLite = {"l1":2}

The fix

Path Why it changes the counts
useUpdateNote archive / unarchive moves the note in or out of the counted set
useDeleteNote trashing drops it out
useRestoreNote restoring puts it back
useDuplicateNote the copy keeps the source's labels
useCreateNote a create can carry labels — the sync-failure "Keep my version" fork does
useSyncFailures resolving deletes a discarded create, tombstones a note, or replaces one wholesale

The invalidation re-reads local SQLite rather than the server, so it is gated where the trigger is narrow: only an archived flag on an update (a debounced title/content save must not re-scan every note's labels), and only a create or duplicate that actually carries labels. There's a test asserting the content-save case leaves the counts untouched.

usePermanentDeleteNote and empty-trash need nothing — a trashed note was already uncounted.

Import needed more than an invalidation

ImportSection pulled only notes after a Keep import and invalidated only the dashboard. Adding a labelsQueryKey invalidation alone would have been theatre: that query's queryFn reads the local labels table, which the import never writes, so the re-read would just re-serve the pre-import rows. It now pulls labels alongside notes and then refreshes all four SQLite-derived caches.

Tests

mobile/__tests__/labelCountInvalidation.test.tsx — five cases against a real migrated SQLite database (archive, trash + restore, duplicate, create-with-labels, and the negative case). The four behavioural ones were confirmed failing on master and passing with the fix; the negative one passes both ways by design.

SettingsScreen-import.test.tsx gained assertions for the label pull and the four invalidations. Doing so surfaced a saveLabels is not a function that the screen's own try/catch was swallowing — the suite's noteQueries mock was incomplete, so the test would have passed while the save silently failed. Fixed in the mocks.

task check passes (lint + 1399 tests + docs/migration/translation gates).

Not in this diff

  • useOfflineNotes' list sync has the mirror asymmetry (invalidates the list scope, not the note scope). It cannot strand the editor the way mobile: refresh the notes list when a single-note sync writes to SQLite #935's gap stranded the dashboard: the editor fetches its note on mount, and both hooks subscribe to the reconnect-resync bus while it is open.
  • useCreateNote's offline branch hardcodes labels: [], ignoring data.labels, so an offline "Keep my version" fork shows unlabelled locally until its queued create drains and a sync brings the server's copy back. Different category — data fidelity, not cache invalidation — and it self-heals. Fixing it means resolving label names to local Label rows, which is real work rather than a one-liner.

Artifacts

No screenshot: the visible change is a number in the drawer moving when it previously didn't, which a still frame can't show any better than the test output above. No API-breaking changes; server untouched.


Generated by Claude Code

The drawer's per-label counts are derived from the notes table, not the
labels one: getLocalLabelCounts counts each label across the active notes
(archived and trashed excluded). A note write that touches no label still
changes them by moving a note in or out of that set — but nothing in
useNotes.ts ever invalidated labelCountsQueryKey, so archiving, trashing,
restoring, duplicating, or creating a note with labels left the drawer
showing the pre-write number.

useLabels.ts already invalidates the counts for every membership edit, and
the SSE handler does it for every remote note event, so this was the local
write path missing a rule the rest of the app follows. Recovery was an
isConnected flip, an SSE reconnect resync, or an app restart; the drawer
stays mounted, so merely opening it did not refresh anything.

Verified against a real SQLite test database: after archiving a labelled
note the rows said {} while the drawer still served {"l1": 1}.

Invalidate the counts from each path that moves a note in or out of the
counted set. The invalidation re-reads local SQLite rather than the
server, so it is gated where the trigger is narrow: only an archive flag
on an update (a debounced title/content save must not re-scan every note's
labels), and only a create or duplicate that actually carries labels.
Permanent delete and empty-trash need nothing — a trashed note was already
uncounted.

The import path needed more than an invalidation. It pulled only notes
after a Keep import, and the drawer's label list reads the local labels
table, which the import never wrote — so invalidating that query would
have re-served the pre-import rows. Pull labels alongside notes, then
refresh all four SQLite-derived caches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FoZMaSuY8Qia39VMiAW44T
@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 77c5566f-f8b0-415f-b273-4d1bf9974c3b

📥 Commits

Reviewing files that changed from the base of the PR and between 0c5f0bd and e13d84c.

📒 Files selected for processing (5)
  • mobile/__tests__/SettingsScreen-import.test.tsx
  • mobile/__tests__/labelCountInvalidation.test.tsx
  • mobile/src/hooks/useNotes.ts
  • mobile/src/hooks/useSyncFailures.ts
  • mobile/src/screens/settings/ImportSection.tsx

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


Walkthrough

The mobile note mutations now invalidate label-count queries when labelled notes change active status or when labelled notes are created or duplicated. Sync failure resolution also invalidates label counts. Settings import now fetches and saves notes and labels, then invalidates related queries. Tests cover import behavior and label-count changes for note mutations.

Poem

I’m a rabbit with labels in tow,
Watching fresh counts hop and grow.
Notes hide, return, or duplicate bright,
Imports bring labels into sight.
Cache paths refresh—what a neat flow!

Merge Risk: ⚪ Minimal · up to e13d8

This change refreshes local label counts after note writes and updates import cache refreshes; no actionable merge-blocking risk remains beyond normal checks and review.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely identifies the main change: refreshing label counts after local note writes.
Description check ✅ Passed The description explains the stale label-count bug, the cache-invalidation fix, import changes, affected paths, and test coverage.
Docstring Coverage ✅ Passed Docstring check was indeterminate for this PR — some files could not be analyzed in time. Not blocking.
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.

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.

@hanzei
hanzei marked this pull request as ready for review August 24, 2026 10:29
@hanzei
hanzei merged commit 2c037ac into master Aug 24, 2026
9 checks passed
@hanzei
hanzei deleted the claude/mobile-label-count-invalidation branch August 24, 2026 10:33
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.

2 participants