Replace Combine with AsyncSequence and @Observable#4
Replace Combine with AsyncSequence and @Observable#4g-enius wants to merge 45 commits intofeature/navigation-stackfrom
Conversation
ec0b732 to
9792972
Compare
9e64c51 to
936d67b
Compare
9792972 to
0b56e79
Compare
051096f to
927d6a6
Compare
0b56e79 to
71b43f1
Compare
927d6a6 to
d09b0c4
Compare
71b43f1 to
d98297d
Compare
d09b0c4 to
054692d
Compare
d98297d to
3375d0e
Compare
054692d to
58464e5
Compare
3375d0e to
c1ca5ea
Compare
58464e5 to
61fffde
Compare
c1ca5ea to
d1e5625
Compare
61fffde to
c578ac0
Compare
d1e5625 to
c7b73a8
Compare
c578ac0 to
67e1f0d
Compare
4828d32 to
b1ab19d
Compare
39537dc to
948fc2a
Compare
a97a082 to
6509324
Compare
948fc2a to
648ac52
Compare
aa84a62 to
970f334
Compare
ae6bd8e to
634d9f5
Compare
970f334 to
b641e0a
Compare
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>
788fb5e to
b0cd7a4
Compare
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>
| continuation.onTermination = { [weak self] _ in | ||
| Task { @MainActor [weak self] in |
There was a problem hiding this comment.
do you really need two [weak self] here? @claude
There was a problem hiding this comment.
Claude finished @g-enius's task in 1m 20s —— View job
- Read
StreamBroadcaster.swiftto 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>
g-enius
left a comment
There was a problem hiding this comment.
3 comments need to address
| @@ -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) | |||
| } | |||
There was a problem hiding this comment.
this logic should live in NetworkService layer, also check ItemsViewModel. Fix on main and sync
There was a problem hiding this comment.
Moved to NetworkService on main (both HomeViewModel and ItemsViewModel). Synced.
| @Published public var featuredCarouselEnabled: Bool = false { | ||
| public var featuredCarouselEnabled: Bool = true { |
There was a problem hiding this comment.
why change the default value here? it should be default true on main
| Fun-iOS/ | ||
| ├── FunApp/ # iOS app target (Xcode project) | ||
| ├── Coordinator/ # Navigation coordinators | ||
| ├── UI/ # SwiftUI views & UIKit controllers |
There was a problem hiding this comment.
this should be changed in the Navigation-Stack branch, not here
There was a problem hiding this comment.
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>
Summary
Replaces all Combine usage with two independent technologies —
@Observable(Observation framework) for ViewModel → View reactivity, andAsyncStream+ customStreamBroadcasterfor service event streams — achieving zeroimport Combineacross the entire codebase. Builds on the SwiftUI NavigationStack migration.import Combineremaining: 0Depends on: PR #3 — Migrate UIKit navigation to SwiftUI NavigationStack
Why iOS 17?
AsyncSequenceandAsyncStreamare available since iOS 13. The iOS 17 requirement comes from two independent things:@Observable(Observation framework)ObservableObject+@PublishedAsyncStream.makeStream(of:)(SE-0388)AsyncStream { continuation in }@Observableprovides 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→ customStreamBroadcaster(in Core).sink { }.store(in: &cancellables)→Task { for await value in stream { } }Set<AnyCancellable>→ Task cancellation (task.cancel()).debounce(for:scheduler:)→didSet+Task.sleepwith cancellationViewModel Observation
ObservableObject+@Published→@Observablemacro@ObservedObject→@Bindable(two-way) or plain property (read-only)What Stays the Same
Key Patterns
StreamBroadcaster vs Combine Subjects
StreamBroadcasteris a custom@MainActor-isolated multi-consumer broadcaster that replaces Combine'sCurrentValueSubjectandPassthroughSubjectfor async/await code. Each consumer gets an independentAsyncStream.CurrentValueSubjectPassthroughSubjectStreamBroadcasterCurrentValueSubject(initial)PassthroughSubject()StreamBroadcaster().eraseToAnyPublisher().eraseToAnyPublisher().makeStream()(new stream per caller).send(value).send(value).yield(value).send(completion: .finished).send(completion: .finished).finish()@MainActorisolationAnyCancellableAnyCancellableRetain-safe observation — capture stream before Task, guard inside loop:
Migration Pitfalls
Things discovered during the Combine → AsyncSequence migration that aren't obvious:
1.
guard let selfbeforefor awaitcreates a retain cycle. Theguardcapturesselfstrongly, and that strong reference persists for the entire duration of thefor awaitsuspension — the ViewModel can never deallocate.2. AsyncStream doesn't auto-emit the current value. Unlike
@Published(which emits immediately on subscribe),AsyncStreamonly delivers future values. Read the current value directly at init time:3.
@Observabletransforms 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:4.
@StateObjectdoesn't work with@Observable.@StateObjectrequiresObservableObjectconformance. Use@Stateinstead for ownership,@Bindablefor two-way binding (replaces@ObservedObject).Test plan
import Combinestatements🤖 Generated with Claude Code