Skip to content

Replace Combine with @Observable and AsyncStream#6

Open
g-enius wants to merge 49 commits intofeature/navigation-stackfrom
feature/observation
Open

Replace Combine with @Observable and AsyncStream#6
g-enius wants to merge 49 commits intofeature/navigation-stackfrom
feature/observation

Conversation

@g-enius
Copy link
Owner

@g-enius g-enius commented Mar 2, 2026

Summary

Replaces all Combine usage with two independent iOS 17 technologies — @Observable (Observation framework) for ViewModel → View reactivity, and AsyncStream + custom StreamBroadcaster for service event streams — achieving zero import Combine across the entire codebase.

  • 63 files changed — 773 lines added, 637 removed
  • Minimum deployment target: iOS 16 → iOS 17
  • import Combine remaining: 0

Depends on: PR #3 — Migrate UIKit navigation to SwiftUI NavigationStack

Why iOS 17?

AsyncSequence/AsyncStream are iOS 13 APIs. The iOS 17 requirement comes from:

Technology Replaces Why iOS 17
@Observable ObservableObject + @Published Observation framework requires iOS 17
AsyncStream.makeStream(of:) (SE-0388) Closure-based AsyncStream { continuation in } Eager continuation used in StreamBroadcaster

Neither depends on the other; they coincidentally share the same deployment target.

Key Changes

Reactive State

Combine AsyncStream
AnyPublisher<Set<String>, Never> AsyncStream<Set<String>>
CurrentValueSubject / PassthroughSubject StreamBroadcaster (custom, in Core)
.sink { }.store(in: &cancellables) Task { for await value in stream { } }
Set<AnyCancellable> cleanup task.cancel()
.debounce(for:scheduler:) didSet + Task.sleep with cancellation

ViewModel Observation

Combine @observable
ObservableObject + @Published @Observable macro
@ObservedObject @Bindable (two-way) or plain property
@StateObject @State
Per-object invalidation Per-property tracking

StreamBroadcaster

Custom @MainActor-isolated multi-consumer broadcaster replacing Combine subjects. Each consumer gets an independent AsyncStream — cleanup is automatic on Task cancellation.

// Service (producer)
private let broadcaster = StreamBroadcaster<Set<String>>()
var favoritesStream: AsyncStream<Set<String>> { broadcaster.makeStream() }
func update() { broadcaster.yield(newValue) }

// ViewModel (consumer) — guard INSIDE loop to avoid retain cycle
let stream = favoritesService.favoritesStream
observation = Task { [weak self] in
    for await value in stream {
        guard let self else { break }
        self.favoriteIds = value
    }
}

What Stays the Same

Migration Pitfalls

1. guard let self before for await = retain cycle. The strong capture persists across the entire loop suspension.

2. AsyncStream doesn't emit current value on subscribe. Unlike @Published, must read current state at init time, then observe future changes via stream.

3. @Observable conflicts with property wrappers. Service properties need @ObservationIgnored @Service(.network).

4. @StateObject requires ObservableObject. Use @State for ownership with @Observable classes.

Test plan

  • All 74 unit tests pass
  • Build and run on iOS 17+ simulator
  • Zero import Combine statements
  • Home → Detail → back navigation
  • Profile modal present/dismiss
  • Favorites sync across screens
  • Search debounce works
  • Feature toggle changes propagate
  • Pull-to-refresh works
  • Deep links work

🤖 Generated with Claude Code

@g-enius g-enius changed the title Replace Combine with @Observable and AsyncSequence Replace Combine with @Observable and AsyncStream Mar 2, 2026
@g-enius g-enius force-pushed the feature/observation branch 2 times, most recently from a5a9a1e to 0257d83 Compare March 2, 2026 03:39
Copy link
Owner Author

@g-enius g-enius left a comment

Choose a reason for hiding this comment

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

The new PR finally LGTM. Now create tags for each branch! @claude

@claude
Copy link

claude bot commented Mar 2, 2026

Claude finished @g-enius's task in 2m 40s —— View job


Creating Git Tags for Each Branch

Following the existing vX.Y.Z[-branch-name] tag pattern. All 3 branches had new commits since their v4.1.0 tags, so tagged as v4.2.0.

  • Gather context: existing tags use vX.Y.Z[-branch-name] pattern; all 3 branches ahead of their last tags
  • Create v4.2.0 for mainebfc4fe (rename async-sequence refs, fix DefaultFeatureToggleService)
  • Create v4.2.0-navigation-stack for feature/navigation-stack3a7a545 (fix stale docs, Duration API, routing table)
  • Create v4.2.0-observation for feature/observation0257d83 (replace Combine with @observable + AsyncStream — LGTM)
  • Push all tags to remote

All 3 tags are now live on the remote. You can view them at:

@g-enius g-enius force-pushed the feature/navigation-stack branch from 3a7a545 to dcc291b Compare March 2, 2026 05:35
g-enius and others added 19 commits March 2, 2026 16:35
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…OS 17+

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
subscribeToDarkMode() now reads the initial value from the service
before subscribing to the stream. Removed the registration-wait
pattern (observeDarkMode) which missed the initial registration
due to Task starting after the synchronous yield.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ster

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
g-enius and others added 29 commits March 2, 2026 16:35
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Single observeServiceRegistrations() dispatches to subscribeToToasts()
and subscribeToDarkMode() on registration events. Removes explicit
subscribe calls from flow transitions — activateSession triggers
re-registration which the watcher picks up automatically.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Both sessions register toast, so the registration watcher
auto-resubscribes via subscribeToToasts() — same as dark mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The AsyncSequence migration is done — these comments described
what the code already does and referenced the wrong iOS version.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…tDescription

NavigationStack maturity and Symbol effects are not specific to the
AsyncSequence/Observable migration — only @observable and @bindable are.

Co-authored-by: Charles Wang <g-enius@users.noreply.github.com>
- Remove 2 non-AsyncSequence-specific iOS 17 bullets from deploymentTargetDescription
  (NavigationStack maturity and symbol effects do not belong in async-sequence branch)
- Restore DispatchGroup callback example from navigation-stack branch — it was
  rewritten unnecessarily; the callback pattern is branch-agnostic

Co-authored-by: Charles Wang <g-enius@users.noreply.github.com>
…base branch

Option 3 (async/await TaskGroup) is branch-agnostic — the TaskGroup pattern
doesn't change between Combine and AsyncSequence. Restored the order-preserving
version from feature/navigation-stack exactly.

Co-authored-by: Charles Wang <g-enius@users.noreply.github.com>
- TechnologyItem enum: case combine → asyncSequence (raw value 'asyncsequence')
- TechnologyDescriptions: combineDescription → asyncSequenceDescription
- FeaturedItem: static let combine → asyncSequence, update carouselSet1
- TechnologyDescriptionsTests: update test name, enum ref, and assertion
- DetailViewModelTests: .combine → .asyncSequence
- DeepLinkTests: update test URL to use new raw value

Co-authored-by: Charles Wang <g-enius@users.noreply.github.com>
…pattern in combineDescription

Shows how didSet + Task.sleep replaces Combine's .debounce operator.

Co-authored-by: Charles Wang <g-enius@users.noreply.github.com>
…escription

Use the same (Int, [Item]) tuple structure and sorted-flatMap order preservation
as the TaskGroup example — only the concurrency primitive differs.

Co-authored-by: Charles Wang <g-enius@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The 'Before (Combine)' example used an improved version with [weak self]
and a query parameter, which is unrelated to the AsyncSequence migration.
Per PR discipline, this change belongs on the base branch first. Reverted
to match feature/navigation-stack exactly: .sink { self.performSearch() }

Co-authored-by: Charles Wang <g-enius@users.noreply.github.com>
- favoritesChanges → favoritesStream (FavoritesServiceProtocol + impl + mock + PreviewHelper + ViewModels + tests)
- featuredCarouselChanges → featuredCarouselStream (FeatureToggleServiceProtocol + impl + mock + PreviewHelper + HomeViewModel + TechnologyDescriptions + tests)
- appearanceModeChanges → appearanceModeStream (FeatureToggleServiceProtocol + impl + mock + PreviewHelper + AppCoordinator + tests)

Naming mirrors the Combine convention (e.g. toastPublisher → toastStream) and is consistent
with toastStream renamed in a prior commit. The *Stream suffix names the mechanism uniformly
regardless of whether the stream carries "changes" or "events".

Co-authored-by: Charles Wang <g-enius@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
AsyncStream.makeStream(of:) registers the continuation immediately
instead of lazily when iteration starts. Values yielded before the
consumer starts iterating are now buffered correctly.

Service tests use direct iterator (no spawned Task needed).
ViewModel tests reduced from 50ms to 10ms sleep.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
ItemsView never uses $viewModel.xxx — it only passes viewModel to
ItemsMainContent. @bindable is correctly placed on ItemsMainContent,
which does use $viewModel.searchText.

Co-authored-by: Charles Wang <g-enius@users.noreply.github.com>
Co-authored-by: Charles Wang <g-enius@users.noreply.github.com>
@observable and AsyncStream.makeStream(of:) both require iOS 17
independently — AsyncSequence itself is iOS 13.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The branch name was misleading — AsyncSequence/AsyncStream are iOS 13
APIs. The actual iOS 17 drivers are @observable and
AsyncStream.makeStream(of:). Updated all references across 8 files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
didSet fires even when the value doesn't change (e.g. keyboard
dismiss re-sets the same text). Guard with oldValue check —
equivalent to .removeDuplicates() in the Combine pipeline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add guard != oldValue on didSet blocks that yield to StreamBroadcaster
  (prevents redundant stream events, mirrors Combine's removeDuplicates)
- Replace Task.sleep with deterministic awaitObservation helper using
  withObservationTracking + withCheckedContinuation
- Fix DetailViewModelTests double-registration and use viewModel action
- Fix SwiftLint line length in HomeViewModel

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@g-enius g-enius force-pushed the feature/observation branch from 0257d83 to 7545fc6 Compare March 2, 2026 05:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant