Repository navigation
Performance and sync reliability. No new features — this release makes the app stay responsive while syncing, keeps launch fast as your library grows, and fixes a bug that was silently disabling feeds.
Every figure below is measured, not estimated. Details and a re-runnable harness are in docs/performance-baseline.md; the plan is docs/plans/2026-08-11-003-fix-startup-and-sync-performance-plan.md.
Sync no longer blocks the UI
Article insertion, feed parsing and OPML sync were all running on the main thread. Measured with Thread.isMainThread probes:
| Phase | Before | After |
|---|---|---|
syncAllFeeds |
main | background |
parseAllFeedsInBackground |
main | background |
insertArticlesInChunks |
main | background |
context.save() |
main | background |
The previous fix for this had been silently undone: SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor re-imposes main-actor isolation on every unannotated declaration. Marking things nonisolated did not help either — with SWIFT_APPROACHABLE_CONCURRENCY = YES, a nonisolated async function runs on the caller's executor. @concurrent is what forces the global executor, and debug assertions now guard it.
Roughly 1.35s of main-thread work removed per sync on a real device.
Launch scales with the window, not the library
Every read path fetched the entire Article table and filtered it in view code, faulting each article's feed relationship. All seven queries are now bounded by predicate and fetch limit, and TodayView's six independent full-array passes collapse into one.
A/B on the same 40,000-article store:
| per render | renders per launch | |
|---|---|---|
| Before | 28.6–33.9 ms | 11 |
| After | 11.4 ms | 1 |
At 10,358 articles it was 11.0 ms before; at 40,000 it's 11.4 ms after. Cost now tracks the visible window rather than the library.
One slow feed no longer stalls the rest
chunked(into: 5) made the fetch phase cost the sum of each batch's slowest feed. Replaced with a task group holding a fixed number of requests in flight, plus a 15s request timeout (was URLSession's 60s default) and cancellation support.
On a real 39-feed subscription: 148 seconds of total fetch work completed in 33.6 seconds — 88% of the theoretical floor for 5 concurrent slots.
Bug fixes
OPML feeds silently deactivating. addFeed upgrades http:// → https:// and stores the feed under the canonical URL, but OPML sync compared against the URL as listed. An http-listed feed never matched its stored https record, so every sync marked it removed — and re-adding it was a no-op that never reset isActive. Feeds went dark with no indication in the UI.
Confirmed on a real device: 15 feeds restored, active feed count 39 → 53. Normalisation now lives in one place (FeedURLNormalizer) that both sides use, and affected feeds reactivate automatically.
A total sync failure recorded itself as successful, so needsSync() refused to retry for two hours — exactly when it mattered. Now only recorded when at least one feed responds; a 304 counts.
Temporary redirects were treated as permanent and could overwrite a stored feed URL. The old code inferred it from the final URL differing, with a comment admitting it couldn't tell 301 from 302. A per-task delegate now reads the actual status codes.
Search on the Today screen was unreachable — collapsed until you pulled the list down, but the same gesture drove pull-to-refresh (#111).
Test infrastructure
TodayTests used explicit file references, so two test files existed on disk but were never target members — and one had never passed: it registered MockURLProtocol but never attached it to a session, so all 10 tests silently hit the live network.
Test suite 145 → 277, and there are now end-to-end sync tests for the first time (syncAllFeeds takes an injectable URLSession).
Known limitations
- New SwiftData indexes apply to newly created stores only. Verified against
sqlite_master: a fresh install gets all five, a migrated store gets none. Bounded queries are still far cheaper than the previous full-library scans, but aVersionedSchemamigration is needed for the full win. - Downgrade to 1.12.0 is not supported. This release adds optional fields to
ArticleandFeed; the older model cannot open the newer store, andTodayAppcallsfatalErrorif the container fails to open. FeedManager.addFeedstill runs on the main actor during OPML sync. Only when a subscription gains a feed, not on a normal sync.
Full changelog: v1.12.0...v1.13.0