Fix iOS launch crash: decouple Dashboard from @Query + atomic pull apply - #43
Merged
Merged
Conversation
… pulls atomically After PR #42 chunked sync-apply landed, the iPhone (TestFlight 117) still crash-looped at launch — EXC_BAD_ACCESS deep in _SwiftData_SwiftUI's @query forEach, top of the stack inside DashboardView's ScrollView. The macOS chunking fix mitigated the magnitude of each @query refresh but didn't stop SwiftUI from observing partial-state transitions between intermediate saves. On iOS, all TabView children stay alive and react to every save, so the Contact.interactions inverse faulted during the mid-pull window. Once the iPhone's store held a partial-state row set, even the *next* launch re-rendered it and crashed before sync could heal anything. Two changes: 1. DashboardView no longer uses @query. Contacts/reminders live in @State, populated by a FetchDescriptor in a .task (with a 500ms initial delay so any in-flight pull-apply commits first) and re-fetched on .blackbookSyncDidComplete. Body is gated on hasLoadedOnce (ProgressView until the first fetch lands). Result: the dashboard never observes a mid-sync transition. 2. pullRemoteChanges applies on a fresh ModelContext(container) with autosave off and commits once at the end. The main context (and remaining @querys in other tabs) only ever sees one settled commit; the partial-state window is invisible to the UI. Replaces PR #42's per-25-record applyInChunks (and the applyChunkSize constant). Verified: iOS Simulator + macOS clean builds, 13 Swift Testing tests pass. Crash signature confirmed via Blackbook-2026-06-02-093228.ips. After this lands and TestFlight build 118 deploys, the user should delete + reinstall Blackbook on the iPhone — the existing store still holds the bad partial state from earlier crashes, and a clean install guarantees the first sync arrives via the single-atomic-commit path. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
4 tasks
mayeack
added a commit
that referenced
this pull request
Jun 2, 2026
…ashboard doc (#45) End-to-end review pass. Findings documented in docs/CODE_REVIEW_2026-06-02.md. Changes (the safe, in-scope fixes): - ContactListViewModel: drop dead `import SwiftUI` (it used no SwiftUI symbols; violated the "ViewModels never import SwiftUI" rule). - .cursor/pages/Dashboard.md: resync with DashboardView after PR #43 (was stale — @query→@State manual fetch, title "Dashboard"→"Overview", ProgressView gate, .blackbookSyncDidComplete refresh, added the prioritizeCard). Satisfies the mandatory page-doc-sync rule that PR #43 missed. - +24 unit tests for two previously-untested, pure-logic services: - ContactDeduplicationServiceTests (14): union-find linking by name/email/phone, transitive components, merged-away exclusion, data-richness primary selection, and the mergeAll mutation path. This service auto-merges contacts and was safety-critical with zero coverage. - NetworkGraphEngineTests (10): graph build, dangling-edge rejection, tag filtering, simulation convergence + NaN-safety. - One test (testNameKeyDoesNotTrimInnerWhitespace) is a regression guard that documents a real nameKey limitation flagged in the report (#5). Test count 195 -> 219, all green on iOS + macOS. No production behavior change beyond the dead-import removal. The report's remaining findings (service-layer sync test coverage, .caption2 rule-vs-reality, nameKey whitespace, stale rules.md Icon-1 section, EntityListRow adoption, dead LocalSyncServer.swift) are left for follow-up PRs with rationale. Co-authored-by: Michael Yeack <mayeack@Michaels-Mac-mini.local> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
mayeack
pushed a commit
that referenced
this pull request
Jun 2, 2026
#43/#44) The sync-apply layer — ContactSyncApply.applyRemoteContact and ModelSyncApply.applyRemoteInteraction — is the exact code behind the recent drift (#44) and launch-crash (#43) incidents and had zero unit coverage. These functions are pure (payload dict + ModelContext), so no protocol seam or network mock is needed; "remote" payloads are built with the real contactToDict/interactionToDict serializers so ISO8601 formatting matches the parser. SyncApplyTests (11): - insert path: absent record inserted + marked synced; round-trip preserves name/emails/phones/score/priority; idempotent re-apply is a UUID upsert (no dup). - conflict resolution: newer remote overwrites; NEWER+PENDING local is protected from a stale remote; NEWER-but-SYNCED local IS clobbered by an older remote (regression guard documenting exactly why edits must flip syncStatus to .pending via markLocallyEdited — the #44 root cause). - malformed payloads: missing id / missing updatedAt are ignored without throwing. - interactions: link to resolved contact; unknown contactId persists with nil contact (heals later) instead of crashing; newer pending local survives stale remote. Network-path integration (URLSession injection into LocalServerSyncService) is the remaining step noted in docs/CODE_REVIEW_2026-06-02.md finding #6. Test count 206 -> 217 XCTest (+ 13 Swift Testing), all green iOS + macOS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3 tasks
mayeack
added a commit
that referenced
this pull request
Jun 2, 2026
#43/#44) (#46) The sync-apply layer — ContactSyncApply.applyRemoteContact and ModelSyncApply.applyRemoteInteraction — is the exact code behind the recent drift (#44) and launch-crash (#43) incidents and had zero unit coverage. These functions are pure (payload dict + ModelContext), so no protocol seam or network mock is needed; "remote" payloads are built with the real contactToDict/interactionToDict serializers so ISO8601 formatting matches the parser. SyncApplyTests (11): - insert path: absent record inserted + marked synced; round-trip preserves name/emails/phones/score/priority; idempotent re-apply is a UUID upsert (no dup). - conflict resolution: newer remote overwrites; NEWER+PENDING local is protected from a stale remote; NEWER-but-SYNCED local IS clobbered by an older remote (regression guard documenting exactly why edits must flip syncStatus to .pending via markLocallyEdited — the #44 root cause). - malformed payloads: missing id / missing updatedAt are ignored without throwing. - interactions: link to resolved contact; unknown contactId persists with nil contact (heals later) instead of crashing; newer pending local survives stale remote. Network-path integration (URLSession injection into LocalServerSyncService) is the remaining step noted in docs/CODE_REVIEW_2026-06-02.md finding #6. Test count 206 -> 217 XCTest (+ 13 Swift Testing), all green iOS + macOS. Co-authored-by: Michael Yeack <mayeack@Michaels-Mac-mini.local> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
mayeack
added a commit
that referenced
this pull request
Jun 4, 2026
…he score (#48) Two app-breaking issues, shipped together as an urgent hotfix. 1. Import-All crash / app won't reopen ContactSyncService imported on the MAIN ModelContext and saved once there. "Import All" inserts hundreds of Contacts; that main-context save fires ContactListView's @query mid-transaction and faults the Contact.interactions inverse (EXC_BAD_ACCESS) — the exact failure PR #43 fixed for the pull path. Because startAutoSync re-imports on every .onAppear, it became a crash loop so the app couldn't reopen. Fix: importContacts/importSelected apply mergeOrInsert on a background ModelContext (autosaveEnabled=false) with a single settled save, so the main context's @queries only ever see one committed state. startAutoSync also defers the launch import by 1s so the first frame renders first. Posts .blackbookSyncDidComplete after import so the Dashboard refetches. 2. Recent interactions don't raise the relationship score (Hugo Dooner) The score reads the denormalized Contact.lastInteractionDate. Synced iMessage interaction *records* arrive cleanly, but the contact-field update carrying lastInteractionDate is rejected by conflict resolution when the local copy is newer + pending — so recency stays 0 and the score sits at priority-only (exactly 20 / "Fading"). Fix: RelationshipScoreEngine.recalculateAll now re-derives each contact's lastInteractionDate from the Interaction records this device already holds (single FetchDescriptor<Interaction>, grouped by the to-one interaction.contact?.id — the same controlled pattern the server uses; never touches the faulting Contact.interactions inverse), taking max() so a newer manual date is never lowered. recalculateAll is now also invoked right after each sync pull on the background context, so scores refresh regardless of the visible tab (on macOS the Dashboard's own recalc wouldn't run otherwise). Tests: +3 regression cases in RelationshipScoreEngineTests covering the heal-from-records behavior (stale date healed, priority contact recovers above the boost, newer manual date preserved). Full macOS suite green (220 tests). Co-authored-by: Michael Yeack <mayeack@Michaels-Mac-mini.local> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
mayeack
added a commit
that referenced
this pull request
Jun 4, 2026
…kepoint (#51) * Hotfix: stop Import-All crash loop + make recent interactions raise the score Two app-breaking issues, shipped together as an urgent hotfix. 1. Import-All crash / app won't reopen ContactSyncService imported on the MAIN ModelContext and saved once there. "Import All" inserts hundreds of Contacts; that main-context save fires ContactListView's @query mid-transaction and faults the Contact.interactions inverse (EXC_BAD_ACCESS) — the exact failure PR #43 fixed for the pull path. Because startAutoSync re-imports on every .onAppear, it became a crash loop so the app couldn't reopen. Fix: importContacts/importSelected apply mergeOrInsert on a background ModelContext (autosaveEnabled=false) with a single settled save, so the main context's @queries only ever see one committed state. startAutoSync also defers the launch import by 1s so the first frame renders first. Posts .blackbookSyncDidComplete after import so the Dashboard refetches. 2. Recent interactions don't raise the relationship score (Hugo Dooner) The score reads the denormalized Contact.lastInteractionDate. Synced iMessage interaction *records* arrive cleanly, but the contact-field update carrying lastInteractionDate is rejected by conflict resolution when the local copy is newer + pending — so recency stays 0 and the score sits at priority-only (exactly 20 / "Fading"). Fix: RelationshipScoreEngine.recalculateAll now re-derives each contact's lastInteractionDate from the Interaction records this device already holds (single FetchDescriptor<Interaction>, grouped by the to-one interaction.contact?.id — the same controlled pattern the server uses; never touches the faulting Contact.interactions inverse), taking max() so a newer manual date is never lowered. recalculateAll is now also invoked right after each sync pull on the background context, so scores refresh regardless of the visible tab (on macOS the Dashboard's own recalc wouldn't run otherwise). Tests: +3 regression cases in RelationshipScoreEngineTests covering the heal-from-records behavior (stale date healed, priority contact recovers above the boost, newer manual date preserved). Full macOS suite green (220 tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Features: 3 suggested records in pickers, click-to-sort columns, hidden-filter chokepoint Three of the five requested features (notifications + archive-on-import follow in PR 3). 2a. Three suggested records in contact pickers New ContactSuggestionEngine ranks candidates by contextual similarity to the subject — shared tags (x3), groups (x2), locations (x2), plus a per-field signal — and falls back to relationship score so suggestions are always available. IntroducedToPickerView and MetViaPickerView now show a "Suggested" section (top 3) when not searching, with the rest under "All Contacts". 2b. Click column headers to sort ContactListViewModel gains sortColumn + sortAscending and toggleSort(): clicking a header sorts by that column; clicking the active column flips direction. The active column shows a chevron. Sorts Name, Score, Groups, Locations, Tags, Met via, Introduced to (string columns sort their first value alphabetically with blanks last; numeric columns default to descending). The sort menu was updated to the same model and keeps Recent/Added. Replaces the old ContactSortOrder enum. 2e. Hidden contacts excluded from every search surface Audit found all selection surfaces already filtered !isHidden && !isMergedAway. Added a single chokepoint — `Sequence<Contact>.selectable` — and adopted it in the contact pickers and ContactListViewModel so the CLAUDE.md rule has one enforcement point. (A hidden contact still appearing on another device is sync propagation, not a missing filter — same conflict-resolution family as the score fix in PR #48.) Tests: ContactSuggestionEngineTests (ranking, exclusions, score fallback) and ContactListViewModelTests (+toggle direction, string-column blanks-last, .selectable). Full macOS suite green (226 tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Michael Yeack <mayeack@Michaels-Mac-mini.local> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
EXC_BAD_ACCESSdeep in_SwiftData_SwiftUI's@Query forEach, top of the stack inDashboardView'sScrollView). Chunking shrank each refresh but didn't stop@Queryfrom observing partial-state transitions between intermediate saves, and on iOS allTabViewchildren stay alive so multiple@Querys reacted simultaneously and faulted theContact.interactionsinverse during the mid-pull window.This PR ships two changes that together break the loop:
DashboardViewno longer uses@Query. Contacts/reminders live in@State, populated by aFetchDescriptorin a.task(with a 500 ms initial delay so any in-flight pull-apply commits first) and re-fetched on.blackbookSyncDidComplete. The body is gated onhasLoadedOnce(ProgressViewuntil the first fetch lands). Result: the dashboard never observes a mid-sync transition.pullRemoteChangesapplies on a freshModelContext(container)with autosave off and commits once at the end. The main context (and any remaining@Querys in other tabs) only ever sees one settled commit; the partial-state window is invisible to the UI. Replaces PR Harden bulk sync-apply to prevent SwiftData @Query faulting crash #42's per-25-recordapplyInChunks+applyChunkSize.Crash signature confirmed via
~/Downloads/Blackbook-2026-06-02-093228.ips— frames 12-13 in_SwiftData_SwiftUI(@Query), frame 18 inScrollView.init, frame 5Sequence.forEach, into SwiftData fault on tagged address0x8000000000000010.Post-merge user action
After TestFlight build 118 deploys, delete + reinstall Blackbook on the iPhone. The existing store still holds the bad partial state from earlier crashes; a clean install guarantees the first sync arrives via the new single-atomic-commit path and the dashboard renders against a settled store.
Test plan
.blackbookSyncDidCompletebgContext.save()never fires → next sync re-pulls same payload idempotently🤖 Generated with Claude Code