Skip to content

Replace Combine with AsyncSequence and @Observable#4

Closed
g-enius wants to merge 45 commits intofeature/navigation-stackfrom
feature/async-sequence
Closed

Replace Combine with AsyncSequence and @Observable#4
g-enius wants to merge 45 commits intofeature/navigation-stackfrom
feature/async-sequence

Conversation

@g-enius
Copy link
Owner

@g-enius g-enius commented Feb 26, 2026

Summary

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

  • 55 files changed (49 modified + 1 new + 5 deleted)
  • 694 lines added, 591 lines removed
  • Minimum deployment target raised from iOS 16 → iOS 17
  • import Combine remaining: 0

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

Why iOS 17?

AsyncSequence and AsyncStream are available since iOS 13. The iOS 17 requirement comes from two independent things:

What Replaces Requires
@Observable (Observation framework) Combine's ObservableObject + @Published iOS 17
AsyncStream.makeStream(of:) (SE-0388) Closure-based AsyncStream { continuation in } iOS 17

@Observable provides per-property tracking (replacing per-object invalidation). AsyncStream.makeStream(of:) provides eager continuation registration (values yielded before iteration starts are buffered instead of lost). Neither depends on the other.

Key Changes

Reactive State

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

ViewModel Observation

  • ObservableObject + @Published@Observable macro
  • @ObservedObject@Bindable (two-way) or plain property (read-only)
  • Per-object invalidation → per-property tracking (more efficient re-renders)

What Stays the Same

Key Patterns

StreamBroadcaster vs Combine Subjects

StreamBroadcaster is a custom @MainActor-isolated multi-consumer broadcaster that replaces Combine's CurrentValueSubject and PassthroughSubject for async/await code. Each consumer gets an independent AsyncStream.

CurrentValueSubject PassthroughSubject StreamBroadcaster
Create CurrentValueSubject(initial) PassthroughSubject() StreamBroadcaster()
Get stream .eraseToAnyPublisher() .eraseToAnyPublisher() .makeStream() (new stream per caller)
Send .send(value) .send(value) .yield(value)
Finish .send(completion: .finished) .send(completion: .finished) .finish()
Initial value Yes — emits on subscribe No No
Late subscriber Receives current value Misses past values Misses past values
Thread safety Internal locks Internal locks @MainActor isolation
Cleanup Manual AnyCancellable Manual AnyCancellable Automatic on Task cancellation

Retain-safe observation — capture stream before Task, guard inside loop:

let stream = favoritesService.favoritesChanges
observation = Task { [weak self] in
    for await newFavorites in stream {
        guard let self else { break }
        self.favoriteIds = newFavorites
    }
}

Migration Pitfalls

Things discovered during the Combine → AsyncSequence migration that aren't obvious:

1. guard let self before for await creates a retain cycle. The guard captures self strongly, and that strong reference persists for the entire duration of the for await suspension — the ViewModel can never deallocate.

// BAD — retains self forever during suspension
Task { [weak self] in
    guard let self else { return }
    for await value in stream { self.property = value }
}

// GOOD — guard inside the loop, break if nil
Task { [weak self] in
    for await value in stream {
        guard let self else { break }
        self.property = value
    }
}

2. AsyncStream doesn't auto-emit the current value. Unlike @Published (which emits immediately on subscribe), AsyncStream only delivers future values. Read the current value directly at init time:

// Must initialize manually — stream won't deliver the current state
favoriteIds = favoritesService.favorites     // read current
let stream = favoritesService.favoritesChanges  // observe future

3. @Observable transforms stored properties into computed ones. This means @Service (a property wrapper) can't be applied directly — it conflicts with @Observable's generated accessors. Fix: mark service properties with @ObservationIgnored:

@ObservationIgnored @Service(.network) private var networkService: NetworkService

4. @StateObject doesn't work with @Observable. @StateObject requires ObservableObject conformance. Use @State instead for ownership, @Bindable for two-way binding (replaces @ObservedObject).

Test plan

  • All 74 unit tests pass
  • Build succeeds on iOS 17+ simulator
  • Verify zero import Combine statements
  • Home → Detail → back navigation
  • Profile modal present/dismiss
  • Favorites sync across screens
  • Search debounce works correctly
  • Feature toggle changes propagate

🤖 Generated with Claude Code

@g-enius g-enius changed the base branch from main to feature/navigation-stack February 26, 2026 01:39
@g-enius g-enius force-pushed the feature/async-sequence branch from ec0b732 to 9792972 Compare February 26, 2026 03:34
@g-enius g-enius force-pushed the feature/navigation-stack branch 2 times, most recently from 9e64c51 to 936d67b Compare February 26, 2026 05:03
@g-enius g-enius force-pushed the feature/async-sequence branch from 9792972 to 0b56e79 Compare February 26, 2026 05:03
@g-enius g-enius force-pushed the feature/navigation-stack branch 2 times, most recently from 051096f to 927d6a6 Compare February 26, 2026 06:14
@g-enius g-enius force-pushed the feature/async-sequence branch from 0b56e79 to 71b43f1 Compare February 26, 2026 06:14
@g-enius g-enius force-pushed the feature/navigation-stack branch from 927d6a6 to d09b0c4 Compare February 26, 2026 06:20
@g-enius g-enius force-pushed the feature/async-sequence branch from 71b43f1 to d98297d Compare February 26, 2026 06:21
@g-enius g-enius force-pushed the feature/navigation-stack branch from d09b0c4 to 054692d Compare February 26, 2026 06:31
@g-enius g-enius force-pushed the feature/async-sequence branch from d98297d to 3375d0e Compare February 26, 2026 06:31
@g-enius g-enius force-pushed the feature/navigation-stack branch from 054692d to 58464e5 Compare February 26, 2026 06:37
@g-enius g-enius force-pushed the feature/async-sequence branch from 3375d0e to c1ca5ea Compare February 26, 2026 06:38
@g-enius g-enius force-pushed the feature/navigation-stack branch from 58464e5 to 61fffde Compare February 26, 2026 06:46
@g-enius g-enius force-pushed the feature/async-sequence branch from c1ca5ea to d1e5625 Compare February 26, 2026 06:46
@g-enius g-enius force-pushed the feature/navigation-stack branch from 61fffde to c578ac0 Compare February 26, 2026 06:56
@g-enius g-enius force-pushed the feature/async-sequence branch from d1e5625 to c7b73a8 Compare February 26, 2026 06:56
@g-enius g-enius force-pushed the feature/navigation-stack branch from c578ac0 to 67e1f0d Compare February 26, 2026 07:05
@g-enius g-enius force-pushed the feature/async-sequence branch 2 times, most recently from 4828d32 to b1ab19d Compare February 26, 2026 11:06
@g-enius g-enius force-pushed the feature/navigation-stack branch from 39537dc to 948fc2a Compare February 27, 2026 05:27
@g-enius g-enius force-pushed the feature/async-sequence branch from a97a082 to 6509324 Compare February 27, 2026 05:27
@g-enius g-enius force-pushed the feature/navigation-stack branch from 948fc2a to 648ac52 Compare February 27, 2026 05:44
@g-enius g-enius force-pushed the feature/async-sequence branch 2 times, most recently from aa84a62 to 970f334 Compare February 27, 2026 05:52
@g-enius g-enius force-pushed the feature/navigation-stack branch from ae6bd8e to 634d9f5 Compare February 27, 2026 06:02
@g-enius g-enius force-pushed the feature/async-sequence branch from 970f334 to b641e0a Compare February 27, 2026 06:02
g-enius and others added 20 commits March 1, 2026 22:22
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>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@g-enius g-enius force-pushed the feature/async-sequence branch from 788fb5e to b0cd7a4 Compare March 1, 2026 11:23
g-enius and others added 2 commits March 1, 2026 22:45
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>
Comment on lines 25 to 26
continuation.onTermination = { [weak self] _ in
Task { @MainActor [weak self] in
Copy link
Owner Author

Choose a reason for hiding this comment

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

do you really need two [weak self] here? @claude

Copy link

@claude claude bot Mar 1, 2026

Choose a reason for hiding this comment

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

Claude finished @g-enius's task in 1m 20s —— View job


  • Read StreamBroadcaster.swift to analyze the [weak self] usage
  • Determine if both are necessary — no, removed the redundant one
  • Commit and push (7fcc3d8)

No — the second [weak self] is redundant

// Before
continuation.onTermination = { [weak self] _ in
    Task { @MainActor [weak self] in          // ← redundant
        self?.continuations.removeValue(forKey: id)
    }
}

// After
continuation.onTermination = { [weak self] _ in
    Task { @MainActor in
        self?.continuations.removeValue(forKey: id)
    }
}

Why only one is needed:

The outer { [weak self] _ in ... } makes self a local weak optional (StreamBroadcaster?) inside that closure. The inner Task closure captures this outer self — which is already a weak optional. Capturing a weak optional in a nested closure doesn't create a new strong reference; you're just referencing the same weak variable from the outer scope.

Adding [weak self] a second time on the inner Task would re-weaken an already-weak reference — which compiles fine but is logically a no-op.

The first [weak self] is the important one — without it, onTermination would strongly capture self, creating a retain cycle:

StreamBroadcaster → continuations[id] → continuation → onTermination → StreamBroadcaster

The @MainActor annotation on the Task is still necessary (and unrelated to [weak self]) — onTermination can fire on any thread, and continuations is @MainActor-isolated, so we need the hop.
· Branch diff

Co-authored-by: Charles Wang <g-enius@users.noreply.github.com>
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.

3 comments need to address

Comment on lines 146 to 159
@@ -140,7 +155,7 @@ public class HomeViewModel: ObservableObject {
isLoading = false
featuredItems = []

toastService.showToast(message: AppError.networkError.errorDescription ?? L10n.Error.unknownError, type: .error)
toast.showToast(message: AppError.networkError.errorDescription ?? L10n.Error.unknownError, type: .error)
}
Copy link
Owner Author

Choose a reason for hiding this comment

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

this logic should live in NetworkService layer, also check ItemsViewModel. Fix on main and sync

Copy link
Owner Author

Choose a reason for hiding this comment

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

Moved to NetworkService on main (both HomeViewModel and ItemsViewModel). Synced.

Comment on lines -28 to +29
@Published public var featuredCarouselEnabled: Bool = false {
public var featuredCarouselEnabled: Bool = true {
Copy link
Owner Author

Choose a reason for hiding this comment

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

why change the default value here? it should be default true on main

Copy link
Owner Author

Choose a reason for hiding this comment

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

Fixed on main and synced.

Fun-iOS/
├── FunApp/ # iOS app target (Xcode project)
├── Coordinator/ # Navigation coordinators
├── UI/ # SwiftUI views & UIKit controllers
Copy link
Owner Author

Choose a reason for hiding this comment

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

this should be changed in the Navigation-Stack branch, not here

Copy link
Owner Author

Choose a reason for hiding this comment

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

Fixed on navigation-stack and synced.

@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>
@g-enius g-enius closed this Mar 1, 2026
@g-enius g-enius deleted the feature/async-sequence branch March 1, 2026 23:51
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